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
This commit is contained in:
2026-05-21 22:26:59 -07:00
parent c6328eef09
commit 3a2d7a282d
49 changed files with 976 additions and 106 deletions
+38
View File
@@ -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({
+6 -3
View File
@@ -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(() => {
@@ -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;
+107 -2
View File
@@ -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);
+4
View File
@@ -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);
+23 -3
View File
@@ -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,
+41
View File
@@ -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;
+1
View File
@@ -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) => ({
+51
View File
@@ -2747,6 +2747,7 @@ const {
reportJellyfinRemoteStopped,
startJellyfinRemoteSession,
stopJellyfinRemoteSession,
cleanupJellyfinSubtitleCache,
runJellyfinCommand,
openJellyfinSetupWindow,
getJellyfinClientInfo,
@@ -2770,6 +2771,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,
@@ -2805,6 +2815,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);
},
@@ -2823,6 +2868,7 @@ const {
},
),
applyJellyfinMpvDefaults: (mpvClient) => applyJellyfinMpvDefaults(mpvClient),
showVisibleOverlay: () => setVisibleOverlayVisible(true),
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
armQuitOnDisconnect: () => {
jellyfinPlayQuitOnDisconnectArmed = false;
@@ -2846,6 +2892,10 @@ const {
showMpvOsd: (text) => {
showMpvOsd(text);
},
recordJellyfinPlaybackMetadata: (metadata) => {
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordJellyfinPlaybackMetadata(metadata);
},
},
remoteComposerOptions: {
getConfiguredSession: () => getConfiguredJellyfinSession(getResolvedJellyfinConfig()),
@@ -3615,6 +3665,7 @@ const {
appState.yomitanSettingsWindow = null;
},
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => {
void appState.discordPresenceService?.stop();
appState.discordPresenceService = null;
+2 -2
View File
@@ -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',
},
@@ -40,13 +40,15 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
cleanup();
assert.equal(calls.length, 30);
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.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
@@ -28,6 +28,7 @@ export function createOnWillQuitCleanupHandler(deps: {
destroyYomitanSettingsWindow: () => void;
clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
}) {
return (): void => {
@@ -60,6 +61,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.destroyYomitanSettingsWindow();
deps.clearYomitanSettingsWindow();
deps.stopJellyfinRemoteSession();
deps.cleanupJellyfinSubtitleCache();
deps.stopDiscordPresenceService();
};
}
@@ -69,6 +69,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
@@ -89,6 +90,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
assert.ok(calls.includes('destroy-first-run-window'));
assert.ok(calls.includes('destroy-yomitan-settings-window'));
assert.ok(calls.includes('stop-jellyfin-remote'));
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'));
@@ -142,6 +144,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {},
});
@@ -190,6 +193,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {},
});
@@ -57,6 +57,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void;
cleanupJellyfinSubtitleCache: () => void;
stopDiscordPresenceService: () => void;
}) {
return () => ({
@@ -139,6 +140,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
},
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
});
}
@@ -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');
@@ -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,
@@ -48,6 +48,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: async () => {},
cleanupJellyfinSubtitleCache: () => {},
stopDiscordPresenceService: () => {},
},
shouldRestoreWindowsOnActivateMainDeps: {
@@ -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',
@@ -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,
});
}
@@ -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: () => {},
+23 -5
View File
@@ -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,
@@ -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');
@@ -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,
@@ -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;
@@ -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}`,
+133 -16
View File
@@ -141,23 +141,140 @@ export function buildJellyfinSetupFormHtml(state: JellyfinSetupViewState): strin
<meta charset="utf-8" />
<title>Jellyfin Setup</title>
<style>
:root { color-scheme: dark; --bg: #10130f; --panel: #191d17; --line: #414835; --text: #f0f2e8; --muted: #b6bca8; --accent: #a7d129; --danger: #ff786f; }
body { font-family: Georgia, "Times New Roman", serif; margin: 0; background: radial-gradient(circle at 20% 0%, #24301b 0, #10130f 42%); color: var(--text); }
main { padding: 22px; }
h1 { margin: 0 0 8px; font-size: 24px; letter-spacing: 0; }
p { margin: 0 0 16px; color: var(--muted); font-size: 13px; line-height: 1.45; }
label { display: block; margin: 12px 0 5px; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
input { width: 100%; box-sizing: border-box; padding: 10px 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; }
button { padding: 10px 12px; border: 1px solid #6f831f; border-radius: 6px; font-weight: 700; cursor: pointer; background: var(--accent); color: #14170f; }
button:disabled { cursor: wait; opacity: .68; }
button.secondary { background: transparent; color: var(--text); border-color: var(--line); }
button.danger { background: transparent; color: var(--danger); border-color: #6b332f; }
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; }
:root {
color-scheme: dark;
--ctp-red: #ed8796;
--ctp-peach: #f5a97f;
--ctp-yellow: #eed49f;
--ctp-green: #a6da95;
--ctp-blue: #8aadf4;
--ctp-lavender: #b7bdf8;
--ctp-text: #cad3f5;
--ctp-subtext1: #b8c0e0;
--ctp-subtext0: #a5adcb;
--ctp-overlay2: #939ab7;
--ctp-overlay1: #8087a2;
--ctp-overlay0: #6e738d;
--ctp-surface1: #494d64;
--ctp-surface0: #363a4f;
--ctp-base: #24273a;
--ctp-mantle: #1e2030;
--ctp-crust: #181926;
--line: rgba(110, 115, 141, 0.28);
--line-soft: rgba(110, 115, 141, 0.14);
--text: var(--ctp-text);
--muted: var(--ctp-subtext0);
}
* { box-sizing: border-box; }
html, body { width: 100%; height: 100%; margin: 0; }
html { background: var(--ctp-base); }
body {
min-height: 100vh;
background: var(--ctp-base);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic", sans-serif;
font-size: 13px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
main { padding: 32px 22px; max-width: 520px; margin: 0 auto; }
h1 { margin: 0 0 6px; font-size: 20px; font-weight: 800; color: var(--ctp-text); letter-spacing: -0.01em; }
p { margin: 0 0 18px; color: var(--muted); font-size: 13px; line-height: 1.5; }
label { display: block; margin: 14px 0 6px; font-size: 11px; font-weight: 800; color: var(--ctp-overlay2); text-transform: uppercase; letter-spacing: 0.1em; }
input {
width: 100%;
padding: 9px 11px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(24, 25, 38, 0.85);
color: var(--text);
font: inherit;
outline: none;
transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
}
input::placeholder { color: var(--ctp-overlay0); }
input:hover { border-color: rgba(138, 173, 244, 0.32); }
input:focus {
border-color: rgba(138, 173, 244, 0.65);
background: rgba(24, 25, 38, 0.95);
box-shadow: 0 0 0 3px rgba(138, 173, 244, 0.15);
}
button {
height: 36px;
padding: 0 16px;
border: 1px solid var(--line);
border-radius: 8px;
font: inherit;
font-weight: 700;
font-size: 13px;
cursor: pointer;
transition: background 140ms ease, border-color 140ms ease, color 140ms ease, transform 60ms ease;
}
button:active { transform: translateY(1px); }
button:disabled { cursor: wait; opacity: 0.7; }
button.primary {
border-color: transparent;
background: var(--ctp-blue);
color: var(--ctp-crust);
}
button.primary:hover:not(:disabled) { filter: brightness(1.06); }
button.primary:disabled {
background: rgba(54, 58, 79, 0.55);
color: var(--ctp-overlay0);
border-color: var(--line);
}
button.secondary {
background: rgba(54, 58, 79, 0.5);
color: var(--text);
}
button.secondary:hover:not(:disabled) {
border-color: rgba(138, 173, 244, 0.45);
background: rgba(73, 77, 100, 0.6);
color: var(--ctp-lavender);
}
button.danger {
background: rgba(237, 135, 150, 0.12);
color: var(--ctp-red);
border-color: rgba(237, 135, 150, 0.45);
}
button.danger:hover:not(:disabled) {
background: rgba(237, 135, 150, 0.22);
}
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
.actions .primary { grid-column: 1 / -1; }
.status { min-height: 18px; margin-top: 12px; font-size: 13px; color: var(--muted); }
.status.success { color: var(--accent); }
.status.error { color: var(--danger); }
.hint { margin-top: 14px; font-size: 12px; color: var(--muted); }
.status {
min-height: 18px;
margin-top: 14px;
font-size: 12.5px;
color: var(--muted);
}
.status:empty { display: none; }
.status.loading,
.status.success,
.status.error {
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--ctp-surface0);
font-weight: 600;
}
.status.success {
border-color: rgba(166, 218, 149, 0.45);
background: rgba(166, 218, 149, 0.1);
color: var(--ctp-green);
}
.status.error {
border-color: rgba(237, 135, 150, 0.55);
background: rgba(237, 135, 150, 0.1);
color: var(--ctp-red);
}
.hint {
margin-top: 16px;
font-size: 11.5px;
color: var(--ctp-overlay2);
line-height: 1.55;
}
</style>
</head>
<body>
@@ -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']);
});
@@ -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),
});
}
@@ -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<typeof createPreloadJellyfinExternalSubtitlesHandler>[0]['wait'];
cacheSubtitleTrack?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['cacheSubtitleTrack'];
cleanupCachedSubtitles?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['cleanupCachedSubtitles'];
logDebug?: Parameters<typeof createPreloadJellyfinExternalSubtitlesHandler>[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<Array<string | number>> = [];
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<Array<string | number>> = [];
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' });
+35 -3
View File
@@ -18,10 +18,23 @@ type JellyfinSubtitleTrack = {
deliveryUrl?: string | null;
};
type CachedSubtitleTrack = {
path: string;
cleanupDir: string;
};
type MpvClientLike = {
requestProperty: (name: string) => Promise<unknown>;
};
export type PreloadJellyfinExternalSubtitlesHandler = ((params: {
session: JellyfinSession;
clientInfo: JellyfinClientInfo;
itemId: string;
}) => Promise<void>) & {
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<string | number>) => void;
wait: (ms: number) => Promise<void>;
cacheSubtitleTrack: (track: JellyfinSubtitleTrack) => Promise<CachedSubtitleTrack>;
cleanupCachedSubtitles: (dirs: string[]) => void;
logDebug: (message: string, error: unknown) => void;
}) {
return async (params: {
}): PreloadJellyfinExternalSubtitlesHandler {
const activeCacheDirs = new Set<string>();
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<void> => {
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,
});
}
@@ -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<Array<string | number>> = [];
const scheduled = new Map<number, () => 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],
]);
});
+6 -7
View File
@@ -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(() => {
+1
View File
@@ -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' }],
};
@@ -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',
]);
});
+20
View File
@@ -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();
}
});
+15 -7
View File
@@ -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;
}
+1
View File
@@ -72,6 +72,7 @@ export interface SubsyncSourceTrack {
export interface SubsyncManualPayload {
sourceTracks: SubsyncSourceTrack[];
ffsubsyncAvailable: boolean;
}
export interface SubsyncManualRunRequest {