fix: download external subtitles as part of scanning

This commit is contained in:
Christian Benincasa
2026-04-16 13:54:52 -04:00
parent 5724999b28
commit 307ebc9454
28 changed files with 369 additions and 71 deletions

View File

@@ -2053,49 +2053,15 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
streamType: isDefined(stream.index)
? 'subtitles'
: 'external_subtitles',
// type: isDefined(stream.index) ? 'embedded' : 'external',
codec: stream.codec.toLocaleLowerCase(),
default: stream.default ?? false,
index: stream.index ?? 0,
title: stream.displayTitle,
// description: stream.extendedDisplayTitle,
sdh,
// path: stream.key ? this.getFullUrl(stream.key) : undefined,
// languageCodeISO6391: stream.languageTag,
languageCodeISO6392: stream.languageCode,
fileName: isNonEmptyString(stream.key)
? this.getFullUrl(stream.key)
: undefined,
fileName: stream.key,
forced: stream.forced ?? false,
} satisfies MediaStream;
// if (details.type === 'external' && isNonEmptyString(stream.key)) {
// const key = stream.key;
// const fullPath =
// await this.externalSubtitleDownloader.downloadSubtitlesIfNecessary(
// {
// externalKey: plexItem.ratingKey,
// externalSourceId: this.options.mediaSource.uuid,
// sourceType: 'plex',
// uuid:
// },
// details,
// () => this.getSubtitles(key),
// );
// if (fullPath) {
// details.path = fullPath;
// return details;
// }
// this.logger.warn(
// 'Skipping external subtitles at index %d because download failed. Please check logs and file an issue for assistance.',
// stream.index ?? -1,
// );
// return;
// }
return details;
},
),

View File

@@ -145,7 +145,7 @@ export class SubtitleStreamPicker {
externalSourceId: lineupItem.program.mediaSourceId,
externalSourceType: lineupItem.program.sourceType,
},
stream,
{ streamIndex: stream.index, codec: stream.codec },
);
if (!filePath) {

View File

@@ -1,18 +1,24 @@
import { MediaSourceDB } from '@/db/mediaSourceDB.js';
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
import { ScanContext } from '@/services/scanner/MediaSourceScanner.js';
import {
GetSubtitlesRequest,
ScanContext,
} from '@/services/scanner/MediaSourceScanner.js';
import { inject, injectable, interfaces } from 'inversify';
import { ProgramConverter } from '../../db/converters/ProgramConverter.ts';
import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts';
import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts';
import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.ts';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { EmbyApiClient } from '../../external/emby/EmbyApiClient.ts';
import { KEYS } from '../../types/inject.ts';
import { EmbyT } from '../../types/internal.ts';
import { EmbyMovie } from '../../types/Media.ts';
import { Result } from '../../types/result.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MeilisearchService } from '../MeilisearchService.ts';
import { EmbyScanUtil } from './EmbyScanUtil.ts';
import { MediaSourceMovieLibraryScanner } from './MediaSourceMovieLibraryScanner.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
@@ -36,6 +42,8 @@ export class EmbyMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner<
mediaSourceProgressService: MediaSourceProgressService,
@inject(MeilisearchService) searchService: MeilisearchService,
@inject(ProgramConverter) programConverter: ProgramConverter,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) {
super(
logger,
@@ -45,6 +53,7 @@ export class EmbyMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner<
searchService,
programConverter,
programMinterFactory(),
externalSubtitleDownloader,
);
}
@@ -90,4 +99,11 @@ export class EmbyMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner<
.getChildItemCount(libraryKey, 'Movie')
.then((_) => _.getOrThrow());
}
protected getSubtitles(
context: ScanContext<EmbyApiClient>,
request: GetSubtitlesRequest,
): Promise<QueryResult<string>> {
return EmbyScanUtil.getSubtitles(context, request);
}
}

View File

@@ -5,8 +5,10 @@ import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts';
import { IProgramDB } from '../../db/interfaces/IProgramDB.ts';
import { MediaSourceDB } from '../../db/mediaSourceDB.ts';
import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { EmbyApiClient } from '../../external/emby/EmbyApiClient.ts';
import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { WrappedError } from '../../types/errors.ts';
import { KEYS } from '../../types/inject.ts';
import { EmbyT } from '../../types/internal.ts';
@@ -18,9 +20,10 @@ import {
import { Result } from '../../types/result.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MeilisearchService } from '../MeilisearchService.ts';
import { EmbyScanUtil } from './EmbyScanUtil.ts';
import { MediaSourceMusicArtistScanner } from './MediaSourceMusicArtistScanner.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import { ScanContext } from './MediaSourceScanner.ts';
import { GetSubtitlesRequest, ScanContext } from './MediaSourceScanner.ts';
export class EmbyMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
EmbyT,
@@ -46,6 +49,8 @@ export class EmbyMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
mediaSourceProgressService: MediaSourceProgressService,
@inject(GetProgramGroupingById)
getProgramGroupingsById: GetProgramGroupingById,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) {
super(
logger,
@@ -56,6 +61,7 @@ export class EmbyMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
searchService,
mediaSourceProgressService,
getProgramGroupingsById,
externalSubtitleDownloader,
);
}
@@ -109,4 +115,11 @@ export class EmbyMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
.getChildItemCount(libraryKey, 'MusicArtist')
.then((_) => _.getOrThrow());
}
protected getSubtitles(
context: ScanContext<EmbyApiClient>,
request: GetSubtitlesRequest,
): Promise<QueryResult<string>> {
return EmbyScanUtil.getSubtitles(context, request);
}
}

