diff --git a/changes/fix-jellyfin-media-identity.md b/changes/fix-jellyfin-media-identity.md new file mode 100644 index 00000000..a50a339b --- /dev/null +++ b/changes/fix-jellyfin-media-identity.md @@ -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. diff --git a/changes/jellyfin-media-identity-docs.md b/changes/jellyfin-media-identity-docs.md new file mode 100644 index 00000000..a0a66904 --- /dev/null +++ b/changes/jellyfin-media-identity-docs.md @@ -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. diff --git a/docs-site/anilist-integration.md b/docs-site/anilist-integration.md index a5a490b2..23a22c76 100644 --- a/docs-site/anilist-integration.md +++ b/docs-site/anilist-integration.md @@ -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 | diff --git a/docs-site/jellyfin-integration.md b/docs-site/jellyfin-integration.md index 4e29be34..2d7704fd 100644 --- a/docs-site/jellyfin-integration.md +++ b/docs-site/jellyfin-integration.md @@ -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: diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index ed63f04a..897e3897 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -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)'); +}); diff --git a/src/anki-integration.ts b/src/anki-integration.ts index 059fcded..a1c47c0b 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -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 = diff --git a/src/core/services/anilist/anilist-update-queue.test.ts b/src/core/services/anilist/anilist-update-queue.test.ts index 37ba54c8..e4776be7 100644 --- a/src/core/services/anilist/anilist-update-queue.test.ts +++ b/src/core/services/anilist/anilist-update-queue.test.ts @@ -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(); diff --git a/src/core/services/anilist/anilist-update-queue.ts b/src/core/services/anilist/anilist-update-queue.ts index 6d2f117d..0a1bc20d 100644 --- a/src/core/services/anilist/anilist-update-queue.ts +++ b/src/core/services/anilist/anilist-update-queue.ts @@ -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; diff --git a/src/core/services/anilist/anilist-updater.test.ts b/src/core/services/anilist/anilist-updater.test.ts index d5e961f9..adbd9886 100644 --- a/src/core/services/anilist/anilist-updater.test.ts +++ b/src/core/services/anilist/anilist-updater.test.ts @@ -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; diff --git a/src/core/services/anilist/anilist-updater.ts b/src/core/services/anilist/anilist-updater.ts index 21149fc2..4e0e35c8 100644 --- a/src/core/services/anilist/anilist-updater.ts +++ b/src/core/services/anilist/anilist-updater.ts @@ -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 { - 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; } diff --git a/src/core/services/anilist/season-resolver.test.ts b/src/core/services/anilist/season-resolver.test.ts index a5e0b8cd..a58f8d23 100644 --- a/src/core/services/anilist/season-resolver.test.ts +++ b/src/core/services/anilist/season-resolver.test.ts @@ -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'); diff --git a/src/core/services/anilist/season-resolver.ts b/src/core/services/anilist/season-resolver.ts index 8bbf9b5d..76aeaa50 100644 --- a/src/core/services/anilist/season-resolver.ts +++ b/src/core/services/anilist/season-resolver.ts @@ -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 " 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 = diff --git a/src/core/services/discord-presence.test.ts b/src/core/services/discord-presence.test.ts index 4932555f..57149b46 100644 --- a/src/core/services/discord-presence.test.ts +++ b/src/core/services/discord-presence.test.ts @@ -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, diff --git a/src/core/services/discord-presence.ts b/src/core/services/discord-presence.ts index 991eb2c4..9cf3859a 100644 --- a/src/core/services/discord-presence.ts +++ b/src/core/services/discord-presence.ts @@ -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 = diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts index ae995172..543ddb6e 100644 --- a/src/core/services/immersion-tracker-service.test.ts +++ b/src/core/services/immersion-tracker-service.test.ts @@ -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; diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts index 74f34bea..1cdc84d7 100644 --- a/src/core/services/immersion-tracker-service.ts +++ b/src/core/services/immersion-tracker-service.ts @@ -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); diff --git a/src/core/services/immersion-tracker/jellyfin-link-repair.ts b/src/core/services/immersion-tracker/jellyfin-link-repair.ts index 0eeed92a..4b499734 100644 --- a/src/core/services/immersion-tracker/jellyfin-link-repair.ts +++ b/src/core/services/immersion-tracker/jellyfin-link-repair.ts @@ -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'); diff --git a/src/core/services/immersion-tracker/metadata.test.ts b/src/core/services/immersion-tracker/metadata.test.ts index c2707532..da3c7c50 100644 --- a/src/core/services/immersion-tracker/metadata.test.ts +++ b/src/core/services/immersion-tracker/metadata.test.ts @@ -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( diff --git a/src/core/services/immersion-tracker/metadata.ts b/src/core/services/immersion-tracker/metadata.ts index 3b09ce07..02868f36 100644 --- a/src/core/services/immersion-tracker/metadata.ts +++ b/src/core/services/immersion-tracker/metadata.ts @@ -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, diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index 3b9a7ccc..c4c66a90 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -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) || ''; diff --git a/src/core/services/mpv.test.ts b/src/core/services/mpv.test.ts index afe6a5bf..be86a33a 100644 --- a/src/core/services/mpv.test.ts +++ b/src/core/services/mpv.test.ts @@ -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()); diff --git a/src/main/media-runtime.ts b/src/main/media-runtime.ts index a994c93b..7f5bb355 100644 --- a/src/main/media-runtime.ts +++ b/src/main/media-runtime.ts @@ -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); }, }; } diff --git a/src/main/runtime/anilist-post-watch.test.ts b/src/main/runtime/anilist-post-watch.test.ts index 06826d11..45c97d01 100644 --- a/src/main/runtime/anilist-post-watch.test.ts +++ b/src/main/runtime/anilist-post-watch.test.ts @@ -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}`), diff --git a/src/main/runtime/anilist-post-watch.ts b/src/main/runtime/anilist-post-watch.ts index 932532b6..e81b355b 100644 --- a/src/main/runtime/anilist-post-watch.ts +++ b/src/main/runtime/anilist-post-watch.ts @@ -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; } diff --git a/src/main/runtime/immersion-media.ts b/src/main/runtime/immersion-media.ts index 35ddd276..744d12f1 100644 --- a/src/main/runtime/immersion-media.ts +++ b/src/main/runtime/immersion-media.ts @@ -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; diff --git a/src/main/runtime/jellyfin-playback-launch.test.ts b/src/main/runtime/jellyfin-playback-launch.test.ts index f6d2916c..46d1e86b 100644 --- a/src/main/runtime/jellyfin-playback-launch.test.ts +++ b/src/main/runtime/jellyfin-playback-launch.test.ts @@ -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); }); diff --git a/src/main/runtime/jellyfin-playback-launch.ts b/src/main/runtime/jellyfin-playback-launch.ts index 9b1ad344..68589c85 100644 --- a/src/main/runtime/jellyfin-playback-launch.ts +++ b/src/main/runtime/jellyfin-playback-launch.ts @@ -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({ diff --git a/src/shared/media-identity.test.ts b/src/shared/media-identity.test.ts new file mode 100644 index 00000000..85ebaa83 --- /dev/null +++ b/src/shared/media-identity.test.ts @@ -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', + ); +}); diff --git a/src/shared/media-identity.ts b/src/shared/media-identity.ts new file mode 100644 index 00000000..68b84f56 --- /dev/null +++ b/src/shared/media-identity.ts @@ -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 ''; + } +}