mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
refactor: scanner refactoring to strategy pattern
This commit is contained in:
16
server/src/external/MediaSourceApiClient.ts
vendored
16
server/src/external/MediaSourceApiClient.ts
vendored
@@ -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,
|
||||
|
||||
44
server/src/external/emby/EmbyApiClient.ts
vendored
44
server/src/external/emby/EmbyApiClient.ts
vendored
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user