View File

@@ -1,12 +1,16 @@
import { MediaSourceDB } from '@/db/mediaSourceDB.js';
import { MediaSourceType } from '@/db/schema/base.js';
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
import { ScanContext } from '@/services/scanner/MediaSourceScanner.js';
import {
GetSubtitlesRequest,
ScanContext,
} from '@/services/scanner/MediaSourceScanner.js';
import { inject, injectable, interfaces } from 'inversify';
import { isNil } from 'lodash-es';
import { ProgramGroupingMinter } from '../../db/converters/ProgramGroupingMinter.ts';
import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts';
import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { EmbyApiClient } from '../../external/emby/EmbyApiClient.ts';
import { WrappedError } from '../../types/errors.ts';
import { KEYS } from '../../types/inject.ts';
@@ -21,8 +25,10 @@ import {
SeasonWithShow,
} from '../../types/Media.ts';
import { Result } from '../../types/result.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MeilisearchService } from '../MeilisearchService.ts';
import { EmbyScanUtil } from './EmbyScanUtil.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import { MediaSourceTvShowLibraryScanner } from './MediaSourceTvShowLibraryScanner.ts';
@@ -51,6 +57,8 @@ export class EmbyMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne
@inject(MeilisearchService) searchService: MeilisearchService,
@inject(GetProgramGroupingById)
getProgramGroupingsById: GetProgramGroupingById,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) {
super(
logger,
@@ -61,6 +69,7 @@ export class EmbyMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne
searchService,
mediaSourceProgressService,
getProgramGroupingsById,
externalSubtitleDownloader,
);
}
@@ -155,4 +164,11 @@ export class EmbyMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne
protected isSeasonT(grouping: ProgramGrouping): grouping is EmbySeason {
return grouping.sourceType === 'emby' && grouping.type === 'season';
}
protected getSubtitles(
context: ScanContext<EmbyApiClient>,
request: GetSubtitlesRequest,
): Promise<QueryResult<string>> {
return EmbyScanUtil.getSubtitles(context, request);
}
}

View File

@@ -0,0 +1,19 @@
import type { QueryResult } from '../../external/BaseApiClient.ts';
import type { EmbyApiClient } from '../../external/emby/EmbyApiClient.ts';
import type { GetSubtitlesRequest, ScanContext } from './MediaSourceScanner.ts';
export class EmbyScanUtil {
private constructor() {}
static async getSubtitles(
context: ScanContext<EmbyApiClient>,
req: GetSubtitlesRequest,
): Promise<QueryResult<string>> {
return context.apiClient.getSubtitles(
req.externalItemId,
req.externalMediaItemId ?? req.externalItemId,
req.streamIndex,
req.extension,
);
}
}

View File

@@ -1,18 +1,24 @@
import { MediaSourceDB } from '@/db/mediaSourceDB.js';
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
import { ScanContext } from '@/services/scanner/MediaSourceScanner.js';
import {
GetSubtitlesRequest,
ScanContext,
} from '@/services/scanner/MediaSourceScanner.js';
import { inject, injectable, interfaces } from 'inversify';
import { ProgramConverter } from '../../db/converters/ProgramConverter.ts';
import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts';
import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts';
import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { KEYS } from '../../types/inject.ts';
import { JellyfinT } from '../../types/internal.ts';
import { JellyfinMovie } from '../../types/Media.ts';
import { Result } from '../../types/result.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MeilisearchService } from '../MeilisearchService.ts';
import { JellyfinScanUtil } from './JellyfinScanUtil.ts';
import { MediaSourceMovieLibraryScanner } from './MediaSourceMovieLibraryScanner.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
@@ -36,6 +42,8 @@ export class JellyfinMediaSourceMovieScanner extends MediaSourceMovieLibraryScan
mediaSourceProgressService: MediaSourceProgressService,
@inject(MeilisearchService) searchService: MeilisearchService,
@inject(ProgramConverter) programConverter: ProgramConverter,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) {
super(
logger,
@@ -45,6 +53,7 @@ export class JellyfinMediaSourceMovieScanner extends MediaSourceMovieLibraryScan
searchService,
programConverter,
programMinterFactory(),
externalSubtitleDownloader,
);
}
@@ -90,4 +99,11 @@ export class JellyfinMediaSourceMovieScanner extends MediaSourceMovieLibraryScan
.getChildItemCount(libraryKey, 'Movie')
.then((_) => _.getOrThrow());
}
protected getSubtitles(
context: ScanContext<JellyfinApiClient>,
request: GetSubtitlesRequest,
): Promise<QueryResult<string>> {
return JellyfinScanUtil.getSubtitles(context, request);
}
}

