fix(jellyfin): fix jellyfin media metadata (#250)

This commit is contained in:
2026-09-18 00:24:25 -07:00
committed by GitHub
parent 2831e05124
commit 186d4a0640
29 changed files with 594 additions and 31 deletions
@@ -27,6 +27,83 @@ function createLogger() {
};
}
test('anilist retry queue migrates stream keys and rejects URL-derived searches', () => {
const queueFile = createTempQueueFile();
const loggerState = createLogger();
const key = 'https://example.com/Videos/item/stream?api_key=test-secret::2';
fs.writeFileSync(
queueFile,
JSON.stringify({
pending: [
{
key,
title: 'My Anime',
episode: 2,
createdAt: 1,
attemptCount: 0,
nextAttemptAt: 1,
lastError: null,
},
],
deadLetter: [],
}),
);
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
assert.equal(queue.nextReady()?.key, 'jellyfin://example.com/item/item::2');
assert.equal(fs.readFileSync(queueFile, 'utf8').includes('test-secret'), false);
queue.enqueue('unsafe', 'stream?api_key=test-secret', 3);
assert.equal(queue.getSnapshot().pending, 1);
queue.markSuccess(key);
assert.equal(queue.getSnapshot().pending, 0);
});
test('anilist retry queue discards empty normalized identities on load and enqueue', () => {
const queueFile = createTempQueueFile();
const loggerState = createLogger();
const invalidKeys = [
'',
' ',
'::3',
'stream?api_key=secret::3',
'stream%3Fapi_key%3Dsecret::3',
'https://[invalid::3',
];
const item = {
title: 'My Anime',
episode: 3,
createdAt: 1,
attemptCount: 0,
nextAttemptAt: 1,
lastError: null,
};
const validKey = 'https://example.com/Videos/item/stream?api_key=secret::3';
fs.writeFileSync(
queueFile,
JSON.stringify({
pending: [...invalidKeys, validKey].map((key) => ({ ...item, key })),
deadLetter: invalidKeys.map((key) => ({ ...item, key })),
}),
);
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
assert.deepEqual(queue.getSnapshot(), { pending: 1, ready: 1, deadLetter: 0 });
const persisted = fs.readFileSync(queueFile, 'utf8');
assert.deepEqual(JSON.parse(persisted), {
pending: [{ ...item, key: 'jellyfin://example.com/item/item::3' }],
deadLetter: [],
});
for (const key of invalidKeys) {
queue.enqueue(key, 'My Anime', 3);
queue.markFailure(key, 'invalid');
queue.markSuccess(key);
}
assert.equal(fs.readFileSync(queueFile, 'utf8'), persisted);
queue.markFailure(validKey, 'retry', 10);
assert.equal(queue.nextReady(30_010)?.attemptCount, 1);
queue.markSuccess(validKey);
queue.enqueue(validKey, 'My Anime', 3);
assert.equal(queue.nextReady()?.key, 'jellyfin://example.com/item/item::3');
});
test('anilist update queue enqueues, snapshots, and dequeues success', () => {
const queueFile = createTempQueueFile();
const loggerState = createLogger();
@@ -1,5 +1,13 @@
import * as fs from 'fs';
import { ensureDirForFile } from '../../../shared/fs-utils';
import { sanitizeMediaTitle, toMediaIdentityPath } from '../../../shared/media-identity';
function normalizeAnilistRetryKey(key: string): string {
const parts = key.match(/^(.*)::(\d+)$/s);
const identity = toMediaIdentityPath(parts ? (parts[1] ?? '') : key);
if (!identity) return '';
return parts ? `${identity}::${parts[2]}` : identity;
}
const INITIAL_BACKOFF_MS = 30_000;
const MAX_BACKOFF_MS = 6 * 60 * 60 * 1000;
@@ -105,6 +113,9 @@ export function createAnilistUpdateQueue(
isValidPersistedMediaId(item.mediaId) &&
(typeof item.lastError === 'string' || item.lastError === null),
)
.filter((item) => sanitizeMediaTitle(item.title) !== null)
.map((item) => ({ ...item, key: normalizeAnilistRetryKey(item.key) }))
.filter((item) => item.key !== '')
.slice(0, MAX_ITEMS);
deadLetter = parsedDeadLetter
.filter(
@@ -120,7 +131,11 @@ export function createAnilistUpdateQueue(
isValidPersistedMediaId(item.mediaId) &&
(typeof item.lastError === 'string' || item.lastError === null),
)
.filter((item) => sanitizeMediaTitle(item.title) !== null)
.map((item) => ({ ...item, key: normalizeAnilistRetryKey(item.key) }))
.filter((item) => item.key !== '')
.slice(0, MAX_ITEMS);
if (JSON.stringify({ pending, deadLetter }) !== JSON.stringify(parsed)) persist();
} catch (error) {
logger.error('Failed to load AniList retry queue.', error);
}
@@ -136,6 +151,9 @@ export function createAnilistUpdateQueue(
season: number | null = null,
mediaId: number | null = null,
): void {
if (!sanitizeMediaTitle(title)) return;
key = normalizeAnilistRetryKey(key);
if (!key) return;
const existing =
pending.find((item) => item.key === key) || deadLetter.find((item) => item.key === key);
if (existing) {
@@ -165,6 +183,8 @@ export function createAnilistUpdateQueue(
},
markSuccess(key: string): void {
key = normalizeAnilistRetryKey(key);
if (!key) return;
const before = pending.length;
pending = pending.filter((item) => item.key !== key);
if (pending.length !== before) {
@@ -173,6 +193,8 @@ export function createAnilistUpdateQueue(
},
markFailure(key: string, reason: string, nowMs: number = Date.now()): void {
key = normalizeAnilistRetryKey(key);
if (!key) return;
const item = pending.find((candidate) => candidate.key === key);
if (!item) {
return;
@@ -140,6 +140,62 @@ test('guessAnilistMediaInfo preserves useful guessit alternative title for ambig
});
});
test('guessAnilistMediaInfo uses the display title for authenticated streams', async () => {
const targets: string[] = [];
const result = await guessAnilistMediaInfo(
'https://jellyfin.example/Videos/item/stream?static=true&api_key=test-secret',
'My Anime S02E03',
{
runGuessit: async (target) => {
targets.push(target);
throw new Error('use fallback parser');
},
},
);
assert.deepEqual(targets, ['My Anime S02E03']);
assert.deepEqual(result, {
title: 'My Anime',
season: 2,
episode: 3,
source: 'fallback',
});
});
test('guessAnilistMediaInfo preserves slashes in display titles', async () => {
const title = 'Fate/stay night S01E02';
for (const mediaPath of [null, title, 'https://example.com/stream?api_key=test-secret']) {
const targets: string[] = [];
await guessAnilistMediaInfo(mediaPath, title, {
runGuessit: async (target) => {
targets.push(target);
return JSON.stringify({ title: 'Fate/stay night', season: 1, episode: 2 });
},
});
assert.deepEqual(targets, [title]);
}
});
test('guessAnilistMediaInfo never parses stream URLs or their query-bearing filenames', async () => {
const unsafeInputs = [
'https://jellyfin.example/Videos/item/stream?static=true&api_key=test-secret',
'stream?static=true&api_key=test-secret&MediaSourceId=item',
'https://user:test-secret@example.com/video.mkv',
];
for (const input of unsafeInputs) {
const targets: string[] = [];
const deps = {
runGuessit: async (target: string) => {
targets.push(target);
return JSON.stringify({ title: target });
},
};
assert.equal(await guessAnilistMediaInfo(input, null, deps), null);
assert.equal(await guessAnilistMediaInfo(null, input, deps), null);
assert.equal(await guessAnilistMediaInfo(input, input, deps), null);
assert.deepEqual(targets, []);
}
});
test('updateAnilistPostWatchProgress updates progress when behind', async () => {
const originalFetch = globalThis.fetch;
let call = 0;
+5 -4
View File
@@ -2,6 +2,7 @@ import * as childProcess from 'child_process';
import * as path from 'path';
import { parseMediaInfo } from '../../../jimaku/utils';
import { resolveMediaLookupTarget, sanitizeMediaTitle } from '../../../shared/media-identity';
import type { AnilistRateLimiter } from './rate-limiter';
import { resolveAnilistSeasonMedia } from './season-resolver';
@@ -230,8 +231,9 @@ export async function guessAnilistMediaInfo(
mediaTitle: string | null,
deps: GuessAnilistMediaInfoDeps = { runGuessit },
): Promise<AnilistMediaGuess | null> {
const target = mediaPath ?? mediaTitle;
const guessitTarget = mediaPath ? path.basename(mediaPath) : mediaTitle;
const target = resolveMediaLookupTarget(mediaPath, mediaTitle);
if (!target) return null;
const guessitTarget = target === sanitizeMediaTitle(mediaTitle) ? target : path.basename(target);
if (guessitTarget && guessitTarget.trim().length > 0) {
try {
@@ -259,8 +261,7 @@ export async function guessAnilistMediaInfo(
}
}
const fallbackTarget = mediaPath ?? mediaTitle;
const parsed = parseMediaInfo(fallbackTarget);
const parsed = parseMediaInfo(target);
if (!parsed.title.trim()) {
return null;
}
@@ -116,6 +116,27 @@ function createExecutor(
return { execute, searches, relationLookups };
}
test('AniList refuses URL-derived search titles before making a request', async () => {
let requests = 0;
for (const title of [
'https://example.com/stream?api_key=test-secret',
'stream?static=true&api_key=test-secret',
'stream static true api key test secret',
]) {
const result = await resolveAnilistSeasonMedia(
{ title },
{
execute: async () => {
requests += 1;
throw new Error('must not send URL-derived searches');
},
},
);
assert.equal(result, null);
}
assert.equal(requests, 0);
});
test('stripSeasonSuffix drops release-name season markers', () => {
assert.equal(stripSeasonSuffix('Some Show Season 3'), 'Some Show');
assert.equal(stripSeasonSuffix('Some Show S3'), 'Some Show');
+5 -1
View File
@@ -1,3 +1,5 @@
import { sanitizeMediaTitle } from '../../../shared/media-identity';
/**
* AniList has no concept of "season N": sequels are separate media with their own
* titles (Zoku, Kan, 2nd Season, ...). Searching "<title> Season 3" therefore returns
@@ -348,7 +350,9 @@ export async function resolveAnilistSeasonMedia(
input: ResolveAnilistSeasonMediaInput,
deps: ResolveAnilistSeasonMediaDeps,
): Promise<AnilistSeasonResolution | null> {
const searchTitle = stripSeasonSuffix(input.title).trim() || input.title.trim();
const safeTitle = sanitizeMediaTitle(input.title);
if (!safeTitle) return null;
const searchTitle = stripSeasonSuffix(safeTitle).trim() || safeTitle;
if (!searchTitle) return null;
const season =
@@ -91,6 +91,21 @@ test('buildDiscordPresenceActivity shows media title regardless of style', () =>
}
});
test('buildDiscordPresenceActivity rejects stream URLs supplied as titles', () => {
for (const mediaTitle of [
'https://example.com/stream?api_key=test-secret',
'stream?api_key=test-secret',
]) {
const activity = buildDiscordPresenceActivity(baseConfig, {
...baseSnapshot,
mediaPath: 'https://example.com/stream?api_key=test-secret',
mediaTitle,
});
assert.equal(activity.details, 'Unknown media');
assert.equal(JSON.stringify(activity).includes('test-secret'), false);
}
});
test('buildDiscordPresenceActivity never falls back to remote stream URLs', () => {
const payload = buildDiscordPresenceActivity(baseConfig, {
...baseSnapshot,
+2 -1
View File
@@ -1,5 +1,6 @@
import type { DiscordPresenceStylePreset } from '../../types/integrations';
import type { ResolvedConfig } from '../../types';
import { sanitizeMediaTitle } from '../../shared/media-identity';
export interface DiscordPresenceSnapshot {
mediaTitle: string | null;
@@ -140,7 +141,7 @@ export function buildDiscordPresenceActivity(
const style = resolvePresenceStyle(config.presenceStyle);
const status = buildStatus(snapshot);
const title = sanitizeText(
snapshot.mediaTitle,
sanitizeMediaTitle(snapshot.mediaTitle),
fallbackTitleFromMediaPath(snapshot.mediaPath) || 'Unknown media',
);
const details =
@@ -3003,7 +3003,27 @@ test('startup repairs existing Jellyfin stream video links to metadata rows', as
const titledStreamUrl =
'http://jellyfin.local/Videos/item-10/stream?static=true&api_key=secret-token&MediaSourceId=ms-2';
tracker.handleMediaChange(titledStreamUrl, 'KonoSuba S01E06 Decision! Class Rep');
tracker.handleMediaTitleUpdate('stream?static=true&api_key=secret-token');
tracker.handleMediaChange(null, null);
// Safety must hold before metadata registration or a startup repair can run.
const liveDb = (tracker as unknown as { db: DatabaseSync }).db;
const persistedRows = liveDb.prepare('SELECT * FROM imm_videos').all();
assert.equal(JSON.stringify(persistedRows).includes('secret-token'), false);
assert.equal(JSON.stringify(persistedRows).includes('/stream'), false);
// Recreate the old on-disk representation to retain coverage of startup repair.
liveDb
.prepare(
'UPDATE imm_videos SET video_key = ?, source_url = ?, canonical_title = ? WHERE source_url = ?',
)
.run(
`remote:${streamUrl}`,
streamUrl,
'stream?static=true&api_key=secret-token',
'jellyfin://jellyfin.local/item/item-9',
);
liveDb
.prepare('UPDATE imm_videos SET video_key = ?, source_url = ? WHERE source_url = ?')
.run(`remote:${titledStreamUrl}`, titledStreamUrl, 'jellyfin://jellyfin.local/item/item-10');
tracker.recordJellyfinPlaybackMetadata({
mediaPath: 'http://jellyfin.local/Videos/item-9/stream?static=true&api_key=secret-token',
displayTitle: 'Frieren S01E09 Aura the Guillotine',
@@ -3105,6 +3125,90 @@ test('startup repairs existing Jellyfin stream video links to metadata rows', as
}
});
test('startup clears leaked parser metadata on safely titled anime without changing assignments', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
tracker.recordJellyfinPlaybackMetadata({
mediaPath: 'https://jellyfin.example/Videos/item/stream?api_key=test-secret',
displayTitle: 'My Anime S01E01',
itemTitle: 'Episode 1',
seriesTitle: 'My Anime',
seasonNumber: 1,
episodeNumber: 1,
itemId: 'item',
});
const db = (tracker as unknown as { db: DatabaseSync }).db;
db.prepare('UPDATE imm_anime SET metadata_json = ?').run(
JSON.stringify({
filename: 'stream?api_key=test-secret',
source: 'guessit',
}),
);
const before = db.prepare('SELECT video_id, anime_id FROM imm_videos').all();
tracker.destroy();
tracker = new Ctor({ dbPath });
const repairedDb = (tracker as unknown as { db: DatabaseSync }).db;
assert.deepEqual(repairedDb.prepare('SELECT video_id, anime_id FROM imm_videos').all(), before);
assert.deepEqual(
repairedDb.prepare('SELECT canonical_title, metadata_json FROM imm_anime').all(),
[{ canonical_title: 'My Anime Season 1', metadata_json: null }],
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('Jellyfin metadata cleanup requires both an API key and a stream marker', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
try {
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const db = (tracker as unknown as { db: DatabaseSync }).db;
const timestamp = toDbTimestamp(trackerNowMs());
const cases = [
{ filename: 'stream?api_key=secret', leaked: true },
{ filename: '/STREAM?API_KEY=secret', leaked: true },
{ filename: '/Videos/item?api_key=secret', leaked: true },
{ filename: 'MediaSourceId=item api key secret', leaked: true },
{ filename: 'An API Key Story', leaked: false },
{ filename: 'api_key=ordinary-metadata', leaked: false },
{ filename: 'stream?quality=high', leaked: false },
{ filename: '/Videos/item', leaked: false },
{ filename: 'MediaSourceId=item', leaked: false },
];
for (const [index, entry] of cases.entries()) {
db.prepare(
`
INSERT INTO imm_anime (
normalized_title_key, canonical_title, metadata_json, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?)
`,
).run(
`show-${index}`,
`Show ${index}`,
JSON.stringify({ filename: entry.filename }),
timestamp,
timestamp,
);
}
repairJellyfinStreamVideoLinks(db);
assert.deepEqual(
db.prepare('SELECT metadata_json FROM imm_anime ORDER BY anime_id').all(),
cases.map(({ filename, leaked }) => ({
metadata_json: leaked ? null : JSON.stringify({ filename }),
})),
);
} finally {
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('Jellyfin link repair removes merged leaked anime rows and sanitizes orphan video titles', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
@@ -1,6 +1,7 @@
import path from 'node:path';
import * as fs from 'node:fs';
import { createLogger } from '../../logger';
import { sanitizeMediaTitle, toMediaIdentityPath } from '../../shared/media-identity';
import { MediaGenerator } from '../../media-generator';
import type { CoverArtFetcher } from './anilist/cover-art-fetcher';
import { getLocalVideoMetadata, guessAnimeVideoMetadata } from './immersion-tracker/metadata';
@@ -356,7 +357,7 @@ function normalizeMetadataInt(value: number | null | undefined): number | null {
function buildJellyfinStatsMediaPath(mediaPath: string, itemId: string): string {
const normalizedItemId = normalizeText(itemId);
if (!normalizedItemId) {
return mediaPath;
return toMediaIdentityPath(mediaPath);
}
try {
const parsed = new URL(mediaPath);
@@ -1520,11 +1521,11 @@ export class ImmersionTrackerService {
}
const displayTitle =
normalizeText(metadata.displayTitle) ||
normalizeText(metadata.itemTitle) ||
normalizeText(sanitizeMediaTitle(metadata.displayTitle)) ||
normalizeText(sanitizeMediaTitle(metadata.itemTitle)) ||
deriveCanonicalTitle(normalizedPath);
const itemTitle = normalizeText(metadata.itemTitle) || displayTitle;
const seriesTitle = normalizeText(metadata.seriesTitle);
const itemTitle = normalizeText(sanitizeMediaTitle(metadata.itemTitle)) || displayTitle;
const seriesTitle = normalizeText(sanitizeMediaTitle(metadata.seriesTitle));
const libraryTitle = seriesTitle || itemTitle;
const seasonNumber = normalizeMetadataInt(metadata.seasonNumber);
const episodeNumber = normalizeMetadataInt(metadata.episodeNumber);
@@ -1611,8 +1612,8 @@ export class ImmersionTrackerService {
const normalizedPath =
buildJellyfinMediaPathAliasCandidates(rawPath)
.map((alias) => this.mediaPathAliases.get(alias))
.find((alias): alias is string => Boolean(alias)) ?? rawPath;
const normalizedTitle = normalizeText(mediaTitle);
.find((alias): alias is string => Boolean(alias)) ?? toMediaIdentityPath(rawPath);
const normalizedTitle = normalizeText(sanitizeMediaTitle(mediaTitle));
this.logger.info(
`handleMediaChange called with path=${normalizedPath || '<empty>'} title=${normalizedTitle || '<empty>'}`,
);
@@ -1670,7 +1671,7 @@ export class ImmersionTrackerService {
handleMediaTitleUpdate(mediaTitle: string | null): void {
if (!this.sessionState) return;
const normalizedTitle = normalizeText(mediaTitle);
const normalizedTitle = normalizeText(sanitizeMediaTitle(mediaTitle));
if (!normalizedTitle) return;
this.currentVideoKey = normalizedTitle;
this.updateVideoTitleForActiveSession(normalizedTitle);
@@ -257,6 +257,30 @@ function repairLeakedJellyfinVideoParseMetadata(
return updated.changes;
}
function repairLeakedJellyfinAnimeParseMetadata(
db: DatabaseSync,
currentTimestamp: string,
): number {
const updated = db
.prepare(
`
UPDATE imm_anime
SET metadata_json = NULL, LAST_UPDATE_DATE = ?
WHERE (
metadata_json LIKE '%api_key=%'
OR lower(metadata_json) LIKE '%api key%'
) AND (
lower(metadata_json) LIKE '%stream?%'
OR lower(metadata_json) LIKE '%/stream?%'
OR lower(metadata_json) LIKE '%/videos/%'
OR lower(metadata_json) LIKE '%mediasourceid%'
)
`,
)
.run(currentTimestamp);
return updated.changes;
}
export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRepairSummary {
const candidates = db
.prepare(
@@ -290,7 +314,8 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
const currentTimestamp = toDbTimestamp(nowMs());
const repaired =
repairLeakedJellyfinAnimeTitles(db, currentTimestamp) +
repairLeakedJellyfinVideoParseMetadata(db, currentTimestamp);
repairLeakedJellyfinVideoParseMetadata(db, currentTimestamp) +
repairLeakedJellyfinAnimeParseMetadata(db, currentTimestamp);
summary.repaired += repaired;
return summary;
}
@@ -422,6 +447,7 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
}
summary.repaired += repairLeakedJellyfinAnimeTitles(db, currentTimestamp);
summary.repaired += repairLeakedJellyfinVideoParseMetadata(db, currentTimestamp);
summary.repaired += repairLeakedJellyfinAnimeParseMetadata(db, currentTimestamp);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
@@ -147,6 +147,25 @@ test('getLocalVideoMetadata derives title and falls back to null hash on read er
assert.equal(hashFallbackMetadata.hashSha256, null);
});
test('stream stats parsing preserves display titles and never persists transport credentials', async () => {
const targets: string[] = [];
const parsed = await guessAnimeVideoMetadata(
'https://jellyfin.example/Videos/item/stream?api_key=test-secret',
'Fate/stay night S01E02',
{
runGuessit: async (target) => {
targets.push(target);
return JSON.stringify({ title: 'Fate/stay night', season: 1, episode: 2 });
},
},
);
assert.deepEqual(targets, ['Fate/stay night S01E02']);
assert.equal(parsed?.parsedBasename, 'Fate/stay night S01E02');
assert.equal(parsed?.parsedTitle, 'Fate/stay night');
assert.equal(JSON.stringify(parsed).includes('test-secret'), false);
assert.equal(JSON.stringify(parsed).includes('/stream'), false);
});
test('guessAnimeVideoMetadata uses guessit basename output first when available', async () => {
const seenTargets: string[] = [];
const parsed = await guessAnimeVideoMetadata(
@@ -3,6 +3,7 @@ import { spawn as nodeSpawn } from 'node:child_process';
import * as fs from 'node:fs';
import path from 'node:path';
import { parseMediaInfo } from '../../../jimaku/utils';
import { resolveMediaLookupTarget, sanitizeMediaTitle } from '../../../shared/media-identity';
import {
guessAnilistMediaInfo,
runGuessit,
@@ -184,6 +185,7 @@ export async function guessAnimeVideoMetadata(
mediaTitle: string | null,
deps: GuessAnimeVideoMetadataDeps = {},
): Promise<ParsedAnimeVideoGuess | null> {
const lookupTarget = resolveMediaLookupTarget(mediaPath, mediaTitle);
const parsed = await guessAnilistMediaInfo(mediaPath, mediaTitle, {
runGuessit: deps.runGuessit ?? runGuessit,
});
@@ -191,7 +193,12 @@ export async function guessAnimeVideoMetadata(
return null;
}
const parsedBasename = mediaPath ? path.basename(mediaPath) : null;
const parsedBasename =
lookupTarget === sanitizeMediaTitle(mediaTitle)
? lookupTarget
: lookupTarget
? path.basename(lookupTarget)
: null;
if (parsed.source === 'guessit') {
return {
parsedBasename,
@@ -207,7 +214,7 @@ export async function guessAnimeVideoMetadata(
};
}
const fallbackInfo = parseMediaInfo(mediaPath ?? mediaTitle);
const fallbackInfo = parseMediaInfo(lookupTarget);
return {
parsedBasename: parsedBasename ?? fallbackInfo.filename ?? null,
parsedTitle: parsed.title,
+4 -1
View File
@@ -1,4 +1,5 @@
import { MpvSubtitleRenderMetrics } from '../../types';
import { sanitizeMediaTitle } from '../../shared/media-identity';
export type MpvMessage = {
event?: string;
@@ -334,8 +335,10 @@ export async function dispatchMpvProtocolMessage(
} else if (msg.name === 'fullscreen') {
deps.emitFullscreenChange({ fullscreen: asBoolean(msg.data, false) });
} else if (msg.name === 'media-title') {
const title = typeof msg.data === 'string' ? sanitizeMediaTitle(msg.data) : null;
if (typeof msg.data === 'string' && msg.data.trim() && !title) return;
deps.emitMediaTitleChange({
title: typeof msg.data === 'string' ? msg.data.trim() : null,
title,
});
} else if (msg.name === 'path') {
const path = (msg.data as string) || '';
+15
View File
@@ -120,6 +120,21 @@ test('MpvIpcClient emits fullscreen property changes', async () => {
assert.deepEqual(events, [{ fullscreen: true }]);
});
test('MpvIpcClient ignores URL-derived titles without replacing known metadata', async () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const titles: Array<string | null> = [];
client.on('media-title-change', ({ title }) => titles.push(title));
for (const data of [
'My Anime S01E02',
'https://example.com/stream?api_key=test-secret',
'stream?api_key=test-secret',
]) {
await invokeHandleMessage(client, { event: 'property-change', name: 'media-title', data });
}
assert.equal(client.currentMediaTitle, 'My Anime S01E02');
assert.deepEqual(titles, ['My Anime S01E02']);
});
test('MpvIpcClient clears cached media title when media path changes', async () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());