mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
Merge remote-tracking branch 'origin/main' into dev
This commit is contained in:
56
.claude/commands/resolve-drizzle-migrations.md
Normal file
56
.claude/commands/resolve-drizzle-migrations.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
allowed-tools: Bash(git show:*), Bash(git checkout *:*), Bash(git status:*), Bash(git diff:*), Bash(git log:*), Bash(cd server && pnpm drizzle-kit generate:*), Bash(rm server/src/migration:*), Bash(ls:*), Bash(python3:*)
|
||||
description: Resolve Drizzle migration conflicts by accepting upstream and regenerating local migrations
|
||||
argument-hint: Upstream branch (default: main)
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
- Current branch: !`git branch --show-current`
|
||||
- Merge state: !`git status --short server/src/migration/db/sql/`
|
||||
|
||||
## Your task
|
||||
|
||||
Resolve Drizzle ORM migration conflicts between the current branch and an upstream branch.
|
||||
The upstream branch's migrations keep their original indices. The current branch's schema
|
||||
changes are collapsed into a single freshly-generated migration at the next available index.
|
||||
|
||||
**Upstream branch:** `$ARGUMENTS` (default to `main` if empty).
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Get both journals.** Read the upstream journal and the current branch's journal:
|
||||
- Upstream: `git show <upstream>:server/src/migration/db/sql/meta/_journal.json`
|
||||
- Ours: if mid-merge and the file is conflicted, use `git show HEAD:server/src/migration/db/sql/meta/_journal.json`. Otherwise read it from the working tree.
|
||||
|
||||
2. **Find the divergence point.** Walk both entry lists and find the last entry where `tag` matches at the same `idx`. Everything after that is either upstream-only or ours-only.
|
||||
|
||||
3. **Identify our branch-only migrations.** These are entries in our journal whose `tag` does not appear in the upstream journal. Record each one's:
|
||||
- SQL file: `server/src/migration/db/sql/<tag>.sql`
|
||||
- Snapshot: `server/src/migration/db/sql/meta/<idx>_snapshot.json`
|
||||
|
||||
4. **Accept upstream's migration state.** For each upstream-only entry (past the shared base), plus the journal itself:
|
||||
```
|
||||
git checkout <upstream> -- server/src/migration/db/sql/<tag>.sql
|
||||
git checkout <upstream> -- server/src/migration/db/sql/meta/<idx>_snapshot.json
|
||||
git checkout <upstream> -- server/src/migration/db/sql/meta/_journal.json
|
||||
```
|
||||
|
||||
5. **Remove our branch-only artifacts.** Delete the SQL and snapshot files identified in step 3:
|
||||
```
|
||||
rm server/src/migration/db/sql/<tag>.sql
|
||||
rm server/src/migration/db/sql/meta/<idx>_snapshot.json
|
||||
```
|
||||
|
||||
6. **Regenerate.** Run `cd server && pnpm drizzle-kit generate`. This diffs the current schema TypeScript against upstream's latest snapshot and produces a single correct migration.
|
||||
|
||||
7. **Verify.**
|
||||
- Read the updated `_journal.json` — confirm the new entry follows upstream's last entry.
|
||||
- Spot-check that the new snapshot's `prevId` matches the last upstream snapshot's `id`.
|
||||
- List the SQL directory and confirm no duplicate-numbered files or orphans.
|
||||
|
||||
8. **Report.** Summarize:
|
||||
- Migrations accepted from upstream (tags)
|
||||
- Migrations removed from our branch (tags)
|
||||
- New migration generated (index, tag, file path)
|
||||
- Remind the user to review the generated SQL before committing.
|
||||
414
server/src/external/plex/PlexApiClient.ts
vendored
414
server/src/external/plex/PlexApiClient.ts
vendored
@@ -41,6 +41,7 @@ import type {
|
||||
PlexMediaContainerResponse,
|
||||
PlexMediaNoCollectionOrPlaylist,
|
||||
PlexMediaNoCollectionPlaylist,
|
||||
PlexMediaSubtitleStream,
|
||||
PlexMediaVideoStream,
|
||||
PlexTerminalMedia,
|
||||
} from '@tunarr/types/plex';
|
||||
@@ -1356,7 +1357,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
||||
directors: plexDirectorInject(plexEpisode.Director),
|
||||
writers: plexWriterInject(plexEpisode.Writer),
|
||||
episodeNumber: plexEpisode.index ?? 0,
|
||||
mediaItem: plexMediaStreamsInject(
|
||||
mediaItem: this.plexMediaStreamsInject(
|
||||
plexEpisode.ratingKey,
|
||||
plexEpisode,
|
||||
).getOrElse(() => emptyMediaItem(plexEpisode)),
|
||||
@@ -1528,7 +1529,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
||||
year: plexMovie.year ?? releaseDate?.year() ?? null,
|
||||
releaseDate: releaseDate?.valueOf() ?? null,
|
||||
releaseDateString: releaseDate?.format() ?? null,
|
||||
mediaItem: plexMediaStreamsInject(
|
||||
mediaItem: this.plexMediaStreamsInject(
|
||||
plexMovie.ratingKey,
|
||||
plexMovie,
|
||||
).getOrElse(() => emptyMediaItem(plexMovie)),
|
||||
@@ -1610,9 +1611,10 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
||||
year: plexClip.year ?? releaseDate?.year() ?? null,
|
||||
releaseDate: releaseDate?.valueOf() ?? null,
|
||||
releaseDateString: releaseDate?.format() ?? null,
|
||||
mediaItem: plexMediaStreamsInject(plexClip.ratingKey, plexClip).getOrElse(
|
||||
() => emptyMediaItem(plexClip),
|
||||
),
|
||||
mediaItem: this.plexMediaStreamsInject(
|
||||
plexClip.ratingKey,
|
||||
plexClip,
|
||||
).getOrElse(() => emptyMediaItem(plexClip)),
|
||||
duration: plexClip.duration,
|
||||
actors: plexActorInject(plexClip.Role),
|
||||
directors: plexDirectorInject(plexClip.Director),
|
||||
@@ -1824,7 +1826,7 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
||||
writers: [],
|
||||
genres: [],
|
||||
trackNumber: plexTrack.index ?? 0,
|
||||
mediaItem: plexMediaStreamsInject(
|
||||
mediaItem: this.plexMediaStreamsInject(
|
||||
plexTrack.ratingKey,
|
||||
plexTrack,
|
||||
).getOrElse(() => emptyMediaItem(plexTrack)),
|
||||
@@ -1957,6 +1959,227 @@ export class PlexApiClient extends MediaSourceApiClient<PlexTypes> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
plexMediaStreamsInject(
|
||||
itemId: string,
|
||||
plexItem: PlexTerminalMedia,
|
||||
requireVideoStream: boolean = true,
|
||||
): Result<MediaItem> {
|
||||
const plexMedia = plexItem.Media;
|
||||
if (isNil(plexMedia) || isEmpty(plexMedia)) {
|
||||
return Result.forError(
|
||||
new Error(`Plex item ID = ${itemId} has no Media streams`),
|
||||
);
|
||||
}
|
||||
|
||||
const relevantMedia = maxBy(
|
||||
filter(
|
||||
plexMedia,
|
||||
(m) => (m.duration ?? 0) >= 0 && (m.Part?.length ?? 0) > 0,
|
||||
),
|
||||
(m) => m.id,
|
||||
);
|
||||
|
||||
if (!relevantMedia) {
|
||||
return Result.forError(
|
||||
new Error(
|
||||
`No Media items on Plex item ID = ${itemId} meet the necessary criteria.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const relevantMediaPart = first(relevantMedia?.Part);
|
||||
const apiMediaStreams = relevantMediaPart?.Stream;
|
||||
|
||||
if (!relevantMediaPart || isEmpty(apiMediaStreams)) {
|
||||
return Result.forError(
|
||||
new Error(`Could not extract a stream for Plex item ID ${itemId}`),
|
||||
);
|
||||
}
|
||||
|
||||
const videoStream = find(
|
||||
apiMediaStreams,
|
||||
(stream): stream is PlexMediaVideoStream => stream.streamType === 1,
|
||||
);
|
||||
|
||||
if (requireVideoStream && !videoStream) {
|
||||
return Result.forError(
|
||||
new Error(`Plex item ID = ${itemId} has no video streams`),
|
||||
);
|
||||
}
|
||||
|
||||
const streams: MediaStream[] = [];
|
||||
if (videoStream) {
|
||||
const videoDetails = {
|
||||
streamType: 'video',
|
||||
codec: videoStream.codec,
|
||||
bitDepth: videoStream.bitDepth ?? 8,
|
||||
languageCodeISO6392: videoStream.languageCode,
|
||||
default: videoStream.default,
|
||||
profile: videoStream.profile?.toLowerCase() ?? '',
|
||||
index: videoStream.index,
|
||||
frameRate: videoStream.frameRate,
|
||||
colorPrimaries: videoStream.colorPrimaries,
|
||||
colorRange: videoStream.colorRange,
|
||||
colorSpace: videoStream.colorSpace,
|
||||
colorTransfer: videoStream.colorTrc,
|
||||
} satisfies MediaStream;
|
||||
streams.push(videoDetails);
|
||||
}
|
||||
|
||||
streams.push(
|
||||
...map(
|
||||
sortBy(
|
||||
filter(apiMediaStreams, (stream): stream is PlexMediaAudioStream => {
|
||||
return stream.streamType === 2 && !isNil(stream.index);
|
||||
}),
|
||||
(stream) => [
|
||||
stream.selected ? -1 : 0,
|
||||
stream.default ? 0 : 1,
|
||||
stream.index,
|
||||
],
|
||||
),
|
||||
(audioStream) => {
|
||||
return {
|
||||
streamType: 'audio',
|
||||
// bitrate: audioStream.bitrate,
|
||||
channels: audioStream.channels,
|
||||
codec: audioStream.codec,
|
||||
index: audioStream.index,
|
||||
// Use the "selected" bit over the "default" if it exists
|
||||
// In plex, selected signifies that the user's preferences would choose
|
||||
// this stream over others, even if it is not the default
|
||||
// This is temporary until we have language preferences within Tunarr
|
||||
// to pick these streams.
|
||||
profile: audioStream.profile?.toLocaleLowerCase() ?? '',
|
||||
selected: audioStream.selected,
|
||||
default: audioStream.default,
|
||||
languageCodeISO6392: audioStream.languageCode,
|
||||
title: audioStream.displayTitle,
|
||||
} satisfies MediaStream;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
streams.push(
|
||||
...map(
|
||||
sortBy(
|
||||
filter(
|
||||
apiMediaStreams,
|
||||
(stream): stream is PlexMediaSubtitleStream => {
|
||||
return stream.streamType === 3 && !stream.embeddedInVideo;
|
||||
},
|
||||
),
|
||||
(stream) => [
|
||||
stream.selected ? -1 : 0,
|
||||
// stream.default ? 0 : 1,
|
||||
stream.index,
|
||||
],
|
||||
),
|
||||
(stream) => {
|
||||
const sdh = !![
|
||||
stream.title,
|
||||
stream.displayTitle,
|
||||
stream.extendedDisplayTitle,
|
||||
]
|
||||
.filter(isNonEmptyString)
|
||||
.find((s) => s.toLocaleLowerCase().includes('sdh'));
|
||||
|
||||
const details = {
|
||||
streamType: isDefined(stream.index)
|
||||
? 'subtitles'
|
||||
: 'external_subtitles',
|
||||
codec: stream.codec.toLocaleLowerCase(),
|
||||
default: stream.default ?? false,
|
||||
index: stream.index ?? 0,
|
||||
title: stream.displayTitle,
|
||||
sdh,
|
||||
languageCodeISO6392: stream.languageCode,
|
||||
fileName: stream.key,
|
||||
forced: stream.forced ?? false,
|
||||
} satisfies MediaStream;
|
||||
return details;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const chapters: MediaChapter[] =
|
||||
plexItem.type === 'movie' || plexItem.type === 'episode'
|
||||
? (plexItem.Chapter?.map((chapter) => {
|
||||
return {
|
||||
index: chapter.index,
|
||||
endTime: chapter.endTimeOffset,
|
||||
startTime: chapter.startTimeOffset,
|
||||
chapterType: 'chapter',
|
||||
title: chapter.tag,
|
||||
} satisfies MediaChapter;
|
||||
}) ?? [])
|
||||
: [];
|
||||
|
||||
const markers =
|
||||
plexItem.type === 'movie' || plexItem.type === 'episode'
|
||||
? (plexItem.Marker ?? [])
|
||||
: [];
|
||||
const intros = zipWithIndex(
|
||||
orderBy(
|
||||
markers.filter((marker) => marker.type === 'intro'),
|
||||
(marker) => marker.startTimeOffset,
|
||||
'asc',
|
||||
),
|
||||
).map(
|
||||
([marker, index]) =>
|
||||
({
|
||||
chapterType: 'intro',
|
||||
endTime: marker.endTimeOffset,
|
||||
startTime: marker.startTimeOffset,
|
||||
index,
|
||||
}) satisfies MediaChapter,
|
||||
);
|
||||
const outros = zipWithIndex(
|
||||
orderBy(
|
||||
markers.filter((marker) => marker.type === 'credits'),
|
||||
(marker) => marker.startTimeOffset,
|
||||
'asc',
|
||||
),
|
||||
).map(
|
||||
([marker, index]) =>
|
||||
({
|
||||
chapterType: 'outro',
|
||||
endTime: marker.endTimeOffset,
|
||||
startTime: marker.startTimeOffset,
|
||||
index,
|
||||
}) satisfies MediaChapter,
|
||||
);
|
||||
|
||||
chapters.push(...intros, ...outros);
|
||||
|
||||
return Result.success({
|
||||
// Handle if this is not present...
|
||||
duration: relevantMedia.duration!,
|
||||
sampleAspectRatio: isNonEmptyString(videoStream?.pixelAspectRatio)
|
||||
? videoStream.pixelAspectRatio
|
||||
: '1:1',
|
||||
displayAspectRatio:
|
||||
(relevantMedia.aspectRatio ?? 0) === 0
|
||||
? ''
|
||||
: (relevantMedia.aspectRatio?.toFixed(2) ?? ''),
|
||||
resolution:
|
||||
isDefined(relevantMedia.width) && isDefined(relevantMedia.height)
|
||||
? { widthPx: relevantMedia.width, heightPx: relevantMedia.height }
|
||||
: undefined,
|
||||
frameRate: videoStream?.frameRate?.toFixed(2),
|
||||
streams,
|
||||
locations: [
|
||||
{
|
||||
type: 'remote',
|
||||
externalKey: relevantMediaPart.key,
|
||||
path: relevantMediaPart.file,
|
||||
sourceType: MediaSourceType.Plex,
|
||||
},
|
||||
],
|
||||
chapters,
|
||||
} satisfies MediaItem);
|
||||
}
|
||||
}
|
||||
|
||||
type PlexTvDevicesResponse = {
|
||||
@@ -2022,182 +2245,3 @@ function emptyMediaItem(item: PlexTerminalMedia): Maybe<MediaItem> {
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function plexMediaStreamsInject(
|
||||
itemId: string,
|
||||
plexItem: PlexTerminalMedia,
|
||||
requireVideoStream: boolean = true,
|
||||
): Result<MediaItem> {
|
||||
const plexMedia = plexItem.Media;
|
||||
if (isNil(plexMedia) || isEmpty(plexMedia)) {
|
||||
return Result.forError(
|
||||
new Error(`Plex item ID = ${itemId} has no Media streams`),
|
||||
);
|
||||
}
|
||||
|
||||
const relevantMedia = maxBy(
|
||||
filter(
|
||||
plexMedia,
|
||||
(m) => (m.duration ?? 0) >= 0 && (m.Part?.length ?? 0) > 0,
|
||||
),
|
||||
(m) => m.id,
|
||||
);
|
||||
|
||||
if (!relevantMedia) {
|
||||
return Result.forError(
|
||||
new Error(
|
||||
`No Media items on Plex item ID = ${itemId} meet the necessary criteria.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const relevantMediaPart = first(relevantMedia?.Part);
|
||||
const apiMediaStreams = relevantMediaPart?.Stream;
|
||||
|
||||
if (!relevantMediaPart || isEmpty(apiMediaStreams)) {
|
||||
return Result.forError(
|
||||
new Error(`Could not extract a stream for Plex item ID ${itemId}`),
|
||||
);
|
||||
}
|
||||
|
||||
const videoStream = find(
|
||||
apiMediaStreams,
|
||||
(stream): stream is PlexMediaVideoStream => stream.streamType === 1,
|
||||
);
|
||||
|
||||
if (requireVideoStream && !videoStream) {
|
||||
return Result.forError(
|
||||
new Error(`Plex item ID = ${itemId} has no video streams`),
|
||||
);
|
||||
}
|
||||
|
||||
const streams: MediaStream[] = [];
|
||||
if (videoStream) {
|
||||
const videoDetails = {
|
||||
streamType: 'video',
|
||||
codec: videoStream.codec,
|
||||
bitDepth: videoStream.bitDepth ?? 8,
|
||||
languageCodeISO6392: videoStream.languageCode,
|
||||
default: videoStream.default,
|
||||
profile: videoStream.profile?.toLowerCase() ?? '',
|
||||
index: videoStream.index,
|
||||
frameRate: videoStream.frameRate,
|
||||
colorPrimaries: videoStream.colorPrimaries,
|
||||
colorRange: videoStream.colorRange,
|
||||
colorSpace: videoStream.colorSpace,
|
||||
colorTransfer: videoStream.colorTrc,
|
||||
} satisfies MediaStream;
|
||||
streams.push(videoDetails);
|
||||
}
|
||||
|
||||
streams.push(
|
||||
...map(
|
||||
sortBy(
|
||||
filter(apiMediaStreams, (stream): stream is PlexMediaAudioStream => {
|
||||
return stream.streamType === 2 && !isNil(stream.index);
|
||||
}),
|
||||
(stream) => [
|
||||
stream.selected ? -1 : 0,
|
||||
stream.default ? 0 : 1,
|
||||
stream.index,
|
||||
],
|
||||
),
|
||||
(audioStream) => {
|
||||
return {
|
||||
streamType: 'audio',
|
||||
// bitrate: audioStream.bitrate,
|
||||
channels: audioStream.channels,
|
||||
codec: audioStream.codec,
|
||||
index: audioStream.index,
|
||||
// Use the "selected" bit over the "default" if it exists
|
||||
// In plex, selected signifies that the user's preferences would choose
|
||||
// this stream over others, even if it is not the default
|
||||
// This is temporary until we have language preferences within Tunarr
|
||||
// to pick these streams.
|
||||
profile: audioStream.profile?.toLocaleLowerCase() ?? '',
|
||||
selected: audioStream.selected,
|
||||
default: audioStream.default,
|
||||
languageCodeISO6392: audioStream.languageCode,
|
||||
title: audioStream.displayTitle,
|
||||
} satisfies MediaStream;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const chapters: MediaChapter[] =
|
||||
plexItem.type === 'movie' || plexItem.type === 'episode'
|
||||
? (plexItem.Chapter?.map((chapter) => {
|
||||
return {
|
||||
index: chapter.index,
|
||||
endTime: chapter.endTimeOffset,
|
||||
startTime: chapter.startTimeOffset,
|
||||
chapterType: 'chapter',
|
||||
title: chapter.tag,
|
||||
} satisfies MediaChapter;
|
||||
}) ?? [])
|
||||
: [];
|
||||
|
||||
const markers =
|
||||
plexItem.type === 'movie' || plexItem.type === 'episode'
|
||||
? (plexItem.Marker ?? [])
|
||||
: [];
|
||||
const intros = zipWithIndex(
|
||||
orderBy(
|
||||
markers.filter((marker) => marker.type === 'intro'),
|
||||
(marker) => marker.startTimeOffset,
|
||||
'asc',
|
||||
),
|
||||
).map(
|
||||
([marker, index]) =>
|
||||
({
|
||||
chapterType: 'intro',
|
||||
endTime: marker.endTimeOffset,
|
||||
startTime: marker.startTimeOffset,
|
||||
index,
|
||||
}) satisfies MediaChapter,
|
||||
);
|
||||
const outros = zipWithIndex(
|
||||
orderBy(
|
||||
markers.filter((marker) => marker.type === 'credits'),
|
||||
(marker) => marker.startTimeOffset,
|
||||
'asc',
|
||||
),
|
||||
).map(
|
||||
([marker, index]) =>
|
||||
({
|
||||
chapterType: 'outro',
|
||||
endTime: marker.endTimeOffset,
|
||||
startTime: marker.startTimeOffset,
|
||||
index,
|
||||
}) satisfies MediaChapter,
|
||||
);
|
||||
|
||||
chapters.push(...intros, ...outros);
|
||||
|
||||
return Result.success({
|
||||
// Handle if this is not present...
|
||||
duration: relevantMedia.duration!,
|
||||
sampleAspectRatio: isNonEmptyString(videoStream?.pixelAspectRatio)
|
||||
? videoStream.pixelAspectRatio
|
||||
: '1:1',
|
||||
displayAspectRatio:
|
||||
(relevantMedia.aspectRatio ?? 0) === 0
|
||||
? ''
|
||||
: (relevantMedia.aspectRatio?.toFixed(2) ?? ''),
|
||||
resolution:
|
||||
isDefined(relevantMedia.width) && isDefined(relevantMedia.height)
|
||||
? { widthPx: relevantMedia.width, heightPx: relevantMedia.height }
|
||||
: undefined,
|
||||
frameRate: videoStream?.frameRate?.toFixed(2),
|
||||
streams,
|
||||
locations: [
|
||||
{
|
||||
type: 'remote',
|
||||
externalKey: relevantMediaPart.key,
|
||||
path: relevantMediaPart.file,
|
||||
sourceType: MediaSourceType.Plex,
|
||||
},
|
||||
],
|
||||
chapters,
|
||||
} satisfies MediaItem);
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ export class SubtitleStreamPicker {
|
||||
externalSourceId: lineupItem.program.mediaSourceId,
|
||||
externalSourceType: lineupItem.program.sourceType,
|
||||
},
|
||||
stream,
|
||||
{ streamIndex: stream.index, codec: stream.codec },
|
||||
);
|
||||
|
||||
if (!filePath) {
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { MediaSourceDB } from '@/db/mediaSourceDB.js';
|
||||
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
|
||||
import { ScanContext } from '@/services/scanner/MediaSourceScanner.js';
|
||||
import {
|
||||
GetSubtitlesRequest,
|
||||
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';
|
||||
import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts';
|
||||
import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.ts';
|
||||
import { QueryResult } from '../../external/BaseApiClient.ts';
|
||||
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 { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
|
||||
import { Logger } from '../../util/logging/LoggerFactory.ts';
|
||||
import { MeilisearchService } from '../MeilisearchService.ts';
|
||||
import { EmbyScanUtil } from './EmbyScanUtil.ts';
|
||||
import { MediaSourceMovieLibraryScanner } from './MediaSourceMovieLibraryScanner.ts';
|
||||
import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
|
||||
|
||||
@@ -36,6 +42,8 @@ export class EmbyMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner<
|
||||
mediaSourceProgressService: MediaSourceProgressService,
|
||||
@inject(MeilisearchService) searchService: MeilisearchService,
|
||||
@inject(ProgramConverter) programConverter: ProgramConverter,
|
||||
@inject(ExternalSubtitleDownloader)
|
||||
externalSubtitleDownloader: ExternalSubtitleDownloader,
|
||||
) {
|
||||
super(
|
||||
logger,
|
||||
@@ -45,6 +53,7 @@ export class EmbyMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner<
|
||||
searchService,
|
||||
programConverter,
|
||||
programMinterFactory(),
|
||||
externalSubtitleDownloader,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,4 +99,11 @@ export class EmbyMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner<
|
||||
.getChildItemCount(libraryKey, 'Movie')
|
||||
.then((_) => _.getOrThrow());
|
||||
}
|
||||
|
||||
protected getSubtitles(
|
||||
context: ScanContext<EmbyApiClient>,
|
||||
request: GetSubtitlesRequest,
|
||||
): Promise<QueryResult<string>> {
|
||||
return EmbyScanUtil.getSubtitles(context, request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts';
|
||||
import { IProgramDB } from '../../db/interfaces/IProgramDB.ts';
|
||||
import { MediaSourceDB } from '../../db/mediaSourceDB.ts';
|
||||
import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js';
|
||||
import { QueryResult } from '../../external/BaseApiClient.ts';
|
||||
import { EmbyApiClient } from '../../external/emby/EmbyApiClient.ts';
|
||||
import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.ts';
|
||||
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
|
||||
import { WrappedError } from '../../types/errors.ts';
|
||||
import { KEYS } from '../../types/inject.ts';
|
||||
import { EmbyT } from '../../types/internal.ts';
|
||||
@@ -18,9 +20,10 @@ import {
|
||||
import { Result } from '../../types/result.ts';
|
||||
import { Logger } from '../../util/logging/LoggerFactory.ts';
|
||||
import { MeilisearchService } from '../MeilisearchService.ts';
|
||||
import { EmbyScanUtil } from './EmbyScanUtil.ts';
|
||||
import { MediaSourceMusicArtistScanner } from './MediaSourceMusicArtistScanner.ts';
|
||||
import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
|
||||
import { ScanContext } from './MediaSourceScanner.ts';
|
||||
import { GetSubtitlesRequest, ScanContext } from './MediaSourceScanner.ts';
|
||||
|
||||
export class EmbyMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
|
||||
EmbyT,
|
||||
@@ -46,6 +49,8 @@ export class EmbyMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
|
||||
mediaSourceProgressService: MediaSourceProgressService,
|
||||
@inject(GetProgramGroupingById)
|
||||
getProgramGroupingsById: GetProgramGroupingById,
|
||||
@inject(ExternalSubtitleDownloader)
|
||||
externalSubtitleDownloader: ExternalSubtitleDownloader,
|
||||
) {
|
||||
super(
|
||||
logger,
|
||||
@@ -56,6 +61,7 @@ export class EmbyMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
|
||||
searchService,
|
||||
mediaSourceProgressService,
|
||||
getProgramGroupingsById,
|
||||
externalSubtitleDownloader,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,4 +115,11 @@ export class EmbyMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
|
||||
.getChildItemCount(libraryKey, 'MusicArtist')
|
||||
.then((_) => _.getOrThrow());
|
||||
}
|
||||
|
||||
protected getSubtitles(
|
||||
context: ScanContext<EmbyApiClient>,
|
||||
request: GetSubtitlesRequest,
|
||||
): Promise<QueryResult<string>> {
|
||||
return EmbyScanUtil.getSubtitles(context, request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
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 {
|
||||
GetSubtitlesRequest,
|
||||
ScanContext,
|
||||
} from '@/services/scanner/MediaSourceScanner.js';
|
||||
import { inject, injectable, interfaces } from 'inversify';
|
||||
import { isNil } from 'lodash-es';
|
||||
import { ProgramGroupingMinter } from '../../db/converters/ProgramGroupingMinter.ts';
|
||||
import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts';
|
||||
import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts';
|
||||
import { QueryResult } from '../../external/BaseApiClient.ts';
|
||||
import { EmbyApiClient } from '../../external/emby/EmbyApiClient.ts';
|
||||
import { WrappedError } from '../../types/errors.ts';
|
||||
import { KEYS } from '../../types/inject.ts';
|
||||
@@ -21,8 +25,10 @@ import {
|
||||
SeasonWithShow,
|
||||
} from '../../types/Media.ts';
|
||||
import { Result } from '../../types/result.ts';
|
||||
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
|
||||
import { Logger } from '../../util/logging/LoggerFactory.ts';
|
||||
import { MeilisearchService } from '../MeilisearchService.ts';
|
||||
import { EmbyScanUtil } from './EmbyScanUtil.ts';
|
||||
import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
|
||||
import { MediaSourceTvShowLibraryScanner } from './MediaSourceTvShowLibraryScanner.ts';
|
||||
|
||||
@@ -51,6 +57,8 @@ export class EmbyMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne
|
||||
@inject(MeilisearchService) searchService: MeilisearchService,
|
||||
@inject(GetProgramGroupingById)
|
||||
getProgramGroupingsById: GetProgramGroupingById,
|
||||
@inject(ExternalSubtitleDownloader)
|
||||
externalSubtitleDownloader: ExternalSubtitleDownloader,
|
||||
) {
|
||||
super(
|
||||
logger,
|
||||
@@ -61,6 +69,7 @@ export class EmbyMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne
|
||||
searchService,
|
||||
mediaSourceProgressService,
|
||||
getProgramGroupingsById,
|
||||
externalSubtitleDownloader,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,4 +164,11 @@ export class EmbyMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne
|
||||
protected isSeasonT(grouping: ProgramGrouping): grouping is EmbySeason {
|
||||
return grouping.sourceType === 'emby' && grouping.type === 'season';
|
||||
}
|
||||
|
||||
protected getSubtitles(
|
||||
context: ScanContext<EmbyApiClient>,
|
||||
request: GetSubtitlesRequest,
|
||||
): Promise<QueryResult<string>> {
|
||||
return EmbyScanUtil.getSubtitles(context, request);
|
||||
}
|
||||
}
|
||||
|
||||
19
server/src/services/scanner/EmbyScanUtil.ts
Normal file
19
server/src/services/scanner/EmbyScanUtil.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { QueryResult } from '../../external/BaseApiClient.ts';
|
||||
import type { EmbyApiClient } from '../../external/emby/EmbyApiClient.ts';
|
||||
import type { GetSubtitlesRequest, ScanContext } from './MediaSourceScanner.ts';
|
||||
|
||||
export class EmbyScanUtil {
|
||||
private constructor() {}
|
||||
|
||||
static async getSubtitles(
|
||||
context: ScanContext<EmbyApiClient>,
|
||||
req: GetSubtitlesRequest,
|
||||
): Promise<QueryResult<string>> {
|
||||
return context.apiClient.getSubtitles(
|
||||
req.externalItemId,
|
||||
req.externalMediaItemId ?? req.externalItemId,
|
||||
req.streamIndex,
|
||||
req.extension,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,24 @@
|
||||
import { MediaSourceDB } from '@/db/mediaSourceDB.js';
|
||||
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
|
||||
import { ScanContext } from '@/services/scanner/MediaSourceScanner.js';
|
||||
import {
|
||||
GetSubtitlesRequest,
|
||||
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';
|
||||
import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts';
|
||||
import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js';
|
||||
import { QueryResult } from '../../external/BaseApiClient.ts';
|
||||
import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.ts';
|
||||
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.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 { JellyfinScanUtil } from './JellyfinScanUtil.ts';
|
||||
import { MediaSourceMovieLibraryScanner } from './MediaSourceMovieLibraryScanner.ts';
|
||||
import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
|
||||
|
||||
@@ -36,6 +42,8 @@ export class JellyfinMediaSourceMovieScanner extends MediaSourceMovieLibraryScan
|
||||
mediaSourceProgressService: MediaSourceProgressService,
|
||||
@inject(MeilisearchService) searchService: MeilisearchService,
|
||||
@inject(ProgramConverter) programConverter: ProgramConverter,
|
||||
@inject(ExternalSubtitleDownloader)
|
||||
externalSubtitleDownloader: ExternalSubtitleDownloader,
|
||||
) {
|
||||
super(
|
||||
logger,
|
||||
@@ -45,6 +53,7 @@ export class JellyfinMediaSourceMovieScanner extends MediaSourceMovieLibraryScan
|
||||
searchService,
|
||||
programConverter,
|
||||
programMinterFactory(),
|
||||
externalSubtitleDownloader,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,4 +99,11 @@ export class JellyfinMediaSourceMovieScanner extends MediaSourceMovieLibraryScan
|
||||
.getChildItemCount(libraryKey, 'Movie')
|
||||
.then((_) => _.getOrThrow());
|
||||
}
|
||||
|
||||
protected getSubtitles(
|
||||
context: ScanContext<JellyfinApiClient>,
|
||||
request: GetSubtitlesRequest,
|
||||
): Promise<QueryResult<string>> {
|
||||
return JellyfinScanUtil.getSubtitles(context, request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
|
||||
import { WrappedError } from '../../types/errors.ts';
|
||||
import { KEYS } from '../../types/inject.ts';
|
||||
import { JellyfinT } from '../../types/internal.ts';
|
||||
@@ -16,11 +17,13 @@ import {
|
||||
JellyfinMusicTrack,
|
||||
} from '../../types/Media.ts';
|
||||
import { Result } from '../../types/result.ts';
|
||||
import { QueryResult } from '../../external/BaseApiClient.ts';
|
||||
import { Logger } from '../../util/logging/LoggerFactory.ts';
|
||||
import { MeilisearchService } from '../MeilisearchService.ts';
|
||||
import { JellyfinScanUtil } from './JellyfinScanUtil.ts';
|
||||
import { MediaSourceMusicArtistScanner } from './MediaSourceMusicArtistScanner.ts';
|
||||
import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
|
||||
import { ScanContext } from './MediaSourceScanner.ts';
|
||||
import { GetSubtitlesRequest, ScanContext } from './MediaSourceScanner.ts';
|
||||
|
||||
export class JellyfinMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
|
||||
JellyfinT,
|
||||
@@ -46,6 +49,8 @@ export class JellyfinMediaSourceMusicScanner extends MediaSourceMusicArtistScann
|
||||
mediaSourceProgressService: MediaSourceProgressService,
|
||||
@inject(GetProgramGroupingById)
|
||||
getProgramGroupingsById: GetProgramGroupingById,
|
||||
@inject(ExternalSubtitleDownloader)
|
||||
externalSubtitleDownloader: ExternalSubtitleDownloader,
|
||||
) {
|
||||
super(
|
||||
logger,
|
||||
@@ -56,6 +61,7 @@ export class JellyfinMediaSourceMusicScanner extends MediaSourceMusicArtistScann
|
||||
searchService,
|
||||
mediaSourceProgressService,
|
||||
getProgramGroupingsById,
|
||||
externalSubtitleDownloader,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,4 +115,11 @@ export class JellyfinMediaSourceMusicScanner extends MediaSourceMusicArtistScann
|
||||
.getChildItemCount(libraryKey, 'MusicArtist')
|
||||
.then((_) => _.getOrThrow());
|
||||
}
|
||||
|
||||
protected getSubtitles(
|
||||
context: ScanContext<JellyfinApiClient>,
|
||||
request: GetSubtitlesRequest,
|
||||
): Promise<QueryResult<string>> {
|
||||
return JellyfinScanUtil.getSubtitles(context, request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ 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 { QueryResult } from '../../external/BaseApiClient.ts';
|
||||
import { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.ts';
|
||||
import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.ts';
|
||||
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
|
||||
import { WrappedError } from '../../types/errors.ts';
|
||||
import { KEYS } from '../../types/inject.ts';
|
||||
import type { JellyfinT } from '../../types/internal.ts';
|
||||
@@ -13,9 +15,10 @@ 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 { JellyfinScanUtil } from './JellyfinScanUtil.ts';
|
||||
import { MediaSourceOtherVideoScanner } from './MediaSourceOtherVideoScanner.ts';
|
||||
import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
|
||||
import type { ScanContext } from './MediaSourceScanner.ts';
|
||||
import type { GetSubtitlesRequest, ScanContext } from './MediaSourceScanner.ts';
|
||||
|
||||
@injectable()
|
||||
export class JellyfinMediaSourceOtherVideoScanner extends MediaSourceOtherVideoScanner<
|
||||
@@ -37,6 +40,8 @@ export class JellyfinMediaSourceOtherVideoScanner extends MediaSourceOtherVideoS
|
||||
mediaSourceProgressService: MediaSourceProgressService,
|
||||
@inject(KEYS.ProgramDaoMinterFactory)
|
||||
programMinterFactory: interfaces.AutoFactory<ProgramDaoMinter>,
|
||||
@inject(ExternalSubtitleDownloader)
|
||||
externalSubtitleDownloader: ExternalSubtitleDownloader,
|
||||
) {
|
||||
super(
|
||||
logger,
|
||||
@@ -45,6 +50,7 @@ export class JellyfinMediaSourceOtherVideoScanner extends MediaSourceOtherVideoS
|
||||
searchService,
|
||||
mediaSourceProgressService,
|
||||
programMinterFactory(),
|
||||
externalSubtitleDownloader,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,4 +107,11 @@ export class JellyfinMediaSourceOtherVideoScanner extends MediaSourceOtherVideoS
|
||||
protected getExternalKey(video: JellyfinOtherVideo): string {
|
||||
return video.externalId;
|
||||
}
|
||||
|
||||
protected getSubtitles(
|
||||
context: ScanContext<JellyfinApiClient>,
|
||||
request: GetSubtitlesRequest,
|
||||
): Promise<QueryResult<string>> {
|
||||
return JellyfinScanUtil.getSubtitles(context, request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
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 {
|
||||
GetSubtitlesRequest,
|
||||
ScanContext,
|
||||
} from '@/services/scanner/MediaSourceScanner.js';
|
||||
import { inject, injectable, interfaces } from 'inversify';
|
||||
import { isNil } from 'lodash-es';
|
||||
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 { QueryResult } from '../../external/BaseApiClient.ts';
|
||||
import { WrappedError } from '../../types/errors.ts';
|
||||
import { KEYS } from '../../types/inject.ts';
|
||||
|
||||
@@ -21,8 +25,10 @@ import {
|
||||
SeasonWithShow,
|
||||
} from '../../types/Media.ts';
|
||||
import { Result } from '../../types/result.ts';
|
||||
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
|
||||
import { Logger } from '../../util/logging/LoggerFactory.ts';
|
||||
import { MeilisearchService } from '../MeilisearchService.ts';
|
||||
import { JellyfinScanUtil } from './JellyfinScanUtil.ts';
|
||||
import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
|
||||
import { MediaSourceTvShowLibraryScanner } from './MediaSourceTvShowLibraryScanner.ts';
|
||||
|
||||
@@ -51,6 +57,8 @@ export class JellyfinMediaSourceTvShowScanner extends MediaSourceTvShowLibrarySc
|
||||
@inject(MeilisearchService) searchService: MeilisearchService,
|
||||
@inject(GetProgramGroupingById)
|
||||
getProgramGroupingsById: GetProgramGroupingById,
|
||||
@inject(ExternalSubtitleDownloader)
|
||||
externalSubtitleDownloader: ExternalSubtitleDownloader,
|
||||
) {
|
||||
super(
|
||||
logger,
|
||||
@@ -61,6 +69,7 @@ export class JellyfinMediaSourceTvShowScanner extends MediaSourceTvShowLibrarySc
|
||||
searchService,
|
||||
mediaSourceProgressService,
|
||||
getProgramGroupingsById,
|
||||
externalSubtitleDownloader,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -155,4 +164,11 @@ export class JellyfinMediaSourceTvShowScanner extends MediaSourceTvShowLibrarySc
|
||||
protected isSeasonT(grouping: ProgramGrouping): grouping is JellyfinSeason {
|
||||
return grouping.sourceType === 'jellyfin' && grouping.type === 'season';
|
||||
}
|
||||
|
||||
protected getSubtitles(
|
||||
context: ScanContext<JellyfinApiClient>,
|
||||
request: GetSubtitlesRequest,
|
||||
): Promise<QueryResult<string>> {
|
||||
return JellyfinScanUtil.getSubtitles(context, request);
|
||||
}
|
||||
}
|
||||
|
||||
19
server/src/services/scanner/JellyfinScanUtil.ts
Normal file
19
server/src/services/scanner/JellyfinScanUtil.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { QueryResult } from '../../external/BaseApiClient.ts';
|
||||
import type { JellyfinApiClient } from '../../external/jellyfin/JellyfinApiClient.ts';
|
||||
import type { GetSubtitlesRequest, ScanContext } from './MediaSourceScanner.ts';
|
||||
|
||||
export class JellyfinScanUtil {
|
||||
private constructor() {}
|
||||
|
||||
static async getSubtitles(
|
||||
context: ScanContext<JellyfinApiClient>,
|
||||
req: GetSubtitlesRequest,
|
||||
): Promise<QueryResult<string>> {
|
||||
return context.apiClient.getSubtitles(
|
||||
req.externalItemId,
|
||||
req.externalMediaItemId ?? req.externalItemId,
|
||||
req.streamIndex,
|
||||
req.extension,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type { RemoteMediaSourceType } from '../../db/schema/MediaSource.ts';
|
||||
import { ProgramType } from '../../db/schema/Program.ts';
|
||||
import { isMovieProgram } from '../../db/schema/schemaTypeGuards.ts';
|
||||
import type { MediaSourceApiClient } from '../../external/MediaSourceApiClient.ts';
|
||||
import type { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
|
||||
import type { HasMediaSourceInfo, Movie } from '../../types/Media.ts';
|
||||
import { Result } from '../../types/result.ts';
|
||||
import type { Logger } from '../../util/logging/LoggerFactory.ts';
|
||||
@@ -45,8 +46,9 @@ export abstract class MediaSourceMovieLibraryScanner<
|
||||
private searchService: MeilisearchService,
|
||||
protected programConverter: ProgramConverter,
|
||||
protected programMinter: ProgramDaoMinter,
|
||||
protected externalSubtitleDownloader: ExternalSubtitleDownloader,
|
||||
) {
|
||||
super(logger, mediaSourceDB);
|
||||
super(logger, mediaSourceDB, externalSubtitleDownloader);
|
||||
}
|
||||
|
||||
protected async scanInternal(
|
||||
@@ -123,6 +125,13 @@ export abstract class MediaSourceMovieLibraryScanner<
|
||||
fullMovie,
|
||||
);
|
||||
|
||||
await this.downloadExternalSubtitleStreams(minted, (req) =>
|
||||
this.getSubtitles(context, {
|
||||
...req,
|
||||
externalMediaItemId: fullMovie.mediaItem?.externalKey ?? undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const upsertResult = await Result.attemptAsync(() =>
|
||||
this.programDB.upsertPrograms([minted]),
|
||||
);
|
||||
|
||||
@@ -32,6 +32,7 @@ import { Result } from '../../types/result.ts';
|
||||
import type { Maybe } from '../../types/util.ts';
|
||||
import type { Logger } from '../../util/logging/LoggerFactory.ts';
|
||||
import type { MeilisearchService } from '../MeilisearchService.ts';
|
||||
import type { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
|
||||
import type { MediaSourceProgressService } from './MediaSourceProgressService.ts';
|
||||
import type { ScanContext } from './MediaSourceScanner.ts';
|
||||
import { MediaSourceScanner } from './MediaSourceScanner.ts';
|
||||
@@ -78,8 +79,9 @@ export abstract class MediaSourceMusicArtistScanner<
|
||||
protected searchService: MeilisearchService,
|
||||
private mediaSourceProgressService: MediaSourceProgressService,
|
||||
private getProgramGroupingByIdCommand: GetProgramGroupingById,
|
||||
protected externalSubtitleDownloader: ExternalSubtitleDownloader,
|
||||
) {
|
||||
super(logger, mediaSourceDB);
|
||||
super(logger, mediaSourceDB, externalSubtitleDownloader);
|
||||
}
|
||||
|
||||
protected async scanInternal(
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MediaSourceDB } from '../../db/mediaSourceDB.ts';
|
||||
import type { RemoteMediaSourceType } from '../../db/schema/MediaSource.ts';
|
||||
import { ProgramType } from '../../db/schema/Program.ts';
|
||||
import type { MediaSourceApiClient } from '../../external/MediaSourceApiClient.ts';
|
||||
import type { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
|
||||
import type { HasMediaSourceInfo, OtherVideo } from '../../types/Media.ts';
|
||||
import { Result } from '../../types/result.ts';
|
||||
import type { Logger } from '../../util/logging/LoggerFactory.ts';
|
||||
@@ -33,8 +34,9 @@ export abstract class MediaSourceOtherVideoScanner<
|
||||
private searchService: MeilisearchService,
|
||||
private mediaSourceProgressService: MediaSourceProgressService,
|
||||
protected programMinter: ProgramDaoMinter,
|
||||
protected externalSubtitleDownloader: ExternalSubtitleDownloader,
|
||||
) {
|
||||
super(logger, mediaSourceDB);
|
||||
super(logger, mediaSourceDB, externalSubtitleDownloader);
|
||||
}
|
||||
|
||||
protected async scanInternal(
|
||||
@@ -105,6 +107,14 @@ export abstract class MediaSourceOtherVideoScanner<
|
||||
fullMetadata,
|
||||
);
|
||||
|
||||
await this.downloadExternalSubtitleStreams(minted, (req) =>
|
||||
this.getSubtitles(context, {
|
||||
...req,
|
||||
externalMediaItemId:
|
||||
fullMetadata.mediaItem?.externalKey ?? undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const upsertResult = await Result.attemptAsync(() =>
|
||||
this.programDB.upsertPrograms([minted]),
|
||||
);
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import type { MediaSourceLibrary } from '@/db/schema/MediaSourceLibrary.js';
|
||||
import dayjs from 'dayjs';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import type { MediaSourceDB } from '../../db/mediaSourceDB.ts';
|
||||
import type { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js';
|
||||
import type {
|
||||
MediaSourceWithRelations,
|
||||
NewProgramWithRelations,
|
||||
} from '../../db/schema/derivedTypes.js';
|
||||
import type {
|
||||
MediaLibraryType,
|
||||
MediaSourceOrm,
|
||||
RemoteMediaSourceType,
|
||||
} from '../../db/schema/MediaSource.ts';
|
||||
import type { QueryResult } from '../../external/BaseApiClient.ts';
|
||||
import type { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
|
||||
import { devAssert } from '../../util/debug.ts';
|
||||
import type { Logger } from '../../util/logging/LoggerFactory.ts';
|
||||
|
||||
@@ -42,6 +48,14 @@ export type GenericMediaSourceScannerFactory = (
|
||||
libraryType: MediaLibraryType,
|
||||
) => GenericMediaSourceScanner;
|
||||
|
||||
export type GetSubtitlesRequest = {
|
||||
key: string;
|
||||
extension: string;
|
||||
externalItemId: string;
|
||||
externalMediaItemId?: string;
|
||||
streamIndex: number; // Only relevant for Jellyfin
|
||||
};
|
||||
|
||||
export abstract class BaseMediaSourceScanner<ApiClientTypeT, ScanRequestT> {
|
||||
abstract scan(req: ScanRequestT): Promise<void>;
|
||||
|
||||
@@ -62,6 +76,7 @@ export abstract class MediaSourceScanner<
|
||||
constructor(
|
||||
protected logger: Logger,
|
||||
protected mediaSourceDB: MediaSourceDB,
|
||||
protected externalSubtitleDownloader: ExternalSubtitleDownloader,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -130,4 +145,55 @@ export abstract class MediaSourceScanner<
|
||||
libraryKey: string,
|
||||
context: ScanContext<ApiClientTypeT>,
|
||||
): Promise<number>;
|
||||
|
||||
protected abstract getSubtitles(
|
||||
context: ScanContext<ApiClientTypeT>,
|
||||
request: GetSubtitlesRequest,
|
||||
): Promise<QueryResult<string>>;
|
||||
|
||||
protected async downloadExternalSubtitleStreams(
|
||||
{ program, subtitles }: NewProgramWithRelations,
|
||||
getSubtitlesCallback: (
|
||||
args: GetSubtitlesRequest,
|
||||
) => Promise<QueryResult<string>>,
|
||||
) {
|
||||
const externalSubtitleStreams =
|
||||
subtitles.filter((stream) => stream.subtitleType === 'sidecar') ?? [];
|
||||
|
||||
for (const stream of externalSubtitleStreams) {
|
||||
if (isEmpty(stream.path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath =
|
||||
await this.externalSubtitleDownloader.downloadSubtitlesIfNecessary(
|
||||
{
|
||||
externalKey: program.externalKey,
|
||||
externalSourceId: program.mediaSourceId,
|
||||
sourceType: program.sourceType,
|
||||
uuid: program.uuid,
|
||||
},
|
||||
{ streamIndex: stream.streamIndex ?? undefined, codec: stream.codec },
|
||||
(args) =>
|
||||
getSubtitlesCallback({
|
||||
...args,
|
||||
key: stream.path!,
|
||||
externalItemId: program.externalKey,
|
||||
streamIndex: stream.streamIndex ?? 0,
|
||||
}),
|
||||
);
|
||||
|
||||
if (fullPath) {
|
||||
stream.path = fullPath;
|
||||
// return details;
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
'Skipping external subtitles at index %d because download failed. Please check logs and file an issue for assistance.',
|
||||
stream.streamIndex ?? -1,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { RemoteMediaSourceType } from '../../db/schema/MediaSource.ts';
|
||||
import { ProgramType } from '../../db/schema/Program.ts';
|
||||
import { ProgramGroupingType } from '../../db/schema/ProgramGrouping.ts';
|
||||
import type { MediaSourceApiClient } from '../../external/MediaSourceApiClient.ts';
|
||||
import type { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
|
||||
import type {
|
||||
HasMediaSourceAndLibraryId,
|
||||
MediaSourceEpisode,
|
||||
@@ -61,8 +62,9 @@ export abstract class MediaSourceTvShowLibraryScanner<
|
||||
protected searchService: MeilisearchService,
|
||||
private mediaSourceProgressService: MediaSourceProgressService,
|
||||
private getProgramGroupingByIdCommand: GetProgramGroupingById,
|
||||
protected externalSubtitleDownloader: ExternalSubtitleDownloader,
|
||||
) {
|
||||
super(logger, mediaSourceDB);
|
||||
super(logger, mediaSourceDB, externalSubtitleDownloader);
|
||||
}
|
||||
|
||||
protected async scanInternal(
|
||||
@@ -499,6 +501,14 @@ export abstract class MediaSourceTvShowLibraryScanner<
|
||||
episodeWithJoins,
|
||||
);
|
||||
|
||||
await this.downloadExternalSubtitleStreams(dao, (req) =>
|
||||
this.getSubtitles(scanContext, {
|
||||
...req,
|
||||
externalMediaItemId:
|
||||
episodeWithJoins.mediaItem?.externalKey ?? undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
dao.program.tvShowUuid = show.uuid;
|
||||
dao.program.seasonUuid = season.uuid;
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
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 {
|
||||
GetSubtitlesRequest,
|
||||
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';
|
||||
import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts';
|
||||
import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js';
|
||||
import { QueryResult } from '../../external/BaseApiClient.ts';
|
||||
import { PlexApiClient } from '../../external/plex/PlexApiClient.ts';
|
||||
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
|
||||
import { KEYS } from '../../types/inject.ts';
|
||||
import { PlexMovie } from '../../types/Media.ts';
|
||||
import { Result } from '../../types/result.ts';
|
||||
@@ -15,6 +20,7 @@ import { Logger } from '../../util/logging/LoggerFactory.ts';
|
||||
import { MeilisearchService } from '../MeilisearchService.ts';
|
||||
import { MediaSourceMovieLibraryScanner } from './MediaSourceMovieLibraryScanner.ts';
|
||||
import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
|
||||
import { PlexScanUtil } from './PlexScanUtil.ts';
|
||||
|
||||
@injectable()
|
||||
export class PlexMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner<
|
||||
@@ -35,6 +41,8 @@ export class PlexMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner<
|
||||
mediaSourceProgressService: MediaSourceProgressService,
|
||||
@inject(MeilisearchService) searchService: MeilisearchService,
|
||||
@inject(ProgramConverter) programConverter: ProgramConverter,
|
||||
@inject(ExternalSubtitleDownloader)
|
||||
externalSubtitleDownloader: ExternalSubtitleDownloader,
|
||||
) {
|
||||
super(
|
||||
logger,
|
||||
@@ -44,6 +52,7 @@ export class PlexMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner<
|
||||
searchService,
|
||||
programConverter,
|
||||
programMinterFactory(),
|
||||
externalSubtitleDownloader,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,4 +86,11 @@ export class PlexMediaSourceMovieScanner extends MediaSourceMovieLibraryScanner<
|
||||
): Promise<Result<PlexMovie>> {
|
||||
return apiClient.getMovie(incomingMovie.externalId);
|
||||
}
|
||||
|
||||
protected getSubtitles(
|
||||
context: ScanContext<PlexApiClient>,
|
||||
{ key }: GetSubtitlesRequest,
|
||||
): Promise<QueryResult<string>> {
|
||||
return PlexScanUtil.getSubtitles(context, key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import { MediaSourceDB } from '@/db/mediaSourceDB.js';
|
||||
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
|
||||
import { ScanContext } from '@/services/scanner/MediaSourceScanner.js';
|
||||
import {
|
||||
GetSubtitlesRequest,
|
||||
ScanContext,
|
||||
} from '@/services/scanner/MediaSourceScanner.js';
|
||||
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';
|
||||
import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts';
|
||||
import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js';
|
||||
import { QueryResult } from '../../external/BaseApiClient.ts';
|
||||
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 { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
|
||||
import { Logger } from '../../util/logging/LoggerFactory.ts';
|
||||
import { MeilisearchService } from '../MeilisearchService.ts';
|
||||
import { MediaSourceMusicArtistScanner } from './MediaSourceMusicArtistScanner.ts';
|
||||
import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
|
||||
import { PlexScanUtil } from './PlexScanUtil.ts';
|
||||
|
||||
@injectable()
|
||||
export class PlexMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
|
||||
@@ -43,6 +49,8 @@ export class PlexMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
|
||||
mediaSourceProgressService: MediaSourceProgressService,
|
||||
@inject(GetProgramGroupingById)
|
||||
getProgramGroupingsById: GetProgramGroupingById,
|
||||
@inject(ExternalSubtitleDownloader)
|
||||
externalSubtitleDownloader: ExternalSubtitleDownloader,
|
||||
) {
|
||||
super(
|
||||
logger,
|
||||
@@ -53,6 +61,7 @@ export class PlexMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
|
||||
searchService,
|
||||
mediaSourceProgressService,
|
||||
getProgramGroupingsById,
|
||||
externalSubtitleDownloader,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,4 +115,11 @@ export class PlexMediaSourceMusicScanner extends MediaSourceMusicArtistScanner<
|
||||
.getLibraryCount(libraryKey)
|
||||
.then((_) => _.getOrThrow());
|
||||
}
|
||||
|
||||
protected getSubtitles(
|
||||
context: ScanContext<PlexApiClient>,
|
||||
{ key }: GetSubtitlesRequest,
|
||||
): Promise<QueryResult<string>> {
|
||||
return PlexScanUtil.getSubtitles(context, key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ 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 { QueryResult } from '../../external/BaseApiClient.ts';
|
||||
import { MediaSourceApiFactory } from '../../external/MediaSourceApiFactory.ts';
|
||||
import type { PlexApiClient } from '../../external/plex/PlexApiClient.ts';
|
||||
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
|
||||
import { WrappedError } from '../../types/errors.ts';
|
||||
import { KEYS } from '../../types/inject.ts';
|
||||
import type { PlexT } from '../../types/internal.ts';
|
||||
@@ -15,7 +17,8 @@ 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';
|
||||
import type { GetSubtitlesRequest, ScanContext } from './MediaSourceScanner.ts';
|
||||
import { PlexScanUtil } from './PlexScanUtil.ts';
|
||||
|
||||
@injectable()
|
||||
export class PlexMediaSourceOtherVideoScanner extends MediaSourceOtherVideoScanner<
|
||||
@@ -37,6 +40,8 @@ export class PlexMediaSourceOtherVideoScanner extends MediaSourceOtherVideoScann
|
||||
mediaSourceProgressService: MediaSourceProgressService,
|
||||
@inject(KEYS.ProgramDaoMinterFactory)
|
||||
programMinterFactory: interfaces.AutoFactory<ProgramDaoMinter>,
|
||||
@inject(ExternalSubtitleDownloader)
|
||||
externalSubtitleDownloader: ExternalSubtitleDownloader,
|
||||
) {
|
||||
super(
|
||||
logger,
|
||||
@@ -45,6 +50,7 @@ export class PlexMediaSourceOtherVideoScanner extends MediaSourceOtherVideoScann
|
||||
searchService,
|
||||
mediaSourceProgressService,
|
||||
programMinterFactory(),
|
||||
externalSubtitleDownloader,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,4 +88,11 @@ export class PlexMediaSourceOtherVideoScanner extends MediaSourceOtherVideoScann
|
||||
protected getExternalKey(video: PlexOtherVideo): string {
|
||||
return video.externalId;
|
||||
}
|
||||
|
||||
protected getSubtitles(
|
||||
context: ScanContext<PlexApiClient>,
|
||||
{ key }: GetSubtitlesRequest,
|
||||
): Promise<QueryResult<string>> {
|
||||
return PlexScanUtil.getSubtitles(context, key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { MediaSourceDB } from '@/db/mediaSourceDB.js';
|
||||
import { MediaSourceApiFactory } from '@/external/MediaSourceApiFactory.js';
|
||||
import { ScanContext } from '@/services/scanner/MediaSourceScanner.js';
|
||||
import {
|
||||
GetSubtitlesRequest,
|
||||
ScanContext,
|
||||
} from '@/services/scanner/MediaSourceScanner.js';
|
||||
import { ProgramGrouping } from '@tunarr/types';
|
||||
import { inject, injectable, interfaces } from 'inversify';
|
||||
import { GetProgramGroupingById } from '../../commands/GetProgramGroupingById.ts';
|
||||
@@ -8,6 +11,7 @@ import { ProgramGroupingMinter } from '../../db/converters/ProgramGroupingMinter
|
||||
import { ProgramDaoMinter } from '../../db/converters/ProgramMinter.ts';
|
||||
import { type IProgramDB } from '../../db/interfaces/IProgramDB.ts';
|
||||
import { MediaSourceWithRelations } from '../../db/schema/derivedTypes.js';
|
||||
import { QueryResult } from '../../external/BaseApiClient.ts';
|
||||
import { PlexApiClient } from '../../external/plex/PlexApiClient.ts';
|
||||
import { WrappedError } from '../../types/errors.ts';
|
||||
import { KEYS } from '../../types/inject.ts';
|
||||
@@ -18,10 +22,12 @@ import {
|
||||
SeasonWithShow,
|
||||
} from '../../types/Media.ts';
|
||||
import { Result } from '../../types/result.ts';
|
||||
import { ExternalSubtitleDownloader } from '../../stream/ExternalSubtitleDownloader.ts';
|
||||
import { Logger } from '../../util/logging/LoggerFactory.ts';
|
||||
import { MeilisearchService } from '../MeilisearchService.ts';
|
||||
import { MediaSourceProgressService } from './MediaSourceProgressService.ts';
|
||||
import { MediaSourceTvShowLibraryScanner } from './MediaSourceTvShowLibraryScanner.ts';
|
||||
import { PlexScanUtil } from './PlexScanUtil.ts';
|
||||
|
||||
@injectable()
|
||||
export class PlexMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanner<
|
||||
@@ -48,6 +54,8 @@ export class PlexMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne
|
||||
mediaSourceProgressService: MediaSourceProgressService,
|
||||
@inject(GetProgramGroupingById)
|
||||
getProgramGroupingsById: GetProgramGroupingById,
|
||||
@inject(ExternalSubtitleDownloader)
|
||||
externalSubtitleDownloader: ExternalSubtitleDownloader,
|
||||
) {
|
||||
super(
|
||||
logger,
|
||||
@@ -58,6 +66,7 @@ export class PlexMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne
|
||||
searchService,
|
||||
mediaSourceProgressService,
|
||||
getProgramGroupingsById,
|
||||
externalSubtitleDownloader,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,4 +145,11 @@ export class PlexMediaSourceTvShowScanner extends MediaSourceTvShowLibraryScanne
|
||||
protected isSeasonT(grouping: ProgramGrouping): grouping is PlexSeason {
|
||||
return grouping.sourceType === 'plex' && grouping.type === 'season';
|
||||
}
|
||||
|
||||
protected getSubtitles(
|
||||
context: ScanContext<PlexApiClient>,
|
||||
{ key }: GetSubtitlesRequest,
|
||||
): Promise<QueryResult<string>> {
|
||||
return PlexScanUtil.getSubtitles(context, key);
|
||||
}
|
||||
}
|
||||
|
||||
14
server/src/services/scanner/PlexScanUtil.ts
Normal file
14
server/src/services/scanner/PlexScanUtil.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { QueryResult } from '../../external/BaseApiClient.ts';
|
||||
import type { PlexApiClient } from '../../external/plex/PlexApiClient.ts';
|
||||
import type { ScanContext } from './MediaSourceScanner.ts';
|
||||
|
||||
export class PlexScanUtil {
|
||||
private constructor() {}
|
||||
|
||||
static async getSubtitles(
|
||||
context: ScanContext<PlexApiClient>,
|
||||
key: string,
|
||||
): Promise<QueryResult<string>> {
|
||||
return context.apiClient.getSubtitles(key);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,33 @@
|
||||
import { inject, injectable } from 'inversify';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'path';
|
||||
import { StreamLineupProgram } from '../db/derived_types/StreamLineup.ts';
|
||||
import { MediaSourceId, MediaSourceType } from '../db/schema/base.ts';
|
||||
import { QueryResult } from '../external/BaseApiClient.ts';
|
||||
import { FileSystemService } from '../services/FileSystemService.ts';
|
||||
import { KEYS } from '../types/inject.ts';
|
||||
import { Maybe } from '../types/util.ts';
|
||||
import { fileExists } from '../util/fsUtil.ts';
|
||||
import { Logger } from '../util/logging/LoggerFactory.ts';
|
||||
import {
|
||||
getSubtitleCacheFilePath,
|
||||
subtitleCodecToExt,
|
||||
} from '../util/subtitles.ts';
|
||||
import { SubtitleStreamDetails } from './types.ts';
|
||||
|
||||
type GetSubtitleCallbackArgs = {
|
||||
export type GetSubtitleCallbackArgs = {
|
||||
extension: string;
|
||||
};
|
||||
|
||||
type GetSubtitlesCallback = (
|
||||
cbArgs: GetSubtitleCallbackArgs,
|
||||
) => Promise<QueryResult<string>>;
|
||||
|
||||
type ExternalItem = {
|
||||
externalKey: string;
|
||||
externalSourceId: MediaSourceId;
|
||||
sourceType: MediaSourceType;
|
||||
uuid: string;
|
||||
};
|
||||
|
||||
@injectable()
|
||||
export class ExternalSubtitleDownloader {
|
||||
constructor(
|
||||
@@ -33,11 +44,9 @@ export class ExternalSubtitleDownloader {
|
||||
* @returns The full path to the downloaded subtitles
|
||||
*/
|
||||
async downloadSubtitlesIfNecessary(
|
||||
item: StreamLineupProgram,
|
||||
details: SubtitleStreamDetails,
|
||||
getSubtitlesCb: (
|
||||
args: GetSubtitleCallbackArgs,
|
||||
) => Promise<QueryResult<string>>,
|
||||
item: ExternalItem,
|
||||
details: { streamIndex: Maybe<number>; codec: string },
|
||||
getSubtitlesCb: GetSubtitlesCallback,
|
||||
) {
|
||||
const outPath = getSubtitleCacheFilePath(
|
||||
{
|
||||
@@ -46,7 +55,10 @@ export class ExternalSubtitleDownloader {
|
||||
externalSourceType: item.sourceType,
|
||||
id: item.uuid,
|
||||
},
|
||||
details,
|
||||
{
|
||||
codec: details.codec,
|
||||
streamIndex: details.streamIndex,
|
||||
},
|
||||
);
|
||||
const ext = subtitleCodecToExt(details.codec);
|
||||
|
||||
|
||||
@@ -344,8 +344,16 @@ export class EmbyStreamDetails extends ExternalStreamDetailsFetcher<EmbyT> {
|
||||
if (details.type === 'external' && isDefined(index)) {
|
||||
const fullPath =
|
||||
await this.externalSubtitleDownloader.downloadSubtitlesIfNecessary(
|
||||
item,
|
||||
details,
|
||||
{
|
||||
externalKey: item.externalKey,
|
||||
externalSourceId: item.mediaSourceId,
|
||||
sourceType: 'emby',
|
||||
uuid: item.uuid,
|
||||
},
|
||||
{
|
||||
codec: details.codec,
|
||||
streamIndex: details.index,
|
||||
},
|
||||
({ extension: ext }) =>
|
||||
this.emby.getSubtitles(
|
||||
item.externalKey,
|
||||
|
||||
@@ -353,8 +353,16 @@ export class JellyfinStreamDetails extends ExternalStreamDetailsFetcher<Jellyfin
|
||||
if (details.type === 'external' && isDefined(index)) {
|
||||
const fullPath =
|
||||
await this.externalSubtitleDownloader.downloadSubtitlesIfNecessary(
|
||||
item,
|
||||
details,
|
||||
{
|
||||
externalKey: item.externalKey,
|
||||
externalSourceId: item.mediaSourceId,
|
||||
sourceType: 'jellyfin',
|
||||
uuid: item.uuid,
|
||||
},
|
||||
{
|
||||
codec: details.codec,
|
||||
streamIndex: details.index,
|
||||
},
|
||||
({ extension: ext }) =>
|
||||
this.jellyfin.getSubtitles(
|
||||
item.externalKey,
|
||||
|
||||
@@ -428,7 +428,7 @@ export class PlexStreamDetails extends ExternalStreamDetailsFetcher<PlexT> {
|
||||
const subtitleStreamDetails = await seq.asyncCollect(
|
||||
sortBy(
|
||||
filter(mediaStreams, (stream): stream is PlexMediaSubtitleStream => {
|
||||
return stream.streamType === 3;
|
||||
return stream.streamType === 3 && !stream.embeddedInVideo;
|
||||
}),
|
||||
(stream) => [
|
||||
stream.selected ? -1 : 0,
|
||||
@@ -462,8 +462,16 @@ export class PlexStreamDetails extends ExternalStreamDetailsFetcher<PlexT> {
|
||||
const key = stream.key;
|
||||
const fullPath =
|
||||
await this.externalSubtitleDownloader.downloadSubtitlesIfNecessary(
|
||||
item,
|
||||
details,
|
||||
{
|
||||
externalKey: item.externalKey,
|
||||
externalSourceId: item.mediaSourceId,
|
||||
sourceType: 'plex',
|
||||
uuid: item.uuid,
|
||||
},
|
||||
{
|
||||
codec: details.codec,
|
||||
streamIndex: details.index,
|
||||
},
|
||||
() => plexApiClient.getSubtitles(key),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { seq } from '@tunarr/shared/util';
|
||||
import { ContentGuideProgram } from '@tunarr/types';
|
||||
import { ContentGuideProgram, tag } from '@tunarr/types';
|
||||
import dayjs from 'dayjs';
|
||||
import { inject, injectable } from 'inversify';
|
||||
import { isUndefined } from 'lodash-es';
|
||||
@@ -47,7 +47,7 @@ const DurationExtractionFilter = z.object({
|
||||
durationMs: z.number(),
|
||||
});
|
||||
|
||||
export type DurationExtractionFilter = z.infer<typeof DurationExtractionFilter>;
|
||||
type DurationExtractionFilter = z.infer<typeof DurationExtractionFilter>;
|
||||
|
||||
const ExtractionFilter = z.discriminatedUnion('type', [
|
||||
ChannelExtractionFilter,
|
||||
@@ -59,7 +59,7 @@ const SubtitleExtractorTaskRequest = z.object({
|
||||
filter: ExtractionFilter.optional(),
|
||||
});
|
||||
|
||||
export type SubtitleExtractorTaskRequest = z.infer<
|
||||
type SubtitleExtractorTaskRequest = z.infer<
|
||||
typeof SubtitleExtractorTaskRequest
|
||||
>;
|
||||
|
||||
@@ -230,12 +230,12 @@ export class SubtitleExtractorTask extends Task2<
|
||||
|
||||
const filePath = getSubtitleCacheFilePath(
|
||||
{
|
||||
externalKey: program.program.externalId,
|
||||
externalSourceId: program.program.mediaSourceId,
|
||||
externalSourceType: program.program.sourceType,
|
||||
externalKey: program.program.externalKey,
|
||||
externalSourceId: tag(program.program.externalSourceId),
|
||||
externalSourceType: program.program.externalSourceType,
|
||||
id: program.uniqueId,
|
||||
},
|
||||
subtitle,
|
||||
{ streamIndex: subtitle.index, codec: subtitle.codec },
|
||||
);
|
||||
if (!filePath) {
|
||||
return;
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import type { MediaSourceType } from '@/db/schema/base.js';
|
||||
import type { MediaSourceId, MediaSourceType } from '@/db/schema/base.js';
|
||||
import crypto from 'node:crypto';
|
||||
import path from 'path';
|
||||
import { match, P } from 'ts-pattern';
|
||||
import type { SubtitleStreamDetails } from '../stream/types.ts';
|
||||
import type { Nullable } from '../types/util.ts';
|
||||
import type { Maybe, Nullable } from '../types/util.ts';
|
||||
|
||||
type MinimalProgram = {
|
||||
id: string;
|
||||
externalSourceType: MediaSourceType;
|
||||
externalSourceId: string;
|
||||
externalSourceId: MediaSourceId;
|
||||
externalKey: string;
|
||||
};
|
||||
|
||||
@@ -22,9 +21,13 @@ export function subtitleCodecToExt(codec: string): Nullable<string> {
|
||||
|
||||
export function getSubtitleCacheFilePath(
|
||||
program: MinimalProgram,
|
||||
subtitleStream: SubtitleStreamDetails,
|
||||
subtitleStream: { streamIndex: Maybe<number>; codec: string },
|
||||
) {
|
||||
const outputPath = getSubtitleCacheFileName(program, subtitleStream);
|
||||
const outputPath = getSubtitleCacheFileName(
|
||||
program,
|
||||
subtitleStream.streamIndex,
|
||||
subtitleStream.codec,
|
||||
);
|
||||
const ext = subtitleCodecToExt(subtitleStream.codec.toLowerCase());
|
||||
if (!ext) {
|
||||
return null;
|
||||
@@ -39,7 +42,8 @@ export function getSubtitleCacheFilePath(
|
||||
|
||||
function getSubtitleCacheFileName(
|
||||
program: MinimalProgram,
|
||||
subtitleStream: SubtitleStreamDetails,
|
||||
streamIndex: Maybe<number>,
|
||||
codec: string,
|
||||
) {
|
||||
// TODO: We should not always include the external key in here. but it will bust the "cache"
|
||||
// if the underlying program changes at the target
|
||||
@@ -49,7 +53,7 @@ function getSubtitleCacheFileName(
|
||||
.update(program.externalSourceType)
|
||||
.update(program.externalSourceId)
|
||||
.update(program.externalKey)
|
||||
.update(subtitleStream.index?.toString() ?? '')
|
||||
.update(subtitleStream.codec)
|
||||
.update(streamIndex?.toString() ?? '')
|
||||
.update(codec)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
@@ -318,6 +318,7 @@ export const PlexMediaSubtitleStreamSchema = BasePlexMediaStreamSchema.extend({
|
||||
.boolean()
|
||||
.or(z.number().transform((i) => i === 1))
|
||||
.optional(),
|
||||
embeddedInVideo: z.coerce.number().optional(),
|
||||
}).partial({
|
||||
bitrate: true,
|
||||
index: true,
|
||||
|
||||
@@ -189,6 +189,7 @@ export const MediaStream = z.object({
|
||||
|
||||
// Subtitles
|
||||
sdh: z.boolean().nullish(),
|
||||
externalKey: z.string().nullish(),
|
||||
|
||||
// Audio or Subtitles
|
||||
languageCodeISO6392: z.string().nullish(),
|
||||
@@ -237,6 +238,7 @@ export const MediaItem = z.object({
|
||||
locations: z.array(MediaLocation),
|
||||
chapters: z.array(MediaChapter).nullish(),
|
||||
scanKind: z.enum(['unknown', 'progressive', 'interlaced']).nullish(),
|
||||
externalKey: z.string().nullish(),
|
||||
});
|
||||
|
||||
const WithMediaItemMetadata = z.object({
|
||||
|
||||
Reference in New Issue
Block a user