View File

@@ -7,6 +7,7 @@ import { MediaSourceDB } from '../../db/mediaSourceDB.ts';
import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js';
import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.ts';
import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { WrappedError } from '../../types/errors.ts';
import { KEYS } from '../../types/inject.ts';
import { JellyfinT } from '../../types/internal.ts';
@@ -16,11 +17,13 @@ import {
JellyfinMusicTrack,
} from '../../types/Media.ts';
import { Result } from '../../types/result.ts';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MeilisearchService } from '../MeilisearchService.ts';
import { JellyfinScanUtil } from './JellyfinScanUtil.ts';
import { MediaSourceMusicArtistScanner } from './MediaSourceMusicArtistScanner.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import { ScanContext } from './MediaSourceScanner.ts';
import { GetSubtitlesRequest, ScanContext } from './MediaSourceScanner.ts';
export class JellyfinMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
JellyfinT,
@@ -46,6 +49,8 @@ export class JellyfinMediaSourceMusicScanner extends MediaSourceMusicArtistScann
mediaSourceProgressService: MediaSourceProgressService,
@inject(GetProgramGroupingById)
getProgramGroupingsById: GetProgramGroupingById,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) {
super(
logger,
@@ -56,6 +61,7 @@ export class JellyfinMediaSourceMusicScanner extends MediaSourceMusicArtistScann
searchService,
mediaSourceProgressService,
getProgramGroupingsById,
externalSubtitleDownloader,
);
}
@@ -109,4 +115,11 @@ export class JellyfinMediaSourceMusicScanner extends MediaSourceMusicArtistScann
.getChildItemCount(libraryKey, 'MusicArtist')
.then((_) => _.getOrThrow());
}
protected getSubtitles(
context: ScanContext<JellyfinApiClient>,
request: GetSubtitlesRequest,
): Promise<QueryResult<string>> {
return JellyfinScanUtil.getSubtitles(context, request);
}
}

View File

@@ -4,8 +4,10 @@ import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts';
import { IProgramDB } from '../../db/interfaces/IProgramDB.ts';
import { MediaSourceDB } from '../../db/mediaSourceDB.ts';
import type { MediaSourceWithRelations } from '../../db/schema/derivedTypes.ts';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.ts';
import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { WrappedError } from '../../types/errors.ts';
import { KEYS } from '../../types/inject.ts';
import type { JellyfinT } from '../../types/internal.ts';
@@ -13,9 +15,10 @@ import type { JellyfinOtherVideo } from '../../types/Media.ts';
import { Result } from '../../types/result.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MeilisearchService } from '../MeilisearchService.ts';
import { JellyfinScanUtil } from './JellyfinScanUtil.ts';
import { MediaSourceOtherVideoScanner } from './MediaSourceOtherVideoScanner.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import type { ScanContext } from './MediaSourceScanner.ts';
import type { GetSubtitlesRequest, ScanContext } from './MediaSourceScanner.ts';
@injectable()
export class JellyfinMediaSourceOtherVideoScanner extends MediaSourceOtherVideoScanner<
@@ -37,6 +40,8 @@ export class JellyfinMediaSourceOtherVideoScanner extends MediaSourceOtherVideoS
mediaSourceProgressService: MediaSourceProgressService,
@inject(KEYS.ProgramDaoMinterFactory)
programMinterFactory: interfaces.AutoFactory<ProgramDaoMinter>,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) {
super(
logger,
@@ -45,6 +50,7 @@ export class JellyfinMediaSourceOtherVideoScanner extends MediaSourceOtherVideoS
searchService,
mediaSourceProgressService,
programMinterFactory(),
externalSubtitleDownloader,
);
}
@@ -102,4 +108,11 @@ export class JellyfinMediaSourceOtherVideoScanner extends MediaSourceOtherVideoS
protected getExternalKey(video: JellyfinOtherVideo): string {
return video.externalId;
}
protected getSubtitles(
context: ScanContext<JellyfinApiClient>,
request: GetSubtitlesRequest,
): Promise<QueryResult<string>> {
return JellyfinScanUtil.getSubtitles(context, request);
}
}

View File

