refactor: scanner refactoring to strategy pattern

This commit is contained in:
Christian Benincasa
2026-03-30 17:25:26 -04:00
parent 73954b2a26
commit de913642a0
21 changed files with 571 additions and 562 deletions

View File

@@ -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<ShowType> = Season<ShowType>,
EpisodeType extends Episode<ShowType, SeasonType> = Episode<
ShowType,
@@ -46,9 +46,13 @@ export type ExtractMediaType<
: never;
export type ExtractShowType<Client extends MediaSourceApiClient> =
ExtractMediaType<Client, 'show'> extends MusicArtist
? ExtractMediaType<Client, 'show'>
: never;
Client['_programTypes']['show'];
export type ExtractSeasonType<Client extends MediaSourceApiClient> =
Client['_programTypes']['season'];
export type ExtractEpisodeType<Client extends MediaSourceApiClient> =
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<OptionsType> {
readonly _programTypes: ProgramTypes;
abstract getMovieLibraryContents(
libraryId: string,
pageSize?: number,

View File

@@ -799,6 +799,50 @@ export class EmbyApiClient extends MediaSourceApiClient<EmbyItemTypes> {
);
}
getOtherVideoLibraryContents(
libraryId: string,
pageSize?: number,
): AsyncIterable<EmbyOtherVideo> {
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<QueryResult<EmbyOtherVideo>> {
return this.getItemOfType(key, 'Video', (video) =>
this.embyApiOtherVideoInjection(video),
);
}
private async getItemOfType<
ItemTypeT extends EmbyItemKind,
OutType = SpecificEmbyType<ItemTypeT>,

View File

@@ -369,6 +369,12 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
);
}
getOtherVideo(key: string): Promise<QueryResult<JellyfinOtherVideo>> {
return this.getItemOfType(key, 'Video', (video) =>
this.jellyfinApiOtherVideoInjection(video),
);
}
private async getItemOfType<ItemTypeT extends JellyfinItemKind, OutType>(
itemId: string,
itemType: ItemTypeT,

View File

@@ -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<EmbyMediaSourceOtherVideoScanner>(
KEYS.MediaSourceOtherVideoLibraryScanner,
)
.to(EmbyMediaSourceOtherVideoScanner)
.whenTargetNamed(MediaSourceType.Emby);
bind<GenericMediaSourceScannerFactory>(
KEYS.MediaSourceLibraryScanner,

View File

@@ -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<EmbyApiClient>,
): AsyncIterable<EmbyMovie> {
return context.apiClient.getMovieLibraryContents(libraryKey);
}
protected async scanMovie(
{ apiClient }: ScanContext<EmbyApiClient>,
apiMovie: EmbyMovie,
): Promise<Result<EmbyMovie>> {
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<EmbyApiClient>,
): Promise<number> {
return context.apiClient
.getChildItemCount(libraryKey, 'Movie')
.then((_) => _.getOrThrow());
}
}

View File

@@ -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<EmbyApiClient>,
): AsyncIterable<EmbyMusicArtist> {
return context.apiClient.getMusicLibraryContents(libraryId, 50);
}
protected getAlbums(
show: EmbyMusicArtist,
context: ScanContext<EmbyApiClient>,
): AsyncIterable<EmbyMusicAlbum> {
return context.apiClient.getArtistAlbums(show.externalId, 50);
}
protected getAlbumTracks(
season: EmbyMusicAlbum,
context: ScanContext<EmbyApiClient>,
): AsyncIterable<EmbyMusicTrack> {
return context.apiClient.getAlbumTracks(season.externalId, 50);
}
protected getFullTrackMetadata(
episodeT: EmbyMusicTrack,
context: ScanContext<EmbyApiClient>,
): Promise<Result<EmbyMusicTrack, WrappedError>> {
return context.apiClient.getMusicTrack(episodeT.externalId);
}
protected getApiClient(
mediaSource: MediaSourceWithRelations,
): Promise<EmbyApiClient> {
@@ -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<EmbyApiClient>,
): Promise<number> {
return context.apiClient
.getChildItemCount(libraryKey, 'MusicArtist')
.then((_) => _.getOrThrow());
}
}

