From 47e78ff698fd444754d189433bddc29543cdb710 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 21 May 2026 22:26:59 -0700 Subject: [PATCH] fix(jellyfin): show overlay, inject plugin, and fix stats title on playb - Show visible overlay automatically during Jellyfin playback so subtitleStyle applies - Inject bundled mpv plugin on auto-launch so keybindings work without overlay focus - Group Jellyfin playback stats under item metadata (jellyfin://host/item/id) instead of stream URLs so episodes merge with matching local titles - Mark ffsubsync unavailable in subsync modal for remote media paths - Drain queued second-instance commands even when onReady throws --- changes/config-settings-option-labels.md | 4 - changes/fix-jellyfin-stats-title.md | 4 + changes/fix-known-words-decks-row-overflow.md | 4 - changes/fix-prerelease-update-channel.md | 4 + changes/jellyfin-visible-overlay.md | 5 + changes/rename-config-window-to-settings.md | 7 - changes/settings-modal-layout.md | 4 - docs-site/configuration.md | 2 + docs-site/jellyfin-integration.md | 2 + docs-site/mining-workflow.md | 2 + docs-site/troubleshooting.md | 2 +- launcher/mpv.test.ts | 1 + src/core/services/app-lifecycle.test.ts | 38 +++++ src/core/services/app-lifecycle.ts | 9 +- .../immersion-tracker-service.test.ts | 92 ++++++++++ .../services/immersion-tracker-service.ts | 109 +++++++++++- src/core/services/jellyfin.test.ts | 4 + src/core/services/jellyfin.ts | 26 ++- src/core/services/subsync.test.ts | 41 +++++ src/core/services/subsync.ts | 1 + src/main.ts | 51 ++++++ src/main/overlay-runtime.test.ts | 4 +- .../runtime/app-lifecycle-actions.test.ts | 2 + src/main/runtime/app-lifecycle-actions.ts | 2 + .../app-lifecycle-main-cleanup.test.ts | 4 + .../runtime/app-lifecycle-main-cleanup.ts | 2 + .../jellyfin-runtime-composer.test.ts | 8 + .../composers/jellyfin-runtime-composer.ts | 2 + .../startup-lifecycle-composer.test.ts | 1 + ...jellyfin-playback-launch-main-deps.test.ts | 11 ++ .../jellyfin-playback-launch-main-deps.ts | 4 + .../runtime/jellyfin-playback-launch.test.ts | 32 +++- src/main/runtime/jellyfin-playback-launch.ts | 28 +++- ...llyfin-remote-connection-main-deps.test.ts | 10 ++ .../jellyfin-remote-connection-main-deps.ts | 2 + .../jellyfin-remote-connection.test.ts | 44 +++++ .../runtime/jellyfin-remote-connection.ts | 11 ++ src/main/runtime/jellyfin-setup-window.ts | 149 +++++++++++++++-- ...ellyfin-subtitle-preload-main-deps.test.ts | 9 +- .../jellyfin-subtitle-preload-main-deps.ts | 2 + .../runtime/jellyfin-subtitle-preload.test.ts | 157 ++++++++++++++---- src/main/runtime/jellyfin-subtitle-preload.ts | 38 ++++- .../runtime/local-subtitle-selection.test.ts | 54 ++++++ src/main/runtime/local-subtitle-selection.ts | 13 +- src/main/runtime/subsync-open.test.ts | 1 + .../runtime/update/update-service.test.ts | 35 ++++ src/renderer/modals/subsync.test.ts | 20 +++ src/renderer/modals/subsync.ts | 22 ++- src/types/runtime.ts | 1 + 49 files changed, 975 insertions(+), 105 deletions(-) delete mode 100644 changes/config-settings-option-labels.md create mode 100644 changes/fix-jellyfin-stats-title.md delete mode 100644 changes/fix-known-words-decks-row-overflow.md create mode 100644 changes/fix-prerelease-update-channel.md create mode 100644 changes/jellyfin-visible-overlay.md delete mode 100644 changes/rename-config-window-to-settings.md delete mode 100644 changes/settings-modal-layout.md diff --git a/changes/config-settings-option-labels.md b/changes/config-settings-option-labels.md deleted file mode 100644 index 1a7fb00e..00000000 --- a/changes/config-settings-option-labels.md +++ /dev/null @@ -1,4 +0,0 @@ -type: changed -area: settings - -- Simplified configuration option rows by hiding raw config paths and placing the live/restart status beside each option title. diff --git a/changes/fix-jellyfin-stats-title.md b/changes/fix-jellyfin-stats-title.md new file mode 100644 index 00000000..06a585c1 --- /dev/null +++ b/changes/fix-jellyfin-stats-title.md @@ -0,0 +1,4 @@ +type: fixed +area: stats + +- Grouped Jellyfin playback stats under Jellyfin item metadata instead of stream URLs, so watched episodes merge with matching local library titles and keep clean display names. diff --git a/changes/fix-known-words-decks-row-overflow.md b/changes/fix-known-words-decks-row-overflow.md deleted file mode 100644 index 467a358d..00000000 --- a/changes/fix-known-words-decks-row-overflow.md +++ /dev/null @@ -1,4 +0,0 @@ -type: changed -area: config - -- Reorganized each known-words deck row in the Settings window into a card with the deck name on its own header line so longer deck names stay readable instead of being truncated. diff --git a/changes/fix-prerelease-update-channel.md b/changes/fix-prerelease-update-channel.md new file mode 100644 index 00000000..adf2825c --- /dev/null +++ b/changes/fix-prerelease-update-channel.md @@ -0,0 +1,4 @@ +type: fixed +area: updater + +- Clarified that beta/RC update checks are controlled by `updates.channel`; set it to `"prerelease"` to receive beta/RC updates. diff --git a/changes/jellyfin-visible-overlay.md b/changes/jellyfin-visible-overlay.md new file mode 100644 index 00000000..5ce8af7d --- /dev/null +++ b/changes/jellyfin-visible-overlay.md @@ -0,0 +1,5 @@ +type: fixed +area: jellyfin + +- Showed the visible subtitle overlay automatically during Jellyfin playback so configured `subtitleStyle` appearance applies to Jellyfin subtitles. +- Injected the bundled mpv plugin when SubMiner auto-launches mpv for Jellyfin playback, restoring mpv-side keybindings without needing overlay focus. diff --git a/changes/rename-config-window-to-settings.md b/changes/rename-config-window-to-settings.md deleted file mode 100644 index 2eb171b4..00000000 --- a/changes/rename-config-window-to-settings.md +++ /dev/null @@ -1,7 +0,0 @@ -type: changed -area: launcher -breaking: true - -- Renamed the SubMiner Configuration window to the Settings window across the UI, tray menu, docs, and CLI verbiage. -- Replaced the `--config` flag and `subminer config` (no action) entry points with `--settings` and `subminer settings`. The `subminer config` subcommand now only accepts `path` or `show`. -- Removed the `--settings` alias that previously opened the bundled Yomitan settings popup. Use `--yomitan` to open Yomitan settings. diff --git a/changes/settings-modal-layout.md b/changes/settings-modal-layout.md deleted file mode 100644 index 8ec6c8b2..00000000 --- a/changes/settings-modal-layout.md +++ /dev/null @@ -1,4 +0,0 @@ -type: changed -area: config - -- Settings: reorganized playback, shortcut, WebSocket, tracking, Jellyfin, character dictionary, and Discord presence controls in the settings modal. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index d3d1e7ce..74acc2fa 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -1299,6 +1299,8 @@ See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enabled`, `jellyfin.remoteControlEnabled`, and `jellyfin.remoteControlAutoConnect`. +Jellyfin playback auto-launched through SubMiner loads the mpv plugin the same way regular playback does, and shows the visible subtitle overlay automatically so `subtitleStyle` applies to subtitles selected from Jellyfin. + When Jellyfin is enabled with a server URL and SubMiner is running, the tray menu also shows a `Jellyfin Discovery` checkbox. It starts or stops discovery for the current runtime session only and does not write config. Starting discovery still requires a valid stored or environment-provided Jellyfin auth session. ### Discord Rich Presence diff --git a/docs-site/jellyfin-integration.md b/docs-site/jellyfin-integration.md index 6a63b0c5..0c32a2ce 100644 --- a/docs-site/jellyfin-integration.md +++ b/docs-site/jellyfin-integration.md @@ -130,6 +130,8 @@ Optional stream overrides: - If direct play is not selected/available, SubMiner requests a Jellyfin transcoded stream (`master.m3u8`) using `jellyfin.transcodeVideoCodec`. - Resume position (`PlaybackPositionTicks`) is applied via mpv seek. - Media title is set in mpv as `[Jellyfin/] `. +- When SubMiner auto-launches mpv for Jellyfin playback, it injects the bundled mpv plugin unless an installed SubMiner mpv plugin is already present. This keeps mpv-side keybindings available without clicking the overlay first. +- Jellyfin playback shows the SubMiner visible overlay before selecting subtitle tracks, so `subtitleStyle` controls the rendered subtitle appearance. Use the overlay toggle shortcut if you want to hide it for a session. ## Cast To Device Mode (jellyfin-mpv-shim style) diff --git a/docs-site/mining-workflow.md b/docs-site/mining-workflow.md index 8ec85ca5..6ff53936 100644 --- a/docs-site/mining-workflow.md +++ b/docs-site/mining-workflow.md @@ -164,6 +164,8 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize 3. For alass, select a reference subtitle track from the video. 4. SubMiner runs the sync and reloads the corrected subtitle. +For remote streams, including Jellyfin playback, the modal only offers alass. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync needs direct access to the local media file and is unavailable for stream URLs. + Install the sync tools separately — see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found. ## Texthooker diff --git a/docs-site/troubleshooting.md b/docs-site/troubleshooting.md index 309d0495..0cd58aa5 100644 --- a/docs-site/troubleshooting.md +++ b/docs-site/troubleshooting.md @@ -114,7 +114,7 @@ Automatic checks log failures quietly so playback is not interrupted. **"SubMiner is up to date" but a prerelease exists** -SubMiner defaults to stable GitHub releases. Set `updates.channel` to `"prerelease"` in `config.jsonc` when you want update checks to include beta and RC releases. +SubMiner uses the configured release channel for update checks. Set `updates.channel` to `"prerelease"` in `config.jsonc` when you want update checks to include beta and RC releases. **Launcher update shows a sudo command** diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 5f4b1232..f070efc1 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -819,6 +819,7 @@ test('startOverlay uses caller config dir for app control socket discovery', asy const { dir, socketPath } = createTempSocketPath(); const configDir = path.join(dir, 'launcher-config'); const controlSocketPath = getAppControlSocketPath({ configDir, platform: 'linux' }); + fs.mkdirSync(configDir, { recursive: true }); const appPath = path.join(dir, 'fake-subminer.sh'); const appInvocationsPath = path.join(dir, 'app-invocations.log'); const receivedControlArgv: string[][] = []; diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index 651f292e..8d6df14c 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -280,6 +280,44 @@ test('startAppLifecycle routes control socket commands through the second-instan assert.deepEqual(handled, ['ready', 'second-instance:start', 'control-close']); }); +test('startAppLifecycle drains queued second-instance commands when app ready runtime fails', async () => { + const handled: string[] = []; + let controlArgvHandler: ((argv: string[]) => void) | null = null; + let readyHandler: (() => Promise<void>) | null = null; + + const { deps } = createDeps({ + shouldStartApp: () => true, + parseArgs: (argv) => makeArgs({ start: argv.includes('--start') }), + handleCliCommand: (args, source) => { + handled.push(`${source}:${args.start ? 'start' : 'other'}`); + }, + startControlServer: (handler) => { + controlArgvHandler = handler; + }, + whenReady: (handler) => { + readyHandler = handler; + }, + onReady: async () => { + handled.push('ready'); + throw new Error('ready failed'); + }, + }); + + startAppLifecycle(makeArgs({ background: true }), deps); + + assert.ok(controlArgvHandler); + (controlArgvHandler as (argv: string[]) => void)(['--start']); + assert.deepEqual(handled, []); + + assert.ok(readyHandler); + await assert.rejects((readyHandler as () => Promise<void>)(), /ready failed/); + + assert.deepEqual(handled, ['ready', 'second-instance:start']); + + (controlArgvHandler as (argv: string[]) => void)(['--start']); + assert.deepEqual(handled, ['ready', 'second-instance:start', 'second-instance:start']); +}); + test('startAppLifecycle quits macOS config-only launch when all windows close', () => { let windowAllClosedHandler: (() => void) | null = null; const { deps, calls } = createDeps({ diff --git a/src/core/services/app-lifecycle.ts b/src/core/services/app-lifecycle.ts index cabf8f5c..c82adfde 100644 --- a/src/core/services/app-lifecycle.ts +++ b/src/core/services/app-lifecycle.ts @@ -172,9 +172,12 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic } deps.whenReady(async () => { - await deps.onReady(); - appReadyRuntimeComplete = true; - flushPendingSecondInstanceCommands(); + try { + await deps.onReady(); + } finally { + appReadyRuntimeComplete = true; + flushPendingSecondInstanceCommands(); + } }); deps.onWindowAllClosed(() => { diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index 3134f129..01439d44 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -1552,6 +1552,98 @@ test('handleMediaChange reuses the same provisional anime row across matching fi } }); +test('Jellyfin playback metadata links stream videos to existing series title', async () => { + const dbPath = makeDbPath(); + let tracker: ImmersionTrackerService | null = null; + + try { + const Ctor = await loadTrackerCtor(); + tracker = new Ctor({ dbPath }); + + tracker.handleMediaChange('/tmp/The Beginning After the End S02E01.mkv', 'Episode 1'); + await waitForPendingAnimeMetadata(tracker); + tracker.destroy(); + tracker = null; + + tracker = new Ctor({ dbPath }); + tracker.recordJellyfinPlaybackMetadata({ + mediaPath: + 'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1', + displayTitle: 'The Beginning After the End S02E02 The Princess Begins Adventuring', + itemTitle: 'The Princess Begins Adventuring', + seriesTitle: 'The Beginning After the End', + seasonNumber: 2, + episodeNumber: 2, + itemId: 'item-2', + }); + tracker.handleMediaChange( + 'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1', + 'The Beginning After the End S02E02 The Princess Begins Adventuring', + ); + tracker.handleMediaChange(null, null); + tracker.recordJellyfinPlaybackMetadata({ + mediaPath: + 'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1&StartTimeTicks=12000000', + displayTitle: 'The Beginning After the End S02E02 The Princess Begins Adventuring', + itemTitle: 'The Princess Begins Adventuring', + seriesTitle: 'The Beginning After the End', + seasonNumber: 2, + episodeNumber: 2, + itemId: 'item-2', + }); + tracker.handleMediaChange( + 'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1&StartTimeTicks=12000000', + 'The Beginning After the End S02E02 The Princess Begins Adventuring', + ); + + const privateApi = tracker as unknown as { db: DatabaseSync }; + const rows = privateApi.db + .prepare( + ` + SELECT + v.source_url, + v.canonical_title AS video_title, + v.parsed_title, + v.parsed_season, + v.parsed_episode, + v.parser_source, + a.canonical_title AS anime_title + FROM imm_videos v + JOIN imm_anime a ON a.anime_id = v.anime_id + ORDER BY v.video_id + `, + ) + .all() as Array<{ + source_url: string | null; + video_title: string; + parsed_title: string | null; + parsed_season: number | null; + parsed_episode: number | null; + parser_source: string | null; + anime_title: string; + }>; + + assert.equal(rows.length, 2); + assert.equal(new Set(rows.map((row) => row.anime_title)).size, 1); + const jellyfinRow = rows.find( + (row) => row.source_url === 'jellyfin://jellyfin.local/item/item-2', + ); + assert.ok(jellyfinRow); + assert.equal( + jellyfinRow.video_title, + 'The Beginning After the End S02E02 The Princess Begins Adventuring', + ); + assert.equal(jellyfinRow.parsed_title, 'The Beginning After the End'); + assert.equal(jellyfinRow.parsed_season, 2); + assert.equal(jellyfinRow.parsed_episode, 2); + assert.equal(jellyfinRow.parser_source, 'jellyfin'); + assert.equal(jellyfinRow.anime_title, 'The Beginning After the End'); + } finally { + tracker?.destroy(); + cleanupDbPath(dbPath); + } +}); + test('applies configurable queue, flush, and retention policy', async () => { const dbPath = makeDbPath(); let tracker: ImmersionTrackerService | null = null; diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 7aa393c7..55bb17bf 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -301,6 +301,33 @@ export type { VocabularyStatsRow, } from './immersion-tracker/types'; +export interface JellyfinPlaybackMetadataInput { + mediaPath: string; + displayTitle: string; + itemTitle: string; + seriesTitle: string | null; + seasonNumber: number | null; + episodeNumber: number | null; + itemId: string; +} + +function normalizeMetadataInt(value: number | null | undefined): number | null { + return typeof value === 'number' && Number.isSafeInteger(value) ? value : null; +} + +function buildJellyfinStatsMediaPath(mediaPath: string, itemId: string): string { + const normalizedItemId = normalizeText(itemId); + if (!normalizedItemId) { + return mediaPath; + } + try { + const parsed = new URL(mediaPath); + return `jellyfin://${parsed.host}/item/${encodeURIComponent(normalizedItemId)}`; + } catch { + return `jellyfin://item/${encodeURIComponent(normalizedItemId)}`; + } +} + export class ImmersionTrackerService { private readonly logger = createLogger('main:immersion-tracker'); private readonly db: DatabaseSync; @@ -337,6 +364,7 @@ export class ImmersionTrackerService { private readonly pendingYoutubeMetadataFetches = new Map<number, Promise<void>>(); private readonly recordedSubtitleKeys = new Set<string>(); private readonly pendingAnimeMetadataUpdates = new Map<number, Promise<void>>(); + private readonly mediaPathAliases = new Map<string, string>(); private readonly resolveLegacyVocabularyPos: | ((row: LegacyVocabularyPosRow) => Promise<LegacyVocabularyPosResolution | null>) | undefined; @@ -1115,8 +1143,85 @@ export class ImmersionTrackerService { rebuildLifetimeSummaryTables(this.db); } + recordJellyfinPlaybackMetadata(metadata: JellyfinPlaybackMetadataInput): void { + const rawPath = normalizeMediaPath(metadata.mediaPath); + if (!rawPath) { + return; + } + const normalizedPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId); + this.mediaPathAliases.set(rawPath, normalizedPath); + + const displayTitle = + normalizeText(metadata.displayTitle) || + normalizeText(metadata.itemTitle) || + deriveCanonicalTitle(normalizedPath); + const itemTitle = normalizeText(metadata.itemTitle) || displayTitle; + const seriesTitle = normalizeText(metadata.seriesTitle); + const libraryTitle = seriesTitle || itemTitle; + if (!libraryTitle) { + return; + } + + const videoId = getOrCreateVideoRecord( + this.db, + buildVideoKey(normalizedPath, SOURCE_TYPE_REMOTE), + { + canonicalTitle: displayTitle, + sourcePath: null, + sourceUrl: normalizedPath, + sourceType: SOURCE_TYPE_REMOTE, + }, + ); + const previousLink = this.db + .prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?') + .get(videoId) as { animeId: number | null } | null; + const metadataJson = JSON.stringify({ + source: 'jellyfin', + itemId: normalizeText(metadata.itemId) || null, + itemTitle, + seriesTitle: seriesTitle || null, + displayTitle, + seasonNumber: normalizeMetadataInt(metadata.seasonNumber), + episodeNumber: normalizeMetadataInt(metadata.episodeNumber), + }); + const animeId = getOrCreateAnimeRecord(this.db, { + parsedTitle: libraryTitle, + canonicalTitle: libraryTitle, + anilistId: null, + titleRomaji: null, + titleEnglish: null, + titleNative: null, + metadataJson, + }); + linkVideoToAnimeRecord(this.db, videoId, { + animeId, + parsedBasename: null, + parsedTitle: libraryTitle, + parsedSeason: normalizeMetadataInt(metadata.seasonNumber), + parsedEpisode: normalizeMetadataInt(metadata.episodeNumber), + parserSource: 'jellyfin', + parserConfidence: 1, + parseMetadataJson: metadataJson, + }); + + const hasLifetimeMedia = Boolean( + this.db.prepare('SELECT 1 FROM imm_lifetime_media WHERE video_id = ?').get(videoId), + ); + if (hasLifetimeMedia || (previousLink && previousLink.animeId !== animeId)) { + rebuildLifetimeSummaryTables(this.db); + } + } + + private hasJellyfinMetadata(videoId: number): boolean { + const row = this.db + .prepare('SELECT parser_source AS parserSource FROM imm_videos WHERE video_id = ?') + .get(videoId) as { parserSource: string | null } | null; + return row?.parserSource === 'jellyfin'; + } + handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void { - const normalizedPath = normalizeMediaPath(mediaPath); + const rawPath = normalizeMediaPath(mediaPath); + const normalizedPath = this.mediaPathAliases.get(rawPath) ?? rawPath; const normalizedTitle = normalizeText(mediaTitle); this.logger.info( `handleMediaChange called with path=${normalizedPath || '<empty>'} title=${normalizedTitle || '<empty>'}`, @@ -1164,7 +1269,7 @@ export class ImmersionTrackerService { if (youtubeVideoId) { void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId); this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath); - } else { + } else if (!this.hasJellyfinMetadata(sessionInfo.videoId)) { this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null); } this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath); diff --git a/src/core/services/jellyfin.test.ts b/src/core/services/jellyfin.test.ts index c8384f77..1bcc56d8 100644 --- a/src/core/services/jellyfin.test.ts +++ b/src/core/services/jellyfin.test.ts @@ -560,6 +560,10 @@ test('resolvePlaybackPlan preserves episode metadata, stream selection, and resu assert.equal(plan.mode, 'direct'); assert.equal(plan.title, 'Galaxy Quest S02E07 A New Hope'); + assert.equal(plan.itemTitle, 'A New Hope'); + assert.equal(plan.seriesTitle, 'Galaxy Quest'); + assert.equal(plan.seasonNumber, 2); + assert.equal(plan.episodeNumber, 7); assert.equal(plan.audioStreamIndex, 6); assert.equal(plan.subtitleStreamIndex, 9); assert.equal(plan.startTimeTicks, 35_000_000); diff --git a/src/core/services/jellyfin.ts b/src/core/services/jellyfin.ts index 46de6809..da9b0b12 100644 --- a/src/core/services/jellyfin.ts +++ b/src/core/services/jellyfin.ts @@ -27,6 +27,10 @@ export interface JellyfinPlaybackPlan { mode: 'direct' | 'transcode'; url: string; title: string; + itemTitle: string; + seriesTitle: string | null; + seasonNumber: number | null; + episodeNumber: number | null; startTimeTicks: number; audioStreamIndex: number | null; subtitleStreamIndex: number | null; @@ -292,14 +296,24 @@ function getStreamDefaults(source: JellyfinMediaSource): { }; } +function getItemTitle(item: JellyfinItem): string { + return ensureString(item.Name).trim() || 'Jellyfin Item'; +} + +function getSeriesTitle(item: JellyfinItem): string | null { + return ensureString(item.SeriesName).trim() || null; +} + function getDisplayTitle(item: JellyfinItem): string { + const itemTitle = getItemTitle(item); if (item.Type === 'Episode') { const season = asIntegerOrNull(item.ParentIndexNumber) ?? 0; const episode = asIntegerOrNull(item.IndexNumber) ?? 0; - const prefix = item.SeriesName ? `${item.SeriesName} ` : ''; - return `${prefix}S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')} ${ensureString(item.Name).trim()}`.trim(); + const seriesTitle = getSeriesTitle(item); + const prefix = seriesTitle ? `${seriesTitle} ` : ''; + return `${prefix}S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')} ${itemTitle}`.trim(); } - return ensureString(item.Name).trim() || 'Jellyfin Item'; + return itemTitle; } function shouldPreferDirectPlay(source: JellyfinMediaSource, config: JellyfinConfig): boolean { @@ -521,10 +535,16 @@ export async function resolvePlaybackPlan( const audioStreamIndex = selection.audioStreamIndex ?? defaults.audioStreamIndex ?? null; const subtitleStreamIndex = selection.subtitleStreamIndex ?? null; const startTimeTicks = Math.max(0, asIntegerOrNull(item.UserData?.PlaybackPositionTicks) ?? 0); + const itemTitle = getItemTitle(item); + const seriesTitle = item.Type === 'Episode' ? getSeriesTitle(item) : null; const basePlan: JellyfinPlaybackPlan = { mode: 'transcode', url: '', title: getDisplayTitle(item), + itemTitle, + seriesTitle, + seasonNumber: item.Type === 'Episode' ? asIntegerOrNull(item.ParentIndexNumber) : null, + episodeNumber: item.Type === 'Episode' ? asIntegerOrNull(item.IndexNumber) : null, startTimeTicks, audioStreamIndex, subtitleStreamIndex, diff --git a/src/core/services/subsync.test.ts b/src/core/services/subsync.test.ts index 8b38fe16..e86bf2b2 100644 --- a/src/core/services/subsync.test.ts +++ b/src/core/services/subsync.test.ts @@ -70,12 +70,14 @@ test('triggerSubsyncFromConfig returns early when already in progress', async () test('triggerSubsyncFromConfig opens manual picker', async () => { const osd: string[] = []; let payloadTrackCount = 0; + let ffsubsyncAvailable: boolean | null = null; let inProgressState: boolean | null = null; await triggerSubsyncFromConfig( makeDeps({ openManualPicker: (payload) => { payloadTrackCount = payload.sourceTracks.length; + ffsubsyncAvailable = payload.ffsubsyncAvailable; }, showMpvOsd: (text) => { osd.push(text); @@ -87,10 +89,49 @@ test('triggerSubsyncFromConfig opens manual picker', async () => { ); assert.equal(payloadTrackCount, 1); + assert.equal(ffsubsyncAvailable, true); assert.ok(osd.includes('Subsync: choose engine and source')); assert.equal(inProgressState, false); }); +test('triggerSubsyncFromConfig marks ffsubsync unavailable for remote media paths', async () => { + let ffsubsyncAvailable: boolean | null = null; + + await triggerSubsyncFromConfig( + makeDeps({ + getMpvClient: () => ({ + connected: true, + currentAudioStreamIndex: null, + send: () => {}, + requestProperty: async (name: string) => { + if (name === 'path') return 'https://jellyfin.example/Videos/movie/stream.mkv'; + if (name === 'sid') return 1; + if (name === 'secondary-sid') return null; + if (name === 'track-list') { + return [ + { id: 1, type: 'sub', selected: true, lang: 'jpn' }, + { + id: 2, + type: 'sub', + selected: false, + external: true, + lang: 'eng', + 'external-filename': 'https://jellyfin.example/subs/eng.srt', + }, + ]; + } + return null; + }, + }), + openManualPicker: (payload) => { + ffsubsyncAvailable = payload.ffsubsyncAvailable; + }, + }), + ); + + assert.equal(ffsubsyncAvailable, false); +}); + test('triggerSubsyncFromConfig does not run automatic sync', async () => { const osd: string[] = []; let payloadTrackCount = 0; diff --git a/src/core/services/subsync.ts b/src/core/services/subsync.ts index 480e50ce..053076f2 100644 --- a/src/core/services/subsync.ts +++ b/src/core/services/subsync.ts @@ -378,6 +378,7 @@ export async function openSubsyncManualPicker(deps: TriggerSubsyncFromConfigDeps const client = getMpvClientForSubsync(deps); const context = await gatherSubsyncContext(client); const payload: SubsyncManualPayload = { + ffsubsyncAvailable: !isRemoteMediaPath(context.videoPath), sourceTracks: context.sourceTracks .filter((track) => typeof track.id === 'number') .map((track) => ({ diff --git a/src/main.ts b/src/main.ts index f9781bbb..60f9c6b2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2801,6 +2801,7 @@ const { reportJellyfinRemoteStopped, startJellyfinRemoteSession, stopJellyfinRemoteSession, + cleanupJellyfinSubtitleCache, runJellyfinCommand, openJellyfinSetupWindow, getJellyfinClientInfo, @@ -2824,6 +2825,15 @@ const { getLaunchMode: () => getResolvedConfig().mpv.launchMode, platform: process.platform, execPath: process.execPath, + getRuntimePluginEntrypoint: () => resolveBundledMpvRuntimePluginEntrypoint(), + getInstalledPluginDetection: () => + detectInstalledMpvPlugin({ + platform: process.platform, + homeDir: os.homedir(), + xdgConfigHome: process.env.XDG_CONFIG_HOME, + appDataDir: app.getPath('appData'), + mpvExecutablePath: getResolvedConfig().mpv.executablePath, + }), getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(), defaultMpvLogPath: DEFAULT_MPV_LOG_PATH, defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS, @@ -2859,6 +2869,41 @@ const { sendMpvCommandRuntime(appState.mpvClient, command); }, wait: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)), + cacheSubtitleTrack: async (track) => { + if (!track.deliveryUrl) { + throw new Error('Jellyfin subtitle track has no delivery URL'); + } + + const cacheDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'subminer-jellyfin-subtitles-'), + ); + const urlPath = (() => { + try { + return new URL(track.deliveryUrl).pathname; + } catch { + return track.deliveryUrl; + } + })(); + const ext = path.extname(urlPath).slice(0, 16) || '.srt'; + const subtitlePath = path.join(cacheDir, `track-${track.index}${ext}`); + try { + const response = await fetch(track.deliveryUrl); + if (!response.ok) { + throw new Error(`Failed to download Jellyfin subtitle (HTTP ${response.status})`); + } + const bytes = new Uint8Array(await response.arrayBuffer()); + await fs.promises.writeFile(subtitlePath, bytes); + } catch (error) { + fs.rmSync(cacheDir, { recursive: true, force: true }); + throw error; + } + return { path: subtitlePath, cleanupDir: cacheDir }; + }, + cleanupCachedSubtitles: (dirs) => { + for (const dir of dirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }, logDebug: (message, error) => { logger.debug(message, error); }, @@ -2877,6 +2922,7 @@ const { }, ), applyJellyfinMpvDefaults: (mpvClient) => applyJellyfinMpvDefaults(mpvClient), + showVisibleOverlay: () => setVisibleOverlayVisible(true), sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command), armQuitOnDisconnect: () => { jellyfinPlayQuitOnDisconnectArmed = false; @@ -2900,6 +2946,10 @@ const { showMpvOsd: (text) => { showMpvOsd(text); }, + recordJellyfinPlaybackMetadata: (metadata) => { + ensureImmersionTrackerStarted(); + appState.immersionTracker?.recordJellyfinPlaybackMetadata(metadata); + }, }, remoteComposerOptions: { getConfiguredSession: () => getConfiguredJellyfinSession(getResolvedJellyfinConfig()), @@ -3670,6 +3720,7 @@ const { }, stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(), cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(), + cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(), stopDiscordPresenceService: () => { void appState.discordPresenceService?.stop(); appState.discordPresenceService = null; diff --git a/src/main/overlay-runtime.test.ts b/src/main/overlay-runtime.test.ts index a9a80035..ff10d2ac 100644 --- a/src/main/overlay-runtime.test.ts +++ b/src/main/overlay-runtime.test.ts @@ -313,7 +313,7 @@ test('handleOverlayModalClosed hides modal window only after all pending modals }); runtime.sendToActiveOverlayWindow( 'subsync:open-manual', - { sourceTracks: [] }, + { ffsubsyncAvailable: true, sourceTracks: [] }, { restoreOnModalClose: 'subsync', }, @@ -459,7 +459,7 @@ test('modal runtime notifies callers when modal input state becomes active/inact }); runtime.sendToActiveOverlayWindow( 'subsync:open-manual', - { sourceTracks: [] }, + { ffsubsyncAvailable: true, sourceTracks: [] }, { restoreOnModalClose: 'subsync', }, diff --git a/src/main/runtime/app-lifecycle-actions.test.ts b/src/main/runtime/app-lifecycle-actions.test.ts index eae95178..94516c12 100644 --- a/src/main/runtime/app-lifecycle-actions.test.ts +++ b/src/main/runtime/app-lifecycle-actions.test.ts @@ -41,6 +41,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => { clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'), stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), + cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), stopDiscordPresenceService: () => calls.push('stop-discord-presence'), }); @@ -48,6 +49,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => { assert.equal(calls.length, 31); assert.equal(calls[0], 'destroy-tray'); assert.equal(calls[calls.length - 1], 'stop-discord-presence'); + assert.ok(calls.includes('cleanup-jellyfin-subtitles')); assert.ok(calls.includes('clear-windows-visible-overlay-poll')); assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts')); assert.ok(calls.includes('cleanup-youtube-subtitles')); diff --git a/src/main/runtime/app-lifecycle-actions.ts b/src/main/runtime/app-lifecycle-actions.ts index 1978c571..a113e795 100644 --- a/src/main/runtime/app-lifecycle-actions.ts +++ b/src/main/runtime/app-lifecycle-actions.ts @@ -29,6 +29,7 @@ export function createOnWillQuitCleanupHandler(deps: { clearYomitanSettingsWindow: () => void; stopJellyfinRemoteSession: () => void; cleanupYoutubeSubtitleTempDirs: () => void; + cleanupJellyfinSubtitleCache: () => void; stopDiscordPresenceService: () => void; }) { return (): void => { @@ -62,6 +63,7 @@ export function createOnWillQuitCleanupHandler(deps: { deps.clearYomitanSettingsWindow(); deps.stopJellyfinRemoteSession(); deps.cleanupYoutubeSubtitleTempDirs(); + deps.cleanupJellyfinSubtitleCache(); deps.stopDiscordPresenceService(); }; } diff --git a/src/main/runtime/app-lifecycle-main-cleanup.test.ts b/src/main/runtime/app-lifecycle-main-cleanup.test.ts index 0d57d6d9..a7067f6d 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.test.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.test.ts @@ -70,6 +70,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects' stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), + cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), stopDiscordPresenceService: () => calls.push('stop-discord-presence'), }); @@ -91,6 +92,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects' assert.ok(calls.includes('destroy-yomitan-settings-window')); assert.ok(calls.includes('stop-jellyfin-remote')); assert.ok(calls.includes('cleanup-youtube-subtitles')); + assert.ok(calls.includes('cleanup-jellyfin-subtitles')); assert.ok(calls.includes('stop-discord-presence')); assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop')); assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts')); @@ -145,6 +147,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => { clearYomitanSettingsWindow: () => {}, stopJellyfinRemoteSession: () => {}, cleanupYoutubeSubtitleTempDirs: () => {}, + cleanupJellyfinSubtitleCache: () => {}, stopDiscordPresenceService: () => {}, }); @@ -194,6 +197,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () = clearYomitanSettingsWindow: () => {}, stopJellyfinRemoteSession: () => {}, cleanupYoutubeSubtitleTempDirs: () => {}, + cleanupJellyfinSubtitleCache: () => {}, stopDiscordPresenceService: () => {}, }); diff --git a/src/main/runtime/app-lifecycle-main-cleanup.ts b/src/main/runtime/app-lifecycle-main-cleanup.ts index f6d42da2..9693ac05 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.ts @@ -58,6 +58,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: { stopJellyfinRemoteSession: () => void; cleanupYoutubeSubtitleTempDirs: () => void; + cleanupJellyfinSubtitleCache: () => void; stopDiscordPresenceService: () => void; }) { return () => ({ @@ -141,6 +142,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: { clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(), stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(), cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(), + cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(), stopDiscordPresenceService: () => deps.stopDiscordPresenceService(), }); } diff --git a/src/main/runtime/composers/jellyfin-runtime-composer.test.ts b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts index 273d8691..adc11403 100644 --- a/src/main/runtime/composers/jellyfin-runtime-composer.test.ts +++ b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts @@ -50,6 +50,8 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers' getMpvClient: () => null, sendMpvCommand: () => {}, wait: async () => {}, + cacheSubtitleTrack: async () => ({ path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' }), + cleanupCachedSubtitles: () => {}, logDebug: () => {}, }, playJellyfinItemInMpvMainDeps: { @@ -58,11 +60,16 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers' mode: 'direct', url: 'https://example.test/video.m3u8', title: 'Episode 1', + itemTitle: 'Episode 1', + seriesTitle: null, + seasonNumber: null, + episodeNumber: null, startTimeTicks: 0, audioStreamIndex: null, subtitleStreamIndex: null, }), applyJellyfinMpvDefaults: () => {}, + showVisibleOverlay: () => {}, sendMpvCommand: () => {}, armQuitOnDisconnect: () => {}, schedule: () => undefined, @@ -189,6 +196,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers' assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function'); assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function'); assert.equal(typeof composed.playJellyfinItemInMpv, 'function'); + assert.equal(typeof composed.cleanupJellyfinSubtitleCache, 'function'); assert.equal(typeof composed.startJellyfinRemoteSession, 'function'); assert.equal(typeof composed.stopJellyfinRemoteSession, 'function'); assert.equal(typeof composed.runJellyfinCommand, 'function'); diff --git a/src/main/runtime/composers/jellyfin-runtime-composer.ts b/src/main/runtime/composers/jellyfin-runtime-composer.ts index ee8d5b2b..4e2b5433 100644 --- a/src/main/runtime/composers/jellyfin-runtime-composer.ts +++ b/src/main/runtime/composers/jellyfin-runtime-composer.ts @@ -142,6 +142,7 @@ export type JellyfinRuntimeComposerResult = ComposerOutputs<{ typeof composeJellyfinRemoteHandlers >['handleJellyfinRemoteGeneralCommand']; playJellyfinItemInMpv: ReturnType<typeof createPlayJellyfinItemInMpvHandler>; + cleanupJellyfinSubtitleCache: () => void; startJellyfinRemoteSession: ReturnType<typeof createStartJellyfinRemoteSessionHandler>; stopJellyfinRemoteSession: ReturnType<typeof createStopJellyfinRemoteSessionHandler>; runJellyfinCommand: ReturnType<typeof createRunJellyfinCommandHandler>; @@ -280,6 +281,7 @@ export function composeJellyfinRuntimeHandlers( handleJellyfinRemotePlaystate, handleJellyfinRemoteGeneralCommand, playJellyfinItemInMpv, + cleanupJellyfinSubtitleCache: () => preloadJellyfinExternalSubtitles.cleanupCachedSubtitles(), startJellyfinRemoteSession, stopJellyfinRemoteSession, runJellyfinCommand, diff --git a/src/main/runtime/composers/startup-lifecycle-composer.test.ts b/src/main/runtime/composers/startup-lifecycle-composer.test.ts index 5bea06e4..62e58076 100644 --- a/src/main/runtime/composers/startup-lifecycle-composer.test.ts +++ b/src/main/runtime/composers/startup-lifecycle-composer.test.ts @@ -49,6 +49,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler clearYomitanSettingsWindow: () => {}, stopJellyfinRemoteSession: async () => {}, cleanupYoutubeSubtitleTempDirs: () => {}, + cleanupJellyfinSubtitleCache: () => {}, stopDiscordPresenceService: () => {}, }, shouldRestoreWindowsOnActivateMainDeps: { diff --git a/src/main/runtime/jellyfin-playback-launch-main-deps.test.ts b/src/main/runtime/jellyfin-playback-launch-main-deps.test.ts index 0b1a98e0..451910e4 100644 --- a/src/main/runtime/jellyfin-playback-launch-main-deps.test.ts +++ b/src/main/runtime/jellyfin-playback-launch-main-deps.test.ts @@ -11,11 +11,16 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => { url: 'u', mode: 'direct', title: 't', + itemTitle: 't', + seriesTitle: null, + seasonNumber: null, + episodeNumber: null, startTimeTicks: 0, audioStreamIndex: null, subtitleStreamIndex: null, }), applyJellyfinMpvDefaults: () => calls.push('defaults'), + showVisibleOverlay: () => calls.push('visible-overlay'), sendMpvCommand: (command) => calls.push(`cmd:${command[0]}`), armQuitOnDisconnect: () => calls.push('arm'), schedule: (_callback, delayMs) => calls.push(`schedule:${delayMs}`), @@ -49,12 +54,17 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => { url: 'u', mode: 'direct', title: 't', + itemTitle: 't', + seriesTitle: null, + seasonNumber: null, + episodeNumber: null, startTimeTicks: 0, audioStreamIndex: null, subtitleStreamIndex: null, }, ); deps.applyJellyfinMpvDefaults({ connected: true, send: () => {} }); + deps.showVisibleOverlay(); deps.sendMpvCommand(['show-text', 'x']); deps.armQuitOnDisconnect(); deps.schedule(() => {}, 500); @@ -85,6 +95,7 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => { assert.deepEqual(calls, [ 'defaults', + 'visible-overlay', 'cmd:show-text', 'arm', 'schedule:500', diff --git a/src/main/runtime/jellyfin-playback-launch-main-deps.ts b/src/main/runtime/jellyfin-playback-launch-main-deps.ts index a0d15257..70fd4508 100644 --- a/src/main/runtime/jellyfin-playback-launch-main-deps.ts +++ b/src/main/runtime/jellyfin-playback-launch-main-deps.ts @@ -10,6 +10,7 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler( getMpvClient: () => deps.getMpvClient(), resolvePlaybackPlan: (params) => deps.resolvePlaybackPlan(params), applyJellyfinMpvDefaults: (mpvClient) => deps.applyJellyfinMpvDefaults(mpvClient), + showVisibleOverlay: () => deps.showVisibleOverlay(), sendMpvCommand: (command: Array<string | number>) => deps.sendMpvCommand(command), armQuitOnDisconnect: () => deps.armQuitOnDisconnect(), schedule: (callback: () => void, delayMs: number) => deps.schedule(callback, delayMs), @@ -19,5 +20,8 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler( setLastProgressAtMs: (value: number) => deps.setLastProgressAtMs(value), reportPlaying: (payload) => deps.reportPlaying(payload), showMpvOsd: (text: string) => deps.showMpvOsd(text), + recordJellyfinPlaybackMetadata: deps.recordJellyfinPlaybackMetadata + ? (metadata) => deps.recordJellyfinPlaybackMetadata!(metadata) + : undefined, }); } diff --git a/src/main/runtime/jellyfin-playback-launch.test.ts b/src/main/runtime/jellyfin-playback-launch.test.ts index 17dfc78c..bfe4de53 100644 --- a/src/main/runtime/jellyfin-playback-launch.test.ts +++ b/src/main/runtime/jellyfin-playback-launch.test.ts @@ -23,6 +23,7 @@ test('playback handler throws when mpv is not connected', async () => { throw new Error('unreachable'); }, applyJellyfinMpvDefaults: () => {}, + showVisibleOverlay: () => {}, sendMpvCommand: () => {}, armQuitOnDisconnect: () => {}, schedule: () => {}, @@ -52,6 +53,7 @@ test('playback handler drives mpv commands and playback state', async () => { const calls: string[] = []; const activeStates: Array<Record<string, unknown>> = []; const reportPayloads: Array<Record<string, unknown>> = []; + const statsMetadata: Array<Record<string, unknown>> = []; const handler = createPlayJellyfinItemInMpvHandler({ ensureMpvConnectedForPlayback: async () => true, getMpvClient: () => ({ connected: true, send: () => {} }), @@ -59,11 +61,16 @@ test('playback handler drives mpv commands and playback state', async () => { url: 'https://stream.example/video.m3u8', mode: 'direct', title: 'Episode 1', + itemTitle: 'Episode 1', + seriesTitle: 'Show Title', + seasonNumber: 1, + episodeNumber: 1, startTimeTicks: 12_000_000, audioStreamIndex: 1, subtitleStreamIndex: 2, }), applyJellyfinMpvDefaults: () => calls.push('defaults'), + showVisibleOverlay: () => calls.push('visible-overlay'), sendMpvCommand: (command) => commands.push(command), armQuitOnDisconnect: () => calls.push('arm'), schedule: (callback, delayMs) => { @@ -75,6 +82,8 @@ test('playback handler drives mpv commands and playback state', async () => { setLastProgressAtMs: (value) => calls.push(`progress:${value}`), reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>), showMpvOsd: (text) => calls.push(`osd:${text}`), + recordJellyfinPlaybackMetadata: (metadata) => + statsMetadata.push(metadata as Record<string, unknown>), }); await handler({ @@ -87,7 +96,7 @@ test('playback handler drives mpv commands and playback state', async () => { assert.deepEqual(commands.slice(0, 5), [ ['set_property', 'sub-auto', 'no'], ['loadfile', 'https://stream.example/video.m3u8', 'replace'], - ['set_property', 'force-media-title', '[Jellyfin/direct] Episode 1'], + ['set_property', 'force-media-title', 'Episode 1'], ['set_property', 'sid', 'no'], ['seek', 1.2, 'absolute+exact'], ]); @@ -97,6 +106,11 @@ test('playback handler drives mpv commands and playback state', async () => { assert.deepEqual(commands[commands.length - 1], ['set_property', 'sid', 'no']); assert.ok(calls.includes('defaults')); + assert.ok(calls.includes('visible-overlay')); + assert.ok( + calls.indexOf('visible-overlay') < calls.indexOf('preload'), + 'visible overlay should be shown before Jellyfin subtitles are selected', + ); assert.ok(calls.includes('arm')); assert.ok(calls.includes('preload')); assert.ok(calls.includes('progress:0')); @@ -106,6 +120,17 @@ test('playback handler drives mpv commands and playback state', async () => { assert.equal(activeStates[0]?.playMethod, 'DirectPlay'); assert.equal(reportPayloads.length, 1); assert.equal(reportPayloads[0]?.eventName, 'start'); + assert.deepEqual(statsMetadata, [ + { + mediaPath: 'https://stream.example/video.m3u8', + displayTitle: 'Episode 1', + itemTitle: 'Episode 1', + seriesTitle: 'Show Title', + seasonNumber: 1, + episodeNumber: 1, + itemId: 'item-1', + }, + ]); }); test('playback handler applies start override to stream url for remote resume', async () => { @@ -117,11 +142,16 @@ test('playback handler applies start override to stream url for remote resume', url: 'https://stream.example/video.m3u8?api_key=token', mode: 'transcode', title: 'Episode 2', + itemTitle: 'Episode 2', + seriesTitle: null, + seasonNumber: null, + episodeNumber: null, startTimeTicks: 0, audioStreamIndex: null, subtitleStreamIndex: null, }), applyJellyfinMpvDefaults: () => {}, + showVisibleOverlay: () => {}, sendMpvCommand: (command) => commands.push(command), armQuitOnDisconnect: () => {}, schedule: () => {}, diff --git a/src/main/runtime/jellyfin-playback-launch.ts b/src/main/runtime/jellyfin-playback-launch.ts index ed681bb4..82322e99 100644 --- a/src/main/runtime/jellyfin-playback-launch.ts +++ b/src/main/runtime/jellyfin-playback-launch.ts @@ -16,6 +16,16 @@ type ActivePlaybackState = { playMethod: 'DirectPlay' | 'Transcode'; }; +export type JellyfinPlaybackStatsMetadata = { + mediaPath: string; + displayTitle: string; + itemTitle: string; + seriesTitle: string | null; + seasonNumber: number | null; + episodeNumber: number | null; + itemId: string; +}; + function applyStartTimeTicksToPlaybackUrl(url: string, startTimeTicksOverride?: number): string { if (typeof startTimeTicksOverride !== 'number') return url; try { @@ -43,6 +53,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: { subtitleStreamIndex?: number | null; }) => Promise<JellyfinPlaybackPlan>; applyJellyfinMpvDefaults: (mpvClient: MpvRuntimeClientLike) => void; + showVisibleOverlay: () => void; sendMpvCommand: (command: Array<string | number>) => void; armQuitOnDisconnect: () => void; schedule: (callback: () => void, delayMs: number) => void; @@ -63,6 +74,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: { eventName: 'start'; }) => void; showMpvOsd: (text: string) => void; + recordJellyfinPlaybackMetadata?: (metadata: JellyfinPlaybackStatsMetadata) => void; }) { return async (params: { session: JellyfinAuthSession; @@ -94,15 +106,20 @@ export function createPlayJellyfinItemInMpvHandler(deps: { deps.applyJellyfinMpvDefaults(mpvClient); deps.sendMpvCommand(['set_property', 'sub-auto', 'no']); const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride); + deps.recordJellyfinPlaybackMetadata?.({ + mediaPath: playbackUrl, + displayTitle: plan.title, + itemTitle: plan.itemTitle, + seriesTitle: plan.seriesTitle, + seasonNumber: plan.seasonNumber, + episodeNumber: plan.episodeNumber, + itemId: params.itemId, + }); deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']); if (params.setQuitOnDisconnectArm !== false) { deps.armQuitOnDisconnect(); } - deps.sendMpvCommand([ - 'set_property', - 'force-media-title', - `[Jellyfin/${plan.mode}] ${plan.title}`, - ]); + deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]); deps.sendMpvCommand(['set_property', 'sid', 'no']); deps.schedule(() => { deps.sendMpvCommand(['set_property', 'sid', 'no']); @@ -116,6 +133,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: { deps.sendMpvCommand(['seek', deps.convertTicksToSeconds(startTimeTicks), 'absolute+exact']); } + deps.showVisibleOverlay(); deps.preloadExternalSubtitles({ session: params.session, clientInfo: params.clientInfo, diff --git a/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts b/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts index 2f80e93d..cf2cfffc 100644 --- a/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts +++ b/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts @@ -36,6 +36,14 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => { getLaunchMode: () => 'fullscreen', platform: 'darwin', execPath: '/tmp/subminer', + getRuntimePluginEntrypoint: () => '/tmp/plugin/subminer/main.lua', + getInstalledPluginDetection: () => ({ + installed: false, + path: null, + version: null, + source: null, + message: null, + }), defaultMpvLogPath: '/tmp/mpv.log', defaultMpvArgs: ['--no-config'], removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`), @@ -51,6 +59,8 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => { assert.equal(deps.getLaunchMode(), 'fullscreen'); assert.equal(deps.platform, 'darwin'); assert.equal(deps.execPath, '/tmp/subminer'); + assert.equal(deps.getRuntimePluginEntrypoint?.(), '/tmp/plugin/subminer/main.lua'); + assert.equal(deps.getInstalledPluginDetection?.().installed, false); assert.equal(deps.defaultMpvLogPath, '/tmp/mpv.log'); assert.deepEqual(deps.defaultMpvArgs, ['--no-config']); deps.removeSocketPath('/tmp/mpv.sock'); diff --git a/src/main/runtime/jellyfin-remote-connection-main-deps.ts b/src/main/runtime/jellyfin-remote-connection-main-deps.ts index 2c2e2eb5..4ec78d83 100644 --- a/src/main/runtime/jellyfin-remote-connection-main-deps.ts +++ b/src/main/runtime/jellyfin-remote-connection-main-deps.ts @@ -20,6 +20,8 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler( getLaunchMode: () => deps.getLaunchMode(), platform: deps.platform, execPath: deps.execPath, + getRuntimePluginEntrypoint: deps.getRuntimePluginEntrypoint, + getInstalledPluginDetection: deps.getInstalledPluginDetection, getPluginRuntimeConfig: deps.getPluginRuntimeConfig, defaultMpvLogPath: deps.defaultMpvLogPath, defaultMpvArgs: deps.defaultMpvArgs, diff --git a/src/main/runtime/jellyfin-remote-connection.test.ts b/src/main/runtime/jellyfin-remote-connection.test.ts index d312478d..6e36eeb0 100644 --- a/src/main/runtime/jellyfin-remote-connection.test.ts +++ b/src/main/runtime/jellyfin-remote-connection.test.ts @@ -34,6 +34,8 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', ( getLaunchMode: () => 'maximized', platform: 'darwin', execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner', + getRuntimePluginEntrypoint: () => + '/Applications/SubMiner.app/Contents/Resources/plugin/subminer/main.lua', defaultMpvLogPath: '/tmp/mp.log', defaultMpvArgs: ['--sid=auto'], removeSocketPath: () => {}, @@ -52,6 +54,11 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', ( assert.equal(spawnedArgs.length, 1); assert.ok(spawnedArgs[0]!.includes('--window-maximized=yes')); assert.ok(spawnedArgs[0]!.includes('--idle=yes')); + assert.ok( + spawnedArgs[0]!.includes( + '--script=/Applications/SubMiner.app/Contents/Resources/plugin/subminer/main.lua', + ), + ); assert.ok(spawnedArgs[0]!.some((arg) => arg.includes('--input-ipc-server=/tmp/subminer.sock'))); assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback'))); }); @@ -101,6 +108,43 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/); }); +test('createLaunchMpvIdleForJellyfinPlaybackHandler skips bundled script when installed plugin exists', () => { + const spawnedArgs: string[][] = []; + const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({ + getSocketPath: () => '/tmp/subminer.sock', + getLaunchMode: () => 'normal', + platform: 'linux', + execPath: '/opt/SubMiner/SubMiner.AppImage', + getRuntimePluginEntrypoint: () => '/opt/SubMiner/plugin/subminer/main.lua', + getInstalledPluginDetection: () => ({ + installed: true, + path: '/home/tester/.config/mpv/scripts/subminer/main.lua', + version: '0.1.0', + source: 'default-config', + message: null, + }), + defaultMpvLogPath: '/tmp/mp.log', + defaultMpvArgs: ['--sid=auto'], + removeSocketPath: () => {}, + spawnMpv: (args) => { + spawnedArgs.push(args); + return { + on: () => {}, + unref: () => {}, + }; + }, + logWarn: () => {}, + logInfo: () => {}, + }); + + launch(); + assert.equal( + spawnedArgs[0]?.some((arg) => arg.startsWith('--script=/opt/SubMiner/plugin/subminer')), + false, + ); + assert.ok(spawnedArgs[0]?.some((arg) => arg.startsWith('--script-opts='))); +}); + test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => { let autoLaunchInFlight: Promise<boolean> | null = null; let launchCalls = 0; diff --git a/src/main/runtime/jellyfin-remote-connection.ts b/src/main/runtime/jellyfin-remote-connection.ts index e72ca17e..90e193f7 100644 --- a/src/main/runtime/jellyfin-remote-connection.ts +++ b/src/main/runtime/jellyfin-remote-connection.ts @@ -4,6 +4,7 @@ import { type SubminerPluginRuntimeScriptOptConfig, } from '../../shared/subminer-plugin-script-opts'; import type { MpvLaunchMode } from '../../types/config'; +import type { InstalledMpvPluginDetection } from './first-run-setup-plugin'; type MpvClientLike = { connected: boolean; @@ -44,6 +45,8 @@ export type LaunchMpvForJellyfinDeps = { getLaunchMode: () => MpvLaunchMode; platform: NodeJS.Platform; execPath: string; + getRuntimePluginEntrypoint?: () => string | null | undefined; + getInstalledPluginDetection?: () => InstalledMpvPluginDetection; getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig; defaultMpvLogPath: string; defaultMpvArgs: readonly string[]; @@ -75,9 +78,17 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor ) : [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`]; const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`; + const installedPlugin = deps.getInstalledPluginDetection?.(); + const runtimePluginEntrypoint = installedPlugin?.installed + ? '' + : (deps.getRuntimePluginEntrypoint?.()?.trim() ?? ''); + if (installedPlugin?.installed && installedPlugin.path) { + deps.logInfo(`Using installed mpv plugin for Jellyfin playback: ${installedPlugin.path}`); + } const mpvArgs = [ ...deps.defaultMpvArgs, ...buildMpvLaunchModeArgs(deps.getLaunchMode()), + ...(runtimePluginEntrypoint ? [`--script=${runtimePluginEntrypoint}`] : []), '--idle=yes', scriptOpts, `--log-file=${deps.defaultMpvLogPath}`, diff --git a/src/main/runtime/jellyfin-setup-window.ts b/src/main/runtime/jellyfin-setup-window.ts index eae22bf1..3a26ac0b 100644 --- a/src/main/runtime/jellyfin-setup-window.ts +++ b/src/main/runtime/jellyfin-setup-window.ts @@ -141,23 +141,140 @@ export function buildJellyfinSetupFormHtml(state: JellyfinSetupViewState): strin <meta charset="utf-8" /> <title>Jellyfin Setup diff --git a/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts b/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts index ce6f8d90..f6f4d1b5 100644 --- a/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts +++ b/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts @@ -14,6 +14,11 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy wait: async () => { calls.push('wait'); }, + cacheSubtitleTrack: async () => { + calls.push('cache'); + return { path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' }; + }, + cleanupCachedSubtitles: () => calls.push('cleanup'), logDebug: (message) => calls.push(`debug:${message}`), })(); @@ -21,6 +26,8 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy assert.equal(typeof deps.getMpvClient()?.requestProperty, 'function'); deps.sendMpvCommand(['set_property', 'sid', 'auto']); await deps.wait(1); + await deps.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' }); + deps.cleanupCachedSubtitles(['/tmp/subs']); deps.logDebug('oops', null); - assert.deepEqual(calls, ['list', 'send', 'wait', 'debug:oops']); + assert.deepEqual(calls, ['list', 'send', 'wait', 'cache', 'cleanup', 'debug:oops']); }); diff --git a/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts b/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts index ed84df5e..ab558dd8 100644 --- a/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts +++ b/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts @@ -13,6 +13,8 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler( getMpvClient: () => deps.getMpvClient(), sendMpvCommand: (command) => deps.sendMpvCommand(command), wait: (ms: number) => deps.wait(ms), + cacheSubtitleTrack: (track) => deps.cacheSubtitleTrack(track), + cleanupCachedSubtitles: (dirs) => deps.cleanupCachedSubtitles(dirs), logDebug: (message: string, error: unknown) => deps.logDebug(message, error), }); } diff --git a/src/main/runtime/jellyfin-subtitle-preload.test.ts b/src/main/runtime/jellyfin-subtitle-preload.test.ts index 696a917e..389453d7 100644 --- a/src/main/runtime/jellyfin-subtitle-preload.test.ts +++ b/src/main/runtime/jellyfin-subtitle-preload.test.ts @@ -15,47 +15,132 @@ const clientInfo = { deviceId: 'dev', }; -test('preload jellyfin subtitles adds external tracks and chooses japanese+english tracks', async () => { +function makeDeps(overrides: { + listJellyfinSubtitleTracks?: Parameters< + typeof createPreloadJellyfinExternalSubtitlesHandler + >[0]['listJellyfinSubtitleTracks']; + getMpvClient?: Parameters< + typeof createPreloadJellyfinExternalSubtitlesHandler + >[0]['getMpvClient']; + sendMpvCommand?: Parameters< + typeof createPreloadJellyfinExternalSubtitlesHandler + >[0]['sendMpvCommand']; + wait?: Parameters[0]['wait']; + cacheSubtitleTrack?: Parameters< + typeof createPreloadJellyfinExternalSubtitlesHandler + >[0]['cacheSubtitleTrack']; + cleanupCachedSubtitles?: Parameters< + typeof createPreloadJellyfinExternalSubtitlesHandler + >[0]['cleanupCachedSubtitles']; + logDebug?: Parameters[0]['logDebug']; +}) { + return { + listJellyfinSubtitleTracks: overrides.listJellyfinSubtitleTracks ?? (async () => []), + getMpvClient: overrides.getMpvClient ?? (() => null), + sendMpvCommand: overrides.sendMpvCommand ?? (() => {}), + wait: overrides.wait ?? (async () => {}), + cacheSubtitleTrack: + overrides.cacheSubtitleTrack ?? + (async (track) => ({ + path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`, + cleanupDir: '/tmp/subminer-jellyfin-subtitles', + })), + cleanupCachedSubtitles: overrides.cleanupCachedSubtitles ?? (() => {}), + logDebug: overrides.logDebug ?? (() => {}), + }; +} + +test('preload jellyfin subtitles caches external tracks locally and chooses japanese+english tracks', async () => { const commands: Array> = []; - const preload = createPreloadJellyfinExternalSubtitlesHandler({ - listJellyfinSubtitleTracks: async () => [ - { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' }, - { index: 1, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' }, - { index: 2, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' }, - ], - getMpvClient: () => ({ - requestProperty: async () => [ - { type: 'sub', id: 5, lang: 'jpn', title: 'Japanese', external: true }, - { type: 'sub', id: 6, lang: 'eng', title: 'English', external: true }, + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [ + { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' }, + { index: 1, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' }, + { index: 2, language: 'eng', title: 'English SDH', deliveryUrl: 'https://sub/b.srt' }, ], + getMpvClient: () => ({ + requestProperty: async () => [ + { type: 'sub', id: 5, lang: 'jpn', title: 'Japanese', external: true }, + { type: 'sub', id: 6, lang: 'eng', title: 'English', external: true }, + ], + }), + sendMpvCommand: (command) => commands.push(command), + cacheSubtitleTrack: async (track) => ({ + path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`, + cleanupDir: '/tmp/subminer-jellyfin-subtitles', + }), }), - sendMpvCommand: (command) => commands.push(command), - wait: async () => {}, - logDebug: () => {}, - }); + ); await preload({ session, clientInfo, itemId: 'item-1' }); assert.deepEqual(commands, [ - ['sub-add', 'https://sub/a.srt', 'cached', 'Japanese', 'jpn'], - ['sub-add', 'https://sub/b.srt', 'cached', 'English SDH', 'eng'], + ['sub-add', '/tmp/subminer-jellyfin-subtitles/0.srt', 'cached', 'Japanese', 'jpn'], + ['sub-add', '/tmp/subminer-jellyfin-subtitles/1.srt', 'cached', 'English SDH', 'eng'], ['set_property', 'sid', 5], ['set_property', 'secondary-sid', 6], ]); }); +test('preload jellyfin subtitles cleans previous cached subtitles before a new preload', async () => { + const cleanupCalls: string[][] = []; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [ + { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' }, + ], + getMpvClient: () => ({ requestProperty: async () => [] }), + cacheSubtitleTrack: async (track) => ({ + path: `/tmp/subminer-jellyfin-subtitles-${track.index}/track.srt`, + cleanupDir: `/tmp/subminer-jellyfin-subtitles-${track.index}`, + }), + cleanupCachedSubtitles: (dirs) => cleanupCalls.push(dirs), + }), + ); + + await preload({ session, clientInfo, itemId: 'item-1' }); + await preload({ session, clientInfo, itemId: 'item-2' }); + + assert.deepEqual(cleanupCalls, [['/tmp/subminer-jellyfin-subtitles-0']]); +}); + +test('preload jellyfin subtitles exposes cleanup for active cached subtitles', async () => { + const cleanupCalls: string[][] = []; + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [ + { index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' }, + ], + getMpvClient: () => ({ requestProperty: async () => [] }), + cacheSubtitleTrack: async () => ({ + path: '/tmp/subminer-jellyfin-subtitles-active/track.srt', + cleanupDir: '/tmp/subminer-jellyfin-subtitles-active', + }), + cleanupCachedSubtitles: (dirs) => cleanupCalls.push(dirs), + }), + ); + + await preload({ session, clientInfo, itemId: 'item-1' }); + preload.cleanupCachedSubtitles(); + preload.cleanupCachedSubtitles(); + + assert.deepEqual(cleanupCalls, [['/tmp/subminer-jellyfin-subtitles-active']]); +}); + test('preload jellyfin subtitles exits quietly when no external tracks', async () => { const commands: Array> = []; let waited = false; - const preload = createPreloadJellyfinExternalSubtitlesHandler({ - listJellyfinSubtitleTracks: async () => [{ index: 0, language: 'jpn', title: 'Embedded' }], - getMpvClient: () => ({ requestProperty: async () => [] }), - sendMpvCommand: (command) => commands.push(command), - wait: async () => { - waited = true; - }, - logDebug: () => {}, - }); + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => [{ index: 0, language: 'jpn', title: 'Embedded' }], + getMpvClient: () => ({ requestProperty: async () => [] }), + sendMpvCommand: (command) => commands.push(command), + wait: async () => { + waited = true; + }, + }), + ); await preload({ session, clientInfo, itemId: 'item-1' }); @@ -65,15 +150,17 @@ test('preload jellyfin subtitles exits quietly when no external tracks', async ( test('preload jellyfin subtitles logs debug on failure', async () => { const logs: string[] = []; - const preload = createPreloadJellyfinExternalSubtitlesHandler({ - listJellyfinSubtitleTracks: async () => { - throw new Error('network down'); - }, - getMpvClient: () => null, - sendMpvCommand: () => {}, - wait: async () => {}, - logDebug: (message) => logs.push(message), - }); + const preload = createPreloadJellyfinExternalSubtitlesHandler( + makeDeps({ + listJellyfinSubtitleTracks: async () => { + throw new Error('network down'); + }, + getMpvClient: () => null, + sendMpvCommand: () => {}, + wait: async () => {}, + logDebug: (message) => logs.push(message), + }), + ); await preload({ session, clientInfo, itemId: 'item-1' }); diff --git a/src/main/runtime/jellyfin-subtitle-preload.ts b/src/main/runtime/jellyfin-subtitle-preload.ts index 409dff51..7c282409 100644 --- a/src/main/runtime/jellyfin-subtitle-preload.ts +++ b/src/main/runtime/jellyfin-subtitle-preload.ts @@ -18,10 +18,23 @@ type JellyfinSubtitleTrack = { deliveryUrl?: string | null; }; +type CachedSubtitleTrack = { + path: string; + cleanupDir: string; +}; + type MpvClientLike = { requestProperty: (name: string) => Promise; }; +export type PreloadJellyfinExternalSubtitlesHandler = ((params: { + session: JellyfinSession; + clientInfo: JellyfinClientInfo; + itemId: string; +}) => Promise) & { + cleanupCachedSubtitles: () => void; +}; + function normalizeLang(value: unknown): string { return String(value || '') .trim() @@ -90,13 +103,26 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { getMpvClient: () => MpvClientLike | null; sendMpvCommand: (command: Array) => void; wait: (ms: number) => Promise; + cacheSubtitleTrack: (track: JellyfinSubtitleTrack) => Promise; + cleanupCachedSubtitles: (dirs: string[]) => void; logDebug: (message: string, error: unknown) => void; -}) { - return async (params: { +}): PreloadJellyfinExternalSubtitlesHandler { + const activeCacheDirs = new Set(); + + function cleanupActiveCache(): void { + const dirs = [...activeCacheDirs]; + activeCacheDirs.clear(); + if (dirs.length === 0) return; + deps.cleanupCachedSubtitles(dirs); + } + + const preload = async (params: { session: JellyfinSession; clientInfo: JellyfinClientInfo; itemId: string; }): Promise => { + cleanupActiveCache(); + try { const tracks = await deps.listJellyfinSubtitleTracks( params.session, @@ -117,7 +143,9 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { seenUrls.add(track.deliveryUrl); const labelBase = (track.title || track.language || '').trim(); const label = labelBase || `Jellyfin Subtitle ${track.index}`; - deps.sendMpvCommand(['sub-add', track.deliveryUrl, 'cached', label, track.language || '']); + const cached = await deps.cacheSubtitleTrack(track); + activeCacheDirs.add(cached.cleanupDir); + deps.sendMpvCommand(['sub-add', cached.path, 'cached', label, track.language || '']); } await deps.wait(250); @@ -154,4 +182,8 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { deps.logDebug('Failed to preload Jellyfin external subtitles', error); } }; + + return Object.assign(preload, { + cleanupCachedSubtitles: cleanupActiveCache, + }); } diff --git a/src/main/runtime/local-subtitle-selection.test.ts b/src/main/runtime/local-subtitle-selection.test.ts index cf55a0a0..15398099 100644 --- a/src/main/runtime/local-subtitle-selection.test.ts +++ b/src/main/runtime/local-subtitle-selection.test.ts @@ -175,3 +175,57 @@ test('managed local subtitle selection keeps waiting for primary after early sec ['set_property', 'sid', 3], ]); }); + +test('managed local subtitle selection keeps pending refresh after early primary-only track list', async () => { + const commands: Array> = []; + const scheduled = new Map void>(); + let nextTimerId = 1; + + const runtime = createManagedLocalSubtitleSelectionRuntime({ + getCurrentMediaPath: () => '/videos/example.mkv', + getMpvClient: () => + ({ + connected: true, + requestProperty: async (name: string) => { + if (name === 'track-list') { + return [ + { type: 'sub', id: 3, lang: 'ja', title: 'ja.srt', external: true }, + { type: 'sub', id: 4, lang: 'en', title: 'en.srt', external: true }, + ]; + } + throw new Error(`Unexpected property: ${name}`); + }, + }) as never, + getPrimarySubtitleLanguages: () => [], + getSecondarySubtitleLanguages: () => [], + sendMpvCommand: (command) => { + commands.push(command); + }, + schedule: (callback) => { + const timerId = nextTimerId++; + scheduled.set(timerId, callback); + return timerId as never; + }, + clearScheduled: (timer) => { + scheduled.delete(timer as never); + }, + }); + + runtime.handleMediaPathChange('/videos/example.mkv'); + runtime.handleSubtitleTrackListChange([ + { type: 'sub', id: 3, lang: 'ja', title: 'ja.srt', external: true }, + ]); + + assert.deepEqual(commands, [['set_property', 'sid', 3]]); + assert.equal(scheduled.size, 1); + + const refresh = [...scheduled.values()][0]; + assert.ok(refresh); + refresh(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.deepEqual(commands, [ + ['set_property', 'sid', 3], + ['set_property', 'secondary-sid', 4], + ]); +}); diff --git a/src/main/runtime/local-subtitle-selection.ts b/src/main/runtime/local-subtitle-selection.ts index fa35bbfc..53e389ef 100644 --- a/src/main/runtime/local-subtitle-selection.ts +++ b/src/main/runtime/local-subtitle-selection.ts @@ -212,12 +212,11 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: { pendingTimer = null; }; + const hasAppliedSelectionForCurrentMediaPath = (): boolean => + appliedPrimaryMediaPath === currentMediaPath && appliedSecondaryMediaPath === currentMediaPath; + const maybeApplySelection = (trackList: unknown[] | null): void => { - if ( - !currentMediaPath || - (appliedPrimaryMediaPath === currentMediaPath && - appliedSecondaryMediaPath === currentMediaPath) - ) { + if (!currentMediaPath || hasAppliedSelectionForCurrentMediaPath()) { return; } const selection = resolveManagedLocalSubtitleSelection({ @@ -236,7 +235,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: { deps.sendMpvCommand(['set_property', 'secondary-sid', selection.secondaryTrackId]); appliedSecondaryMediaPath = currentMediaPath; } - if (appliedPrimaryMediaPath === currentMediaPath) { + if (hasAppliedSelectionForCurrentMediaPath()) { clearPendingTimer(); } }; @@ -260,7 +259,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: { const scheduleRefresh = (): void => { clearPendingTimer(); - if (!currentMediaPath || appliedPrimaryMediaPath === currentMediaPath) { + if (!currentMediaPath || hasAppliedSelectionForCurrentMediaPath()) { return; } pendingTimer = deps.schedule(() => { diff --git a/src/main/runtime/subsync-open.test.ts b/src/main/runtime/subsync-open.test.ts index 24ad84ab..bbaa457b 100644 --- a/src/main/runtime/subsync-open.test.ts +++ b/src/main/runtime/subsync-open.test.ts @@ -4,6 +4,7 @@ import { openSubsyncManualModal } from './subsync-open'; import type { SubsyncManualPayload } from '../../types'; const payload: SubsyncManualPayload = { + ffsubsyncAvailable: true, sourceTracks: [{ id: 2, label: 'External #2 - eng' }], }; diff --git a/src/main/runtime/update/update-service.test.ts b/src/main/runtime/update/update-service.test.ts index bd9a1225..c8addb34 100644 --- a/src/main/runtime/update/update-service.test.ts +++ b/src/main/runtime/update/update-service.test.ts @@ -361,3 +361,38 @@ test('manual prerelease update check uses prerelease release and launcher channe 'restart-dialog', ]); }); + +test('manual update check keeps current prerelease builds on configured stable channel', async () => { + const { deps, calls } = createDeps({ + getCurrentVersion: () => '0.15.0-beta.3', + checkAppUpdate: async (channel) => { + calls.push(`app:${channel}`); + return { available: false, version: '0.15.0-beta.3' }; + }, + fetchLatestStableRelease: async (channel) => { + calls.push(`fetch:${channel}`); + return { + tag_name: 'v0.14.0', + prerelease: false, + draft: false, + assets: [], + }; + }, + showNoUpdateDialog: async (version) => { + calls.push(`no-update:${version}`); + }, + showUpdateAvailableDialog: async () => { + throw new Error('unexpected update dialog'); + }, + }); + const service = createUpdateService(deps); + + const result = await service.checkForUpdates({ source: 'manual' }); + + assert.equal(result.status, 'up-to-date'); + assert.deepEqual(calls, [ + 'app:stable', + 'fetch:stable', + 'no-update:0.15.0-beta.3', + ]); +}); diff --git a/src/renderer/modals/subsync.test.ts b/src/renderer/modals/subsync.test.ts index bb642724..26aea7bf 100644 --- a/src/renderer/modals/subsync.test.ts +++ b/src/renderer/modals/subsync.test.ts @@ -83,6 +83,7 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag const subsyncEngineFfsubsync = { checked: false, + disabled: false, addEventListener: engineFfsubsyncEvents.addEventListener, dispatch: engineFfsubsyncEvents.dispatch, }; @@ -194,6 +195,7 @@ test('manual subsync failure closes during run, then reopens modal with error', harness.modal.wireDomEvents(); harness.modal.openSubsyncModal({ sourceTracks: [{ id: 2, label: 'External #2 - eng' }], + ffsubsyncAvailable: true, }); harness.runButton.dispatch('click'); @@ -224,3 +226,21 @@ test('manual subsync failure closes during run, then reopens modal with error', harness.restoreGlobals(); } }); + +test('subsync modal disables ffsubsync when payload marks it unavailable', () => { + const harness = createTestHarness(async () => ({ ok: true, message: 'ok' })); + + try { + harness.modal.openSubsyncModal({ + sourceTracks: [{ id: 2, label: 'External #2 - eng' }], + ffsubsyncAvailable: false, + }); + + assert.equal(harness.ctx.dom.subsyncEngineAlass.checked, true); + assert.equal(harness.ctx.dom.subsyncEngineFfsubsync.checked, false); + assert.equal(harness.ctx.dom.subsyncEngineFfsubsync.disabled, true); + assert.equal(harness.ctx.dom.subsyncStatus.textContent, 'Choose alass source, then run.'); + } finally { + harness.restoreGlobals(); + } +}); diff --git a/src/renderer/modals/subsync.ts b/src/renderer/modals/subsync.ts index 21fdc7ae..fd8e6c86 100644 --- a/src/renderer/modals/subsync.ts +++ b/src/renderer/modals/subsync.ts @@ -8,6 +8,8 @@ export function createSubsyncModal( syncSettingsModalSubtitleSuppression: () => void; }, ) { + let ffsubsyncAvailable = true; + function setSubsyncStatus(message: string, isError = false): void { ctx.dom.subsyncStatus.textContent = message; ctx.dom.subsyncStatus.classList.toggle('error', isError); @@ -46,20 +48,26 @@ export function createSubsyncModal( function openSubsyncModal(payload: SubsyncManualPayload): void { ctx.state.subsyncSubmitting = false; - ctx.dom.subsyncRunButton.disabled = false; ctx.state.subsyncSourceTracks = payload.sourceTracks; + ffsubsyncAvailable = payload.ffsubsyncAvailable; const hasSources = ctx.state.subsyncSourceTracks.length > 0; ctx.dom.subsyncEngineAlass.checked = hasSources; - ctx.dom.subsyncEngineFfsubsync.checked = !hasSources; + ctx.dom.subsyncEngineFfsubsync.checked = !hasSources && ffsubsyncAvailable; + ctx.dom.subsyncEngineFfsubsync.disabled = !ffsubsyncAvailable; + ctx.dom.subsyncRunButton.disabled = !hasSources && !ffsubsyncAvailable; renderSubsyncSourceTracks(); updateSubsyncSourceVisibility(); setSubsyncStatus( - hasSources - ? 'Choose engine and source, then run.' - : 'No source subtitles available for alass. Use ffsubsync.', + !ffsubsyncAvailable && hasSources + ? 'Choose alass source, then run.' + : !ffsubsyncAvailable + ? 'No source subtitles available for alass.' + : hasSources + ? 'Choose engine and source, then run.' + : 'No source subtitles available for alass. Use ffsubsync.', false, ); @@ -77,7 +85,7 @@ export function createSubsyncModal( sourceTrackId: number | null, message: string, ): void { - openSubsyncModal({ sourceTracks }); + openSubsyncModal({ sourceTracks, ffsubsyncAvailable }); if (engine === 'alass' && sourceTracks.length > 0) { ctx.dom.subsyncEngineAlass.checked = true; @@ -85,7 +93,7 @@ export function createSubsyncModal( if (Number.isFinite(sourceTrackId)) { ctx.dom.subsyncSourceSelect.value = String(sourceTrackId); } - } else { + } else if (ffsubsyncAvailable) { ctx.dom.subsyncEngineAlass.checked = false; ctx.dom.subsyncEngineFfsubsync.checked = true; } diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 4a1bb0ae..80ea121a 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -72,6 +72,7 @@ export interface SubsyncSourceTrack { export interface SubsyncManualPayload { sourceTracks: SubsyncSourceTrack[]; + ffsubsyncAvailable: boolean; } export interface SubsyncManualRunRequest {