feat: support for syncing / scanning Other Video libraries in Plex/Jellyfin

This commit is contained in:
Christian Benincasa
2025-09-19 16:06:08 -04:00
parent dd494fc683
commit 1ea6e8a197
21 changed files with 696 additions and 60 deletions

File diff suppressed because one or more lines are too long

View File

@@ -21,7 +21,7 @@ import {
} from '@/util/index.js';
import { LoggerFactory } from '@/util/logging/LoggerFactory.js';
import { seq } from '@tunarr/shared/util';
import type { ProgramGrouping } from '@tunarr/types';
import type { OtherVideo, ProgramGrouping } from '@tunarr/types';
import {
tag,
type Episode,
@@ -229,6 +229,22 @@ function convertProgramSearchResult(
trackNumber: doc.index ?? 0,
}) satisfies MusicTrack,
)
.with(
{
type: 'other_video',
},
(video) =>
({
...video,
...base,
identifiers,
uuid,
originalTitle: null,
year,
releaseDate,
canonicalId: program.canonicalId!,
}) satisfies OtherVideo,
)
.otherwise(() => null);
if (!result) {

View File

@@ -35,6 +35,7 @@ import {
MediaSourceEpisode,
MediaSourceMovie,
MediaSourceMusicTrack,
MediaSourceOtherVideo,
} from '../../types/Media.ts';
import { KEYS } from '../../types/inject.ts';
import { Maybe } from '../../types/util.ts';
@@ -51,6 +52,7 @@ import {
NewEpisodeProgram,
NewMovieProgram,
NewMusicTrack,
NewOtherVideoProgram,
NewProgramVersion,
NewProgramWithExternalIds,
NewProgramWithRelations,
@@ -181,7 +183,9 @@ export class ProgramDaoMinter {
uuid: programId,
sourceType: movie.sourceType,
externalKey: movie.externalKey,
originalAirDate: dayjs(movie.releaseDate)?.format(),
originalAirDate: movie.releaseDate
? dayjs(movie.releaseDate)?.format()
: null,
duration: movie.duration,
// filePath: file?.file ?? null,
externalSourceId: mediaSource.name,
@@ -327,7 +331,9 @@ export class ProgramDaoMinter {
uuid: programId,
sourceType: episode.sourceType,
externalKey: episode.externalKey,
originalAirDate: dayjs(episode.releaseDate).format(),
originalAirDate: episode.releaseDate
? dayjs(episode.releaseDate).format()
: null,
duration: episode.duration,
// filePath: file?.file ?? null,
externalSourceId: mediaSource.name,
@@ -370,7 +376,9 @@ export class ProgramDaoMinter {
uuid: programId,
sourceType: track.sourceType,
externalKey: track.externalKey,
originalAirDate: dayjs(track.releaseDate)?.format(),
originalAirDate: track.releaseDate
? dayjs(track.releaseDate)?.format()
: null,
duration: track.duration,
// filePath: file?.file ?? null,
externalSourceId: mediaSource.name,
@@ -395,6 +403,44 @@ export class ProgramDaoMinter {
};
}
mintOtherVideo(
mediaSource: MediaSource,
mediaLibrary: MediaSourceLibrary,
video: MediaSourceOtherVideo,
): NewProgramWithRelations<'other_video'> {
const programId = v4();
const now = +dayjs();
const newVideo = {
uuid: programId,
sourceType: video.sourceType,
externalKey: video.externalKey,
originalAirDate: video.releaseDate
? dayjs(video.releaseDate)?.format()
: null,
duration: video.duration,
// filePath: file?.file ?? null,
externalSourceId: mediaSource.name,
mediaSourceId: mediaSource.uuid,
libraryId: mediaLibrary.uuid,
// plexRatingKey: plexMovie.ratingKey,
// plexFilePath: file?.key ?? null,
// rating: movie.rating,
// summary: movie.summary,
title: video.title,
type: ProgramType.OtherVideo,
year: video.year,
createdAt: now,
updatedAt: now,
canonicalId: video.canonicalId,
} satisfies NewOtherVideoProgram;
return {
program: newVideo,
externalIds: this.mintExternalIdsNew(programId, video, mediaSource, now),
versions: this.mintVersions(programId, video, now),
};
}
private mintProgramForPlexMovie(
mediaSource: MediaSource,
mediaLibrary: MediaSourceLibrary,

View File

@@ -26,6 +26,7 @@ import { retag, tag } from '@tunarr/types';
import { inject, injectable, interfaces } from 'inversify';
import { Kysely } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/sqlite';
import { MarkRequired } from 'ts-essentials';
import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.ts';
import { MediaSourceLibraryRefresher } from '../services/MediaSourceLibraryRefresher.ts';
import { withLibraries } from './mediaSourceQueryHelpers.ts';
@@ -297,11 +298,24 @@ export class MediaSourceDB {
.execute();
}
if (updates.updatedLibraries.length) {
// TODO;
if (updates.updatedLibraries.length > 0) {
// await tx.updateTable('mediaSourceLibrary').set(({eb}) => {
// return reduce(updates.updatedLibraries, (builder, lib) => {
// builder.when('mediaSourceLibrary.uuid', '=', lib.uuid).then({
// })
// }, eb.case() as unknown as CaseWhenBuilder<DB, 'mediaSourceLibrary', unknown, number>).end()
// }).execute()
for (const update of updates.updatedLibraries) {
await tx
.updateTable('mediaSourceLibrary')
.set(update)
.where('uuid', '=', update.uuid)
.executeTakeFirstOrThrow();
}
}
if (updates.deletedLibraries.length) {
if (updates.deletedLibraries.length > 0) {
await tx
.deleteFrom('mediaSourceLibrary')
.where(
@@ -464,6 +478,6 @@ export class MediaSourceDB {
export type MediaSourceLibrariesUpdate = {
addedLibraries: NewMediaSourceLibrary[];
updatedLibraries: MediaSourceLibraryUpdate[];
updatedLibraries: MarkRequired<MediaSourceLibraryUpdate, 'uuid'>[];
deletedLibraries: MediaSourceLibrary[];
};

View File

@@ -167,6 +167,10 @@ export type NewProgramWithExternalIds = NewProgramDao & {
};
export type NewMovieProgram = SpecificProgramType<'movie', NewProgramDao>;
export type NewOtherVideoProgram = SpecificProgramType<
'other_video',
NewProgramDao
>;
export type NewEpisodeProgram = SpecificProgramType<'episode', NewProgramDao>;

View File

@@ -349,7 +349,7 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
converter: (item: SpecificJellyfinType<ItemTypeT>) => Nullable<OutType>,
extraFields: JellyfinItemFields[] = [],
): Promise<QueryResult<OutType>> {
return this.getItem(itemId, itemType, extraFields).then((result) => {
return this.getRawItem(itemId, itemType, extraFields).then((result) => {
return result.flatMap((item) => {
if (!item) {
return this.makeErrorResult(
@@ -375,26 +375,6 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
});
}
async getItem<ItemTypeT extends JellyfinItemKind>(
itemId: string,
itemType: ItemTypeT | null = null,
extraFields: JellyfinItemFields[] = [],
): Promise<QueryResult<Maybe<ApiJellyfinItem>>> {
const result = await this.getRawItems(
null,
itemType ? [itemType] : null,
['MediaStreams', 'MediaSources', ...extraFields],
{ offset: 0, limit: 1 },
{
ids: [itemId],
},
);
return result.mapPure((data) => {
return find(data.Items, (item) => item.Id === itemId);
});
}
async getItems(
parentId: Nilable<string>,
itemTypes: Nilable<JellyfinItemKind[]> = null,
@@ -429,6 +409,41 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
});
}
async getItem<ItemTypeT extends JellyfinItemKind>(
itemId: string,
itemType: ItemTypeT | null = null,
extraFields: JellyfinItemFields[] = [],
): Promise<QueryResult<Maybe<JellyfinItem>>> {
const item = await this.getRawItem(itemId, itemType, extraFields);
return item.mapPure((data) => {
if (!data) {
return;
}
return this.jelllyfinApiItemInjection(data) ?? undefined;
});
}
async getRawItem<ItemTypeT extends JellyfinItemKind>(
itemId: string,
itemType: ItemTypeT | null = null,
extraFields: JellyfinItemFields[] = [],
): Promise<QueryResult<Maybe<ApiJellyfinItem>>> {
const result = await this.getRawItems(
null,
itemType ? [itemType] : null,
['MediaStreams', 'MediaSources', ...extraFields],
{ offset: 0, limit: 1 },
{
ids: [itemId],
},
);
return result.mapPure((data) => {
return find(data.Items, (item) => item.Id === itemId);
});
}
async getRawItems(
parentId: Nilable<string>,
itemTypes: Nilable<JellyfinItemKind[]> = null,
@@ -541,6 +556,20 @@ export class JellyfinApiClient extends MediaSourceApiClient<JellyfinItemTypes> {
);
}
getOtherVideoLibraryContents(
parentId: string,
pageSize: number = 50,
): AsyncIterable<JellyfinOtherVideo> {
return this.getChildContents(
parentId,
'Video',
(video) => this.jellyfinApiOtherVideoInjection(video),
[],
{},
pageSize,
);
}
getTvShowLibraryContents(
parentId: string,
pageSize: number = 50,

View File

@@ -165,7 +165,7 @@ export class JellyfinItemFinder {
}
// If we can locate the item on JF, there is no problem.
const existingItem = await jfClient.getItem(program.externalKey);
const existingItem = await jfClient.getRawItem(program.externalKey);
if (existingItem.isSuccess() && isDefined(existingItem.get())) {
this.logger.error(
existingItem,

View File

@@ -104,12 +104,14 @@ import type {
PlexEpisode,
PlexItem,
PlexMovie,
PlexOtherVideo,
PlexSeason,
PlexShow,
PlexTrack,
} from '../../types/Media.js';
import { Result } from '../../types/result.ts';
import { parsePlexGuid } from '../../util/externalIds.ts';
import iterators from '../../util/iterator.ts';
import type { ApiClientOptions } from '../BaseApiClient.js';
import { QueryError, type QueryResult } from '../BaseApiClient.js';
import { MediaSourceApiClient } from '../MediaSourceApiClient.ts';
@@ -262,7 +264,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
libraryId: string,
pageSize: number = 50,
): AsyncIterable<PlexMovie> {
return this.getLibraryContents(
return this.iterateChildItems(
libraryId,
PlexMovieMediaContainerResponseSchema,
(movie, library) => this.plexMovieInjection(movie, library),
@@ -274,7 +276,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
libraryId: string,
pageSize: number = 50,
): AsyncGenerator<PlexShow> {
return this.getLibraryContents(
return this.iterateChildItems(
libraryId,
MakePlexMediaContainerResponseSchema(PlexTvShowSchema),
(show, library) => this.plexShowInjection(show, library),
@@ -283,7 +285,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
}
getShowSeasons(tvShowKey: string, pageSize: number = 50) {
return this.getLibraryContents(
return this.iterateChildItems(
tvShowKey,
MakePlexMediaContainerResponseSchema(PlexTvSeasonSchema),
(season, library) => this.plexSeasonInjection(season, library),
@@ -296,7 +298,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
tvSeasonKey: string,
pageSize: number = 50,
): AsyncIterable<PlexEpisode> {
return this.getLibraryContents(
return this.iterateChildItems(
tvSeasonKey,
MakePlexMediaContainerResponseSchema(PlexEpisodeSchema),
(ep, library) => this.plexEpisodeInjection(ep, library),
@@ -309,7 +311,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
libraryId: string,
pageSize: number = 50,
): AsyncIterable<PlexArtist> {
return this.getLibraryContents(
return this.iterateChildItems(
libraryId,
MakePlexMediaContainerResponseSchema(PlexMusicArtistSchema),
(artist, library) => this.plexMusicArtistInjection(artist, library),
@@ -321,7 +323,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
artistKey: string,
pageSize: number = 50,
): AsyncIterable<PlexAlbum> {
return this.getLibraryContents(
return this.iterateChildItems(
artistKey,
MakePlexMediaContainerResponseSchema(PlexMusicAlbumSchema),
(album, library) => this.plexAlbumInjection(album, library),
@@ -334,7 +336,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
albumKey: string,
pageSize: number = 50,
): AsyncIterable<PlexTrack> {
return this.getLibraryContents(
return this.iterateChildItems(
albumKey,
MakePlexMediaContainerResponseSchema(PlexMusicTrackSchema),
(track, library) => this.plexTrackInjection(track, library),
@@ -356,7 +358,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
);
}
private async *getLibraryContents<
private async *iterateChildItems<
OutType,
ItemType extends PlexMediaNoCollectionOrPlaylist,
>(
@@ -625,6 +627,22 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
);
}
async getVideo(key: string): Promise<QueryResult<PlexOtherVideo>> {
return this.getMediaOfType(
key,
PlexMediaNoCollectionPlaylistResponse,
(video, library) => {
if (video.type !== 'movie' || video.subtype !== 'clip') {
return this.makeErrorResult(
'generic_request_error',
`Got unexpected Plex item of type ${video.type} (subtype = ${video.type === 'movie' ? video.subtype : 'none'})`,
);
}
return this.plexOtherVideoInjection(video, library);
},
);
}
async getShow(externalKey: string): Promise<QueryResult<PlexShow>> {
return this.getMediaOfType(
externalKey,
@@ -766,8 +784,6 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
size: items.length,
};
});
// return result.map((response) => this.setCanonicalIdOnResponse(response));
}
async getItemChildren(
@@ -821,6 +837,22 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
});
}
getOtherVideosLibraryContents(
parentId: string,
): AsyncIterable<PlexOtherVideo> {
const generator = this.iterateChildItems(
parentId,
PlexMediaNoCollectionPlaylistResponse,
(item, lib) => {
if (item.type !== 'movie' || item.subtype !== 'clip') {
return Result.success(null);
}
return this.plexOtherVideoInjection(item, lib);
},
);
return iterators.compact(generator);
}
private convertPlexResponse(
item: z.infer<typeof PlexMediaNoCollectionPlaylist>,
externalLibraryId: Maybe<string>,
@@ -1446,6 +1478,86 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
});
}
private plexOtherVideoInjection(
plexClip: ApiPlexMovie,
mediaLibrary: MediaSourceLibrary,
): Result<PlexOtherVideo> {
if (isNil(plexClip.duration) || plexClip.duration <= 0) {
return Result.forError(
new Error(
`Plex movie ID = ${plexClip.ratingKey} has invalid duration.`,
),
);
}
if (isNil(plexClip.Media) || isEmpty(plexClip.Media)) {
return Result.forError(
new Error(`Plex movie ID = ${plexClip.ratingKey} has no Media streams`),
);
}
const actors =
plexClip.Role?.map(({ tag, role }) => ({ name: tag, role })) ?? [];
const directors =
plexClip.Director?.map(({ tag }) => ({ name: tag })) ?? [];
const writers = plexClip.Writer?.map(({ tag }) => ({ name: tag })) ?? [];
const studios = isNonEmptyString(plexClip.studio)
? [{ name: plexClip.studio }]
: [];
return Result.success({
uuid: v4(),
type: ProgramType.OtherVideo,
canonicalId: this.canonicalizer.getCanonicalId(plexClip),
mediaSourceId: this.options.mediaSource.uuid,
libraryId: mediaLibrary.uuid,
externalLibraryId: mediaLibrary.externalKey,
sourceType: MediaSourceType.Plex,
externalKey: plexClip.ratingKey,
title: plexClip.title,
originalTitle: null,
year: plexClip.year ?? null,
releaseDate: plexClip.originallyAvailableAt
? +dayjs(plexClip.originallyAvailableAt, 'YYYY-MM-DD')
: null,
releaseDateString: plexClip.originallyAvailableAt ?? null,
mediaItem: plexMediaStreamsInject(plexClip.ratingKey, plexClip).getOrElse(
() => emptyMediaItem(plexClip),
),
duration: plexClip.duration,
actors,
directors,
writers,
studios,
genres: plexClip.Genre?.map(({ tag }) => ({ name: tag })) ?? [],
summary: plexClip.summary ?? null,
plot: null,
tagline: plexClip.tagline ?? null,
rating: plexClip.contentRating ?? null,
tags: [],
externalId: plexClip.ratingKey,
identifiers: [
{
id: plexClip.ratingKey,
type: 'plex',
sourceId: this.options.mediaSource.uuid,
},
{
id: plexClip.guid,
type: 'plex-guid',
},
...seq.collect(plexClip.Guid, (guid) => {
const parsed = parsePlexGuid(guid.id);
if (!parsed) return;
return {
id: parsed.externalKey,
type: parsed.sourceType,
};
}),
],
});
}
private plexMusicArtistInjection(
plexArtist: ApiPlexMusicArtist,
mediaLibrary: MediaSourceLibrary,

View File

@@ -7,6 +7,7 @@ import { v4 } from 'uuid';
import { MediaSourceDB } from '../db/mediaSourceDB.js';
import type {
MediaLibraryType,
MediaSourceLibraryUpdate,
NewMediaSourceLibrary,
} from '../db/schema/MediaSource.ts';
import { MediaSourceId } from '../db/schema/base.ts';
@@ -14,7 +15,7 @@ import type { MediaSourceWithLibraries } from '../db/schema/derivedTypes.js';
import { MediaSourceApiFactory } from '../external/MediaSourceApiFactory.js';
import { KEYS } from '../types/inject.ts';
import { Maybe } from '../types/util.ts';
import { isDefined } from '../util/index.ts';
import { groupByUniq, isDefined } from '../util/index.ts';
import { Logger } from '../util/logging/LoggerFactory.ts';
import { booleanToNumber } from '../util/sqliteUtil.ts';
@@ -86,16 +87,17 @@ export class MediaSourceLibraryRefresher {
const plexLibraries = plexLibrariesResult
.get()
.MediaContainer.Directory.filter((lib) =>
isDefined(this.plexLibraryTypeToTunarrType(lib.type)),
isDefined(this.plexLibraryTypeToTunarrType(lib)),
);
const plexLibraryKeys = new Set(plexLibraries.map((lib) => lib.key));
const existingLibraries = new Set(
mediaSource.libraries.map((lib) => lib.externalKey),
);
const incomingLibrariesById = groupByUniq(plexLibraries, (lib) => lib.key);
const newLibraries = plexLibraryKeys.difference(existingLibraries);
const removedLibraries = existingLibraries.difference(plexLibraryKeys);
// const updatedLibraries = plexLibraryKeys.intersection(existingLibraries);
const updatedLibraries = plexLibraryKeys.intersection(existingLibraries);
const librariesToAdd: NewMediaSourceLibrary[] = [];
for (const newLibraryKey of newLibraries) {
@@ -111,7 +113,7 @@ export class MediaSourceLibraryRefresher {
mediaSourceId: mediaSource.uuid,
externalKey: plexLibrary.key,
// Checked above
mediaType: this.plexLibraryTypeToTunarrType(plexLibrary.type)!,
mediaType: this.plexLibraryTypeToTunarrType(plexLibrary)!,
uuid: v4(),
enabled: booleanToNumber(false),
name: plexLibrary.title,
@@ -122,12 +124,19 @@ export class MediaSourceLibraryRefresher {
removedLibraries.has(existing.externalKey),
);
// nothing really to update yet
// const librariesToUpdate = mediaSource.libraries.filter(existing => updatedLibraries.has(existing.externalKey)).map(existing => {
// return {
// } satisfies MediaSourceLibraryUpdate
// })
const librariesToUpdate = mediaSource.libraries
.filter((existing) => updatedLibraries.has(existing.externalKey))
.map((existing) => {
const updatedApiLibrary = incomingLibrariesById[existing.externalKey];
return {
externalKey: existing.externalKey,
name: updatedApiLibrary?.title ?? existing.name,
mediaType: updatedApiLibrary
? this.plexLibraryTypeToTunarrType(updatedApiLibrary)
: existing.mediaType,
uuid: existing.uuid,
} satisfies MediaSourceLibraryUpdate;
});
this.logger.debug(
'Found %d new Plex libraries, %d removed libraries for media source %s',
@@ -139,16 +148,17 @@ export class MediaSourceLibraryRefresher {
await this.mediaSourceDB.updateLibraries({
addedLibraries: librariesToAdd,
deletedLibraries: librariesToRemove,
updatedLibraries: [],
updatedLibraries: librariesToUpdate,
});
}
private plexLibraryTypeToTunarrType(
plexLibraryType: PlexLibrarySection['type'],
plexLibrary: PlexLibrarySection,
): Maybe<MediaLibraryType> {
switch (plexLibraryType) {
switch (plexLibrary.type) {
case 'movie':
return 'movies';
// Other video plex libraries have type=movie but a tv.plex.agents.none agent, AFAICT.
return plexLibrary.agent.includes('none') ? 'other_videos' : 'movies';
case 'show':
return 'shows';
case 'artist':

View File

@@ -42,6 +42,7 @@ import {
MusicArtist,
MusicTrack,
MusicTrackWithAncestors,
OtherVideo,
Season,
SeasonWithShow,
Show,
@@ -581,6 +582,19 @@ export class MeilisearchService implements ISearchService {
);
}
async indexOtherVideo(programs: (OtherVideo & HasMediaSourceAndLibraryId)[]) {
if (isEmpty(programs)) {
return;
}
await this.client()
.index<ProgramSearchDocument>(ProgramsIndex.name)
.addDocumentsInBatches(
programs.map((p) => this.convertProgramToSearchDocument(p)),
100,
);
}
async indexShow(show: Show & HasMediaSourceAndLibraryId) {
const externalIds = show.identifiers.map((eid) => ({
id: eid.id,
@@ -1056,7 +1070,7 @@ export class MeilisearchService implements ISearchService {
}
private convertProgramToSearchDocument<
ProgramT extends (Movie | Episode | MusicTrack) &
ProgramT extends (Movie | Episode | MusicTrack | OtherVideo) &
HasMediaSourceAndLibraryId,
>(
program: ProgramT,
@@ -1109,6 +1123,7 @@ export class MeilisearchService implements ISearchService {
summary = program.summary;
break;
case 'track':
case 'other_video':
summary = null;
break;
}
@@ -1122,6 +1137,7 @@ export class MeilisearchService implements ISearchService {
rating = program.season?.show?.rating ?? null;
break;
case 'track':
case 'other_video':
rating = null;
break;
}

View File

@@ -8,8 +8,12 @@ import type { Canonicalizer } from './Canonicalizer.ts';
import { EmbyItemCanonicalizer } from './EmbyItemCanonicalizer.ts';
import { JellyfinItemCanonicalizer } from './JellyfinItemCanonicalizer.ts';
import { PlexMediaCanonicalizer } from './PlexMediaCanonicalizers.ts';
import { EmbyMediaSourceMovieScanner } from './scanner/EmbyMediaSourceMovieScanner.ts';
import { EmbyMediaSourceMusicScanner } from './scanner/EmbyMediaSourceMusicScanner.ts';
import { EmbyMediaSourceTvShowScanner } from './scanner/EmbyMediaSourceTvShowScanner.ts';
import { JellyfinMediaSourceMovieScanner } from './scanner/JellyfinMediaSourceMovieScanner.ts';
import { JellyfinMediaSourceMusicScanner } from './scanner/JellyfinMediaSourceMusicScanner.ts';
import { JellyfinMediaSourceOtherVideoScanner } from './scanner/JellyfinMediaSourceOtherVideoScanner.ts';
import { JellyfinMediaSourceTvShowScanner } from './scanner/JellyfinMediaSourceTvShowScanner.ts';
import type { GenericMediaSourceMovieLibraryScanner } from './scanner/MediaSourceMovieLibraryScanner.ts';
import type { GenericMediaSourceMusicLibraryScanner } from './scanner/MediaSourceMusicArtistScanner.ts';
@@ -22,6 +26,7 @@ import type {
import type { GenericMediaSourceTvShowLibraryScanner } from './scanner/MediaSourceTvShowLibraryScanner.ts';
import { PlexMediaSourceMovieScanner } from './scanner/PlexMediaSourceMovieScanner.ts';
import { PlexMediaSourceMusicScanner } from './scanner/PlexMediaSourceMusicScanner.ts';
import { PlexMediaSourceOtherVideoScanner } from './scanner/PlexMediaSourceOtherVideoScanner.ts';
import { PlexMediaSourceTvShowScanner } from './scanner/PlexMediaSourceTvShowScanner.ts';
export const ServicesModule = new ContainerModule((bind) => {
@@ -41,6 +46,9 @@ export const ServicesModule = new ContainerModule((bind) => {
bind<JellyfinMediaSourceMovieScanner>(KEYS.MediaSourceMovieLibraryScanner)
.to(JellyfinMediaSourceMovieScanner)
.whenTargetNamed(MediaSourceType.Jellyfin);
bind<EmbyMediaSourceMovieScanner>(KEYS.MediaSourceMovieLibraryScanner)
.to(EmbyMediaSourceMovieScanner)
.whenTargetNamed(MediaSourceType.Emby);
bind<PlexMediaSourceTvShowScanner>(KEYS.MediaSourceTvShowLibraryScanner)
.to(PlexMediaSourceTvShowScanner)
@@ -48,6 +56,9 @@ export const ServicesModule = new ContainerModule((bind) => {
bind<JellyfinMediaSourceTvShowScanner>(KEYS.MediaSourceTvShowLibraryScanner)
.to(JellyfinMediaSourceTvShowScanner)
.whenTargetNamed(MediaSourceType.Jellyfin);
bind<EmbyMediaSourceTvShowScanner>(KEYS.MediaSourceTvShowLibraryScanner)
.to(EmbyMediaSourceTvShowScanner)
.whenTargetNamed(MediaSourceType.Emby);
bind<PlexMediaSourceMusicScanner>(KEYS.MediaSourceMusicLibraryScanner)
.to(PlexMediaSourceMusicScanner)
@@ -55,6 +66,20 @@ export const ServicesModule = new ContainerModule((bind) => {
bind<JellyfinMediaSourceMusicScanner>(KEYS.MediaSourceMusicLibraryScanner)
.to(JellyfinMediaSourceMusicScanner)
.whenTargetNamed(MediaSourceType.Jellyfin);
bind<EmbyMediaSourceMusicScanner>(KEYS.MediaSourceMusicLibraryScanner)
.to(EmbyMediaSourceMusicScanner)
.whenTargetNamed(MediaSourceType.Emby);
bind<PlexMediaSourceOtherVideoScanner>(
KEYS.MediaSourceOtherVideoLibraryScanner,
)
.to(PlexMediaSourceOtherVideoScanner)
.whenTargetNamed(MediaSourceType.Plex);
bind<JellyfinMediaSourceOtherVideoScanner>(
KEYS.MediaSourceOtherVideoLibraryScanner,
)
.to(JellyfinMediaSourceOtherVideoScanner)
.whenTargetNamed(MediaSourceType.Jellyfin);
bind<GenericMediaSourceScannerFactory>(
KEYS.MediaSourceLibraryScanner,
@@ -78,8 +103,12 @@ export const ServicesModule = new ContainerModule((bind) => {
KEYS.MediaSourceMusicLibraryScanner,
sourceType,
);
case 'music_videos':
case 'other_videos':
return ctx.container.getNamed<GenericMediaSourceMovieLibraryScanner>(
KEYS.MediaSourceOtherVideoLibraryScanner,
sourceType,
);
case 'music_videos':
throw new Error('No binding set for library type ' + libraryType);
}
});

View File

@@ -0,0 +1,105 @@
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 { MediaSourceWithLibraries } from '../../db/schema/derivedTypes.ts';
import { MediaSourceType } from '../../db/schema/MediaSource.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 { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import type { ScanContext } from './MediaSourceScanner.ts';
@injectable()
export class JellyfinMediaSourceOtherVideoScanner extends MediaSourceOtherVideoScanner<
JellyfinT,
JellyfinApiClient,
JellyfinOtherVideo
> {
readonly type = 'other_videos';
readonly mediaSourceType = MediaSourceType.Jellyfin;
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 getVideos(
libraryId: string,
context: ScanContext<JellyfinApiClient>,
): AsyncIterable<JellyfinOtherVideo> {
return context.apiClient.getOtherVideoLibraryContents(libraryId);
}
protected getApiClient(
mediaSource: MediaSourceWithLibraries,
): Promise<JellyfinApiClient> {
return this.mediaSourceApiFactory.getJellyfinApiClientForMediaSource(
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.externalKey}`,
),
);
} 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

@@ -0,0 +1,150 @@
import { head, round } from 'lodash-es';
import type { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts';
import type { IProgramDB } from '../../db/interfaces/IProgramDB.ts';
import type { MediaSourceDB } from '../../db/mediaSourceDB.ts';
import type { MediaSourceType } from '../../db/schema/MediaSource.ts';
import { ProgramType } from '../../db/schema/Program.ts';
import type { MediaSourceApiClient } from '../../external/MediaSourceApiClient.ts';
import type { HasMediaSourceInfo, OtherVideo } from '../../types/Media.ts';
import { Result } from '../../types/result.ts';
import { wait } from '../../util/index.ts';
import type { Logger } from '../../util/logging/LoggerFactory.ts';
import type { MeilisearchService } from '../MeilisearchService.ts';
import type { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import type { ScanContext } from './MediaSourceScanner.ts';
import { MediaSourceScanner } from './MediaSourceScanner.ts';
export abstract class MediaSourceOtherVideoScanner<
MediaSourceTypeT extends MediaSourceType,
ApiClientTypeT extends MediaSourceApiClient,
OtherVideoTypeT extends OtherVideo,
> extends MediaSourceScanner<'other_videos', MediaSourceTypeT, ApiClientTypeT> {
constructor(
logger: Logger,
mediaSourceDB: MediaSourceDB,
protected programDB: IProgramDB,
private searchService: MeilisearchService,
private mediaSourceProgressService: MediaSourceProgressService,
protected programMinter: ProgramDaoMinter,
) {
super(logger, mediaSourceDB);
}
protected async scanInternal(
context: ScanContext<ApiClientTypeT>,
): Promise<void> {
this.mediaSourceProgressService.scanStarted(context.library.uuid);
const { library, mediaSource, force } = context;
const existingPrograms =
await this.programDB.getProgramCanonicalIdsForMediaSource(
library.uuid,
ProgramType.OtherVideo,
);
const seenVideos = new Set<string>();
try {
const totalSize = await this.getLibrarySize(library.externalKey, context);
for await (const video of this.getVideos(library.externalKey, context)) {
if (this.state(library.uuid) === 'canceled') {
return;
}
const canonicalId = video.canonicalId;
const externalKey = this.getExternalKey(video);
seenVideos.add(externalKey);
const processedAmount = round(seenVideos.size / totalSize, 2) * 100.0;
this.mediaSourceProgressService.scanProgress(
library.uuid,
processedAmount,
);
if (
!force &&
existingPrograms[externalKey] &&
existingPrograms[externalKey].canonicalId === canonicalId
) {
this.logger.debug(
'Found an unchanged program: rating key = %s, program ID = %s',
externalKey,
existingPrograms[externalKey].uuid,
);
continue;
}
const result = await this.scanVideo(context, video).then((result) =>
result.flatMapAsync((fullVideo) => {
return Result.attemptAsync(async () => {
const minted = this.programMinter.mintOtherVideo(
mediaSource,
library,
fullVideo,
);
const upsertResult = await this.programDB.upsertPrograms([
minted,
]);
return [fullVideo, upsertResult] as const;
});
}),
);
if (result.isFailure()) {
this.logger.warn(
result.error,
'Error while processing video (%O)',
video,
);
continue;
}
const [fullApiVideo, upsertedDbVideos] = result.get();
const dbVideo = head(upsertedDbVideos);
if (dbVideo) {
this.logger.debug(
'Upserted video %s (ID = %s)',
dbVideo?.title,
dbVideo?.uuid,
);
await this.searchService.indexOtherVideo([
{
...fullApiVideo,
uuid: dbVideo.uuid,
mediaSourceId: mediaSource.uuid,
libraryId: library.uuid,
},
]);
} else {
this.logger.warn('No upserted video');
}
await wait();
}
this.logger.debug('Completed scanning library %s', context.library.uuid);
} finally {
this.mediaSourceProgressService.scanEnded(context.library.uuid);
}
}
protected abstract getVideos(
libraryId: string,
context: ScanContext<ApiClientTypeT>,
): AsyncIterable<OtherVideoTypeT>;
protected abstract scanVideo(
context: ScanContext<ApiClientTypeT>,
incomingVideo: OtherVideoTypeT,
): Promise<Result<OtherVideoTypeT & HasMediaSourceInfo>>;
protected abstract getExternalKey(video: OtherVideoTypeT): string;
}

View File

@@ -0,0 +1,85 @@
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 { MediaSourceWithLibraries } from '../../db/schema/derivedTypes.ts';
import { MediaSourceType } from '../../db/schema/MediaSource.ts';
import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.ts';
import type { PlexApiClient } from '../../external/plex/PlexApiClient.ts';
import { WrappedError } from '../../types/errors.ts';
import { KEYS } from '../../types/inject.ts';
import type { PlexT } from '../../types/internal.ts';
import type { PlexOtherVideo } 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 { MediaSourceProgressService } from './MediaSourceProgressService.ts';
import type { ScanContext } from './MediaSourceScanner.ts';
@injectable()
export class PlexMediaSourceOtherVideoScanner extends MediaSourceOtherVideoScanner<
PlexT,
PlexApiClient,
PlexOtherVideo
> {
readonly type = 'other_videos';
readonly mediaSourceType = MediaSourceType.Plex;
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 getVideos(
libraryId: string,
context: ScanContext<PlexApiClient>,
): AsyncIterable<PlexOtherVideo> {
return context.apiClient.getOtherVideosLibraryContents(libraryId);
}
protected getApiClient(
mediaSource: MediaSourceWithLibraries,
): Promise<PlexApiClient> {
return this.mediaSourceApiFactory.getPlexApiClientForMediaSource(
mediaSource,
);
}
protected getLibrarySize(
libraryKey: string,
context: ScanContext<PlexApiClient>,
): Promise<number> {
return context.apiClient
.getLibraryCount(libraryKey)
.then((_) => _.getOrThrow());
}
protected scanVideo(
context: ScanContext<PlexApiClient>,
incomingVideo: PlexOtherVideo,
): Promise<Result<PlexOtherVideo, WrappedError>> {
return context.apiClient.getVideo(incomingVideo.externalId);
}
protected getExternalKey(video: PlexOtherVideo): string {
return video.externalId;
}
}

View File

@@ -87,7 +87,7 @@ export class JellyfinStreamDetails extends ExternalStreamDetailsFetcher<Jellyfin
mediaSource,
);
const itemMetadataResult = await this.jellyfin.getItem(item.externalKey);
const itemMetadataResult = await this.jellyfin.getRawItem(item.externalKey);
if (itemMetadataResult.isFailure()) {
this.logger.error(

View File

@@ -70,7 +70,7 @@ export class SaveJellyfinProgramExternalIdsTask extends Task {
return;
}
const metadataResult = await api.getItem(chosenId.externalKey);
const metadataResult = await api.getRawItem(chosenId.externalKey);
if (metadataResult.isFailure()) {
this.logger.error(

View File

@@ -295,6 +295,8 @@ export type MediaSourceMusicTrack<
TrackT extends MusicTrack<ArtistT, AlbumT> = MusicTrack<ArtistT, AlbumT>,
> = TrackT & HasMediaSourceInfo;
export type MediaSourceOtherVideo = OtherVideo & HasMediaSourceInfo;
type PlexMixin = HasMediaSourceInfo & {
sourceType: typeof MediaSourceType.Plex;
};
@@ -306,6 +308,7 @@ export type PlexEpisode = Episode<PlexShow, PlexSeason> & PlexMixin;
export type PlexArtist = MusicArtist & PlexMixin;
export type PlexAlbum = MusicAlbum<PlexArtist> & PlexMixin;
export type PlexTrack = MusicTrack<PlexArtist, PlexAlbum> & PlexMixin;
export type PlexOtherVideo = OtherVideo & PlexMixin;
export type PlexItem =
| PlexMovie
@@ -314,7 +317,8 @@ export type PlexItem =
| PlexEpisode
| PlexArtist
| PlexAlbum
| PlexTrack;
| PlexTrack
| PlexOtherVideo;
interface JellyfinMixin extends HasMediaSourceInfo {
sourceType: typeof MediaSourceType.Jellyfin;

View File

@@ -66,6 +66,9 @@ const KEYS = {
'MediaSourceTvShowLibraryScanner',
),
MediaSourceMusicLibraryScanner: Symbol.for('MediaSourceMusicLibraryScanner'),
MediaSourceOtherVideoLibraryScanner: Symbol.for(
'MediaSourceOtherVideoLibraryScanner',
),
MediaSourceLibraryScanner: Symbol.for('MediaSourceLibraryScanner'),
// Tasks

View File

@@ -1,3 +1,5 @@
import type { Nilable } from '../types/util.ts';
async function* map<T, U>(
it: AsyncIterable<T>,
fn: (i: T) => U,
@@ -16,6 +18,14 @@ async function* flatMap<T, U>(
}
}
async function* compact<T>(it: AsyncIterable<Nilable<T>>): AsyncIterable<T> {
for await (const i of it) {
if (i) {
yield i;
}
}
}
async function* take<T>(it: AsyncIterable<T>, n: number) {
let idx = 0;
for await (const i of it) {
@@ -71,6 +81,7 @@ const iterators = {
flatMap,
take,
chain,
compact,
};
export default iterators;

File diff suppressed because one or more lines are too long

View File

@@ -375,6 +375,7 @@ export const PlexMovieSchema = BasePlexMediaSchema.extend({
editionTitle: z.string().optional(),
studio: z.string().optional(),
type: z.literal('movie'),
subtype: z.string().optional(),
title: z.string(),
titleSort: z.string().optional(),
contentRating: z.string().optional(),