View File

@@ -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<ProgramDaoMinter>,
) {
super(
logger,
mediaSourceDB,
programDB,
searchService,
mediaSourceProgressService,
programMinterFactory(),
);
}
protected getApiClient(
mediaSource: MediaSourceWithRelations,
): Promise<EmbyApiClient> {
return this.mediaSourceApiFactory.getEmbyApiClientForMediaSource(
mediaSource,
);
}
}

View File

@@ -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<EmbyApiClient>,
): AsyncIterable<EmbyShow> {
return context.apiClient.getTvShowLibraryContents(libraryId);
}
protected getTvShowSeasons(
show: EmbyShow,
context: ScanContext<EmbyApiClient>,
): AsyncIterable<EmbySeason> {
return context.apiClient.getShowSeasons(show.externalId);
}
protected getSeasonEpisodes(
season: SeasonWithShow<EmbySeason, EmbyShow>,
context: ScanContext<EmbyApiClient>,
): AsyncIterable<EmbyEpisode> {
return context.apiClient.getSeasonEpisodes(
season.show.externalId,
season.externalId,
);
}
protected getFullEpisodeMetadata(
episodeT: EmbyEpisode,
context: ScanContext<EmbyApiClient>,
): Promise<Result<EmbyEpisode, 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),
),
);
}
protected getApiClient(
mediaSource: MediaSourceWithRelations,
): Promise<EmbyApiClient> {
@@ -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<EmbyApiClient>,
): Promise<number> {
return context.apiClient
.getChildItemCount(libraryKey, 'Series')
.then((_) => _.getOrThrow());
}
protected getFullTvSeasonMetadata(
externalId: string,
context: ScanContext<EmbyApiClient>,
): Promise<Result<EmbySeason, WrappedError>> {
return context.apiClient.getSeason(externalId);
}
protected getFullTvShowMetadata(
externalId: string,
context: ScanContext<EmbyApiClient>,
): Promise<Result<EmbyShow, WrappedError>> {
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';
}
}

View File

@@ -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<Result<number>>;
},
MovieT extends Movie & HasMediaSourceInfo,
> extends MediaSourceMovieLibraryScanner<SourceTypeT, ClientT, MovieT> {
protected getLibrarySize(
libraryKey: string,
context: ScanContext<ClientT>,
): Promise<number> {
return context.apiClient
.getChildItemCount(libraryKey, 'Movie')
.then((_) => _.getOrThrow());
}
protected getLibraryContents(
libraryKey: string,
context: ScanContext<ClientT>,
): AsyncIterable<MovieT> {
// Safe: the concrete client returns MovieT items; base type is the wider Movie
return context.apiClient.getMovieLibraryContents(
libraryKey,
) as AsyncIterable<MovieT>;
}
protected async scanMovie(
{ apiClient }: ScanContext<ClientT>,
apiMovie: MovieT,
): Promise<Result<MovieT>> {
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;
});
}
}

View File

@@ -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<ArtistT>,
TrackT extends MediaSourceMusicTrack<ArtistT, AlbumT>,
ClientT extends MediaSourceApiClient<
ProgramTypeMapForMusic<ArtistT, AlbumT, TrackT>
> & {
getChildItemCount(
parentId: string,
itemType: string,
): Promise<Result<number>>;
},
> extends MediaSourceCompatibleMusicScanner<
SourceTypeT,
ArtistT,
AlbumT,
TrackT,
ClientT
> {
protected getLibrarySize(
libraryKey: string,
context: ScanContext<ClientT>,
): Promise<number> {
return context.apiClient
.getChildItemCount(libraryKey, 'MusicArtist')
.then((_) => _.getOrThrow());
}
}

View File

