From de913642a065e80064a35f36064d0a7e69096616 Mon Sep 17 00:00:00 2001 From: Christian Benincasa Date: Mon, 30 Mar 2026 17:25:26 -0400 Subject: [PATCH] refactor: scanner refactoring to strategy pattern --- server/src/external/MediaSourceApiClient.ts | 16 ++- server/src/external/emby/EmbyApiClient.ts | 44 +++++++ .../external/jellyfin/JellyfinApiClient.ts | 6 + server/src/services/ServicesModule.ts | 6 + .../scanner/EmbyMediaSourceMovieScanner.ts | 41 +------ .../scanner/EmbyMediaSourceMusicScanner.ts | 53 +------- .../EmbyMediaSourceOtherVideoScanner.ts | 55 +++++++++ .../scanner/EmbyMediaSourceTvShowScanner.ts | 111 +---------------- .../scanner/JellyfinCompatibleMovieScanner.ts | 59 +++++++++ .../scanner/JellyfinCompatibleMusicScanner.ts | 52 ++++++++ .../JellyfinCompatibleOtherVideoScanner.ts | 62 ++++++++++ .../JellyfinCompatibleTvShowScanner.ts | 59 +++++++++ .../JellyfinMediaSourceMovieScanner.ts | 47 +------ .../JellyfinMediaSourceMusicScanner.ts | 53 +------- .../JellyfinMediaSourceOtherVideoScanner.ts | 58 +-------- .../JellyfinMediaSourceTvShowScanner.ts | 111 +---------------- .../MediaSourceCompatibleMusicScanner.ts | 63 ++++++++++ .../MediaSourceCompatibleTvShowScanner.ts | 116 ++++++++++++++++++ .../scanner/MediaSourceMusicArtistScanner.ts | 2 +- .../scanner/PlexMediaSourceMusicScanner.ts | 40 +----- .../scanner/PlexMediaSourceTvShowScanner.ts | 79 +----------- 21 files changed, 571 insertions(+), 562 deletions(-) create mode 100644 server/src/services/scanner/EmbyMediaSourceOtherVideoScanner.ts create mode 100644 server/src/services/scanner/JellyfinCompatibleMovieScanner.ts create mode 100644 server/src/services/scanner/JellyfinCompatibleMusicScanner.ts create mode 100644 server/src/services/scanner/JellyfinCompatibleOtherVideoScanner.ts create mode 100644 server/src/services/scanner/JellyfinCompatibleTvShowScanner.ts create mode 100644 server/src/services/scanner/MediaSourceCompatibleMusicScanner.ts create mode 100644 server/src/services/scanner/MediaSourceCompatibleTvShowScanner.ts diff --git a/server/src/external/MediaSourceApiClient.ts b/server/src/external/MediaSourceApiClient.ts index e75f48b2..2c65f589 100644 --- a/server/src/external/MediaSourceApiClient.ts +++ b/server/src/external/MediaSourceApiClient.ts @@ -2,19 +2,19 @@ import type { ProgramType } from '../db/schema/Program.ts'; import type { ProgramGroupingType } from '../db/schema/ProgramGrouping.ts'; import type { Episode, + MediaSourceShow, Movie, MusicAlbum, MusicArtist, MusicTrack, Season, - Show, } from '../types/Media.ts'; import type { ApiClientOptions, QueryResult } from './BaseApiClient.ts'; import { BaseApiClient } from './BaseApiClient.ts'; export type ProgramTypeMap< MovieType extends Movie = Movie, - ShowType extends Show = Show, + ShowType extends MediaSourceShow = MediaSourceShow, SeasonType extends Season = Season, EpisodeType extends Episode = Episode< ShowType, @@ -46,9 +46,13 @@ export type ExtractMediaType< : never; export type ExtractShowType = - ExtractMediaType extends MusicArtist - ? ExtractMediaType - : never; + Client['_programTypes']['show']; + +export type ExtractSeasonType = + Client['_programTypes']['season']; + +export type ExtractEpisodeType = + Client['_programTypes']['episode']; export type MediaSourceApiClientFactory< Type extends MediaSourceApiClient, @@ -65,6 +69,8 @@ export abstract class MediaSourceApiClient< ProgramTypes extends ProgramTypeMap = ProgramTypeMap, OptionsType extends ApiClientOptions = ApiClientOptions, > extends BaseApiClient { + readonly _programTypes: ProgramTypes; + abstract getMovieLibraryContents( libraryId: string, pageSize?: number, diff --git a/server/src/external/emby/EmbyApiClient.ts b/server/src/external/emby/EmbyApiClient.ts index 984f0e9c..baeaecef 100644 --- a/server/src/external/emby/EmbyApiClient.ts +++ b/server/src/external/emby/EmbyApiClient.ts @@ -799,6 +799,50 @@ export class EmbyApiClient extends MediaSourceApiClient { ); } + getOtherVideoLibraryContents( + libraryId: string, + pageSize?: number, + ): AsyncIterable { + return this.getChildContents( + 'Video', + (video) => this.embyApiOtherVideoInjection(video), + (page) => + this.getRawItems( + null, + libraryId, + ['Video'], + [ + 'Path', + 'Genres', + 'Tags', + 'DateCreated', + 'Etag', + 'Overview', + 'Taglines', + 'Studios', + 'People', + 'ProductionYear', + 'PremiereDate', + 'MediaSources', + 'OfficialRating', + 'ProviderIds', + 'Chapters', + ], + { + offset: page * (pageSize ?? 50), + limit: pageSize ?? 50, + }, + ), + pageSize, + ); + } + + getOtherVideo(key: string): Promise> { + return this.getItemOfType(key, 'Video', (video) => + this.embyApiOtherVideoInjection(video), + ); + } + private async getItemOfType< ItemTypeT extends EmbyItemKind, OutType = SpecificEmbyType, diff --git a/server/src/external/jellyfin/JellyfinApiClient.ts b/server/src/external/jellyfin/JellyfinApiClient.ts index e98dd19c..2d3c6a95 100644 --- a/server/src/external/jellyfin/JellyfinApiClient.ts +++ b/server/src/external/jellyfin/JellyfinApiClient.ts @@ -369,6 +369,12 @@ export class JellyfinApiClient extends MediaSourceApiClient { ); } + getOtherVideo(key: string): Promise> { + return this.getItemOfType(key, 'Video', (video) => + this.jellyfinApiOtherVideoInjection(video), + ); + } + private async getItemOfType( itemId: string, itemType: ItemTypeT, diff --git a/server/src/services/ServicesModule.ts b/server/src/services/ServicesModule.ts index cb8e4e03..28c350cf 100644 --- a/server/src/services/ServicesModule.ts +++ b/server/src/services/ServicesModule.ts @@ -16,6 +16,7 @@ import { LocalMediaCanonicalizer } from './LocalMediaCanonicalizer.ts'; import { PlexMediaCanonicalizer } from './PlexMediaCanonicalizers.ts'; import { EmbyMediaSourceMovieScanner } from './scanner/EmbyMediaSourceMovieScanner.ts'; import { EmbyMediaSourceMusicScanner } from './scanner/EmbyMediaSourceMusicScanner.ts'; +import { EmbyMediaSourceOtherVideoScanner } from './scanner/EmbyMediaSourceOtherVideoScanner.ts'; import { EmbyMediaSourceTvShowScanner } from './scanner/EmbyMediaSourceTvShowScanner.ts'; import type { GenericExternalCollectionScanner } from './scanner/ExternalCollectionScanner.ts'; import type { @@ -103,6 +104,11 @@ export const ServicesModule = new ContainerModule((bind) => { ) .to(JellyfinMediaSourceOtherVideoScanner) .whenTargetNamed(MediaSourceType.Jellyfin); + bind( + KEYS.MediaSourceOtherVideoLibraryScanner, + ) + .to(EmbyMediaSourceOtherVideoScanner) + .whenTargetNamed(MediaSourceType.Emby); bind( KEYS.MediaSourceLibraryScanner, diff --git a/server/src/services/scanner/EmbyMediaSourceMovieScanner.ts b/server/src/services/scanner/EmbyMediaSourceMovieScanner.ts index 6a15a37a..b4de3735 100644 --- a/server/src/services/scanner/EmbyMediaSourceMovieScanner.ts +++ b/server/src/services/scanner/EmbyMediaSourceMovieScanner.ts @@ -1,6 +1,5 @@ import { MediaSourceDB } from '@/db/mediaSourceDB.js'; import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js'; -import { 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'; @@ -10,14 +9,13 @@ 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 { Logger } from '../../util/logging/LoggerFactory.ts'; import { MeilisearchService } from '../MeilisearchService.ts'; -import { MediaSourceMovieLibraryScanner } from './MediaSourceMovieLibraryScanner.ts'; +import { JellyfinCompatibleMovieScanner } from './JellyfinCompatibleMovieScanner.ts'; import { MediaSourceProgressService } from './MediaSourceProgressService.ts'; @injectable() -export class EmbyMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner< +export class EmbyMediaSourceMovieScanner extends JellyfinCompatibleMovieScanner< EmbyT, EmbyApiClient, EmbyMovie @@ -55,39 +53,4 @@ export class EmbyMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner< mediaSource, ); } - - protected getLibraryContents( - libraryKey: string, - context: ScanContext, - ): AsyncIterable { - return context.apiClient.getMovieLibraryContents(libraryKey); - } - - protected async scanMovie( - { apiClient }: ScanContext, - apiMovie: EmbyMovie, - ): Promise> { - const fullMetadataResult = await apiClient.getMovie(apiMovie.externalId); - - if (fullMetadataResult.isFailure()) { - throw fullMetadataResult.error; - } - - return fullMetadataResult.map((fullMovie) => { - if (!fullMovie) { - throw new Error(`Movie (ID = ${apiMovie.externalId}) not found`); - } - - return fullMovie; - }); - } - - protected getLibrarySize( - libraryKey: string, - context: ScanContext, - ): Promise { - return context.apiClient - .getChildItemCount(libraryKey, 'Movie') - .then((_) => _.getOrThrow()); - } } diff --git a/server/src/services/scanner/EmbyMediaSourceMusicScanner.ts b/server/src/services/scanner/EmbyMediaSourceMusicScanner.ts index 935f31ad..652a76e7 100644 --- a/server/src/services/scanner/EmbyMediaSourceMusicScanner.ts +++ b/server/src/services/scanner/EmbyMediaSourceMusicScanner.ts @@ -1,4 +1,4 @@ -import { inject, interfaces } from 'inversify'; +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'; @@ -7,7 +7,6 @@ import { MediaSourceDB } from '../../db/mediaSourceDB.ts'; import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js'; import { EmbyApiClient } from '../../external/emby/EmbyApiClient.ts'; import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.ts'; -import { WrappedError } from '../../types/errors.ts'; import { KEYS } from '../../types/inject.ts'; import { EmbyT } from '../../types/internal.ts'; import { @@ -15,14 +14,13 @@ import { EmbyMusicArtist, EmbyMusicTrack, } from '../../types/Media.ts'; -import { Result } from '../../types/result.ts'; import { Logger } from '../../util/logging/LoggerFactory.ts'; import { MeilisearchService } from '../MeilisearchService.ts'; -import { MediaSourceMusicArtistScanner } from './MediaSourceMusicArtistScanner.ts'; +import { JellyfinCompatibleMusicScanner } from './JellyfinCompatibleMusicScanner.ts'; import { MediaSourceProgressService } from './MediaSourceProgressService.ts'; -import { ScanContext } from './MediaSourceScanner.ts'; -export class EmbyMediaSourceMusicScanner extends MediaSourceMusicArtistScanner< +@injectable() +export class EmbyMediaSourceMusicScanner extends JellyfinCompatibleMusicScanner< EmbyT, EmbyMusicArtist, EmbyMusicAlbum, @@ -59,34 +57,6 @@ export class EmbyMediaSourceMusicScanner extends MediaSourceMusicArtistScanner< ); } - protected getArtists( - libraryId: string, - context: ScanContext, - ): AsyncIterable { - return context.apiClient.getMusicLibraryContents(libraryId, 50); - } - - protected getAlbums( - show: EmbyMusicArtist, - context: ScanContext, - ): AsyncIterable { - return context.apiClient.getArtistAlbums(show.externalId, 50); - } - - protected getAlbumTracks( - season: EmbyMusicAlbum, - context: ScanContext, - ): AsyncIterable { - return context.apiClient.getAlbumTracks(season.externalId, 50); - } - - protected getFullTrackMetadata( - episodeT: EmbyMusicTrack, - context: ScanContext, - ): Promise> { - return context.apiClient.getMusicTrack(episodeT.externalId); - } - protected getApiClient( mediaSource: MediaSourceWithRelations, ): Promise { @@ -94,19 +64,4 @@ export class EmbyMediaSourceMusicScanner extends MediaSourceMusicArtistScanner< mediaSource, ); } - - protected getEntityExternalKey( - show: EmbyMusicArtist | EmbyMusicAlbum | EmbyMusicTrack, - ): string { - return show.externalId; - } - - protected getLibrarySize( - libraryKey: string, - context: ScanContext, - ): Promise { - return context.apiClient - .getChildItemCount(libraryKey, 'MusicArtist') - .then((_) => _.getOrThrow()); - } } diff --git a/server/src/services/scanner/EmbyMediaSourceOtherVideoScanner.ts b/server/src/services/scanner/EmbyMediaSourceOtherVideoScanner.ts new file mode 100644 index 00000000..26f32a13 --- /dev/null +++ b/server/src/services/scanner/EmbyMediaSourceOtherVideoScanner.ts @@ -0,0 +1,55 @@ +import { MediaSourceType } from '@/db/schema/base.js'; +import { inject, injectable, interfaces } from 'inversify'; +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 { EmbyApiClient } from '../../external/emby/EmbyApiClient.ts'; +import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.ts'; +import { KEYS } from '../../types/inject.ts'; +import type { EmbyT } from '../../types/internal.ts'; +import type { EmbyOtherVideo } from '../../types/Media.ts'; +import { Logger } from '../../util/logging/LoggerFactory.ts'; +import { MeilisearchService } from '../MeilisearchService.ts'; +import { JellyfinCompatibleOtherVideoScanner } from './JellyfinCompatibleOtherVideoScanner.ts'; +import { MediaSourceProgressService } from './MediaSourceProgressService.ts'; + +@injectable() +export class EmbyMediaSourceOtherVideoScanner extends JellyfinCompatibleOtherVideoScanner< + EmbyT, + EmbyOtherVideo, + EmbyApiClient +> { + readonly type = 'other_videos'; + readonly mediaSourceType = MediaSourceType.Emby; + + constructor( + @inject(KEYS.Logger) logger: Logger, + @inject(MediaSourceDB) mediaSourceDB: MediaSourceDB, + @inject(KEYS.ProgramDB) programDB: IProgramDB, + @inject(MeilisearchService) searchService: MeilisearchService, + @inject(MediaSourceApiFactory) + private mediaSourceApiFactory: MediaSourceApiFactory, + @inject(MediaSourceProgressService) + mediaSourceProgressService: MediaSourceProgressService, + @inject(KEYS.ProgramDaoMinterFactory) + programMinterFactory: interfaces.AutoFactory, + ) { + super( + logger, + mediaSourceDB, + programDB, + searchService, + mediaSourceProgressService, + programMinterFactory(), + ); + } + + protected getApiClient( + mediaSource: MediaSourceWithRelations, + ): Promise { + return this.mediaSourceApiFactory.getEmbyApiClientForMediaSource( + mediaSource, + ); + } +} diff --git a/server/src/services/scanner/EmbyMediaSourceTvShowScanner.ts b/server/src/services/scanner/EmbyMediaSourceTvShowScanner.ts index 6f5cf998..12c45c54 100644 --- a/server/src/services/scanner/EmbyMediaSourceTvShowScanner.ts +++ b/server/src/services/scanner/EmbyMediaSourceTvShowScanner.ts @@ -1,38 +1,23 @@ 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 { inject, injectable, interfaces } from 'inversify'; -import { isNil } from 'lodash-es'; +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 { EmbyApiClient } from '../../external/emby/EmbyApiClient.ts'; -import { WrappedError } from '../../types/errors.ts'; -import { KEYS } from '../../types/inject.ts'; - -import { ProgramGrouping } from '@tunarr/types'; -import { GetProgramGroupingById } from '../../commands/GetProgramGroupingById.ts'; import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js'; -import { - EmbyEpisode, - EmbySeason, - EmbyShow, - SeasonWithShow, -} from '../../types/Media.ts'; -import { Result } from '../../types/result.ts'; +import { EmbyApiClient } from '../../external/emby/EmbyApiClient.ts'; +import { KEYS } from '../../types/inject.ts'; import { Logger } from '../../util/logging/LoggerFactory.ts'; import { MeilisearchService } from '../MeilisearchService.ts'; +import { JellyfinCompatibleTvShowScanner } from './JellyfinCompatibleTvShowScanner.ts'; import { MediaSourceProgressService } from './MediaSourceProgressService.ts'; -import { MediaSourceTvShowLibraryScanner } from './MediaSourceTvShowLibraryScanner.ts'; @injectable() -export class EmbyMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanner< +export class EmbyMediaSourceTvShowScanner extends JellyfinCompatibleTvShowScanner< typeof MediaSourceType.Emby, - EmbyApiClient, - EmbyShow, - EmbySeason, - EmbyEpisode + EmbyApiClient > { readonly mediaSourceType = MediaSourceType.Emby; @@ -64,47 +49,6 @@ export class EmbyMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne ); } - protected getTvShowLibraryContents( - libraryId: string, - context: ScanContext, - ): AsyncIterable { - return context.apiClient.getTvShowLibraryContents(libraryId); - } - - protected getTvShowSeasons( - show: EmbyShow, - context: ScanContext, - ): AsyncIterable { - return context.apiClient.getShowSeasons(show.externalId); - } - - protected getSeasonEpisodes( - season: SeasonWithShow, - context: ScanContext, - ): AsyncIterable { - return context.apiClient.getSeasonEpisodes( - season.show.externalId, - season.externalId, - ); - } - - protected getFullEpisodeMetadata( - episodeT: EmbyEpisode, - context: ScanContext, - ): Promise> { - return context.apiClient - .getEpisode(episodeT.externalId) - .then((_) => - _.flatMap((ep) => - isNil(ep) - ? Result.forError( - new Error(`Episode ID ${episodeT.externalId} not found`), - ) - : Result.success(ep), - ), - ); - } - protected getApiClient( mediaSource: MediaSourceWithRelations, ): Promise { @@ -112,47 +56,4 @@ export class EmbyMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne mediaSource, ); } - - protected getCanonicalId( - entity: EmbyShow | EmbySeason | EmbyEpisode, - ): string { - return entity.canonicalId; - } - - protected getEntityExternalKey( - show: EmbyShow | EmbySeason | EmbyEpisode, - ): string { - return show.externalId; - } - - protected getLibrarySize( - libraryKey: string, - context: ScanContext, - ): Promise { - return context.apiClient - .getChildItemCount(libraryKey, 'Series') - .then((_) => _.getOrThrow()); - } - - protected getFullTvSeasonMetadata( - externalId: string, - context: ScanContext, - ): Promise> { - return context.apiClient.getSeason(externalId); - } - - protected getFullTvShowMetadata( - externalId: string, - context: ScanContext, - ): Promise> { - return context.apiClient.getShow(externalId); - } - - protected isShowT(grouping: ProgramGrouping): grouping is EmbyShow { - return grouping.sourceType === 'emby' && grouping.type === 'show'; - } - - protected isSeasonT(grouping: ProgramGrouping): grouping is EmbySeason { - return grouping.sourceType === 'emby' && grouping.type === 'season'; - } } diff --git a/server/src/services/scanner/JellyfinCompatibleMovieScanner.ts b/server/src/services/scanner/JellyfinCompatibleMovieScanner.ts new file mode 100644 index 00000000..a380fc46 --- /dev/null +++ b/server/src/services/scanner/JellyfinCompatibleMovieScanner.ts @@ -0,0 +1,59 @@ +import type { MediaSourceApiClient } from '../../external/MediaSourceApiClient.ts'; +import type { RemoteMediaSourceType } from '../../db/schema/MediaSource.ts'; +import type { Movie, HasMediaSourceInfo } from '../../types/Media.ts'; +import type { Result } from '../../types/result.ts'; +import { MediaSourceMovieLibraryScanner } from './MediaSourceMovieLibraryScanner.ts'; +import type { ScanContext } from './MediaSourceScanner.ts'; + +/** + * Shared abstract base for Jellyfin and Emby movie scanners. + * Both sources use the same API shape (getChildItemCount, getMovieLibraryContents, + * getMovie), so this class provides a single implementation instead of duplicating + * it across the two concrete scanner classes. + */ +export abstract class JellyfinCompatibleMovieScanner< + SourceTypeT extends RemoteMediaSourceType, + ClientT extends MediaSourceApiClient & { + getChildItemCount( + parentId: string, + itemType: string, + ): Promise>; + }, + MovieT extends Movie & HasMediaSourceInfo, +> extends MediaSourceMovieLibraryScanner { + protected getLibrarySize( + libraryKey: string, + context: ScanContext, + ): Promise { + return context.apiClient + .getChildItemCount(libraryKey, 'Movie') + .then((_) => _.getOrThrow()); + } + + protected getLibraryContents( + libraryKey: string, + context: ScanContext, + ): AsyncIterable { + // Safe: the concrete client returns MovieT items; base type is the wider Movie + return context.apiClient.getMovieLibraryContents( + libraryKey, + ) as AsyncIterable; + } + + protected async scanMovie( + { apiClient }: ScanContext, + apiMovie: MovieT, + ): Promise> { + const fullMetadataResult = await apiClient.getMovie(apiMovie.externalId); + if (fullMetadataResult.isFailure()) { + throw fullMetadataResult.error; + } + return fullMetadataResult.map((fullMovie) => { + if (!fullMovie) { + throw new Error(`Movie (ID = ${apiMovie.externalId}) not found`); + } + // Safe: at runtime the client returns the concrete MovieT + return fullMovie as MovieT; + }); + } +} diff --git a/server/src/services/scanner/JellyfinCompatibleMusicScanner.ts b/server/src/services/scanner/JellyfinCompatibleMusicScanner.ts new file mode 100644 index 00000000..04857105 --- /dev/null +++ b/server/src/services/scanner/JellyfinCompatibleMusicScanner.ts @@ -0,0 +1,52 @@ +import type { MediaSourceApiClient } from '../../external/MediaSourceApiClient.ts'; +import type { RemoteMediaSourceType } from '../../db/schema/MediaSource.ts'; +import type { + MediaSourceMusicAlbum, + MediaSourceMusicArtist, + MediaSourceMusicTrack, +} from '../../types/Media.ts'; +import type { Result } from '../../types/result.ts'; +import type { ProgramTypeMapForMusic } from './MediaSourceMusicArtistScanner.ts'; +import { MediaSourceCompatibleMusicScanner } from './MediaSourceCompatibleMusicScanner.ts'; +import type { ScanContext } from './MediaSourceScanner.ts'; + +/** + * Shared abstract base for Jellyfin and Emby music scanners. + * + * Extends MediaSourceCompatibleMusicScanner (which covers all three sources), + * adding Jellyfin/Emby-specific behaviour: + * - getLibrarySize via getChildItemCount (both sources expose this API) + * + * Subclasses must supply: + * - mediaSourceType – the 'jellyfin' | 'emby' discriminant + * - getApiClient – which factory method to call + */ +export abstract class JellyfinCompatibleMusicScanner< + SourceTypeT extends RemoteMediaSourceType, + ArtistT extends MediaSourceMusicArtist, + AlbumT extends MediaSourceMusicAlbum, + TrackT extends MediaSourceMusicTrack, + ClientT extends MediaSourceApiClient< + ProgramTypeMapForMusic + > & { + getChildItemCount( + parentId: string, + itemType: string, + ): Promise>; + }, +> extends MediaSourceCompatibleMusicScanner< + SourceTypeT, + ArtistT, + AlbumT, + TrackT, + ClientT +> { + protected getLibrarySize( + libraryKey: string, + context: ScanContext, + ): Promise { + return context.apiClient + .getChildItemCount(libraryKey, 'MusicArtist') + .then((_) => _.getOrThrow()); + } +} diff --git a/server/src/services/scanner/JellyfinCompatibleOtherVideoScanner.ts b/server/src/services/scanner/JellyfinCompatibleOtherVideoScanner.ts new file mode 100644 index 00000000..b8603891 --- /dev/null +++ b/server/src/services/scanner/JellyfinCompatibleOtherVideoScanner.ts @@ -0,0 +1,62 @@ +import type { RemoteMediaSourceType } from '../../db/schema/MediaSource.ts'; +import type { MediaSourceApiClient } from '../../external/MediaSourceApiClient.ts'; +import type { HasMediaSourceInfo, OtherVideo } from '../../types/Media.ts'; +import type { Result } from '../../types/result.ts'; +import { MediaSourceOtherVideoScanner } from './MediaSourceOtherVideoScanner.ts'; +import type { ScanContext } from './MediaSourceScanner.ts'; + +/** + * Shared abstract base for Jellyfin and Emby OtherVideo scanners. + * + * Both sources use the same API shape (getChildItemCount, getOtherVideoLibraryContents, + * getOtherVideo), so this class provides a single implementation instead of duplicating + * it across the two concrete scanner classes. + * + * Subclasses must supply: + * - mediaSourceType – the 'jellyfin' | 'emby' discriminant + * - getApiClient – which factory method to call + */ +export abstract class JellyfinCompatibleOtherVideoScanner< + SourceTypeT extends RemoteMediaSourceType, + OtherVideoTypeT extends OtherVideo & HasMediaSourceInfo, + ClientT extends MediaSourceApiClient & { + getChildItemCount( + parentId: string, + itemType: string, + ): Promise>; + getOtherVideoLibraryContents( + parentId: string, + pageSize?: number, + ): AsyncIterable; + getOtherVideo(key: string): Promise>; + }, +> extends MediaSourceOtherVideoScanner { + protected getVideos( + libraryId: string, + context: ScanContext, + ): AsyncIterable { + return context.apiClient.getOtherVideoLibraryContents(libraryId); + } + + protected getLibrarySize( + libraryKey: string, + context: ScanContext, + ): Promise { + return context.apiClient + .getChildItemCount(libraryKey, 'Video') + .then((_) => _.getOrThrow()); + } + + protected scanVideo( + context: ScanContext, + incomingVideo: OtherVideoTypeT, + ): Promise> { + return context.apiClient.getOtherVideo( + incomingVideo.externalId, + ) as unknown as Promise>; + } + + protected getExternalKey(video: OtherVideoTypeT): string { + return video.externalId; + } +} diff --git a/server/src/services/scanner/JellyfinCompatibleTvShowScanner.ts b/server/src/services/scanner/JellyfinCompatibleTvShowScanner.ts new file mode 100644 index 00000000..305df2db --- /dev/null +++ b/server/src/services/scanner/JellyfinCompatibleTvShowScanner.ts @@ -0,0 +1,59 @@ +import { isNil } from 'lodash-es'; +import type { RemoteMediaSourceType } from '../../db/schema/MediaSource.ts'; +import type { + ExtractEpisodeType, + MediaSourceApiClient, +} from '../../external/MediaSourceApiClient.ts'; +import type { WrappedError } from '../../types/errors.ts'; +import { Result } from '../../types/result.ts'; +import type { ScanContext } from './MediaSourceScanner.ts'; +import { MediaSourceCompatibleTvShowScanner } from './MediaSourceCompatibleTvShowScanner.ts'; + +/** + * Shared abstract base for Jellyfin and Emby TV show scanners. + * + * Extends MediaSourceCompatibleTvShowScanner (which covers all three sources), + * adding Jellyfin/Emby-specific behaviour: + * - getLibrarySize via getChildItemCount (both sources expose this API) + * - getFullEpisodeMetadata with null-check handling (both sources may return + * null for a missing episode rather than an error) + * + * Subclasses must supply: + * - mediaSourceType – the 'jellyfin' | 'emby' discriminant + * - getApiClient – which factory method to call + */ +export abstract class JellyfinCompatibleTvShowScanner< + SourceTypeT extends RemoteMediaSourceType, + ClientT extends MediaSourceApiClient & { + getChildItemCount( + parentId: string, + itemType: string, + ): Promise>; + }, +> extends MediaSourceCompatibleTvShowScanner { + protected getLibrarySize( + libraryKey: string, + context: ScanContext, + ): Promise { + return context.apiClient + .getChildItemCount(libraryKey, 'Series') + .then((_) => _.getOrThrow()); + } + + protected getFullEpisodeMetadata( + episodeT: ExtractEpisodeType, + context: ScanContext, + ): Promise, WrappedError>> { + return context.apiClient + .getEpisode(episodeT.externalId) + .then((_) => + _.flatMap((ep) => + isNil(ep) + ? Result.forError( + new Error(`Episode ID ${episodeT.externalId} not found`), + ) + : Result.success(ep), + ), + ); + } +} diff --git a/server/src/services/scanner/JellyfinMediaSourceMovieScanner.ts b/server/src/services/scanner/JellyfinMediaSourceMovieScanner.ts index 3cced5ef..0a8f2b4d 100644 --- a/server/src/services/scanner/JellyfinMediaSourceMovieScanner.ts +++ b/server/src/services/scanner/JellyfinMediaSourceMovieScanner.ts @@ -1,6 +1,6 @@ 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 { inject, injectable, interfaces } from 'inversify'; import { ProgramConverter } from '../../db/converters/ProgramConverter.ts'; import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts'; @@ -8,21 +8,19 @@ import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts'; import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js'; import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.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 { MediaSourceMovieLibraryScanner } from './MediaSourceMovieLibraryScanner.ts'; +import { JellyfinCompatibleMovieScanner } from './JellyfinCompatibleMovieScanner.ts'; import { MediaSourceProgressService } from './MediaSourceProgressService.ts'; @injectable() -export class JellyfinMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner< - JellyfinT, +export class JellyfinMediaSourceMovieScanner extends JellyfinCompatibleMovieScanner< + typeof MediaSourceType.Jellyfin, JellyfinApiClient, JellyfinMovie > { - readonly mediaSourceType = 'jellyfin'; + readonly mediaSourceType = MediaSourceType.Jellyfin; constructor( @inject(KEYS.Logger) logger: Logger, @@ -55,39 +53,4 @@ export class JellyfinMediaSourceMovieScanner extends MediaSourceMovieLibraryScan mediaSource, ); } - - protected getLibraryContents( - libraryKey: string, - context: ScanContext, - ): AsyncIterable { - return context.apiClient.getMovieLibraryContents(libraryKey); - } - - protected async scanMovie( - { apiClient }: ScanContext, - apiMovie: JellyfinMovie, - ): Promise> { - const fullMetadataResult = await apiClient.getMovie(apiMovie.externalId); - - if (fullMetadataResult.isFailure()) { - throw fullMetadataResult.error; - } - - return fullMetadataResult.map((fullMovie) => { - if (!fullMovie) { - throw new Error(`Movie (ID = ${apiMovie.externalId}) not found`); - } - - return fullMovie; - }); - } - - protected getLibrarySize( - libraryKey: string, - context: ScanContext, - ): Promise { - return context.apiClient - .getChildItemCount(libraryKey, 'Movie') - .then((_) => _.getOrThrow()); - } } diff --git a/server/src/services/scanner/JellyfinMediaSourceMusicScanner.ts b/server/src/services/scanner/JellyfinMediaSourceMusicScanner.ts index d27806ab..732db6f1 100644 --- a/server/src/services/scanner/JellyfinMediaSourceMusicScanner.ts +++ b/server/src/services/scanner/JellyfinMediaSourceMusicScanner.ts @@ -1,4 +1,4 @@ -import { inject, interfaces } from 'inversify'; +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'; @@ -7,7 +7,6 @@ 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 { WrappedError } from '../../types/errors.ts'; import { KEYS } from '../../types/inject.ts'; import { JellyfinT } from '../../types/internal.ts'; import { @@ -15,14 +14,13 @@ import { JellyfinMusicArtist, JellyfinMusicTrack, } from '../../types/Media.ts'; -import { Result } from '../../types/result.ts'; import { Logger } from '../../util/logging/LoggerFactory.ts'; import { MeilisearchService } from '../MeilisearchService.ts'; -import { MediaSourceMusicArtistScanner } from './MediaSourceMusicArtistScanner.ts'; +import { JellyfinCompatibleMusicScanner } from './JellyfinCompatibleMusicScanner.ts'; import { MediaSourceProgressService } from './MediaSourceProgressService.ts'; -import { ScanContext } from './MediaSourceScanner.ts'; -export class JellyfinMediaSourceMusicScanner extends MediaSourceMusicArtistScanner< +@injectable() +export class JellyfinMediaSourceMusicScanner extends JellyfinCompatibleMusicScanner< JellyfinT, JellyfinMusicArtist, JellyfinMusicAlbum, @@ -59,34 +57,6 @@ export class JellyfinMediaSourceMusicScanner extends MediaSourceMusicArtistScann ); } - protected getArtists( - libraryId: string, - context: ScanContext, - ): AsyncIterable { - return context.apiClient.getMusicLibraryContents(libraryId, 50); - } - - protected getAlbums( - show: JellyfinMusicArtist, - context: ScanContext, - ): AsyncIterable { - return context.apiClient.getArtistAlbums(show.externalId, 50); - } - - protected getAlbumTracks( - season: JellyfinMusicAlbum, - context: ScanContext, - ): AsyncIterable { - return context.apiClient.getAlbumTracks(season.externalId, 50); - } - - protected getFullTrackMetadata( - episodeT: JellyfinMusicTrack, - context: ScanContext, - ): Promise> { - return context.apiClient.getMusicTrack(episodeT.externalId); - } - protected getApiClient( mediaSource: MediaSourceWithRelations, ): Promise { @@ -94,19 +64,4 @@ export class JellyfinMediaSourceMusicScanner extends MediaSourceMusicArtistScann mediaSource, ); } - - protected getEntityExternalKey( - show: JellyfinMusicArtist | JellyfinMusicAlbum | JellyfinMusicTrack, - ): string { - return show.externalId; - } - - protected getLibrarySize( - libraryKey: string, - context: ScanContext, - ): Promise { - return context.apiClient - .getChildItemCount(libraryKey, 'MusicArtist') - .then((_) => _.getOrThrow()); - } } diff --git a/server/src/services/scanner/JellyfinMediaSourceOtherVideoScanner.ts b/server/src/services/scanner/JellyfinMediaSourceOtherVideoScanner.ts index 19406f50..47821a96 100644 --- a/server/src/services/scanner/JellyfinMediaSourceOtherVideoScanner.ts +++ b/server/src/services/scanner/JellyfinMediaSourceOtherVideoScanner.ts @@ -6,22 +6,19 @@ import { MediaSourceDB } from '../../db/mediaSourceDB.ts'; import type { MediaSourceWithRelations } from '../../db/schema/derivedTypes.ts'; import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.ts'; import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.ts'; -import { WrappedError } from '../../types/errors.ts'; import { KEYS } from '../../types/inject.ts'; import type { JellyfinT } from '../../types/internal.ts'; 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 { MediaSourceOtherVideoScanner } from './MediaSourceOtherVideoScanner.ts'; +import { JellyfinCompatibleOtherVideoScanner } from './JellyfinCompatibleOtherVideoScanner.ts'; import { MediaSourceProgressService } from './MediaSourceProgressService.ts'; -import type { ScanContext } from './MediaSourceScanner.ts'; @injectable() -export class JellyfinMediaSourceOtherVideoScanner extends MediaSourceOtherVideoScanner< +export class JellyfinMediaSourceOtherVideoScanner extends JellyfinCompatibleOtherVideoScanner< JellyfinT, - JellyfinApiClient, - JellyfinOtherVideo + JellyfinOtherVideo, + JellyfinApiClient > { readonly type = 'other_videos'; readonly mediaSourceType = MediaSourceType.Jellyfin; @@ -48,13 +45,6 @@ export class JellyfinMediaSourceOtherVideoScanner extends MediaSourceOtherVideoS ); } - protected getVideos( - libraryId: string, - context: ScanContext, - ): AsyncIterable { - return context.apiClient.getOtherVideoLibraryContents(libraryId); - } - protected getApiClient( mediaSource: MediaSourceWithRelations, ): Promise { @@ -62,44 +52,4 @@ export class JellyfinMediaSourceOtherVideoScanner extends MediaSourceOtherVideoS mediaSource, ); } - - protected getLibrarySize( - libraryKey: string, - context: ScanContext, - ): Promise { - return context.apiClient - .getChildItemCount(libraryKey, 'Video') - .then((_) => _.getOrThrow()); - } - - protected async scanVideo( - context: ScanContext, - incomingVideo: JellyfinOtherVideo, - ): Promise> { - const convertedItem = await context.apiClient.getItem( - incomingVideo.externalId, - 'Video', - ); - return convertedItem.flatMap((item) => { - if (!item) { - return Result.failure( - WrappedError.forMessage( - `Could not find Jellyfin item id ${incomingVideo.externalId}`, - ), - ); - } else if (item.type !== 'other_video') { - return Result.failure( - WrappedError.forMessage( - `Expected item type to be other_video for ID ${incomingVideo.externalId} but got ${item.type}`, - ), - ); - } - - return Result.success(item); - }); - } - - protected getExternalKey(video: JellyfinOtherVideo): string { - return video.externalId; - } } diff --git a/server/src/services/scanner/JellyfinMediaSourceTvShowScanner.ts b/server/src/services/scanner/JellyfinMediaSourceTvShowScanner.ts index 33b5ef9a..8dcd7508 100644 --- a/server/src/services/scanner/JellyfinMediaSourceTvShowScanner.ts +++ b/server/src/services/scanner/JellyfinMediaSourceTvShowScanner.ts @@ -1,38 +1,23 @@ 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 { inject, injectable, interfaces } from 'inversify'; -import { isNil } from 'lodash-es'; +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 { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.ts'; -import { WrappedError } from '../../types/errors.ts'; -import { KEYS } from '../../types/inject.ts'; - -import { ProgramGrouping } from '@tunarr/types'; -import { GetProgramGroupingById } from '../../commands/GetProgramGroupingById.ts'; import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js'; -import { - JellyfinEpisode, - JellyfinSeason, - JellyfinShow, - SeasonWithShow, -} from '../../types/Media.ts'; -import { Result } from '../../types/result.ts'; +import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.ts'; +import { KEYS } from '../../types/inject.ts'; import { Logger } from '../../util/logging/LoggerFactory.ts'; import { MeilisearchService } from '../MeilisearchService.ts'; +import { JellyfinCompatibleTvShowScanner } from './JellyfinCompatibleTvShowScanner.ts'; import { MediaSourceProgressService } from './MediaSourceProgressService.ts'; -import { MediaSourceTvShowLibraryScanner } from './MediaSourceTvShowLibraryScanner.ts'; @injectable() -export class JellyfinMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanner< +export class JellyfinMediaSourceTvShowScanner extends JellyfinCompatibleTvShowScanner< typeof MediaSourceType.Jellyfin, - JellyfinApiClient, - JellyfinShow, - JellyfinSeason, - JellyfinEpisode + JellyfinApiClient > { readonly mediaSourceType = MediaSourceType.Jellyfin; @@ -64,47 +49,6 @@ export class JellyfinMediaSourceTvShowScanner extends MediaSourceTvShowLibrarySc ); } - protected getTvShowLibraryContents( - libraryId: string, - context: ScanContext, - ): AsyncIterable { - return context.apiClient.getTvShowLibraryContents(libraryId); - } - - protected getTvShowSeasons( - show: JellyfinShow, - context: ScanContext, - ): AsyncIterable { - return context.apiClient.getShowSeasons(show.externalId); - } - - protected getSeasonEpisodes( - season: SeasonWithShow, - context: ScanContext, - ): AsyncIterable { - return context.apiClient.getSeasonEpisodes( - season.show.externalId, - season.externalId, - ); - } - - protected getFullEpisodeMetadata( - episodeT: JellyfinEpisode, - context: ScanContext, - ): Promise> { - return context.apiClient - .getEpisode(episodeT.externalId) - .then((_) => - _.flatMap((ep) => - isNil(ep) - ? Result.forError( - new Error(`Episode ID ${episodeT.externalId} not found`), - ) - : Result.success(ep), - ), - ); - } - protected getApiClient( mediaSource: MediaSourceWithRelations, ): Promise { @@ -112,47 +56,4 @@ export class JellyfinMediaSourceTvShowScanner extends MediaSourceTvShowLibrarySc mediaSource, ); } - - protected getCanonicalId( - entity: JellyfinShow | JellyfinSeason | JellyfinEpisode, - ): string { - return entity.canonicalId; - } - - protected getEntityExternalKey( - show: JellyfinShow | JellyfinSeason | JellyfinEpisode, - ): string { - return show.externalId; - } - - protected getLibrarySize( - libraryKey: string, - context: ScanContext, - ): Promise { - return context.apiClient - .getChildItemCount(libraryKey, 'Series') - .then((_) => _.getOrThrow()); - } - - protected getFullTvSeasonMetadata( - externalId: string, - context: ScanContext, - ): Promise> { - return context.apiClient.getSeason(externalId); - } - - protected getFullTvShowMetadata( - externalId: string, - context: ScanContext, - ): Promise> { - return context.apiClient.getShow(externalId); - } - - protected isShowT(grouping: ProgramGrouping): grouping is JellyfinShow { - return grouping.sourceType === 'jellyfin' && grouping.type === 'show'; - } - - protected isSeasonT(grouping: ProgramGrouping): grouping is JellyfinSeason { - return grouping.sourceType === 'jellyfin' && grouping.type === 'season'; - } } diff --git a/server/src/services/scanner/MediaSourceCompatibleMusicScanner.ts b/server/src/services/scanner/MediaSourceCompatibleMusicScanner.ts new file mode 100644 index 00000000..07fc4815 --- /dev/null +++ b/server/src/services/scanner/MediaSourceCompatibleMusicScanner.ts @@ -0,0 +1,63 @@ +import type { RemoteMediaSourceType } from '../../db/schema/MediaSource.ts'; +import type { MediaSourceApiClient } from '../../external/MediaSourceApiClient.ts'; +import type { + MediaSourceMusicAlbum, + MediaSourceMusicArtist, + MediaSourceMusicTrack, +} from '../../types/Media.ts'; +import { + MediaSourceMusicArtistScanner, + type ProgramTypeMapForMusic, +} from './MediaSourceMusicArtistScanner.ts'; +import type { ScanContext } from './MediaSourceScanner.ts'; + +/** + * Shared abstract base for music scanners across all media sources (Plex, Jellyfin, Emby). + * + * All three sources expose the same MediaSourceApiClient method signatures for + * retrieving artists, albums, and tracks. This class provides default implementations + * for all those methods, so concrete subclasses only need to supply: + * - mediaSourceType – the 'plex' | 'jellyfin' | 'emby' discriminant + * - getApiClient – which factory method to call + * - getLibrarySize – Plex uses getLibraryCount; Jellyfin/Emby use getChildItemCount + */ +export abstract class MediaSourceCompatibleMusicScanner< + SourceTypeT extends RemoteMediaSourceType, + ArtistT extends MediaSourceMusicArtist, + AlbumT extends MediaSourceMusicAlbum, + TrackT extends MediaSourceMusicTrack, + ClientT extends MediaSourceApiClient< + ProgramTypeMapForMusic + >, +> extends MediaSourceMusicArtistScanner< + SourceTypeT, + ArtistT, + AlbumT, + TrackT, + ClientT +> { + protected getArtists( + libraryId: string, + context: ScanContext, + ): AsyncIterable { + return context.apiClient.getMusicLibraryContents(libraryId, 50); + } + + protected getAlbums( + artist: ArtistT, + context: ScanContext, + ): AsyncIterable { + return context.apiClient.getArtistAlbums(artist.externalId, 50); + } + + protected getAlbumTracks( + album: AlbumT, + context: ScanContext, + ): AsyncIterable { + return context.apiClient.getAlbumTracks(album.externalId, 50); + } + + protected getEntityExternalKey(entity: ArtistT | AlbumT | TrackT): string { + return entity.externalId; + } +} diff --git a/server/src/services/scanner/MediaSourceCompatibleTvShowScanner.ts b/server/src/services/scanner/MediaSourceCompatibleTvShowScanner.ts new file mode 100644 index 00000000..1a8ff0b8 --- /dev/null +++ b/server/src/services/scanner/MediaSourceCompatibleTvShowScanner.ts @@ -0,0 +1,116 @@ +import type { ProgramGrouping } from '@tunarr/types'; +import type { RemoteMediaSourceType } from '../../db/schema/MediaSource.ts'; +import type { + ExtractEpisodeType, + ExtractSeasonType, + ExtractShowType, + MediaSourceApiClient, +} from '../../external/MediaSourceApiClient.ts'; +import type { WrappedError } from '../../types/errors.ts'; +import type { SeasonWithShow } from '../../types/Media.ts'; +import type { Result } from '../../types/result.ts'; +import type { ScanContext } from './MediaSourceScanner.ts'; +import { MediaSourceTvShowLibraryScanner } from './MediaSourceTvShowLibraryScanner.ts'; + +/** + * Shared abstract base for TV show scanners across all media sources (Plex, Jellyfin, Emby). + * + * All three sources expose the same MediaSourceApiClient method signatures for + * retrieving shows, seasons, and episodes. This class provides default implementations + * for all those methods, so concrete subclasses only need to supply: + * - mediaSourceType – the 'plex' | 'jellyfin' | 'emby' discriminant + * - getApiClient – which factory method to call + * - getLibrarySize – Plex uses getLibraryCount; Jellyfin/Emby use getChildItemCount + * + * Subclasses may override: + * - getFullEpisodeMetadata – Jellyfin/Emby add null-check handling for missing episodes + */ +export abstract class MediaSourceCompatibleTvShowScanner< + SourceTypeT extends RemoteMediaSourceType, + ClientT extends MediaSourceApiClient, +> extends MediaSourceTvShowLibraryScanner< + SourceTypeT, + ClientT, + ExtractShowType, + ExtractSeasonType, + ExtractEpisodeType +> { + protected getTvShowLibraryContents( + libraryId: string, + context: ScanContext, + ): AsyncIterable> { + return context.apiClient.getTvShowLibraryContents(libraryId); + } + + protected getTvShowSeasons( + show: ExtractShowType, + context: ScanContext, + ): AsyncIterable> { + return context.apiClient.getShowSeasons(show.externalId); + } + + protected getSeasonEpisodes( + season: SeasonWithShow< + ExtractSeasonType, + ExtractShowType + >, + context: ScanContext, + ): AsyncIterable> { + return context.apiClient.getSeasonEpisodes( + season.show.externalId, + season.externalId, + ); + } + + protected getFullEpisodeMetadata( + episodeT: ExtractEpisodeType, + context: ScanContext, + ): Promise, WrappedError>> { + return context.apiClient.getEpisode( + episodeT.externalId, + ) as unknown as Promise, WrappedError>>; + } + + protected getFullTvShowMetadata( + externalId: string, + context: ScanContext, + ): Promise, WrappedError>> { + return context.apiClient.getShow(externalId) as unknown as Promise< + Result, WrappedError> + >; + } + + protected getFullTvSeasonMetadata( + externalId: string, + context: ScanContext, + ): Promise, WrappedError>> { + return context.apiClient.getSeason(externalId) as unknown as Promise< + Result, WrappedError> + >; + } + + protected getEntityExternalKey( + show: + | ExtractShowType + | ExtractSeasonType + | ExtractEpisodeType, + ): string { + return show.externalId; + } + + protected isShowT( + grouping: ProgramGrouping, + ): grouping is ExtractShowType { + return ( + grouping.sourceType === this.mediaSourceType && grouping.type === 'show' + ); + } + + protected isSeasonT( + grouping: ProgramGrouping, + ): grouping is ExtractSeasonType { + return ( + grouping.sourceType === this.mediaSourceType && grouping.type === 'season' + ); + } +} diff --git a/server/src/services/scanner/MediaSourceMusicArtistScanner.ts b/server/src/services/scanner/MediaSourceMusicArtistScanner.ts index 1dadd845..9bab9a6d 100644 --- a/server/src/services/scanner/MediaSourceMusicArtistScanner.ts +++ b/server/src/services/scanner/MediaSourceMusicArtistScanner.ts @@ -52,7 +52,7 @@ export type GenericMediaSourceMusicLibraryScanner< MediaSourceApiClient> >; -type ProgramTypeMapForMusic< +export type ProgramTypeMapForMusic< ArtistT extends MediaSourceMusicArtist, AlbumT extends MediaSourceMusicAlbum, TrackT extends MediaSourceMusicTrack, diff --git a/server/src/services/scanner/PlexMediaSourceMusicScanner.ts b/server/src/services/scanner/PlexMediaSourceMusicScanner.ts index 548e1a1c..994bd3f9 100644 --- a/server/src/services/scanner/PlexMediaSourceMusicScanner.ts +++ b/server/src/services/scanner/PlexMediaSourceMusicScanner.ts @@ -8,18 +8,16 @@ import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts'; import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts'; import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js'; 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 { Logger } from '../../util/logging/LoggerFactory.ts'; import { MeilisearchService } from '../MeilisearchService.ts'; -import { MediaSourceMusicArtistScanner } from './MediaSourceMusicArtistScanner.ts'; +import { MediaSourceCompatibleMusicScanner } from './MediaSourceCompatibleMusicScanner.ts'; import { MediaSourceProgressService } from './MediaSourceProgressService.ts'; @injectable() -export class PlexMediaSourceMusicScanner extends MediaSourceMusicArtistScanner< +export class PlexMediaSourceMusicScanner extends MediaSourceCompatibleMusicScanner< PlexT, PlexArtist, PlexAlbum, @@ -56,34 +54,6 @@ export class PlexMediaSourceMusicScanner extends MediaSourceMusicArtistScanner< ); } - protected getArtists( - libraryId: string, - context: ScanContext, - ): AsyncIterable { - return context.apiClient.getMusicLibraryContents(libraryId); - } - - protected getAlbums( - show: PlexArtist, - context: ScanContext, - ): AsyncIterable { - return context.apiClient.getArtistAlbums(show.externalId); - } - - protected getAlbumTracks( - season: PlexAlbum, - context: ScanContext, - ): AsyncIterable { - return context.apiClient.getAlbumTracks(season.externalId); - } - - protected getFullTrackMetadata( - episodeT: PlexTrack, - context: ScanContext, - ): Promise> { - return context.apiClient.getMusicTrack(episodeT.externalId); - } - protected getApiClient( mediaSource: MediaSourceWithRelations, ): Promise { @@ -92,12 +62,6 @@ export class PlexMediaSourceMusicScanner extends MediaSourceMusicArtistScanner< ); } - protected getEntityExternalKey( - show: PlexArtist | PlexAlbum | PlexTrack, - ): string { - return show.externalId; - } - protected getLibrarySize( libraryKey: string, context: ScanContext, diff --git a/server/src/services/scanner/PlexMediaSourceTvShowScanner.ts b/server/src/services/scanner/PlexMediaSourceTvShowScanner.ts index b5dcb798..ea62a06f 100644 --- a/server/src/services/scanner/PlexMediaSourceTvShowScanner.ts +++ b/server/src/services/scanner/PlexMediaSourceTvShowScanner.ts @@ -1,7 +1,5 @@ import { MediaSourceDB } from '@/db/mediaSourceDB.js'; import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js'; -import { ScanContext } from '@/services/scanner/MediaSourceScanner.js'; -import { ProgramGrouping } from '@tunarr/types'; import { inject, injectable, interfaces } from 'inversify'; import { GetProgramGroupingById } from '../../commands/GetProgramGroupingById.ts'; import { ProgramGroupingMinter } from '../../db/converters/ProgramGroupingMinter.ts'; @@ -9,27 +7,17 @@ import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts'; import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts'; import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js'; import { PlexApiClient } from '../../external/plex/PlexApiClient.ts'; -import { WrappedError } from '../../types/errors.ts'; import { KEYS } from '../../types/inject.ts'; -import { - PlexEpisode, - PlexSeason, - PlexShow, - SeasonWithShow, -} from '../../types/Media.ts'; -import { Result } from '../../types/result.ts'; import { Logger } from '../../util/logging/LoggerFactory.ts'; import { MeilisearchService } from '../MeilisearchService.ts'; +import { MediaSourceCompatibleTvShowScanner } from './MediaSourceCompatibleTvShowScanner.ts'; import { MediaSourceProgressService } from './MediaSourceProgressService.ts'; -import { MediaSourceTvShowLibraryScanner } from './MediaSourceTvShowLibraryScanner.ts'; +import type { ScanContext } from './MediaSourceScanner.ts'; @injectable() -export class PlexMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanner< +export class PlexMediaSourceTvShowScanner extends MediaSourceCompatibleTvShowScanner< 'plex', - PlexApiClient, - PlexShow, - PlexSeason, - PlexEpisode + PlexApiClient > { readonly mediaSourceType = 'plex'; @@ -61,51 +49,6 @@ export class PlexMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne ); } - protected getTvShowLibraryContents( - libraryId: string, - context: ScanContext, - ): AsyncIterable { - return context.apiClient.getTvShowLibraryContents(libraryId); - } - - protected getTvShowSeasons( - show: PlexShow, - context: ScanContext, - ): AsyncIterable { - return context.apiClient.getShowSeasons(show.externalId); - } - - protected getSeasonEpisodes( - season: SeasonWithShow, - context: ScanContext, - ): AsyncIterable { - return context.apiClient.getSeasonEpisodes( - season.show.externalId, - season.externalId, - ); - } - - protected getFullEpisodeMetadata( - episodeT: PlexEpisode, - context: ScanContext, - ): Promise> { - return context.apiClient.getEpisode(episodeT.externalId); - } - - protected getFullTvShowMetadata( - externalId: string, - context: ScanContext, - ): Promise> { - return context.apiClient.getShow(externalId); - } - - protected getFullTvSeasonMetadata( - externalId: string, - context: ScanContext, - ): Promise> { - return context.apiClient.getSeason(externalId); - } - protected getApiClient( mediaSource: MediaSourceWithRelations, ): Promise { @@ -114,12 +57,6 @@ export class PlexMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne ); } - protected getEntityExternalKey( - item: PlexShow | PlexSeason | PlexEpisode, - ): string { - return item.externalId; - } - protected getLibrarySize( libraryKey: string, context: ScanContext, @@ -128,12 +65,4 @@ export class PlexMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne .getLibraryCount(libraryKey) .then((_) => _.getOrThrow()); } - - protected isShowT(grouping: ProgramGrouping): grouping is PlexShow { - return grouping.sourceType === 'plex' && grouping.type === 'show'; - } - - protected isSeasonT(grouping: ProgramGrouping): grouping is PlexSeason { - return grouping.sourceType === 'plex' && grouping.type === 'season'; - } }