@@ -1,13 +1,17 @@
import { MediaSourceDB } from '@/db/mediaSourceDB.js';
import { MediaSourceType } from '@/db/schema/base.js';
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
import { ScanContext } from '@/services/scanner/MediaSourceScanner.js';
import {
GetSubtitlesRequest,
ScanContext,
} from '@/services/scanner/MediaSourceScanner.js';
import { inject, injectable, interfaces } from 'inversify';
import { isNil } from 'lodash-es';
import { ProgramGroupingMinter } from '../../db/converters/ProgramGroupingMinter.ts';
import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts';
import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts';
import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.ts';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { WrappedError } from '../../types/errors.ts';
import { KEYS } from '../../types/inject.ts';
@@ -21,8 +25,10 @@ import {
SeasonWithShow,
} from '../../types/Media.ts';
import { Result } from '../../types/result.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MeilisearchService } from '../MeilisearchService.ts';
import { JellyfinScanUtil } from './JellyfinScanUtil.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import { MediaSourceTvShowLibraryScanner } from './MediaSourceTvShowLibraryScanner.ts';
@@ -51,6 +57,8 @@ export class JellyfinMediaSourceTvShowScanner extends MediaSourceTvShowLibrarySc
@inject(MeilisearchService) searchService: MeilisearchService,
@inject(GetProgramGroupingById)
getProgramGroupingsById: GetProgramGroupingById,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) {
super(
logger,
@@ -61,6 +69,7 @@ export class JellyfinMediaSourceTvShowScanner extends MediaSourceTvShowLibrarySc
searchService,
mediaSourceProgressService,
getProgramGroupingsById,
externalSubtitleDownloader,
);
}
@@ -155,4 +164,11 @@ export class JellyfinMediaSourceTvShowScanner extends MediaSourceTvShowLibrarySc
protected isSeasonT(grouping: ProgramGrouping): grouping is JellyfinSeason {
return grouping.sourceType === 'jellyfin' && grouping.type === 'season';
}
protected getSubtitles(
context: ScanContext<JellyfinApiClient>,
request: GetSubtitlesRequest,
): Promise<QueryResult<string>> {
return JellyfinScanUtil.getSubtitles(context, request);
}
}

View File

