diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index 519d3e3..5060ed7 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -316,3 +316,33 @@ test('FieldGroupingMergeCollaborator deduplicates identical sentence, audio, and assert.equal(merged.Picture, ''); assert.equal(merged.ExpressionAudio, merged.SentenceAudio); }); + +test('AnkiIntegration.formatMiscInfoPattern avoids leaking Jellyfin api_key query params', () => { + const integration = new AnkiIntegration( + { + metadata: { + pattern: '[SubMiner] %f (%t)', + }, + } as never, + {} as never, + { + currentSubText: '', + currentVideoPath: + 'stream?static=true&api_key=secret-token&MediaSourceId=a762ab23d26d4347e3cacdb83aaae405&AudioStreamIndex=3', + currentTimePos: 426, + currentSubStart: 426, + currentSubEnd: 428, + currentAudioStreamIndex: 3, + currentMediaTitle: '[Jellyfin/direct] Bocchi the Rock! - S01E02', + send: () => true, + } as unknown as never, + ); + + const privateApi = integration as unknown as { + formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string; + }; + const result = privateApi.formatMiscInfoPattern('audio_123.mp3', 426); + + assert.equal(result, '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)'); + assert.equal(result.includes('api_key='), false); +}); diff --git a/src/anki-integration.ts b/src/anki-integration.ts index c03b2c5..9cf0da8 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -58,6 +58,56 @@ interface NoteInfo { type CardKind = 'sentence' | 'audio'; +function trimToNonEmptyString(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function decodeURIComponentSafe(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function extractFilenameFromMediaPath(rawPath: string): string { + const trimmedPath = rawPath.trim(); + if (!trimmedPath) return ''; + + if (/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmedPath)) { + try { + const parsed = new URL(trimmedPath); + return decodeURIComponentSafe(path.basename(parsed.pathname)); + } catch { + // Fall through to separator-based handling below. + } + } + + const separatorIndex = trimmedPath.search(/[?#]/); + const pathWithoutQuery = + separatorIndex >= 0 ? trimmedPath.slice(0, separatorIndex) : trimmedPath; + return decodeURIComponentSafe(path.basename(pathWithoutQuery)); +} + +function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): boolean { + const loweredPath = rawPath.toLowerCase(); + const loweredFilename = filename.toLowerCase(); + if (loweredPath.includes('api_key=')) { + return true; + } + if (loweredPath.startsWith('http://') || loweredPath.startsWith('https://')) { + return true; + } + return ( + loweredFilename === 'stream' || + loweredFilename === 'master.m3u8' || + loweredFilename === 'index.m3u8' || + loweredFilename === 'playlist.m3u8' + ); +} + export class AnkiIntegration { private client: AnkiConnectClient; private mediaGenerator: MediaGenerator; @@ -729,8 +779,12 @@ export class AnkiIntegration { } const currentVideoPath = this.mpvClient.currentVideoPath || ''; - const videoFilename = currentVideoPath ? path.basename(currentVideoPath) : ''; - const filenameWithExt = videoFilename || fallbackFilename; + const videoFilename = extractFilenameFromMediaPath(currentVideoPath); + const mediaTitle = trimToNonEmptyString(this.mpvClient.currentMediaTitle); + const filenameWithExt = + (shouldPreferMediaTitleForMiscInfo(currentVideoPath, videoFilename) + ? mediaTitle || videoFilename + : videoFilename || mediaTitle) || fallbackFilename; const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, ''); const currentTimePos = diff --git a/src/core/services/mpv.test.ts b/src/core/services/mpv.test.ts index 153a8a4..7f18d5e 100644 --- a/src/core/services/mpv.test.ts +++ b/src/core/services/mpv.test.ts @@ -57,6 +57,26 @@ test('MpvIpcClient handles sub-text property change and broadcasts tokenized sub assert.equal(events[0]!.isOverlayVisible, false); }); +test('MpvIpcClient clears cached media title when media path changes', async () => { + const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); + + await invokeHandleMessage(client, { + event: 'property-change', + name: 'media-title', + data: '[Jellyfin/direct] Episode 1', + }); + assert.equal(client.currentMediaTitle, '[Jellyfin/direct] Episode 1'); + + await invokeHandleMessage(client, { + event: 'property-change', + name: 'path', + data: '/tmp/new-episode.mkv', + }); + + assert.equal(client.currentVideoPath, '/tmp/new-episode.mkv'); + assert.equal(client.currentMediaTitle, null); +}); + test('MpvIpcClient parses JSON line protocol in processBuffer', () => { const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); const seen: Array> = []; diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index 5bab9a8..50c0e74 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -134,6 +134,7 @@ export class MpvIpcClient implements MpvClient { private firstConnection = true; private hasConnectedOnce = false; public currentVideoPath = ''; + public currentMediaTitle: string | null = null; public currentTimePos = 0; public currentSubStart = 0; public currentSubEnd = 0; @@ -330,6 +331,7 @@ export class MpvIpcClient implements MpvClient { this.emit('media-path-change', payload); }, emitMediaTitleChange: (payload) => { + this.currentMediaTitle = payload.title; this.emit('media-title-change', payload); }, emitSubtitleMetricsChange: (patch) => { @@ -364,6 +366,7 @@ export class MpvIpcClient implements MpvClient { }, setCurrentVideoPath: (value: string) => { this.currentVideoPath = value; + this.currentMediaTitle = null; }, emitSecondarySubtitleVisibility: (payload) => { this.emit('secondary-subtitle-visibility', payload); diff --git a/src/types.ts b/src/types.ts index 8d28650..bc4d6e1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -124,6 +124,7 @@ export interface NotificationOptions { export interface MpvClient { currentSubText: string; currentVideoPath: string; + currentMediaTitle?: string | null; currentTimePos: number; currentSubStart: number; currentSubEnd: number;