mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-27 11:35:23 -07:00
fix(jellyfin): fix jellyfin media metadata (#250)
This commit is contained in:
1 parent
2831e05124
commit
186d4a0640
29 files changed
+594
-31
No files matched your search
@@ -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 =
|
||||
|
||||
Reference in new issue
Block a user