@@ -0,0 +1,19 @@
import type { QueryResult } from '../../external/BaseApiClient.ts';
import type { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.ts';
import type { GetSubtitlesRequest, ScanContext } from './MediaSourceScanner.ts';
export class JellyfinScanUtil {
private constructor() {}
static async getSubtitles(
context: ScanContext<JellyfinApiClient>,
req: GetSubtitlesRequest,
): Promise<QueryResult<string>> {
return context.apiClient.getSubtitles(
req.externalItemId,
req.externalMediaItemId ?? req.externalItemId,
req.streamIndex,
req.extension,
);
}
}

View File

@@ -8,6 +8,7 @@ import type { RemoteMediaSourceType } from '../../db/schema/MediaSource.ts';
import { ProgramType } from '../../db/schema/Program.ts';
import { isMovieProgram } from '../../db/schema/schemaTypeGuards.ts';
import type { MediaSourceApiClient } from '../../external/MediaSourceApiClient.ts';
import type { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import type { HasMediaSourceInfo, Movie } from '../../types/Media.ts';
import { Result } from '../../types/result.ts';
import type { Logger } from '../../util/logging/LoggerFactory.ts';
@@ -45,9 +46,9 @@ export abstract class MediaSourceMovieLibraryScanner<
private searchService: MeilisearchService,
protected programConverter: ProgramConverter,
protected programMinter: ProgramDaoMinter,
// protected externalSubtitleDownloader: ExternalSubtitleDownloader,
protected externalSubtitleDownloader: ExternalSubtitleDownloader,
) {
super(logger, mediaSourceDB);
super(logger, mediaSourceDB, externalSubtitleDownloader);
}
protected async scanInternal(
@@ -124,6 +125,13 @@ export abstract class MediaSourceMovieLibraryScanner<
fullMovie,
);
await this.downloadExternalSubtitleStreams(minted, (req) =>
this.getSubtitles(context, {
...req,
externalMediaItemId: fullMovie.mediaItem?.externalKey ?? undefined,
}),
);
const upsertResult = await Result.attemptAsync(() =>
this.programDB.upsertPrograms([minted]),
);

View File

@@ -32,6 +32,7 @@ import { Result } from '../../types/result.ts';
import type { Maybe } from '../../types/util.ts';
import type { Logger } from '../../util/logging/LoggerFactory.ts';
import type { MeilisearchService } from '../MeilisearchService.ts';
import type { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import type { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import type { ScanContext } from './MediaSourceScanner.ts';
import { MediaSourceScanner } from './MediaSourceScanner.ts';
@@ -78,8 +79,9 @@ export abstract class MediaSourceMusicArtistScanner<
protected searchService: MeilisearchService,
private mediaSourceProgressService: MediaSourceProgressService,
private getProgramGroupingByIdCommand: GetProgramGroupingById,
protected externalSubtitleDownloader: ExternalSubtitleDownloader,
) {
super(logger, mediaSourceDB);
super(logger, mediaSourceDB, externalSubtitleDownloader);
}
protected async scanInternal(

View File

@@ -5,6 +5,7 @@ import type { MediaSourceDB } from '../../db/mediaSourceDB.ts';
import type { RemoteMediaSourceType } from '../../db/schema/MediaSource.ts';
import { ProgramType } from '../../db/schema/Program.ts';
import type { MediaSourceApiClient } from '../../external/MediaSourceApiClient.ts';
import type { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import type { HasMediaSourceInfo, OtherVideo } from '../../types/Media.ts';
import { Result } from '../../types/result.ts';
import type { Logger } from '../../util/logging/LoggerFactory.ts';
@@ -33,8 +34,9 @@ export abstract class MediaSourceOtherVideoScanner<
private searchService: MeilisearchService,
private mediaSourceProgressService: MediaSourceProgressService,
protected programMinter: ProgramDaoMinter,
protected externalSubtitleDownloader: ExternalSubtitleDownloader,
) {
super(logger, mediaSourceDB);
super(logger, mediaSourceDB, externalSubtitleDownloader);
}
protected async scanInternal(
@@ -105,6 +107,14 @@ export abstract class MediaSourceOtherVideoScanner<
fullMetadata,
);
await this.downloadExternalSubtitleStreams(minted, (req) =>
this.getSubtitles(context, {
...req,
externalMediaItemId:
fullMetadata.mediaItem?.externalKey ?? undefined,
}),
);
const upsertResult = await Result.attemptAsync(() =>
this.programDB.upsertPrograms([minted]),
);

View File

@@ -1,12 +1,18 @@
import type { MediaSourceLibraryOrm } from '@/db/schema/MediaSourceLibrary.js';
import dayjs from 'dayjs';
import { isEmpty } from 'lodash-es';
import type { MediaSourceDB } from '../../db/mediaSourceDB.ts';
import type { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js';
import type {
MediaSourceWithRelations,
NewProgramWithRelations,
} from '../../db/schema/derivedTypes.js';
import type {
MediaLibraryType,
MediaSourceOrm,
RemoteMediaSourceType,
} from '../../db/schema/MediaSource.ts';
import type { QueryResult } from '../../external/BaseApiClient.ts';
import type { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { devAssert } from '../../util/debug.ts';
import type { Logger } from '../../util/logging/LoggerFactory.ts';
@@ -42,6 +48,14 @@ export type GenericMediaSourceScannerFactory = (
libraryType: MediaLibraryType,
) => GenericMediaSourceScanner;
export type GetSubtitlesRequest = {
key: string;
extension: string;
externalItemId: string;
externalMediaItemId?: string;
streamIndex: number; // Only relevant for Jellyfin
};
export abstract class BaseMediaSourceScanner<ApiClientTypeT, ScanRequestT> {
abstract scan(req: ScanRequestT): Promise<void>;
@@ -62,6 +76,7 @@ export abstract class MediaSourceScanner<
constructor(
protected logger: Logger,
protected mediaSourceDB: MediaSourceDB,
protected externalSubtitleDownloader: ExternalSubtitleDownloader,
) {
super();
}
@@ -130,4 +145,55 @@ export abstract class MediaSourceScanner<
libraryKey: string,
context: ScanContext<ApiClientTypeT>,
): Promise<number>;
protected abstract getSubtitles(
context: ScanContext<ApiClientTypeT>,
request: GetSubtitlesRequest,
): Promise<QueryResult<string>>;
protected async downloadExternalSubtitleStreams(
{ program, subtitles }: NewProgramWithRelations,
getSubtitlesCallback: (
args: GetSubtitlesRequest,
) => Promise<QueryResult<string>>,
) {
const externalSubtitleStreams =
subtitles.filter((stream) => stream.subtitleType === 'sidecar') ?? [];
for (const stream of externalSubtitleStreams) {
if (isEmpty(stream.path)) {
continue;
}
const fullPath =
await this.externalSubtitleDownloader.downloadSubtitlesIfNecessary(
{
externalKey: program.externalKey,
externalSourceId: program.mediaSourceId,
sourceType: program.sourceType,
uuid: program.uuid,
},
{ streamIndex: stream.streamIndex ?? undefined, codec: stream.codec },
(args) =>
getSubtitlesCallback({
...args,
key: stream.path!,
externalItemId: program.externalKey,
streamIndex: stream.streamIndex ?? 0,
}),
);
if (fullPath) {
stream.path = fullPath;
// return details;
}
this.logger.warn(
'Skipping external subtitles at index %d because download failed. Please check logs and file an issue for assistance.',
stream.streamIndex ?? -1,
);
return;
}
}
}

View File

@@ -13,6 +13,7 @@ import type { RemoteMediaSourceType } from '../../db/schema/MediaSource.ts';
import { ProgramType } from '../../db/schema/Program.ts';
import { ProgramGroupingType } from '../../db/schema/ProgramGrouping.ts';
import type { MediaSourceApiClient } from '../../external/MediaSourceApiClient.ts';
import type { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import type {
HasMediaSourceAndLibraryId,
MediaSourceEpisode,
@@ -61,8 +62,9 @@ export abstract class MediaSourceTvShowLibraryScanner<
protected searchService: MeilisearchService,
private mediaSourceProgressService: MediaSourceProgressService,
private getProgramGroupingByIdCommand: GetProgramGroupingById,
protected externalSubtitleDownloader: ExternalSubtitleDownloader,
) {
super(logger, mediaSourceDB);
super(logger, mediaSourceDB, externalSubtitleDownloader);
}
protected async scanInternal(
@@ -499,6 +501,14 @@ export abstract class MediaSourceTvShowLibraryScanner<
episodeWithJoins,
);
await this.downloadExternalSubtitleStreams(dao, (req) =>
this.getSubtitles(scanContext, {
...req,
externalMediaItemId:
episodeWithJoins.mediaItem?.externalKey ?? undefined,
}),
);
dao.program.tvShowUuid = show.uuid;
dao.program.seasonUuid = season.uuid;

View File

@@ -1,13 +1,18 @@
import { MediaSourceDB } from '@/db/mediaSourceDB.js';
import { MediaSourceType } from '@/db/schema/base.js';
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
import { ScanContext } from '@/services/scanner/MediaSourceScanner.js';
import {
GetSubtitlesRequest,
ScanContext,
} from '@/services/scanner/MediaSourceScanner.js';
import { inject, injectable, interfaces } from 'inversify';
import { ProgramConverter } from '../../db/converters/ProgramConverter.ts';
import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts';
import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts';
import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { PlexApiClient } from '../../external/plex/PlexApiClient.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { KEYS } from '../../types/inject.ts';
import { PlexMovie } from '../../types/Media.ts';
import { Result } from '../../types/result.ts';
@@ -15,6 +20,7 @@ import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MeilisearchService } from '../MeilisearchService.ts';
import { MediaSourceMovieLibraryScanner } from './MediaSourceMovieLibraryScanner.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import { PlexScanUtil } from './PlexScanUtil.ts';
@injectable()
export class PlexMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner<
@@ -35,6 +41,8 @@ export class PlexMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner<
mediaSourceProgressService: MediaSourceProgressService,
@inject(MeilisearchService) searchService: MeilisearchService,
@inject(ProgramConverter) programConverter: ProgramConverter,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) {
super(
logger,
@@ -44,6 +52,7 @@ export class PlexMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner<
searchService,
programConverter,
programMinterFactory(),
externalSubtitleDownloader,
);
}
@@ -77,4 +86,11 @@ export class PlexMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner<
): Promise<Result<PlexMovie>> {
return apiClient.getMovie(incomingMovie.externalId);
}
protected getSubtitles(
context: ScanContext<PlexApiClient>,
{ key }: GetSubtitlesRequest,
): Promise<QueryResult<string>> {
return PlexScanUtil.getSubtitles(context, key);
}
}

View File

@@ -1,22 +1,28 @@
import { MediaSourceDB } from '@/db/mediaSourceDB.js';
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
import { ScanContext } from '@/services/scanner/MediaSourceScanner.js';
import {
GetSubtitlesRequest,
ScanContext,
} from '@/services/scanner/MediaSourceScanner.js';
import { inject, injectable, interfaces } from 'inversify';
import { GetProgramGroupingById } from '../../commands/GetProgramGroupingById.ts';
import { ProgramGroupingMinter } from '../../db/converters/ProgramGroupingMinter.ts';
import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts';
import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts';
import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { PlexApiClient } from '../../external/plex/PlexApiClient.ts';
import { WrappedError } from '../../types/errors.ts';
import { KEYS } from '../../types/inject.ts';
import { PlexT } from '../../types/internal.ts';
import { PlexAlbum, PlexArtist, PlexTrack } from '../../types/Media.ts';
import { Result } from '../../types/result.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MeilisearchService } from '../MeilisearchService.ts';
import { MediaSourceMusicArtistScanner } from './MediaSourceMusicArtistScanner.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import { PlexScanUtil } from './PlexScanUtil.ts';
@injectable()
export class PlexMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
@@ -43,6 +49,8 @@ export class PlexMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
mediaSourceProgressService: MediaSourceProgressService,
@inject(GetProgramGroupingById)
getProgramGroupingsById: GetProgramGroupingById,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) {
super(
logger,
@@ -53,6 +61,7 @@ export class PlexMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
searchService,
mediaSourceProgressService,
getProgramGroupingsById,
externalSubtitleDownloader,
);
}
@@ -106,4 +115,11 @@ export class PlexMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
.getLibraryCount(libraryKey)
.then((_) => _.getOrThrow());
}
protected getSubtitles(
context: ScanContext<PlexApiClient>,
{ key }: GetSubtitlesRequest,
): Promise<QueryResult<string>> {
return PlexScanUtil.getSubtitles(context, key);
}
}

