mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-19 05:16:27 -07:00
fix(jellyfin): fix jellyfin media metadata (#250)
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) || '';
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user