mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 05:16:20 -07:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0efdc5db15
|
||
|
|
f6855ba459
|
||
|
|
3a0884f4c7
|
||
|
|
c316ae08c2
|
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: jellyfin
|
||||||
|
|
||||||
|
- Authenticate Jellyfin playback, subtitle, artwork, and remote-control socket URLs with the `ApiKey` query parameter and stop sending the legacy `X-Emby-Token` and `X-Emby-Authorization` headers, so the integration keeps working on Jellyfin 12 where legacy authorization is disabled by default.
|
||||||
|
- Keep the cast-target websocket alive by answering Jellyfin keep-alive requests and reconnect when the server stops replying, so "Play on SubMiner" keeps working on Jellyfin 12 instead of silently dying about a minute after connecting. Failed playback progress and stop reports are now logged as warnings.
|
||||||
@@ -12,7 +12,7 @@ This is the recommended way to use Jellyfin with SubMiner. A terminal-only optio
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- A Jellyfin server plus your username and password
|
- A Jellyfin server plus your username and password (Jellyfin 12, which disables legacy authorization by default, is supported)
|
||||||
- SubMiner installed and running (see [Installation](/installation))
|
- SubMiner installed and running (see [Installation](/installation))
|
||||||
- On Linux, the session token is stored with `gnome-libsecret` by default
|
- On Linux, the session token is stored with `gnome-libsecret` by default
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ export async function jellyfinApiRequest<T>(
|
|||||||
const url = `${session.serverUrl}${requestPath}`;
|
const url = `${session.serverUrl}${requestPath}`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'X-Emby-Token': session.accessToken,
|
|
||||||
Authorization: `MediaBrowser Token="${session.accessToken}"`,
|
Authorization: `MediaBrowser Token="${session.accessToken}"`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -103,7 +102,7 @@ export async function jellyfinApiRequest<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function itemPreviewUrl(session: JellyfinSessionConfig, id: string): string {
|
function itemPreviewUrl(session: JellyfinSessionConfig, id: string): string {
|
||||||
return `${session.serverUrl}/Items/${id}/Images/Primary?maxHeight=720&quality=85&api_key=${encodeURIComponent(session.accessToken)}`;
|
return `${session.serverUrl}/Items/${id}/Images/Primary?maxHeight=720&quality=85&ApiKey=${encodeURIComponent(session.accessToken)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function jellyfinIconCacheDir(session: JellyfinSessionConfig): string {
|
function jellyfinIconCacheDir(session: JellyfinSessionConfig): string {
|
||||||
|
|||||||
+3
-3
@@ -228,7 +228,7 @@ export function pickLibrary(
|
|||||||
commandExists('chafa') && commandExists('curl')
|
commandExists('chafa') && commandExists('curl')
|
||||||
? `
|
? `
|
||||||
id={1}
|
id={1}
|
||||||
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)}
|
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&ApiKey=${escapeShellSingle(session.accessToken)}
|
||||||
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
|
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
|
||||||
`.trim()
|
`.trim()
|
||||||
: 'echo "Install curl + chafa for image preview"';
|
: 'echo "Install curl + chafa for image preview"';
|
||||||
@@ -266,7 +266,7 @@ export function pickItem(
|
|||||||
commandExists('chafa') && commandExists('curl')
|
commandExists('chafa') && commandExists('curl')
|
||||||
? `
|
? `
|
||||||
id={1}
|
id={1}
|
||||||
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)}
|
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&ApiKey=${escapeShellSingle(session.accessToken)}
|
||||||
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
|
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
|
||||||
`.trim()
|
`.trim()
|
||||||
: 'echo "Install curl + chafa for image preview"';
|
: 'echo "Install curl + chafa for image preview"';
|
||||||
@@ -304,7 +304,7 @@ export function pickGroup(
|
|||||||
commandExists('chafa') && commandExists('curl')
|
commandExists('chafa') && commandExists('curl')
|
||||||
? `
|
? `
|
||||||
id={1}
|
id={1}
|
||||||
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)}
|
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&ApiKey=${escapeShellSingle(session.accessToken)}
|
||||||
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
|
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
|
||||||
`.trim()
|
`.trim()
|
||||||
: 'echo "Install curl + chafa for image preview"';
|
: 'echo "Install curl + chafa for image preview"';
|
||||||
|
|||||||
@@ -1545,3 +1545,25 @@ test('Anki metadata rejects a credential-bearing media title before metadata arr
|
|||||||
const result = privateApi.formatMiscInfoPattern('stream?api_key=test-secret', 426);
|
const result = privateApi.formatMiscInfoPattern('stream?api_key=test-secret', 426);
|
||||||
assert.equal(result, '[SubMiner] Unknown media | Unknown media (00:07:06)');
|
assert.equal(result, '[SubMiner] Unknown media | Unknown media (00:07:06)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('AnkiIntegration.formatMiscInfoPattern treats ApiKey stream paths like legacy api_key ones', () => {
|
||||||
|
const integration = new AnkiIntegration(
|
||||||
|
{ metadata: { pattern: '[SubMiner] %f (%t)' } } as never,
|
||||||
|
{} as never,
|
||||||
|
{
|
||||||
|
currentSubText: '',
|
||||||
|
currentVideoPath: 'stream?static=true&ApiKey=secret-token&MediaSourceId=ms-1',
|
||||||
|
currentTimePos: 426,
|
||||||
|
currentSubStart: 426,
|
||||||
|
currentSubEnd: 428,
|
||||||
|
currentMediaTitle: '[Jellyfin/direct] Bocchi the Rock! - S01E02',
|
||||||
|
send: () => true,
|
||||||
|
} as unknown as never,
|
||||||
|
);
|
||||||
|
const privateApi = integration as unknown as {
|
||||||
|
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
|
||||||
|
};
|
||||||
|
const result = privateApi.formatMiscInfoPattern('audio_123.mp3', 426);
|
||||||
|
assert.equal(result, '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)');
|
||||||
|
assert.equal(result.includes('ApiKey='), false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ function extractFilenameFromMediaPath(rawPath: string): string {
|
|||||||
function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): boolean {
|
function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): boolean {
|
||||||
const loweredPath = rawPath.toLowerCase();
|
const loweredPath = rawPath.toLowerCase();
|
||||||
const loweredFilename = filename.toLowerCase();
|
const loweredFilename = filename.toLowerCase();
|
||||||
if (loweredPath.includes('api_key=')) {
|
if (loweredPath.includes('api_key=') || loweredPath.includes('apikey=')) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (loweredPath.startsWith('http://') || loweredPath.startsWith('https://')) {
|
if (loweredPath.startsWith('http://') || loweredPath.startsWith('https://')) {
|
||||||
|
|||||||
@@ -3174,6 +3174,7 @@ test('Jellyfin metadata cleanup requires both an API key and a stream marker', a
|
|||||||
{ filename: 'stream?api_key=secret', leaked: true },
|
{ filename: 'stream?api_key=secret', leaked: true },
|
||||||
{ filename: '/STREAM?API_KEY=secret', leaked: true },
|
{ filename: '/STREAM?API_KEY=secret', leaked: true },
|
||||||
{ filename: '/Videos/item?api_key=secret', leaked: true },
|
{ filename: '/Videos/item?api_key=secret', leaked: true },
|
||||||
|
{ filename: '/Videos/item?ApiKey=secret', leaked: true },
|
||||||
{ filename: 'MediaSourceId=item api key secret', leaked: true },
|
{ filename: 'MediaSourceId=item api key secret', leaked: true },
|
||||||
{ filename: 'An API Key Story', leaked: false },
|
{ filename: 'An API Key Story', leaked: false },
|
||||||
{ filename: 'api_key=ordinary-metadata', leaked: false },
|
{ filename: 'api_key=ordinary-metadata', leaked: false },
|
||||||
|
|||||||
@@ -376,6 +376,7 @@ function buildJellyfinStatsMediaPath(mediaPath: string, itemId: string): string
|
|||||||
|
|
||||||
const JELLYFIN_MEDIA_ALIAS_QUERY_KEYS = [
|
const JELLYFIN_MEDIA_ALIAS_QUERY_KEYS = [
|
||||||
'api_key',
|
'api_key',
|
||||||
|
'ApiKey',
|
||||||
'StartTimeTicks',
|
'StartTimeTicks',
|
||||||
'AudioStreamIndex',
|
'AudioStreamIndex',
|
||||||
'SubtitleStreamIndex',
|
'SubtitleStreamIndex',
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ function parseLegacyJellyfinStreamUrl(value: string | null): URL | null {
|
|||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!url.searchParams.has('api_key')) {
|
if (!url.searchParams.has('api_key') && !url.searchParams.has('ApiKey')) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return url;
|
return url;
|
||||||
@@ -130,13 +130,13 @@ function repairLeakedJellyfinAnimeTitles(db: DatabaseSync, currentTimestamp: str
|
|||||||
SELECT v.canonical_title
|
SELECT v.canonical_title
|
||||||
FROM imm_videos v
|
FROM imm_videos v
|
||||||
WHERE v.anime_id = a.anime_id
|
WHERE v.anime_id = a.anime_id
|
||||||
AND v.canonical_title NOT LIKE '%api_key=%'
|
AND v.canonical_title NOT LIKE '%api_key=%' AND v.canonical_title NOT LIKE '%ApiKey=%'
|
||||||
AND lower(v.canonical_title) NOT LIKE '%api key%'
|
AND lower(v.canonical_title) NOT LIKE '%api key%'
|
||||||
ORDER BY v.LAST_UPDATE_DATE DESC, v.video_id DESC
|
ORDER BY v.LAST_UPDATE_DATE DESC, v.video_id DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) AS linked_video_title
|
) AS linked_video_title
|
||||||
FROM imm_anime a
|
FROM imm_anime a
|
||||||
WHERE a.canonical_title LIKE '%api_key=%'
|
WHERE a.canonical_title LIKE '%api_key=%' OR a.canonical_title LIKE '%ApiKey=%'
|
||||||
OR lower(a.canonical_title) LIKE '%api key%'
|
OR lower(a.canonical_title) LIKE '%api key%'
|
||||||
OR lower(a.normalized_title_key) LIKE '%api key%'
|
OR lower(a.normalized_title_key) LIKE '%api key%'
|
||||||
`,
|
`,
|
||||||
@@ -244,11 +244,11 @@ function repairLeakedJellyfinVideoParseMetadata(
|
|||||||
LAST_UPDATE_DATE = ?
|
LAST_UPDATE_DATE = ?
|
||||||
WHERE source_type = 2
|
WHERE source_type = 2
|
||||||
AND (
|
AND (
|
||||||
parsed_basename LIKE '%api_key=%'
|
parsed_basename LIKE '%api_key=%' OR parsed_basename LIKE '%ApiKey=%'
|
||||||
OR lower(parsed_basename) LIKE '%api key%'
|
OR lower(parsed_basename) LIKE '%api key%'
|
||||||
OR parsed_title LIKE '%api_key=%'
|
OR parsed_title LIKE '%api_key=%' OR parsed_title LIKE '%ApiKey=%'
|
||||||
OR lower(parsed_title) LIKE '%api key%'
|
OR lower(parsed_title) LIKE '%api key%'
|
||||||
OR parse_metadata_json LIKE '%api_key=%'
|
OR parse_metadata_json LIKE '%api_key=%' OR parse_metadata_json LIKE '%ApiKey=%'
|
||||||
OR lower(parse_metadata_json) LIKE '%api key%'
|
OR lower(parse_metadata_json) LIKE '%api key%'
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
@@ -267,7 +267,7 @@ function repairLeakedJellyfinAnimeParseMetadata(
|
|||||||
UPDATE imm_anime
|
UPDATE imm_anime
|
||||||
SET metadata_json = NULL, LAST_UPDATE_DATE = ?
|
SET metadata_json = NULL, LAST_UPDATE_DATE = ?
|
||||||
WHERE (
|
WHERE (
|
||||||
metadata_json LIKE '%api_key=%'
|
metadata_json LIKE '%api_key=%' OR metadata_json LIKE '%ApiKey=%'
|
||||||
OR lower(metadata_json) LIKE '%api key%'
|
OR lower(metadata_json) LIKE '%api key%'
|
||||||
) AND (
|
) AND (
|
||||||
lower(metadata_json) LIKE '%stream?%'
|
lower(metadata_json) LIKE '%stream?%'
|
||||||
@@ -295,11 +295,11 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
|
|||||||
FROM imm_videos
|
FROM imm_videos
|
||||||
WHERE source_type = 2
|
WHERE source_type = 2
|
||||||
AND (
|
AND (
|
||||||
video_key LIKE '%api_key=%'
|
video_key LIKE '%api_key=%' OR video_key LIKE '%ApiKey=%'
|
||||||
OR lower(video_key) LIKE '%api key%'
|
OR lower(video_key) LIKE '%api key%'
|
||||||
OR source_url LIKE '%api_key=%'
|
OR source_url LIKE '%api_key=%' OR source_url LIKE '%ApiKey=%'
|
||||||
OR lower(source_url) LIKE '%api key%'
|
OR lower(source_url) LIKE '%api key%'
|
||||||
OR canonical_title LIKE '%api_key=%'
|
OR canonical_title LIKE '%api_key=%' OR canonical_title LIKE '%ApiKey=%'
|
||||||
OR lower(canonical_title) LIKE '%api key%'
|
OR lower(canonical_title) LIKE '%api key%'
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ import { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './je
|
|||||||
|
|
||||||
class FakeWebSocket {
|
class FakeWebSocket {
|
||||||
private listeners: Record<string, Array<(...args: unknown[]) => void>> = {};
|
private listeners: Record<string, Array<(...args: unknown[]) => void>> = {};
|
||||||
|
sent: string[] = [];
|
||||||
|
terminated = false;
|
||||||
|
|
||||||
|
send(data: string): void {
|
||||||
|
this.sent.push(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate(): void {
|
||||||
|
this.terminated = true;
|
||||||
|
this.emit('close');
|
||||||
|
}
|
||||||
|
|
||||||
on(event: string, listener: (...args: unknown[]) => void): this {
|
on(event: string, listener: (...args: unknown[]) => void): this {
|
||||||
if (!this.listeners[event]) {
|
if (!this.listeners[event]) {
|
||||||
@@ -58,7 +69,7 @@ test('start posts capabilities on socket connect', async () => {
|
|||||||
accessToken: 'token-1',
|
accessToken: 'token-1',
|
||||||
deviceId: 'device-1',
|
deviceId: 'device-1',
|
||||||
webSocketFactory: (url) => {
|
webSocketFactory: (url) => {
|
||||||
assert.equal(url, 'ws://jellyfin.local:8096/socket?api_key=token-1&deviceId=device-1');
|
assert.equal(url, 'ws://jellyfin.local:8096/socket?ApiKey=token-1&deviceId=device-1');
|
||||||
const socket = new FakeWebSocket();
|
const socket = new FakeWebSocket();
|
||||||
sockets.push(socket);
|
sockets.push(socket);
|
||||||
return socket as unknown as any;
|
return socket as unknown as any;
|
||||||
@@ -99,7 +110,8 @@ test('socket headers include jellyfin authorization metadata', () => {
|
|||||||
assert.equal(seenHeaders.length, 1);
|
assert.equal(seenHeaders.length, 1);
|
||||||
assert.ok(seenHeaders[0]!['Authorization']!.includes('Client="SubMiner"'));
|
assert.ok(seenHeaders[0]!['Authorization']!.includes('Client="SubMiner"'));
|
||||||
assert.ok(seenHeaders[0]!['Authorization']!.includes('DeviceId="device-auth"'));
|
assert.ok(seenHeaders[0]!['Authorization']!.includes('DeviceId="device-auth"'));
|
||||||
assert.ok(seenHeaders[0]!['X-Emby-Authorization']);
|
assert.equal('X-Emby-Authorization' in seenHeaders[0]!, false);
|
||||||
|
assert.equal('X-Emby-Token' in seenHeaders[0]!, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('dispatches inbound Play, Playstate, and GeneralCommand messages', () => {
|
test('dispatches inbound Play, Playstate, and GeneralCommand messages', () => {
|
||||||
@@ -355,3 +367,114 @@ test('advertiseNow validates server registration using Sessions endpoint', async
|
|||||||
assert.equal(ok, true);
|
assert.equal(ok, true);
|
||||||
assert.ok(calls.some((url) => url.endsWith('/Sessions')));
|
assert.ok(calls.some((url) => url.endsWith('/Sessions')));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('answers ForceKeepAlive with KeepAlive messages on the advertised cadence', () => {
|
||||||
|
const sockets: FakeWebSocket[] = [];
|
||||||
|
const timers: Array<{ handler: () => void; delay: number }> = [];
|
||||||
|
|
||||||
|
const service = new JellyfinRemoteSessionService({
|
||||||
|
serverUrl: 'http://jellyfin.local',
|
||||||
|
accessToken: 'token-ka',
|
||||||
|
deviceId: 'device-ka',
|
||||||
|
webSocketFactory: () => {
|
||||||
|
const socket = new FakeWebSocket();
|
||||||
|
sockets.push(socket);
|
||||||
|
return socket as unknown as any;
|
||||||
|
},
|
||||||
|
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
|
||||||
|
setTimer: ((handler: () => void, delay?: number) => {
|
||||||
|
timers.push({ handler, delay: Number(delay) });
|
||||||
|
return timers.length as unknown as ReturnType<typeof setTimeout>;
|
||||||
|
}) as typeof setTimeout,
|
||||||
|
clearTimer: (() => undefined) as typeof clearTimeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
service.start();
|
||||||
|
sockets[0]!.emit('open');
|
||||||
|
assert.deepEqual(sockets[0]!.sent, ['{"MessageType":"KeepAlive"}']);
|
||||||
|
assert.equal(timers[0]!.delay, 30_000);
|
||||||
|
|
||||||
|
sockets[0]!.emit('message', JSON.stringify({ MessageType: 'ForceKeepAlive', Data: 20 }));
|
||||||
|
assert.equal(sockets[0]!.sent.length, 2);
|
||||||
|
assert.equal(timers.at(-1)!.delay, 10_000);
|
||||||
|
|
||||||
|
timers.at(-1)!.handler();
|
||||||
|
assert.equal(sockets[0]!.sent.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reconnects when the server stops answering keep-alives', () => {
|
||||||
|
let now = 1_000_000;
|
||||||
|
const sockets: FakeWebSocket[] = [];
|
||||||
|
const timers: Array<() => void> = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
const service = new JellyfinRemoteSessionService({
|
||||||
|
serverUrl: 'http://jellyfin.local',
|
||||||
|
accessToken: 'token-lost',
|
||||||
|
deviceId: 'device-lost',
|
||||||
|
webSocketFactory: () => {
|
||||||
|
const socket = new FakeWebSocket();
|
||||||
|
sockets.push(socket);
|
||||||
|
return socket as unknown as any;
|
||||||
|
},
|
||||||
|
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
|
||||||
|
getNow: () => now,
|
||||||
|
logWarn: (message) => {
|
||||||
|
warnings.push(message);
|
||||||
|
},
|
||||||
|
reconnectBaseDelayMs: 100,
|
||||||
|
setTimer: ((handler: () => void) => {
|
||||||
|
timers.push(handler);
|
||||||
|
return timers.length as unknown as ReturnType<typeof setTimeout>;
|
||||||
|
}) as typeof setTimeout,
|
||||||
|
clearTimer: (() => undefined) as typeof clearTimeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
service.start();
|
||||||
|
sockets[0]!.emit('open');
|
||||||
|
|
||||||
|
// Two silent ticks are still within the 90s tolerance; the third marks the socket lost.
|
||||||
|
now += 30_000;
|
||||||
|
timers.shift()!();
|
||||||
|
now += 30_000;
|
||||||
|
timers.shift()!();
|
||||||
|
assert.equal(sockets[0]!.sent.length, 3);
|
||||||
|
assert.equal(sockets[0]!.terminated, false);
|
||||||
|
|
||||||
|
now += 30_000;
|
||||||
|
timers.shift()!();
|
||||||
|
assert.equal(sockets[0]!.terminated, true);
|
||||||
|
assert.equal(service.isConnected(), false);
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
|
||||||
|
timers.shift()!();
|
||||||
|
assert.equal(sockets.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warns once per failing timeline endpoint until it recovers', async () => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
let status = 400;
|
||||||
|
|
||||||
|
const service = new JellyfinRemoteSessionService({
|
||||||
|
serverUrl: 'http://jellyfin.local',
|
||||||
|
accessToken: 'token-warn',
|
||||||
|
deviceId: 'device-warn',
|
||||||
|
webSocketFactory: () => new FakeWebSocket() as unknown as any,
|
||||||
|
fetchImpl: (async () => new Response(null, { status })) as typeof fetch,
|
||||||
|
logWarn: (message) => {
|
||||||
|
warnings.push(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const state = { itemId: 'item-1', positionTicks: 10, playMethod: 'DirectPlay' };
|
||||||
|
|
||||||
|
assert.equal(await service.reportStopped(state), false);
|
||||||
|
assert.equal(await service.reportStopped(state), false);
|
||||||
|
assert.equal(warnings.length, 1);
|
||||||
|
assert.match(warnings[0]!, /Sessions\/Playing\/Stopped/);
|
||||||
|
|
||||||
|
status = 200;
|
||||||
|
assert.equal(await service.reportStopped(state), true);
|
||||||
|
status = 500;
|
||||||
|
assert.equal(await service.reportStopped(state), false);
|
||||||
|
assert.equal(warnings.length, 2);
|
||||||
|
});
|
||||||
|
|||||||
@@ -45,9 +45,22 @@ interface JellyfinRemoteSocket {
|
|||||||
on(event: 'close', listener: () => void): this;
|
on(event: 'close', listener: () => void): this;
|
||||||
on(event: 'error', listener: (error: Error) => void): this;
|
on(event: 'error', listener: (error: Error) => void): this;
|
||||||
on(event: 'message', listener: (data: unknown) => void): this;
|
on(event: 'message', listener: (data: unknown) => void): this;
|
||||||
|
send(data: string): void;
|
||||||
|
terminate?(): void;
|
||||||
close(): void;
|
close(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Jellyfin advertises its keep-alive timeout in the ForceKeepAlive message (60s by default),
|
||||||
|
// drops sockets that stay silent past it, and since 12.0 also detaches the session's remote
|
||||||
|
// controller when that happens. The drop never reaches the client as a close frame, so the
|
||||||
|
// client has to keep sending KeepAlive and treat missing replies as a dead connection.
|
||||||
|
const DEFAULT_KEEP_ALIVE_TIMEOUT_MS = 60_000;
|
||||||
|
const KEEP_ALIVE_LOST_FACTOR = 1.5;
|
||||||
|
|
||||||
|
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
|
||||||
|
(timer as unknown as { unref?: () => void }).unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
type JellyfinRemoteSocketHeaders = Record<string, string>;
|
type JellyfinRemoteSocketHeaders = Record<string, string>;
|
||||||
|
|
||||||
export interface JellyfinRemoteSessionServiceOptions {
|
export interface JellyfinRemoteSessionServiceOptions {
|
||||||
@@ -77,6 +90,9 @@ export interface JellyfinRemoteSessionServiceOptions {
|
|||||||
deviceName?: string;
|
deviceName?: string;
|
||||||
onConnected?: () => void;
|
onConnected?: () => void;
|
||||||
onDisconnected?: () => void;
|
onDisconnected?: () => void;
|
||||||
|
logWarn?: (message: string, details?: unknown) => void;
|
||||||
|
keepAliveTimeoutMs?: number;
|
||||||
|
getNow?: () => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeServerUrl(serverUrl: string): string {
|
function normalizeServerUrl(serverUrl: string): string {
|
||||||
@@ -196,6 +212,12 @@ export class JellyfinRemoteSessionService {
|
|||||||
private readonly authHeader: string;
|
private readonly authHeader: string;
|
||||||
private readonly onConnected?: () => void;
|
private readonly onConnected?: () => void;
|
||||||
private readonly onDisconnected?: () => void;
|
private readonly onDisconnected?: () => void;
|
||||||
|
private readonly logWarn?: (message: string, details?: unknown) => void;
|
||||||
|
private readonly now: () => number;
|
||||||
|
private keepAliveTimeoutMs: number;
|
||||||
|
private keepAliveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private lastInboundAtMs = 0;
|
||||||
|
private readonly failedRequestPaths = new Set<string>();
|
||||||
|
|
||||||
private readonly reconnectBaseDelayMs: number;
|
private readonly reconnectBaseDelayMs: number;
|
||||||
private readonly reconnectMaxDelayMs: number;
|
private readonly reconnectMaxDelayMs: number;
|
||||||
@@ -233,6 +255,12 @@ export class JellyfinRemoteSessionService {
|
|||||||
});
|
});
|
||||||
this.onConnected = options.onConnected;
|
this.onConnected = options.onConnected;
|
||||||
this.onDisconnected = options.onDisconnected;
|
this.onDisconnected = options.onDisconnected;
|
||||||
|
this.logWarn = options.logWarn;
|
||||||
|
this.now = options.getNow ?? Date.now;
|
||||||
|
this.keepAliveTimeoutMs = Math.max(
|
||||||
|
1000,
|
||||||
|
options.keepAliveTimeoutMs ?? DEFAULT_KEEP_ALIVE_TIMEOUT_MS,
|
||||||
|
);
|
||||||
this.reconnectBaseDelayMs = Math.max(100, options.reconnectBaseDelayMs ?? 500);
|
this.reconnectBaseDelayMs = Math.max(100, options.reconnectBaseDelayMs ?? 500);
|
||||||
this.reconnectMaxDelayMs = Math.max(
|
this.reconnectMaxDelayMs = Math.max(
|
||||||
this.reconnectBaseDelayMs,
|
this.reconnectBaseDelayMs,
|
||||||
@@ -250,6 +278,7 @@ export class JellyfinRemoteSessionService {
|
|||||||
public stop(): void {
|
public stop(): void {
|
||||||
this.running = false;
|
this.running = false;
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
|
this.stopKeepAlive();
|
||||||
if (this.reconnectTimer) {
|
if (this.reconnectTimer) {
|
||||||
this.clearTimer(this.reconnectTimer);
|
this.clearTimer(this.reconnectTimer);
|
||||||
this.reconnectTimer = null;
|
this.reconnectTimer = null;
|
||||||
@@ -298,12 +327,15 @@ export class JellyfinRemoteSessionService {
|
|||||||
if (this.socket !== socket || !this.running) return;
|
if (this.socket !== socket || !this.running) return;
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.reconnectAttempt = 0;
|
this.reconnectAttempt = 0;
|
||||||
|
this.lastInboundAtMs = this.now();
|
||||||
|
this.startKeepAlive(socket, this.keepAliveTimeoutMs);
|
||||||
this.onConnected?.();
|
this.onConnected?.();
|
||||||
void this.postCapabilities();
|
void this.postCapabilities();
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('message', (rawData) => {
|
socket.on('message', (rawData) => {
|
||||||
this.handleInboundMessage(rawData);
|
this.lastInboundAtMs = this.now();
|
||||||
|
this.handleInboundMessage(socket, rawData);
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDisconnect = () => {
|
const handleDisconnect = () => {
|
||||||
@@ -311,6 +343,7 @@ export class JellyfinRemoteSessionService {
|
|||||||
disconnected = true;
|
disconnected = true;
|
||||||
if (this.socket === socket) {
|
if (this.socket === socket) {
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
|
this.stopKeepAlive();
|
||||||
}
|
}
|
||||||
this.connected = false;
|
this.connected = false;
|
||||||
this.onDisconnected?.();
|
this.onDisconnected?.();
|
||||||
@@ -323,6 +356,51 @@ export class JellyfinRemoteSessionService {
|
|||||||
socket.on('error', handleDisconnect);
|
socket.on('error', handleDisconnect);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startKeepAlive(socket: JellyfinRemoteSocket, timeoutMs: number): void {
|
||||||
|
this.stopKeepAlive();
|
||||||
|
this.keepAliveTimeoutMs = timeoutMs;
|
||||||
|
this.sendKeepAlive(socket);
|
||||||
|
this.scheduleKeepAliveTick(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleKeepAliveTick(socket: JellyfinRemoteSocket): void {
|
||||||
|
const intervalMs = Math.max(1000, Math.floor(this.keepAliveTimeoutMs / 2));
|
||||||
|
const timer = this.setTimer(() => {
|
||||||
|
this.keepAliveTimer = null;
|
||||||
|
if (this.socket !== socket || !this.running) return;
|
||||||
|
const silentForMs = this.now() - this.lastInboundAtMs;
|
||||||
|
if (silentForMs >= this.keepAliveTimeoutMs * KEEP_ALIVE_LOST_FACTOR) {
|
||||||
|
this.logWarn?.('Jellyfin remote websocket stopped answering keep-alives; reconnecting.');
|
||||||
|
// Dropping the socket raises 'close', which schedules the reconnect.
|
||||||
|
if (socket.terminate) {
|
||||||
|
socket.terminate();
|
||||||
|
} else {
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sendKeepAlive(socket);
|
||||||
|
this.scheduleKeepAliveTick(socket);
|
||||||
|
}, intervalMs);
|
||||||
|
unrefTimer(timer);
|
||||||
|
this.keepAliveTimer = timer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopKeepAlive(): void {
|
||||||
|
if (this.keepAliveTimer) {
|
||||||
|
this.clearTimer(this.keepAliveTimer);
|
||||||
|
this.keepAliveTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendKeepAlive(socket: JellyfinRemoteSocket): void {
|
||||||
|
try {
|
||||||
|
socket.send(JSON.stringify({ MessageType: 'KeepAlive' }));
|
||||||
|
} catch (error) {
|
||||||
|
this.logWarn?.('Failed to send Jellyfin remote keep-alive.', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private scheduleReconnect(): void {
|
private scheduleReconnect(): void {
|
||||||
const delay = Math.min(
|
const delay = Math.min(
|
||||||
this.reconnectMaxDelayMs,
|
this.reconnectMaxDelayMs,
|
||||||
@@ -342,7 +420,7 @@ export class JellyfinRemoteSessionService {
|
|||||||
const baseUrl = new URL(`${this.serverUrl}/`);
|
const baseUrl = new URL(`${this.serverUrl}/`);
|
||||||
const socketUrl = new URL('/socket', baseUrl);
|
const socketUrl = new URL('/socket', baseUrl);
|
||||||
socketUrl.protocol = baseUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
socketUrl.protocol = baseUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
socketUrl.searchParams.set('api_key', this.accessToken);
|
socketUrl.searchParams.set('ApiKey', this.accessToken);
|
||||||
socketUrl.searchParams.set('deviceId', this.deviceId);
|
socketUrl.searchParams.set('deviceId', this.deviceId);
|
||||||
return socketUrl.toString();
|
return socketUrl.toString();
|
||||||
}
|
}
|
||||||
@@ -350,8 +428,6 @@ export class JellyfinRemoteSessionService {
|
|||||||
private createSocket(url: string): JellyfinRemoteSocket {
|
private createSocket(url: string): JellyfinRemoteSocket {
|
||||||
const headers: JellyfinRemoteSocketHeaders = {
|
const headers: JellyfinRemoteSocketHeaders = {
|
||||||
Authorization: this.authHeader,
|
Authorization: this.authHeader,
|
||||||
'X-Emby-Authorization': this.authHeader,
|
|
||||||
'X-Emby-Token': this.accessToken,
|
|
||||||
};
|
};
|
||||||
if (this.socketHeadersFactory) {
|
if (this.socketHeadersFactory) {
|
||||||
return this.socketHeadersFactory(url, headers);
|
return this.socketHeadersFactory(url, headers);
|
||||||
@@ -375,8 +451,6 @@ export class JellyfinRemoteSessionService {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: this.authHeader,
|
Authorization: this.authHeader,
|
||||||
'X-Emby-Authorization': this.authHeader,
|
|
||||||
'X-Emby-Token': this.accessToken,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!response.ok) return false;
|
if (!response.ok) return false;
|
||||||
@@ -398,21 +472,41 @@ export class JellyfinRemoteSessionService {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: this.authHeader,
|
Authorization: this.authHeader,
|
||||||
'X-Emby-Authorization': this.authHeader,
|
|
||||||
'X-Emby-Token': this.accessToken,
|
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
this.noteRequestOutcome(path, response.ok ? null : `HTTP ${response.status}`);
|
||||||
return response.ok;
|
return response.ok;
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
this.noteRequestOutcome(path, error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleInboundMessage(rawData: unknown): void {
|
// Warn once per path while it keeps failing so a rejected stop report is visible in the
|
||||||
|
// log without a warning per progress tick.
|
||||||
|
private noteRequestOutcome(path: string, failure: unknown): void {
|
||||||
|
if (failure === null) {
|
||||||
|
this.failedRequestPaths.delete(path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.failedRequestPaths.has(path)) return;
|
||||||
|
this.failedRequestPaths.add(path);
|
||||||
|
this.logWarn?.(`Jellyfin remote request failed: POST ${path}`, failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleInboundMessage(socket: JellyfinRemoteSocket, rawData: unknown): void {
|
||||||
const message = parseInboundMessage(rawData);
|
const message = parseInboundMessage(rawData);
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
const messageType = message.MessageType;
|
const messageType = message.MessageType;
|
||||||
|
if (messageType === 'ForceKeepAlive') {
|
||||||
|
const seconds = Number(message.Data);
|
||||||
|
const timeoutMs =
|
||||||
|
Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : this.keepAliveTimeoutMs;
|
||||||
|
this.startKeepAlive(socket, timeoutMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (messageType === 'KeepAlive') return;
|
||||||
const payload = parseMessageData(message.Data);
|
const payload = parseMessageData(message.Data);
|
||||||
if (messageType === 'Play') {
|
if (messageType === 'Play') {
|
||||||
this.onPlay?.(payload);
|
this.onPlay?.(payload);
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ test('resolvePlaybackPlan prefers transcode when directPlayPreferred is disabled
|
|||||||
assert.equal(plan.mode, 'transcode');
|
assert.equal(plan.mode, 'transcode');
|
||||||
const url = new URL(plan.url);
|
const url = new URL(plan.url);
|
||||||
assert.match(url.pathname, /\/Videos\/movie-2\/master\.m3u8$/);
|
assert.match(url.pathname, /\/Videos\/movie-2\/master\.m3u8$/);
|
||||||
assert.equal(url.searchParams.get('api_key'), 'token');
|
assert.equal(url.searchParams.get('ApiKey'), 'token');
|
||||||
assert.equal(url.searchParams.get('AudioStreamIndex'), '4');
|
assert.equal(url.searchParams.get('AudioStreamIndex'), '4');
|
||||||
assert.equal(url.searchParams.get('StartTimeTicks'), '10000000');
|
assert.equal(url.searchParams.get('StartTimeTicks'), '10000000');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -365,7 +365,7 @@ test('listSubtitleTracks returns all subtitle streams with delivery urls', async
|
|||||||
IsForced: true,
|
IsForced: true,
|
||||||
IsExternal: true,
|
IsExternal: true,
|
||||||
DeliveryMethod: 'External',
|
DeliveryMethod: 'External',
|
||||||
DeliveryUrl: '/Videos/movie-1/ms-1/Subtitles/3/Stream.srt',
|
DeliveryUrl: '/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?api_key=server-token',
|
||||||
IsExternalUrl: false,
|
IsExternalUrl: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -402,11 +402,11 @@ test('listSubtitleTracks returns all subtitle streams with delivery urls', async
|
|||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
tracks[0]!.deliveryUrl,
|
tracks[0]!.deliveryUrl,
|
||||||
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/2/Stream.srt?api_key=token',
|
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/2/Stream.srt?ApiKey=token',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
tracks[1]!.deliveryUrl,
|
tracks[1]!.deliveryUrl,
|
||||||
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?api_key=token',
|
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?ApiKey=token',
|
||||||
);
|
);
|
||||||
assert.equal(tracks[2]!.deliveryUrl, 'https://cdn.example.com/subs.srt');
|
assert.equal(tracks[2]!.deliveryUrl, 'https://cdn.example.com/subs.srt');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -505,7 +505,7 @@ test('resolvePlaybackPlan reuses server transcoding url and appends missing para
|
|||||||
const url = new URL(plan.url);
|
const url = new URL(plan.url);
|
||||||
assert.match(url.pathname, /\/Videos\/movie-4\/master\.m3u8$/);
|
assert.match(url.pathname, /\/Videos\/movie-4\/master\.m3u8$/);
|
||||||
assert.equal(url.searchParams.get('VideoCodec'), 'hevc');
|
assert.equal(url.searchParams.get('VideoCodec'), 'hevc');
|
||||||
assert.equal(url.searchParams.get('api_key'), 'token');
|
assert.equal(url.searchParams.get('ApiKey'), 'token');
|
||||||
assert.equal(url.searchParams.get('AudioStreamIndex'), '3');
|
assert.equal(url.searchParams.get('AudioStreamIndex'), '3');
|
||||||
assert.equal(url.searchParams.get('SubtitleStreamIndex'), '8');
|
assert.equal(url.searchParams.get('SubtitleStreamIndex'), '8');
|
||||||
assert.equal(url.searchParams.get('StartTimeTicks'), '50000000');
|
assert.equal(url.searchParams.get('StartTimeTicks'), '50000000');
|
||||||
@@ -626,7 +626,7 @@ test('listSubtitleTracks falls back from PlaybackInfo to item media sources', as
|
|||||||
assert.equal(tracks[0]!.index, 11);
|
assert.equal(tracks[0]!.index, 11);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
tracks[0]!.deliveryUrl,
|
tracks[0]!.deliveryUrl,
|
||||||
'http://jellyfin.local/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt?api_key=token',
|
'http://jellyfin.local/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt?ApiKey=token',
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
@@ -789,3 +789,67 @@ test('resolvePlaybackPlan surfaces no-source and no-stream fallback errors', asy
|
|||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('API requests authenticate with the MediaBrowser header only (no legacy X-Emby-Token)', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const seenHeaders: Headers[] = [];
|
||||||
|
globalThis.fetch = (async (_input, init) => {
|
||||||
|
seenHeaders.push(new Headers(init?.headers));
|
||||||
|
return new Response(JSON.stringify({ Items: [] }), { status: 200 });
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await listLibraries(
|
||||||
|
{ serverUrl: 'http://jellyfin.local', accessToken: 'token', userId: 'u1', username: 'kyle' },
|
||||||
|
clientInfo,
|
||||||
|
);
|
||||||
|
assert.equal(seenHeaders.length, 1);
|
||||||
|
const headers = seenHeaders[0]!;
|
||||||
|
const authorization = headers.get('authorization') ?? '';
|
||||||
|
assert.match(authorization, /^MediaBrowser /);
|
||||||
|
assert.match(authorization, /Token="token"/);
|
||||||
|
assert.match(authorization, /DeviceId="subminer-test"/);
|
||||||
|
assert.equal(headers.has('x-emby-token'), false);
|
||||||
|
assert.equal(headers.has('x-emby-authorization'), false);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolvePlaybackPlan replaces a legacy api_key on the server transcoding url with ApiKey', async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = (async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
Id: 'movie-legacy',
|
||||||
|
Name: 'Movie Legacy',
|
||||||
|
MediaSources: [
|
||||||
|
{
|
||||||
|
Id: 'ms-legacy',
|
||||||
|
Container: 'mkv',
|
||||||
|
SupportsDirectStream: false,
|
||||||
|
SupportsTranscoding: true,
|
||||||
|
TranscodingUrl: '/Videos/movie-legacy/master.m3u8?VideoCodec=hevc&api_key=server-token',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
)) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const plan = await resolvePlaybackPlan(
|
||||||
|
{ serverUrl: 'http://jellyfin.local', accessToken: 'token', userId: 'u1', username: 'kyle' },
|
||||||
|
clientInfo,
|
||||||
|
{ enabled: true, directPlayPreferred: true },
|
||||||
|
{ itemId: 'movie-legacy' },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(plan.mode, 'transcode');
|
||||||
|
const url = new URL(plan.url);
|
||||||
|
assert.equal(url.searchParams.get('ApiKey'), 'token');
|
||||||
|
assert.equal(url.searchParams.has('api_key'), false);
|
||||||
|
assert.equal(url.searchParams.get('VideoCodec'), 'hevc');
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -136,6 +136,16 @@ function getErrorMessage(error: unknown): string {
|
|||||||
return String(error || 'unknown error');
|
return String(error || 'unknown error');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Jellyfin reads query keys case-insensitively and older servers embed the token as
|
||||||
|
// `api_key` in the URLs they hand back, so drop every spelling before setting the one
|
||||||
|
// form Jellyfin 12 still accepts with legacy authorization disabled.
|
||||||
|
function setApiKeyParam(url: URL, accessToken: string): void {
|
||||||
|
for (const key of [...url.searchParams.keys()]) {
|
||||||
|
if (/^api_?key$/i.test(key)) url.searchParams.delete(key);
|
||||||
|
}
|
||||||
|
url.searchParams.set('ApiKey', accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveDeliveryUrl(
|
function resolveDeliveryUrl(
|
||||||
session: JellyfinAuthSession,
|
session: JellyfinAuthSession,
|
||||||
stream: JellyfinMediaStream,
|
stream: JellyfinMediaStream,
|
||||||
@@ -146,9 +156,7 @@ function resolveDeliveryUrl(
|
|||||||
if (deliveryUrl) {
|
if (deliveryUrl) {
|
||||||
if (stream.IsExternalUrl === true) return deliveryUrl;
|
if (stream.IsExternalUrl === true) return deliveryUrl;
|
||||||
const resolved = new URL(deliveryUrl, `${session.serverUrl}/`);
|
const resolved = new URL(deliveryUrl, `${session.serverUrl}/`);
|
||||||
if (!resolved.searchParams.has('api_key')) {
|
setApiKeyParam(resolved, session.accessToken);
|
||||||
resolved.searchParams.set('api_key', session.accessToken);
|
|
||||||
}
|
|
||||||
return resolved.toString();
|
return resolved.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,9 +179,7 @@ function resolveDeliveryUrl(
|
|||||||
`/Videos/${encodeURIComponent(itemId)}/${encodeURIComponent(mediaSourceId)}/Subtitles/${streamIndex}/Stream.${ext}`,
|
`/Videos/${encodeURIComponent(itemId)}/${encodeURIComponent(mediaSourceId)}/Subtitles/${streamIndex}/Stream.${ext}`,
|
||||||
`${session.serverUrl}/`,
|
`${session.serverUrl}/`,
|
||||||
);
|
);
|
||||||
if (!fallback.searchParams.has('api_key')) {
|
setApiKeyParam(fallback, session.accessToken);
|
||||||
fallback.searchParams.set('api_key', session.accessToken);
|
|
||||||
}
|
|
||||||
return fallback.toString();
|
return fallback.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +203,6 @@ async function jellyfinRequestJson<T>(
|
|||||||
const headers = new Headers(init.headers ?? {});
|
const headers = new Headers(init.headers ?? {});
|
||||||
headers.set('Content-Type', 'application/json');
|
headers.set('Content-Type', 'application/json');
|
||||||
headers.set('Authorization', createAuthorizationHeader(client, session.accessToken));
|
headers.set('Authorization', createAuthorizationHeader(client, session.accessToken));
|
||||||
headers.set('X-Emby-Token', session.accessToken);
|
|
||||||
|
|
||||||
const response = await fetch(`${session.serverUrl}${path}`, {
|
const response = await fetch(`${session.serverUrl}${path}`, {
|
||||||
...init,
|
...init,
|
||||||
@@ -221,7 +226,7 @@ function createDirectPlayUrl(
|
|||||||
): string {
|
): string {
|
||||||
const query = new URLSearchParams({
|
const query = new URLSearchParams({
|
||||||
static: 'true',
|
static: 'true',
|
||||||
api_key: session.accessToken,
|
ApiKey: session.accessToken,
|
||||||
MediaSourceId: ensureString(mediaSource.Id),
|
MediaSourceId: ensureString(mediaSource.Id),
|
||||||
});
|
});
|
||||||
if (mediaSource.LiveStreamId) {
|
if (mediaSource.LiveStreamId) {
|
||||||
@@ -245,9 +250,7 @@ function createTranscodeUrl(
|
|||||||
): string {
|
): string {
|
||||||
if (mediaSource.TranscodingUrl) {
|
if (mediaSource.TranscodingUrl) {
|
||||||
const url = new URL(`${session.serverUrl}${mediaSource.TranscodingUrl}`);
|
const url = new URL(`${session.serverUrl}${mediaSource.TranscodingUrl}`);
|
||||||
if (!url.searchParams.has('api_key')) {
|
setApiKeyParam(url, session.accessToken);
|
||||||
url.searchParams.set('api_key', session.accessToken);
|
|
||||||
}
|
|
||||||
if (!url.searchParams.has('AudioStreamIndex') && plan.audioStreamIndex !== null) {
|
if (!url.searchParams.has('AudioStreamIndex') && plan.audioStreamIndex !== null) {
|
||||||
url.searchParams.set('AudioStreamIndex', String(plan.audioStreamIndex));
|
url.searchParams.set('AudioStreamIndex', String(plan.audioStreamIndex));
|
||||||
}
|
}
|
||||||
@@ -261,7 +264,7 @@ function createTranscodeUrl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const query = new URLSearchParams({
|
const query = new URLSearchParams({
|
||||||
api_key: session.accessToken,
|
ApiKey: session.accessToken,
|
||||||
MediaSourceId: ensureString(mediaSource.Id),
|
MediaSourceId: ensureString(mediaSource.Id),
|
||||||
VideoCodec: ensureString(config.transcodeVideoCodec, 'h264'),
|
VideoCodec: ensureString(config.transcodeVideoCodec, 'h264'),
|
||||||
TranscodingContainer: 'ts',
|
TranscodingContainer: 'ts',
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export function composeJellyfinRemoteHandlers(
|
|||||||
getNow: options.getNow,
|
getNow: options.getNow,
|
||||||
ticksPerSecond: options.ticksPerSecond,
|
ticksPerSecond: options.ticksPerSecond,
|
||||||
logDebug: options.logDebug,
|
logDebug: options.logDebug,
|
||||||
|
logWarn: options.logWarn,
|
||||||
});
|
});
|
||||||
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler(
|
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler(
|
||||||
buildReportJellyfinRemoteProgressMainDepsHandler(),
|
buildReportJellyfinRemoteProgressMainDepsHandler(),
|
||||||
|
|||||||
@@ -75,5 +75,6 @@ export function createBuildReportJellyfinRemoteStoppedMainDepsHandler(
|
|||||||
getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined,
|
getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined,
|
||||||
ticksPerSecond: deps.ticksPerSecond,
|
ticksPerSecond: deps.ticksPerSecond,
|
||||||
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
|
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
|
||||||
|
...(deps.logWarn ? { logWarn: (message: string) => deps.logWarn?.(message) } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ export type JellyfinRemoteStoppedReporterDeps = {
|
|||||||
getNow?: () => number;
|
getNow?: () => number;
|
||||||
ticksPerSecond: number;
|
ticksPerSecond: number;
|
||||||
logDebug: (message: string, error: unknown) => void;
|
logDebug: (message: string, error: unknown) => void;
|
||||||
|
logWarn?: (message: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteStoppedReporterDeps) {
|
export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteStoppedReporterDeps) {
|
||||||
@@ -244,7 +245,7 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
deps.logDebug('Failed to report Jellyfin remote final progress', error);
|
deps.logDebug('Failed to report Jellyfin remote final progress', error);
|
||||||
}
|
}
|
||||||
await session.reportStopped({
|
const reported = await session.reportStopped({
|
||||||
itemId: playback.itemId,
|
itemId: playback.itemId,
|
||||||
mediaSourceId: playback.mediaSourceId,
|
mediaSourceId: playback.mediaSourceId,
|
||||||
positionTicks,
|
positionTicks,
|
||||||
@@ -254,6 +255,11 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
|||||||
subtitleStreamIndex: playback.subtitleStreamIndex,
|
subtitleStreamIndex: playback.subtitleStreamIndex,
|
||||||
eventName: 'stop',
|
eventName: 'stop',
|
||||||
});
|
});
|
||||||
|
if (reported === false) {
|
||||||
|
deps.logWarn?.(
|
||||||
|
`Jellyfin did not accept the playback stop report for item ${playback.itemId}; the server may keep showing it as playing.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
deps.logDebug('Failed to report Jellyfin remote stop', error);
|
deps.logDebug('Failed to report Jellyfin remote stop', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type JellyfinRemoteServiceOptions = {
|
|||||||
};
|
};
|
||||||
onConnected: () => void;
|
onConnected: () => void;
|
||||||
onDisconnected: () => void;
|
onDisconnected: () => void;
|
||||||
|
logWarn?: (message: string, details?: unknown) => void;
|
||||||
onPlay: (payload: JellyfinRemoteEventPayload) => void;
|
onPlay: (payload: JellyfinRemoteEventPayload) => void;
|
||||||
onPlaystate: (payload: JellyfinRemoteEventPayload) => void;
|
onPlaystate: (payload: JellyfinRemoteEventPayload) => void;
|
||||||
onGeneralCommand: (payload: JellyfinRemoteEventPayload) => void;
|
onGeneralCommand: (payload: JellyfinRemoteEventPayload) => void;
|
||||||
@@ -110,6 +111,7 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
|
|||||||
onDisconnected: () => {
|
onDisconnected: () => {
|
||||||
deps.logWarn('Jellyfin remote websocket disconnected; retrying.');
|
deps.logWarn('Jellyfin remote websocket disconnected; retrying.');
|
||||||
},
|
},
|
||||||
|
logWarn: (message, details) => deps.logWarn(message, details),
|
||||||
onPlay: (payload) => {
|
onPlay: (payload) => {
|
||||||
void deps.handlePlay(payload).catch((error) => {
|
void deps.handlePlay(payload).catch((error) => {
|
||||||
deps.logWarn('Failed handling Jellyfin remote Play event', error);
|
deps.logWarn('Failed handling Jellyfin remote Play event', error);
|
||||||
|
|||||||
Vendored
+1
-1
Submodule vendor/subminer-yomitan updated: 57516d3b7f...99d6bf853c
Reference in New Issue
Block a user