@@ -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<Result<number>>;
getOtherVideoLibraryContents(
parentId: string,
pageSize?: number,
): AsyncIterable<OtherVideoTypeT>;
getOtherVideo(key: string): Promise<Result<OtherVideoTypeT>>;
},
> extends MediaSourceOtherVideoScanner<SourceTypeT, ClientT, OtherVideoTypeT> {
protected getVideos(
libraryId: string,
context: ScanContext<ClientT>,
): AsyncIterable<OtherVideoTypeT> {
return context.apiClient.getOtherVideoLibraryContents(libraryId);
}
protected getLibrarySize(
libraryKey: string,
context: ScanContext<ClientT>,
): Promise<number> {
return context.apiClient
.getChildItemCount(libraryKey, 'Video')
.then((_) => _.getOrThrow());
}
protected scanVideo(
context: ScanContext<ClientT>,
incomingVideo: OtherVideoTypeT,
): Promise<Result<OtherVideoTypeT>> {
return context.apiClient.getOtherVideo(
incomingVideo.externalId,
) as unknown as Promise<Result<OtherVideoTypeT>>;
}
protected getExternalKey(video: OtherVideoTypeT): string {
return video.externalId;
}
}

View File

@@ -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<Result<number>>;
},
> extends MediaSourceCompatibleTvShowScanner<SourceTypeT, ClientT> {
protected getLibrarySize(
libraryKey: string,
context: ScanContext<ClientT>,
): Promise<number> {
return context.apiClient
.getChildItemCount(libraryKey, 'Series')
.then((_) => _.getOrThrow());
}
protected getFullEpisodeMetadata(
episodeT: ExtractEpisodeType<ClientT>,
context: ScanContext<ClientT>,
): Promise<Result<ExtractEpisodeType<ClientT>, 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),
),
);
}
}

View File

@@ -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<JellyfinApiClient>,
): AsyncIterable<JellyfinMovie> {
return context.apiClient.getMovieLibraryContents(libraryKey);
}
protected async scanMovie(
{ apiClient }: ScanContext<JellyfinApiClient>,
apiMovie: JellyfinMovie,
): Promise<Result<JellyfinMovie>> {
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<JellyfinApiClient>,
): Promise<number> {
return context.apiClient
.getChildItemCount(libraryKey, 'Movie')
.then((_) => _.getOrThrow());
}
}

View File

@@ -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<JellyfinApiClient>,
): AsyncIterable<JellyfinMusicArtist> {
return context.apiClient.getMusicLibraryContents(libraryId, 50);
}
protected getAlbums(
show: JellyfinMusicArtist,
context: ScanContext<JellyfinApiClient>,
): AsyncIterable<JellyfinMusicAlbum> {
return context.apiClient.getArtistAlbums(show.externalId, 50);
}
protected getAlbumTracks(
season: JellyfinMusicAlbum,
context: ScanContext<JellyfinApiClient>,
): AsyncIterable<JellyfinMusicTrack> {
return context.apiClient.getAlbumTracks(season.externalId, 50);
}
protected getFullTrackMetadata(
episodeT: JellyfinMusicTrack,
context: ScanContext<JellyfinApiClient>,
): Promise<Result<JellyfinMusicTrack, WrappedError>> {
return context.apiClient.getMusicTrack(episodeT.externalId);
}
protected getApiClient(
mediaSource: MediaSourceWithRelations,
): Promise<JellyfinApiClient> {
@@ -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<JellyfinApiClient>,
): Promise<number> {
return context.apiClient
.getChildItemCount(libraryKey, 'MusicArtist')
.then((_) => _.getOrThrow());
}
}

View File

@@ -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<JellyfinApiClient>,
): AsyncIterable<JellyfinOtherVideo> {
return context.apiClient.getOtherVideoLibraryContents(libraryId);
}
protected getApiClient(
mediaSource: MediaSourceWithRelations,
): Promise<JellyfinApiClient> {
@@ -62,44 +52,4 @@ export class JellyfinMediaSourceOtherVideoScanner extends MediaSourceOtherVideoS
mediaSource,
);
}
protected getLibrarySize(
libraryKey: string,
context: ScanContext<JellyfinApiClient>,
): Promise<number> {
return context.apiClient
.getChildItemCount(libraryKey, 'Video')
.then((_) => _.getOrThrow());
}
protected async scanVideo(
context: ScanContext<JellyfinApiClient>,
incomingVideo: JellyfinOtherVideo,
): Promise<Result<JellyfinOtherVideo>> {
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;
}
}

View File

