Merge remote-tracking branch 'origin/main' into dev

This commit is contained in:
Christian Benincasa
2026-04-16 13:56:44 -04:00
30 changed files with 690 additions and 234 deletions

View 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.

View File

@@ -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);
}

View File

@@ -161,7 +161,7 @@ export class SubtitleStreamPicker {
externalSourceId: lineupItem.program.mediaSourceId,
externalSourceType: lineupItem.program.sourceType,
},
stream,
{ streamIndex: stream.index, codec: stream.codec },
);
if (!filePath) {

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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,
);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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,
);
}
}

View File

@@ -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]),
);

View File

@@ -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(

View File

@@ -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]),
);

View File

@@ -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;
}
}
}

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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),
);

View File

@@ -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;

View File

@@ -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');
}

View File

@@ -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,

View File

@@ -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({