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) streamType: isDefined(stream.index)
? 'subtitles' ? 'subtitles'
: 'external_subtitles', : 'external_subtitles',
// type: isDefined(stream.index) ? 'embedded' : 'external',
codec: stream.codec.toLocaleLowerCase(), codec: stream.codec.toLocaleLowerCase(),
default: stream.default ?? false, default: stream.default ?? false,
index: stream.index ?? 0, index: stream.index ?? 0,
title: stream.displayTitle, title: stream.displayTitle,
// description: stream.extendedDisplayTitle,
sdh, sdh,
// path: stream.key ? this.getFullUrl(stream.key) : undefined,
// languageCodeISO6391: stream.languageTag,
languageCodeISO6392: stream.languageCode, languageCodeISO6392: stream.languageCode,
fileName: isNonEmptyString(stream.key) fileName: stream.key,
? this.getFullUrl(stream.key)
: undefined,
forced: stream.forced ?? false, forced: stream.forced ?? false,
} satisfies MediaStream; } 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; return details;
}, },
), ),

View File

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

View File

@@ -1,18 +1,24 @@
import { MediaSourceDB } from '@/db/mediaSourceDB.js'; import { MediaSourceDB } from '@/db/mediaSourceDB.js';
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.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 { inject, injectable, interfaces } from 'inversify';
import { ProgramConverter } from '../../db/converters/ProgramConverter.ts'; import { ProgramConverter } from '../../db/converters/ProgramConverter.ts';
import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts'; import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts';
import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts'; import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts';
import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.ts'; import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.ts';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { EmbyApiClient } from '../../external/emby/EmbyApiClient.ts'; import { EmbyApiClient } from '../../external/emby/EmbyApiClient.ts';
import { KEYS } from '../../types/inject.ts'; import { KEYS } from '../../types/inject.ts';
import { EmbyT } from '../../types/internal.ts'; import { EmbyT } from '../../types/internal.ts';
import { EmbyMovie } from '../../types/Media.ts'; import { EmbyMovie } from '../../types/Media.ts';
import { Result } from '../../types/result.ts'; import { Result } from '../../types/result.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts'; import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MeilisearchService } from '../MeilisearchService.ts'; import { MeilisearchService } from '../MeilisearchService.ts';
import { EmbyScanUtil } from './EmbyScanUtil.ts';
import { MediaSourceMovieLibraryScanner } from './MediaSourceMovieLibraryScanner.ts'; import { MediaSourceMovieLibraryScanner } from './MediaSourceMovieLibraryScanner.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.ts'; import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
@@ -36,6 +42,8 @@ export class EmbyMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner<
mediaSourceProgressService: MediaSourceProgressService, mediaSourceProgressService: MediaSourceProgressService,
@inject(MeilisearchService) searchService: MeilisearchService, @inject(MeilisearchService) searchService: MeilisearchService,
@inject(ProgramConverter) programConverter: ProgramConverter, @inject(ProgramConverter) programConverter: ProgramConverter,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) { ) {
super( super(
logger, logger,
@@ -45,6 +53,7 @@ export class EmbyMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner<
searchService, searchService,
programConverter, programConverter,
programMinterFactory(), programMinterFactory(),
externalSubtitleDownloader,
); );
} }
@@ -90,4 +99,11 @@ export class EmbyMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner<
.getChildItemCount(libraryKey, 'Movie') .getChildItemCount(libraryKey, 'Movie')
.then((_) => _.getOrThrow()); .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 { IProgramDB } from '../../db/interfaces/IProgramDB.ts';
import { MediaSourceDB } from '../../db/mediaSourceDB.ts'; import { MediaSourceDB } from '../../db/mediaSourceDB.ts';
import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js'; import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { EmbyApiClient } from '../../external/emby/EmbyApiClient.ts'; import { EmbyApiClient } from '../../external/emby/EmbyApiClient.ts';
import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.ts'; import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { WrappedError } from '../../types/errors.ts'; import { WrappedError } from '../../types/errors.ts';
import { KEYS } from '../../types/inject.ts'; import { KEYS } from '../../types/inject.ts';
import { EmbyT } from '../../types/internal.ts'; import { EmbyT } from '../../types/internal.ts';
@@ -18,9 +20,10 @@ import {
import { Result } from '../../types/result.ts'; import { Result } from '../../types/result.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts'; import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MeilisearchService } from '../MeilisearchService.ts'; import { MeilisearchService } from '../MeilisearchService.ts';
import { EmbyScanUtil } from './EmbyScanUtil.ts';
import { MediaSourceMusicArtistScanner } from './MediaSourceMusicArtistScanner.ts'; import { MediaSourceMusicArtistScanner } from './MediaSourceMusicArtistScanner.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.ts'; import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import { ScanContext } from './MediaSourceScanner.ts'; import { GetSubtitlesRequest, ScanContext } from './MediaSourceScanner.ts';
export class EmbyMediaSourceMusicScanner extends MediaSourceMusicArtistScanner< export class EmbyMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
EmbyT, EmbyT,
@@ -46,6 +49,8 @@ export class EmbyMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
mediaSourceProgressService: MediaSourceProgressService, mediaSourceProgressService: MediaSourceProgressService,
@inject(GetProgramGroupingById) @inject(GetProgramGroupingById)
getProgramGroupingsById: GetProgramGroupingById, getProgramGroupingsById: GetProgramGroupingById,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) { ) {
super( super(
logger, logger,
@@ -56,6 +61,7 @@ export class EmbyMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
searchService, searchService,
mediaSourceProgressService, mediaSourceProgressService,
getProgramGroupingsById, getProgramGroupingsById,
externalSubtitleDownloader,
); );
} }
@@ -109,4 +115,11 @@ export class EmbyMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
.getChildItemCount(libraryKey, 'MusicArtist') .getChildItemCount(libraryKey, 'MusicArtist')
.then((_) => _.getOrThrow()); .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 { MediaSourceDB } from '@/db/mediaSourceDB.js';
import { MediaSourceType } from '@/db/schema/base.js'; import { MediaSourceType } from '@/db/schema/base.js';
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.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 { inject, injectable, interfaces } from 'inversify';
import { isNil } from 'lodash-es'; import { isNil } from 'lodash-es';
import { ProgramGroupingMinter } from '../../db/converters/ProgramGroupingMinter.ts'; import { ProgramGroupingMinter } from '../../db/converters/ProgramGroupingMinter.ts';
import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts'; import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts';
import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts'; import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { EmbyApiClient } from '../../external/emby/EmbyApiClient.ts'; import { EmbyApiClient } from '../../external/emby/EmbyApiClient.ts';
import { WrappedError } from '../../types/errors.ts'; import { WrappedError } from '../../types/errors.ts';
import { KEYS } from '../../types/inject.ts'; import { KEYS } from '../../types/inject.ts';
@@ -21,8 +25,10 @@ import {
SeasonWithShow, SeasonWithShow,
} from '../../types/Media.ts'; } from '../../types/Media.ts';
import { Result } from '../../types/result.ts'; import { Result } from '../../types/result.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts'; import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MeilisearchService } from '../MeilisearchService.ts'; import { MeilisearchService } from '../MeilisearchService.ts';
import { EmbyScanUtil } from './EmbyScanUtil.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.ts'; import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import { MediaSourceTvShowLibraryScanner } from './MediaSourceTvShowLibraryScanner.ts'; import { MediaSourceTvShowLibraryScanner } from './MediaSourceTvShowLibraryScanner.ts';
@@ -51,6 +57,8 @@ export class EmbyMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne
@inject(MeilisearchService) searchService: MeilisearchService, @inject(MeilisearchService) searchService: MeilisearchService,
@inject(GetProgramGroupingById) @inject(GetProgramGroupingById)
getProgramGroupingsById: GetProgramGroupingById, getProgramGroupingsById: GetProgramGroupingById,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) { ) {
super( super(
logger, logger,
@@ -61,6 +69,7 @@ export class EmbyMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne
searchService, searchService,
mediaSourceProgressService, mediaSourceProgressService,
getProgramGroupingsById, getProgramGroupingsById,
externalSubtitleDownloader,
); );
} }
@@ -155,4 +164,11 @@ export class EmbyMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne
protected isSeasonT(grouping: ProgramGrouping): grouping is EmbySeason { protected isSeasonT(grouping: ProgramGrouping): grouping is EmbySeason {
return grouping.sourceType === 'emby' && grouping.type === 'season'; 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 { MediaSourceDB } from '@/db/mediaSourceDB.js';
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.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 { inject, injectable, interfaces } from 'inversify';
import { ProgramConverter } from '../../db/converters/ProgramConverter.ts'; import { ProgramConverter } from '../../db/converters/ProgramConverter.ts';
import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts'; import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts';
import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts'; import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts';
import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js'; import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.ts'; import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { KEYS } from '../../types/inject.ts'; import { KEYS } from '../../types/inject.ts';
import { JellyfinT } from '../../types/internal.ts'; import { JellyfinT } from '../../types/internal.ts';
import { JellyfinMovie } from '../../types/Media.ts'; import { JellyfinMovie } from '../../types/Media.ts';
import { Result } from '../../types/result.ts'; import { Result } from '../../types/result.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts'; import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MeilisearchService } from '../MeilisearchService.ts'; import { MeilisearchService } from '../MeilisearchService.ts';
import { JellyfinScanUtil } from './JellyfinScanUtil.ts';
import { MediaSourceMovieLibraryScanner } from './MediaSourceMovieLibraryScanner.ts'; import { MediaSourceMovieLibraryScanner } from './MediaSourceMovieLibraryScanner.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.ts'; import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
@@ -36,6 +42,8 @@ export class JellyfinMediaSourceMovieScanner extends MediaSourceMovieLibraryScan
mediaSourceProgressService: MediaSourceProgressService, mediaSourceProgressService: MediaSourceProgressService,
@inject(MeilisearchService) searchService: MeilisearchService, @inject(MeilisearchService) searchService: MeilisearchService,
@inject(ProgramConverter) programConverter: ProgramConverter, @inject(ProgramConverter) programConverter: ProgramConverter,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) { ) {
super( super(
logger, logger,
@@ -45,6 +53,7 @@ export class JellyfinMediaSourceMovieScanner extends MediaSourceMovieLibraryScan
searchService, searchService,
programConverter, programConverter,
programMinterFactory(), programMinterFactory(),
externalSubtitleDownloader,
); );
} }
@@ -90,4 +99,11 @@ export class JellyfinMediaSourceMovieScanner extends MediaSourceMovieLibraryScan
.getChildItemCount(libraryKey, 'Movie') .getChildItemCount(libraryKey, 'Movie')
.then((_) => _.getOrThrow()); .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 { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js';
import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.ts'; import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.ts';
import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.ts'; import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { WrappedError } from '../../types/errors.ts'; import { WrappedError } from '../../types/errors.ts';
import { KEYS } from '../../types/inject.ts'; import { KEYS } from '../../types/inject.ts';
import { JellyfinT } from '../../types/internal.ts'; import { JellyfinT } from '../../types/internal.ts';
@@ -16,11 +17,13 @@ import {
JellyfinMusicTrack, JellyfinMusicTrack,
} from '../../types/Media.ts'; } from '../../types/Media.ts';
import { Result } from '../../types/result.ts'; import { Result } from '../../types/result.ts';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts'; import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MeilisearchService } from '../MeilisearchService.ts'; import { MeilisearchService } from '../MeilisearchService.ts';
import { JellyfinScanUtil } from './JellyfinScanUtil.ts';
import { MediaSourceMusicArtistScanner } from './MediaSourceMusicArtistScanner.ts'; import { MediaSourceMusicArtistScanner } from './MediaSourceMusicArtistScanner.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.ts'; import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import { ScanContext } from './MediaSourceScanner.ts'; import { GetSubtitlesRequest, ScanContext } from './MediaSourceScanner.ts';
export class JellyfinMediaSourceMusicScanner extends MediaSourceMusicArtistScanner< export class JellyfinMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
JellyfinT, JellyfinT,
@@ -46,6 +49,8 @@ export class JellyfinMediaSourceMusicScanner extends MediaSourceMusicArtistScann
mediaSourceProgressService: MediaSourceProgressService, mediaSourceProgressService: MediaSourceProgressService,
@inject(GetProgramGroupingById) @inject(GetProgramGroupingById)
getProgramGroupingsById: GetProgramGroupingById, getProgramGroupingsById: GetProgramGroupingById,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) { ) {
super( super(
logger, logger,
@@ -56,6 +61,7 @@ export class JellyfinMediaSourceMusicScanner extends MediaSourceMusicArtistScann
searchService, searchService,
mediaSourceProgressService, mediaSourceProgressService,
getProgramGroupingsById, getProgramGroupingsById,
externalSubtitleDownloader,
); );
} }
@@ -109,4 +115,11 @@ export class JellyfinMediaSourceMusicScanner extends MediaSourceMusicArtistScann
.getChildItemCount(libraryKey, 'MusicArtist') .getChildItemCount(libraryKey, 'MusicArtist')
.then((_) => _.getOrThrow()); .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 { IProgramDB } from '../../db/interfaces/IProgramDB.ts';
import { MediaSourceDB } from '../../db/mediaSourceDB.ts'; import { MediaSourceDB } from '../../db/mediaSourceDB.ts';
import type { MediaSourceWithRelations } from '../../db/schema/derivedTypes.ts'; import type { MediaSourceWithRelations } from '../../db/schema/derivedTypes.ts';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.ts'; import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.ts';
import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.ts'; import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { WrappedError } from '../../types/errors.ts'; import { WrappedError } from '../../types/errors.ts';
import { KEYS } from '../../types/inject.ts'; import { KEYS } from '../../types/inject.ts';
import type { JellyfinT } from '../../types/internal.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 { Result } from '../../types/result.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts'; import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MeilisearchService } from '../MeilisearchService.ts'; import { MeilisearchService } from '../MeilisearchService.ts';
import { JellyfinScanUtil } from './JellyfinScanUtil.ts';
import { MediaSourceOtherVideoScanner } from './MediaSourceOtherVideoScanner.ts'; import { MediaSourceOtherVideoScanner } from './MediaSourceOtherVideoScanner.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.ts'; import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import type { ScanContext } from './MediaSourceScanner.ts'; import type { GetSubtitlesRequest, ScanContext } from './MediaSourceScanner.ts';
@injectable() @injectable()
export class JellyfinMediaSourceOtherVideoScanner extends MediaSourceOtherVideoScanner< export class JellyfinMediaSourceOtherVideoScanner extends MediaSourceOtherVideoScanner<
@@ -37,6 +40,8 @@ export class JellyfinMediaSourceOtherVideoScanner extends MediaSourceOtherVideoS
mediaSourceProgressService: MediaSourceProgressService, mediaSourceProgressService: MediaSourceProgressService,
@inject(KEYS.ProgramDaoMinterFactory) @inject(KEYS.ProgramDaoMinterFactory)
programMinterFactory: interfaces.AutoFactory<ProgramDaoMinter>, programMinterFactory: interfaces.AutoFactory<ProgramDaoMinter>,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) { ) {
super( super(
logger, logger,
@@ -45,6 +50,7 @@ export class JellyfinMediaSourceOtherVideoScanner extends MediaSourceOtherVideoS
searchService, searchService,
mediaSourceProgressService, mediaSourceProgressService,
programMinterFactory(), programMinterFactory(),
externalSubtitleDownloader,
); );
} }
@@ -102,4 +108,11 @@ export class JellyfinMediaSourceOtherVideoScanner extends MediaSourceOtherVideoS
protected getExternalKey(video: JellyfinOtherVideo): string { protected getExternalKey(video: JellyfinOtherVideo): string {
return video.externalId; 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 { MediaSourceDB } from '@/db/mediaSourceDB.js';
import { MediaSourceType } from '@/db/schema/base.js'; import { MediaSourceType } from '@/db/schema/base.js';
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.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 { inject, injectable, interfaces } from 'inversify';
import { isNil } from 'lodash-es'; import { isNil } from 'lodash-es';
import { ProgramGroupingMinter } from '../../db/converters/ProgramGroupingMinter.ts'; import { ProgramGroupingMinter } from '../../db/converters/ProgramGroupingMinter.ts';
import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts'; import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts';
import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts'; import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts';
import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.ts'; import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.ts';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { WrappedError } from '../../types/errors.ts'; import { WrappedError } from '../../types/errors.ts';
import { KEYS } from '../../types/inject.ts'; import { KEYS } from '../../types/inject.ts';
@@ -21,8 +25,10 @@ import {
SeasonWithShow, SeasonWithShow,
} from '../../types/Media.ts'; } from '../../types/Media.ts';
import { Result } from '../../types/result.ts'; import { Result } from '../../types/result.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts'; import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MeilisearchService } from '../MeilisearchService.ts'; import { MeilisearchService } from '../MeilisearchService.ts';
import { JellyfinScanUtil } from './JellyfinScanUtil.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.ts'; import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import { MediaSourceTvShowLibraryScanner } from './MediaSourceTvShowLibraryScanner.ts'; import { MediaSourceTvShowLibraryScanner } from './MediaSourceTvShowLibraryScanner.ts';
@@ -51,6 +57,8 @@ export class JellyfinMediaSourceTvShowScanner extends MediaSourceTvShowLibrarySc
@inject(MeilisearchService) searchService: MeilisearchService, @inject(MeilisearchService) searchService: MeilisearchService,
@inject(GetProgramGroupingById) @inject(GetProgramGroupingById)
getProgramGroupingsById: GetProgramGroupingById, getProgramGroupingsById: GetProgramGroupingById,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) { ) {
super( super(
logger, logger,
@@ -61,6 +69,7 @@ export class JellyfinMediaSourceTvShowScanner extends MediaSourceTvShowLibrarySc
searchService, searchService,
mediaSourceProgressService, mediaSourceProgressService,
getProgramGroupingsById, getProgramGroupingsById,
externalSubtitleDownloader,
); );
} }
@@ -155,4 +164,11 @@ export class JellyfinMediaSourceTvShowScanner extends MediaSourceTvShowLibrarySc
protected isSeasonT(grouping: ProgramGrouping): grouping is JellyfinSeason { protected isSeasonT(grouping: ProgramGrouping): grouping is JellyfinSeason {
return grouping.sourceType === 'jellyfin' && grouping.type === 'season'; 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 { ProgramType } from '../../db/schema/Program.ts';
import { isMovieProgram } from '../../db/schema/schemaTypeGuards.ts'; import { isMovieProgram } from '../../db/schema/schemaTypeGuards.ts';
import type { MediaSourceApiClient } from '../../external/MediaSourceApiClient.ts'; import type { MediaSourceApiClient } from '../../external/MediaSourceApiClient.ts';
import type { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import type { HasMediaSourceInfo, Movie } from '../../types/Media.ts'; import type { HasMediaSourceInfo, Movie } from '../../types/Media.ts';
import { Result } from '../../types/result.ts'; import { Result } from '../../types/result.ts';
import type { Logger } from '../../util/logging/LoggerFactory.ts'; import type { Logger } from '../../util/logging/LoggerFactory.ts';
@@ -45,9 +46,9 @@ export abstract class MediaSourceMovieLibraryScanner<
private searchService: MeilisearchService, private searchService: MeilisearchService,
protected programConverter: ProgramConverter, protected programConverter: ProgramConverter,
protected programMinter: ProgramDaoMinter, protected programMinter: ProgramDaoMinter,
// protected externalSubtitleDownloader: ExternalSubtitleDownloader, protected externalSubtitleDownloader: ExternalSubtitleDownloader,
) { ) {
super(logger, mediaSourceDB); super(logger, mediaSourceDB, externalSubtitleDownloader);
} }
protected async scanInternal( protected async scanInternal(
@@ -124,6 +125,13 @@ export abstract class MediaSourceMovieLibraryScanner<
fullMovie, fullMovie,
); );
await this.downloadExternalSubtitleStreams(minted, (req) =>
this.getSubtitles(context, {
...req,
externalMediaItemId: fullMovie.mediaItem?.externalKey ?? undefined,
}),
);
const upsertResult = await Result.attemptAsync(() => const upsertResult = await Result.attemptAsync(() =>
this.programDB.upsertPrograms([minted]), 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 { Maybe } from '../../types/util.ts';
import type { Logger } from '../../util/logging/LoggerFactory.ts'; import type { Logger } from '../../util/logging/LoggerFactory.ts';
import type { MeilisearchService } from '../MeilisearchService.ts'; import type { MeilisearchService } from '../MeilisearchService.ts';
import type { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import type { MediaSourceProgressService } from './MediaSourceProgressService.ts'; import type { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import type { ScanContext } from './MediaSourceScanner.ts'; import type { ScanContext } from './MediaSourceScanner.ts';
import { MediaSourceScanner } from './MediaSourceScanner.ts'; import { MediaSourceScanner } from './MediaSourceScanner.ts';
@@ -78,8 +79,9 @@ export abstract class MediaSourceMusicArtistScanner<
protected searchService: MeilisearchService, protected searchService: MeilisearchService,
private mediaSourceProgressService: MediaSourceProgressService, private mediaSourceProgressService: MediaSourceProgressService,
private getProgramGroupingByIdCommand: GetProgramGroupingById, private getProgramGroupingByIdCommand: GetProgramGroupingById,
protected externalSubtitleDownloader: ExternalSubtitleDownloader,
) { ) {
super(logger, mediaSourceDB); super(logger, mediaSourceDB, externalSubtitleDownloader);
} }
protected async scanInternal( 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 type { RemoteMediaSourceType } from '../../db/schema/MediaSource.ts';
import { ProgramType } from '../../db/schema/Program.ts'; import { ProgramType } from '../../db/schema/Program.ts';
import type { MediaSourceApiClient } from '../../external/MediaSourceApiClient.ts'; import type { MediaSourceApiClient } from '../../external/MediaSourceApiClient.ts';
import type { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import type { HasMediaSourceInfo, OtherVideo } from '../../types/Media.ts'; import type { HasMediaSourceInfo, OtherVideo } from '../../types/Media.ts';
import { Result } from '../../types/result.ts'; import { Result } from '../../types/result.ts';
import type { Logger } from '../../util/logging/LoggerFactory.ts'; import type { Logger } from '../../util/logging/LoggerFactory.ts';
@@ -33,8 +34,9 @@ export abstract class MediaSourceOtherVideoScanner<
private searchService: MeilisearchService, private searchService: MeilisearchService,
private mediaSourceProgressService: MediaSourceProgressService, private mediaSourceProgressService: MediaSourceProgressService,
protected programMinter: ProgramDaoMinter, protected programMinter: ProgramDaoMinter,
protected externalSubtitleDownloader: ExternalSubtitleDownloader,
) { ) {
super(logger, mediaSourceDB); super(logger, mediaSourceDB, externalSubtitleDownloader);
} }
protected async scanInternal( protected async scanInternal(
@@ -105,6 +107,14 @@ export abstract class MediaSourceOtherVideoScanner<
fullMetadata, fullMetadata,
); );
await this.downloadExternalSubtitleStreams(minted, (req) =>
this.getSubtitles(context, {
...req,
externalMediaItemId:
fullMetadata.mediaItem?.externalKey ?? undefined,
}),
);
const upsertResult = await Result.attemptAsync(() => const upsertResult = await Result.attemptAsync(() =>
this.programDB.upsertPrograms([minted]), this.programDB.upsertPrograms([minted]),
); );

View File

@@ -1,12 +1,18 @@
import type { MediaSourceLibraryOrm } from '@/db/schema/MediaSourceLibrary.js'; import type { MediaSourceLibraryOrm } from '@/db/schema/MediaSourceLibrary.js';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { isEmpty } from 'lodash-es';
import type { MediaSourceDB } from '../../db/mediaSourceDB.ts'; 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 { import type {
MediaLibraryType, MediaLibraryType,
MediaSourceOrm, MediaSourceOrm,
RemoteMediaSourceType, RemoteMediaSourceType,
} from '../../db/schema/MediaSource.ts'; } 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 { devAssert } from '../../util/debug.ts';
import type { Logger } from '../../util/logging/LoggerFactory.ts'; import type { Logger } from '../../util/logging/LoggerFactory.ts';
@@ -42,6 +48,14 @@ export type GenericMediaSourceScannerFactory = (
libraryType: MediaLibraryType, libraryType: MediaLibraryType,
) => GenericMediaSourceScanner; ) => GenericMediaSourceScanner;
export type GetSubtitlesRequest = {
key: string;
extension: string;
externalItemId: string;
externalMediaItemId?: string;
streamIndex: number; // Only relevant for Jellyfin
};
export abstract class BaseMediaSourceScanner<ApiClientTypeT, ScanRequestT> { export abstract class BaseMediaSourceScanner<ApiClientTypeT, ScanRequestT> {
abstract scan(req: ScanRequestT): Promise<void>; abstract scan(req: ScanRequestT): Promise<void>;
@@ -62,6 +76,7 @@ export abstract class MediaSourceScanner<
constructor( constructor(
protected logger: Logger, protected logger: Logger,
protected mediaSourceDB: MediaSourceDB, protected mediaSourceDB: MediaSourceDB,
protected externalSubtitleDownloader: ExternalSubtitleDownloader,
) { ) {
super(); super();
} }
@@ -130,4 +145,55 @@ export abstract class MediaSourceScanner<
libraryKey: string, libraryKey: string,
context: ScanContext<ApiClientTypeT>, context: ScanContext<ApiClientTypeT>,
): Promise<number>; ): 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 { ProgramType } from '../../db/schema/Program.ts';
import { ProgramGroupingType } from '../../db/schema/ProgramGrouping.ts'; import { ProgramGroupingType } from '../../db/schema/ProgramGrouping.ts';
import type { MediaSourceApiClient } from '../../external/MediaSourceApiClient.ts'; import type { MediaSourceApiClient } from '../../external/MediaSourceApiClient.ts';
import type { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import type { import type {
HasMediaSourceAndLibraryId, HasMediaSourceAndLibraryId,
MediaSourceEpisode, MediaSourceEpisode,
@@ -61,8 +62,9 @@ export abstract class MediaSourceTvShowLibraryScanner<
protected searchService: MeilisearchService, protected searchService: MeilisearchService,
private mediaSourceProgressService: MediaSourceProgressService, private mediaSourceProgressService: MediaSourceProgressService,
private getProgramGroupingByIdCommand: GetProgramGroupingById, private getProgramGroupingByIdCommand: GetProgramGroupingById,
protected externalSubtitleDownloader: ExternalSubtitleDownloader,
) { ) {
super(logger, mediaSourceDB); super(logger, mediaSourceDB, externalSubtitleDownloader);
} }
protected async scanInternal( protected async scanInternal(
@@ -499,6 +501,14 @@ export abstract class MediaSourceTvShowLibraryScanner<
episodeWithJoins, episodeWithJoins,
); );
await this.downloadExternalSubtitleStreams(dao, (req) =>
this.getSubtitles(scanContext, {
...req,
externalMediaItemId:
episodeWithJoins.mediaItem?.externalKey ?? undefined,
}),
);
dao.program.tvShowUuid = show.uuid; dao.program.tvShowUuid = show.uuid;
dao.program.seasonUuid = season.uuid; dao.program.seasonUuid = season.uuid;

View File

@@ -1,13 +1,18 @@
import { MediaSourceDB } from '@/db/mediaSourceDB.js'; import { MediaSourceDB } from '@/db/mediaSourceDB.js';
import { MediaSourceType } from '@/db/schema/base.js'; import { MediaSourceType } from '@/db/schema/base.js';
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.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 { inject, injectable, interfaces } from 'inversify';
import { ProgramConverter } from '../../db/converters/ProgramConverter.ts'; import { ProgramConverter } from '../../db/converters/ProgramConverter.ts';
import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts'; import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts';
import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts'; import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts';
import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js'; import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { PlexApiClient } from '../../external/plex/PlexApiClient.ts'; import { PlexApiClient } from '../../external/plex/PlexApiClient.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { KEYS } from '../../types/inject.ts'; import { KEYS } from '../../types/inject.ts';
import { PlexMovie } from '../../types/Media.ts'; import { PlexMovie } from '../../types/Media.ts';
import { Result } from '../../types/result.ts'; import { Result } from '../../types/result.ts';
@@ -15,6 +20,7 @@ import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MeilisearchService } from '../MeilisearchService.ts'; import { MeilisearchService } from '../MeilisearchService.ts';
import { MediaSourceMovieLibraryScanner } from './MediaSourceMovieLibraryScanner.ts'; import { MediaSourceMovieLibraryScanner } from './MediaSourceMovieLibraryScanner.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.ts'; import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import { PlexScanUtil } from './PlexScanUtil.ts';
@injectable() @injectable()
export class PlexMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner< export class PlexMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner<
@@ -35,6 +41,8 @@ export class PlexMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner<
mediaSourceProgressService: MediaSourceProgressService, mediaSourceProgressService: MediaSourceProgressService,
@inject(MeilisearchService) searchService: MeilisearchService, @inject(MeilisearchService) searchService: MeilisearchService,
@inject(ProgramConverter) programConverter: ProgramConverter, @inject(ProgramConverter) programConverter: ProgramConverter,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) { ) {
super( super(
logger, logger,
@@ -44,6 +52,7 @@ export class PlexMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner<
searchService, searchService,
programConverter, programConverter,
programMinterFactory(), programMinterFactory(),
externalSubtitleDownloader,
); );
} }
@@ -77,4 +86,11 @@ export class PlexMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner<
): Promise<Result<PlexMovie>> { ): Promise<Result<PlexMovie>> {
return apiClient.getMovie(incomingMovie.externalId); 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 { MediaSourceDB } from '@/db/mediaSourceDB.js';
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.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 { inject, injectable, interfaces } from 'inversify';
import { GetProgramGroupingById } from '../../commands/GetProgramGroupingById.ts'; import { GetProgramGroupingById } from '../../commands/GetProgramGroupingById.ts';
import { ProgramGroupingMinter } from '../../db/converters/ProgramGroupingMinter.ts'; import { ProgramGroupingMinter } from '../../db/converters/ProgramGroupingMinter.ts';
import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts'; import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts';
import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts'; import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts';
import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js'; import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { PlexApiClient } from '../../external/plex/PlexApiClient.ts'; import { PlexApiClient } from '../../external/plex/PlexApiClient.ts';
import { WrappedError } from '../../types/errors.ts'; import { WrappedError } from '../../types/errors.ts';
import { KEYS } from '../../types/inject.ts'; import { KEYS } from '../../types/inject.ts';
import { PlexT } from '../../types/internal.ts'; import { PlexT } from '../../types/internal.ts';
import { PlexAlbum, PlexArtist, PlexTrack } from '../../types/Media.ts'; import { PlexAlbum, PlexArtist, PlexTrack } from '../../types/Media.ts';
import { Result } from '../../types/result.ts'; import { Result } from '../../types/result.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts'; import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MeilisearchService } from '../MeilisearchService.ts'; import { MeilisearchService } from '../MeilisearchService.ts';
import { MediaSourceMusicArtistScanner } from './MediaSourceMusicArtistScanner.ts'; import { MediaSourceMusicArtistScanner } from './MediaSourceMusicArtistScanner.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.ts'; import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import { PlexScanUtil } from './PlexScanUtil.ts';
@injectable() @injectable()
export class PlexMediaSourceMusicScanner extends MediaSourceMusicArtistScanner< export class PlexMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
@@ -43,6 +49,8 @@ export class PlexMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
mediaSourceProgressService: MediaSourceProgressService, mediaSourceProgressService: MediaSourceProgressService,
@inject(GetProgramGroupingById) @inject(GetProgramGroupingById)
getProgramGroupingsById: GetProgramGroupingById, getProgramGroupingsById: GetProgramGroupingById,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) { ) {
super( super(
logger, logger,
@@ -53,6 +61,7 @@ export class PlexMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
searchService, searchService,
mediaSourceProgressService, mediaSourceProgressService,
getProgramGroupingsById, getProgramGroupingsById,
externalSubtitleDownloader,
); );
} }
@@ -106,4 +115,11 @@ export class PlexMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
.getLibraryCount(libraryKey) .getLibraryCount(libraryKey)
.then((_) => _.getOrThrow()); .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 { IProgramDB } from '../../db/interfaces/IProgramDB.ts';
import { MediaSourceDB } from '../../db/mediaSourceDB.ts'; import { MediaSourceDB } from '../../db/mediaSourceDB.ts';
import type { MediaSourceWithRelations } from '../../db/schema/derivedTypes.ts'; import type { MediaSourceWithRelations } from '../../db/schema/derivedTypes.ts';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.ts'; import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.ts';
import type { PlexApiClient } from '../../external/plex/PlexApiClient.ts'; import type { PlexApiClient } from '../../external/plex/PlexApiClient.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { WrappedError } from '../../types/errors.ts'; import { WrappedError } from '../../types/errors.ts';
import { KEYS } from '../../types/inject.ts'; import { KEYS } from '../../types/inject.ts';
import type { PlexT } from '../../types/internal.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 { MeilisearchService } from '../MeilisearchService.ts';
import { MediaSourceOtherVideoScanner } from './MediaSourceOtherVideoScanner.ts'; import { MediaSourceOtherVideoScanner } from './MediaSourceOtherVideoScanner.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.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() @injectable()
export class PlexMediaSourceOtherVideoScanner extends MediaSourceOtherVideoScanner< export class PlexMediaSourceOtherVideoScanner extends MediaSourceOtherVideoScanner<
@@ -37,6 +40,8 @@ export class PlexMediaSourceOtherVideoScanner extends MediaSourceOtherVideoScann
mediaSourceProgressService: MediaSourceProgressService, mediaSourceProgressService: MediaSourceProgressService,
@inject(KEYS.ProgramDaoMinterFactory) @inject(KEYS.ProgramDaoMinterFactory)
programMinterFactory: interfaces.AutoFactory<ProgramDaoMinter>, programMinterFactory: interfaces.AutoFactory<ProgramDaoMinter>,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) { ) {
super( super(
logger, logger,
@@ -45,6 +50,7 @@ export class PlexMediaSourceOtherVideoScanner extends MediaSourceOtherVideoScann
searchService, searchService,
mediaSourceProgressService, mediaSourceProgressService,
programMinterFactory(), programMinterFactory(),
externalSubtitleDownloader,
); );
} }
@@ -82,4 +88,11 @@ export class PlexMediaSourceOtherVideoScanner extends MediaSourceOtherVideoScann
protected getExternalKey(video: PlexOtherVideo): string { protected getExternalKey(video: PlexOtherVideo): string {
return video.externalId; 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 { MediaSourceDB } from '@/db/mediaSourceDB.js';
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.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 { ProgramGrouping } from '@tunarr/types';
import { inject, injectable, interfaces } from 'inversify'; import { inject, injectable, interfaces } from 'inversify';
import { GetProgramGroupingById } from '../../commands/GetProgramGroupingById.ts'; import { GetProgramGroupingById } from '../../commands/GetProgramGroupingById.ts';
@@ -8,6 +11,7 @@ import { ProgramGroupingMinter } from '../../db/converters/ProgramGroupingMinter
import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts'; import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts';
import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts'; import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts';
import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js'; import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js';
import { QueryResult } from '../../external/BaseApiClient.ts';
import { PlexApiClient } from '../../external/plex/PlexApiClient.ts'; import { PlexApiClient } from '../../external/plex/PlexApiClient.ts';
import { WrappedError } from '../../types/errors.ts'; import { WrappedError } from '../../types/errors.ts';
import { KEYS } from '../../types/inject.ts'; import { KEYS } from '../../types/inject.ts';
@@ -18,10 +22,12 @@ import {
SeasonWithShow, SeasonWithShow,
} from '../../types/Media.ts'; } from '../../types/Media.ts';
import { Result } from '../../types/result.ts'; import { Result } from '../../types/result.ts';
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
import { Logger } from '../../util/logging/LoggerFactory.ts'; import { Logger } from '../../util/logging/LoggerFactory.ts';
import { MeilisearchService } from '../MeilisearchService.ts'; import { MeilisearchService } from '../MeilisearchService.ts';
import { MediaSourceProgressService } from './MediaSourceProgressService.ts'; import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import { MediaSourceTvShowLibraryScanner } from './MediaSourceTvShowLibraryScanner.ts'; import { MediaSourceTvShowLibraryScanner } from './MediaSourceTvShowLibraryScanner.ts';
import { PlexScanUtil } from './PlexScanUtil.ts';
@injectable() @injectable()
export class PlexMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanner< export class PlexMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanner<
@@ -48,6 +54,8 @@ export class PlexMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne
mediaSourceProgressService: MediaSourceProgressService, mediaSourceProgressService: MediaSourceProgressService,
@inject(GetProgramGroupingById) @inject(GetProgramGroupingById)
getProgramGroupingsById: GetProgramGroupingById, getProgramGroupingsById: GetProgramGroupingById,
@inject(ExternalSubtitleDownloader)
externalSubtitleDownloader: ExternalSubtitleDownloader,
) { ) {
super( super(
logger, logger,
@@ -58,6 +66,7 @@ export class PlexMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne
searchService, searchService,
mediaSourceProgressService, mediaSourceProgressService,
getProgramGroupingsById, getProgramGroupingsById,
externalSubtitleDownloader,
); );
} }
@@ -136,4 +145,11 @@ export class PlexMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne
protected isSeasonT(grouping: ProgramGrouping): grouping is PlexSeason { protected isSeasonT(grouping: ProgramGrouping): grouping is PlexSeason {
return grouping.sourceType === 'plex' && grouping.type === 'season'; 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 { QueryResult } from '../external/BaseApiClient.ts';
import { FileSystemService } from '../services/FileSystemService.ts'; import { FileSystemService } from '../services/FileSystemService.ts';
import { KEYS } from '../types/inject.ts'; import { KEYS } from '../types/inject.ts';
import { Maybe } from '../types/util.ts';
import { fileExists } from '../util/fsUtil.ts'; import { fileExists } from '../util/fsUtil.ts';
import { Logger } from '../util/logging/LoggerFactory.ts'; import { Logger } from '../util/logging/LoggerFactory.ts';
import { import {
getSubtitleCacheFilePath, getSubtitleCacheFilePath,
subtitleCodecToExt, subtitleCodecToExt,
} from '../util/subtitles.ts'; } from '../util/subtitles.ts';
import { SubtitleStreamDetails } from './types.ts';
type GetSubtitleCallbackArgs = { export type GetSubtitleCallbackArgs = {
extension: string; extension: string;
}; };
type GetSubtitlesCallback = (
cbArgs: GetSubtitleCallbackArgs,
) => Promise<QueryResult<string>>;
type ExternalItem = { type ExternalItem = {
externalKey: string; externalKey: string;
externalSourceId: MediaSourceId; externalSourceId: MediaSourceId;
@@ -41,10 +45,8 @@ export class ExternalSubtitleDownloader {
*/ */
async downloadSubtitlesIfNecessary( async downloadSubtitlesIfNecessary(
item: ExternalItem, item: ExternalItem,
details: SubtitleStreamDetails, details: { streamIndex: Maybe<number>; codec: string },
getSubtitlesCb: ( getSubtitlesCb: GetSubtitlesCallback,
args: GetSubtitleCallbackArgs,
) => Promise<QueryResult<string>>,
) { ) {
const outPath = getSubtitleCacheFilePath( const outPath = getSubtitleCacheFilePath(
{ {
@@ -53,7 +55,10 @@ export class ExternalSubtitleDownloader {
externalSourceType: item.sourceType, externalSourceType: item.sourceType,
id: item.uuid, id: item.uuid,
}, },
details, {
codec: details.codec,
streamIndex: details.streamIndex,
},
); );
const ext = subtitleCodecToExt(details.codec); const ext = subtitleCodecToExt(details.codec);

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,8 +2,7 @@ import type { MediaSourceId, MediaSourceType } from '@/db/schema/base.js';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import path from 'path'; import path from 'path';
import { match, P } from 'ts-pattern'; import { match, P } from 'ts-pattern';
import type { SubtitleStreamDetails } from '../stream/types.ts'; import type { Maybe, Nullable } from '../types/util.ts';
import type { Nullable } from '../types/util.ts';
type MinimalProgram = { type MinimalProgram = {
id: string; id: string;
@@ -22,9 +21,13 @@ export function subtitleCodecToExt(codec: string): Nullable<string> {
export function getSubtitleCacheFilePath( export function getSubtitleCacheFilePath(
program: MinimalProgram, 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()); const ext = subtitleCodecToExt(subtitleStream.codec.toLowerCase());
if (!ext) { if (!ext) {
return null; return null;
@@ -39,7 +42,8 @@ export function getSubtitleCacheFilePath(
function getSubtitleCacheFileName( function getSubtitleCacheFileName(
program: MinimalProgram, 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" // 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 // if the underlying program changes at the target
@@ -49,7 +53,7 @@ function getSubtitleCacheFileName(
.update(program.externalSourceType) .update(program.externalSourceType)
.update(program.externalSourceId) .update(program.externalSourceId)
.update(program.externalKey) .update(program.externalKey)
.update(subtitleStream.index?.toString() ?? '') .update(streamIndex?.toString() ?? '')
.update(subtitleStream.codec) .update(codec)
.digest('hex'); .digest('hex');
} }

View File

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