@@ -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<JellyfinApiClient>,
): AsyncIterable<JellyfinShow> {
return context.apiClient.getTvShowLibraryContents(libraryId);
}
protected getTvShowSeasons(
show: JellyfinShow,
context: ScanContext<JellyfinApiClient>,
): AsyncIterable<JellyfinSeason> {
return context.apiClient.getShowSeasons(show.externalId);
}
protected getSeasonEpisodes(
season: SeasonWithShow<JellyfinSeason, JellyfinShow>,
context: ScanContext<JellyfinApiClient>,
): AsyncIterable<JellyfinEpisode> {
return context.apiClient.getSeasonEpisodes(
season.show.externalId,
season.externalId,
);
}
protected getFullEpisodeMetadata(
episodeT: JellyfinEpisode,
context: ScanContext<JellyfinApiClient>,
): Promise<Result<JellyfinEpisode, 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),
),
);
}
protected getApiClient(
mediaSource: MediaSourceWithRelations,
): Promise<JellyfinApiClient> {
@@ -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<JellyfinApiClient>,
): Promise<number> {
return context.apiClient
.getChildItemCount(libraryKey, 'Series')
.then((_) => _.getOrThrow());
}
protected getFullTvSeasonMetadata(
externalId: string,
context: ScanContext<JellyfinApiClient>,
): Promise<Result<JellyfinSeason, WrappedError>> {
return context.apiClient.getSeason(externalId);
}
protected getFullTvShowMetadata(
externalId: string,
context: ScanContext<JellyfinApiClient>,
): Promise<Result<JellyfinShow, WrappedError>> {
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';
}
}

View File

@@ -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<ArtistT>,
TrackT extends MediaSourceMusicTrack<ArtistT, AlbumT>,
ClientT extends MediaSourceApiClient<
ProgramTypeMapForMusic<ArtistT, AlbumT, TrackT>
>,
> extends MediaSourceMusicArtistScanner<
SourceTypeT,
ArtistT,
AlbumT,
TrackT,
ClientT
> {
protected getArtists(
libraryId: string,
context: ScanContext<ClientT>,
): AsyncIterable<ArtistT> {
return context.apiClient.getMusicLibraryContents(libraryId, 50);
}
protected getAlbums(
artist: ArtistT,
context: ScanContext<ClientT>,
): AsyncIterable<AlbumT> {
return context.apiClient.getArtistAlbums(artist.externalId, 50);
}
protected getAlbumTracks(
album: AlbumT,
context: ScanContext<ClientT>,
): AsyncIterable<TrackT> {
return context.apiClient.getAlbumTracks(album.externalId, 50);
}
protected getEntityExternalKey(entity: ArtistT | AlbumT | TrackT): string {
return entity.externalId;
}
}

View File

@@ -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<ClientT>,
ExtractSeasonType<ClientT>,
ExtractEpisodeType<ClientT>
> {
protected getTvShowLibraryContents(
libraryId: string,
context: ScanContext<ClientT>,
): AsyncIterable<ExtractShowType<ClientT>> {
return context.apiClient.getTvShowLibraryContents(libraryId);
}
protected getTvShowSeasons(
show: ExtractShowType<ClientT>,
context: ScanContext<ClientT>,
): AsyncIterable<ExtractSeasonType<ClientT>> {
return context.apiClient.getShowSeasons(show.externalId);
}
protected getSeasonEpisodes(
season: SeasonWithShow<
ExtractSeasonType<ClientT>,
ExtractShowType<ClientT>
>,
context: ScanContext<ClientT>,
): AsyncIterable<ExtractEpisodeType<ClientT>> {
return context.apiClient.getSeasonEpisodes(
season.show.externalId,
season.externalId,
);
}
protected getFullEpisodeMetadata(
episodeT: ExtractEpisodeType<ClientT>,
context: ScanContext<ClientT>,
): Promise<Result<ExtractEpisodeType<ClientT>, WrappedError>> {
return context.apiClient.getEpisode(
episodeT.externalId,
) as unknown as Promise<Result<ExtractEpisodeType<ClientT>, WrappedError>>;
}
protected getFullTvShowMetadata(
externalId: string,
context: ScanContext<ClientT>,
): Promise<Result<ExtractShowType<ClientT>, WrappedError>> {
return context.apiClient.getShow(externalId) as unknown as Promise<
Result<ExtractShowType<ClientT>, WrappedError>
>;
}
protected getFullTvSeasonMetadata(
externalId: string,
context: ScanContext<ClientT>,
): Promise<Result<ExtractSeasonType<ClientT>, WrappedError>> {
return context.apiClient.getSeason(externalId) as unknown as Promise<
Result<ExtractSeasonType<ClientT>, WrappedError>
>;
}
protected getEntityExternalKey(
show:
| ExtractShowType<ClientT>
| ExtractSeasonType<ClientT>
| ExtractEpisodeType<ClientT>,
): string {
return show.externalId;
}
protected isShowT(
grouping: ProgramGrouping,
): grouping is ExtractShowType<ClientT> {
return (
grouping.sourceType === this.mediaSourceType && grouping.type === 'show'
);
}
protected isSeasonT(
grouping: ProgramGrouping,
): grouping is ExtractSeasonType<ClientT> {
return (
grouping.sourceType === this.mediaSourceType && grouping.type === 'season'
);
}
}