View File

@@ -4,8 +4,10 @@ import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts';
import { IProgramDB } from '../../db/interfaces/IProgramDB.ts';
import { MediaSourceDB } from '../../db/mediaSourceDB.ts';
import type { MediaSourceWithRelations } from '../../db/schema/derivedTypes.ts';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.ts';
import type { PlexApiClient } from '../../external/plex/PlexApiClient.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { WrappedError } from '../../types/errors.ts';
import { KEYS } from '../../types/inject.ts';
import type { PlexT } from '../../types/internal.ts';
@@ -15,7 +17,8 @@ import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MeilisearchService } from '../MeilisearchService.ts';
import { MediaSourceOtherVideoScanner } from './MediaSourceOtherVideoScanner.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import type { ScanContext } from './MediaSourceScanner.ts';
import type { GetSubtitlesRequest, ScanContext } from './MediaSourceScanner.ts';
import { PlexScanUtil } from './PlexScanUtil.ts';
@injectable()
export class PlexMediaSourceOtherVideoScanner extends MediaSourceOtherVideoScanner<
@@ -37,6 +40,8 @@ export class PlexMediaSourceOtherVideoScanner extends MediaSourceOtherVideoScann
mediaSourceProgressService: MediaSourceProgressService,
@inject(KEYS.ProgramDaoMinterFactory)
programMinterFactory: interfaces.AutoFactory<ProgramDaoMinter>,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) {
super(
logger,
@@ -45,6 +50,7 @@ export class PlexMediaSourceOtherVideoScanner extends MediaSourceOtherVideoScann
searchService,
mediaSourceProgressService,
programMinterFactory(),
externalSubtitleDownloader,
);
}
@@ -82,4 +88,11 @@ export class PlexMediaSourceOtherVideoScanner extends MediaSourceOtherVideoScann
protected getExternalKey(video: PlexOtherVideo): string {
return video.externalId;
}
protected getSubtitles(
context: ScanContext<PlexApiClient>,
{ key }: GetSubtitlesRequest,
): Promise<QueryResult<string>> {
return PlexScanUtil.getSubtitles(context, key);
}
}

