mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
feat: support for syncing / scanning Other Video libraries in Plex/Jellyfin
This commit is contained in:
1
docs/generated/tunarr-v0.23.0-alpha.7-openapi.json
Normal file
1
docs/generated/tunarr-v0.23.0-alpha.7-openapi.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
132
server/src/external/plex/PlexApiClient.ts
vendored
132
server/src/external/plex/PlexApiClient.ts
vendored
@@ -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,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
150
server/src/services/scanner/MediaSourceOtherVideoScanner.ts
Normal file
150
server/src/services/scanner/MediaSourceOtherVideoScanner.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -66,6 +66,9 @@ const KEYS = {
|
||||
'MediaSourceTvShowLibraryScanner',
|
||||
),
|
||||
MediaSourceMusicLibraryScanner: Symbol.for('MediaSourceMusicLibraryScanner'),
|
||||
MediaSourceOtherVideoLibraryScanner: Symbol.for(
|
||||
'MediaSourceOtherVideoLibraryScanner',
|
||||
),
|
||||
MediaSourceLibraryScanner: Symbol.for('MediaSourceLibraryScanner'),
|
||||
|
||||
// Tasks
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user