View File

@@ -52,7 +52,7 @@ export type GenericMediaSourceMusicLibraryScanner<
MediaSourceApiClient<ProgramTypeMapForMusic<ArtistT, AlbumT, TrackT>>
>;
type ProgramTypeMapForMusic<
export type ProgramTypeMapForMusic<
ArtistT extends MediaSourceMusicArtist,
AlbumT extends MediaSourceMusicAlbum<ArtistT>,
TrackT extends MediaSourceMusicTrack<ArtistT, AlbumT>,

View File

@@ -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<PlexApiClient>,
): AsyncIterable<PlexArtist> {
return context.apiClient.getMusicLibraryContents(libraryId);
}
protected getAlbums(
show: PlexArtist,
context: ScanContext<PlexApiClient>,
): AsyncIterable<PlexAlbum> {
return context.apiClient.getArtistAlbums(show.externalId);
}
protected getAlbumTracks(
season: PlexAlbum,
context: ScanContext<PlexApiClient>,
): AsyncIterable<PlexTrack> {
return context.apiClient.getAlbumTracks(season.externalId);
}
protected getFullTrackMetadata(
episodeT: PlexTrack,
context: ScanContext<PlexApiClient>,
): Promise<Result<PlexTrack, WrappedError>> {
return context.apiClient.getMusicTrack(episodeT.externalId);
}
protected getApiClient(
mediaSource: MediaSourceWithRelations,
): Promise<PlexApiClient> {
@@ -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<PlexApiClient>,

View File

@@ -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<PlexApiClient>,
): AsyncIterable<PlexShow> {
return context.apiClient.getTvShowLibraryContents(libraryId);
}
protected getTvShowSeasons(
show: PlexShow,
context: ScanContext<PlexApiClient>,
): AsyncIterable<PlexSeason> {
return context.apiClient.getShowSeasons(show.externalId);
}
protected getSeasonEpisodes(
season: SeasonWithShow<PlexSeason, PlexShow>,
context: ScanContext<PlexApiClient>,
): AsyncIterable<PlexEpisode> {
return context.apiClient.getSeasonEpisodes(
season.show.externalId,
season.externalId,
);
}
protected getFullEpisodeMetadata(
episodeT: PlexEpisode,
context: ScanContext<PlexApiClient>,
): Promise<Result<PlexEpisode, WrappedError>> {
return context.apiClient.getEpisode(episodeT.externalId);
}
protected getFullTvShowMetadata(
externalId: string,
context: ScanContext<PlexApiClient>,
): Promise<Result<PlexShow, WrappedError>> {
return context.apiClient.getShow(externalId);
}
protected getFullTvSeasonMetadata(
externalId: string,
context: ScanContext<PlexApiClient>,
): Promise<Result<PlexSeason, WrappedError>> {
return context.apiClient.getSeason(externalId);
}
protected getApiClient(
mediaSource: MediaSourceWithRelations,
): Promise<PlexApiClient> {
@@ -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<PlexApiClient>,
@@ -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';
}
}