View File

@@ -1,6 +1,9 @@
import { MediaSourceDB } from '@/db/mediaSourceDB.js';
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
import { ScanContext } from '@/services/scanner/MediaSourceScanner.js';
import {
GetSubtitlesRequest,
ScanContext,
} from '@/services/scanner/MediaSourceScanner.js';
import { ProgramGrouping } from '@tunarr/types';
import { inject, injectable, interfaces } from 'inversify';
import { GetProgramGroupingById } from '../../commands/GetProgramGroupingById.ts';
@@ -8,6 +11,7 @@ import { ProgramGroupingMinter } from '../../db/converters/ProgramGroupingMinter
import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts';
import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts';
import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { PlexApiClient } from '../../external/plex/PlexApiClient.ts';
import { WrappedError } from '../../types/errors.ts';
import { KEYS } from '../../types/inject.ts';
@@ -18,10 +22,12 @@ import {
SeasonWithShow,
} from '../../types/Media.ts';
import { Result } from '../../types/result.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MeilisearchService } from '../MeilisearchService.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import { MediaSourceTvShowLibraryScanner } from './MediaSourceTvShowLibraryScanner.ts';
import { PlexScanUtil } from './PlexScanUtil.ts';
@injectable()
export class PlexMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanner<
@@ -48,6 +54,8 @@ export class PlexMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne
mediaSourceProgressService: MediaSourceProgressService,
@inject(GetProgramGroupingById)
getProgramGroupingsById: GetProgramGroupingById,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) {
super(
logger,
@@ -58,6 +66,7 @@ export class PlexMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne
searchService,
mediaSourceProgressService,
getProgramGroupingsById,
externalSubtitleDownloader,
);
}
@@ -136,4 +145,11 @@ export class PlexMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne
protected isSeasonT(grouping: ProgramGrouping): grouping is PlexSeason {
return grouping.sourceType === 'plex' && grouping.type === 'season';
}
protected getSubtitles(
context: ScanContext<PlexApiClient>,
{ key }: GetSubtitlesRequest,
): Promise<QueryResult<string>> {
return PlexScanUtil.getSubtitles(context, key);
}
}

View File

@@ -0,0 +1,14 @@
import type { QueryResult } from '../../external/BaseApiClient.ts';
import type { PlexApiClient } from '../../external/plex/PlexApiClient.ts';
import type { ScanContext } from './MediaSourceScanner.ts';
export class PlexScanUtil {
private constructor() {}
static async getSubtitles(
context: ScanContext<PlexApiClient>,
key: string,
): Promise<QueryResult<string>> {
return context.apiClient.getSubtitles(key);
}
}

View File

