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;