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
+7
View File
@@ -0,0 +1,7 @@
type: fixed
area: jellyfin
- Set the mpv title before loading Jellyfin streams and reject URL-derived titles from metadata lookups, Anki source fields, Discord presence, and stats.
- Keep authenticated stream URLs out of stats identities even when playback metadata has not arrived.
- Remove previously cached credential-bearing Jellyfin stream parser metadata without changing unrelated anime metadata, watch history, or library assignments.
- Use safe media identities for persisted AniList retry keys, discard URL-derived queued searches, and skip updates and retries whose media identity is empty.
+5
View File
@@ -0,0 +1,5 @@
type: docs
area: jellyfin
- Explain how Jellyfin media titles, unknown-title fallbacks, and stats identities keep stream credentials out of metadata.
- Document AniList rejection of empty media identities and the conditions for clearing cached Jellyfin parser metadata.
+2
View File
@@ -47,6 +47,8 @@ The update flow:
Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff.
Updates are skipped if the media path cannot produce a safe, nonempty identity. Invalid entries are discarded when loading or adding to the retry queue.
| Parameter | Value |
| ---------------- | ---------- |
| Initial backoff | 30 seconds |
+3
View File
@@ -52,10 +52,13 @@ From then on, pause / resume / seek / stop and audio or subtitle track changes y
- **mpv launches automatically.** If mpv isn't already running when you cast, SubMiner starts it with SubMiner defaults and the bundled mpv plugin, so keybindings work right away.
- **The overlay is managed by SubMiner,** so your configured `subtitleStyle` controls how subtitles look. Use the [overlay-toggle shortcut](/shortcuts) to hide it for a session.
- **Resume works.** If Jellyfin has a saved position for the item, SubMiner seeks there on load.
- **Titles and credentials stay separate.** AniList, character dictionaries, Anki source fields, and Discord presence use media titles, never authenticated stream URLs. If a usable title is unavailable, lookups are skipped and source fields show an unknown-media label. Stats identifies Jellyfin videos by server and item ID without the stream URL or API key.
- **Direct play first.** When the source allows it and the container is in your direct-play allowlist, SubMiner streams the original file; otherwise it requests a transcoded stream from Jellyfin.
- **Japanese subtitles are auto-selected,** preferring Jellyfin's default and embedded tracks over external sidecar files when several match.
- **Downloaded subtitles keep their original timing.** SubMiner removes Jellyfin's server-selected subtitle stream from the mpv load URL, suppresses the mpv plugin's one-shot subtitle auto-selection and overlay auto-start for managed Jellyfin loads, stages the subtitle files exposed by Jellyfin without letting mpv auto-switch between tracks, resets mpv's subtitle delay to zero, then selects the Japanese track. SubMiner does not compare Japanese and English cue timelines or save an inferred delay.
On startup, SubMiner clears cached anime parser metadata containing both API-key text and Jellyfin stream markers. Metadata containing only one of these is preserved.
## Settings
All Jellyfin options live under **Settings → Integrations → Jellyfin** (open settings from the tray's **Open SubMiner Settings**). The ones that matter for casting:
+18
View File
@@ -1527,3 +1527,21 @@ test('AnkiIntegration.formatMiscInfoPattern avoids leaking Jellyfin api_key quer
assert.equal(result, '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)');
assert.equal(result.includes('api_key='), false);
});
test('Anki metadata rejects a credential-bearing media title before metadata arrives', () => {
const integration = new AnkiIntegration(
{ metadata: { pattern: '[SubMiner] %f | %F (%t)' } } as never,
{} as never,
{
currentVideoPath: 'https://jellyfin.example/Videos/item/stream?api_key=test-secret',
currentMediaTitle: 'stream?static=true&api_key=test-secret',
currentTimePos: 426,
send: () => true,
} as never,
);
const privateApi = integration as unknown as {
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
};
const result = privateApi.formatMiscInfoPattern('stream?api_key=test-secret', 426);
assert.equal(result, '[SubMiner] Unknown media | Unknown media (00:07:06)');
});
+6 -3
View File
@@ -32,6 +32,7 @@ import {
type MediaTimingReviewRequest,
} from './types/anki';
import { AiConfig } from './types/integrations';
import { sanitizeMediaTitle } from './shared/media-identity';
import type { KnownWordMaturityTier } from './types/subtitle';
import { MpvClient } from './types/runtime';
import { OPEN_ANKI_CARD_ACTION_ID } from './types/notification';
@@ -1165,11 +1166,13 @@ export class AnkiIntegration {
}
const videoFilename = extractFilenameFromMediaPath(mediaPath);
const resolvedMediaTitle = trimToNonEmptyString(mediaTitle);
const resolvedMediaTitle = sanitizeMediaTitle(mediaTitle);
const filenameWithExt =
(shouldPreferMediaTitleForMiscInfo(mediaPath, videoFilename)
? resolvedMediaTitle || videoFilename
: videoFilename || resolvedMediaTitle) || fallbackFilename;
? resolvedMediaTitle || 'Unknown media'
: sanitizeMediaTitle(videoFilename) || resolvedMediaTitle) ||
sanitizeMediaTitle(fallbackFilename) ||
'Unknown media';
const filenameWithoutExt = filenameWithExt.replace(/\.[^.]+$/, '');
const currentTimePos =
@@ -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());
+7 -5
View File
@@ -1,4 +1,5 @@
import { updateCurrentMediaPath } from '../core/services';
import { sanitizeMediaTitle } from '../shared/media-identity';
import type { SubtitlePosition } from '../types';
@@ -52,17 +53,18 @@ export function createMediaRuntimeService(deps: MediaRuntimeDeps): MediaRuntimeS
updateCurrentMediaTitle(mediaTitle: unknown): void {
if (typeof mediaTitle === 'string') {
const sanitized = mediaTitle.trim();
deps.setCurrentMediaTitle(sanitized.length > 0 ? sanitized : null);
const sanitized = sanitizeMediaTitle(mediaTitle);
if (mediaTitle.trim() && !sanitized) return;
deps.setCurrentMediaTitle(sanitized);
return;
}
deps.setCurrentMediaTitle(null);
},
resolveMediaPathForJimaku(mediaPath: string | null): string | null {
return mediaPath && deps.isRemoteMediaPath(mediaPath) && deps.getCurrentMediaTitle()
? deps.getCurrentMediaTitle()
: mediaPath;
return mediaPath && deps.isRemoteMediaPath(mediaPath)
? sanitizeMediaTitle(deps.getCurrentMediaTitle())
: sanitizeMediaTitle(mediaPath);
},
};
}
@@ -9,6 +9,10 @@ import {
test('buildAnilistAttemptKey formats media and episode', () => {
assert.equal(buildAnilistAttemptKey('/tmp/video.mkv', 3), '/tmp/video.mkv::3');
assert.equal(
buildAnilistAttemptKey('https://example.com/Videos/item/stream?api_key=test-secret', 3),
'jellyfin://example.com/item/item::3',
);
});
test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => {
@@ -17,6 +21,49 @@ test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => {
assert.deepEqual(Array.from(set), ['b', 'c']);
});
test('post-watch rejects empty media identities before attempted keys or update side effects', async () => {
for (const mediaKey of [
' ',
'stream?api_key=secret',
'stream%3Fapi_key%3Dsecret',
'https://[invalid',
]) {
assert.equal(buildAnilistAttemptKey(mediaKey, 3), null);
const calls: string[] = [];
const unexpected = () => assert.fail('invalid identity reached update side effects');
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => mediaKey,
hasMpvClient: () => true,
getTrackedMediaKey: () => mediaKey,
resetTrackedMedia: unexpected,
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => 1000,
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 3 }),
hasAttemptedUpdateKey: unexpected,
processNextAnilistRetryUpdate: unexpected,
refreshAnilistClientSecretState: unexpected,
enqueueRetry: unexpected,
markRetryFailure: unexpected,
markRetrySuccess: unexpected,
refreshRetryQueueState: unexpected,
updateAnilistPostWatchProgress: unexpected,
rememberAttemptedUpdateKey: unexpected,
showMpvOsd: unexpected,
logInfo: unexpected,
logWarn: unexpected,
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler();
await handler({ force: true });
assert.deepEqual(calls, ['inflight:true', 'inflight:false', 'inflight:true', 'inflight:false']);
}
});
test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => {
const calls: string[] = [];
const handler = createProcessNextAnilistRetryUpdateHandler({
@@ -335,6 +382,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler notifies when retry already ha
const attemptedKeys = new Set<string>();
const mediaKey = '/tmp/video.mkv';
const attemptKey = buildAnilistAttemptKey(mediaKey, 1);
assert.ok(attemptKey);
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
+5 -2
View File
@@ -1,4 +1,5 @@
import { isYoutubeMediaPath } from './youtube-playback';
import { toMediaIdentityPath } from '../../shared/media-identity';
type AnilistGuess = {
title: string;
@@ -31,8 +32,9 @@ type AnilistDurationProbeOptions = {
force?: boolean;
};
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string {
return `${mediaKey}::${episode}`;
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string | null {
const identity = toMediaIdentityPath(mediaKey);
return identity ? `${identity}::${episode}` : null;
}
export function rememberAnilistAttemptedUpdateKey(
@@ -214,6 +216,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
}
const attemptKey = buildAnilistAttemptKey(mediaKey, guess.episode);
if (!attemptKey) return;
if (deps.hasAttemptedUpdateKey(attemptKey)) {
return;
}
+3 -1
View File
@@ -1,3 +1,5 @@
import { toMediaIdentityPath } from '../../shared/media-identity';
type ResolvedConfigLike = {
immersionTracking?: {
dbPath?: string | null;
@@ -119,7 +121,7 @@ export function createImmersionMediaRuntime(deps: ImmersionMediaRuntimeDeps): {
const mediaState = await getCurrentMpvMediaStateForTracker();
if (mediaState.path) {
deps.logInfo(
`Seeded immersion tracker media state at attempt ${attempt + 1}/${attempts}: ${mediaState.path}`,
`Seeded immersion tracker media state at attempt ${attempt + 1}/${attempts}: ${toMediaIdentityPath(mediaState.path)}`,
);
tracker.handleMediaChange(mediaState.path, mediaState.title);
return;
@@ -103,6 +103,7 @@ test('playback handler drives mpv commands and playback state', async () => {
['set_property', 'sub-visibility', 'no'],
['set_property', 'secondary-sub-visibility', 'no'],
['script-message', 'subminer-managed-subtitles-loading'],
['set_property', 'force-media-title', 'Episode 1'],
[
'loadfile',
'https://stream.example/video.m3u8',
@@ -110,7 +111,6 @@ test('playback handler drives mpv commands and playback state', async () => {
-1,
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no,start=1.2',
],
['set_property', 'force-media-title', 'Episode 1'],
]);
assert.equal(scheduled.length, 0);
assert.equal(
@@ -437,6 +437,8 @@ test('playback handler publishes Jellyfin title before loading tokenized stream
assert.ok(titleIndex >= 0);
assert.ok(loadIndex >= 0);
assert.ok(titleIndex < loadIndex);
const mpvTitleIndex = timeline.indexOf('cmd:set_property:force-media-title');
assert.ok(mpvTitleIndex >= 0 && mpvTitleIndex < loadIndex);
assert.equal(timeline[titleIndex]?.includes('api_key'), false);
});
+2 -1
View File
@@ -221,11 +221,12 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
});
deps.setLastProgressAtMs(0);
deps.sendMpvCommand(['script-message', 'subminer-managed-subtitles-loading']);
// Set mpv's title before loadfile can emit a URL-derived media-title event.
deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]);
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace', -1, loadfileOptions]);
if (params.setQuitOnDisconnectArm !== false) {
deps.armQuitOnDisconnect();
}
deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]);
await awaitBestEffortPlaybackHook(() =>
deps.preloadExternalSubtitles({
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { sanitizeMediaTitle, toMediaIdentityPath } from './media-identity';
test('media identity separates authenticated transport URLs from titles and stats keys', () => {
const url =
'https://user:password@jellyfin.example/Videos/item-1/stream?api_key=test-secret#token';
assert.equal(sanitizeMediaTitle(url), null);
assert.equal(sanitizeMediaTitle('stream?static=true&api_key=test-secret'), null);
assert.equal(sanitizeMediaTitle('stream static true api key test secret'), null);
assert.equal(sanitizeMediaTitle('stream%3Fapi_key%3Dtest-secret'), null);
assert.equal(sanitizeMediaTitle(' My Anime S01E02 '), 'My Anime S01E02');
assert.equal(toMediaIdentityPath(url), 'jellyfin://jellyfin.example/item/item-1');
assert.equal(
toMediaIdentityPath('https://example.com/base/Videos/item-1/master.m3u8?token=secret'),
'jellyfin://example.com/item/item-1',
);
assert.equal(toMediaIdentityPath('stream?api_key=test-secret'), '');
assert.equal(toMediaIdentityPath('/media/My Anime S01E02.mkv'), '/media/My Anime S01E02.mkv');
});
test('remote stats identities drop credentials while preserving YouTube video identity', () => {
assert.match(
toMediaIdentityPath('https://user:password@example.com/video.mkv?signature=secret#secret'),
/^https:\/\/example\.com\/video\.mkv#query-[a-f0-9]{64}$/,
);
assert.notEqual(
toMediaIdentityPath('https://example.com/video?id=1'),
toMediaIdentityPath('https://example.com/video?id=2'),
);
const identity = toMediaIdentityPath('https://example.com/video?id=1');
assert.equal(toMediaIdentityPath(identity), identity);
assert.equal(
toMediaIdentityPath('https://www.youtube.com/watch?v=video-id&token=secret'),
'https://www.youtube.com/watch?v=video-id',
);
});
+58
View File
@@ -0,0 +1,58 @@
import { createHash } from 'node:crypto';
const URL_SCHEME = /[a-z][a-z0-9+.-]*:\/\//i;
const QUERY_PAIR = /[?&][^=\s&#]+=/;
const CREDENTIAL_LABEL = /\b(?:api[_ -]?key|access[_ -]?token|x[_ -]?emby[_ -]?token)\b/i;
/** mpv can report the URL or its query-bearing basename before metadata arrives. */
export function sanitizeMediaTitle(value: string | null | undefined): string | null {
const title = value?.trim();
if (!title) return null;
let decoded = title;
try {
decoded = decodeURIComponent(title);
} catch {
// Ordinary titles can contain a literal percent sign.
}
if (URL_SCHEME.test(decoded) || QUERY_PAIR.test(decoded) || CREDENTIAL_LABEL.test(decoded)) {
return null;
}
return title;
}
export function resolveMediaLookupTarget(
mediaPath: string | null,
mediaTitle: string | null,
): string | null {
return sanitizeMediaTitle(mediaPath) ?? sanitizeMediaTitle(mediaTitle);
}
/** Persistent identity, never a URL to use for authenticated media retrieval. */
export function toMediaIdentityPath(mediaPath: string): string {
const value = mediaPath.trim();
if (!URL_SCHEME.test(value)) return sanitizeMediaTitle(value) ?? '';
try {
const url = new URL(value);
const jellyfinItem = url.pathname.match(
/\/Videos\/([^/]+)\/(?:stream(?:\.[^/]*)?|master\.m3u8)\/?$/i,
);
if (jellyfinItem) return `jellyfin://${url.host}/item/${jellyfinItem[1]}`;
url.username = '';
url.password = '';
const query = url.search;
const queryFingerprint = /^#query-[a-f0-9]{64}$/.test(url.hash) ? url.hash : '';
const youtubeId = /^(?:www\.|m\.)?youtube\.com$/i.test(url.hostname)
? url.searchParams.get('v')
: null;
url.search = '';
url.hash = queryFingerprint;
if (youtubeId) url.searchParams.set('v', youtubeId);
else if (query) {
// Different query-selected videos must not collapse into one stats entry.
url.hash = `query-${createHash('sha256').update(query).digest('hex')}`;
}
return url.toString();
} catch {
return '';
}
}