@@ -5,18 +5,22 @@ import { MediaSourceId, MediaSourceType } from '../db/schema/base.ts';
import { QueryResult } from '../external/BaseApiClient.ts';
import { FileSystemService } from '../services/FileSystemService.ts';
import { KEYS } from '../types/inject.ts';
import { Maybe } from '../types/util.ts';
import { fileExists } from '../util/fsUtil.ts';
import { Logger } from '../util/logging/LoggerFactory.ts';
import {
getSubtitleCacheFilePath,
subtitleCodecToExt,
} from '../util/subtitles.ts';
import { SubtitleStreamDetails } from './types.ts';
type GetSubtitleCallbackArgs = {
export type GetSubtitleCallbackArgs = {
extension: string;
};
type GetSubtitlesCallback = (
cbArgs: GetSubtitleCallbackArgs,
) => Promise<QueryResult<string>>;
type ExternalItem = {
externalKey: string;
externalSourceId: MediaSourceId;
@@ -41,10 +45,8 @@ export class ExternalSubtitleDownloader {
*/
async downloadSubtitlesIfNecessary(
item: ExternalItem,
details: SubtitleStreamDetails,
getSubtitlesCb: (
args: GetSubtitleCallbackArgs,
) => Promise<QueryResult<string>>,
details: { streamIndex: Maybe<number>; codec: string },
getSubtitlesCb: GetSubtitlesCallback,
) {
const outPath = getSubtitleCacheFilePath(
{
@@ -53,7 +55,10 @@ export class ExternalSubtitleDownloader {
externalSourceType: item.sourceType,
id: item.uuid,
},
details,
{
codec: details.codec,
streamIndex: details.streamIndex,
},
);
const ext = subtitleCodecToExt(details.codec);

View File

@@ -350,7 +350,10 @@ export class EmbyStreamDetails extends ExternalStreamDetailsFetcher<EmbyT> {
sourceType: 'emby',
uuid: item.uuid,
},
details,
{
codec: details.codec,
streamIndex: details.index,
},
({ extension: ext }) =>
this.emby.getSubtitles(
item.externalKey,

View File

@@ -359,7 +359,10 @@ export class JellyfinStreamDetails extends ExternalStreamDetailsFetcher<Jellyfin
sourceType: 'jellyfin',
uuid: item.uuid,
},
details,
{
codec: details.codec,
streamIndex: details.index,
},
({ extension: ext }) =>
this.jellyfin.getSubtitles(
item.externalKey,

View File

@@ -468,7 +468,10 @@ export class PlexStreamDetails extends ExternalStreamDetailsFetcher<PlexT> {
sourceType: 'plex',
uuid: item.uuid,
},
details,
{
codec: details.codec,
streamIndex: details.index,
},
() => plexApiClient.getSubtitles(key),
);

View File

@@ -237,7 +237,7 @@ export class SubtitleExtractorTask extends Task2<
externalSourceType: program.externalSourceType,
id: program.id,
},
subtitle,
{ streamIndex: subtitle.index, codec: subtitle.codec },
);
if (!filePath) {
return;

View File

@@ -2,8 +2,7 @@ import type { MediaSourceId, MediaSourceType } from '@/db/schema/base.js';
import crypto from 'node:crypto';
import path from 'path';
import { match, P } from 'ts-pattern';
import type { SubtitleStreamDetails } from '../stream/types.ts';
import type { Nullable } from '../types/util.ts';
import type { Maybe, Nullable } from '../types/util.ts';
type MinimalProgram = {
id: string;
@@ -22,9 +21,13 @@ export function subtitleCodecToExt(codec: string): Nullable<string> {
export function getSubtitleCacheFilePath(
program: MinimalProgram,
subtitleStream: SubtitleStreamDetails,
subtitleStream: { streamIndex: Maybe<number>; codec: string },
) {
const outputPath = getSubtitleCacheFileName(program, subtitleStream);
const outputPath = getSubtitleCacheFileName(
program,
subtitleStream.streamIndex,
subtitleStream.codec,
);
const ext = subtitleCodecToExt(subtitleStream.codec.toLowerCase());
if (!ext) {
return null;
@@ -39,7 +42,8 @@ export function getSubtitleCacheFilePath(
function getSubtitleCacheFileName(
program: MinimalProgram,
subtitleStream: SubtitleStreamDetails,
streamIndex: Maybe<number>,
codec: string,
) {
// TODO: We should not always include the external key in here. but it will bust the "cache"
// if the underlying program changes at the target
@@ -49,7 +53,7 @@ function getSubtitleCacheFileName(
.update(program.externalSourceType)
.update(program.externalSourceId)
.update(program.externalKey)
.update(subtitleStream.index?.toString() ?? '')
.update(subtitleStream.codec)
.update(streamIndex?.toString() ?? '')
.update(codec)
.digest('hex');
}

View File

@@ -422,6 +422,7 @@ export const MediaStream = z.object({
// Subtitles
sdh: z.boolean().nullish(),
externalKey: z.string().nullish(),
// Audio or Subtitles
languageCodeISO6392: z.string().nullish(),
@@ -470,6 +471,7 @@ export const MediaItem = z.object({
locations: z.array(MediaLocation),
chapters: z.array(MediaChapter).nullish(),
scanKind: z.enum(['unknown', 'progressive', 'interlaced']).nullish(),
externalKey: z.string().nullish(),
});
const WithMediaItemMetadata = z.object({