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,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