fix: sanitize jellyfin misc info formatting

This commit is contained in:
2026-03-01 20:05:19 -08:00
parent 7023a3263f
commit 68e5a7fef3
5 changed files with 110 additions and 2 deletions

View File

@@ -316,3 +316,33 @@ test('FieldGroupingMergeCollaborator deduplicates identical sentence, audio, and
assert.equal(merged.Picture, '<img data-group-id="202" src="same.png">'); assert.equal(merged.Picture, '<img data-group-id="202" src="same.png">');
assert.equal(merged.ExpressionAudio, merged.SentenceAudio); 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);
});

View File

@@ -58,6 +58,56 @@ interface NoteInfo {
type CardKind = 'sentence' | 'audio'; 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 { export class AnkiIntegration {
private client: AnkiConnectClient; private client: AnkiConnectClient;
private mediaGenerator: MediaGenerator; private mediaGenerator: MediaGenerator;
@@ -729,8 +779,12 @@ export class AnkiIntegration {
} }
const currentVideoPath = this.mpvClient.currentVideoPath || ''; const currentVideoPath = this.mpvClient.currentVideoPath || '';
const videoFilename = currentVideoPath ? path.basename(currentVideoPath) : ''; const videoFilename = extractFilenameFromMediaPath(currentVideoPath);
const filenameWithExt = videoFilename || fallbackFilename; const mediaTitle = trimToNonEmptyString(this.mpvClient.currentMediaTitle);
const filenameWithExt =
(shouldPreferMediaTitleForMiscInfo(currentVideoPath, videoFilename)
? mediaTitle || videoFilename
: videoFilename || mediaTitle) || fallbackFilename;
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, ''); const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
const currentTimePos = const currentTimePos =

View File

@@ -57,6 +57,26 @@ test('MpvIpcClient handles sub-text property change and broadcasts tokenized sub
assert.equal(events[0]!.isOverlayVisible, false); 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', () => { test('MpvIpcClient parses JSON line protocol in processBuffer', () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const seen: Array<Record<string, unknown>> = []; const seen: Array<Record<string, unknown>> = [];

View File

@@ -134,6 +134,7 @@ export class MpvIpcClient implements MpvClient {
private firstConnection = true; private firstConnection = true;
private hasConnectedOnce = false; private hasConnectedOnce = false;
public currentVideoPath = ''; public currentVideoPath = '';
public currentMediaTitle: string | null = null;
public currentTimePos = 0; public currentTimePos = 0;
public currentSubStart = 0; public currentSubStart = 0;
public currentSubEnd = 0; public currentSubEnd = 0;
@@ -330,6 +331,7 @@ export class MpvIpcClient implements MpvClient {
this.emit('media-path-change', payload); this.emit('media-path-change', payload);
}, },
emitMediaTitleChange: (payload) => { emitMediaTitleChange: (payload) => {
this.currentMediaTitle = payload.title;
this.emit('media-title-change', payload); this.emit('media-title-change', payload);
}, },
emitSubtitleMetricsChange: (patch) => { emitSubtitleMetricsChange: (patch) => {
@@ -364,6 +366,7 @@ export class MpvIpcClient implements MpvClient {
}, },
setCurrentVideoPath: (value: string) => { setCurrentVideoPath: (value: string) => {
this.currentVideoPath = value; this.currentVideoPath = value;
this.currentMediaTitle = null;
}, },
emitSecondarySubtitleVisibility: (payload) => { emitSecondarySubtitleVisibility: (payload) => {
this.emit('secondary-subtitle-visibility', payload); this.emit('secondary-subtitle-visibility', payload);

View File

@@ -124,6 +124,7 @@ export interface NotificationOptions {
export interface MpvClient { export interface MpvClient {
currentSubText: string; currentSubText: string;
currentVideoPath: string; currentVideoPath: string;
currentMediaTitle?: string | null;
currentTimePos: number; currentTimePos: number;
currentSubStart: number; currentSubStart: number;
currentSubEnd: number; currentSubEnd: number;