mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
fix(jellyfin): subtitle timing, resume progress, and overlay sync
- Add per-stream subtitle delay persistence and auto timeline-offset correction - Strip server-selected subtitle stream from mpv load URL; suppress plugin subtitle rearm and auto-start during app-managed preload - Fix resume position lost when mpv resets on stop; use last known position for final progress/stopped reports - Keep Play vs Resume distinct to avoid early seek race on normal play - Fix discovery resume when remote play sends StartPositionTicks=0 despite saved progress - Deduplicate show/hide overlay commands using recorded visibility state - Rewrite docs-site Jellyfin page around cast-to-device UX
This commit is contained in:
@@ -25,7 +25,9 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => {
|
||||
armQuitOnDisconnect: () => calls.push('arm'),
|
||||
schedule: (_callback, delayMs) => calls.push(`schedule:${delayMs}`),
|
||||
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
||||
preloadExternalSubtitles: () => calls.push('preload'),
|
||||
preloadExternalSubtitles: () => {
|
||||
calls.push('preload');
|
||||
},
|
||||
setActivePlayback: () => calls.push('active'),
|
||||
setLastProgressAtMs: () => calls.push('progress'),
|
||||
reportPlaying: () => calls.push('report'),
|
||||
|
||||
@@ -77,7 +77,9 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
scheduled.push({ delay: delayMs, callback });
|
||||
},
|
||||
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
||||
preloadExternalSubtitles: () => calls.push('preload'),
|
||||
preloadExternalSubtitles: () => {
|
||||
calls.push('preload');
|
||||
},
|
||||
setActivePlayback: (state) => activeStates.push(state as Record<string, unknown>),
|
||||
setLastProgressAtMs: (value) => calls.push(`progress:${value}`),
|
||||
reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>),
|
||||
@@ -94,12 +96,21 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
itemId: 'item-1',
|
||||
});
|
||||
|
||||
assert.deepEqual(commands.slice(0, 5), [
|
||||
assert.deepEqual(commands.slice(0, 8), [
|
||||
['set_property', 'sub-auto', 'no'],
|
||||
['loadfile', 'https://stream.example/video.m3u8', 'replace'],
|
||||
['set_property', 'force-media-title', 'Episode 1'],
|
||||
['set_property', 'sid', 'no'],
|
||||
['seek', 1.2, 'absolute+exact'],
|
||||
['set_property', 'secondary-sid', 'no'],
|
||||
['set_property', 'sub-visibility', 'no'],
|
||||
['set_property', 'secondary-sub-visibility', 'no'],
|
||||
['script-message', 'subminer-managed-subtitles-loading'],
|
||||
[
|
||||
'loadfile',
|
||||
'https://stream.example/video.m3u8',
|
||||
'replace',
|
||||
-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(
|
||||
@@ -108,11 +119,11 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
);
|
||||
|
||||
assert.ok(calls.includes('defaults'));
|
||||
assert.ok(calls.includes('visible-overlay'));
|
||||
assert.ok(
|
||||
calls.indexOf('visible-overlay') < calls.indexOf('preload'),
|
||||
'visible overlay should be shown before Jellyfin subtitles are selected',
|
||||
calls.indexOf('preload') < calls.indexOf('visible-overlay'),
|
||||
'visible overlay should be shown after Jellyfin subtitles are selected',
|
||||
);
|
||||
assert.ok(calls.includes('visible-overlay'));
|
||||
assert.ok(calls.includes('arm'));
|
||||
assert.ok(calls.includes('preload'));
|
||||
assert.ok(calls.includes('progress:0'));
|
||||
@@ -120,6 +131,7 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
|
||||
assert.equal(activeStates.length, 1);
|
||||
assert.equal(activeStates[0]?.playMethod, 'DirectPlay');
|
||||
assert.equal(activeStates[0]?.lastKnownPositionSeconds, 1.2);
|
||||
assert.equal(reportPayloads.length, 1);
|
||||
assert.equal(reportPayloads[0]?.eventName, 'start');
|
||||
assert.equal(reportPayloads[0]?.positionTicks, 12_000_000);
|
||||
@@ -137,6 +149,249 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('playback handler waits for Jellyfin subtitle preload before showing visible overlay', async () => {
|
||||
const calls: string[] = [];
|
||||
let resolvePreload!: () => void;
|
||||
const preloadComplete = new Promise<void>((resolve) => {
|
||||
resolvePreload = resolve;
|
||||
});
|
||||
const handler = createPlayJellyfinItemInMpvHandler({
|
||||
ensureMpvConnectedForPlayback: async () => true,
|
||||
getMpvClient: () => ({ connected: true, send: () => {} }),
|
||||
resolvePlaybackPlan: async () => ({
|
||||
url: 'https://stream.example/video.m3u8',
|
||||
mode: 'direct',
|
||||
title: 'Episode 1',
|
||||
itemTitle: 'Episode 1',
|
||||
seriesTitle: 'Show Title',
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: 1,
|
||||
subtitleStreamIndex: 2,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => calls.push('visible-overlay'),
|
||||
sendMpvCommand: () => {},
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => {},
|
||||
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
||||
preloadExternalSubtitles: async () => {
|
||||
calls.push('preload-start');
|
||||
await preloadComplete;
|
||||
calls.push('preload-done');
|
||||
},
|
||||
setActivePlayback: () => {},
|
||||
setLastProgressAtMs: () => {},
|
||||
reportPlaying: () => {},
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
const playback = handler({
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: {},
|
||||
itemId: 'item-1',
|
||||
});
|
||||
for (let i = 0; i < 5 && calls.length === 0; i += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0], 'preload-start');
|
||||
resolvePreload();
|
||||
await playback;
|
||||
|
||||
assert.deepEqual(calls, ['preload-start', 'preload-done', 'visible-overlay']);
|
||||
});
|
||||
|
||||
test('playback handler strips Jellyfin subtitle stream from mpv load URL', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const reports: Array<Record<string, unknown>> = [];
|
||||
const handler = createPlayJellyfinItemInMpvHandler({
|
||||
ensureMpvConnectedForPlayback: async () => true,
|
||||
getMpvClient: () => ({ connected: true, send: () => {} }),
|
||||
resolvePlaybackPlan: async () => ({
|
||||
url: 'https://jellyfin.local/Videos/ep-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1&AudioStreamIndex=3&SubtitleStreamIndex=4',
|
||||
mode: 'direct',
|
||||
title: 'Episode 1',
|
||||
itemTitle: 'Episode 1',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: 3,
|
||||
subtitleStreamIndex: 4,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => {},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => {},
|
||||
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
||||
preloadExternalSubtitles: () => {},
|
||||
setActivePlayback: () => {},
|
||||
setLastProgressAtMs: () => {},
|
||||
reportPlaying: (payload) => reports.push(payload),
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
await handler({
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: {},
|
||||
itemId: 'ep-1',
|
||||
});
|
||||
|
||||
const loadCommand = commands.find((command) => command[0] === 'loadfile');
|
||||
assert.ok(loadCommand);
|
||||
const url = new URL(String(loadCommand[1]));
|
||||
assert.equal(url.searchParams.get('AudioStreamIndex'), '3');
|
||||
assert.equal(url.searchParams.has('SubtitleStreamIndex'), false);
|
||||
assert.equal(reports[0]?.subtitleStreamIndex, 4);
|
||||
});
|
||||
|
||||
test('playback handler starts remote Play from beginning when requested despite saved plan progress', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const reportPayloads: Array<Record<string, unknown>> = [];
|
||||
const handler = createPlayJellyfinItemInMpvHandler({
|
||||
ensureMpvConnectedForPlayback: async () => true,
|
||||
getMpvClient: () => ({ connected: true, send: () => {} }),
|
||||
resolvePlaybackPlan: async () => ({
|
||||
url: 'https://stream.example/video.m3u8?api_key=token&StartTimeTicks=35000000',
|
||||
mode: 'transcode',
|
||||
title: 'Episode 2',
|
||||
itemTitle: 'Episode 2',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 35_000_000,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => {},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => {},
|
||||
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
||||
preloadExternalSubtitles: () => {},
|
||||
setActivePlayback: () => {},
|
||||
setLastProgressAtMs: () => {},
|
||||
reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>),
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
await handler({
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: {},
|
||||
itemId: 'item-2',
|
||||
startTimeTicksOverride: 0,
|
||||
fallbackToPlanStartTimeOnZeroOverride: false,
|
||||
});
|
||||
|
||||
const loadCommand = commands.find((command) => command[0] === 'loadfile');
|
||||
assert.ok(loadCommand);
|
||||
const loadedUrl = String(loadCommand[1] ?? '');
|
||||
const parsed = new URL(loadedUrl);
|
||||
assert.equal(parsed.searchParams.get('StartTimeTicks'), null);
|
||||
assert.equal(
|
||||
commands.some((command) => command[0] === 'seek'),
|
||||
false,
|
||||
);
|
||||
assert.equal(reportPayloads[0]?.positionTicks, 0);
|
||||
});
|
||||
|
||||
test('playback handler disables mpv subtitle selection before Jellyfin media loads', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const handler = createPlayJellyfinItemInMpvHandler({
|
||||
ensureMpvConnectedForPlayback: async () => true,
|
||||
getMpvClient: () => ({ connected: true, send: () => {} }),
|
||||
resolvePlaybackPlan: async () => ({
|
||||
url: 'https://stream.example/video.m3u8',
|
||||
mode: 'direct',
|
||||
title: 'Episode 1',
|
||||
itemTitle: 'Episode 1',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => {},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => {},
|
||||
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
||||
preloadExternalSubtitles: () => {},
|
||||
setActivePlayback: () => {},
|
||||
setLastProgressAtMs: () => {},
|
||||
reportPlaying: () => {},
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
await handler({
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: {},
|
||||
itemId: 'item-1',
|
||||
});
|
||||
|
||||
const loadIndex = commands.findIndex((command) => command[0] === 'loadfile');
|
||||
assert.ok(loadIndex > 0);
|
||||
assert.ok(
|
||||
commands.findIndex(
|
||||
(command, index) =>
|
||||
index < loadIndex &&
|
||||
command[0] === 'script-message' &&
|
||||
command[1] === 'subminer-managed-subtitles-loading',
|
||||
) >= 0,
|
||||
);
|
||||
assert.ok(
|
||||
commands.findIndex(
|
||||
(command, index) =>
|
||||
index < loadIndex &&
|
||||
command[0] === 'set_property' &&
|
||||
command[1] === 'sid' &&
|
||||
command[2] === 'no',
|
||||
) >= 0,
|
||||
);
|
||||
assert.ok(
|
||||
commands.findIndex(
|
||||
(command, index) =>
|
||||
index < loadIndex &&
|
||||
command[0] === 'set_property' &&
|
||||
command[1] === 'secondary-sid' &&
|
||||
command[2] === 'no',
|
||||
) >= 0,
|
||||
);
|
||||
assert.ok(
|
||||
commands.findIndex(
|
||||
(command, index) =>
|
||||
index < loadIndex &&
|
||||
command[0] === 'set_property' &&
|
||||
command[1] === 'sub-visibility' &&
|
||||
command[2] === 'no',
|
||||
) >= 0,
|
||||
);
|
||||
assert.ok(
|
||||
commands.findIndex(
|
||||
(command, index) =>
|
||||
index < loadIndex &&
|
||||
command[0] === 'set_property' &&
|
||||
command[1] === 'secondary-sub-visibility' &&
|
||||
command[2] === 'no',
|
||||
) >= 0,
|
||||
);
|
||||
assert.equal(
|
||||
commands[loadIndex]?.[4],
|
||||
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no',
|
||||
);
|
||||
});
|
||||
|
||||
test('playback handler publishes Jellyfin title before loading tokenized stream url', async () => {
|
||||
const timeline: string[] = [];
|
||||
const handler = createPlayJellyfinItemInMpvHandler({
|
||||
@@ -264,11 +519,67 @@ test('playback handler applies start override to stream url for remote resume',
|
||||
startTimeTicksOverride: 55_000_000,
|
||||
});
|
||||
|
||||
assert.equal(commands[1]?.[0], 'loadfile');
|
||||
const loadedUrl = String(commands[1]?.[1] ?? '');
|
||||
const loadCommand = commands.find((command) => command[0] === 'loadfile');
|
||||
assert.ok(loadCommand);
|
||||
const loadedUrl = String(loadCommand[1] ?? '');
|
||||
const parsed = new URL(loadedUrl);
|
||||
assert.equal(parsed.searchParams.get('StartTimeTicks'), '55000000');
|
||||
assert.deepEqual(commands[4], ['seek', 5.5, 'absolute+exact']);
|
||||
assert.equal(
|
||||
commands.some((command) => command[0] === 'seek'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('playback handler keeps Jellyfin resume ticks when remote start override is zero', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const reportPayloads: Array<Record<string, unknown>> = [];
|
||||
const handler = createPlayJellyfinItemInMpvHandler({
|
||||
ensureMpvConnectedForPlayback: async () => true,
|
||||
getMpvClient: () => ({ connected: true, send: () => {} }),
|
||||
resolvePlaybackPlan: async () => ({
|
||||
url: 'https://stream.example/video.m3u8?api_key=token&StartTimeTicks=35000000',
|
||||
mode: 'transcode',
|
||||
title: 'Episode 2',
|
||||
itemTitle: 'Episode 2',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 35_000_000,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => {},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => {},
|
||||
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
||||
preloadExternalSubtitles: () => {},
|
||||
setActivePlayback: () => {},
|
||||
setLastProgressAtMs: () => {},
|
||||
reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>),
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
await handler({
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: {},
|
||||
itemId: 'item-2',
|
||||
startTimeTicksOverride: 0,
|
||||
fallbackToPlanStartTimeOnZeroOverride: true,
|
||||
});
|
||||
|
||||
const loadCommand = commands.find((command) => command[0] === 'loadfile');
|
||||
assert.ok(loadCommand);
|
||||
const loadedUrl = String(loadCommand[1] ?? '');
|
||||
const parsed = new URL(loadedUrl);
|
||||
assert.equal(parsed.searchParams.get('StartTimeTicks'), '35000000');
|
||||
assert.equal(
|
||||
commands.some((command) => command[0] === 'seek'),
|
||||
false,
|
||||
);
|
||||
assert.equal(reportPayloads[0]?.positionTicks, 35_000_000);
|
||||
});
|
||||
|
||||
test('playback handler does not let stats metadata failures block playback startup', async () => {
|
||||
@@ -311,7 +622,16 @@ test('playback handler does not let stats metadata failures block playback start
|
||||
itemId: 'item-3',
|
||||
});
|
||||
|
||||
assert.deepEqual(commands[1], ['loadfile', 'https://stream.example/video.m3u8', 'replace']);
|
||||
assert.deepEqual(
|
||||
commands.find((command) => command[0] === 'loadfile'),
|
||||
[
|
||||
'loadfile',
|
||||
'https://stream.example/video.m3u8',
|
||||
'replace',
|
||||
-1,
|
||||
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('playback handler does not let media title failures block playback startup', async () => {
|
||||
@@ -354,7 +674,16 @@ test('playback handler does not let media title failures block playback startup'
|
||||
itemId: 'item-4',
|
||||
});
|
||||
|
||||
assert.deepEqual(commands[1], ['loadfile', 'https://stream.example/video.m3u8', 'replace']);
|
||||
assert.deepEqual(
|
||||
commands.find((command) => command[0] === 'loadfile'),
|
||||
[
|
||||
'loadfile',
|
||||
'https://stream.example/video.m3u8',
|
||||
'replace',
|
||||
-1,
|
||||
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('playback handler handles rejected best-effort hook promises', async () => {
|
||||
@@ -402,5 +731,14 @@ test('playback handler handles rejected best-effort hook promises', async () =>
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(commands[1], ['loadfile', 'https://stream.example/video.m3u8', 'replace']);
|
||||
assert.deepEqual(
|
||||
commands.find((command) => command[0] === 'loadfile'),
|
||||
[
|
||||
'loadfile',
|
||||
'https://stream.example/video.m3u8',
|
||||
'replace',
|
||||
-1,
|
||||
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ type ActivePlaybackState = {
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
loadedMediaPath?: string | null;
|
||||
stopReportsAfterMs?: number;
|
||||
lastKnownPositionSeconds?: number;
|
||||
};
|
||||
|
||||
export type JellyfinPlaybackStatsMetadata = {
|
||||
@@ -28,6 +29,14 @@ export type JellyfinPlaybackStatsMetadata = {
|
||||
itemId: string;
|
||||
};
|
||||
|
||||
const JELLYFIN_LOADFILE_SUBTITLE_SUPPRESSION_OPTIONS = [
|
||||
'sid=no',
|
||||
'secondary-sid=no',
|
||||
'sub-auto=no',
|
||||
'sub-visibility=no',
|
||||
'secondary-sub-visibility=no',
|
||||
];
|
||||
|
||||
function runBestEffortPlaybackHook(callback: () => void | Promise<void>): void {
|
||||
try {
|
||||
void Promise.resolve(callback()).catch(() => {});
|
||||
@@ -36,6 +45,14 @@ function runBestEffortPlaybackHook(callback: () => void | Promise<void>): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function awaitBestEffortPlaybackHook(callback: () => void | Promise<void>): Promise<void> {
|
||||
try {
|
||||
await Promise.resolve(callback());
|
||||
} catch {
|
||||
// Best-effort startup hooks must not block playback startup.
|
||||
}
|
||||
}
|
||||
|
||||
function applyStartTimeTicksToPlaybackUrl(url: string, startTimeTicksOverride?: number): string {
|
||||
if (typeof startTimeTicksOverride !== 'number') return url;
|
||||
try {
|
||||
@@ -51,6 +68,48 @@ function applyStartTimeTicksToPlaybackUrl(url: string, startTimeTicksOverride?:
|
||||
}
|
||||
}
|
||||
|
||||
function stripStartTimeTicksFromPlaybackUrl(url: string): string {
|
||||
try {
|
||||
const resolved = new URL(url);
|
||||
resolved.searchParams.delete('StartTimeTicks');
|
||||
return resolved.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function stripManagedSubtitleStreamFromPlaybackUrl(url: string): string {
|
||||
try {
|
||||
const resolved = new URL(url);
|
||||
resolved.searchParams.delete('SubtitleStreamIndex');
|
||||
return resolved.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEffectiveStartTimeTicks(
|
||||
planStartTimeTicks: number,
|
||||
startTimeTicksOverride?: number,
|
||||
fallbackToPlanStartTimeOnZeroOverride = false,
|
||||
) {
|
||||
if (typeof startTimeTicksOverride === 'number' && startTimeTicksOverride > 0) {
|
||||
return Math.max(0, startTimeTicksOverride);
|
||||
}
|
||||
if (typeof startTimeTicksOverride === 'number') {
|
||||
return fallbackToPlanStartTimeOnZeroOverride ? Math.max(0, planStartTimeTicks) : 0;
|
||||
}
|
||||
return Math.max(0, planStartTimeTicks);
|
||||
}
|
||||
|
||||
function buildJellyfinLoadfileOptions(plan: JellyfinPlaybackPlan, startSeconds: number): string {
|
||||
const options = [...JELLYFIN_LOADFILE_SUBTITLE_SUPPRESSION_OPTIONS];
|
||||
if (plan.mode === 'direct' && startSeconds > 0) {
|
||||
options.push(`start=${startSeconds}`);
|
||||
}
|
||||
return options.join(',');
|
||||
}
|
||||
|
||||
export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
ensureMpvConnectedForPlayback: () => Promise<boolean>;
|
||||
getMpvClient: () => MpvRuntimeClientLike | null;
|
||||
@@ -72,7 +131,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
session: JellyfinAuthSession;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
itemId: string;
|
||||
}) => void;
|
||||
}) => void | Promise<void>;
|
||||
setActivePlayback: (state: ActivePlaybackState) => void;
|
||||
setLastProgressAtMs: (value: number) => void;
|
||||
reportPlaying: (payload: {
|
||||
@@ -99,6 +158,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
startTimeTicksOverride?: number;
|
||||
fallbackToPlanStartTimeOnZeroOverride?: boolean;
|
||||
setQuitOnDisconnectArm?: boolean;
|
||||
}): Promise<void> => {
|
||||
const connected = await deps.ensureMpvConnectedForPlayback();
|
||||
@@ -120,7 +180,23 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
|
||||
deps.applyJellyfinMpvDefaults(mpvClient);
|
||||
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
|
||||
const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride);
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']);
|
||||
deps.sendMpvCommand(['set_property', 'sub-visibility', 'no']);
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sub-visibility', 'no']);
|
||||
const startTimeTicks = resolveEffectiveStartTimeTicks(
|
||||
plan.startTimeTicks,
|
||||
params.startTimeTicksOverride,
|
||||
params.fallbackToPlanStartTimeOnZeroOverride,
|
||||
);
|
||||
const startSeconds =
|
||||
startTimeTicks > 0 ? Math.max(0, deps.convertTicksToSeconds(startTimeTicks)) : 0;
|
||||
const playbackUrlBase =
|
||||
plan.mode === 'direct'
|
||||
? stripStartTimeTicksFromPlaybackUrl(plan.url)
|
||||
: applyStartTimeTicksToPlaybackUrl(plan.url, startTimeTicks);
|
||||
const playbackUrl = stripManagedSubtitleStreamFromPlaybackUrl(playbackUrlBase);
|
||||
const loadfileOptions = buildJellyfinLoadfileOptions(plan, startSeconds);
|
||||
const playMethod = plan.mode === 'direct' ? 'DirectPlay' : 'Transcode';
|
||||
runBestEffortPlaybackHook(() => deps.updateCurrentMediaTitle?.(plan.title));
|
||||
runBestEffortPlaybackHook(() =>
|
||||
@@ -141,29 +217,24 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
subtitleStreamIndex: plan.subtitleStreamIndex,
|
||||
playMethod,
|
||||
loadedMediaPath: null,
|
||||
lastKnownPositionSeconds: startSeconds > 0 ? startSeconds : undefined,
|
||||
});
|
||||
deps.setLastProgressAtMs(0);
|
||||
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']);
|
||||
deps.sendMpvCommand(['script-message', 'subminer-managed-subtitles-loading']);
|
||||
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace', -1, loadfileOptions]);
|
||||
if (params.setQuitOnDisconnectArm !== false) {
|
||||
deps.armQuitOnDisconnect();
|
||||
}
|
||||
deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]);
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
|
||||
const startTimeTicks =
|
||||
typeof params.startTimeTicksOverride === 'number'
|
||||
? Math.max(0, params.startTimeTicksOverride)
|
||||
: plan.startTimeTicks;
|
||||
if (startTimeTicks > 0) {
|
||||
deps.sendMpvCommand(['seek', deps.convertTicksToSeconds(startTimeTicks), 'absolute+exact']);
|
||||
}
|
||||
|
||||
await awaitBestEffortPlaybackHook(() =>
|
||||
deps.preloadExternalSubtitles({
|
||||
session: params.session,
|
||||
clientInfo: params.clientInfo,
|
||||
itemId: params.itemId,
|
||||
}),
|
||||
);
|
||||
deps.showVisibleOverlay();
|
||||
deps.preloadExternalSubtitles({
|
||||
session: params.session,
|
||||
clientInfo: params.clientInfo,
|
||||
itemId: params.itemId,
|
||||
});
|
||||
|
||||
deps.reportPlaying({
|
||||
itemId: params.itemId,
|
||||
|
||||
@@ -21,7 +21,13 @@ test('getConfiguredJellyfinSession returns null for incomplete config', () => {
|
||||
});
|
||||
|
||||
test('createHandleJellyfinRemotePlay forwards parsed payload to play runtime', async () => {
|
||||
const calls: Array<{ itemId: string; audio?: number; subtitle?: number; start?: number }> = [];
|
||||
const calls: Array<{
|
||||
itemId: string;
|
||||
audio?: number;
|
||||
subtitle?: number;
|
||||
start?: number;
|
||||
fallback?: boolean;
|
||||
}> = [];
|
||||
const handlePlay = createHandleJellyfinRemotePlay({
|
||||
getConfiguredSession: () => ({
|
||||
serverUrl: 'https://jellyfin.local',
|
||||
@@ -37,6 +43,7 @@ test('createHandleJellyfinRemotePlay forwards parsed payload to play runtime', a
|
||||
audio: params.audioStreamIndex,
|
||||
subtitle: params.subtitleStreamIndex,
|
||||
start: params.startTimeTicksOverride,
|
||||
fallback: params.fallbackToPlanStartTimeOnZeroOverride,
|
||||
});
|
||||
},
|
||||
logWarn: () => {},
|
||||
@@ -49,11 +56,13 @@ test('createHandleJellyfinRemotePlay forwards parsed payload to play runtime', a
|
||||
StartPositionTicks: 1000,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [{ itemId: 'item-1', audio: 3, subtitle: 7, start: 1000 }]);
|
||||
assert.deepEqual(calls, [
|
||||
{ itemId: 'item-1', audio: 3, subtitle: 7, start: 1000, fallback: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test('createHandleJellyfinRemotePlay parses string StartPositionTicks', async () => {
|
||||
const calls: Array<{ itemId: string; start?: number }> = [];
|
||||
const calls: Array<{ itemId: string; start?: number; fallback?: boolean }> = [];
|
||||
const handlePlay = createHandleJellyfinRemotePlay({
|
||||
getConfiguredSession: () => ({
|
||||
serverUrl: 'https://jellyfin.local',
|
||||
@@ -67,6 +76,7 @@ test('createHandleJellyfinRemotePlay parses string StartPositionTicks', async ()
|
||||
calls.push({
|
||||
itemId: params.itemId,
|
||||
start: params.startTimeTicksOverride,
|
||||
fallback: params.fallbackToPlanStartTimeOnZeroOverride,
|
||||
});
|
||||
},
|
||||
logWarn: () => {},
|
||||
@@ -77,7 +87,64 @@ test('createHandleJellyfinRemotePlay parses string StartPositionTicks', async ()
|
||||
StartPositionTicks: '12345',
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [{ itemId: 'item-2', start: 12345 }]);
|
||||
assert.deepEqual(calls, [{ itemId: 'item-2', start: 12345, fallback: true }]);
|
||||
});
|
||||
|
||||
test('createHandleJellyfinRemotePlay starts from beginning when StartPositionTicks is omitted', async () => {
|
||||
const calls: Array<{ itemId: string; start?: number; fallback?: boolean }> = [];
|
||||
const handlePlay = createHandleJellyfinRemotePlay({
|
||||
getConfiguredSession: () => ({
|
||||
serverUrl: 'https://jellyfin.local',
|
||||
accessToken: 'token',
|
||||
userId: 'user',
|
||||
username: 'name',
|
||||
}),
|
||||
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
|
||||
getJellyfinConfig: () => ({ enabled: true }),
|
||||
playJellyfinItem: async (params) => {
|
||||
calls.push({
|
||||
itemId: params.itemId,
|
||||
start: params.startTimeTicksOverride,
|
||||
fallback: params.fallbackToPlanStartTimeOnZeroOverride,
|
||||
});
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
await handlePlay({
|
||||
ItemIds: ['item-3'],
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [{ itemId: 'item-3', start: 0, fallback: false }]);
|
||||
});
|
||||
|
||||
test('createHandleJellyfinRemotePlay lets explicit zero fall back to Jellyfin item progress', async () => {
|
||||
const calls: Array<{ itemId: string; start?: number; fallback?: boolean }> = [];
|
||||
const handlePlay = createHandleJellyfinRemotePlay({
|
||||
getConfiguredSession: () => ({
|
||||
serverUrl: 'https://jellyfin.local',
|
||||
accessToken: 'token',
|
||||
userId: 'user',
|
||||
username: 'name',
|
||||
}),
|
||||
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
|
||||
getJellyfinConfig: () => ({ enabled: true }),
|
||||
playJellyfinItem: async (params) => {
|
||||
calls.push({
|
||||
itemId: params.itemId,
|
||||
start: params.startTimeTicksOverride,
|
||||
fallback: params.fallbackToPlanStartTimeOnZeroOverride,
|
||||
});
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
await handlePlay({
|
||||
ItemIds: ['item-4'],
|
||||
StartPositionTicks: 0,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [{ itemId: 'item-4', start: 0, fallback: true }]);
|
||||
});
|
||||
|
||||
test('createHandleJellyfinRemotePlay logs and skips payload without item id', async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ export type ActiveJellyfinRemotePlaybackState = {
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
loadedMediaPath?: string | null;
|
||||
stopReportsAfterMs?: number;
|
||||
lastKnownPositionSeconds?: number;
|
||||
};
|
||||
|
||||
type JellyfinSession = {
|
||||
@@ -62,6 +63,7 @@ export type JellyfinRemotePlayHandlerDeps = {
|
||||
audioStreamIndex?: number;
|
||||
subtitleStreamIndex?: number;
|
||||
startTimeTicksOverride?: number;
|
||||
fallbackToPlanStartTimeOnZeroOverride?: boolean;
|
||||
setQuitOnDisconnectArm?: boolean;
|
||||
}) => Promise<void>;
|
||||
logWarn: (message: string) => void;
|
||||
@@ -85,6 +87,10 @@ export function createHandleJellyfinRemotePlay(deps: JellyfinRemotePlayHandlerDe
|
||||
if (deps.getActivePlayback?.()?.itemId === itemId) {
|
||||
return;
|
||||
}
|
||||
const hasStartPositionTicks = Object.prototype.hasOwnProperty.call(data, 'StartPositionTicks');
|
||||
const startTimeTicksOverride = hasStartPositionTicks
|
||||
? (asInteger(data.StartPositionTicks) ?? 0)
|
||||
: 0;
|
||||
await deps.playJellyfinItem({
|
||||
session,
|
||||
clientInfo,
|
||||
@@ -92,7 +98,8 @@ export function createHandleJellyfinRemotePlay(deps: JellyfinRemotePlayHandlerDe
|
||||
itemId,
|
||||
audioStreamIndex: asInteger(data.AudioStreamIndex),
|
||||
subtitleStreamIndex: asInteger(data.SubtitleStreamIndex),
|
||||
startTimeTicksOverride: asInteger(data.StartPositionTicks),
|
||||
startTimeTicksOverride,
|
||||
fallbackToPlanStartTimeOnZeroOverride: hasStartPositionTicks,
|
||||
setQuitOnDisconnectArm: false,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createReportJellyfinRemoteProgressHandler,
|
||||
createReportJellyfinRemoteStoppedHandler,
|
||||
secondsToJellyfinTicks,
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
|
||||
} from './jellyfin-remote-playback';
|
||||
|
||||
test('secondsToJellyfinTicks converts seconds and clamps invalid values', () => {
|
||||
@@ -13,6 +14,39 @@ test('secondsToJellyfinTicks converts seconds and clamps invalid values', () =>
|
||||
assert.equal(secondsToJellyfinTicks(Number.NaN, 10_000_000), 0);
|
||||
});
|
||||
|
||||
test('shouldAutoLoadSecondarySubTrackForJellyfinPlayback suppresses generic secondary autoload for active Jellyfin media', () => {
|
||||
assert.equal(shouldAutoLoadSecondarySubTrackForJellyfinPlayback(null, '/tmp/local.mkv'), true);
|
||||
assert.equal(
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
||||
{ itemId: 'item-1', playMethod: 'DirectPlay', loadedMediaPath: null },
|
||||
'http://pve-main:8096/Videos/item/stream',
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
||||
{
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
loadedMediaPath: 'http://pve-main:8096/Videos/item/stream',
|
||||
},
|
||||
'http://pve-main:8096/Videos/item/stream',
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
||||
{
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
loadedMediaPath: 'http://pve-main:8096/Videos/item/stream',
|
||||
},
|
||||
'/tmp/local.mkv',
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteProgressHandler reports playback progress', async () => {
|
||||
let lastProgressAtMs = 0;
|
||||
const reportPayloads: Array<{ itemId: string; positionTicks: number; isPaused: boolean }> = [];
|
||||
@@ -303,6 +337,48 @@ test('createReportJellyfinRemoteStoppedHandler reports stop while remote websock
|
||||
assert.equal(cleared, true);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler uses cached position after mpv unload reset', async () => {
|
||||
let cleared = false;
|
||||
const calls: Array<{ event: string; positionTicks?: number }> = [];
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
||||
getActivePlayback: () =>
|
||||
({
|
||||
itemId: 'item-2',
|
||||
mediaSourceId: undefined,
|
||||
playMethod: 'DirectPlay',
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
loadedMediaPath: 'https://stream.example/video.m3u8',
|
||||
lastKnownPositionSeconds: 72.25,
|
||||
}) as never,
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () => ({
|
||||
isConnected: () => true,
|
||||
reportProgress: async (payload) => {
|
||||
calls.push({ event: 'progress', positionTicks: payload.positionTicks });
|
||||
},
|
||||
reportStopped: async (payload) => {
|
||||
calls.push({ event: 'stopped', positionTicks: payload.positionTicks });
|
||||
},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: 0,
|
||||
}),
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportStopped();
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
{ event: 'progress', positionTicks: 722_500_000 },
|
||||
{ event: 'stopped', positionTicks: 722_500_000 },
|
||||
]);
|
||||
assert.equal(cleared, true);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler ignores unloaded active playback', async () => {
|
||||
let cleared = false;
|
||||
let stopped = false;
|
||||
@@ -336,6 +412,42 @@ test('createReportJellyfinRemoteStoppedHandler ignores unloaded active playback'
|
||||
assert.equal(cleared, false);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteProgressHandler caches last nonzero mpv position', async () => {
|
||||
let position = 42;
|
||||
let lastProgressAtMs = 0;
|
||||
const playback = {
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay' as const,
|
||||
};
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
||||
getActivePlayback: () => playback,
|
||||
clearActivePlayback: () => {},
|
||||
getSession: () => ({
|
||||
isConnected: () => true,
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async () => {},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: position,
|
||||
requestProperty: async (name: string) => (name === 'pause' ? false : position),
|
||||
}),
|
||||
getNow: () => 5000,
|
||||
getLastProgressAtMs: () => lastProgressAtMs,
|
||||
setLastProgressAtMs: (value) => {
|
||||
lastProgressAtMs = value;
|
||||
},
|
||||
progressIntervalMs: 3000,
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportProgress(true);
|
||||
position = 0;
|
||||
await reportProgress(true);
|
||||
|
||||
assert.equal((playback as { lastKnownPositionSeconds?: number }).lastKnownPositionSeconds, 42);
|
||||
});
|
||||
|
||||
test('markJellyfinRemotePlaybackLoaded preserves the loaded marker on unload paths', () => {
|
||||
const playback = {
|
||||
itemId: 'item-2',
|
||||
|
||||
@@ -44,6 +44,21 @@ export function markJellyfinRemotePlaybackLoaded(
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
|
||||
playback: ActiveJellyfinRemotePlaybackState | null,
|
||||
path: string,
|
||||
): boolean {
|
||||
const normalizedPath = path.trim();
|
||||
if (!normalizedPath || !playback) {
|
||||
return true;
|
||||
}
|
||||
const loadedMediaPath = playback.loadedMediaPath?.trim() ?? '';
|
||||
if (!loadedMediaPath) {
|
||||
return false;
|
||||
}
|
||||
return loadedMediaPath !== normalizedPath;
|
||||
}
|
||||
|
||||
function isMpvPauseEnabled(value: unknown): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') return value !== 0;
|
||||
@@ -87,6 +102,29 @@ async function readMpvPositionSecondsOrFallback(
|
||||
}
|
||||
}
|
||||
|
||||
function cacheLastKnownPosition(
|
||||
playback: ActiveJellyfinRemotePlaybackState,
|
||||
positionSeconds: number,
|
||||
): void {
|
||||
if (!Number.isFinite(positionSeconds)) return;
|
||||
if (positionSeconds > 0 || playback.lastKnownPositionSeconds === undefined) {
|
||||
playback.lastKnownPositionSeconds = Math.max(0, positionSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveReportablePositionSeconds(
|
||||
playback: ActiveJellyfinRemotePlaybackState,
|
||||
positionSeconds: number,
|
||||
): number {
|
||||
const normalizedPosition = normalizeMpvPositionSeconds(positionSeconds);
|
||||
if (normalizedPosition > 0) return normalizedPosition;
|
||||
const cachedPosition = playback.lastKnownPositionSeconds;
|
||||
if (typeof cachedPosition === 'number' && Number.isFinite(cachedPosition) && cachedPosition > 0) {
|
||||
return cachedPosition;
|
||||
}
|
||||
return normalizedPosition;
|
||||
}
|
||||
|
||||
function isSeekLikePositionJump(
|
||||
previousPositionSeconds: number | null,
|
||||
nextPositionSeconds: number,
|
||||
@@ -123,7 +161,9 @@ export function createReportJellyfinRemoteProgressHandler(
|
||||
const now = deps.getNow();
|
||||
try {
|
||||
const mpvClient = deps.getMpvClient();
|
||||
const positionSeconds = await readMpvPositionSeconds(mpvClient);
|
||||
const observedPositionSeconds = await readMpvPositionSeconds(mpvClient);
|
||||
cacheLastKnownPosition(playback, observedPositionSeconds);
|
||||
const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds);
|
||||
const forceForSeekJump = isSeekLikePositionJump(
|
||||
lastReportedPositionSeconds,
|
||||
positionSeconds,
|
||||
@@ -184,11 +224,27 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const positionSeconds = await readMpvPositionSecondsOrFallback(deps.getMpvClient());
|
||||
const observedPositionSeconds = await readMpvPositionSecondsOrFallback(deps.getMpvClient());
|
||||
const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds);
|
||||
const positionTicks = secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond);
|
||||
try {
|
||||
await session.reportProgress({
|
||||
itemId: playback.itemId,
|
||||
mediaSourceId: playback.mediaSourceId,
|
||||
positionTicks,
|
||||
isPaused: false,
|
||||
playMethod: playback.playMethod,
|
||||
audioStreamIndex: playback.audioStreamIndex,
|
||||
subtitleStreamIndex: playback.subtitleStreamIndex,
|
||||
eventName: 'TimeUpdate',
|
||||
});
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to report Jellyfin remote final progress', error);
|
||||
}
|
||||
await session.reportStopped({
|
||||
itemId: playback.itemId,
|
||||
mediaSourceId: playback.mediaSourceId,
|
||||
positionTicks: secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond),
|
||||
positionTicks,
|
||||
failed: false,
|
||||
playMethod: playback.playMethod,
|
||||
audioStreamIndex: playback.audioStreamIndex,
|
||||
|
||||
@@ -19,6 +19,19 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
|
||||
return { path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' };
|
||||
},
|
||||
cleanupCachedSubtitles: () => calls.push('cleanup'),
|
||||
getSavedSubtitleDelay: (_itemId, streamIndex) => {
|
||||
calls.push(`load-delay:${streamIndex}`);
|
||||
return 1.25;
|
||||
},
|
||||
setActiveSubtitleDelayKey: (key) => calls.push(`active-delay:${key?.streamIndex ?? 'none'}`),
|
||||
loadSubtitleSourceText: async (source) => {
|
||||
calls.push(`load-source:${source}`);
|
||||
return 'subtitle';
|
||||
},
|
||||
saveSubtitleDelay: (_itemId, streamIndex, delaySeconds) => {
|
||||
calls.push(`save-delay:${streamIndex}:${delaySeconds}`);
|
||||
return true;
|
||||
},
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
})();
|
||||
|
||||
@@ -28,6 +41,21 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
|
||||
await deps.wait(1);
|
||||
await deps.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' });
|
||||
deps.cleanupCachedSubtitles(['/tmp/subs']);
|
||||
assert.equal(deps.getSavedSubtitleDelay?.('item', 3), 1.25);
|
||||
deps.setActiveSubtitleDelayKey?.({ itemId: 'item', streamIndex: 3 });
|
||||
assert.equal(await deps.loadSubtitleSourceText?.('/tmp/sub.srt'), 'subtitle');
|
||||
assert.equal(deps.saveSubtitleDelay?.('item', 3, -31.5), true);
|
||||
deps.logDebug('oops', null);
|
||||
assert.deepEqual(calls, ['list', 'send', 'wait', 'cache', 'cleanup', 'debug:oops']);
|
||||
assert.deepEqual(calls, [
|
||||
'list',
|
||||
'send',
|
||||
'wait',
|
||||
'cache',
|
||||
'cleanup',
|
||||
'load-delay:3',
|
||||
'active-delay:3',
|
||||
'load-source:/tmp/sub.srt',
|
||||
'save-delay:3:-31.5',
|
||||
'debug:oops',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,19 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler(
|
||||
wait: (ms: number) => deps.wait(ms),
|
||||
cacheSubtitleTrack: (track) => deps.cacheSubtitleTrack(track),
|
||||
cleanupCachedSubtitles: (dirs) => deps.cleanupCachedSubtitles(dirs),
|
||||
getSavedSubtitleDelay: deps.getSavedSubtitleDelay
|
||||
? (itemId, streamIndex) => deps.getSavedSubtitleDelay!(itemId, streamIndex)
|
||||
: undefined,
|
||||
setActiveSubtitleDelayKey: deps.setActiveSubtitleDelayKey
|
||||
? (key) => deps.setActiveSubtitleDelayKey!(key)
|
||||
: undefined,
|
||||
loadSubtitleSourceText: deps.loadSubtitleSourceText
|
||||
? (source) => deps.loadSubtitleSourceText!(source)
|
||||
: undefined,
|
||||
saveSubtitleDelay: deps.saveSubtitleDelay
|
||||
? (itemId, streamIndex, delaySeconds) =>
|
||||
deps.saveSubtitleDelay!(itemId, streamIndex, delaySeconds)
|
||||
: undefined,
|
||||
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,6 +32,14 @@ function makeDeps(overrides: {
|
||||
cleanupCachedSubtitles?: Parameters<
|
||||
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||
>[0]['cleanupCachedSubtitles'];
|
||||
getSavedSubtitleDelay?: Parameters<
|
||||
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||
>[0]['getSavedSubtitleDelay'];
|
||||
setActiveSubtitleDelayKey?: Parameters<
|
||||
typeof createPreloadJellyfinExternalSubtitlesHandler
|
||||
>[0]['setActiveSubtitleDelayKey'];
|
||||
loadSubtitleSourceText?: (source: string) => Promise<string>;
|
||||
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => void;
|
||||
logDebug?: Parameters<typeof createPreloadJellyfinExternalSubtitlesHandler>[0]['logDebug'];
|
||||
}) {
|
||||
return {
|
||||
@@ -46,10 +54,38 @@ function makeDeps(overrides: {
|
||||
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
|
||||
})),
|
||||
cleanupCachedSubtitles: overrides.cleanupCachedSubtitles ?? (() => {}),
|
||||
getSavedSubtitleDelay: overrides.getSavedSubtitleDelay,
|
||||
setActiveSubtitleDelayKey: overrides.setActiveSubtitleDelayKey,
|
||||
loadSubtitleSourceText: overrides.loadSubtitleSourceText,
|
||||
saveSubtitleDelay: overrides.saveSubtitleDelay,
|
||||
logDebug: overrides.logDebug ?? (() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function withoutTrackAutoSelectionCommands(
|
||||
commands: Array<Array<string | number>>,
|
||||
): Array<Array<string | number>> {
|
||||
return commands.filter(
|
||||
(command) =>
|
||||
!(
|
||||
command[0] === 'set_property' &&
|
||||
(command[1] === 'track-auto-selection' ||
|
||||
(command[1] === 'sid' && command[2] === 'no') ||
|
||||
(command[1] === 'secondary-sid' && command[2] === 'no') ||
|
||||
(command[1] === 'sub-visibility' && command[2] === 'no') ||
|
||||
(command[1] === 'secondary-sub-visibility' && command[2] === 'no'))
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function setPropertyCommandsExceptTrackAutoSelection(
|
||||
commands: Array<Array<string | number>>,
|
||||
): Array<Array<string | number>> {
|
||||
return withoutTrackAutoSelectionCommands(commands).filter(
|
||||
(command) => command[0] === 'set_property',
|
||||
);
|
||||
}
|
||||
|
||||
test('preload jellyfin subtitles caches external tracks locally and chooses japanese+english tracks', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
@@ -89,7 +125,7 @@ test('preload jellyfin subtitles caches external tracks locally and chooses japa
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
assert.deepEqual(withoutTrackAutoSelectionCommands(commands), [
|
||||
['sub-add', '/tmp/subminer-jellyfin-subtitles/0.srt', 'auto', 'Japanese', 'jpn'],
|
||||
['sub-add', '/tmp/subminer-jellyfin-subtitles/1.srt', 'auto', 'English SDH', 'eng'],
|
||||
['set_property', 'sid', 5],
|
||||
@@ -97,6 +133,59 @@ test('preload jellyfin subtitles caches external tracks locally and chooses japa
|
||||
]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles stages tracks without temporary subtitle selection', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
|
||||
{ index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' },
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async () => [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 5,
|
||||
lang: 'jpn',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
|
||||
},
|
||||
{
|
||||
type: 'sub',
|
||||
id: 6,
|
||||
lang: 'eng',
|
||||
title: 'English',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
|
||||
},
|
||||
],
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
assert.deepEqual(
|
||||
commands.filter((command) => command[0] === 'sub-add').map((command) => command[2]),
|
||||
['auto', 'auto'],
|
||||
);
|
||||
const firstFinalSelectionIndex = commands.findIndex(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 5,
|
||||
);
|
||||
assert.ok(firstFinalSelectionIndex >= 0);
|
||||
assert.equal(
|
||||
commands
|
||||
.slice(0, firstFinalSelectionIndex)
|
||||
.some(
|
||||
(command) =>
|
||||
command[0] === 'sub-add' && (command[2] === 'cached' || command[2] === 'select'),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles waits for delayed cached japanese track before selecting', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let requestCount = 0;
|
||||
@@ -140,13 +229,10 @@ test('preload jellyfin subtitles waits for delayed cached japanese track before
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
assert.equal(requestCount, 3);
|
||||
assert.deepEqual(
|
||||
commands.filter((command) => command[0] === 'set_property'),
|
||||
[
|
||||
['set_property', 'sid', 5],
|
||||
['set_property', 'secondary-sid', 6],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
|
||||
['set_property', 'sid', 5],
|
||||
['set_property', 'secondary-sid', 6],
|
||||
]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles waits for delayed external japanese track instead of embedded japanese', async () => {
|
||||
@@ -192,13 +278,286 @@ test('preload jellyfin subtitles waits for delayed external japanese track inste
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
assert.equal(requestCount, 3);
|
||||
assert.deepEqual(
|
||||
commands.filter((command) => command[0] === 'set_property'),
|
||||
[
|
||||
['set_property', 'sid', 42],
|
||||
['set_property', 'secondary-sid', 43],
|
||||
],
|
||||
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
|
||||
['set_property', 'sid', 42],
|
||||
['set_property', 'secondary-sid', 43],
|
||||
]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles prefers Jellyfin default and embedded japanese sources', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{
|
||||
index: 0,
|
||||
language: 'jpn',
|
||||
title: 'External Japanese',
|
||||
isExternal: true,
|
||||
deliveryUrl: 'https://sub/external.srt',
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
language: 'jpn',
|
||||
title: 'Embedded Japanese',
|
||||
isDefault: true,
|
||||
isExternal: false,
|
||||
deliveryUrl: 'https://sub/embedded.srt',
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
language: 'eng',
|
||||
title: 'English',
|
||||
deliveryUrl: 'https://sub/english.srt',
|
||||
},
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async () => [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 5,
|
||||
lang: 'jpn',
|
||||
title: 'External Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
|
||||
},
|
||||
{
|
||||
type: 'sub',
|
||||
id: 6,
|
||||
lang: 'jpn',
|
||||
title: 'Embedded Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
|
||||
},
|
||||
{
|
||||
type: 'sub',
|
||||
id: 7,
|
||||
lang: 'eng',
|
||||
title: 'English',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/2.srt',
|
||||
},
|
||||
],
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
|
||||
['set_property', 'sid', 6],
|
||||
['set_property', 'secondary-sid', 7],
|
||||
]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles applies saved delay for selected japanese stream', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const activeKeys: Array<{ itemId: string; streamIndex: number } | null> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 3, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async () => [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 11,
|
||||
lang: 'jpn',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/3.srt',
|
||||
},
|
||||
],
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
getSavedSubtitleDelay: (_itemId, streamIndex) => (streamIndex === 3 ? 1.25 : null),
|
||||
setActiveSubtitleDelayKey: (key) => activeKeys.push(key),
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-9' });
|
||||
|
||||
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
|
||||
['set_property', 'sub-delay', 1.25],
|
||||
['set_property', 'sid', 11],
|
||||
]);
|
||||
assert.deepEqual(activeKeys, [{ itemId: 'item-9', streamIndex: 3 }]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles applies saved delay before selecting japanese stream', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 3, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async () => [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 11,
|
||||
lang: 'jpn',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/3.srt',
|
||||
},
|
||||
],
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
getSavedSubtitleDelay: () => 1.25,
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-9' });
|
||||
|
||||
const delayIndex = commands.findIndex(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'sub-delay' && command[2] === 1.25,
|
||||
);
|
||||
const selectedSidIndex = commands.findIndex(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 11,
|
||||
);
|
||||
assert.ok(delayIndex >= 0);
|
||||
assert.ok(selectedSidIndex >= 0);
|
||||
assert.ok(delayIndex < selectedSidIndex);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles auto-aligns late japanese track from english reference', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const savedDelays: Array<{ itemId: string; streamIndex: number; delaySeconds: number }> = [];
|
||||
const primarySrt = `1
|
||||
00:00:34,935 --> 00:00:36,937
|
||||
Japanese 1
|
||||
|
||||
2
|
||||
00:00:36,937 --> 00:00:41,441
|
||||
Japanese 2
|
||||
|
||||
3
|
||||
00:00:41,441 --> 00:00:45,279
|
||||
Japanese 3
|
||||
|
||||
4
|
||||
00:00:45,279 --> 00:00:48,115
|
||||
Japanese 4
|
||||
|
||||
5
|
||||
00:00:48,115 --> 00:00:52,286
|
||||
Japanese 5
|
||||
|
||||
6
|
||||
00:00:52,286 --> 00:00:54,955
|
||||
Japanese 6
|
||||
|
||||
7
|
||||
00:00:54,955 --> 00:00:59,793
|
||||
Japanese 7
|
||||
|
||||
8
|
||||
00:00:59,793 --> 00:01:03,630
|
||||
Japanese 8
|
||||
|
||||
9
|
||||
00:01:03,630 --> 00:01:07,634
|
||||
Japanese 9
|
||||
|
||||
10
|
||||
00:01:07,634 --> 00:01:13,040
|
||||
Japanese 10
|
||||
|
||||
11
|
||||
00:01:16,643 --> 00:01:20,814
|
||||
Japanese 11
|
||||
|
||||
12
|
||||
00:01:20,814 --> 00:01:23,116
|
||||
Japanese 12
|
||||
|
||||
13
|
||||
00:01:27,988 --> 00:01:30,991
|
||||
Japanese 13
|
||||
|
||||
14
|
||||
00:01:30,991 --> 00:01:34,094
|
||||
Japanese 14
|
||||
|
||||
15
|
||||
00:01:34,094 --> 00:01:37,097
|
||||
Japanese 15
|
||||
|
||||
16
|
||||
00:01:37,097 --> 00:01:39,100
|
||||
Japanese 16
|
||||
`;
|
||||
const referenceAss = `[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:00:03.46,0:00:08.73,Default,,0,0,0,,English 1
|
||||
Dialogue: 0,0:00:09.48,0:00:13.61,Default,,0,0,0,,English 2
|
||||
Dialogue: 0,0:00:13.61,0:00:19.64,Default,,0,0,0,,English 3
|
||||
Dialogue: 0,0:00:21.40,0:00:27.32,Default,,0,0,0,,English 4
|
||||
Dialogue: 0,0:00:28.16,0:00:31.75,Default,,0,0,0,,English 5
|
||||
Dialogue: 0,0:00:32.06,0:00:34.52,Default,,0,0,0,,English 6
|
||||
Dialogue: 0,0:00:35.93,0:00:40.57,Default,,0,0,0,,English 7
|
||||
Dialogue: 0,0:00:45.10,0:00:51.01,Default,,0,0,0,,English 8
|
||||
Dialogue: 0,0:00:56.57,0:00:59.12,Default,,0,0,0,,English 9
|
||||
Dialogue: 0,0:00:59.68,0:01:02.44,Default,,0,0,0,,English 10
|
||||
Dialogue: 0,0:01:02.44,0:01:05.56,Default,,0,0,0,,English 11
|
||||
Dialogue: 0,0:01:05.56,0:01:06.87,Default,,0,0,0,,English 12
|
||||
`;
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
|
||||
{ index: 4, language: 'eng', title: 'English', deliveryUrl: 'https://sub/eng.ass' },
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async () => [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 10,
|
||||
lang: 'jpn',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
|
||||
},
|
||||
{
|
||||
type: 'sub',
|
||||
id: 12,
|
||||
lang: 'eng',
|
||||
title: 'English',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/4.ass',
|
||||
},
|
||||
],
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
cacheSubtitleTrack: async (track) => ({
|
||||
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.${track.index === 4 ? 'ass' : 'srt'}`,
|
||||
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
|
||||
}),
|
||||
getSavedSubtitleDelay: () => null,
|
||||
loadSubtitleSourceText: async (source) =>
|
||||
source.endsWith('.ass') ? referenceAss : primarySrt,
|
||||
saveSubtitleDelay: (itemId, streamIndex, delaySeconds) => {
|
||||
savedDelays.push({ itemId, streamIndex, delaySeconds });
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-9' });
|
||||
|
||||
const delayCommand = commands.find(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'sub-delay',
|
||||
);
|
||||
assert.ok(delayCommand);
|
||||
const delaySeconds = delayCommand[2];
|
||||
if (typeof delaySeconds !== 'number') {
|
||||
assert.fail('Expected numeric subtitle delay.');
|
||||
}
|
||||
assert.ok(delaySeconds > -32);
|
||||
assert.ok(delaySeconds < -31);
|
||||
assert.deepEqual(savedDelays, [{ itemId: 'item-9', streamIndex: 0, delaySeconds }]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles accepts numeric string mpv track ids', async () => {
|
||||
@@ -243,13 +602,10 @@ test('preload jellyfin subtitles accepts numeric string mpv track ids', async ()
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
assert.deepEqual(
|
||||
commands.filter((command) => command[0] === 'set_property'),
|
||||
[
|
||||
['set_property', 'sid', 10],
|
||||
['set_property', 'secondary-sid', 11],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
|
||||
['set_property', 'sid', 10],
|
||||
['set_property', 'secondary-sid', 11],
|
||||
]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles retries transient mpv track-list read failures', async () => {
|
||||
@@ -286,7 +642,7 @@ test('preload jellyfin subtitles retries transient mpv track-list read failures'
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
assert.equal(requestCount, 2);
|
||||
assert.deepEqual(commands.at(-1), ['set_property', 'sid', 10]);
|
||||
assert.deepEqual(withoutTrackAutoSelectionCommands(commands).at(-1), ['set_property', 'sid', 10]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles does not let later subtitle adds steal japanese primary selection', async () => {
|
||||
@@ -359,13 +715,71 @@ test('preload jellyfin subtitles does not let later subtitle adds steal japanese
|
||||
['sub-add', '/tmp/subminer-jellyfin-subtitles/12.srt', 'auto', 'Russian', 'rus'],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
commands.filter((command) => command[0] === 'set_property'),
|
||||
[['set_property', 'sid', 11]],
|
||||
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
|
||||
['set_property', 'sid', 11],
|
||||
]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles suppresses subtitle selection without disabling video auto selection', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 1, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
|
||||
{ index: 2, language: 'eng', title: 'English', deliveryUrl: 'https://sub/eng.srt' },
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async () => [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 11,
|
||||
lang: 'jpn',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
|
||||
},
|
||||
{
|
||||
type: 'sub',
|
||||
id: 12,
|
||||
lang: 'eng',
|
||||
title: 'English',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/2.srt',
|
||||
},
|
||||
],
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
const firstSubAddIndex = commands.findIndex((command) => command[0] === 'sub-add');
|
||||
const subtitleSuppressionIndex = commands.findIndex(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 'no',
|
||||
);
|
||||
const finalPrimarySidIndex = commands.findIndex(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 11,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
commands.some(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'track-auto-selection',
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.ok(subtitleSuppressionIndex >= 0);
|
||||
assert.ok(subtitleSuppressionIndex < firstSubAddIndex);
|
||||
assert.ok(firstSubAddIndex < finalPrimarySidIndex);
|
||||
assert.equal(
|
||||
commands.filter(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 11,
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles leaves current track alone when reported japanese track never appears', async () => {
|
||||
test('preload jellyfin subtitles does not select a missing japanese track', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const logs: string[] = [];
|
||||
let requestCount = 0;
|
||||
@@ -390,7 +804,8 @@ test('preload jellyfin subtitles leaves current track alone when reported japane
|
||||
assert.equal(requestCount, 10);
|
||||
assert.equal(
|
||||
commands.some(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 'no',
|
||||
(command) =>
|
||||
command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number',
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
|
||||
import { estimateSubtitleTimingOffset } from '../../core/services/subtitle-timing-offset';
|
||||
|
||||
type JellyfinSession = {
|
||||
serverUrl: string;
|
||||
accessToken: string;
|
||||
@@ -15,6 +18,11 @@ type JellyfinSubtitleTrack = {
|
||||
index: number;
|
||||
language?: string;
|
||||
title?: string;
|
||||
codec?: string;
|
||||
isDefault?: boolean;
|
||||
isForced?: boolean;
|
||||
isExternal?: boolean;
|
||||
deliveryMethod?: string;
|
||||
deliveryUrl?: string | null;
|
||||
};
|
||||
|
||||
@@ -27,6 +35,11 @@ type CachedExternalSubtitleTrack = CachedSubtitleTrack & {
|
||||
source: JellyfinSubtitleTrack;
|
||||
};
|
||||
|
||||
type JellyfinSubtitleDelayKey = {
|
||||
itemId: string;
|
||||
streamIndex: number;
|
||||
};
|
||||
|
||||
type MpvSubtitleTrack = {
|
||||
id: number;
|
||||
lang: string;
|
||||
@@ -130,6 +143,10 @@ function pickBestCachedTrackId(
|
||||
track,
|
||||
score:
|
||||
(track.external ? 100 : 0) +
|
||||
(cached?.source.isDefault ? 35 : 0) +
|
||||
(cached?.source.isExternal === false ? 25 : 0) +
|
||||
(cached?.source.isExternal === true ? -10 : 0) +
|
||||
(cached?.source.isForced ? -25 : 0) +
|
||||
(isLikelyHearingImpaired(title) ? -10 : 10) +
|
||||
(/\bdefault\b/i.test(title) ? 3 : 0),
|
||||
};
|
||||
@@ -138,6 +155,17 @@ function pickBestCachedTrackId(
|
||||
return ranked[0]?.track.id ?? null;
|
||||
}
|
||||
|
||||
function findCachedTrackForMpvTrackId(
|
||||
tracks: MpvSubtitleTrack[],
|
||||
cachedTracks: CachedExternalSubtitleTrack[],
|
||||
trackId: number | null,
|
||||
): CachedExternalSubtitleTrack | null {
|
||||
if (trackId === null) return null;
|
||||
const mpvTrack = tracks.find((track) => track.id === trackId);
|
||||
if (!mpvTrack?.externalFilename) return null;
|
||||
return cachedTracks.find((track) => track.path === mpvTrack.externalFilename) ?? null;
|
||||
}
|
||||
|
||||
function isJapaneseTrack(track: MpvSubtitleTrack): boolean {
|
||||
return isJapanese(track.lang) || isJapanese(track.title);
|
||||
}
|
||||
@@ -229,6 +257,54 @@ async function waitForPreferredSubtitleTracks(
|
||||
return subtitleTracks;
|
||||
}
|
||||
|
||||
async function estimateSubtitleDelayFromReference(
|
||||
deps: {
|
||||
loadSubtitleSourceText?: (source: string) => Promise<string>;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
},
|
||||
primaryTrack: CachedExternalSubtitleTrack | null,
|
||||
referenceTrack: CachedExternalSubtitleTrack | null,
|
||||
): Promise<number | null> {
|
||||
if (!deps.loadSubtitleSourceText || !primaryTrack || !referenceTrack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const [primaryContent, referenceContent] = await Promise.all([
|
||||
deps.loadSubtitleSourceText(primaryTrack.path),
|
||||
deps.loadSubtitleSourceText(referenceTrack.path),
|
||||
]);
|
||||
const primaryCues = parseSubtitleCues(primaryContent, primaryTrack.path);
|
||||
const referenceCues = parseSubtitleCues(referenceContent, referenceTrack.path);
|
||||
return estimateSubtitleTimingOffset(primaryCues, referenceCues)?.offsetSeconds ?? null;
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to auto-align Jellyfin subtitle timing', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveEstimatedSubtitleDelay(
|
||||
deps: {
|
||||
saveSubtitleDelay?: (
|
||||
itemId: string,
|
||||
streamIndex: number,
|
||||
delaySeconds: number,
|
||||
) => boolean | void;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
},
|
||||
key: JellyfinSubtitleDelayKey,
|
||||
delaySeconds: number,
|
||||
): void {
|
||||
try {
|
||||
const saved = deps.saveSubtitleDelay?.(key.itemId, key.streamIndex, delaySeconds);
|
||||
if (saved === false) {
|
||||
deps.logDebug('Failed to save Jellyfin auto subtitle delay', key);
|
||||
}
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to save Jellyfin auto subtitle delay', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
listJellyfinSubtitleTracks: (
|
||||
session: JellyfinSession,
|
||||
@@ -240,11 +316,21 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
wait: (ms: number) => Promise<void>;
|
||||
cacheSubtitleTrack: (track: JellyfinSubtitleTrack) => Promise<CachedSubtitleTrack>;
|
||||
cleanupCachedSubtitles: (dirs: string[]) => void;
|
||||
getSavedSubtitleDelay?: (itemId: string, streamIndex: number) => number | null;
|
||||
setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void;
|
||||
loadSubtitleSourceText?: (source: string) => Promise<string>;
|
||||
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
}): PreloadJellyfinExternalSubtitlesHandler {
|
||||
const activeCacheDirs = new Set<string>();
|
||||
let preloadQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
function resetManagedSubtitleDelay(): void {
|
||||
if (deps.getSavedSubtitleDelay) {
|
||||
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupActiveCache(): void {
|
||||
const dirs = [...activeCacheDirs];
|
||||
if (dirs.length === 0) return;
|
||||
@@ -275,6 +361,10 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
return;
|
||||
}
|
||||
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']);
|
||||
deps.sendMpvCommand(['set_property', 'sub-visibility', 'no']);
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sub-visibility', 'no']);
|
||||
await deps.wait(300);
|
||||
const seenUrls = new Set<string>();
|
||||
const cachedTracks: CachedExternalSubtitleTrack[] = [];
|
||||
@@ -310,18 +400,55 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedSubtitleTracks = subtitleTracks ?? [];
|
||||
const japanesePrimaryId =
|
||||
pickBestCachedTrackId(subtitleTracks ?? [], cachedTracks, isJapanese) ??
|
||||
pickBestTrackId(subtitleTracks ?? [], isJapanese);
|
||||
pickBestCachedTrackId(resolvedSubtitleTracks, cachedTracks, isJapanese) ??
|
||||
pickBestTrackId(resolvedSubtitleTracks, isJapanese);
|
||||
const englishSecondaryId =
|
||||
pickBestCachedTrackId(resolvedSubtitleTracks, cachedTracks, isEnglish, japanesePrimaryId) ??
|
||||
pickBestTrackId(resolvedSubtitleTracks, isEnglish, japanesePrimaryId);
|
||||
if (japanesePrimaryId !== null) {
|
||||
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
|
||||
const selectedCachedTrack = findCachedTrackForMpvTrackId(
|
||||
resolvedSubtitleTracks,
|
||||
cachedTracks,
|
||||
japanesePrimaryId,
|
||||
);
|
||||
if (selectedCachedTrack) {
|
||||
const delayKey = { itemId: params.itemId, streamIndex: selectedCachedTrack.source.index };
|
||||
deps.setActiveSubtitleDelayKey?.(delayKey);
|
||||
const savedDelay = deps.getSavedSubtitleDelay?.(delayKey.itemId, delayKey.streamIndex);
|
||||
if (typeof savedDelay === 'number' && Number.isFinite(savedDelay)) {
|
||||
deps.sendMpvCommand(['set_property', 'sub-delay', savedDelay]);
|
||||
} else {
|
||||
const referenceCachedTrack = findCachedTrackForMpvTrackId(
|
||||
resolvedSubtitleTracks,
|
||||
cachedTracks,
|
||||
englishSecondaryId,
|
||||
);
|
||||
const estimatedDelay = await estimateSubtitleDelayFromReference(
|
||||
deps,
|
||||
selectedCachedTrack,
|
||||
referenceCachedTrack,
|
||||
);
|
||||
if (estimatedDelay !== null) {
|
||||
deps.sendMpvCommand(['set_property', 'sub-delay', estimatedDelay]);
|
||||
saveEstimatedSubtitleDelay(deps, delayKey, estimatedDelay);
|
||||
} else {
|
||||
resetManagedSubtitleDelay();
|
||||
}
|
||||
}
|
||||
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
|
||||
} else {
|
||||
deps.setActiveSubtitleDelayKey?.(null);
|
||||
resetManagedSubtitleDelay();
|
||||
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
|
||||
}
|
||||
} else {
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
deps.setActiveSubtitleDelayKey?.(null);
|
||||
resetManagedSubtitleDelay();
|
||||
}
|
||||
|
||||
const englishSecondaryId =
|
||||
pickBestCachedTrackId(subtitleTracks ?? [], cachedTracks, isEnglish, japanesePrimaryId) ??
|
||||
pickBestTrackId(subtitleTracks ?? [], isEnglish, japanesePrimaryId);
|
||||
if (englishSecondaryId !== null) {
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export function createBuildMpvClientRuntimeServiceFactoryDepsHandler<
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
|
||||
shouldAutoLoadSecondarySubTrack?: (path: string) => boolean;
|
||||
shouldQuitOnMpvShutdown?: () => boolean;
|
||||
requestAppQuit?: () => void;
|
||||
bindEventHandlers: (client: TClient) => void;
|
||||
@@ -26,6 +27,9 @@ export function createBuildMpvClientRuntimeServiceFactoryDepsHandler<
|
||||
getReconnectTimer: () => deps.getReconnectTimer(),
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) =>
|
||||
deps.setReconnectTimer(timer),
|
||||
shouldAutoLoadSecondarySubTrack: deps.shouldAutoLoadSecondarySubTrack
|
||||
? (path: string) => deps.shouldAutoLoadSecondarySubTrack?.(path) ?? true
|
||||
: undefined,
|
||||
shouldQuitOnMpvShutdown: () => deps.shouldQuitOnMpvShutdown?.() ?? false,
|
||||
requestAppQuit: () => deps.requestAppQuit?.(),
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ export type MpvClientRuntimeServiceOptions = {
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
|
||||
shouldAutoLoadSecondarySubTrack?: (path: string) => boolean;
|
||||
shouldQuitOnMpvShutdown?: () => boolean;
|
||||
requestAppQuit?: () => void;
|
||||
};
|
||||
|
||||
@@ -14,10 +14,11 @@ test('apply jellyfin mpv defaults sends expected property commands', () => {
|
||||
|
||||
applyDefaults({ connected: true, send: () => {} });
|
||||
assert.deepEqual(calls, [
|
||||
'set_property:sub-auto:fuzzy',
|
||||
'set_property:sub-auto:no',
|
||||
'set_property:aid:auto',
|
||||
'set_property:sid:auto',
|
||||
'set_property:secondary-sid:auto',
|
||||
'set_property:sid:no',
|
||||
'set_property:secondary-sid:no',
|
||||
'set_property:sub-visibility:no',
|
||||
'set_property:secondary-sub-visibility:no',
|
||||
'set_property:alang:ja,jp',
|
||||
'set_property:slang:ja,jp',
|
||||
|
||||
@@ -6,10 +6,11 @@ export function createApplyJellyfinMpvDefaultsHandler(deps: {
|
||||
jellyfinLangPref: string;
|
||||
}) {
|
||||
return (client: MpvRuntimeClientLike): void => {
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'sub-auto', 'fuzzy']);
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'sub-auto', 'no']);
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'aid', 'auto']);
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'sid', 'auto']);
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'secondary-sid', 'auto']);
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'sid', 'no']);
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'secondary-sid', 'no']);
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'sub-visibility', 'no']);
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'secondary-sub-visibility', 'no']);
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'alang', deps.jellyfinLangPref]);
|
||||
deps.sendMpvCommandRuntime(client, ['set_property', 'slang', deps.jellyfinLangPref]);
|
||||
|
||||
@@ -10,6 +10,9 @@ export function createBuildSetVisibleOverlayVisibleMainDepsHandler(
|
||||
deps: SetVisibleOverlayVisibleMainDeps,
|
||||
) {
|
||||
return (): SetVisibleOverlayVisibleMainDeps => ({
|
||||
getVisibleOverlayVisible: deps.getVisibleOverlayVisible
|
||||
? () => deps.getVisibleOverlayVisible?.() ?? false
|
||||
: undefined,
|
||||
setVisibleOverlayVisibleCore: (options) => deps.setVisibleOverlayVisibleCore(options),
|
||||
setVisibleOverlayVisibleState: (visible: boolean) =>
|
||||
deps.setVisibleOverlayVisibleState(visible),
|
||||
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
test('set visible overlay handler forwards dependencies to core', () => {
|
||||
const calls: string[] = [];
|
||||
let warmupStarts = 0;
|
||||
let currentVisible = false;
|
||||
const setVisible = createSetVisibleOverlayVisibleHandler({
|
||||
getVisibleOverlayVisible: () => currentVisible,
|
||||
setVisibleOverlayVisibleCore: (options) => {
|
||||
calls.push(`core:${options.visible}`);
|
||||
currentVisible = options.visible;
|
||||
options.setVisibleOverlayVisibleState(options.visible);
|
||||
options.updateVisibleOverlayVisibility();
|
||||
},
|
||||
@@ -25,6 +28,10 @@ test('set visible overlay handler forwards dependencies to core', () => {
|
||||
assert.deepEqual(calls, ['core:true', 'state:true', 'update-visible']);
|
||||
assert.equal(warmupStarts, 1);
|
||||
|
||||
setVisible(true);
|
||||
assert.deepEqual(calls, ['core:true', 'state:true', 'update-visible']);
|
||||
assert.equal(warmupStarts, 1);
|
||||
|
||||
setVisible(false);
|
||||
assert.equal(warmupStarts, 1);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export function createSetVisibleOverlayVisibleHandler(deps: {
|
||||
getVisibleOverlayVisible?: () => boolean;
|
||||
setVisibleOverlayVisibleCore: (options: {
|
||||
visible: boolean;
|
||||
setVisibleOverlayVisibleState: (visible: boolean) => void;
|
||||
@@ -9,6 +10,9 @@ export function createSetVisibleOverlayVisibleHandler(deps: {
|
||||
onVisibleOverlayEnabled?: () => void;
|
||||
}) {
|
||||
return (visible: boolean): void => {
|
||||
if (deps.getVisibleOverlayVisible?.() === visible) {
|
||||
return;
|
||||
}
|
||||
if (visible) {
|
||||
deps.onVisibleOverlayEnabled?.();
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ test('overlay visibility runtime wires set/toggle handlers through composed deps
|
||||
|
||||
runtime.setVisibleOverlayVisible(true);
|
||||
assert.equal(visible, true);
|
||||
runtime.setVisibleOverlayVisible(true);
|
||||
assert.equal(setVisibleCoreCalls, 1);
|
||||
|
||||
runtime.toggleVisibleOverlay();
|
||||
assert.equal(visible, false);
|
||||
|
||||
@@ -22,9 +22,10 @@ export type OverlayVisibilityRuntimeDeps = {
|
||||
};
|
||||
|
||||
export function createOverlayVisibilityRuntime(deps: OverlayVisibilityRuntimeDeps) {
|
||||
const setVisibleOverlayVisibleMainDeps = createBuildSetVisibleOverlayVisibleMainDepsHandler(
|
||||
deps.setVisibleOverlayVisibleDeps,
|
||||
)();
|
||||
const setVisibleOverlayVisibleMainDeps = createBuildSetVisibleOverlayVisibleMainDepsHandler({
|
||||
...deps.setVisibleOverlayVisibleDeps,
|
||||
getVisibleOverlayVisible: deps.getVisibleOverlayVisible,
|
||||
})();
|
||||
const setVisibleOverlayVisible = createSetVisibleOverlayVisibleHandler(
|
||||
setVisibleOverlayVisibleMainDeps,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user