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 da3c971ee6
commit 47e78ff698
49 changed files with 975 additions and 105 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) => ({