mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
fix(jellyfin): show overlay, inject plugin, and fix stats title on playback (#77)
* fix(jellyfin): show overlay, inject plugin, and fix stats title on playb - Show visible overlay automatically during Jellyfin playback so subtitleStyle applies - Inject bundled mpv plugin on auto-launch so keybindings work without overlay focus - Group Jellyfin playback stats under item metadata (jellyfin://host/item/id) instead of stream URLs so episodes merge with matching local titles - Mark ffsubsync unavailable in subsync modal for remote media paths - Drain queued second-instance commands even when onReady throws * fix(overlay): stabilize macOS focus handoff and sidebar Yomitan pause - Keep overlay visible during macOS foreground probe after overlay blur - Hold sidebar hover-pause while a Yomitan lookup popup remains open * fix(jellyfin): fix discovery loop, device identity, tray state, and Disc - Derive device identity from OS hostname; remove legacy configurable client/device fields - Prevent discovery playback from reloading active item, misreporting pause state, and duplicate overlay restores - Restart stale tray discovery sessions without re-login when server drops SubMiner cast target - Sync tray discovery checkbox state on Linux after CLI/startup/remote-session changes - Stop Discord presence falling back to stream URLs; prime title before tokenized stream loads - Fix picker library discovery when log level is above info - Fix config.example.jsonc trailing commas and array formatting * docs(release): trim and consolidate prerelease notes for 0.15.0 - Remove breaking changes section and several redundant bullet points - Consolidate per-platform updater notes into a single entry - Normalize em-dash separators to hyphens in section headers * fix(config): remove trailing commas from config.example.jsonc - Strip trailing commas throughout both config.example.jsonc copies - Reformat inline arrays to multi-line for JSON strictness - Update Jellyfin subtitle preload and playback launch tests and impl * fix(tokenizer): preserve known-word highlight when POS filters suppress - Known-word cache matches now set isKnown=true even for tokens excluded by POS filters - POS exclusion gate suppresses N+1, frequency, and JLPT only; known status is computed before the gate - Jellyfin subtitle preload continues after cleanup failures instead of aborting - Update config docs and option description to document the known-word bypass behavior * fix(jellyfin): send explicit hide/show overlay instead of toggle - Track overlay visibility in plugin state; y-t uses explicit hide/show commands when state is known - Prevent paused Jellyfin playback from resuming on overlay hide - Fix subtitle cache cleanup to only remove dirs after successful cleanup * fix(jellyfin): fix remote progress sync, seek reporting, and startup sto - arm active playback before loadfile with loadedMediaPath: null to suppress premature stop events - force immediate progress report on seek-like position jumps at the mpv time-pos level - send positionTicks and failed=false in reportStopped payload - remove EventName from HTTP timeline payloads (websocket-only field) - add startup grace window to drop stop events before media finishes loading * fix(jellyfin): fix overlay toggle sync, redirect reload, and AppImage bi - Sync visible-overlay state back to plugin via script messages to avoid toggle/hide drift - Collapse duplicate toggle events within 250ms to prevent hide-then-show on single keypress - Preserve manual hide across Jellyfin path-changing redirects even when media-title drops - Rearm managed subtitle defaults on path-changing redirects - Route toggleVisibleOverlay session binding through plugin toggle instead of app-side IPC - Show Linux/Hyprland overlay passively (showInactive) to avoid stealing mpv keyboard focus - Fix AppImage binary resolution to prefer $APPIMAGE env over mounted inner binary - Add stats window layer management so delete/update dialogs appear above stats window - Fix Jellyfin remote progress sync during Linux websocket reconnect windows * Fix CodeRabbit review feedback * 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 * test: update lifecycle cleanup assertion * fix: clear aborted playback state, fix overlay passthrough, and guard du - Reset app_managed_playback_pending on lifecycle cleanup to prevent state leak into next item - Record visible overlay action only after command succeeds, not before - Non-native passive overlay now always click-through on re-show (fix isNonNativePassiveOverlay ordering) - Defer activeParsedSubtitleMediaPath assignment until after prefetch completes - Move autoplay gate release into the hide branch of toggleVisibleOverlay - Clear active Jellyfin playback when stopping media that never loaded - Reset managed subtitle delay and delay key when no external tracks are available - Await async removeDir in subtitle cache cleanup - Guard duplicate delete clicks in MediaDetailView and SessionsTab with refs - Escape key in DeleteConfirmDialog now calls stopPropagation and stopImmediatePropagation
This commit is contained in:
@@ -75,7 +75,10 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.jellyfin.remoteControlEnabled, true);
|
||||
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
|
||||
assert.equal(config.jellyfin.autoAnnounce, false);
|
||||
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
|
||||
assert.equal('clientName' in config.jellyfin, false);
|
||||
assert.equal('remoteControlDeviceName' in config.jellyfin, false);
|
||||
assert.equal('deviceId' in config.jellyfin, false);
|
||||
assert.equal('clientVersion' in config.jellyfin, false);
|
||||
assert.equal(config.ai.enabled, false);
|
||||
assert.equal(config.ai.apiKeyCommand, '');
|
||||
assert.equal(config.texthooker.openBrowser, false);
|
||||
@@ -832,7 +835,7 @@ test('parses anilist.characterDictionary.collapsibleSections booleans and warns
|
||||
);
|
||||
});
|
||||
|
||||
test('parses jellyfin remote control fields', () => {
|
||||
test('parses jellyfin remote control fields and ignores legacy identity fields', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
@@ -843,6 +846,7 @@ test('parses jellyfin remote control fields', () => {
|
||||
"remoteControlEnabled": true,
|
||||
"remoteControlAutoConnect": true,
|
||||
"autoAnnounce": true,
|
||||
"clientName": "Custom Client",
|
||||
"remoteControlDeviceName": "SubMiner"
|
||||
}
|
||||
}`,
|
||||
@@ -857,7 +861,8 @@ test('parses jellyfin remote control fields', () => {
|
||||
assert.equal(config.jellyfin.remoteControlEnabled, true);
|
||||
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
|
||||
assert.equal(config.jellyfin.autoAnnounce, true);
|
||||
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
|
||||
assert.equal('clientName' in config.jellyfin, false);
|
||||
assert.equal('remoteControlDeviceName' in config.jellyfin, false);
|
||||
});
|
||||
|
||||
test('parses jellyfin.enabled and remoteControlEnabled disabled combinations', () => {
|
||||
@@ -2469,6 +2474,8 @@ test('template generator includes known keys', () => {
|
||||
assert.match(output, /"startupWarmups":/);
|
||||
assert.match(output, /"updates":/);
|
||||
assert.match(output, /"youtube":/);
|
||||
assert.doesNotMatch(output, /"deviceId":/);
|
||||
assert.doesNotMatch(output, /"clientVersion":/);
|
||||
assert.doesNotMatch(output, /"youtubeSubgen":/);
|
||||
assert.match(output, /"characterDictionary":\s*\{/);
|
||||
assert.match(output, /"preserveLineBreaks": false/);
|
||||
|
||||
@@ -130,14 +130,10 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
serverUrl: '',
|
||||
recentServers: [],
|
||||
username: '',
|
||||
deviceId: 'subminer',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '0.1.0',
|
||||
defaultLibraryId: '',
|
||||
remoteControlEnabled: true,
|
||||
remoteControlAutoConnect: true,
|
||||
autoAnnounce: false,
|
||||
remoteControlDeviceName: 'SubMiner',
|
||||
pullPictures: false,
|
||||
iconCacheDir: '/tmp/subminer-jellyfin-icons',
|
||||
directPlayPreferred: true,
|
||||
|
||||
@@ -265,7 +265,8 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
kind: 'enum',
|
||||
enumValues: ['headword', 'surface'],
|
||||
defaultValue: defaultConfig.ankiConnect.knownWords.matchMode,
|
||||
description: 'Known-word matching strategy for subtitle annotations.',
|
||||
description:
|
||||
'Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.knownWords.highlightEnabled',
|
||||
@@ -548,26 +549,6 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.jellyfin.username,
|
||||
description: 'Default Jellyfin username used during CLI login.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.deviceId',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jellyfin.deviceId,
|
||||
description:
|
||||
'Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.clientName',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jellyfin.clientName,
|
||||
description: 'Client name sent on the Jellyfin authentication handshake; primarily internal.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.clientVersion',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jellyfin.clientVersion,
|
||||
description:
|
||||
'Client version sent on the Jellyfin authentication handshake; primarily internal.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.defaultLibraryId',
|
||||
kind: 'string',
|
||||
@@ -593,12 +574,6 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description:
|
||||
'When enabled, automatically trigger remote announce/visibility check on websocket connect.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.remoteControlDeviceName',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.jellyfin.remoteControlDeviceName,
|
||||
description: 'Device name reported for Jellyfin remote control sessions.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.pullPictures',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -371,9 +371,6 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
||||
const stringKeys = [
|
||||
'serverUrl',
|
||||
'username',
|
||||
'deviceId',
|
||||
'clientName',
|
||||
'clientVersion',
|
||||
'defaultLibraryId',
|
||||
'iconCacheDir',
|
||||
'transcodeVideoCodec',
|
||||
|
||||
@@ -59,7 +59,6 @@ test('settings registry hides removed modal-only fields', () => {
|
||||
'shortcuts.multiCopyTimeoutMs',
|
||||
'anilist.characterDictionary.profileScope',
|
||||
'jellyfin.directPlayContainers',
|
||||
'jellyfin.remoteControlDeviceName',
|
||||
]) {
|
||||
assert.equal(
|
||||
fields.some((candidate) => candidate.configPath === path),
|
||||
@@ -246,10 +245,7 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
|
||||
'controller.preferredGamepadLabel',
|
||||
'controller.profiles',
|
||||
'youtubeSubgen.whisperBin',
|
||||
'jellyfin.clientVersion',
|
||||
'jellyfin.defaultLibraryId',
|
||||
'jellyfin.deviceId',
|
||||
'jellyfin.clientName',
|
||||
'subtitleSidebar.toggleKey',
|
||||
'jellyfin.recentServers',
|
||||
]) {
|
||||
|
||||
@@ -68,12 +68,8 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
|
||||
'anilist.characterDictionary.profileScope',
|
||||
'jellyfin.accessToken',
|
||||
'jellyfin.userId',
|
||||
'jellyfin.clientName',
|
||||
'jellyfin.clientVersion',
|
||||
'jellyfin.defaultLibraryId',
|
||||
'jellyfin.deviceId',
|
||||
'jellyfin.directPlayContainers',
|
||||
'jellyfin.remoteControlDeviceName',
|
||||
'controller.buttonIndices',
|
||||
'shortcuts.multiCopyTimeoutMs',
|
||||
'subtitleSidebar.toggleKey',
|
||||
|
||||
@@ -280,6 +280,44 @@ test('startAppLifecycle routes control socket commands through the second-instan
|
||||
assert.deepEqual(handled, ['ready', 'second-instance:start', 'control-close']);
|
||||
});
|
||||
|
||||
test('startAppLifecycle drains queued second-instance commands when app ready runtime fails', async () => {
|
||||
const handled: string[] = [];
|
||||
let controlArgvHandler: ((argv: string[]) => void) | null = null;
|
||||
let readyHandler: (() => Promise<void>) | null = null;
|
||||
|
||||
const { deps } = createDeps({
|
||||
shouldStartApp: () => true,
|
||||
parseArgs: (argv) => makeArgs({ start: argv.includes('--start') }),
|
||||
handleCliCommand: (args, source) => {
|
||||
handled.push(`${source}:${args.start ? 'start' : 'other'}`);
|
||||
},
|
||||
startControlServer: (handler) => {
|
||||
controlArgvHandler = handler;
|
||||
},
|
||||
whenReady: (handler) => {
|
||||
readyHandler = handler;
|
||||
},
|
||||
onReady: async () => {
|
||||
handled.push('ready');
|
||||
throw new Error('ready failed');
|
||||
},
|
||||
});
|
||||
|
||||
startAppLifecycle(makeArgs({ background: true }), deps);
|
||||
|
||||
assert.ok(controlArgvHandler);
|
||||
(controlArgvHandler as (argv: string[]) => void)(['--start']);
|
||||
assert.deepEqual(handled, []);
|
||||
|
||||
assert.ok(readyHandler);
|
||||
await assert.rejects((readyHandler as () => Promise<void>)(), /ready failed/);
|
||||
|
||||
assert.deepEqual(handled, ['ready', 'second-instance:start']);
|
||||
|
||||
(controlArgvHandler as (argv: string[]) => void)(['--start']);
|
||||
assert.deepEqual(handled, ['ready', 'second-instance:start', 'second-instance:start']);
|
||||
});
|
||||
|
||||
test('startAppLifecycle quits macOS config-only launch when all windows close', () => {
|
||||
let windowAllClosedHandler: (() => void) | null = null;
|
||||
const { deps, calls } = createDeps({
|
||||
|
||||
@@ -172,9 +172,12 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
||||
}
|
||||
|
||||
deps.whenReady(async () => {
|
||||
await deps.onReady();
|
||||
appReadyRuntimeComplete = true;
|
||||
flushPendingSecondInstanceCommands();
|
||||
try {
|
||||
await deps.onReady();
|
||||
} finally {
|
||||
appReadyRuntimeComplete = true;
|
||||
flushPendingSecondInstanceCommands();
|
||||
}
|
||||
});
|
||||
|
||||
deps.onWindowAllClosed(() => {
|
||||
|
||||
@@ -91,6 +91,22 @@ test('buildDiscordPresenceActivity shows media title regardless of style', () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('buildDiscordPresenceActivity never falls back to remote stream URLs', () => {
|
||||
const payload = buildDiscordPresenceActivity(baseConfig, {
|
||||
...baseSnapshot,
|
||||
mediaTitle: null,
|
||||
mediaPath:
|
||||
'http://jellyfin.local/Videos/item-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1',
|
||||
});
|
||||
|
||||
assert.equal(payload.details, 'Unknown media');
|
||||
assert.equal(payload.state, 'Playing 01:35 / 24:10');
|
||||
const serialized = JSON.stringify(payload);
|
||||
assert.equal(serialized.includes('api_key'), false);
|
||||
assert.equal(serialized.includes('secret-token'), false);
|
||||
assert.equal(serialized.includes('/Videos/item-1/stream'), false);
|
||||
});
|
||||
|
||||
test('service deduplicates identical updates and sends changed timeline', async () => {
|
||||
const sent: DiscordActivityPayload[] = [];
|
||||
const timers = new Map<number, () => void>();
|
||||
|
||||
@@ -106,6 +106,15 @@ function basename(filePath: string | null): string {
|
||||
return parts[parts.length - 1] ?? '';
|
||||
}
|
||||
|
||||
function fallbackTitleFromMediaPath(mediaPath: string | null): string {
|
||||
const trimmed = mediaPath?.trim();
|
||||
if (!trimmed) return '';
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) && !trimmed.toLowerCase().startsWith('file://')) {
|
||||
return '';
|
||||
}
|
||||
return basename(trimmed).split(/[?#]/)[0] ?? '';
|
||||
}
|
||||
|
||||
function buildStatus(snapshot: DiscordPresenceSnapshot): string {
|
||||
if (!snapshot.connected || !snapshot.mediaPath) return 'Idle';
|
||||
if (snapshot.paused) return 'Paused';
|
||||
@@ -130,7 +139,10 @@ export function buildDiscordPresenceActivity(
|
||||
): DiscordActivityPayload {
|
||||
const style = resolvePresenceStyle(config.presenceStyle);
|
||||
const status = buildStatus(snapshot);
|
||||
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
|
||||
const title = sanitizeText(
|
||||
snapshot.mediaTitle,
|
||||
fallbackTitleFromMediaPath(snapshot.mediaPath) || 'Unknown media',
|
||||
);
|
||||
const details =
|
||||
snapshot.connected && snapshot.mediaPath ? trimField(title) : style.fallbackDetails;
|
||||
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
|
||||
|
||||
@@ -1552,6 +1552,98 @@ test('handleMediaChange reuses the same provisional anime row across matching fi
|
||||
}
|
||||
});
|
||||
|
||||
test('Jellyfin playback metadata links stream videos to existing series title', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
|
||||
tracker.handleMediaChange('/tmp/The Beginning After the End S02E01.mkv', 'Episode 1');
|
||||
await waitForPendingAnimeMetadata(tracker);
|
||||
tracker.destroy();
|
||||
tracker = null;
|
||||
|
||||
tracker = new Ctor({ dbPath });
|
||||
tracker.recordJellyfinPlaybackMetadata({
|
||||
mediaPath:
|
||||
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1',
|
||||
displayTitle: 'The Beginning After the End S02E02 The Princess Begins Adventuring',
|
||||
itemTitle: 'The Princess Begins Adventuring',
|
||||
seriesTitle: 'The Beginning After the End',
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 2,
|
||||
itemId: 'item-2',
|
||||
});
|
||||
tracker.handleMediaChange(
|
||||
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1',
|
||||
'The Beginning After the End S02E02 The Princess Begins Adventuring',
|
||||
);
|
||||
tracker.handleMediaChange(null, null);
|
||||
tracker.recordJellyfinPlaybackMetadata({
|
||||
mediaPath:
|
||||
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1&StartTimeTicks=12000000',
|
||||
displayTitle: 'The Beginning After the End S02E02 The Princess Begins Adventuring',
|
||||
itemTitle: 'The Princess Begins Adventuring',
|
||||
seriesTitle: 'The Beginning After the End',
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 2,
|
||||
itemId: 'item-2',
|
||||
});
|
||||
tracker.handleMediaChange(
|
||||
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1&StartTimeTicks=12000000',
|
||||
'The Beginning After the End S02E02 The Princess Begins Adventuring',
|
||||
);
|
||||
|
||||
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||
const rows = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
v.source_url,
|
||||
v.canonical_title AS video_title,
|
||||
v.parsed_title,
|
||||
v.parsed_season,
|
||||
v.parsed_episode,
|
||||
v.parser_source,
|
||||
a.canonical_title AS anime_title
|
||||
FROM imm_videos v
|
||||
JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||
ORDER BY v.video_id
|
||||
`,
|
||||
)
|
||||
.all() as Array<{
|
||||
source_url: string | null;
|
||||
video_title: string;
|
||||
parsed_title: string | null;
|
||||
parsed_season: number | null;
|
||||
parsed_episode: number | null;
|
||||
parser_source: string | null;
|
||||
anime_title: string;
|
||||
}>;
|
||||
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(new Set(rows.map((row) => row.anime_title)).size, 1);
|
||||
const jellyfinRow = rows.find(
|
||||
(row) => row.source_url === 'jellyfin://jellyfin.local/item/item-2',
|
||||
);
|
||||
assert.ok(jellyfinRow);
|
||||
assert.equal(
|
||||
jellyfinRow.video_title,
|
||||
'The Beginning After the End S02E02 The Princess Begins Adventuring',
|
||||
);
|
||||
assert.equal(jellyfinRow.parsed_title, 'The Beginning After the End');
|
||||
assert.equal(jellyfinRow.parsed_season, 2);
|
||||
assert.equal(jellyfinRow.parsed_episode, 2);
|
||||
assert.equal(jellyfinRow.parser_source, 'jellyfin');
|
||||
assert.equal(jellyfinRow.anime_title, 'The Beginning After the End');
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('applies configurable queue, flush, and retention policy', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
@@ -301,6 +301,33 @@ export type {
|
||||
VocabularyStatsRow,
|
||||
} from './immersion-tracker/types';
|
||||
|
||||
export interface JellyfinPlaybackMetadataInput {
|
||||
mediaPath: string;
|
||||
displayTitle: string;
|
||||
itemTitle: string;
|
||||
seriesTitle: string | null;
|
||||
seasonNumber: number | null;
|
||||
episodeNumber: number | null;
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
function normalizeMetadataInt(value: number | null | undefined): number | null {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) ? value : null;
|
||||
}
|
||||
|
||||
function buildJellyfinStatsMediaPath(mediaPath: string, itemId: string): string {
|
||||
const normalizedItemId = normalizeText(itemId);
|
||||
if (!normalizedItemId) {
|
||||
return mediaPath;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(mediaPath);
|
||||
return `jellyfin://${parsed.host}/item/${encodeURIComponent(normalizedItemId)}`;
|
||||
} catch {
|
||||
return `jellyfin://item/${encodeURIComponent(normalizedItemId)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class ImmersionTrackerService {
|
||||
private readonly logger = createLogger('main:immersion-tracker');
|
||||
private readonly db: DatabaseSync;
|
||||
@@ -337,6 +364,7 @@ export class ImmersionTrackerService {
|
||||
private readonly pendingYoutubeMetadataFetches = new Map<number, Promise<void>>();
|
||||
private readonly recordedSubtitleKeys = new Set<string>();
|
||||
private readonly pendingAnimeMetadataUpdates = new Map<number, Promise<void>>();
|
||||
private readonly mediaPathAliases = new Map<string, string>();
|
||||
private readonly resolveLegacyVocabularyPos:
|
||||
| ((row: LegacyVocabularyPosRow) => Promise<LegacyVocabularyPosResolution | null>)
|
||||
| undefined;
|
||||
@@ -1115,8 +1143,85 @@ export class ImmersionTrackerService {
|
||||
rebuildLifetimeSummaryTables(this.db);
|
||||
}
|
||||
|
||||
recordJellyfinPlaybackMetadata(metadata: JellyfinPlaybackMetadataInput): void {
|
||||
const rawPath = normalizeMediaPath(metadata.mediaPath);
|
||||
if (!rawPath) {
|
||||
return;
|
||||
}
|
||||
const normalizedPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId);
|
||||
this.mediaPathAliases.set(rawPath, normalizedPath);
|
||||
|
||||
const displayTitle =
|
||||
normalizeText(metadata.displayTitle) ||
|
||||
normalizeText(metadata.itemTitle) ||
|
||||
deriveCanonicalTitle(normalizedPath);
|
||||
const itemTitle = normalizeText(metadata.itemTitle) || displayTitle;
|
||||
const seriesTitle = normalizeText(metadata.seriesTitle);
|
||||
const libraryTitle = seriesTitle || itemTitle;
|
||||
if (!libraryTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const videoId = getOrCreateVideoRecord(
|
||||
this.db,
|
||||
buildVideoKey(normalizedPath, SOURCE_TYPE_REMOTE),
|
||||
{
|
||||
canonicalTitle: displayTitle,
|
||||
sourcePath: null,
|
||||
sourceUrl: normalizedPath,
|
||||
sourceType: SOURCE_TYPE_REMOTE,
|
||||
},
|
||||
);
|
||||
const previousLink = this.db
|
||||
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = ?')
|
||||
.get(videoId) as { animeId: number | null } | null;
|
||||
const metadataJson = JSON.stringify({
|
||||
source: 'jellyfin',
|
||||
itemId: normalizeText(metadata.itemId) || null,
|
||||
itemTitle,
|
||||
seriesTitle: seriesTitle || null,
|
||||
displayTitle,
|
||||
seasonNumber: normalizeMetadataInt(metadata.seasonNumber),
|
||||
episodeNumber: normalizeMetadataInt(metadata.episodeNumber),
|
||||
});
|
||||
const animeId = getOrCreateAnimeRecord(this.db, {
|
||||
parsedTitle: libraryTitle,
|
||||
canonicalTitle: libraryTitle,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson,
|
||||
});
|
||||
linkVideoToAnimeRecord(this.db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: null,
|
||||
parsedTitle: libraryTitle,
|
||||
parsedSeason: normalizeMetadataInt(metadata.seasonNumber),
|
||||
parsedEpisode: normalizeMetadataInt(metadata.episodeNumber),
|
||||
parserSource: 'jellyfin',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: metadataJson,
|
||||
});
|
||||
|
||||
const hasLifetimeMedia = Boolean(
|
||||
this.db.prepare('SELECT 1 FROM imm_lifetime_media WHERE video_id = ?').get(videoId),
|
||||
);
|
||||
if (hasLifetimeMedia || (previousLink && previousLink.animeId !== animeId)) {
|
||||
rebuildLifetimeSummaryTables(this.db);
|
||||
}
|
||||
}
|
||||
|
||||
private hasJellyfinMetadata(videoId: number): boolean {
|
||||
const row = this.db
|
||||
.prepare('SELECT parser_source AS parserSource FROM imm_videos WHERE video_id = ?')
|
||||
.get(videoId) as { parserSource: string | null } | null;
|
||||
return row?.parserSource === 'jellyfin';
|
||||
}
|
||||
|
||||
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
|
||||
const normalizedPath = normalizeMediaPath(mediaPath);
|
||||
const rawPath = normalizeMediaPath(mediaPath);
|
||||
const normalizedPath = this.mediaPathAliases.get(rawPath) ?? rawPath;
|
||||
const normalizedTitle = normalizeText(mediaTitle);
|
||||
this.logger.info(
|
||||
`handleMediaChange called with path=${normalizedPath || '<empty>'} title=${normalizedTitle || '<empty>'}`,
|
||||
@@ -1164,7 +1269,7 @@ export class ImmersionTrackerService {
|
||||
if (youtubeVideoId) {
|
||||
void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId);
|
||||
this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath);
|
||||
} else {
|
||||
} else if (!this.hasJellyfinMetadata(sessionInfo.videoId)) {
|
||||
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
|
||||
}
|
||||
this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath);
|
||||
|
||||
@@ -116,6 +116,12 @@ export {
|
||||
resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime,
|
||||
ticksToSeconds as jellyfinTicksToSecondsRuntime,
|
||||
} from './jellyfin';
|
||||
export { loadJellyfinSubtitleDelay, saveJellyfinSubtitleDelay } from './jellyfin-subtitle-delay';
|
||||
export {
|
||||
estimateSubtitleTimingOffset,
|
||||
type SubtitleTimingOffsetOptions,
|
||||
type SubtitleTimingOffsetResult,
|
||||
} from './subtitle-timing-offset';
|
||||
export { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote';
|
||||
export {
|
||||
broadcastRuntimeOptionsChangedRuntime,
|
||||
|
||||
@@ -289,6 +289,44 @@ test('reportProgress posts timeline payload and treats failure as non-fatal', as
|
||||
assert.deepEqual(JSON.parse(String(timelineCall.init.body)), expectedPostedPayload);
|
||||
});
|
||||
|
||||
test('timeline payload omits websocket-only event names', () => {
|
||||
const payload = buildJellyfinTimelinePayload({
|
||||
itemId: 'movie-2',
|
||||
positionTicks: 123456,
|
||||
eventName: 'TimeUpdate',
|
||||
});
|
||||
|
||||
assert.equal('EventName' in payload, false);
|
||||
});
|
||||
|
||||
test('reportStopped posts final position and explicit non-failed state', async () => {
|
||||
const fetchCalls: Array<{ input: string; init: RequestInit }> = [];
|
||||
const service = new JellyfinRemoteSessionService({
|
||||
serverUrl: 'http://jellyfin.local',
|
||||
accessToken: 'token-stop-payload',
|
||||
deviceId: 'device-stop-payload',
|
||||
webSocketFactory: () => new FakeWebSocket() as unknown as any,
|
||||
fetchImpl: (async (input, init) => {
|
||||
fetchCalls.push({ input: String(input), init: init ?? {} });
|
||||
return new Response(null, { status: 200 });
|
||||
}) as typeof fetch,
|
||||
});
|
||||
|
||||
const ok = await service.reportStopped({
|
||||
itemId: 'movie-stop',
|
||||
positionTicks: 7654321,
|
||||
failed: false,
|
||||
});
|
||||
|
||||
const stoppedCall = fetchCalls.find((call) => call.input.endsWith('/Sessions/Playing/Stopped'));
|
||||
assert.equal(ok, true);
|
||||
assert.ok(stoppedCall);
|
||||
assert.ok(typeof stoppedCall.init.body === 'string');
|
||||
const posted = JSON.parse(String(stoppedCall.init.body));
|
||||
assert.equal(posted.PositionTicks, 7654321);
|
||||
assert.equal(posted.Failed, false);
|
||||
});
|
||||
|
||||
test('advertiseNow validates server registration using Sessions endpoint', async () => {
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface JellyfinTimelinePlaybackState {
|
||||
subtitleStreamIndex?: number | null;
|
||||
playlistItemId?: string | null;
|
||||
eventName?: string;
|
||||
failed?: boolean;
|
||||
}
|
||||
|
||||
export interface JellyfinTimelinePayload {
|
||||
@@ -36,7 +37,7 @@ export interface JellyfinTimelinePayload {
|
||||
AudioStreamIndex?: number | null;
|
||||
SubtitleStreamIndex?: number | null;
|
||||
PlaylistItemId?: string | null;
|
||||
EventName: string;
|
||||
Failed?: boolean;
|
||||
}
|
||||
|
||||
interface JellyfinRemoteSocket {
|
||||
@@ -168,7 +169,7 @@ export function buildJellyfinTimelinePayload(
|
||||
AudioStreamIndex: asNullableInteger(state.audioStreamIndex),
|
||||
SubtitleStreamIndex: asNullableInteger(state.subtitleStreamIndex),
|
||||
PlaylistItemId: state.playlistItemId,
|
||||
EventName: state.eventName || 'timeupdate',
|
||||
Failed: state.failed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -269,10 +270,7 @@ export class JellyfinRemoteSessionService {
|
||||
}
|
||||
|
||||
public async reportPlaying(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postTimeline('/Sessions/Playing', {
|
||||
...buildJellyfinTimelinePayload(state),
|
||||
EventName: state.eventName || 'start',
|
||||
});
|
||||
return this.postTimeline('/Sessions/Playing', buildJellyfinTimelinePayload(state));
|
||||
}
|
||||
|
||||
public async reportProgress(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
@@ -282,7 +280,7 @@ export class JellyfinRemoteSessionService {
|
||||
public async reportStopped(state: JellyfinTimelinePlaybackState): Promise<boolean> {
|
||||
return this.postTimeline('/Sessions/Playing/Stopped', {
|
||||
...buildJellyfinTimelinePayload(state),
|
||||
EventName: state.eventName || 'stop',
|
||||
Failed: state.failed === true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { loadJellyfinSubtitleDelay, saveJellyfinSubtitleDelay } from './jellyfin-subtitle-delay';
|
||||
|
||||
function statePath(name: string): string {
|
||||
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jellyfin-delay-')), name);
|
||||
}
|
||||
|
||||
test('jellyfin subtitle delay store saves and loads delay by item and stream', () => {
|
||||
const filePath = statePath('delays.json');
|
||||
|
||||
assert.equal(
|
||||
saveJellyfinSubtitleDelay({
|
||||
filePath,
|
||||
itemId: 'episode-1',
|
||||
streamIndex: 3,
|
||||
delaySeconds: 1.25,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), 1.25);
|
||||
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4 }), null);
|
||||
});
|
||||
|
||||
test('jellyfin subtitle delay store preserves other stream delays when updating one stream', () => {
|
||||
const filePath = statePath('delays.json');
|
||||
|
||||
saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3, delaySeconds: 1.25 });
|
||||
saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4, delaySeconds: -0.5 });
|
||||
saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3, delaySeconds: 2 });
|
||||
|
||||
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), 2);
|
||||
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4 }), -0.5);
|
||||
});
|
||||
|
||||
test('jellyfin subtitle delay store ignores invalid files and values', () => {
|
||||
const filePath = statePath('delays.json');
|
||||
fs.writeFileSync(filePath, '{');
|
||||
|
||||
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), null);
|
||||
assert.equal(
|
||||
saveJellyfinSubtitleDelay({
|
||||
filePath,
|
||||
itemId: 'episode-1',
|
||||
streamIndex: 3,
|
||||
delaySeconds: Number.NaN,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
type JellyfinSubtitleDelayStore = {
|
||||
version?: unknown;
|
||||
delays?: unknown;
|
||||
};
|
||||
|
||||
type JellyfinSubtitleDelayParams = {
|
||||
filePath: string;
|
||||
itemId: string;
|
||||
streamIndex: number;
|
||||
};
|
||||
|
||||
type SaveJellyfinSubtitleDelayParams = JellyfinSubtitleDelayParams & {
|
||||
delaySeconds: number;
|
||||
};
|
||||
|
||||
function storeKey(itemId: string, streamIndex: number): string {
|
||||
return JSON.stringify([itemId, streamIndex]);
|
||||
}
|
||||
|
||||
function readDelayMap(filePath: string): Record<string, number> {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as JellyfinSubtitleDelayStore;
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
!parsed.delays ||
|
||||
typeof parsed.delays !== 'object'
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
const delays: Record<string, number> = {};
|
||||
for (const [key, value] of Object.entries(parsed.delays as Record<string, unknown>)) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
delays[key] = value;
|
||||
}
|
||||
}
|
||||
return delays;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function loadJellyfinSubtitleDelay(params: JellyfinSubtitleDelayParams): number | null {
|
||||
const delay = readDelayMap(params.filePath)[storeKey(params.itemId, params.streamIndex)];
|
||||
return typeof delay === 'number' && Number.isFinite(delay) ? delay : null;
|
||||
}
|
||||
|
||||
export function saveJellyfinSubtitleDelay(params: SaveJellyfinSubtitleDelayParams): boolean {
|
||||
if (!Number.isFinite(params.delaySeconds)) return false;
|
||||
try {
|
||||
const delays = readDelayMap(params.filePath);
|
||||
delays[storeKey(params.itemId, params.streamIndex)] = params.delaySeconds;
|
||||
const dir = path.dirname(params.filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(params.filePath, JSON.stringify({ version: 1, delays }, null, 2));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -229,6 +229,7 @@ test('resolvePlaybackPlan chooses direct play when allowed', async () => {
|
||||
assert.equal(plan.mode, 'direct');
|
||||
assert.match(plan.url, /Videos\/movie-1\/stream\?/);
|
||||
assert.doesNotMatch(plan.url, /SubtitleStreamIndex=/);
|
||||
assert.equal(new URL(plan.url).searchParams.get('StartTimeTicks'), null);
|
||||
assert.equal(plan.subtitleStreamIndex, null);
|
||||
assert.equal(ticksToSeconds(plan.startTimeTicks), 2);
|
||||
} finally {
|
||||
@@ -560,13 +561,17 @@ test('resolvePlaybackPlan preserves episode metadata, stream selection, and resu
|
||||
|
||||
assert.equal(plan.mode, 'direct');
|
||||
assert.equal(plan.title, 'Galaxy Quest S02E07 A New Hope');
|
||||
assert.equal(plan.itemTitle, 'A New Hope');
|
||||
assert.equal(plan.seriesTitle, 'Galaxy Quest');
|
||||
assert.equal(plan.seasonNumber, 2);
|
||||
assert.equal(plan.episodeNumber, 7);
|
||||
assert.equal(plan.audioStreamIndex, 6);
|
||||
assert.equal(plan.subtitleStreamIndex, 9);
|
||||
assert.equal(plan.startTimeTicks, 35_000_000);
|
||||
const url = new URL(plan.url);
|
||||
assert.equal(url.searchParams.get('AudioStreamIndex'), '6');
|
||||
assert.equal(url.searchParams.get('SubtitleStreamIndex'), '9');
|
||||
assert.equal(url.searchParams.get('StartTimeTicks'), '35000000');
|
||||
assert.equal(url.searchParams.get('StartTimeTicks'), null);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ export interface JellyfinPlaybackPlan {
|
||||
mode: 'direct' | 'transcode';
|
||||
url: string;
|
||||
title: string;
|
||||
itemTitle: string;
|
||||
seriesTitle: string | null;
|
||||
seasonNumber: number | null;
|
||||
episodeNumber: number | null;
|
||||
startTimeTicks: number;
|
||||
audioStreamIndex: number | null;
|
||||
subtitleStreamIndex: number | null;
|
||||
@@ -229,9 +233,6 @@ function createDirectPlayUrl(
|
||||
if (plan.subtitleStreamIndex !== null) {
|
||||
query.set('SubtitleStreamIndex', String(plan.subtitleStreamIndex));
|
||||
}
|
||||
if (plan.startTimeTicks > 0) {
|
||||
query.set('StartTimeTicks', String(plan.startTimeTicks));
|
||||
}
|
||||
return `${session.serverUrl}/Videos/${itemId}/stream?${query.toString()}`;
|
||||
}
|
||||
|
||||
@@ -292,14 +293,24 @@ function getStreamDefaults(source: JellyfinMediaSource): {
|
||||
};
|
||||
}
|
||||
|
||||
function getItemTitle(item: JellyfinItem): string {
|
||||
return ensureString(item.Name).trim() || 'Jellyfin Item';
|
||||
}
|
||||
|
||||
function getSeriesTitle(item: JellyfinItem): string | null {
|
||||
return ensureString(item.SeriesName).trim() || null;
|
||||
}
|
||||
|
||||
function getDisplayTitle(item: JellyfinItem): string {
|
||||
const itemTitle = getItemTitle(item);
|
||||
if (item.Type === 'Episode') {
|
||||
const season = asIntegerOrNull(item.ParentIndexNumber) ?? 0;
|
||||
const episode = asIntegerOrNull(item.IndexNumber) ?? 0;
|
||||
const prefix = item.SeriesName ? `${item.SeriesName} ` : '';
|
||||
return `${prefix}S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')} ${ensureString(item.Name).trim()}`.trim();
|
||||
const seriesTitle = getSeriesTitle(item);
|
||||
const prefix = seriesTitle ? `${seriesTitle} ` : '';
|
||||
return `${prefix}S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')} ${itemTitle}`.trim();
|
||||
}
|
||||
return ensureString(item.Name).trim() || 'Jellyfin Item';
|
||||
return itemTitle;
|
||||
}
|
||||
|
||||
function shouldPreferDirectPlay(source: JellyfinMediaSource, config: JellyfinConfig): boolean {
|
||||
@@ -521,10 +532,16 @@ export async function resolvePlaybackPlan(
|
||||
const audioStreamIndex = selection.audioStreamIndex ?? defaults.audioStreamIndex ?? null;
|
||||
const subtitleStreamIndex = selection.subtitleStreamIndex ?? null;
|
||||
const startTimeTicks = Math.max(0, asIntegerOrNull(item.UserData?.PlaybackPositionTicks) ?? 0);
|
||||
const itemTitle = getItemTitle(item);
|
||||
const seriesTitle = item.Type === 'Episode' ? getSeriesTitle(item) : null;
|
||||
const basePlan: JellyfinPlaybackPlan = {
|
||||
mode: 'transcode',
|
||||
url: '',
|
||||
title: getDisplayTitle(item),
|
||||
itemTitle,
|
||||
seriesTitle,
|
||||
seasonNumber: item.Type === 'Episode' ? asIntegerOrNull(item.ParentIndexNumber) : null,
|
||||
episodeNumber: item.Type === 'Episode' ? asIntegerOrNull(item.IndexNumber) : null,
|
||||
startTimeTicks,
|
||||
audioStreamIndex,
|
||||
subtitleStreamIndex,
|
||||
|
||||
@@ -78,7 +78,7 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
setPendingPauseAtSubEnd: (value: boolean) => void;
|
||||
getPauseAtTime: () => number | null;
|
||||
setPauseAtTime: (value: number | null) => void;
|
||||
autoLoadSecondarySubTrack: () => void;
|
||||
autoLoadSecondarySubTrack: (path: string) => void;
|
||||
setCurrentVideoPath: (value: string) => void;
|
||||
emitSecondarySubtitleVisibility: (payload: { visible: boolean }) => void;
|
||||
setPreviousSecondarySubVisibility: (visible: boolean) => void;
|
||||
@@ -303,7 +303,7 @@ export async function dispatchMpvProtocolMessage(
|
||||
const path = (msg.data as string) || '';
|
||||
deps.setCurrentVideoPath(path);
|
||||
deps.emitMediaPathChange({ path });
|
||||
deps.autoLoadSecondarySubTrack();
|
||||
deps.autoLoadSecondarySubTrack(path);
|
||||
deps.syncCurrentAudioStreamIndex();
|
||||
} else if (msg.name === 'sub-pos') {
|
||||
deps.emitSubtitleMetricsChange({ subPos: msg.data as number });
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
MpvIpcClientProtocolDeps,
|
||||
MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
|
||||
} from './mpv';
|
||||
import { MPV_REQUEST_ID_TRACK_LIST_AUDIO } from './mpv-protocol';
|
||||
import {
|
||||
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
|
||||
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
||||
} from './mpv-protocol';
|
||||
|
||||
function makeDeps(overrides: Partial<MpvIpcClientProtocolDeps> = {}): MpvIpcClientDeps {
|
||||
return {
|
||||
@@ -93,6 +96,53 @@ test('MpvIpcClient clears cached media title when media path changes', async ()
|
||||
assert.equal(client.currentMediaTitle, null);
|
||||
});
|
||||
|
||||
test('MpvIpcClient skips secondary subtitle autoload when media path is managed', async () => {
|
||||
const commands: unknown[] = [];
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const client = new MpvIpcClient(
|
||||
'/tmp/mpv.sock',
|
||||
makeDeps({
|
||||
getResolvedConfig: () =>
|
||||
({
|
||||
secondarySub: {
|
||||
autoLoadSecondarySub: true,
|
||||
secondarySubLanguages: ['en'],
|
||||
},
|
||||
}) as any,
|
||||
shouldAutoLoadSecondarySubTrack: () => false,
|
||||
} as any),
|
||||
);
|
||||
(client as any).send = (command: unknown) => {
|
||||
commands.push(command);
|
||||
return true;
|
||||
};
|
||||
(globalThis as any).setTimeout = (callback: () => void) => {
|
||||
callback();
|
||||
return 0;
|
||||
};
|
||||
|
||||
try {
|
||||
await invokeHandleMessage(client, {
|
||||
event: 'property-change',
|
||||
name: 'path',
|
||||
data: 'http://pve-main:8096/Videos/item/stream',
|
||||
});
|
||||
} finally {
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
commands.some(
|
||||
(command) =>
|
||||
Array.isArray((command as { command?: unknown[] }).command) &&
|
||||
(command as { command: unknown[] }).command[0] === 'get_property' &&
|
||||
(command as { command: unknown[] }).command[1] === 'track-list' &&
|
||||
(command as { request_id?: number }).request_id === MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('MpvIpcClient parses JSON line protocol in processBuffer', () => {
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
const seen: Array<Record<string, unknown>> = [];
|
||||
|
||||
@@ -105,6 +105,7 @@ export interface MpvIpcClientProtocolDeps {
|
||||
isVisibleOverlayVisible: () => boolean;
|
||||
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
|
||||
shouldAutoLoadSecondarySubTrack?: (path: string) => boolean;
|
||||
shouldQuitOnMpvShutdown?: () => boolean;
|
||||
requestAppQuit?: () => void;
|
||||
}
|
||||
@@ -404,8 +405,8 @@ export class MpvIpcClient implements MpvClient {
|
||||
setPauseAtTime: (value: number | null) => {
|
||||
this.pauseAtTime = value;
|
||||
},
|
||||
autoLoadSecondarySubTrack: () => {
|
||||
this.autoLoadSecondarySubTrack();
|
||||
autoLoadSecondarySubTrack: (path: string) => {
|
||||
this.autoLoadSecondarySubTrack(path);
|
||||
},
|
||||
setCurrentVideoPath: (value: string) => {
|
||||
this.currentVideoPath = value;
|
||||
@@ -429,7 +430,12 @@ export class MpvIpcClient implements MpvClient {
|
||||
};
|
||||
}
|
||||
|
||||
private autoLoadSecondarySubTrack(): void {
|
||||
private autoLoadSecondarySubTrack(path: string): void {
|
||||
const normalizedPath = path.trim();
|
||||
if (!normalizedPath) return;
|
||||
if (this.deps.shouldAutoLoadSecondarySubTrack?.(normalizedPath) === false) {
|
||||
return;
|
||||
}
|
||||
const config = this.deps.getResolvedConfig();
|
||||
if (!config.secondarySub?.autoLoadSecondarySub) return;
|
||||
const languages = config.secondarySub.secondarySubLanguages;
|
||||
|
||||
@@ -197,6 +197,68 @@ test('tracked non-macOS overlay stays hidden while tracker is not ready', () =>
|
||||
assert.ok(!calls.includes('osd'));
|
||||
});
|
||||
|
||||
test('non-native passive overlay stays click-through after subsequent visibility updates', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: false,
|
||||
overlayInteractionActive: false,
|
||||
showOverlayLoadingOsd: () => {},
|
||||
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
|
||||
} as never);
|
||||
calls.length = 0;
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: false,
|
||||
overlayInteractionActive: false,
|
||||
showOverlayLoadingOsd: () => {},
|
||||
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
|
||||
} as never);
|
||||
|
||||
assert.equal(calls.includes('mouse-ignore:false:plain'), false);
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
});
|
||||
|
||||
test('suspended visible overlay hides without refreshing bounds or z-order', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
@@ -244,7 +306,7 @@ test('suspended visible overlay hides without refreshing bounds or z-order', ()
|
||||
assert.ok(!calls.includes('focus'));
|
||||
});
|
||||
|
||||
test('untracked non-macOS overlay keeps fallback visible behavior when no tracker exists', () => {
|
||||
test('untracked non-macOS overlay shows passively when no tracker exists', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
let trackerWarning = false;
|
||||
|
||||
@@ -279,11 +341,49 @@ test('untracked non-macOS overlay keeps fallback visible behavior when no tracke
|
||||
} as never);
|
||||
|
||||
assert.equal(trackerWarning, false);
|
||||
assert.ok(calls.includes('show'));
|
||||
assert.ok(calls.includes('focus'));
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('focus'));
|
||||
assert.ok(!calls.includes('osd'));
|
||||
});
|
||||
|
||||
test('passive Linux visible overlay does not take keyboard focus', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
};
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: false,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('focus'));
|
||||
});
|
||||
|
||||
test('tracked non-macOS overlay reapplies bounds after first show', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
@@ -317,8 +417,8 @@ test('tracked non-macOS overlay reapplies bounds after first show', () => {
|
||||
} as never);
|
||||
|
||||
assert.deepEqual(
|
||||
calls.filter((call) => call === 'update-bounds' || call === 'show'),
|
||||
['update-bounds', 'show', 'update-bounds'],
|
||||
calls.filter((call) => call === 'update-bounds' || call === 'show-inactive'),
|
||||
['update-bounds', 'show-inactive', 'update-bounds'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1260,6 +1360,54 @@ test('macOS tracked overlay hides when mpv loses foreground', () => {
|
||||
assert.ok(!calls.includes('show'));
|
||||
});
|
||||
|
||||
test('macOS keeps visible overlay stable while probing frontmost app after overlay blur', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
isTargetWindowFocused: () => false,
|
||||
isTargetWindowMinimized: () => false,
|
||||
};
|
||||
|
||||
window.show();
|
||||
calls.length = 0;
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: true,
|
||||
isWindowsPlatform: false,
|
||||
macOSForegroundProbeActive: true,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('update-bounds'));
|
||||
assert.ok(calls.includes('sync-layer'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('ensure-level'));
|
||||
assert.ok(calls.includes('enforce-order'));
|
||||
assert.ok(calls.includes('sync-shortcuts'));
|
||||
assert.ok(!calls.includes('always-on-top:false'));
|
||||
assert.ok(!calls.includes('hide'));
|
||||
});
|
||||
|
||||
test('macOS keeps tracked overlay visible while overlay interaction is active after mpv loses foreground', () => {
|
||||
const { window, calls, setFocused } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
|
||||
@@ -71,6 +71,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
lastKnownWindowsForegroundProcessName?: string | null;
|
||||
windowsOverlayProcessName?: string | null;
|
||||
windowsFocusHandoffGraceActive?: boolean;
|
||||
macOSForegroundProbeActive?: boolean;
|
||||
trackerNotReadyWarningShown: boolean;
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
@@ -128,6 +129,12 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
const isTrackedMacOSTargetMinimized =
|
||||
canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true;
|
||||
const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.();
|
||||
const shouldPreserveMacOSOverlayDuringForegroundProbe =
|
||||
args.isMacOSPlatform &&
|
||||
args.macOSForegroundProbeActive === true &&
|
||||
!!windowTracker &&
|
||||
!isTrackedMacOSTargetMinimized &&
|
||||
(windowTracker.isTracking() || windowTracker.getGeometry() !== null);
|
||||
const hasTransientMacOSTrackerLoss =
|
||||
args.isMacOSPlatform &&
|
||||
canReportMacOSTargetMinimized &&
|
||||
@@ -137,7 +144,10 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
trackedMacOSTargetFocused !== false &&
|
||||
mainWindow.isVisible();
|
||||
const isTrackedMacOSTargetFocused =
|
||||
hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker
|
||||
hasTransientMacOSTrackerLoss ||
|
||||
shouldPreserveMacOSOverlayDuringForegroundProbe ||
|
||||
!args.isMacOSPlatform ||
|
||||
!args.windowTracker
|
||||
? true
|
||||
: (trackedMacOSTargetFocused ?? true);
|
||||
const shouldReleaseMacOSOverlayLevel =
|
||||
@@ -171,9 +181,12 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
!isTrackedWindowsTargetMinimized &&
|
||||
(args.windowTracker.isTracking() || args.windowTracker.getGeometry() !== null);
|
||||
const shouldForcePassiveReshow = args.isWindowsPlatform && !wasVisible;
|
||||
const isNonNativePassiveOverlay =
|
||||
!args.isWindowsPlatform && !args.isMacOSPlatform && !overlayInteractionActive;
|
||||
const shouldIgnoreMouseEvents =
|
||||
shouldUseMacOSMousePassthrough ||
|
||||
forceMousePassthrough ||
|
||||
isNonNativePassiveOverlay ||
|
||||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
|
||||
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
|
||||
const shouldKeepTrackedWindowsOverlayTopmost =
|
||||
@@ -217,7 +230,10 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
// skip — ready-to-show hasn't fired yet; the onWindowContentReady
|
||||
// callback will trigger another visibility update when the renderer
|
||||
// has painted its first frame.
|
||||
} else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) {
|
||||
} else if (
|
||||
((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) ||
|
||||
isNonNativePassiveOverlay
|
||||
) {
|
||||
if (args.isWindowsPlatform) {
|
||||
setOverlayWindowOpacity(mainWindow, 0);
|
||||
}
|
||||
@@ -261,7 +277,12 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
|
||||
if (
|
||||
!args.isWindowsPlatform &&
|
||||
!args.isMacOSPlatform &&
|
||||
!forceMousePassthrough &&
|
||||
overlayInteractionActive
|
||||
) {
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export type StatsWindowLayerSuspensionState = {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export function createStatsWindowLayerSuspensionState(): StatsWindowLayerSuspensionState {
|
||||
return { count: 0 };
|
||||
}
|
||||
|
||||
export function isStatsWindowLayerSuspended(state: StatsWindowLayerSuspensionState): boolean {
|
||||
return state.count > 0;
|
||||
}
|
||||
|
||||
export function suspendStatsWindowLayer(state: StatsWindowLayerSuspensionState): boolean {
|
||||
state.count += 1;
|
||||
return state.count === 1;
|
||||
}
|
||||
|
||||
export function restoreStatsWindowLayer(state: StatsWindowLayerSuspensionState): boolean {
|
||||
if (state.count <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.count -= 1;
|
||||
return state.count === 0;
|
||||
}
|
||||
|
||||
export function resetStatsWindowLayerSuspension(state: StatsWindowLayerSuspensionState): void {
|
||||
state.count = 0;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createStatsWindowLayerSuspensionState,
|
||||
isStatsWindowLayerSuspended,
|
||||
resetStatsWindowLayerSuspension,
|
||||
restoreStatsWindowLayer,
|
||||
suspendStatsWindowLayer,
|
||||
} from './stats-window-layer';
|
||||
|
||||
test('stats window layer suspension reset clears missed native dialog closes', () => {
|
||||
const state = createStatsWindowLayerSuspensionState();
|
||||
|
||||
assert.equal(suspendStatsWindowLayer(state), true);
|
||||
assert.equal(suspendStatsWindowLayer(state), false);
|
||||
assert.equal(isStatsWindowLayerSuspended(state), true);
|
||||
|
||||
resetStatsWindowLayerSuspension(state);
|
||||
|
||||
assert.equal(isStatsWindowLayerSuspended(state), false);
|
||||
assert.equal(restoreStatsWindowLayer(state), false);
|
||||
assert.equal(suspendStatsWindowLayer(state), true);
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron';
|
||||
import type {
|
||||
BrowserWindow,
|
||||
BrowserWindowConstructorOptions,
|
||||
MessageBoxSyncOptions,
|
||||
} from 'electron';
|
||||
import type { WindowGeometry } from '../../types';
|
||||
|
||||
const DEFAULT_STATS_WINDOW_WIDTH = 900;
|
||||
@@ -9,6 +13,15 @@ type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTo
|
||||
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
|
||||
type VisibleStatsWindowLevelController = StatsWindowLevelController &
|
||||
Pick<BrowserWindow, 'isDestroyed' | 'isVisible'>;
|
||||
type VisibleStatsWindowDialogLayerController = Pick<
|
||||
BrowserWindow,
|
||||
'isDestroyed' | 'isVisible' | 'setAlwaysOnTop'
|
||||
>;
|
||||
type StatsNativeConfirmDialogWindow = Pick<BrowserWindow, 'isDestroyed'>;
|
||||
type StatsNativeConfirmDialogPresenter<WindowT> = {
|
||||
showWithParent: (window: WindowT, options: MessageBoxSyncOptions) => number;
|
||||
showWithoutParent: (options: MessageBoxSyncOptions) => number;
|
||||
};
|
||||
|
||||
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
|
||||
type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> &
|
||||
@@ -124,6 +137,41 @@ export function promoteVisibleStatsWindowAboveOverlay(
|
||||
return true;
|
||||
}
|
||||
|
||||
export function demoteVisibleStatsWindowBelowDialogs(
|
||||
window: VisibleStatsWindowDialogLayerController,
|
||||
): boolean {
|
||||
if (window.isDestroyed() || !window.isVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
window.setAlwaysOnTop(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function buildStatsNativeConfirmDialogOptions(message: string): MessageBoxSyncOptions {
|
||||
return {
|
||||
type: 'warning',
|
||||
message,
|
||||
buttons: ['Delete', 'Cancel'],
|
||||
defaultId: 1,
|
||||
cancelId: 1,
|
||||
noLink: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function showStatsNativeConfirmDialog<WindowT extends StatsNativeConfirmDialogWindow>(
|
||||
window: WindowT | null,
|
||||
message: string,
|
||||
presenter: StatsNativeConfirmDialogPresenter<WindowT>,
|
||||
): boolean {
|
||||
const options = buildStatsNativeConfirmDialogOptions(message);
|
||||
const response =
|
||||
window && !window.isDestroyed()
|
||||
? presenter.showWithParent(window, options)
|
||||
: presenter.showWithoutParent(options);
|
||||
return response === 0;
|
||||
}
|
||||
|
||||
export function presentStatsWindow(
|
||||
window: StatsWindowPresentationController,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
|
||||
@@ -3,10 +3,13 @@ import test from 'node:test';
|
||||
import {
|
||||
buildStatsWindowLoadFileOptions,
|
||||
buildStatsWindowOptions,
|
||||
buildStatsNativeConfirmDialogOptions,
|
||||
demoteVisibleStatsWindowBelowDialogs,
|
||||
presentStatsWindow,
|
||||
promoteVisibleStatsWindowAboveOverlay,
|
||||
promoteStatsWindowLevel,
|
||||
resolveStatsWindowOuterBoundsForContent,
|
||||
showStatsNativeConfirmDialog,
|
||||
shouldHideStatsWindowForInput,
|
||||
} from './stats-window-runtime';
|
||||
|
||||
@@ -274,6 +277,90 @@ test('promoteVisibleStatsWindowAboveOverlay skips hidden stats windows', () => {
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('demoteVisibleStatsWindowBelowDialogs lowers visible stats below native dialogs', () => {
|
||||
const calls: string[] = [];
|
||||
const demoted = demoteVisibleStatsWindowBelowDialogs({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
|
||||
calls.push(`always-on-top:${flag}:${level ?? 'none'}:${relativeLevel ?? 0}`);
|
||||
},
|
||||
} as never);
|
||||
|
||||
assert.equal(demoted, true);
|
||||
assert.deepEqual(calls, ['always-on-top:false:none:0']);
|
||||
});
|
||||
|
||||
test('demoteVisibleStatsWindowBelowDialogs skips hidden stats windows', () => {
|
||||
const calls: string[] = [];
|
||||
const demoted = demoteVisibleStatsWindowBelowDialogs({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => false,
|
||||
setAlwaysOnTop: () => calls.push('always-on-top'),
|
||||
} as never);
|
||||
|
||||
assert.equal(demoted, false);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('buildStatsNativeConfirmDialogOptions makes delete the explicit destructive action', () => {
|
||||
assert.deepEqual(buildStatsNativeConfirmDialogOptions('Delete this session?'), {
|
||||
type: 'warning',
|
||||
message: 'Delete this session?',
|
||||
buttons: ['Delete', 'Cancel'],
|
||||
defaultId: 1,
|
||||
cancelId: 1,
|
||||
noLink: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('showStatsNativeConfirmDialog parents the native dialog to live stats windows', () => {
|
||||
const calls: string[] = [];
|
||||
const parent = { isDestroyed: () => false };
|
||||
|
||||
const confirmed = showStatsNativeConfirmDialog(parent, 'Delete this session?', {
|
||||
showWithParent: (window, options) => {
|
||||
assert.equal(window, parent);
|
||||
calls.push(`${options.message}:${options.defaultId}:${options.cancelId}`);
|
||||
return 0;
|
||||
},
|
||||
showWithoutParent: () => {
|
||||
calls.push('unparented');
|
||||
return 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(confirmed, true);
|
||||
assert.deepEqual(calls, ['Delete this session?:1:1']);
|
||||
});
|
||||
|
||||
test('showStatsNativeConfirmDialog treats cancel as not confirmed', () => {
|
||||
const confirmed = showStatsNativeConfirmDialog({ isDestroyed: () => false }, 'Delete?', {
|
||||
showWithParent: () => 1,
|
||||
showWithoutParent: () => 0,
|
||||
});
|
||||
|
||||
assert.equal(confirmed, false);
|
||||
});
|
||||
|
||||
test('showStatsNativeConfirmDialog falls back to an unparented dialog without a live stats window', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const confirmed = showStatsNativeConfirmDialog({ isDestroyed: () => true }, 'Delete?', {
|
||||
showWithParent: () => {
|
||||
calls.push('parented');
|
||||
return 0;
|
||||
},
|
||||
showWithoutParent: (options) => {
|
||||
calls.push(options.message);
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(confirmed, true);
|
||||
assert.deepEqual(calls, ['Delete?']);
|
||||
});
|
||||
|
||||
test('presentStatsWindow shows inactive on macOS to stay on the fullscreen mpv Space', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron';
|
||||
import { BrowserWindow, dialog, ipcMain } from 'electron';
|
||||
import * as path from 'path';
|
||||
import type { WindowGeometry } from '../../types.js';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
|
||||
import {
|
||||
buildStatsWindowLoadFileOptions,
|
||||
buildStatsWindowOptions,
|
||||
demoteVisibleStatsWindowBelowDialogs,
|
||||
presentStatsWindow,
|
||||
promoteStatsWindowLevel,
|
||||
promoteVisibleStatsWindowAboveOverlay,
|
||||
resolveStatsWindowOuterBoundsForContent,
|
||||
showStatsNativeConfirmDialog,
|
||||
shouldHideStatsWindowForInput,
|
||||
STATS_WINDOW_TITLE,
|
||||
} from './stats-window-runtime.js';
|
||||
import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement.js';
|
||||
import {
|
||||
createStatsWindowLayerSuspensionState,
|
||||
isStatsWindowLayerSuspended,
|
||||
resetStatsWindowLayerSuspension,
|
||||
restoreStatsWindowLayer,
|
||||
suspendStatsWindowLayer,
|
||||
} from './stats-window-layer.js';
|
||||
|
||||
let statsWindow: BrowserWindow | null = null;
|
||||
let toggleRegistered = false;
|
||||
let nativeDialogLayerRegistered = false;
|
||||
const nativeDialogLayerSuspension = createStatsWindowLayerSuspensionState();
|
||||
|
||||
export interface StatsWindowOptions {
|
||||
/** Absolute path to stats/dist/ directory */
|
||||
@@ -63,6 +74,10 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo
|
||||
}
|
||||
|
||||
export function promoteStatsOverlayAbovePlayback(): boolean {
|
||||
if (isStatsWindowLayerSuspended(nativeDialogLayerSuspension)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!statsWindow) {
|
||||
return false;
|
||||
}
|
||||
@@ -74,6 +89,69 @@ export function promoteStatsOverlayAbovePlayback(): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
export function demoteStatsOverlayBelowDialogs(): boolean {
|
||||
if (!statsWindow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return demoteVisibleStatsWindowBelowDialogs(statsWindow);
|
||||
}
|
||||
|
||||
export function suspendStatsWindowLayerForNativeDialog(): void {
|
||||
if (!suspendStatsWindowLayer(nativeDialogLayerSuspension)) {
|
||||
return;
|
||||
}
|
||||
|
||||
demoteStatsOverlayBelowDialogs();
|
||||
}
|
||||
|
||||
export function restoreStatsWindowLayerAfterNativeDialog(): void {
|
||||
if (restoreStatsWindowLayer(nativeDialogLayerSuspension)) {
|
||||
promoteStatsOverlayAbovePlayback();
|
||||
}
|
||||
}
|
||||
|
||||
function resetStatsWindowLayerAfterLifecycleEnd(): void {
|
||||
resetStatsWindowLayerSuspension(nativeDialogLayerSuspension);
|
||||
}
|
||||
|
||||
export async function withStatsWindowLayerSuspendedForNativeDialog<T>(
|
||||
showDialog: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
suspendStatsWindowLayerForNativeDialog();
|
||||
try {
|
||||
return await showDialog();
|
||||
} finally {
|
||||
restoreStatsWindowLayerAfterNativeDialog();
|
||||
}
|
||||
}
|
||||
|
||||
function confirmStatsNativeDialog(message: unknown): boolean {
|
||||
const dialogMessage =
|
||||
typeof message === 'string' && message.trim().length > 0 ? message : 'Confirm deletion?';
|
||||
|
||||
return showStatsNativeConfirmDialog(statsWindow, dialogMessage, {
|
||||
showWithParent: (parentWindow, options) => dialog.showMessageBoxSync(parentWindow, options),
|
||||
showWithoutParent: (options) => dialog.showMessageBoxSync(options),
|
||||
});
|
||||
}
|
||||
|
||||
function registerStatsNativeDialogLayerHandlers(): void {
|
||||
if (nativeDialogLayerRegistered) return;
|
||||
nativeDialogLayerRegistered = true;
|
||||
|
||||
ipcMain.on(IPC_CHANNELS.command.statsNativeConfirmDialog, (event, message) => {
|
||||
event.returnValue = confirmStatsNativeDialog(message);
|
||||
});
|
||||
ipcMain.on(IPC_CHANNELS.command.statsNativeDialogOpened, (event) => {
|
||||
suspendStatsWindowLayerForNativeDialog();
|
||||
event.returnValue = true;
|
||||
});
|
||||
ipcMain.on(IPC_CHANNELS.command.statsNativeDialogClosed, () => {
|
||||
restoreStatsWindowLayerAfterNativeDialog();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the stats overlay window: create on first call, then show/hide.
|
||||
* The React app stays mounted across toggles — state is preserved.
|
||||
@@ -99,6 +177,7 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
|
||||
statsWindow.on('closed', () => {
|
||||
options.onVisibilityChanged?.(false);
|
||||
statsWindow = null;
|
||||
resetStatsWindowLayerAfterLifecycleEnd();
|
||||
});
|
||||
|
||||
statsWindow.webContents.on('before-input-event', (event, input) => {
|
||||
@@ -132,6 +211,7 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
|
||||
* Call this once during app initialization.
|
||||
*/
|
||||
export function registerStatsOverlayToggle(options: StatsWindowOptions): void {
|
||||
registerStatsNativeDialogLayerHandlers();
|
||||
if (toggleRegistered) return;
|
||||
toggleRegistered = true;
|
||||
ipcMain.on(IPC_CHANNELS.command.toggleStatsOverlay, () => {
|
||||
@@ -148,4 +228,5 @@ export function destroyStatsWindow(): void {
|
||||
statsWindow.destroy();
|
||||
statsWindow = null;
|
||||
}
|
||||
resetStatsWindowLayerAfterLifecycleEnd();
|
||||
}
|
||||
|
||||
@@ -70,12 +70,14 @@ test('triggerSubsyncFromConfig returns early when already in progress', async ()
|
||||
test('triggerSubsyncFromConfig opens manual picker', async () => {
|
||||
const osd: string[] = [];
|
||||
let payloadTrackCount = 0;
|
||||
let ffsubsyncAvailable: boolean | null = null;
|
||||
let inProgressState: boolean | null = null;
|
||||
|
||||
await triggerSubsyncFromConfig(
|
||||
makeDeps({
|
||||
openManualPicker: (payload) => {
|
||||
payloadTrackCount = payload.sourceTracks.length;
|
||||
ffsubsyncAvailable = payload.ffsubsyncAvailable;
|
||||
},
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
@@ -87,10 +89,49 @@ test('triggerSubsyncFromConfig opens manual picker', async () => {
|
||||
);
|
||||
|
||||
assert.equal(payloadTrackCount, 1);
|
||||
assert.equal(ffsubsyncAvailable, true);
|
||||
assert.ok(osd.includes('Subsync: choose engine and source'));
|
||||
assert.equal(inProgressState, false);
|
||||
});
|
||||
|
||||
test('triggerSubsyncFromConfig marks ffsubsync unavailable for remote media paths', async () => {
|
||||
let ffsubsyncAvailable: boolean | null = null;
|
||||
|
||||
await triggerSubsyncFromConfig(
|
||||
makeDeps({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
currentAudioStreamIndex: null,
|
||||
send: () => {},
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'path') return 'https://jellyfin.example/Videos/movie/stream.mkv';
|
||||
if (name === 'sid') return 1;
|
||||
if (name === 'secondary-sid') return null;
|
||||
if (name === 'track-list') {
|
||||
return [
|
||||
{ id: 1, type: 'sub', selected: true, lang: 'jpn' },
|
||||
{
|
||||
id: 2,
|
||||
type: 'sub',
|
||||
selected: false,
|
||||
external: true,
|
||||
lang: 'eng',
|
||||
'external-filename': 'https://jellyfin.example/subs/eng.srt',
|
||||
},
|
||||
];
|
||||
}
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
openManualPicker: (payload) => {
|
||||
ffsubsyncAvailable = payload.ffsubsyncAvailable;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(ffsubsyncAvailable, false);
|
||||
});
|
||||
|
||||
test('triggerSubsyncFromConfig does not run automatic sync', async () => {
|
||||
const osd: string[] = [];
|
||||
let payloadTrackCount = 0;
|
||||
|
||||
@@ -378,6 +378,7 @@ export async function openSubsyncManualPicker(deps: TriggerSubsyncFromConfigDeps
|
||||
const client = getMpvClientForSubsync(deps);
|
||||
const context = await gatherSubsyncContext(client);
|
||||
const payload: SubsyncManualPayload = {
|
||||
ffsubsyncAvailable: !isRemoteMediaPath(context.videoPath),
|
||||
sourceTracks: context.sourceTracks
|
||||
.filter((track) => typeof track.id === 'number')
|
||||
.map((track) => ({
|
||||
|
||||
@@ -89,6 +89,40 @@ Dialogue: 0,0:00:04.00,0:00:05.00,Default,,0,0,0,,line-3`,
|
||||
assert.equal(Math.abs((delta as number) + 1.5) < 0.0001, true);
|
||||
});
|
||||
|
||||
test('shift subtitle delay reports cumulative delay after adjacent cue shift', async () => {
|
||||
const shiftedDelays: number[] = [];
|
||||
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||
getMpvClient: () =>
|
||||
createMpvClient({
|
||||
'track-list': [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 2,
|
||||
external: true,
|
||||
'external-filename': '/tmp/subs.srt',
|
||||
},
|
||||
],
|
||||
sid: 2,
|
||||
'sub-start': 3.0,
|
||||
'sub-delay': 0.5,
|
||||
}),
|
||||
loadSubtitleSourceText: async () => `1
|
||||
00:00:03,000 --> 00:00:04,000
|
||||
line-1
|
||||
|
||||
2
|
||||
00:00:05,000 --> 00:00:06,000
|
||||
line-2`,
|
||||
sendMpvCommand: () => {},
|
||||
showMpvOsd: () => {},
|
||||
onSubtitleDelayShifted: (delay) => shiftedDelays.push(delay),
|
||||
});
|
||||
|
||||
await handler('next');
|
||||
|
||||
assert.deepEqual(shiftedDelays, [2.5]);
|
||||
});
|
||||
|
||||
test('shift subtitle delay throws when no next cue exists', async () => {
|
||||
const handler = createShiftSubtitleDelayToAdjacentCueHandler({
|
||||
getMpvClient: () =>
|
||||
|
||||
@@ -21,6 +21,7 @@ type SubtitleDelayShiftDeps = {
|
||||
loadSubtitleSourceText: (source: string) => Promise<string>;
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
onSubtitleDelayShifted?: (delaySeconds: number) => void;
|
||||
};
|
||||
|
||||
function asTrackId(value: unknown): number | null {
|
||||
@@ -175,10 +176,11 @@ export function createShiftSubtitleDelayToAdjacentCueHandler(deps: SubtitleDelay
|
||||
throw new Error('MPV not connected.');
|
||||
}
|
||||
|
||||
const [trackListRaw, sidRaw, subStartRaw] = await Promise.all([
|
||||
const [trackListRaw, sidRaw, subStartRaw, subDelayRaw] = await Promise.all([
|
||||
client.requestProperty('track-list'),
|
||||
client.requestProperty('sid'),
|
||||
client.requestProperty('sub-start'),
|
||||
client.requestProperty('sub-delay'),
|
||||
]);
|
||||
|
||||
const currentStart =
|
||||
@@ -198,6 +200,11 @@ export function createShiftSubtitleDelayToAdjacentCueHandler(deps: SubtitleDelay
|
||||
const targetStart = findAdjacentCueStart(cueStarts, currentStart, direction);
|
||||
const delta = targetStart - currentStart;
|
||||
deps.sendMpvCommand(['add', 'sub-delay', delta]);
|
||||
const currentDelay =
|
||||
typeof subDelayRaw === 'number' && Number.isFinite(subDelayRaw) ? subDelayRaw : 0;
|
||||
try {
|
||||
deps.onSubtitleDelayShifted?.(currentDelay + delta);
|
||||
} catch {}
|
||||
deps.showMpvOsd('Subtitle delay: ${sub-delay}');
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { estimateSubtitleTimingOffset } from './subtitle-timing-offset';
|
||||
|
||||
function cue(startTime: number) {
|
||||
return { startTime, endTime: startTime + 1, text: `cue ${startTime}` };
|
||||
}
|
||||
|
||||
test('estimate subtitle timing offset detects a late Jellyfin subtitle timeline', () => {
|
||||
const primary = [
|
||||
34.935, 36.937, 41.441, 45.279, 48.115, 52.286, 54.955, 59.793, 63.63, 67.634, 76.643, 80.814,
|
||||
87.988, 90.991, 94.094, 97.097,
|
||||
].map(cue);
|
||||
const reference = [
|
||||
3.46, 9.48, 13.61, 21.4, 28.16, 32.06, 35.93, 45.1, 56.57, 59.68, 62.44, 65.56,
|
||||
].map(cue);
|
||||
|
||||
const result = estimateSubtitleTimingOffset(primary, reference);
|
||||
|
||||
assert.ok(result);
|
||||
assert.ok(result.offsetSeconds > -32);
|
||||
assert.ok(result.offsetSeconds < -31);
|
||||
assert.ok(result.matchCount >= 8);
|
||||
assert.ok(result.meanErrorSeconds <= 0.75);
|
||||
});
|
||||
|
||||
test('estimate subtitle timing offset favors the early episode timeline', () => {
|
||||
const primary = [
|
||||
34.935, 36.937, 41.441, 45.279, 48.115, 52.286, 54.955, 59.793, 63.63, 67.634, 76.643, 80.814,
|
||||
87.988, 90.991, 94.094, 97.097, 207.974, 212.579, 222.422, 228.095, 232.432, 238.271, 244.778,
|
||||
246.78, 249.282, 251.284, 253.62, 256.289, 259.626, 262.129, 264.965, 267.634, 270.303, 274.407,
|
||||
277.077, 280.08, 284.084, 288.421, 291.925, 295.262, 298.431, 301.101, 306.773, 308.942,
|
||||
312.946, 316.283, 321.621, 326.626, 331.131, 336.069, 340.407, 343.41, 351.418, 355.422,
|
||||
357.924, 362.429, 365.432, 370.604, 373.273, 377.944, 381.114, 384.618, 387.621, 390.957,
|
||||
396.73, 399.232, 401.568, 403.57, 405.572, 407.574, 409.743, 412.746, 418.752, 425.258, 427.26,
|
||||
435.602, 440.44, 442.942, 445.445, 449.783,
|
||||
].map(cue);
|
||||
const reference = [
|
||||
3.46, 9.48, 13.61, 21.4, 28.16, 32.06, 35.93, 45.1, 56.57, 59.68, 62.44, 65.56, 165.77, 172.81,
|
||||
176.1, 177.27, 186.33, 191.33, 195.78, 201.83, 212.9, 214.09, 216.73, 220.2, 222.91, 225.65,
|
||||
232.8, 237.92, 242.23, 243.28, 247.53, 252.04, 255.9, 258.86, 262.09, 264.43, 276.07, 278.01,
|
||||
280.98, 285.67, 289.89, 294.57, 300, 303.56, 308.58, 316.37, 318.38, 319.86, 325.38, 328.82,
|
||||
333.68, 335.26, 336.82, 340.11, 342.11, 344.36, 346.39, 347.53, 350.92, 370.18, 372.88, 376.43,
|
||||
388.2, 390.57, 403.96, 406.36, 409.72, 413.78, 425.55, 432.76, 435.03, 438.06, 443.73, 448.31,
|
||||
450.57, 457.62, 463.41, 465.85, 473.79, 480.59,
|
||||
].map(cue);
|
||||
|
||||
const result = estimateSubtitleTimingOffset(primary, reference);
|
||||
|
||||
assert.ok(result);
|
||||
assert.ok(result.offsetSeconds > -32);
|
||||
assert.ok(result.offsetSeconds < -31);
|
||||
});
|
||||
|
||||
test('estimate subtitle timing offset ignores subtitle timelines that are already aligned', () => {
|
||||
const starts = [1, 5, 9, 14, 20, 25, 31, 38];
|
||||
|
||||
const result = estimateSubtitleTimingOffset(
|
||||
starts.map(cue),
|
||||
starts.map((start) => cue(start + 0.04)),
|
||||
);
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('estimate subtitle timing offset rejects weak timeline matches', () => {
|
||||
const primary = [10, 20, 30, 40, 50, 60, 70, 80].map(cue);
|
||||
const reference = [1, 2, 3, 4, 5, 6, 7, 8].map(cue);
|
||||
|
||||
const result = estimateSubtitleTimingOffset(primary, reference);
|
||||
|
||||
assert.equal(result, null);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { SubtitleCue } from './subtitle-cue-parser';
|
||||
|
||||
export type SubtitleTimingOffsetResult = {
|
||||
offsetSeconds: number;
|
||||
matchCount: number;
|
||||
meanErrorSeconds: number;
|
||||
maxErrorSeconds: number;
|
||||
};
|
||||
|
||||
export type SubtitleTimingOffsetOptions = {
|
||||
maxCueCount?: number;
|
||||
maxOffsetSeconds?: number;
|
||||
matchThresholdSeconds?: number;
|
||||
maxMeanErrorSeconds?: number;
|
||||
minMatchCount?: number;
|
||||
minMatchRatio?: number;
|
||||
minUsefulOffsetSeconds?: number;
|
||||
};
|
||||
|
||||
type OffsetScore = SubtitleTimingOffsetResult;
|
||||
|
||||
const DEFAULT_MAX_CUE_COUNT = 60;
|
||||
const DEFAULT_MAX_OFFSET_SECONDS = 180;
|
||||
const DEFAULT_MATCH_THRESHOLD_SECONDS = 1;
|
||||
const DEFAULT_MAX_MEAN_ERROR_SECONDS = 0.75;
|
||||
const DEFAULT_MIN_MATCH_COUNT = 8;
|
||||
const DEFAULT_MIN_MATCH_RATIO = 0.25;
|
||||
const DEFAULT_MIN_USEFUL_OFFSET_SECONDS = 0.25;
|
||||
|
||||
function normalizeCueStarts(cues: SubtitleCue[], maxCueCount: number): number[] {
|
||||
const starts = cues
|
||||
.map((cue) => cue.startTime)
|
||||
.filter((start) => Number.isFinite(start) && start >= 0)
|
||||
.sort((a, b) => a - b);
|
||||
const deduped: number[] = [];
|
||||
for (const start of starts) {
|
||||
const previous = deduped[deduped.length - 1];
|
||||
if (previous === undefined || Math.abs(start - previous) > 0.05) {
|
||||
deduped.push(start);
|
||||
}
|
||||
if (deduped.length >= maxCueCount) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function roundToMillis(value: number): number {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
function scoreOffset(
|
||||
primaryStarts: number[],
|
||||
referenceStarts: number[],
|
||||
offsetSeconds: number,
|
||||
matchThresholdSeconds: number,
|
||||
): OffsetScore {
|
||||
let primaryIndex = 0;
|
||||
let referenceIndex = 0;
|
||||
let matchCount = 0;
|
||||
let totalErrorSeconds = 0;
|
||||
let maxErrorSeconds = 0;
|
||||
|
||||
while (primaryIndex < primaryStarts.length && referenceIndex < referenceStarts.length) {
|
||||
const shiftedPrimary = primaryStarts[primaryIndex]! + offsetSeconds;
|
||||
const reference = referenceStarts[referenceIndex]!;
|
||||
const errorSeconds = Math.abs(shiftedPrimary - reference);
|
||||
if (errorSeconds <= matchThresholdSeconds) {
|
||||
matchCount += 1;
|
||||
totalErrorSeconds += errorSeconds;
|
||||
maxErrorSeconds = Math.max(maxErrorSeconds, errorSeconds);
|
||||
primaryIndex += 1;
|
||||
referenceIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (shiftedPrimary < reference) {
|
||||
primaryIndex += 1;
|
||||
} else {
|
||||
referenceIndex += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
offsetSeconds,
|
||||
matchCount,
|
||||
meanErrorSeconds: matchCount > 0 ? totalErrorSeconds / matchCount : Number.POSITIVE_INFINITY,
|
||||
maxErrorSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
function isBetterScore(next: OffsetScore, current: OffsetScore | null): boolean {
|
||||
if (current === null) return true;
|
||||
if (next.matchCount !== current.matchCount) return next.matchCount > current.matchCount;
|
||||
if (next.meanErrorSeconds !== current.meanErrorSeconds) {
|
||||
return next.meanErrorSeconds < current.meanErrorSeconds;
|
||||
}
|
||||
return Math.abs(next.offsetSeconds) < Math.abs(current.offsetSeconds);
|
||||
}
|
||||
|
||||
export function estimateSubtitleTimingOffset(
|
||||
primaryCues: SubtitleCue[],
|
||||
referenceCues: SubtitleCue[],
|
||||
options: SubtitleTimingOffsetOptions = {},
|
||||
): SubtitleTimingOffsetResult | null {
|
||||
const maxCueCount = options.maxCueCount ?? DEFAULT_MAX_CUE_COUNT;
|
||||
const maxOffsetSeconds = options.maxOffsetSeconds ?? DEFAULT_MAX_OFFSET_SECONDS;
|
||||
const matchThresholdSeconds = options.matchThresholdSeconds ?? DEFAULT_MATCH_THRESHOLD_SECONDS;
|
||||
const maxMeanErrorSeconds = options.maxMeanErrorSeconds ?? DEFAULT_MAX_MEAN_ERROR_SECONDS;
|
||||
const minMatchCount = options.minMatchCount ?? DEFAULT_MIN_MATCH_COUNT;
|
||||
const minMatchRatio = options.minMatchRatio ?? DEFAULT_MIN_MATCH_RATIO;
|
||||
const minUsefulOffsetSeconds =
|
||||
options.minUsefulOffsetSeconds ?? DEFAULT_MIN_USEFUL_OFFSET_SECONDS;
|
||||
|
||||
const primaryStarts = normalizeCueStarts(primaryCues, maxCueCount);
|
||||
const referenceStarts = normalizeCueStarts(referenceCues, maxCueCount);
|
||||
const comparableCueCount = Math.min(primaryStarts.length, referenceStarts.length);
|
||||
if (comparableCueCount < minMatchCount) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = new Set<number>();
|
||||
for (const primaryStart of primaryStarts) {
|
||||
for (const referenceStart of referenceStarts) {
|
||||
const offsetSeconds = roundToMillis(referenceStart - primaryStart);
|
||||
if (Math.abs(offsetSeconds) <= maxOffsetSeconds) {
|
||||
candidates.add(offsetSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let best: OffsetScore | null = null;
|
||||
for (const offsetSeconds of candidates) {
|
||||
if (Math.abs(offsetSeconds) < minUsefulOffsetSeconds) {
|
||||
continue;
|
||||
}
|
||||
const score = scoreOffset(primaryStarts, referenceStarts, offsetSeconds, matchThresholdSeconds);
|
||||
if (score.matchCount < minMatchCount) {
|
||||
continue;
|
||||
}
|
||||
if (score.matchCount / comparableCueCount < minMatchRatio) {
|
||||
continue;
|
||||
}
|
||||
if (score.meanErrorSeconds > maxMeanErrorSeconds) {
|
||||
continue;
|
||||
}
|
||||
if (isBetterScore(score, best)) {
|
||||
best = score;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
@@ -129,7 +129,7 @@ test('tokenizeSubtitle splits same-line grammar endings before applying annotati
|
||||
assert.equal(result.tokens?.[0]?.jlptLevel, 'N5');
|
||||
assert.equal(result.tokens?.[0]?.frequencyRank, 40);
|
||||
assert.equal(result.tokens?.[1]?.surface, 'です');
|
||||
assert.equal(result.tokens?.[1]?.isKnown, false);
|
||||
assert.equal(result.tokens?.[1]?.isKnown, true);
|
||||
assert.equal(result.tokens?.[1]?.isNPlusOneTarget, false);
|
||||
assert.equal(result.tokens?.[1]?.frequencyRank, undefined);
|
||||
assert.equal(result.tokens?.[1]?.jlptLevel, undefined);
|
||||
@@ -3365,7 +3365,7 @@ test('tokenizeSubtitle excludes default non-independent pos2 from N+1 and freque
|
||||
assert.equal(result.tokens?.[0]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle clears known-word highlight for exact non-independent kanji noun tokens', async () => {
|
||||
test('tokenizeSubtitle keeps known-word highlight for exact non-independent kanji noun tokens', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'その点',
|
||||
makeDepsFromYomitanTokens(
|
||||
@@ -3413,7 +3413,7 @@ test('tokenizeSubtitle clears known-word highlight for exact non-independent kan
|
||||
assert.equal(result.tokens?.length, 2);
|
||||
assert.equal(result.tokens?.[0]?.isKnown, false);
|
||||
assert.equal(result.tokens?.[1]?.surface, '点');
|
||||
assert.equal(result.tokens?.[1]?.isKnown, false);
|
||||
assert.equal(result.tokens?.[1]?.isKnown, true);
|
||||
assert.equal(result.tokens?.[1]?.isNPlusOneTarget, false);
|
||||
assert.equal(result.tokens?.[1]?.frequencyRank, undefined);
|
||||
assert.equal(result.tokens?.[1]?.jlptLevel, undefined);
|
||||
@@ -4028,7 +4028,7 @@ test('tokenizeSubtitle clears all annotations for kana-only demonstrative helper
|
||||
{
|
||||
surface: 'これで',
|
||||
headword: 'これ',
|
||||
isKnown: false,
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: false,
|
||||
frequencyRank: undefined,
|
||||
jlptLevel: undefined,
|
||||
@@ -4143,7 +4143,7 @@ test('tokenizeSubtitle clears all annotations for explanatory pondering endings'
|
||||
{
|
||||
surface: 'のかな',
|
||||
headword: 'の',
|
||||
isKnown: false,
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: false,
|
||||
frequencyRank: undefined,
|
||||
jlptLevel: undefined,
|
||||
@@ -4672,7 +4672,7 @@ test('tokenizeSubtitle clears annotations for ja-nai explanatory endings and aru
|
||||
{
|
||||
surface: 'ある',
|
||||
headword: 'ある',
|
||||
isKnown: false,
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: false,
|
||||
frequencyRank: undefined,
|
||||
jlptLevel: undefined,
|
||||
@@ -4717,7 +4717,7 @@ test('tokenizeSubtitle clears annotations for standalone polite copula endings w
|
||||
{
|
||||
surface: 'ですよ',
|
||||
headword: 'です',
|
||||
isKnown: false,
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: false,
|
||||
frequencyRank: undefined,
|
||||
jlptLevel: undefined,
|
||||
@@ -5044,7 +5044,7 @@ test('tokenizeSubtitle clears annotations for auxiliary inflection fragments whi
|
||||
{
|
||||
surface: 'れた',
|
||||
headword: 'れる',
|
||||
isKnown: false,
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: false,
|
||||
frequencyRank: undefined,
|
||||
jlptLevel: undefined,
|
||||
@@ -5181,7 +5181,7 @@ test('tokenizeSubtitle clears annotations for te-kureru auxiliary helper spans',
|
||||
{
|
||||
surface: 'てく',
|
||||
headword: 'てく',
|
||||
isKnown: false,
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: false,
|
||||
frequencyRank: undefined,
|
||||
jlptLevel: undefined,
|
||||
@@ -5192,7 +5192,7 @@ test('tokenizeSubtitle clears annotations for te-kureru auxiliary helper spans',
|
||||
{
|
||||
surface: 'れた',
|
||||
headword: 'れる',
|
||||
isKnown: false,
|
||||
isKnown: true,
|
||||
isNPlusOneTarget: false,
|
||||
frequencyRank: undefined,
|
||||
jlptLevel: undefined,
|
||||
|
||||
@@ -425,6 +425,21 @@ test('shouldExcludeTokenFromSubtitleAnnotations keeps lexical tokens outside exp
|
||||
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), false);
|
||||
});
|
||||
|
||||
test('shouldExcludeTokenFromSubtitleAnnotations still excludes lexical non-independent kanji nouns from non-known annotations', () => {
|
||||
const token = makeToken({
|
||||
surface: '以外',
|
||||
headword: '以外',
|
||||
reading: 'イガイ',
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
pos1: '名詞',
|
||||
pos2: '非自立',
|
||||
pos3: '副詞可能',
|
||||
});
|
||||
|
||||
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
|
||||
assert.equal(shouldExcludeTokenFromVocabularyPersistence(token), true);
|
||||
});
|
||||
|
||||
test('shouldExcludeTokenFromSubtitleAnnotations excludes standalone particles auxiliaries and adnominals', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
@@ -971,8 +986,8 @@ test('annotateTokens N+1 minimum sentence words counts only eligible word tokens
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[1]?.isKnown, false);
|
||||
assert.equal(result[2]?.isKnown, false);
|
||||
assert.equal(result[1]?.isKnown, true);
|
||||
assert.equal(result[2]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
@@ -1186,7 +1201,7 @@ test('annotateTokens excludes default non-independent pos2 from frequency and N+
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('annotateTokens clears known-word status for non-independent kanji noun tokens', () => {
|
||||
test('annotateTokens keeps known-word status for non-independent kanji noun tokens', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: '点',
|
||||
@@ -1211,12 +1226,41 @@ test('annotateTokens clears known-word status for non-independent kanji noun tok
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens keeps known-word status for lexical non-independent kanji nouns', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: '以外',
|
||||
reading: 'イガイ',
|
||||
headword: '以外',
|
||||
partOfSpeech: PartOfSpeech.noun,
|
||||
pos1: '名詞',
|
||||
pos2: '非自立',
|
||||
pos3: '副詞可能',
|
||||
startPos: 2,
|
||||
endPos: 4,
|
||||
frequencyRank: 437,
|
||||
}),
|
||||
];
|
||||
|
||||
const result = annotateTokens(
|
||||
tokens,
|
||||
makeDeps({
|
||||
isKnownWord: (text) => text === '以外',
|
||||
}),
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('annotateTokens clears all annotations for non-independent kanji noun tokens under unified gate', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
@@ -1401,7 +1445,7 @@ test('annotateTokens excludes composite tokens when all component pos tags are e
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
});
|
||||
|
||||
test('annotateTokens applies one shared exclusion gate across known N+1 frequency and JLPT', () => {
|
||||
test('annotateTokens lets known words bypass the shared exclusion gate for known status only', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'これで',
|
||||
@@ -1425,13 +1469,13 @@ test('annotateTokens applies one shared exclusion gate across known N+1 frequenc
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status and other annotations for kana-only non-independent noun helper merges', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations for kana-only non-independent noun helper merges', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'ことに',
|
||||
@@ -1455,13 +1499,13 @@ test('annotateTokens clears known status and other annotations for kana-only non
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status and other annotations for standalone auxiliary inflection fragments', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations for standalone auxiliary inflection fragments', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'れる',
|
||||
@@ -1497,14 +1541,14 @@ test('annotateTokens clears known status and other annotations for standalone au
|
||||
);
|
||||
|
||||
for (const token of result) {
|
||||
assert.equal(token.isKnown, false, token.surface);
|
||||
assert.equal(token.isKnown, true, token.surface);
|
||||
assert.equal(token.isNPlusOneTarget, false, token.surface);
|
||||
assert.equal(token.frequencyRank, undefined, token.surface);
|
||||
assert.equal(token.jlptLevel, undefined, token.surface);
|
||||
}
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status and other annotations for auxiliary-only te-kureru helper spans', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations for auxiliary-only te-kureru helper spans', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'てく',
|
||||
@@ -1540,7 +1584,7 @@ test('annotateTokens clears known status and other annotations for auxiliary-onl
|
||||
);
|
||||
|
||||
for (const token of result) {
|
||||
assert.equal(token.isKnown, false, token.surface);
|
||||
assert.equal(token.isKnown, true, token.surface);
|
||||
assert.equal(token.isNPlusOneTarget, false, token.surface);
|
||||
assert.equal(token.frequencyRank, undefined, token.surface);
|
||||
assert.equal(token.jlptLevel, undefined, token.surface);
|
||||
@@ -1576,7 +1620,7 @@ test('annotateTokens keeps lexical くれる forms eligible for annotation', ()
|
||||
assert.equal(result[0]?.jlptLevel, 'N4');
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status and other annotations for standalone して helper fragments', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations for standalone して helper fragments', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'してる',
|
||||
@@ -1600,13 +1644,13 @@ test('annotateTokens clears known status and other annotations for standalone
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status and other annotations for standalone particle fragments without POS tags', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations for standalone particle fragments without POS tags', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'と',
|
||||
@@ -1630,13 +1674,13 @@ test('annotateTokens clears known status and other annotations for standalone pa
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status from standalone particles even when the known-word cache contains them', () => {
|
||||
test('annotateTokens keeps known status on standalone particles when the known-word cache contains them', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'に',
|
||||
@@ -1671,7 +1715,7 @@ test('annotateTokens clears known status from standalone particles even when the
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
@@ -1728,7 +1772,7 @@ test('annotateTokens does not mark standalone connective particles as N+1', () =
|
||||
assert.equal(result[1]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status and other annotations for rhetorical もんか grammar particle phrases', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations for rhetorical もんか grammar particle phrases', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'もんか',
|
||||
@@ -1752,13 +1796,13 @@ test('annotateTokens clears known status and other annotations for rhetorical
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status and other annotations for bare くれ auxiliary fragments', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations for bare くれ auxiliary fragments', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'くれ',
|
||||
@@ -1782,13 +1826,13 @@ test('annotateTokens clears known status and other annotations for bare くれ a
|
||||
{ minSentenceWordsForNPlusOne: 1 },
|
||||
);
|
||||
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status and other annotations for aru existence verbs', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations for aru existence verbs', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: '有る',
|
||||
@@ -1818,14 +1862,14 @@ test('annotateTokens clears known status and other annotations for aru existence
|
||||
|
||||
assert.equal(result[0]?.surface, '有る');
|
||||
assert.equal(result[0]?.headword, '有る');
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.isNameMatch, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status and other annotations for standalone quote particle and auxiliary grammar terms', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations for standalone quote particle and auxiliary grammar terms', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'って',
|
||||
@@ -1861,14 +1905,14 @@ test('annotateTokens clears known status and other annotations for standalone qu
|
||||
);
|
||||
|
||||
for (const token of result) {
|
||||
assert.equal(token.isKnown, false, token.surface);
|
||||
assert.equal(token.isKnown, true, token.surface);
|
||||
assert.equal(token.isNPlusOneTarget, false, token.surface);
|
||||
assert.equal(token.frequencyRank, undefined, token.surface);
|
||||
assert.equal(token.jlptLevel, undefined, token.surface);
|
||||
}
|
||||
});
|
||||
|
||||
test('annotateTokens clears known status and other annotations from standalone あ interjections without POS tags', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations from standalone あ interjections without POS tags', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'あ',
|
||||
@@ -1898,13 +1942,13 @@ test('annotateTokens clears known status and other annotations from standalone
|
||||
assert.equal(result[0]?.surface, 'あ');
|
||||
assert.equal(result[0]?.headword, 'あ');
|
||||
assert.equal(result[0]?.reading, 'あ');
|
||||
assert.equal(result[0]?.isKnown, false);
|
||||
assert.equal(result[0]?.isKnown, true);
|
||||
assert.equal(result[0]?.isNPlusOneTarget, false);
|
||||
assert.equal(result[0]?.frequencyRank, undefined);
|
||||
assert.equal(result[0]?.jlptLevel, undefined);
|
||||
});
|
||||
|
||||
test('annotateTokens clears all annotations from expressive subtitle interjections without POS tags', () => {
|
||||
test('annotateTokens keeps known status while clearing other annotations from expressive subtitle interjections without POS tags', () => {
|
||||
const tokens = [
|
||||
makeToken({
|
||||
surface: 'ハァ',
|
||||
@@ -1960,7 +2004,7 @@ test('annotateTokens clears all annotations from expressive subtitle interjectio
|
||||
);
|
||||
|
||||
for (const token of result.slice(0, 2)) {
|
||||
assert.equal(token.isKnown, false, token.surface);
|
||||
assert.equal(token.isKnown, true, token.surface);
|
||||
assert.equal(token.isNPlusOneTarget, false, token.surface);
|
||||
assert.equal(token.frequencyRank, undefined, token.surface);
|
||||
assert.equal(token.jlptLevel, undefined, token.surface);
|
||||
|
||||
@@ -680,6 +680,11 @@ export function annotateTokens(
|
||||
|
||||
// Single pass: compute known word status, frequency filtering, and JLPT level together
|
||||
const annotated = tokens.map((token, index) => {
|
||||
const isKnownForMatching = shouldComputeKnownStatus
|
||||
? computeTokenKnownStatus(token, deps.isKnownWord, deps.knownWordMatchMode)
|
||||
: false;
|
||||
nPlusOneKnownStatuses[index] = isKnownForMatching;
|
||||
|
||||
if (
|
||||
sharedShouldExcludeTokenFromSubtitleAnnotations(token, {
|
||||
pos1Exclusions,
|
||||
@@ -690,18 +695,13 @@ export function annotateTokens(
|
||||
pos1Exclusions,
|
||||
pos2Exclusions,
|
||||
});
|
||||
nPlusOneKnownStatuses[index] = false;
|
||||
return {
|
||||
...strippedToken,
|
||||
isKnown: false,
|
||||
isKnown: knownWordsEnabled ? isKnownForMatching : false,
|
||||
};
|
||||
}
|
||||
|
||||
const prioritizedNameMatch = nameMatchEnabled && token.isNameMatch === true;
|
||||
const isKnownForMatching = shouldComputeKnownStatus
|
||||
? computeTokenKnownStatus(token, deps.isKnownWord, deps.knownWordMatchMode)
|
||||
: false;
|
||||
nPlusOneKnownStatuses[index] = isKnownForMatching;
|
||||
|
||||
const frequencyRank =
|
||||
frequencyEnabled && !prioritizedNameMatch
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
shouldHandleLaunchMpvAtEntry,
|
||||
shouldHandleStatsDaemonCommandAtEntry,
|
||||
hasTransportedStartupArgs,
|
||||
shouldForwardStartupArgvViaAppControl,
|
||||
applyEarlyLinuxCommandLineSwitches,
|
||||
resolveLinuxPasswordStoreValue,
|
||||
} from './main-entry-runtime';
|
||||
|
||||
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
|
||||
@@ -106,6 +109,74 @@ test('hasTransportedStartupArgs detects env-carried app args', () => {
|
||||
assert.equal(hasTransportedStartupArgs({}), false);
|
||||
});
|
||||
|
||||
test('resolveLinuxPasswordStoreValue defaults Linux safeStorage to gnome-libsecret', () => {
|
||||
assert.equal(resolveLinuxPasswordStoreValue(['SubMiner.AppImage'], 'linux'), 'gnome-libsecret');
|
||||
assert.equal(
|
||||
resolveLinuxPasswordStoreValue(['SubMiner.AppImage', '--password-store', 'gnome'], 'linux'),
|
||||
'gnome-libsecret',
|
||||
);
|
||||
assert.equal(resolveLinuxPasswordStoreValue(['SubMiner.exe'], 'win32'), null);
|
||||
});
|
||||
|
||||
test('resolveLinuxPasswordStoreValue keeps scanning after a bare password-store flag', () => {
|
||||
assert.equal(
|
||||
resolveLinuxPasswordStoreValue(
|
||||
['SubMiner.AppImage', '--password-store', '--start', '--password-store=kwallet6'],
|
||||
'linux',
|
||||
),
|
||||
'kwallet6',
|
||||
);
|
||||
});
|
||||
|
||||
test('applyEarlyLinuxCommandLineSwitches appends password store before main startup', () => {
|
||||
const switches: Array<[string, string | undefined]> = [];
|
||||
applyEarlyLinuxCommandLineSwitches(
|
||||
{
|
||||
appendSwitch: (name, value) => {
|
||||
switches.push([name, value]);
|
||||
},
|
||||
},
|
||||
['SubMiner.AppImage', '--password-store=kwallet6'],
|
||||
'linux',
|
||||
);
|
||||
|
||||
assert.deepEqual(switches, [
|
||||
['enable-features', 'GlobalShortcutsPortal'],
|
||||
['password-store', 'kwallet6'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('transported AppImage visibility commands should forward through app control', () => {
|
||||
assert.equal(
|
||||
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {
|
||||
SUBMINER_APP_ARGC: '1',
|
||||
SUBMINER_APP_ARG_0: '--hide-visible-overlay',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('app control forwarding is only for transported runtime commands', () => {
|
||||
assert.equal(
|
||||
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--app-ping'], {
|
||||
SUBMINER_APP_ARGC: '1',
|
||||
SUBMINER_APP_ARG_0: '--app-ping',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--launch-mpv'], {
|
||||
SUBMINER_APP_ARGC: '1',
|
||||
SUBMINER_APP_ARG_0: '--launch-mpv',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
|
||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true);
|
||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { CliArgs, parseArgs, shouldStartApp } from './cli/args';
|
||||
import { CliArgs, hasExplicitCommand, parseArgs, shouldStartApp } from './cli/args';
|
||||
import { resolveConfigDir } from './config/path-resolution';
|
||||
|
||||
const BACKGROUND_ARG = '--background';
|
||||
const START_ARG = '--start';
|
||||
const PASSWORD_STORE_ARG = '--password-store';
|
||||
const DEFAULT_LINUX_PASSWORD_STORE = 'gnome-libsecret';
|
||||
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
|
||||
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
|
||||
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
||||
@@ -34,6 +35,10 @@ type EarlyAppLike = {
|
||||
setPath: (name: 'userData', value: string) => void;
|
||||
};
|
||||
|
||||
type CommandLineLike = {
|
||||
appendSwitch: (name: string, value?: string) => void;
|
||||
};
|
||||
|
||||
type EarlyAppPathOptions = {
|
||||
platform?: NodeJS.Platform;
|
||||
appDataDir?: string;
|
||||
@@ -73,6 +78,60 @@ function removePassiveStartupArgs(argv: string[]): string[] {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function getPasswordStoreArg(argv: string[]): string | null {
|
||||
let resolved: string | null = null;
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (!arg?.startsWith(PASSWORD_STORE_ARG)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === PASSWORD_STORE_ARG) {
|
||||
const value = argv[i + 1];
|
||||
if (value && !value.startsWith('--')) {
|
||||
resolved = value.trim();
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const [prefix, value] = arg.split('=', 2);
|
||||
if (prefix === PASSWORD_STORE_ARG && value && value.trim().length > 0) {
|
||||
resolved = value.trim();
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function normalizePasswordStoreArg(value: string): string {
|
||||
const normalized = value.trim();
|
||||
if (normalized.toLowerCase() === 'gnome') {
|
||||
return DEFAULT_LINUX_PASSWORD_STORE;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveLinuxPasswordStoreValue(
|
||||
argv: string[],
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string | null {
|
||||
if (platform !== 'linux') return null;
|
||||
return normalizePasswordStoreArg(getPasswordStoreArg(argv) ?? DEFAULT_LINUX_PASSWORD_STORE);
|
||||
}
|
||||
|
||||
export function applyEarlyLinuxCommandLineSwitches(
|
||||
commandLine: CommandLineLike,
|
||||
argv: string[],
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): void {
|
||||
if (platform !== 'linux') return;
|
||||
commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
|
||||
commandLine.appendSwitch(
|
||||
'password-store',
|
||||
resolveLinuxPasswordStoreValue(argv, platform) ?? DEFAULT_LINUX_PASSWORD_STORE,
|
||||
);
|
||||
}
|
||||
|
||||
function consumesLaunchMpvValue(token: string): boolean {
|
||||
return (
|
||||
token.startsWith('--') &&
|
||||
@@ -90,6 +149,20 @@ export function hasTransportedStartupArgs(env: NodeJS.ProcessEnv): boolean {
|
||||
return typeof env[TRANSPORTED_APP_ARGC_ENV] === 'string';
|
||||
}
|
||||
|
||||
export function shouldForwardStartupArgvViaAppControl(
|
||||
argv: string[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
): boolean {
|
||||
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
|
||||
if (!hasTransportedStartupArgs(env)) return false;
|
||||
|
||||
const args = parseCliArgs(argv);
|
||||
if (args.help || args.appPing || args.launchMpv) return false;
|
||||
if (resolveStatsDaemonCommandAction(argv) !== null) return false;
|
||||
|
||||
return hasExplicitCommand(args);
|
||||
}
|
||||
|
||||
function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null {
|
||||
const rawCount = env[TRANSPORTED_APP_ARGC_ENV];
|
||||
if (rawCount === undefined) {
|
||||
|
||||
+51
-12
@@ -9,17 +9,20 @@ import {
|
||||
normalizeLaunchMpvExtraArgs,
|
||||
normalizeLaunchMpvTargets,
|
||||
normalizeStartupArgv,
|
||||
applyEarlyLinuxCommandLineSwitches,
|
||||
sanitizeStartupEnv,
|
||||
sanitizeBackgroundEnv,
|
||||
sanitizeHelpEnv,
|
||||
sanitizeLaunchMpvEnv,
|
||||
hasTransportedStartupArgs,
|
||||
shouldForwardStartupArgvViaAppControl,
|
||||
shouldDetachBackgroundLaunch,
|
||||
shouldHandleHelpOnlyAtEntry,
|
||||
shouldHandleLaunchMpvAtEntry,
|
||||
shouldHandleStatsDaemonCommandAtEntry,
|
||||
} from './main-entry-runtime';
|
||||
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
||||
import { sendAppControlCommand } from './shared/app-control-client';
|
||||
import {
|
||||
detectInstalledFirstRunPluginCandidates,
|
||||
detectInstalledMpvPlugin,
|
||||
@@ -173,6 +176,7 @@ function readConfiguredWindowsMpvLaunch(configDir: string): {
|
||||
}
|
||||
|
||||
process.argv = normalizeStartupArgv(process.argv, process.env);
|
||||
applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv);
|
||||
applySanitizedEnv(sanitizeStartupEnv(process.env));
|
||||
const userDataPath = configureEarlyAppPaths(app);
|
||||
const reportFatalError = createFatalErrorReporter({
|
||||
@@ -184,6 +188,44 @@ registerFatalErrorHandlers({
|
||||
exit: (code) => app.exit(code),
|
||||
});
|
||||
|
||||
function startMainProcess(): void {
|
||||
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app);
|
||||
if (!gotSingleInstanceLock) {
|
||||
app.exit(0);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
require('./main.js');
|
||||
} catch (error) {
|
||||
reportFatalError(error, {
|
||||
title: 'SubMiner startup failed',
|
||||
context: 'SubMiner failed while loading the main process.',
|
||||
});
|
||||
app.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function forwardStartupArgvViaAppControlIfAvailable(): Promise<boolean> {
|
||||
if (!shouldForwardStartupArgvViaAppControl(process.argv, process.env)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await sendAppControlCommand(process.argv, {
|
||||
configDir: userDataPath,
|
||||
timeoutMs: 500,
|
||||
});
|
||||
if (result.ok) {
|
||||
app.exit(0);
|
||||
return true;
|
||||
}
|
||||
if (!result.unavailable) {
|
||||
console.error(`SubMiner app-control handoff failed: ${result.error ?? 'unknown error'}`);
|
||||
app.exit(1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
|
||||
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
|
||||
const child = spawn(process.execPath, childArgs, {
|
||||
@@ -233,17 +275,14 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
||||
app.exit(exitCode);
|
||||
});
|
||||
} else {
|
||||
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app);
|
||||
if (!gotSingleInstanceLock) {
|
||||
app.exit(0);
|
||||
}
|
||||
try {
|
||||
require('./main.js');
|
||||
} catch (error) {
|
||||
reportFatalError(error, {
|
||||
title: 'SubMiner startup failed',
|
||||
context: 'SubMiner failed while loading the main process.',
|
||||
void forwardStartupArgvViaAppControlIfAvailable()
|
||||
.then((forwarded) => {
|
||||
if (!forwarded) {
|
||||
startMainProcess();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('SubMiner app-control handoff failed:', error);
|
||||
startMainProcess();
|
||||
});
|
||||
app.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+181
-21
@@ -35,6 +35,10 @@ import { applyControllerConfigUpdate } from './main/controller-config-update.js'
|
||||
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
|
||||
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
||||
import { startAppControlServer } from './main/runtime/app-control-server';
|
||||
import {
|
||||
markJellyfinRemotePlaybackLoaded as markJellyfinRemotePlaybackLoadedState,
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
|
||||
} from './main/runtime/jellyfin-remote-playback';
|
||||
import { getAppControlSocketPath } from './shared/app-control';
|
||||
import {
|
||||
type CancelLinuxMpvFullscreenOverlayRefreshBurst,
|
||||
@@ -44,6 +48,7 @@ import {
|
||||
import { mergeAiConfig } from './ai/config';
|
||||
|
||||
function getPasswordStoreArg(argv: string[]): string | null {
|
||||
let resolved: string | null = null;
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (!arg?.startsWith('--password-store')) {
|
||||
@@ -53,17 +58,18 @@ function getPasswordStoreArg(argv: string[]): string | null {
|
||||
if (arg === '--password-store') {
|
||||
const value = argv[i + 1];
|
||||
if (value && !value.startsWith('--')) {
|
||||
return value;
|
||||
resolved = value.trim();
|
||||
i += 1;
|
||||
}
|
||||
return null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const [prefix, value] = arg.split('=', 2);
|
||||
if (prefix === '--password-store' && value && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
resolved = value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function normalizePasswordStoreArg(value: string): string {
|
||||
@@ -319,6 +325,7 @@ import {
|
||||
listJellyfinItemsRuntime,
|
||||
listJellyfinLibrariesRuntime,
|
||||
listJellyfinSubtitleTracksRuntime,
|
||||
loadJellyfinSubtitleDelay,
|
||||
loadSubtitlePosition as loadSubtitlePositionCore,
|
||||
loadYomitanExtension as loadYomitanExtensionCore,
|
||||
markLastCardAsAudioCard as markLastCardAsAudioCardCore,
|
||||
@@ -329,6 +336,7 @@ import {
|
||||
replayCurrentSubtitleRuntime,
|
||||
resolveJellyfinPlaybackPlanRuntime,
|
||||
runStartupBootstrapRuntime,
|
||||
saveJellyfinSubtitleDelay,
|
||||
saveSubtitlePosition as saveSubtitlePositionCore,
|
||||
addYomitanNoteViaSearch,
|
||||
clearYomitanParserCachesForWindow,
|
||||
@@ -356,6 +364,7 @@ import {
|
||||
promoteStatsOverlayAbovePlayback,
|
||||
registerStatsOverlayToggle,
|
||||
toggleStatsOverlay as toggleStatsOverlayWindow,
|
||||
withStatsWindowLayerSuspendedForNativeDialog,
|
||||
} from './core/services/stats-window.js';
|
||||
import {
|
||||
createFirstRunSetupService,
|
||||
@@ -403,6 +412,11 @@ import {
|
||||
launchWindowsMpv,
|
||||
} from './main/runtime/windows-mpv-launch';
|
||||
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
|
||||
import {
|
||||
DEFAULT_JELLYFIN_CLIENT_NAME,
|
||||
DEFAULT_JELLYFIN_CLIENT_VERSION,
|
||||
createHostDerivedJellyfinDeviceId,
|
||||
} from './main/runtime/jellyfin-device-identity';
|
||||
import {
|
||||
clearJellyfinAuthSessionAndRefreshTray as clearJellyfinAuthSessionAndRefreshTrayRuntime,
|
||||
isJellyfinConfiguredForTray as isJellyfinConfiguredForTrayRuntime,
|
||||
@@ -508,6 +522,7 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac
|
||||
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
||||
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
||||
import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot';
|
||||
import { createJellyfinSubtitleCacheIo } from './main/runtime/jellyfin-subtitle-cache-io';
|
||||
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
|
||||
import {
|
||||
createElectronAppUpdater,
|
||||
@@ -619,6 +634,15 @@ const DEFAULT_MPV_LOG_FILE = resolveDefaultLogFilePath({
|
||||
appDataDir: process.env.APPDATA,
|
||||
});
|
||||
const ANILIST_SETUP_CLIENT_ID_URL = 'https://anilist.co/api/v2/oauth/authorize';
|
||||
const jellyfinSubtitleCacheIo = createJellyfinSubtitleCacheIo({
|
||||
tmpDir: () => os.tmpdir(),
|
||||
makeTempDir: (prefix) => fs.promises.mkdtemp(prefix),
|
||||
writeFile: (filePath, bytes) => fs.promises.writeFile(filePath, bytes),
|
||||
removeDir: (dir, options) => {
|
||||
fs.rmSync(dir, options);
|
||||
},
|
||||
fetch: (url) => fetch(url),
|
||||
});
|
||||
const ANILIST_SETUP_RESPONSE_TYPE = 'token';
|
||||
const ANILIST_DEFAULT_CLIENT_ID = '36084';
|
||||
const ANILIST_REDIRECT_URI = 'https://anilist.subminer.moe/';
|
||||
@@ -639,6 +663,7 @@ let jellyfinPlayQuitOnDisconnectArmed = false;
|
||||
const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US';
|
||||
const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
|
||||
const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000;
|
||||
const JELLYFIN_REMOTE_STARTUP_STOP_GRACE_MS = 10_000;
|
||||
const DISCORD_PRESENCE_APP_ID = '1475264834730856619';
|
||||
const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000;
|
||||
const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000;
|
||||
@@ -647,16 +672,18 @@ const YOUTUBE_MPV_AUTO_LAUNCH_TIMEOUT_MS = 10000;
|
||||
const YOUTUBE_MPV_YTDL_FORMAT = 'bestvideo*+bestaudio/best';
|
||||
const YOUTUBE_DIRECT_PLAYBACK_FORMAT = 'b';
|
||||
const MPV_JELLYFIN_DEFAULT_ARGS = [
|
||||
'--sub-auto=fuzzy',
|
||||
'--sub-auto=no',
|
||||
'--sub-file-paths=.;subs;subtitles',
|
||||
'--sid=auto',
|
||||
'--secondary-sid=auto',
|
||||
'--sid=no',
|
||||
'--secondary-sid=no',
|
||||
'--sub-visibility=no',
|
||||
'--secondary-sub-visibility=no',
|
||||
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||
] as const;
|
||||
|
||||
let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null;
|
||||
let activeJellyfinSubtitleDelayKey: { itemId: string; streamIndex: number } | null = null;
|
||||
let jellyfinRemoteLastProgressAtMs = 0;
|
||||
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
|
||||
let backgroundWarmupsStarted = false;
|
||||
@@ -1803,12 +1830,13 @@ async function refreshSubtitleSidebarFromSource(
|
||||
if (!normalizedSourcePath) {
|
||||
return;
|
||||
}
|
||||
appState.activeParsedSubtitleMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath();
|
||||
const nextMediaPath = mediaPath?.trim() || getCurrentAutoplayMediaPath();
|
||||
await subtitlePrefetchInitController.initSubtitlePrefetch(
|
||||
normalizedSourcePath,
|
||||
lastObservedTimePos,
|
||||
normalizedSourcePath,
|
||||
);
|
||||
appState.activeParsedSubtitleMediaPath = nextMediaPath;
|
||||
}
|
||||
const refreshSubtitlePrefetchFromActiveTrackHandler =
|
||||
createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||
@@ -2115,6 +2143,7 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHos
|
||||
const createFieldGroupingCallback = fieldGroupingOverlayRuntime.createFieldGroupingCallback;
|
||||
|
||||
const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, 'subtitle-positions');
|
||||
const JELLYFIN_SUBTITLE_DELAYS_PATH = path.join(CONFIG_DIR, 'jellyfin-subtitle-delays.json');
|
||||
|
||||
const mediaRuntime = createMediaRuntimeService(
|
||||
createBuildMediaRuntimeMainDepsHandler({
|
||||
@@ -2280,6 +2309,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
|
||||
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
|
||||
getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(),
|
||||
getMacOSForegroundProbeActive: () => macOSVisibleOverlayForegroundProbeActive,
|
||||
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
appState.trackerNotReadyWarningShown = shown;
|
||||
@@ -2323,6 +2353,7 @@ const VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
|
||||
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
|
||||
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
|
||||
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
|
||||
const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200;
|
||||
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||
@@ -2331,6 +2362,9 @@ let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval>
|
||||
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
||||
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
||||
let visibleOverlayInteractionActive = false;
|
||||
let macOSVisibleOverlayForegroundProbeActive = false;
|
||||
let macOSVisibleOverlayForegroundProbeToken = 0;
|
||||
let macOSVisibleOverlayForegroundProbeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const handleStatsOverlayVisibilityChanged = createStatsOverlayVisibilityChangeHandler({
|
||||
setStatsOverlayVisibleState: (visible) => {
|
||||
@@ -2357,6 +2391,49 @@ function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
|
||||
windowsVisibleOverlayZOrderRetryTimeouts = [];
|
||||
}
|
||||
|
||||
function finishMacOSVisibleOverlayForegroundProbe(token: number): void {
|
||||
if (token !== macOSVisibleOverlayForegroundProbeToken) {
|
||||
return;
|
||||
}
|
||||
if (macOSVisibleOverlayForegroundProbeTimeout !== null) {
|
||||
clearTimeout(macOSVisibleOverlayForegroundProbeTimeout);
|
||||
macOSVisibleOverlayForegroundProbeTimeout = null;
|
||||
}
|
||||
if (!macOSVisibleOverlayForegroundProbeActive) {
|
||||
return;
|
||||
}
|
||||
macOSVisibleOverlayForegroundProbeActive = false;
|
||||
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||
}
|
||||
|
||||
function startMacOSVisibleOverlayForegroundProbe(): void {
|
||||
if (process.platform !== 'darwin') {
|
||||
return;
|
||||
}
|
||||
const tracker = appState.windowTracker;
|
||||
if (!tracker) {
|
||||
return;
|
||||
}
|
||||
|
||||
macOSVisibleOverlayForegroundProbeActive = true;
|
||||
const token = ++macOSVisibleOverlayForegroundProbeToken;
|
||||
if (macOSVisibleOverlayForegroundProbeTimeout !== null) {
|
||||
clearTimeout(macOSVisibleOverlayForegroundProbeTimeout);
|
||||
}
|
||||
macOSVisibleOverlayForegroundProbeTimeout = setTimeout(() => {
|
||||
finishMacOSVisibleOverlayForegroundProbe(token);
|
||||
}, MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS);
|
||||
|
||||
void tracker
|
||||
.refreshNow()
|
||||
.catch((error) => {
|
||||
logger.warn('Failed to refresh macOS frontmost app after overlay blur', error);
|
||||
})
|
||||
.finally(() => {
|
||||
finishMacOSVisibleOverlayForegroundProbe(token);
|
||||
});
|
||||
}
|
||||
|
||||
function getWindowsNativeWindowHandle(window: BrowserWindow): string {
|
||||
const handle = window.getNativeWindowHandle();
|
||||
return handle.length >= 8
|
||||
@@ -2555,6 +2632,7 @@ function scheduleVisibleOverlayBlurRefresh(): void {
|
||||
if (process.platform === 'win32') {
|
||||
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
|
||||
}
|
||||
startMacOSVisibleOverlayForegroundProbe();
|
||||
clearVisibleOverlayBlurRefreshTimeouts();
|
||||
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
|
||||
const refreshTimeout = setTimeout(() => {
|
||||
@@ -2801,6 +2879,7 @@ const {
|
||||
reportJellyfinRemoteStopped,
|
||||
startJellyfinRemoteSession,
|
||||
stopJellyfinRemoteSession,
|
||||
cleanupJellyfinSubtitleCache,
|
||||
runJellyfinCommand,
|
||||
openJellyfinSetupWindow,
|
||||
getJellyfinClientInfo,
|
||||
@@ -2812,7 +2891,9 @@ const {
|
||||
},
|
||||
getJellyfinClientInfoMainDeps: {
|
||||
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
|
||||
getDefaultJellyfinConfig: () => DEFAULT_CONFIG.jellyfin,
|
||||
getHostName: () => os.hostname(),
|
||||
defaultClientName: DEFAULT_JELLYFIN_CLIENT_NAME,
|
||||
defaultClientVersion: DEFAULT_JELLYFIN_CLIENT_VERSION,
|
||||
},
|
||||
waitForMpvConnectedMainDeps: {
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
@@ -2824,6 +2905,15 @@ const {
|
||||
getLaunchMode: () => getResolvedConfig().mpv.launchMode,
|
||||
platform: process.platform,
|
||||
execPath: process.execPath,
|
||||
getRuntimePluginEntrypoint: () => resolveBundledMpvRuntimePluginEntrypoint(),
|
||||
getInstalledPluginDetection: () =>
|
||||
detectInstalledMpvPlugin({
|
||||
platform: process.platform,
|
||||
homeDir: os.homedir(),
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
appDataDir: app.getPath('appData'),
|
||||
mpvExecutablePath: getResolvedConfig().mpv.executablePath,
|
||||
}),
|
||||
getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(),
|
||||
defaultMpvLogPath: DEFAULT_MPV_LOG_PATH,
|
||||
defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS,
|
||||
@@ -2859,6 +2949,25 @@ const {
|
||||
sendMpvCommandRuntime(appState.mpvClient, command);
|
||||
},
|
||||
wait: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
|
||||
cacheSubtitleTrack: (track) => jellyfinSubtitleCacheIo.cacheSubtitleTrack(track),
|
||||
cleanupCachedSubtitles: (dirs) => jellyfinSubtitleCacheIo.cleanupCachedSubtitles(dirs),
|
||||
getSavedSubtitleDelay: (itemId, streamIndex) =>
|
||||
loadJellyfinSubtitleDelay({
|
||||
filePath: JELLYFIN_SUBTITLE_DELAYS_PATH,
|
||||
itemId,
|
||||
streamIndex,
|
||||
}),
|
||||
setActiveSubtitleDelayKey: (key) => {
|
||||
activeJellyfinSubtitleDelayKey = key;
|
||||
},
|
||||
loadSubtitleSourceText,
|
||||
saveSubtitleDelay: (itemId, streamIndex, delaySeconds) =>
|
||||
saveJellyfinSubtitleDelay({
|
||||
filePath: JELLYFIN_SUBTITLE_DELAYS_PATH,
|
||||
itemId,
|
||||
streamIndex,
|
||||
delaySeconds,
|
||||
}),
|
||||
logDebug: (message, error) => {
|
||||
logger.debug(message, error);
|
||||
},
|
||||
@@ -2877,6 +2986,7 @@ const {
|
||||
},
|
||||
),
|
||||
applyJellyfinMpvDefaults: (mpvClient) => applyJellyfinMpvDefaults(mpvClient),
|
||||
showVisibleOverlay: () => setVisibleOverlayVisible(true),
|
||||
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
|
||||
armQuitOnDisconnect: () => {
|
||||
jellyfinPlayQuitOnDisconnectArmed = false;
|
||||
@@ -2889,7 +2999,11 @@ const {
|
||||
},
|
||||
convertTicksToSeconds: (ticks) => jellyfinTicksToSecondsRuntime(ticks),
|
||||
setActivePlayback: (state) => {
|
||||
activeJellyfinRemotePlayback = state as ActiveJellyfinRemotePlaybackState;
|
||||
activeJellyfinRemotePlayback = {
|
||||
...(state as ActiveJellyfinRemotePlaybackState),
|
||||
stopReportsAfterMs:
|
||||
state.stopReportsAfterMs ?? Date.now() + JELLYFIN_REMOTE_STARTUP_STOP_GRACE_MS,
|
||||
};
|
||||
},
|
||||
setLastProgressAtMs: (value) => {
|
||||
jellyfinRemoteLastProgressAtMs = value;
|
||||
@@ -2900,6 +3014,13 @@ const {
|
||||
showMpvOsd: (text) => {
|
||||
showMpvOsd(text);
|
||||
},
|
||||
updateCurrentMediaTitle: (title) => {
|
||||
mediaRuntime.updateCurrentMediaTitle(title);
|
||||
},
|
||||
recordJellyfinPlaybackMetadata: (metadata) => {
|
||||
ensureImmersionTrackerStarted();
|
||||
appState.immersionTracker?.recordJellyfinPlaybackMetadata(metadata);
|
||||
},
|
||||
},
|
||||
remoteComposerOptions: {
|
||||
getConfiguredSession: () => getConfiguredJellyfinSession(getResolvedJellyfinConfig()),
|
||||
@@ -2910,6 +3031,7 @@ const {
|
||||
getActivePlayback: () => activeJellyfinRemotePlayback,
|
||||
clearActivePlayback: () => {
|
||||
activeJellyfinRemotePlayback = null;
|
||||
activeJellyfinSubtitleDelayKey = null;
|
||||
},
|
||||
getSession: () => appState.jellyfinRemoteSession,
|
||||
getNow: () => Date.now(),
|
||||
@@ -2960,11 +3082,13 @@ const {
|
||||
appState.jellyfinRemoteSession = session as typeof appState.jellyfinRemoteSession;
|
||||
},
|
||||
createRemoteSessionService: (options) => new JellyfinRemoteSessionService(options),
|
||||
defaultDeviceId: DEFAULT_CONFIG.jellyfin.deviceId,
|
||||
defaultClientName: DEFAULT_CONFIG.jellyfin.clientName,
|
||||
defaultClientVersion: DEFAULT_CONFIG.jellyfin.clientVersion,
|
||||
getHostName: () => os.hostname(),
|
||||
defaultDeviceId: createHostDerivedJellyfinDeviceId(os.hostname()),
|
||||
defaultClientName: DEFAULT_JELLYFIN_CLIENT_NAME,
|
||||
defaultClientVersion: DEFAULT_JELLYFIN_CLIENT_VERSION,
|
||||
logInfo: (message) => logger.info(message),
|
||||
logWarn: (message, details) => logger.warn(message, details),
|
||||
onSessionStateChanged: () => refreshTrayMenuIfPresent(),
|
||||
},
|
||||
stopJellyfinRemoteSessionMainDeps: {
|
||||
getCurrentSession: () => appState.jellyfinRemoteSession,
|
||||
@@ -2974,6 +3098,7 @@ const {
|
||||
clearActivePlayback: () => {
|
||||
activeJellyfinRemotePlayback = null;
|
||||
},
|
||||
onSessionStateChanged: () => refreshTrayMenuIfPresent(),
|
||||
},
|
||||
runJellyfinCommandMainDeps: {
|
||||
defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl,
|
||||
@@ -2994,7 +3119,6 @@ const {
|
||||
clearStoredSession: () =>
|
||||
clearJellyfinAuthSessionAndRefreshTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
||||
patchJellyfinConfig: (session) => {
|
||||
const clientInfo = getJellyfinClientInfo();
|
||||
const recentServers = mergeJellyfinRecentServers(
|
||||
session.serverUrl,
|
||||
getResolvedConfig().jellyfin.recentServers || [],
|
||||
@@ -3004,9 +3128,6 @@ const {
|
||||
enabled: true,
|
||||
serverUrl: session.serverUrl,
|
||||
username: session.username,
|
||||
deviceId: clientInfo.deviceId,
|
||||
clientName: clientInfo.clientName,
|
||||
clientVersion: clientInfo.clientVersion,
|
||||
recentServers,
|
||||
},
|
||||
});
|
||||
@@ -3670,6 +3791,7 @@ const {
|
||||
},
|
||||
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
|
||||
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
|
||||
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
|
||||
stopDiscordPresenceService: () => {
|
||||
void appState.discordPresenceService?.stop();
|
||||
appState.discordPresenceService = null;
|
||||
@@ -4331,11 +4453,12 @@ const {
|
||||
appState.activeParsedSubtitleSource = null;
|
||||
appState.activeParsedSubtitleMediaPath = null;
|
||||
}
|
||||
activeJellyfinSubtitleDelayKey = null;
|
||||
broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload);
|
||||
subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
||||
annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
|
||||
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
|
||||
}
|
||||
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
|
||||
currentMediaTokenizationGate.updateCurrentMediaPath(path);
|
||||
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
|
||||
startupOsdSequencer.reset();
|
||||
@@ -4372,6 +4495,9 @@ const {
|
||||
immersionMediaRuntime.syncFromCurrentMediaState();
|
||||
},
|
||||
signalAutoplayReadyIfWarm: (path) => signalAutoplayReadyFromWarmTokenization?.(path),
|
||||
markJellyfinRemotePlaybackLoaded: (path) => {
|
||||
markJellyfinRemotePlaybackLoadedState(activeJellyfinRemotePlayback, path);
|
||||
},
|
||||
scheduleCharacterDictionarySync: () => {
|
||||
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) {
|
||||
return;
|
||||
@@ -4435,6 +4561,8 @@ const {
|
||||
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
|
||||
appState.reconnectTimer = timer;
|
||||
},
|
||||
shouldAutoLoadSecondarySubTrack: (path: string) =>
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(activeJellyfinRemotePlayback, path),
|
||||
shouldQuitOnMpvShutdown: () =>
|
||||
shouldQuitOnMpvShutdownForTrayState({
|
||||
managedPlayback: appState.initialArgs?.managedPlayback === true,
|
||||
@@ -5088,6 +5216,8 @@ function getUpdateService() {
|
||||
});
|
||||
app.focus({ steal: true });
|
||||
},
|
||||
withStatsWindowLayerSuspended: (showDialog) =>
|
||||
withStatsWindowLayerSuspendedForNativeDialog(showDialog),
|
||||
showMessageBox: (options) => dialog.showMessageBox(options),
|
||||
});
|
||||
updateService = createUpdateService({
|
||||
@@ -5418,6 +5548,19 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
loadSubtitleSourceText,
|
||||
sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command),
|
||||
onSubtitleDelayShifted: (delaySeconds) => {
|
||||
const key = activeJellyfinSubtitleDelayKey;
|
||||
if (!key) return;
|
||||
const saved = saveJellyfinSubtitleDelay({
|
||||
filePath: JELLYFIN_SUBTITLE_DELAYS_PATH,
|
||||
itemId: key.itemId,
|
||||
streamIndex: key.streamIndex,
|
||||
delaySeconds,
|
||||
});
|
||||
if (!saved) {
|
||||
logger.warn('Failed to save Jellyfin subtitle delay.');
|
||||
}
|
||||
},
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
});
|
||||
|
||||
@@ -6062,6 +6205,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
},
|
||||
buildTrayMenuTemplateDeps: {
|
||||
buildTrayMenuTemplateRuntime,
|
||||
platform: process.platform,
|
||||
initializeOverlayRuntime: () => initializeOverlayRuntime(),
|
||||
isOverlayRuntimeInitialized: () => appState.overlayRuntimeInitialized,
|
||||
openSessionHelpModal: () => openSessionHelpOverlay(),
|
||||
@@ -6077,8 +6221,10 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
|
||||
isJellyfinConfigured: () =>
|
||||
isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
||||
isJellyfinDiscoveryActive: () => Boolean(appState.jellyfinRemoteSession),
|
||||
toggleJellyfinDiscovery: () =>
|
||||
toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps()),
|
||||
toggleJellyfinDiscovery: (checked: boolean) =>
|
||||
toggleJellyfinDiscoveryFromTrayRuntime(getJellyfinTrayDiscoveryDeps(), {
|
||||
desiredActive: checked,
|
||||
}),
|
||||
openAnilistSetupWindow: () => openAnilistSetupWindow(),
|
||||
checkForUpdates: () => {
|
||||
void getUpdateService().checkForUpdates({ source: 'manual' });
|
||||
@@ -6307,36 +6453,50 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function notifyMpvPluginVisibleOverlayVisibility(visible: boolean): void {
|
||||
sendMpvCommandRuntime(appState.mpvClient, [
|
||||
'script-message',
|
||||
visible ? 'subminer-visible-overlay-shown' : 'subminer-visible-overlay-hidden',
|
||||
]);
|
||||
}
|
||||
|
||||
function setVisibleOverlayVisible(visible: boolean): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
if (!visible) {
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
}
|
||||
if (visible) {
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
}
|
||||
setVisibleOverlayVisibleHandler(visible);
|
||||
notifyMpvPluginVisibleOverlayVisibility(visible);
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
function toggleVisibleOverlay(): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
if (overlayManager.getVisibleOverlayVisible()) {
|
||||
const nextVisible = !overlayManager.getVisibleOverlayVisible();
|
||||
if (!nextVisible) {
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
} else {
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
}
|
||||
toggleVisibleOverlayHandler();
|
||||
notifyMpvPluginVisibleOverlayVisibility(nextVisible);
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
function setOverlayVisible(visible: boolean): void {
|
||||
if (!visible) {
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
}
|
||||
if (visible) {
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
}
|
||||
setOverlayVisibleHandler(visible);
|
||||
notifyMpvPluginVisibleOverlayVisibility(visible);
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
function handleOverlayModalClosed(modal: OverlayHostedModal): void {
|
||||
|
||||
@@ -46,6 +46,65 @@ test('media path changes clear rendered subtitle state without clearing same-you
|
||||
);
|
||||
});
|
||||
|
||||
test('same media path updates do not reset autoplay ready fallback state', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)\n restoreMpvSubVisibility:/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(
|
||||
actionBlock,
|
||||
/annotationSubtitleWsService\.broadcast\(resetSubtitlePayload, frequencyOptions\);\s+autoplayReadyGate\.invalidatePendingAutoplayReadyFallbacks\(\);\s+\}\s+currentMediaTokenizationGate\.updateCurrentMediaPath\(path\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('manual visible overlay toggles only release current-media autoplay when hiding', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(
|
||||
actionBlock,
|
||||
/if \(!nextVisible\) \{\s+autoplayReadyGate\.markCurrentMediaAutoplayReady\(\);\s+cancelPendingLinuxMpvFullscreenOverlayRefreshBurst\(\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitle sidebar media path tag is assigned after prefetch succeeds', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
/async function refreshSubtitleSidebarFromSource\([\s\S]*?\): Promise<void> \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(actionBlock);
|
||||
assert.match(
|
||||
actionBlock,
|
||||
/const nextMediaPath = mediaPath\?\.trim\(\) \|\| getCurrentAutoplayMediaPath\(\);/,
|
||||
);
|
||||
assert.ok(
|
||||
actionBlock.indexOf('subtitlePrefetchInitController.initSubtitlePrefetch') <
|
||||
actionBlock.indexOf('appState.activeParsedSubtitleMediaPath = nextMediaPath;'),
|
||||
);
|
||||
});
|
||||
|
||||
test('manual visible overlay changes notify mpv plugin visibility state', () => {
|
||||
const source = readMainSource();
|
||||
const setBlock = source.match(
|
||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const toggleBlock = source.match(
|
||||
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(setBlock);
|
||||
assert.ok(toggleBlock);
|
||||
assert.match(setBlock, /notifyMpvPluginVisibleOverlayVisibility\(visible\);/);
|
||||
assert.match(toggleBlock, /const nextVisible = !overlayManager\.getVisibleOverlayVisible\(\);/);
|
||||
assert.match(toggleBlock, /notifyMpvPluginVisibleOverlayVisibility\(nextVisible\);/);
|
||||
});
|
||||
|
||||
test('main process uses one shared mpv plugin runtime config helper', () => {
|
||||
const source = readMainSource();
|
||||
assert.match(source, /function getMpvPluginRuntimeConfig\(\)/);
|
||||
|
||||
@@ -9,6 +9,7 @@ type MockWindow = {
|
||||
ignoreMouseEvents: boolean;
|
||||
forwardedIgnoreMouseEvents: boolean;
|
||||
webContentsFocused: boolean;
|
||||
alwaysOnTopCalls: string[];
|
||||
showCount: number;
|
||||
hideCount: number;
|
||||
sent: unknown[][];
|
||||
@@ -53,6 +54,7 @@ function createMockWindow(): MockWindow & {
|
||||
ignoreMouseEvents: false,
|
||||
forwardedIgnoreMouseEvents: false,
|
||||
webContentsFocused: false,
|
||||
alwaysOnTopCalls: [],
|
||||
showCount: 0,
|
||||
hideCount: 0,
|
||||
sent: [],
|
||||
@@ -72,7 +74,9 @@ function createMockWindow(): MockWindow & {
|
||||
state.ignoreMouseEvents = ignore;
|
||||
state.forwardedIgnoreMouseEvents = options?.forward === true;
|
||||
},
|
||||
setAlwaysOnTop: (_flag: boolean, _level?: string, _relativeLevel?: number) => {},
|
||||
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
|
||||
state.alwaysOnTopCalls.push(`top:${flag}:${level ?? ''}:${relativeLevel ?? ''}`);
|
||||
},
|
||||
moveTop: () => {},
|
||||
getShowCount: () => state.showCount,
|
||||
getHideCount: () => state.hideCount,
|
||||
@@ -155,6 +159,13 @@ function createMockWindow(): MockWindow & {
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'alwaysOnTopCalls', {
|
||||
get: () => state.alwaysOnTopCalls,
|
||||
set: (value: string[]) => {
|
||||
state.alwaysOnTopCalls = value;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'url', {
|
||||
get: () => state.url,
|
||||
set: (value: string) => {
|
||||
@@ -219,6 +230,7 @@ test('sendToActiveOverlayWindow targets modal window with full geometry and trac
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
assert.equal(window.getShowCount(), 1);
|
||||
assert.equal(window.isFocused(), true);
|
||||
assert.deepEqual(window.alwaysOnTopCalls, ['top:true:screen-saver:3']);
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
@@ -313,7 +325,7 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
|
||||
});
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'subsync:open-manual',
|
||||
{ sourceTracks: [] },
|
||||
{ ffsubsyncAvailable: true, sourceTracks: [] },
|
||||
{
|
||||
restoreOnModalClose: 'subsync',
|
||||
},
|
||||
@@ -459,7 +471,7 @@ test('modal runtime notifies callers when modal input state becomes active/inact
|
||||
});
|
||||
runtime.sendToActiveOverlayWindow(
|
||||
'subsync:open-manual',
|
||||
{ sourceTracks: [] },
|
||||
{ ffsubsyncAvailable: true, sourceTracks: [] },
|
||||
{
|
||||
restoreOnModalClose: 'subsync',
|
||||
},
|
||||
|
||||
@@ -138,7 +138,7 @@ export function createOverlayModalRuntimeService(
|
||||
|
||||
const elevateModalWindow = (window: BrowserWindow): void => {
|
||||
if (window.isDestroyed()) return;
|
||||
window.setAlwaysOnTop(true, 'screen-saver', 1);
|
||||
window.setAlwaysOnTop(true, 'screen-saver', 3);
|
||||
window.moveTop();
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface OverlayVisibilityRuntimeDeps {
|
||||
getLastKnownWindowsForegroundProcessName?: () => string | null;
|
||||
getWindowsOverlayProcessName?: () => string | null;
|
||||
getWindowsFocusHandoffGraceActive?: () => boolean;
|
||||
getMacOSForegroundProbeActive?: () => boolean;
|
||||
getTrackerNotReadyWarningShown: () => boolean;
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||
@@ -59,6 +60,7 @@ export function createOverlayVisibilityRuntimeService(
|
||||
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
|
||||
windowsOverlayProcessName: deps.getWindowsOverlayProcessName?.() ?? null,
|
||||
windowsFocusHandoffGraceActive: deps.getWindowsFocusHandoffGraceActive?.() ?? false,
|
||||
macOSForegroundProbeActive: deps.getMacOSForegroundProbeActive?.() ?? false,
|
||||
trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(),
|
||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||
deps.setTrackerNotReadyWarningShown(shown);
|
||||
|
||||
@@ -41,19 +41,65 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 31);
|
||||
assert.equal(calls.length, 32);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
|
||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||
});
|
||||
|
||||
test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', () => {
|
||||
const calls: string[] = [];
|
||||
const cleanup = createOnWillQuitCleanupHandler({
|
||||
destroyTray: () => {},
|
||||
stopConfigHotReload: () => {},
|
||||
restorePreviousSecondarySubVisibility: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
unregisterAllGlobalShortcuts: () => {},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
|
||||
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
|
||||
destroyMainOverlayWindow: () => {},
|
||||
destroyModalOverlayWindow: () => {},
|
||||
destroyYomitanParserWindow: () => {},
|
||||
clearYomitanParserState: () => {},
|
||||
stopWindowTracker: () => {},
|
||||
flushMpvLog: () => {},
|
||||
destroyMpvSocket: () => {},
|
||||
clearReconnectTimer: () => {},
|
||||
destroySubtitleTimingTracker: () => {},
|
||||
destroyImmersionTracker: () => {},
|
||||
destroyAnkiIntegration: () => {},
|
||||
destroyAnilistSetupWindow: () => {},
|
||||
clearAnilistSetupWindow: () => {},
|
||||
destroyJellyfinSetupWindow: () => {},
|
||||
clearJellyfinSetupWindow: () => {},
|
||||
destroyFirstRunSetupWindow: () => {},
|
||||
clearFirstRunSetupWindow: () => {},
|
||||
destroyYomitanSettingsWindow: () => {},
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {
|
||||
calls.push('stop-jellyfin-remote');
|
||||
throw new Error('stop failed');
|
||||
},
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
assert.throws(() => cleanup(), /stop failed/);
|
||||
assert.deepEqual(calls, ['stop-jellyfin-remote', 'cleanup-jellyfin-subtitles']);
|
||||
});
|
||||
|
||||
test('should restore windows on activate requires initialized runtime and no windows', () => {
|
||||
let initialized = false;
|
||||
let windowCount = 1;
|
||||
|
||||
@@ -29,6 +29,7 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
clearYomitanSettingsWindow: () => void;
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
@@ -60,7 +61,11 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.clearFirstRunSetupWindow();
|
||||
deps.destroyYomitanSettingsWindow();
|
||||
deps.clearYomitanSettingsWindow();
|
||||
deps.stopJellyfinRemoteSession();
|
||||
try {
|
||||
deps.stopJellyfinRemoteSession();
|
||||
} finally {
|
||||
deps.cleanupJellyfinSubtitleCache();
|
||||
}
|
||||
deps.cleanupYoutubeSubtitleTempDirs();
|
||||
deps.stopDiscordPresenceService();
|
||||
};
|
||||
|
||||
@@ -70,6 +70,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
|
||||
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
|
||||
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
|
||||
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
|
||||
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
|
||||
});
|
||||
|
||||
@@ -91,6 +92,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
assert.ok(calls.includes('destroy-yomitan-settings-window'));
|
||||
assert.ok(calls.includes('stop-jellyfin-remote'));
|
||||
assert.ok(calls.includes('cleanup-youtube-subtitles'));
|
||||
assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
|
||||
assert.ok(calls.includes('stop-discord-presence'));
|
||||
assert.ok(calls.includes('clear-windows-visible-overlay-foreground-poll-loop'));
|
||||
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
|
||||
@@ -145,6 +147,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
@@ -194,6 +197,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
});
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
|
||||
stopJellyfinRemoteSession: () => void;
|
||||
cleanupYoutubeSubtitleTempDirs: () => void;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
stopDiscordPresenceService: () => void;
|
||||
}) {
|
||||
return () => ({
|
||||
@@ -141,6 +142,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
|
||||
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
|
||||
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
|
||||
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
|
||||
stopDiscordPresenceService: () => deps.stopDiscordPresenceService(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ test('autoplay ready gate suppresses duplicate media signals for the same media'
|
||||
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||
),
|
||||
);
|
||||
assert.equal(scheduled.length > 0, true);
|
||||
});
|
||||
|
||||
test('autoplay ready gate retry loop does not re-signal plugin readiness', async () => {
|
||||
@@ -144,6 +143,86 @@ test('autoplay ready gate does not unpause again after a later manual pause on t
|
||||
);
|
||||
});
|
||||
|
||||
test('autoplay ready gate cancels release retries after playback is paused again', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
let playbackPaused = true;
|
||||
|
||||
const gate = createAutoplayReadyGate({
|
||||
isAppOwnedFlowInFlight: () => false,
|
||||
getCurrentMediaPath: () => '/media/video.mkv',
|
||||
getCurrentVideoPath: () => null,
|
||||
getPlaybackPaused: () => playbackPaused,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async () => playbackPaused,
|
||||
send: ({ command }: { command: Array<string | boolean> }) => {
|
||||
commands.push(command);
|
||||
if (command[0] === 'set_property' && command[1] === 'pause' && command[2] === false) {
|
||||
playbackPaused = false;
|
||||
}
|
||||
},
|
||||
}) as never,
|
||||
signalPluginAutoplayReady: () => {
|
||||
commands.push(['script-message', 'subminer-autoplay-ready']);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
scheduled.push(callback);
|
||||
return 1 as never;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
playbackPaused = true;
|
||||
const retry = scheduled.shift();
|
||||
retry?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(
|
||||
commands.filter(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('autoplay ready gate suppresses release after manual current-media dismissal', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
|
||||
const gate = createAutoplayReadyGate({
|
||||
isAppOwnedFlowInFlight: () => false,
|
||||
getCurrentMediaPath: () => '/media/video.mkv',
|
||||
getCurrentVideoPath: () => null,
|
||||
getPlaybackPaused: () => true,
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async () => true,
|
||||
send: ({ command }: { command: Array<string | boolean> }) => {
|
||||
commands.push(command);
|
||||
},
|
||||
}) as never,
|
||||
signalPluginAutoplayReady: () => {
|
||||
commands.push(['script-message', 'subminer-autoplay-ready']);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
queueMicrotask(callback);
|
||||
return 1 as never;
|
||||
},
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
gate.markCurrentMediaAutoplayReady();
|
||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(commands, []);
|
||||
});
|
||||
|
||||
test('autoplay ready gate defers plugin readiness until the signal target is ready', async () => {
|
||||
const commands: Array<Array<string | boolean>> = [];
|
||||
let targetReady = false;
|
||||
|
||||
@@ -39,6 +39,12 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
const getSignalMediaPath = (): string =>
|
||||
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
|
||||
|
||||
const markCurrentMediaAutoplayReady = (): void => {
|
||||
pendingAutoplayReadySignal = null;
|
||||
autoPlayReadySignalMediaPath = getSignalMediaPath();
|
||||
autoPlayReadySignalGeneration += 1;
|
||||
};
|
||||
|
||||
const maybeSignalPluginAutoplayReady = (
|
||||
payload: SubtitleData,
|
||||
options?: { forceWhilePaused?: boolean },
|
||||
@@ -58,6 +64,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
forceWhilePaused: options?.forceWhilePaused === true,
|
||||
retryDelayMs: releaseRetryDelayMs,
|
||||
});
|
||||
let releaseUnpauseSent = false;
|
||||
|
||||
const isPlaybackPaused = async (client: MpvClientLike): Promise<boolean> => {
|
||||
try {
|
||||
@@ -102,12 +109,20 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (releaseUnpauseSent && deps.getPlaybackPaused() === true) {
|
||||
deps.logDebug(
|
||||
`[autoplay-ready] stopped release retries after playback paused again for media ${mediaPath}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldUnpause = await isPlaybackPaused(mpvClient);
|
||||
if (!shouldUnpause) {
|
||||
return;
|
||||
}
|
||||
|
||||
mpvClient.send({ command: ['set_property', 'pause', false] });
|
||||
releaseUnpauseSent = true;
|
||||
if (attempt < maxReleaseAttempts) {
|
||||
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
|
||||
}
|
||||
@@ -153,6 +168,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
||||
flushPendingAutoplayReadySignal,
|
||||
getAutoPlayReadySignalMediaPath: (): string | null => autoPlayReadySignalMediaPath,
|
||||
invalidatePendingAutoplayReadyFallbacks,
|
||||
markCurrentMediaAutoplayReady,
|
||||
maybeSignalPluginAutoplayReady,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,6 +87,9 @@ export function composeJellyfinRemoteHandlers(
|
||||
getActivePlayback: options.getActivePlayback,
|
||||
clearActivePlayback: options.clearActivePlayback,
|
||||
getSession: options.getSession,
|
||||
getMpvClient: options.getMpvClient,
|
||||
getNow: options.getNow,
|
||||
ticksPerSecond: options.ticksPerSecond,
|
||||
logDebug: options.logDebug,
|
||||
});
|
||||
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler(
|
||||
@@ -101,6 +104,7 @@ export function composeJellyfinRemoteHandlers(
|
||||
getConfiguredSession: options.getConfiguredSession,
|
||||
getClientInfo: options.getClientInfo,
|
||||
getJellyfinConfig: options.getJellyfinConfig,
|
||||
getActivePlayback: options.getActivePlayback,
|
||||
playJellyfinItem: options.playJellyfinItem,
|
||||
logWarn: options.logWarn,
|
||||
});
|
||||
|
||||
@@ -13,11 +13,9 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
},
|
||||
getJellyfinClientInfoMainDeps: {
|
||||
getResolvedJellyfinConfig: () => ({}) as never,
|
||||
getDefaultJellyfinConfig: () => ({
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: 'test',
|
||||
deviceId: 'dev',
|
||||
}),
|
||||
getHostName: () => 'workstation',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: 'test',
|
||||
},
|
||||
waitForMpvConnectedMainDeps: {
|
||||
getMpvClient: () => null,
|
||||
@@ -50,6 +48,8 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
getMpvClient: () => null,
|
||||
sendMpvCommand: () => {},
|
||||
wait: async () => {},
|
||||
cacheSubtitleTrack: async () => ({ path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' }),
|
||||
cleanupCachedSubtitles: () => {},
|
||||
logDebug: () => {},
|
||||
},
|
||||
playJellyfinItemInMpvMainDeps: {
|
||||
@@ -58,11 +58,16 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
mode: 'direct',
|
||||
url: 'https://example.test/video.m3u8',
|
||||
title: 'Episode 1',
|
||||
itemTitle: 'Episode 1',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => {},
|
||||
sendMpvCommand: () => {},
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => undefined,
|
||||
@@ -133,6 +138,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
defaultDeviceId: 'dev',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: 'test',
|
||||
getHostName: () => 'workstation',
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
},
|
||||
@@ -189,6 +195,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function');
|
||||
assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function');
|
||||
assert.equal(typeof composed.playJellyfinItemInMpv, 'function');
|
||||
assert.equal(typeof composed.cleanupJellyfinSubtitleCache, 'function');
|
||||
assert.equal(typeof composed.startJellyfinRemoteSession, 'function');
|
||||
assert.equal(typeof composed.stopJellyfinRemoteSession, 'function');
|
||||
assert.equal(typeof composed.runJellyfinCommand, 'function');
|
||||
|
||||
@@ -100,7 +100,11 @@ export type JellyfinRuntimeComposerOptions = ComposerInputs<{
|
||||
>;
|
||||
startJellyfinRemoteSessionMainDeps: Omit<
|
||||
StartRemoteSessionMainDeps,
|
||||
'getJellyfinConfig' | 'handlePlay' | 'handlePlaystate' | 'handleGeneralCommand'
|
||||
| 'getJellyfinConfig'
|
||||
| 'getClientInfo'
|
||||
| 'handlePlay'
|
||||
| 'handlePlaystate'
|
||||
| 'handleGeneralCommand'
|
||||
>;
|
||||
stopJellyfinRemoteSessionMainDeps: Parameters<
|
||||
typeof createBuildStopJellyfinRemoteSessionMainDepsHandler
|
||||
@@ -142,6 +146,7 @@ export type JellyfinRuntimeComposerResult = ComposerOutputs<{
|
||||
typeof composeJellyfinRemoteHandlers
|
||||
>['handleJellyfinRemoteGeneralCommand'];
|
||||
playJellyfinItemInMpv: ReturnType<typeof createPlayJellyfinItemInMpvHandler>;
|
||||
cleanupJellyfinSubtitleCache: () => void;
|
||||
startJellyfinRemoteSession: ReturnType<typeof createStartJellyfinRemoteSessionHandler>;
|
||||
stopJellyfinRemoteSession: ReturnType<typeof createStopJellyfinRemoteSessionHandler>;
|
||||
runJellyfinCommand: ReturnType<typeof createRunJellyfinCommandHandler>;
|
||||
@@ -235,6 +240,7 @@ export function composeJellyfinRuntimeHandlers(
|
||||
createBuildStartJellyfinRemoteSessionMainDepsHandler({
|
||||
...options.startJellyfinRemoteSessionMainDeps,
|
||||
getJellyfinConfig: () => getResolvedJellyfinConfig(),
|
||||
getClientInfo: () => getJellyfinClientInfo(),
|
||||
handlePlay: (payload) => handleJellyfinRemotePlay(payload),
|
||||
handlePlaystate: (payload) => handleJellyfinRemotePlaystate(payload),
|
||||
handleGeneralCommand: (payload) => handleJellyfinRemoteGeneralCommand(payload),
|
||||
@@ -280,6 +286,7 @@ export function composeJellyfinRuntimeHandlers(
|
||||
handleJellyfinRemotePlaystate,
|
||||
handleJellyfinRemoteGeneralCommand,
|
||||
playJellyfinItemInMpv,
|
||||
cleanupJellyfinSubtitleCache: () => preloadJellyfinExternalSubtitles.cleanupCachedSubtitles(),
|
||||
startJellyfinRemoteSession,
|
||||
stopJellyfinRemoteSession,
|
||||
runJellyfinCommand,
|
||||
|
||||
@@ -49,6 +49,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
clearYomitanSettingsWindow: () => {},
|
||||
stopJellyfinRemoteSession: async () => {},
|
||||
cleanupYoutubeSubtitleTempDirs: () => {},
|
||||
cleanupJellyfinSubtitleCache: () => {},
|
||||
stopDiscordPresenceService: () => {},
|
||||
},
|
||||
shouldRestoreWindowsOnActivateMainDeps: {
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from '../jellyfin-client-info';
|
||||
export * from '../jellyfin-client-info-main-deps';
|
||||
export * from '../jellyfin-command-dispatch';
|
||||
export * from '../jellyfin-command-dispatch-main-deps';
|
||||
export * from '../jellyfin-device-identity';
|
||||
export * from '../jellyfin-playback-launch';
|
||||
export * from '../jellyfin-playback-launch-main-deps';
|
||||
export * from '../jellyfin-remote-commands';
|
||||
|
||||
@@ -89,16 +89,13 @@ test('jellyfin auth handler processes login', async () => {
|
||||
enabled: true,
|
||||
serverUrl: 'http://localhost',
|
||||
username: 'user',
|
||||
deviceId: 'd1',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
recentServers: ['http://localhost'],
|
||||
},
|
||||
});
|
||||
assert.ok(calls.some((entry) => entry.includes('Jellyfin login succeeded')));
|
||||
});
|
||||
|
||||
test('persistJellyfinAuthSession stores client metadata and recent servers', () => {
|
||||
test('persistJellyfinAuthSession stores session config and recent servers', () => {
|
||||
let patchPayload: unknown = null;
|
||||
let storedSession: unknown = null;
|
||||
|
||||
@@ -134,9 +131,6 @@ test('persistJellyfinAuthSession stores client metadata and recent servers', ()
|
||||
enabled: true,
|
||||
serverUrl: 'http://localhost:8096',
|
||||
username: 'alice',
|
||||
deviceId: 'device-1',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
recentServers: [
|
||||
'http://localhost:8096',
|
||||
'http://old.example:8096',
|
||||
@@ -146,6 +140,38 @@ test('persistJellyfinAuthSession stores client metadata and recent servers', ()
|
||||
});
|
||||
});
|
||||
|
||||
test('persistJellyfinAuthSession does not write generated local device id to config', () => {
|
||||
let patchPayload: unknown = null;
|
||||
|
||||
persistJellyfinAuthSession({
|
||||
session: {
|
||||
serverUrl: 'http://localhost:8096',
|
||||
username: 'alice',
|
||||
accessToken: 'token',
|
||||
userId: 'uid',
|
||||
},
|
||||
clientInfo: {
|
||||
deviceId: 'subminer-local-pc',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
},
|
||||
existingRecentServers: [],
|
||||
saveStoredSession: () => {},
|
||||
patchRawConfig: (patch) => {
|
||||
patchPayload = patch;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(patchPayload, {
|
||||
jellyfin: {
|
||||
enabled: true,
|
||||
serverUrl: 'http://localhost:8096',
|
||||
username: 'alice',
|
||||
recentServers: ['http://localhost:8096'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('jellyfin auth handler no-ops when no auth command', async () => {
|
||||
const handleAuth = createHandleJellyfinAuthCommands({
|
||||
patchRawConfig: () => {},
|
||||
|
||||
@@ -53,9 +53,6 @@ export function persistJellyfinAuthSession(deps: {
|
||||
enabled: boolean;
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
deviceId: string;
|
||||
clientName: string;
|
||||
clientVersion: string;
|
||||
recentServers: string[];
|
||||
}>;
|
||||
}) => void;
|
||||
@@ -69,9 +66,6 @@ export function persistJellyfinAuthSession(deps: {
|
||||
enabled: true,
|
||||
serverUrl: deps.session.serverUrl,
|
||||
username: deps.session.username,
|
||||
deviceId: deps.clientInfo.deviceId,
|
||||
clientName: deps.clientInfo.clientName,
|
||||
clientVersion: deps.clientInfo.clientVersion,
|
||||
recentServers: mergeJellyfinRecentServers(
|
||||
deps.session.serverUrl,
|
||||
deps.existingRecentServers || [],
|
||||
@@ -86,9 +80,6 @@ export function createHandleJellyfinAuthCommands(deps: {
|
||||
enabled: boolean;
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
deviceId: string;
|
||||
clientName: string;
|
||||
clientVersion: string;
|
||||
}>;
|
||||
}) => void;
|
||||
authenticateWithPassword: (
|
||||
|
||||
@@ -19,12 +19,15 @@ test('get resolved jellyfin config main deps builder maps callbacks', () => {
|
||||
|
||||
test('get jellyfin client info main deps builder maps callbacks', () => {
|
||||
const configured = { clientName: 'Configured' };
|
||||
const defaults = { clientName: 'Default' };
|
||||
const deps = createBuildGetJellyfinClientInfoMainDepsHandler({
|
||||
getResolvedJellyfinConfig: () => configured as never,
|
||||
getDefaultJellyfinConfig: () => defaults as never,
|
||||
getHostName: () => 'workstation',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0.0',
|
||||
})();
|
||||
|
||||
assert.equal(deps.getResolvedJellyfinConfig(), configured);
|
||||
assert.equal(deps.getDefaultJellyfinConfig(), defaults);
|
||||
assert.equal(deps.getHostName?.(), 'workstation');
|
||||
assert.equal(deps.defaultClientName, 'SubMiner');
|
||||
assert.equal(deps.defaultClientVersion, '1.0.0');
|
||||
});
|
||||
|
||||
@@ -23,6 +23,8 @@ export function createBuildGetJellyfinClientInfoMainDepsHandler(
|
||||
) {
|
||||
return (): GetJellyfinClientInfoMainDeps => ({
|
||||
getResolvedJellyfinConfig: () => deps.getResolvedJellyfinConfig(),
|
||||
getDefaultJellyfinConfig: () => deps.getDefaultJellyfinConfig(),
|
||||
getHostName: deps.getHostName ? () => deps.getHostName?.() || '' : undefined,
|
||||
defaultClientName: deps.defaultClientName,
|
||||
defaultClientVersion: deps.defaultClientVersion,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -80,23 +80,20 @@ test('get resolved jellyfin config uses stored user id when env token set withou
|
||||
|
||||
test('jellyfin client info resolves defaults when fields are missing', () => {
|
||||
const getClientInfo = createGetJellyfinClientInfoHandler({
|
||||
getResolvedJellyfinConfig: () => ({ clientName: '', clientVersion: '', deviceId: '' }) as never,
|
||||
getDefaultJellyfinConfig: () =>
|
||||
({
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0.0',
|
||||
deviceId: 'default-device',
|
||||
}) as never,
|
||||
getResolvedJellyfinConfig: () => ({ clientName: '' }) as never,
|
||||
getHostName: () => 'workstation',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0.0',
|
||||
});
|
||||
|
||||
assert.deepEqual(getClientInfo(), {
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0.0',
|
||||
deviceId: 'default-device',
|
||||
deviceId: 'workstation',
|
||||
});
|
||||
});
|
||||
|
||||
test('jellyfin client info keeps explicit config values', () => {
|
||||
test('jellyfin client info ignores legacy configured client name, device id, and version', () => {
|
||||
const getClientInfo = createGetJellyfinClientInfoHandler({
|
||||
getResolvedJellyfinConfig: () =>
|
||||
({
|
||||
@@ -104,17 +101,34 @@ test('jellyfin client info keeps explicit config values', () => {
|
||||
clientVersion: '2.3.4',
|
||||
deviceId: 'custom-device',
|
||||
}) as never,
|
||||
getDefaultJellyfinConfig: () =>
|
||||
({
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0.0',
|
||||
deviceId: 'default-device',
|
||||
}) as never,
|
||||
getHostName: () => 'Kyle-PC',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0.0',
|
||||
});
|
||||
|
||||
assert.deepEqual(getClientInfo(), {
|
||||
clientName: 'Custom',
|
||||
clientVersion: '2.3.4',
|
||||
deviceId: 'custom-device',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0.0',
|
||||
deviceId: 'Kyle-PC',
|
||||
});
|
||||
});
|
||||
|
||||
test('jellyfin client info ignores legacy configured device id and client version', () => {
|
||||
const getClientInfo = createGetJellyfinClientInfoHandler({
|
||||
getResolvedJellyfinConfig: () =>
|
||||
({
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '9.9.9',
|
||||
deviceId: 'custom-device',
|
||||
}) as never,
|
||||
getHostName: () => 'media-box',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0.0',
|
||||
});
|
||||
|
||||
assert.deepEqual(getClientInfo(), {
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0.0',
|
||||
deviceId: 'media-box',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { JellyfinStoredSession } from '../../core/services/jellyfin-token-store';
|
||||
import type { ResolvedConfig } from '../../types';
|
||||
import {
|
||||
DEFAULT_JELLYFIN_CLIENT_NAME,
|
||||
DEFAULT_JELLYFIN_CLIENT_VERSION,
|
||||
createHostDerivedJellyfinDeviceId,
|
||||
} from './jellyfin-device-identity';
|
||||
|
||||
type ResolvedJellyfinConfig = ResolvedConfig['jellyfin'];
|
||||
type ResolvedJellyfinConfigWithSession = ResolvedJellyfinConfig & {
|
||||
@@ -42,25 +47,22 @@ export function createGetResolvedJellyfinConfigHandler(deps: {
|
||||
}
|
||||
|
||||
export function createGetJellyfinClientInfoHandler(deps: {
|
||||
getResolvedJellyfinConfig: () => Partial<
|
||||
Pick<ResolvedJellyfinConfig, 'clientName' | 'clientVersion' | 'deviceId'>
|
||||
>;
|
||||
getDefaultJellyfinConfig: () => Partial<
|
||||
Pick<ResolvedJellyfinConfig, 'clientName' | 'clientVersion' | 'deviceId'>
|
||||
>;
|
||||
getResolvedJellyfinConfig: () => unknown;
|
||||
getHostName?: () => string;
|
||||
defaultClientName?: string;
|
||||
defaultClientVersion?: string;
|
||||
}) {
|
||||
return (
|
||||
config = deps.getResolvedJellyfinConfig(),
|
||||
_config = deps.getResolvedJellyfinConfig(),
|
||||
): {
|
||||
clientName: string;
|
||||
clientVersion: string;
|
||||
deviceId: string;
|
||||
} => {
|
||||
const defaults = deps.getDefaultJellyfinConfig();
|
||||
return {
|
||||
clientName: config.clientName || defaults.clientName || '',
|
||||
clientVersion: config.clientVersion || defaults.clientVersion || '',
|
||||
deviceId: config.deviceId || defaults.deviceId || '',
|
||||
clientName: deps.defaultClientName || DEFAULT_JELLYFIN_CLIENT_NAME,
|
||||
clientVersion: deps.defaultClientVersion || DEFAULT_JELLYFIN_CLIENT_VERSION,
|
||||
deviceId: createHostDerivedJellyfinDeviceId(deps.getHostName?.() || ''),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createHostDerivedJellyfinDeviceId,
|
||||
resolveJellyfinRemoteDeviceName,
|
||||
} from './jellyfin-device-identity';
|
||||
|
||||
test('createHostDerivedJellyfinDeviceId uses the hostname as the stable id', () => {
|
||||
assert.equal(createHostDerivedJellyfinDeviceId('Kyle-PC.local'), 'Kyle-PC.local');
|
||||
assert.equal(createHostDerivedJellyfinDeviceId(''), 'device');
|
||||
});
|
||||
|
||||
test('resolveJellyfinRemoteDeviceName uses hostname by default', () => {
|
||||
assert.equal(
|
||||
resolveJellyfinRemoteDeviceName({
|
||||
hostName: 'kyle-pc',
|
||||
}),
|
||||
'kyle-pc',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveJellyfinRemoteDeviceName falls back when hostname is empty', () => {
|
||||
assert.equal(resolveJellyfinRemoteDeviceName({ hostName: '' }), 'device');
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
export const DEFAULT_JELLYFIN_CLIENT_VERSION = '0.1.0';
|
||||
export const DEFAULT_JELLYFIN_CLIENT_NAME = 'SubMiner';
|
||||
|
||||
export function normalizeJellyfinHostName(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function createHostDerivedJellyfinDeviceId(hostName: string): string {
|
||||
return normalizeJellyfinHostName(hostName) || 'device';
|
||||
}
|
||||
|
||||
export function resolveJellyfinDeviceId(params: { hostName: string }): string {
|
||||
return createHostDerivedJellyfinDeviceId(params.hostName);
|
||||
}
|
||||
|
||||
export function resolveJellyfinRemoteDeviceName(params: { hostName: string }): string {
|
||||
return normalizeJellyfinHostName(params.hostName) || 'device';
|
||||
}
|
||||
@@ -11,16 +11,23 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => {
|
||||
url: 'u',
|
||||
mode: 'direct',
|
||||
title: 't',
|
||||
itemTitle: 't',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => calls.push('defaults'),
|
||||
showVisibleOverlay: () => calls.push('visible-overlay'),
|
||||
sendMpvCommand: (command) => calls.push(`cmd:${command[0]}`),
|
||||
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'),
|
||||
@@ -49,12 +56,17 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => {
|
||||
url: 'u',
|
||||
mode: 'direct',
|
||||
title: 't',
|
||||
itemTitle: 't',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
},
|
||||
);
|
||||
deps.applyJellyfinMpvDefaults({ connected: true, send: () => {} });
|
||||
deps.showVisibleOverlay();
|
||||
deps.sendMpvCommand(['show-text', 'x']);
|
||||
deps.armQuitOnDisconnect();
|
||||
deps.schedule(() => {}, 500);
|
||||
@@ -85,6 +97,7 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => {
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'defaults',
|
||||
'visible-overlay',
|
||||
'cmd:show-text',
|
||||
'arm',
|
||||
'schedule:500',
|
||||
|
||||
@@ -10,6 +10,7 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler(
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
resolvePlaybackPlan: (params) => deps.resolvePlaybackPlan(params),
|
||||
applyJellyfinMpvDefaults: (mpvClient) => deps.applyJellyfinMpvDefaults(mpvClient),
|
||||
showVisibleOverlay: () => deps.showVisibleOverlay(),
|
||||
sendMpvCommand: (command: Array<string | number>) => deps.sendMpvCommand(command),
|
||||
armQuitOnDisconnect: () => deps.armQuitOnDisconnect(),
|
||||
schedule: (callback: () => void, delayMs: number) => deps.schedule(callback, delayMs),
|
||||
@@ -19,5 +20,11 @@ export function createBuildPlayJellyfinItemInMpvMainDepsHandler(
|
||||
setLastProgressAtMs: (value: number) => deps.setLastProgressAtMs(value),
|
||||
reportPlaying: (payload) => deps.reportPlaying(payload),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
recordJellyfinPlaybackMetadata: deps.recordJellyfinPlaybackMetadata
|
||||
? (metadata) => deps.recordJellyfinPlaybackMetadata!(metadata)
|
||||
: undefined,
|
||||
updateCurrentMediaTitle: deps.updateCurrentMediaTitle
|
||||
? (title) => deps.updateCurrentMediaTitle!(title)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ test('playback handler throws when mpv is not connected', async () => {
|
||||
throw new Error('unreachable');
|
||||
},
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => {},
|
||||
sendMpvCommand: () => {},
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => {},
|
||||
@@ -52,6 +53,7 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
const calls: string[] = [];
|
||||
const activeStates: Array<Record<string, unknown>> = [];
|
||||
const reportPayloads: Array<Record<string, unknown>> = [];
|
||||
const statsMetadata: Array<Record<string, unknown>> = [];
|
||||
const handler = createPlayJellyfinItemInMpvHandler({
|
||||
ensureMpvConnectedForPlayback: async () => true,
|
||||
getMpvClient: () => ({ connected: true, send: () => {} }),
|
||||
@@ -59,22 +61,32 @@ test('playback handler drives mpv commands and playback state', async () => {
|
||||
url: 'https://stream.example/video.m3u8',
|
||||
mode: 'direct',
|
||||
title: 'Episode 1',
|
||||
itemTitle: 'Episode 1',
|
||||
seriesTitle: 'Show Title',
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
startTimeTicks: 12_000_000,
|
||||
audioStreamIndex: 1,
|
||||
subtitleStreamIndex: 2,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => calls.push('defaults'),
|
||||
showVisibleOverlay: () => calls.push('visible-overlay'),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
armQuitOnDisconnect: () => calls.push('arm'),
|
||||
schedule: (callback, delayMs) => {
|
||||
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>),
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
recordJellyfinPlaybackMetadata: (metadata) => {
|
||||
statsMetadata.push(metadata as Record<string, unknown>);
|
||||
},
|
||||
});
|
||||
|
||||
await handler({
|
||||
@@ -84,19 +96,34 @@ 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', '[Jellyfin/direct] 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, 1);
|
||||
assert.equal(scheduled[0]?.delay, 500);
|
||||
scheduled[0]?.callback();
|
||||
assert.deepEqual(commands[commands.length - 1], ['set_property', 'sid', 'no']);
|
||||
assert.equal(scheduled.length, 0);
|
||||
assert.equal(
|
||||
commands.filter((command) => command[0] === 'set_property' && command[1] === 'sid').length,
|
||||
1,
|
||||
);
|
||||
|
||||
assert.ok(calls.includes('defaults'));
|
||||
assert.ok(
|
||||
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'));
|
||||
@@ -104,8 +131,354 @@ 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);
|
||||
assert.equal(reportPayloads[0]?.isPaused, false);
|
||||
assert.deepEqual(statsMetadata, [
|
||||
{
|
||||
mediaPath: 'https://stream.example/video.m3u8',
|
||||
displayTitle: 'Episode 1',
|
||||
itemTitle: 'Episode 1',
|
||||
seriesTitle: 'Show Title',
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 1,
|
||||
itemId: 'item-1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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({
|
||||
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',
|
||||
mode: 'direct',
|
||||
title: 'Galaxy Quest S02E07 A New Hope',
|
||||
itemTitle: 'A New Hope',
|
||||
seriesTitle: 'Galaxy Quest',
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 7,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => {},
|
||||
sendMpvCommand: (command) => timeline.push(`cmd:${command[0]}:${String(command[1] ?? '')}`),
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => {},
|
||||
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
||||
preloadExternalSubtitles: () => {},
|
||||
setActivePlayback: () => {},
|
||||
setLastProgressAtMs: () => {},
|
||||
reportPlaying: () => {},
|
||||
showMpvOsd: () => {},
|
||||
updateCurrentMediaTitle: (title) => {
|
||||
timeline.push(`title:${title}`);
|
||||
},
|
||||
});
|
||||
|
||||
await handler({
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: {},
|
||||
itemId: 'ep-1',
|
||||
});
|
||||
|
||||
const titleIndex = timeline.indexOf('title:Galaxy Quest S02E07 A New Hope');
|
||||
const loadIndex = timeline.findIndex((entry) => entry.startsWith('cmd:loadfile:'));
|
||||
assert.ok(titleIndex >= 0);
|
||||
assert.ok(loadIndex >= 0);
|
||||
assert.ok(titleIndex < loadIndex);
|
||||
assert.equal(timeline[titleIndex]?.includes('api_key'), false);
|
||||
});
|
||||
|
||||
test('playback handler arms unloaded active playback before loading mpv media', async () => {
|
||||
const timeline: string[] = [];
|
||||
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) => timeline.push(`cmd:${command[0]}`),
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => {},
|
||||
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
|
||||
preloadExternalSubtitles: () => {},
|
||||
setActivePlayback: (state) => timeline.push(`active:${String(state.loadedMediaPath)}`),
|
||||
setLastProgressAtMs: () => {},
|
||||
reportPlaying: () => {},
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
await handler({
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: {},
|
||||
itemId: 'item-1',
|
||||
});
|
||||
|
||||
assert.ok(timeline.indexOf('active:null') >= 0);
|
||||
assert.ok(timeline.indexOf('active:null') < timeline.indexOf('cmd:loadfile'));
|
||||
});
|
||||
|
||||
test('playback handler applies start override to stream url for remote resume', async () => {
|
||||
@@ -117,11 +490,16 @@ test('playback handler applies start override to stream url for remote resume',
|
||||
url: 'https://stream.example/video.m3u8?api_key=token',
|
||||
mode: 'transcode',
|
||||
title: 'Episode 2',
|
||||
itemTitle: 'Episode 2',
|
||||
seriesTitle: null,
|
||||
seasonNumber: null,
|
||||
episodeNumber: null,
|
||||
startTimeTicks: 0,
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
}),
|
||||
applyJellyfinMpvDefaults: () => {},
|
||||
showVisibleOverlay: () => {},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
armQuitOnDisconnect: () => {},
|
||||
schedule: () => {},
|
||||
@@ -141,9 +519,226 @@ 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 () => {
|
||||
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 3',
|
||||
itemTitle: 'Episode 3',
|
||||
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: () => {},
|
||||
recordJellyfinPlaybackMetadata: () => {
|
||||
throw new Error('stats db unavailable');
|
||||
},
|
||||
});
|
||||
|
||||
await handler({
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: {},
|
||||
itemId: 'item-3',
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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 4',
|
||||
itemTitle: 'Episode 4',
|
||||
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: () => {},
|
||||
updateCurrentMediaTitle: () => {
|
||||
throw new Error('title state unavailable');
|
||||
},
|
||||
});
|
||||
|
||||
await handler({
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: {},
|
||||
itemId: 'item-4',
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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 5',
|
||||
itemTitle: 'Episode 5',
|
||||
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: () => {},
|
||||
updateCurrentMediaTitle: async () => {
|
||||
throw new Error('title async unavailable');
|
||||
},
|
||||
recordJellyfinPlaybackMetadata: async () => {
|
||||
throw new Error('stats async unavailable');
|
||||
},
|
||||
});
|
||||
|
||||
await handler({
|
||||
session: baseSession,
|
||||
clientInfo: baseClientInfo,
|
||||
jellyfinConfig: {},
|
||||
itemId: 'item-5',
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
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',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -14,8 +14,45 @@ type ActivePlaybackState = {
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
loadedMediaPath?: string | null;
|
||||
stopReportsAfterMs?: number;
|
||||
lastKnownPositionSeconds?: number;
|
||||
};
|
||||
|
||||
export type JellyfinPlaybackStatsMetadata = {
|
||||
mediaPath: string;
|
||||
displayTitle: string;
|
||||
itemTitle: string;
|
||||
seriesTitle: string | null;
|
||||
seasonNumber: number | null;
|
||||
episodeNumber: number | null;
|
||||
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(() => {});
|
||||
} catch {
|
||||
// Best-effort metadata/title hooks must not block playback startup.
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -31,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;
|
||||
@@ -43,6 +122,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
subtitleStreamIndex?: number | null;
|
||||
}) => Promise<JellyfinPlaybackPlan>;
|
||||
applyJellyfinMpvDefaults: (mpvClient: MpvRuntimeClientLike) => void;
|
||||
showVisibleOverlay: () => void;
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
armQuitOnDisconnect: () => void;
|
||||
schedule: (callback: () => void, delayMs: number) => void;
|
||||
@@ -51,18 +131,24 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
session: JellyfinAuthSession;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
itemId: string;
|
||||
}) => void;
|
||||
}) => void | Promise<void>;
|
||||
setActivePlayback: (state: ActivePlaybackState) => void;
|
||||
setLastProgressAtMs: (value: number) => void;
|
||||
reportPlaying: (payload: {
|
||||
itemId: string;
|
||||
mediaSourceId: undefined;
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
positionTicks?: number;
|
||||
isPaused?: boolean;
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
eventName: 'start';
|
||||
}) => void;
|
||||
showMpvOsd: (text: string) => void;
|
||||
recordJellyfinPlaybackMetadata?: (
|
||||
metadata: JellyfinPlaybackStatsMetadata,
|
||||
) => void | Promise<void>;
|
||||
updateCurrentMediaTitle?: (title: string) => void | Promise<void>;
|
||||
}) {
|
||||
return async (params: {
|
||||
session: JellyfinAuthSession;
|
||||
@@ -72,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();
|
||||
@@ -93,48 +180,68 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
|
||||
deps.applyJellyfinMpvDefaults(mpvClient);
|
||||
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
|
||||
const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride);
|
||||
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']);
|
||||
if (params.setQuitOnDisconnectArm !== false) {
|
||||
deps.armQuitOnDisconnect();
|
||||
}
|
||||
deps.sendMpvCommand([
|
||||
'set_property',
|
||||
'force-media-title',
|
||||
`[Jellyfin/${plan.mode}] ${plan.title}`,
|
||||
]);
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
deps.schedule(() => {
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
}, 500);
|
||||
|
||||
const startTimeTicks =
|
||||
typeof params.startTimeTicksOverride === 'number'
|
||||
? Math.max(0, params.startTimeTicksOverride)
|
||||
: plan.startTimeTicks;
|
||||
if (startTimeTicks > 0) {
|
||||
deps.sendMpvCommand(['seek', deps.convertTicksToSeconds(startTimeTicks), 'absolute+exact']);
|
||||
}
|
||||
|
||||
deps.preloadExternalSubtitles({
|
||||
session: params.session,
|
||||
clientInfo: params.clientInfo,
|
||||
itemId: params.itemId,
|
||||
});
|
||||
|
||||
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(() =>
|
||||
deps.recordJellyfinPlaybackMetadata?.({
|
||||
mediaPath: playbackUrl,
|
||||
displayTitle: plan.title,
|
||||
itemTitle: plan.itemTitle,
|
||||
seriesTitle: plan.seriesTitle,
|
||||
seasonNumber: plan.seasonNumber,
|
||||
episodeNumber: plan.episodeNumber,
|
||||
itemId: params.itemId,
|
||||
}),
|
||||
);
|
||||
deps.setActivePlayback({
|
||||
itemId: params.itemId,
|
||||
mediaSourceId: undefined,
|
||||
audioStreamIndex: plan.audioStreamIndex,
|
||||
subtitleStreamIndex: plan.subtitleStreamIndex,
|
||||
playMethod,
|
||||
loadedMediaPath: null,
|
||||
lastKnownPositionSeconds: startSeconds > 0 ? startSeconds : undefined,
|
||||
});
|
||||
deps.setLastProgressAtMs(0);
|
||||
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]);
|
||||
|
||||
await awaitBestEffortPlaybackHook(() =>
|
||||
deps.preloadExternalSubtitles({
|
||||
session: params.session,
|
||||
clientInfo: params.clientInfo,
|
||||
itemId: params.itemId,
|
||||
}),
|
||||
);
|
||||
deps.showVisibleOverlay();
|
||||
|
||||
deps.reportPlaying({
|
||||
itemId: params.itemId,
|
||||
mediaSourceId: undefined,
|
||||
playMethod,
|
||||
positionTicks: startTimeTicks,
|
||||
isPaused: false,
|
||||
audioStreamIndex: plan.audioStreamIndex,
|
||||
subtitleStreamIndex: plan.subtitleStreamIndex,
|
||||
eventName: 'start',
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -101,6 +168,32 @@ test('createHandleJellyfinRemotePlay logs and skips payload without item id', as
|
||||
assert.deepEqual(warnings, ['Ignoring Jellyfin remote Play event without ItemIds.']);
|
||||
});
|
||||
|
||||
test('createHandleJellyfinRemotePlay ignores duplicate play for active item', async () => {
|
||||
let playCalls = 0;
|
||||
const handlePlay = createHandleJellyfinRemotePlay({
|
||||
getConfiguredSession: () => ({
|
||||
serverUrl: 'https://jellyfin.local',
|
||||
accessToken: 'token',
|
||||
userId: 'user',
|
||||
username: 'name',
|
||||
}),
|
||||
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
|
||||
getJellyfinConfig: () => ({}),
|
||||
getActivePlayback: () => ({
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
}),
|
||||
playJellyfinItem: async () => {
|
||||
playCalls += 1;
|
||||
},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
await handlePlay({ ItemIds: ['item-1'] });
|
||||
|
||||
assert.equal(playCalls, 0);
|
||||
});
|
||||
|
||||
test('createHandleJellyfinRemotePlaystate dispatches pause/seek/stop flows', async () => {
|
||||
const mpvClient = {};
|
||||
const commands: Array<(string | number)[]> = [];
|
||||
|
||||
@@ -4,6 +4,9 @@ export type ActiveJellyfinRemotePlaybackState = {
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
loadedMediaPath?: string | null;
|
||||
stopReportsAfterMs?: number;
|
||||
lastKnownPositionSeconds?: number;
|
||||
};
|
||||
|
||||
type JellyfinSession = {
|
||||
@@ -51,6 +54,7 @@ export type JellyfinRemotePlayHandlerDeps = {
|
||||
getConfiguredSession: () => JellyfinSession | null;
|
||||
getClientInfo: () => JellyfinClientInfo;
|
||||
getJellyfinConfig: () => unknown;
|
||||
getActivePlayback?: () => ActiveJellyfinRemotePlaybackState | null;
|
||||
playJellyfinItem: (params: {
|
||||
session: JellyfinSession;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
@@ -59,6 +63,7 @@ export type JellyfinRemotePlayHandlerDeps = {
|
||||
audioStreamIndex?: number;
|
||||
subtitleStreamIndex?: number;
|
||||
startTimeTicksOverride?: number;
|
||||
fallbackToPlanStartTimeOnZeroOverride?: boolean;
|
||||
setQuitOnDisconnectArm?: boolean;
|
||||
}) => Promise<void>;
|
||||
logWarn: (message: string) => void;
|
||||
@@ -79,6 +84,13 @@ export function createHandleJellyfinRemotePlay(deps: JellyfinRemotePlayHandlerDe
|
||||
deps.logWarn('Ignoring Jellyfin remote Play event without ItemIds.');
|
||||
return;
|
||||
}
|
||||
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,
|
||||
@@ -86,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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -36,6 +36,14 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
|
||||
getLaunchMode: () => 'fullscreen',
|
||||
platform: 'darwin',
|
||||
execPath: '/tmp/subminer',
|
||||
getRuntimePluginEntrypoint: () => '/tmp/plugin/subminer/main.lua',
|
||||
getInstalledPluginDetection: () => ({
|
||||
installed: false,
|
||||
path: null,
|
||||
version: null,
|
||||
source: null,
|
||||
message: null,
|
||||
}),
|
||||
defaultMpvLogPath: '/tmp/mpv.log',
|
||||
defaultMpvArgs: ['--no-config'],
|
||||
removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`),
|
||||
@@ -51,6 +59,8 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
|
||||
assert.equal(deps.getLaunchMode(), 'fullscreen');
|
||||
assert.equal(deps.platform, 'darwin');
|
||||
assert.equal(deps.execPath, '/tmp/subminer');
|
||||
assert.equal(deps.getRuntimePluginEntrypoint?.(), '/tmp/plugin/subminer/main.lua');
|
||||
assert.equal(deps.getInstalledPluginDetection?.().installed, false);
|
||||
assert.equal(deps.defaultMpvLogPath, '/tmp/mpv.log');
|
||||
assert.deepEqual(deps.defaultMpvArgs, ['--no-config']);
|
||||
deps.removeSocketPath('/tmp/mpv.sock');
|
||||
|
||||
@@ -20,6 +20,8 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
|
||||
getLaunchMode: () => deps.getLaunchMode(),
|
||||
platform: deps.platform,
|
||||
execPath: deps.execPath,
|
||||
getRuntimePluginEntrypoint: deps.getRuntimePluginEntrypoint,
|
||||
getInstalledPluginDetection: deps.getInstalledPluginDetection,
|
||||
getPluginRuntimeConfig: deps.getPluginRuntimeConfig,
|
||||
defaultMpvLogPath: deps.defaultMpvLogPath,
|
||||
defaultMpvArgs: deps.defaultMpvArgs,
|
||||
|
||||
@@ -34,6 +34,8 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
|
||||
getLaunchMode: () => 'maximized',
|
||||
platform: 'darwin',
|
||||
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
getRuntimePluginEntrypoint: () =>
|
||||
'/Applications/SubMiner.app/Contents/Resources/plugin/subminer/main.lua',
|
||||
defaultMpvLogPath: '/tmp/mp.log',
|
||||
defaultMpvArgs: ['--sid=auto'],
|
||||
removeSocketPath: () => {},
|
||||
@@ -52,6 +54,11 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
|
||||
assert.equal(spawnedArgs.length, 1);
|
||||
assert.ok(spawnedArgs[0]!.includes('--window-maximized=yes'));
|
||||
assert.ok(spawnedArgs[0]!.includes('--idle=yes'));
|
||||
assert.ok(
|
||||
spawnedArgs[0]!.includes(
|
||||
'--script=/Applications/SubMiner.app/Contents/Resources/plugin/subminer/main.lua',
|
||||
),
|
||||
);
|
||||
assert.ok(spawnedArgs[0]!.some((arg) => arg.includes('--input-ipc-server=/tmp/subminer.sock')));
|
||||
assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback')));
|
||||
});
|
||||
@@ -101,6 +108,43 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
|
||||
assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/);
|
||||
});
|
||||
|
||||
test('createLaunchMpvIdleForJellyfinPlaybackHandler skips bundled script when installed plugin exists', () => {
|
||||
const spawnedArgs: string[][] = [];
|
||||
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
||||
getSocketPath: () => '/tmp/subminer.sock',
|
||||
getLaunchMode: () => 'normal',
|
||||
platform: 'linux',
|
||||
execPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||
getRuntimePluginEntrypoint: () => '/opt/SubMiner/plugin/subminer/main.lua',
|
||||
getInstalledPluginDetection: () => ({
|
||||
installed: true,
|
||||
path: '/home/tester/.config/mpv/scripts/subminer/main.lua',
|
||||
version: '0.1.0',
|
||||
source: 'default-config',
|
||||
message: null,
|
||||
}),
|
||||
defaultMpvLogPath: '/tmp/mp.log',
|
||||
defaultMpvArgs: ['--sid=auto'],
|
||||
removeSocketPath: () => {},
|
||||
spawnMpv: (args) => {
|
||||
spawnedArgs.push(args);
|
||||
return {
|
||||
on: () => {},
|
||||
unref: () => {},
|
||||
};
|
||||
},
|
||||
logWarn: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
launch();
|
||||
assert.equal(
|
||||
spawnedArgs[0]?.some((arg) => arg.startsWith('--script=/opt/SubMiner/plugin/subminer')),
|
||||
false,
|
||||
);
|
||||
assert.ok(spawnedArgs[0]?.some((arg) => arg.startsWith('--script-opts=')));
|
||||
});
|
||||
|
||||
test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => {
|
||||
let autoLaunchInFlight: Promise<boolean> | null = null;
|
||||
let launchCalls = 0;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type SubminerPluginRuntimeScriptOptConfig,
|
||||
} from '../../shared/subminer-plugin-script-opts';
|
||||
import type { MpvLaunchMode } from '../../types/config';
|
||||
import type { InstalledMpvPluginDetection } from './first-run-setup-plugin';
|
||||
|
||||
type MpvClientLike = {
|
||||
connected: boolean;
|
||||
@@ -44,6 +45,8 @@ export type LaunchMpvForJellyfinDeps = {
|
||||
getLaunchMode: () => MpvLaunchMode;
|
||||
platform: NodeJS.Platform;
|
||||
execPath: string;
|
||||
getRuntimePluginEntrypoint?: () => string | null | undefined;
|
||||
getInstalledPluginDetection?: () => InstalledMpvPluginDetection;
|
||||
getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig;
|
||||
defaultMpvLogPath: string;
|
||||
defaultMpvArgs: readonly string[];
|
||||
@@ -75,9 +78,17 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
|
||||
)
|
||||
: [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`];
|
||||
const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`;
|
||||
const installedPlugin = deps.getInstalledPluginDetection?.();
|
||||
const runtimePluginEntrypoint = installedPlugin?.installed
|
||||
? ''
|
||||
: (deps.getRuntimePluginEntrypoint?.()?.trim() ?? '');
|
||||
if (installedPlugin?.installed && installedPlugin.path) {
|
||||
deps.logInfo(`Using installed mpv plugin for Jellyfin playback: ${installedPlugin.path}`);
|
||||
}
|
||||
const mpvArgs = [
|
||||
...deps.defaultMpvArgs,
|
||||
...buildMpvLaunchModeArgs(deps.getLaunchMode()),
|
||||
...(runtimePluginEntrypoint ? [`--script=${runtimePluginEntrypoint}`] : []),
|
||||
'--idle=yes',
|
||||
scriptOpts,
|
||||
`--log-file=${deps.defaultMpvLogPath}`,
|
||||
|
||||
@@ -103,12 +103,16 @@ test('jellyfin remote stopped main deps builder maps callbacks', () => {
|
||||
getActivePlayback: () => ({ itemId: 'abc', playMethod: 'DirectPlay' }),
|
||||
clearActivePlayback: () => calls.push('clear'),
|
||||
getSession: () => session as never,
|
||||
getMpvClient: () => ({ id: 2, currentTimePos: 4 }) as never,
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: (message) => calls.push(`debug:${message}`),
|
||||
})();
|
||||
|
||||
assert.deepEqual(deps.getActivePlayback(), { itemId: 'abc', playMethod: 'DirectPlay' });
|
||||
deps.clearActivePlayback();
|
||||
assert.equal(deps.getSession(), session);
|
||||
assert.deepEqual(deps.getMpvClient(), { id: 2, currentTimePos: 4 });
|
||||
assert.equal(deps.ticksPerSecond, 10_000_000);
|
||||
deps.logDebug('stopped', null);
|
||||
assert.deepEqual(calls, ['clear', 'debug:stopped']);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,9 @@ export function createBuildHandleJellyfinRemotePlayMainDepsHandler(
|
||||
getConfiguredSession: () => deps.getConfiguredSession(),
|
||||
getClientInfo: () => deps.getClientInfo(),
|
||||
getJellyfinConfig: () => deps.getJellyfinConfig(),
|
||||
...(deps.getActivePlayback
|
||||
? { getActivePlayback: () => deps.getActivePlayback?.() ?? null }
|
||||
: {}),
|
||||
playJellyfinItem: (params) => deps.playJellyfinItem(params),
|
||||
logWarn: (message: string) => deps.logWarn(message),
|
||||
});
|
||||
@@ -68,6 +71,9 @@ export function createBuildReportJellyfinRemoteStoppedMainDepsHandler(
|
||||
getActivePlayback: () => deps.getActivePlayback(),
|
||||
clearActivePlayback: () => deps.clearActivePlayback(),
|
||||
getSession: () => deps.getSession(),
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined,
|
||||
ticksPerSecond: deps.ticksPerSecond,
|
||||
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
markJellyfinRemotePlaybackLoaded,
|
||||
createReportJellyfinRemoteProgressHandler,
|
||||
createReportJellyfinRemoteStoppedHandler,
|
||||
secondsToJellyfinTicks,
|
||||
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
|
||||
} from './jellyfin-remote-playback';
|
||||
|
||||
test('secondsToJellyfinTicks converts seconds and clamps invalid values', () => {
|
||||
@@ -12,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 }> = [];
|
||||
@@ -61,6 +96,74 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn
|
||||
assert.equal(lastProgressAtMs, 5000);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteProgressHandler reports while remote websocket is disconnected', async () => {
|
||||
const reportPayloads: Array<{ positionTicks: number; isPaused: boolean }> = [];
|
||||
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
||||
getActivePlayback: () => ({
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
}),
|
||||
clearActivePlayback: () => {},
|
||||
getSession: () => ({
|
||||
isConnected: () => false,
|
||||
reportProgress: async (payload) => {
|
||||
reportPayloads.push({
|
||||
positionTicks: payload.positionTicks,
|
||||
isPaused: payload.isPaused,
|
||||
});
|
||||
},
|
||||
reportStopped: async () => {},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: 42,
|
||||
requestProperty: async (name: string) => (name === 'pause' ? false : 42),
|
||||
}),
|
||||
getNow: () => 5000,
|
||||
getLastProgressAtMs: () => 0,
|
||||
setLastProgressAtMs: () => {},
|
||||
progressIntervalMs: 3000,
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportProgress(true);
|
||||
|
||||
assert.deepEqual(reportPayloads, [{ positionTicks: 420_000_000, isPaused: false }]);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteProgressHandler normalizes mpv pause strings', async () => {
|
||||
const reportPayloads: Array<{ isPaused: boolean }> = [];
|
||||
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
||||
getActivePlayback: () => ({
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
}),
|
||||
clearActivePlayback: () => {},
|
||||
getSession: () => ({
|
||||
isConnected: () => true,
|
||||
reportProgress: async (payload) => {
|
||||
reportPayloads.push({ isPaused: payload.isPaused });
|
||||
},
|
||||
reportStopped: async () => {},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async (name: string) => (name === 'pause' ? 'yes' : 3),
|
||||
}),
|
||||
getNow: () => 5000,
|
||||
getLastProgressAtMs: () => 0,
|
||||
setLastProgressAtMs: () => {},
|
||||
progressIntervalMs: 3000,
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportProgress(true);
|
||||
|
||||
assert.deepEqual(reportPayloads, [{ isPaused: true }]);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteProgressHandler respects debounce interval', async () => {
|
||||
let called = false;
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
||||
@@ -91,9 +194,61 @@ test('createReportJellyfinRemoteProgressHandler respects debounce interval', asy
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteProgressHandler reports mpv seek jumps during debounce', async () => {
|
||||
let now = 5000;
|
||||
let lastProgressAtMs = 0;
|
||||
let position = 10;
|
||||
const reportPayloads: Array<{ positionTicks: number; eventName: string }> = [];
|
||||
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
||||
getActivePlayback: () => ({
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
}),
|
||||
clearActivePlayback: () => {},
|
||||
getSession: () => ({
|
||||
isConnected: () => true,
|
||||
reportProgress: async (payload) => {
|
||||
reportPayloads.push({
|
||||
positionTicks: payload.positionTicks,
|
||||
eventName: payload.eventName,
|
||||
});
|
||||
},
|
||||
reportStopped: async () => {},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: position,
|
||||
requestProperty: async (name: string) => (name === 'pause' ? false : position),
|
||||
}),
|
||||
getNow: () => now,
|
||||
getLastProgressAtMs: () => lastProgressAtMs,
|
||||
setLastProgressAtMs: (value) => {
|
||||
lastProgressAtMs = value;
|
||||
},
|
||||
progressIntervalMs: 3000,
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportProgress(true);
|
||||
now = 5500;
|
||||
position = 90;
|
||||
await reportProgress(false);
|
||||
|
||||
assert.deepEqual(reportPayloads, [
|
||||
{ positionTicks: 100_000_000, eventName: 'TimeUpdate' },
|
||||
{ positionTicks: 900_000_000, eventName: 'TimeUpdate' },
|
||||
]);
|
||||
assert.equal(lastProgressAtMs, 5500);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback', async () => {
|
||||
let cleared = false;
|
||||
let stoppedItemId: string | null = null;
|
||||
let stoppedPayload: {
|
||||
itemId: string;
|
||||
positionTicks?: number;
|
||||
failed?: boolean;
|
||||
} | null = null;
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
||||
getActivePlayback: () => ({
|
||||
itemId: 'item-2',
|
||||
@@ -109,13 +264,267 @@ test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback'
|
||||
isConnected: () => true,
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async (payload) => {
|
||||
stoppedItemId = payload.itemId;
|
||||
stoppedPayload = {
|
||||
itemId: payload.itemId,
|
||||
positionTicks: payload.positionTicks,
|
||||
failed: payload.failed,
|
||||
};
|
||||
},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: 12.5,
|
||||
requestProperty: async () => {
|
||||
throw new Error('unloaded');
|
||||
},
|
||||
}),
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportStopped();
|
||||
assert.equal(stoppedItemId, 'item-2');
|
||||
assert.deepEqual(stoppedPayload, {
|
||||
itemId: 'item-2',
|
||||
positionTicks: 125_000_000,
|
||||
failed: false,
|
||||
});
|
||||
assert.equal(cleared, true);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler clears aborted playback that never loaded', async () => {
|
||||
let cleared = false;
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
||||
getActivePlayback: () => ({
|
||||
itemId: 'item-2',
|
||||
mediaSourceId: undefined,
|
||||
playMethod: 'Transcode',
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
loadedMediaPath: null,
|
||||
}),
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () => ({
|
||||
isConnected: () => true,
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async () => {
|
||||
throw new Error('should not report stopped for unloaded media');
|
||||
},
|
||||
}),
|
||||
getMpvClient: () => null,
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportStopped();
|
||||
|
||||
assert.equal(cleared, true);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler reports stop while remote websocket is disconnected', async () => {
|
||||
let cleared = false;
|
||||
let stoppedPayload: {
|
||||
itemId: string;
|
||||
positionTicks?: number;
|
||||
failed?: boolean;
|
||||
} | null = null;
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
||||
getActivePlayback: () => ({
|
||||
itemId: 'item-2',
|
||||
mediaSourceId: undefined,
|
||||
playMethod: 'Transcode',
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
loadedMediaPath: 'https://stream.example/video.m3u8',
|
||||
}),
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () => ({
|
||||
isConnected: () => false,
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async (payload) => {
|
||||
stoppedPayload = {
|
||||
itemId: payload.itemId,
|
||||
positionTicks: payload.positionTicks,
|
||||
failed: payload.failed,
|
||||
};
|
||||
},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: 12.5,
|
||||
}),
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportStopped();
|
||||
|
||||
assert.deepEqual(stoppedPayload, {
|
||||
itemId: 'item-2',
|
||||
positionTicks: 125_000_000,
|
||||
failed: false,
|
||||
});
|
||||
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;
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
||||
getActivePlayback: () =>
|
||||
({
|
||||
itemId: 'item-2',
|
||||
playMethod: 'Transcode',
|
||||
loadedMediaPath: null,
|
||||
}) as never,
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () => ({
|
||||
isConnected: () => true,
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async () => {
|
||||
stopped = true;
|
||||
},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: 0,
|
||||
}),
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportStopped();
|
||||
|
||||
assert.equal(stopped, false);
|
||||
assert.equal(cleared, true);
|
||||
});
|
||||
|
||||
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',
|
||||
playMethod: 'Transcode' as const,
|
||||
loadedMediaPath: 'https://stream.example/video.m3u8',
|
||||
};
|
||||
|
||||
markJellyfinRemotePlaybackLoaded(playback, '');
|
||||
markJellyfinRemotePlaybackLoaded(playback, ' ');
|
||||
assert.equal(playback.loadedMediaPath, 'https://stream.example/video.m3u8');
|
||||
|
||||
markJellyfinRemotePlaybackLoaded(playback, ' https://stream.example/next.m3u8 ');
|
||||
assert.equal(playback.loadedMediaPath, 'https://stream.example/next.m3u8');
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before grace expires', async () => {
|
||||
let cleared = false;
|
||||
let stopped = false;
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
||||
getActivePlayback: () =>
|
||||
({
|
||||
itemId: 'item-2',
|
||||
playMethod: 'DirectPlay',
|
||||
loadedMediaPath: 'https://stream.example/video.m3u8',
|
||||
stopReportsAfterMs: 20_000,
|
||||
}) as never,
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () => ({
|
||||
isConnected: () => true,
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async () => {
|
||||
stopped = true;
|
||||
},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: 0,
|
||||
}),
|
||||
getNow: () => 12_000,
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportStopped();
|
||||
|
||||
assert.equal(stopped, false);
|
||||
assert.equal(cleared, false);
|
||||
});
|
||||
|
||||
@@ -10,11 +10,13 @@ type JellyfinRemoteSessionLike = {
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
eventName: 'timeupdate';
|
||||
eventName: 'TimeUpdate';
|
||||
}) => Promise<unknown>;
|
||||
reportStopped: (payload: {
|
||||
itemId: string;
|
||||
mediaSourceId?: string;
|
||||
positionTicks?: number;
|
||||
failed?: boolean;
|
||||
playMethod: 'DirectPlay' | 'Transcode';
|
||||
audioStreamIndex?: number | null;
|
||||
subtitleStreamIndex?: number | null;
|
||||
@@ -23,7 +25,8 @@ type JellyfinRemoteSessionLike = {
|
||||
};
|
||||
|
||||
type MpvClientLike = {
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
currentTimePos?: number;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export function secondsToJellyfinTicks(seconds: number, ticksPerSecond: number): number {
|
||||
@@ -31,6 +34,106 @@ export function secondsToJellyfinTicks(seconds: number, ticksPerSecond: number):
|
||||
return Math.max(0, Math.floor(seconds * ticksPerSecond));
|
||||
}
|
||||
|
||||
export function markJellyfinRemotePlaybackLoaded(
|
||||
playback: ActiveJellyfinRemotePlaybackState | null,
|
||||
path: string,
|
||||
): void {
|
||||
const normalizedPath = path.trim();
|
||||
if (playback && normalizedPath) {
|
||||
playback.loadedMediaPath = normalizedPath;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized || normalized === 'no' || normalized === 'false' || normalized === '0') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeMpvPositionSeconds(value: unknown): number {
|
||||
const seconds = Number(value);
|
||||
if (!Number.isFinite(seconds)) return 0;
|
||||
return Math.max(0, seconds);
|
||||
}
|
||||
|
||||
function getCachedMpvPositionSeconds(client: MpvClientLike | null): number | null {
|
||||
if (!client) return null;
|
||||
const seconds = Number(client.currentTimePos);
|
||||
return Number.isFinite(seconds) ? Math.max(0, seconds) : null;
|
||||
}
|
||||
|
||||
async function readMpvPositionSeconds(client: MpvClientLike | null): Promise<number> {
|
||||
const cached = getCachedMpvPositionSeconds(client);
|
||||
if (cached !== null) return cached;
|
||||
const position = await client?.requestProperty?.('time-pos');
|
||||
return normalizeMpvPositionSeconds(position);
|
||||
}
|
||||
|
||||
async function readMpvPositionSecondsOrFallback(
|
||||
client: MpvClientLike | null,
|
||||
fallback = 0,
|
||||
): Promise<number> {
|
||||
try {
|
||||
return await readMpvPositionSeconds(client);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
thresholdSeconds: number,
|
||||
): boolean {
|
||||
if (previousPositionSeconds === null) return false;
|
||||
return Math.abs(nextPositionSeconds - previousPositionSeconds) >= thresholdSeconds;
|
||||
}
|
||||
|
||||
export type JellyfinRemoteProgressReporterDeps = {
|
||||
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
||||
clearActivePlayback: () => void;
|
||||
@@ -47,29 +150,44 @@ export type JellyfinRemoteProgressReporterDeps = {
|
||||
export function createReportJellyfinRemoteProgressHandler(
|
||||
deps: JellyfinRemoteProgressReporterDeps,
|
||||
) {
|
||||
let lastReportedPositionSeconds: number | null = null;
|
||||
|
||||
return async (force = false): Promise<void> => {
|
||||
const playback = deps.getActivePlayback();
|
||||
if (!playback) return;
|
||||
const session = deps.getSession();
|
||||
if (!session || !session.isConnected()) return;
|
||||
// Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects.
|
||||
if (!session) return;
|
||||
const now = deps.getNow();
|
||||
if (!force && now - deps.getLastProgressAtMs() < deps.progressIntervalMs) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const mpvClient = deps.getMpvClient();
|
||||
const position = await mpvClient?.requestProperty('time-pos');
|
||||
const paused = await mpvClient?.requestProperty('pause');
|
||||
const observedPositionSeconds = await readMpvPositionSeconds(mpvClient);
|
||||
cacheLastKnownPosition(playback, observedPositionSeconds);
|
||||
const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds);
|
||||
const forceForSeekJump = isSeekLikePositionJump(
|
||||
lastReportedPositionSeconds,
|
||||
positionSeconds,
|
||||
Math.max(2, deps.progressIntervalMs / 1000),
|
||||
);
|
||||
if (
|
||||
!force &&
|
||||
!forceForSeekJump &&
|
||||
now - deps.getLastProgressAtMs() < deps.progressIntervalMs
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const paused = await mpvClient?.requestProperty?.('pause');
|
||||
await session.reportProgress({
|
||||
itemId: playback.itemId,
|
||||
mediaSourceId: playback.mediaSourceId,
|
||||
positionTicks: secondsToJellyfinTicks(Number(position) || 0, deps.ticksPerSecond),
|
||||
isPaused: paused === true,
|
||||
positionTicks: secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond),
|
||||
isPaused: isMpvPauseEnabled(paused),
|
||||
playMethod: playback.playMethod,
|
||||
audioStreamIndex: playback.audioStreamIndex,
|
||||
subtitleStreamIndex: playback.subtitleStreamIndex,
|
||||
eventName: 'timeupdate',
|
||||
eventName: 'TimeUpdate',
|
||||
});
|
||||
lastReportedPositionSeconds = positionSeconds;
|
||||
deps.setLastProgressAtMs(now);
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to report Jellyfin remote progress', error);
|
||||
@@ -81,6 +199,9 @@ export type JellyfinRemoteStoppedReporterDeps = {
|
||||
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
||||
clearActivePlayback: () => void;
|
||||
getSession: () => JellyfinRemoteSessionLike | null;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
getNow?: () => number;
|
||||
ticksPerSecond: number;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
};
|
||||
|
||||
@@ -88,15 +209,46 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
||||
return async (): Promise<void> => {
|
||||
const playback = deps.getActivePlayback();
|
||||
if (!playback) return;
|
||||
if (playback.loadedMediaPath === null) {
|
||||
deps.clearActivePlayback();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof playback.stopReportsAfterMs === 'number' &&
|
||||
Number.isFinite(playback.stopReportsAfterMs) &&
|
||||
(deps.getNow?.() ?? Date.now()) < playback.stopReportsAfterMs
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const session = deps.getSession();
|
||||
if (!session || !session.isConnected()) {
|
||||
// Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects.
|
||||
if (!session) {
|
||||
deps.clearActivePlayback();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
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,
|
||||
failed: false,
|
||||
playMethod: playback.playMethod,
|
||||
audioStreamIndex: playback.audioStreamIndex,
|
||||
subtitleStreamIndex: playback.subtitleStreamIndex,
|
||||
|
||||
@@ -13,10 +13,6 @@ function createConfig(overrides?: Partial<Record<string, unknown>>) {
|
||||
serverUrl: 'http://localhost',
|
||||
accessToken: 'token',
|
||||
userId: 'user-id',
|
||||
deviceId: '',
|
||||
clientName: '',
|
||||
clientVersion: '',
|
||||
remoteControlDeviceName: '',
|
||||
autoAnnounce: false,
|
||||
...(overrides || {}),
|
||||
} as never;
|
||||
@@ -39,6 +35,12 @@ test('start handler no-ops when jellyfin integration is disabled', async () => {
|
||||
defaultDeviceId: 'default-device',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0',
|
||||
getClientInfo: () => ({
|
||||
deviceId: 'workstation',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
}),
|
||||
getHostName: () => 'workstation',
|
||||
handlePlay: async () => {},
|
||||
handlePlaystate: async () => {},
|
||||
handleGeneralCommand: async () => {},
|
||||
@@ -67,6 +69,12 @@ test('start handler no-ops when remote control is disabled', async () => {
|
||||
defaultDeviceId: 'default-device',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0',
|
||||
getClientInfo: () => ({
|
||||
deviceId: 'workstation',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
}),
|
||||
getHostName: () => 'workstation',
|
||||
handlePlay: async () => {},
|
||||
handlePlaystate: async () => {},
|
||||
handleGeneralCommand: async () => {},
|
||||
@@ -95,6 +103,12 @@ test('start handler respects auto-connect unless explicit start is requested', a
|
||||
defaultDeviceId: 'default-device',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0',
|
||||
getClientInfo: () => ({
|
||||
deviceId: 'workstation',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
}),
|
||||
getHostName: () => 'workstation',
|
||||
handlePlay: async () => {},
|
||||
handlePlaystate: async () => {},
|
||||
handleGeneralCommand: async () => {},
|
||||
@@ -117,6 +131,7 @@ test('start handler creates, starts, and stores session', async () => {
|
||||
} | null = null;
|
||||
let started = false;
|
||||
const infos: string[] = [];
|
||||
let stateChanges = 0;
|
||||
const startRemote = createStartJellyfinRemoteSessionHandler({
|
||||
getJellyfinConfig: () => createConfig({ clientName: 'Desk' }),
|
||||
getCurrentSession: () => null,
|
||||
@@ -124,7 +139,7 @@ test('start handler creates, starts, and stores session', async () => {
|
||||
storedSession = session as never;
|
||||
},
|
||||
createRemoteSessionService: (options) => {
|
||||
assert.equal(options.deviceName, 'Desk');
|
||||
assert.equal(options.deviceName, 'workstation');
|
||||
return {
|
||||
start: () => {
|
||||
started = true;
|
||||
@@ -136,18 +151,119 @@ test('start handler creates, starts, and stores session', async () => {
|
||||
defaultDeviceId: 'default-device',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0',
|
||||
getClientInfo: () => ({
|
||||
deviceId: 'workstation',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
}),
|
||||
getHostName: () => 'workstation',
|
||||
handlePlay: async () => {},
|
||||
handlePlaystate: async () => {},
|
||||
handleGeneralCommand: async () => {},
|
||||
logInfo: (message) => infos.push(message),
|
||||
logWarn: () => {},
|
||||
onSessionStateChanged: () => {
|
||||
stateChanges += 1;
|
||||
},
|
||||
});
|
||||
|
||||
await startRemote();
|
||||
|
||||
assert.equal(started, true);
|
||||
assert.ok(storedSession);
|
||||
assert.ok(infos.some((line) => line.includes('Jellyfin remote session enabled (Desk).')));
|
||||
assert.equal(stateChanges, 1);
|
||||
assert.ok(infos.some((line) => line.includes('Jellyfin remote session enabled (workstation).')));
|
||||
});
|
||||
|
||||
test('start handler uses hostname-derived client info and visible device name', async () => {
|
||||
let createdOptions: {
|
||||
deviceId: string;
|
||||
clientName: string;
|
||||
clientVersion: string;
|
||||
deviceName: string;
|
||||
} | null = null;
|
||||
const startRemote = createStartJellyfinRemoteSessionHandler({
|
||||
getJellyfinConfig: () =>
|
||||
createConfig({
|
||||
clientName: 'SubMiner',
|
||||
}),
|
||||
getClientInfo: () => ({
|
||||
deviceId: 'kyle-pc',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '0.1.0',
|
||||
}),
|
||||
getHostName: () => 'kyle-pc',
|
||||
getCurrentSession: () => null,
|
||||
setCurrentSession: () => {},
|
||||
createRemoteSessionService: (options) => {
|
||||
createdOptions = {
|
||||
deviceId: options.deviceId,
|
||||
clientName: options.clientName,
|
||||
clientVersion: options.clientVersion,
|
||||
deviceName: options.deviceName,
|
||||
};
|
||||
return {
|
||||
start: () => {},
|
||||
stop: () => {},
|
||||
advertiseNow: async () => true,
|
||||
};
|
||||
},
|
||||
defaultDeviceId: 'subminer',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '0.1.0',
|
||||
handlePlay: async () => {},
|
||||
handlePlaystate: async () => {},
|
||||
handleGeneralCommand: async () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
await startRemote({ explicit: true });
|
||||
|
||||
assert.deepEqual(createdOptions, {
|
||||
deviceId: 'kyle-pc',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '0.1.0',
|
||||
deviceName: 'kyle-pc',
|
||||
});
|
||||
});
|
||||
|
||||
test('start handler ignores configured visible device name', async () => {
|
||||
let createdDeviceName = '';
|
||||
const startRemote = createStartJellyfinRemoteSessionHandler({
|
||||
getJellyfinConfig: () =>
|
||||
createConfig({
|
||||
remoteControlDeviceName: 'SubMiner Cachy sudacode',
|
||||
}),
|
||||
getClientInfo: () => ({
|
||||
deviceId: 'cachy',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '0.1.0',
|
||||
}),
|
||||
getHostName: () => 'cachy',
|
||||
getCurrentSession: () => null,
|
||||
setCurrentSession: () => {},
|
||||
createRemoteSessionService: (options) => {
|
||||
createdDeviceName = options.deviceName;
|
||||
return {
|
||||
start: () => {},
|
||||
stop: () => {},
|
||||
advertiseNow: async () => true,
|
||||
};
|
||||
},
|
||||
defaultDeviceId: 'subminer',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '0.1.0',
|
||||
handlePlay: async () => {},
|
||||
handlePlaystate: async () => {},
|
||||
handleGeneralCommand: async () => {},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
});
|
||||
|
||||
await startRemote({ explicit: true });
|
||||
|
||||
assert.equal(createdDeviceName, 'cachy');
|
||||
});
|
||||
|
||||
test('start handler stops previous session before replacing', async () => {
|
||||
@@ -175,6 +291,12 @@ test('start handler stops previous session before replacing', async () => {
|
||||
defaultDeviceId: 'default-device',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0',
|
||||
getClientInfo: () => ({
|
||||
deviceId: 'workstation',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
}),
|
||||
getHostName: () => 'workstation',
|
||||
handlePlay: async () => {},
|
||||
handlePlaystate: async () => {},
|
||||
handleGeneralCommand: async () => {},
|
||||
@@ -189,6 +311,7 @@ test('start handler stops previous session before replacing', async () => {
|
||||
test('stop handler stops active session and clears playback', () => {
|
||||
let stopCalls = 0;
|
||||
let clearCalls = 0;
|
||||
let stateChanges = 0;
|
||||
let currentSession: { stop: () => void } | null = {
|
||||
stop: () => {
|
||||
stopCalls += 1;
|
||||
@@ -203,10 +326,14 @@ test('stop handler stops active session and clears playback', () => {
|
||||
clearActivePlayback: () => {
|
||||
clearCalls += 1;
|
||||
},
|
||||
onSessionStateChanged: () => {
|
||||
stateChanges += 1;
|
||||
},
|
||||
});
|
||||
|
||||
stopRemote();
|
||||
assert.equal(stopCalls, 1);
|
||||
assert.equal(clearCalls, 1);
|
||||
assert.equal(currentSession, null);
|
||||
assert.equal(stateChanges, 1);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { resolveJellyfinRemoteDeviceName } from './jellyfin-device-identity';
|
||||
|
||||
type JellyfinRemoteConfig = {
|
||||
enabled: boolean;
|
||||
remoteControlEnabled: boolean;
|
||||
@@ -5,11 +7,13 @@ type JellyfinRemoteConfig = {
|
||||
serverUrl: string;
|
||||
accessToken?: string;
|
||||
userId?: string;
|
||||
autoAnnounce: boolean;
|
||||
};
|
||||
|
||||
type JellyfinClientInfo = {
|
||||
deviceId: string;
|
||||
clientName: string;
|
||||
clientVersion: string;
|
||||
remoteControlDeviceName: string;
|
||||
autoAnnounce: boolean;
|
||||
};
|
||||
|
||||
type JellyfinRemoteService = {
|
||||
@@ -44,6 +48,8 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
|
||||
getCurrentSession: () => JellyfinRemoteService | null;
|
||||
setCurrentSession: (session: JellyfinRemoteService | null) => void;
|
||||
createRemoteSessionService: (options: JellyfinRemoteServiceOptions) => JellyfinRemoteService;
|
||||
getClientInfo: () => JellyfinClientInfo;
|
||||
getHostName: () => string;
|
||||
defaultDeviceId: string;
|
||||
defaultClientName: string;
|
||||
defaultClientVersion: string;
|
||||
@@ -52,6 +58,7 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
|
||||
handleGeneralCommand: (payload: JellyfinRemoteEventPayload) => Promise<void>;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string, details?: unknown) => void;
|
||||
onSessionStateChanged?: () => void;
|
||||
}) {
|
||||
return async (options?: { explicit?: boolean }): Promise<void> => {
|
||||
const jellyfinConfig = deps.getJellyfinConfig();
|
||||
@@ -60,6 +67,13 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
|
||||
if (jellyfinConfig.remoteControlAutoConnect === false && options?.explicit !== true) return;
|
||||
if (!jellyfinConfig.serverUrl || !jellyfinConfig.accessToken || !jellyfinConfig.userId) return;
|
||||
|
||||
const clientInfo = deps.getClientInfo();
|
||||
const clientName = clientInfo.clientName || deps.defaultClientName;
|
||||
const clientVersion = clientInfo.clientVersion || deps.defaultClientVersion;
|
||||
const deviceName = resolveJellyfinRemoteDeviceName({
|
||||
hostName: deps.getHostName(),
|
||||
});
|
||||
|
||||
const existing = deps.getCurrentSession();
|
||||
if (existing) {
|
||||
existing.stop();
|
||||
@@ -69,13 +83,10 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
|
||||
const service = deps.createRemoteSessionService({
|
||||
serverUrl: jellyfinConfig.serverUrl,
|
||||
accessToken: jellyfinConfig.accessToken,
|
||||
deviceId: jellyfinConfig.deviceId || deps.defaultDeviceId,
|
||||
clientName: jellyfinConfig.clientName || deps.defaultClientName,
|
||||
clientVersion: jellyfinConfig.clientVersion || deps.defaultClientVersion,
|
||||
deviceName:
|
||||
jellyfinConfig.remoteControlDeviceName ||
|
||||
jellyfinConfig.clientName ||
|
||||
deps.defaultClientName,
|
||||
deviceId: clientInfo.deviceId || deps.defaultDeviceId,
|
||||
clientName,
|
||||
clientVersion,
|
||||
deviceName,
|
||||
capabilities: {
|
||||
PlayableMediaTypes: 'Video,Audio',
|
||||
SupportedCommands:
|
||||
@@ -118,9 +129,8 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
|
||||
|
||||
service.start();
|
||||
deps.setCurrentSession(service);
|
||||
deps.logInfo(
|
||||
`Jellyfin remote session enabled (${jellyfinConfig.remoteControlDeviceName || jellyfinConfig.clientName || 'SubMiner'}).`,
|
||||
);
|
||||
deps.onSessionStateChanged?.();
|
||||
deps.logInfo(`Jellyfin remote session enabled (${deviceName}).`);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,6 +138,7 @@ export function createStopJellyfinRemoteSessionHandler(deps: {
|
||||
getCurrentSession: () => JellyfinRemoteService | null;
|
||||
setCurrentSession: (session: JellyfinRemoteService | null) => void;
|
||||
clearActivePlayback: () => void;
|
||||
onSessionStateChanged?: () => void;
|
||||
}) {
|
||||
return (): void => {
|
||||
const session = deps.getCurrentSession();
|
||||
@@ -135,5 +146,6 @@ export function createStopJellyfinRemoteSessionHandler(deps: {
|
||||
session.stop();
|
||||
deps.setCurrentSession(null);
|
||||
deps.clearActivePlayback();
|
||||
deps.onSessionStateChanged?.();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,13 @@ test('start jellyfin remote session main deps builder maps callbacks', async ()
|
||||
getCurrentSession: () => null,
|
||||
setCurrentSession: () => calls.push('set-session'),
|
||||
createRemoteSessionService: () => session as never,
|
||||
getClientInfo: () =>
|
||||
({
|
||||
deviceId: 'workstation',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
}) as never,
|
||||
getHostName: () => 'workstation',
|
||||
defaultDeviceId: 'device',
|
||||
defaultClientName: 'SubMiner',
|
||||
defaultClientVersion: '1.0',
|
||||
@@ -27,19 +34,34 @@ test('start jellyfin remote session main deps builder maps callbacks', async ()
|
||||
},
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
onSessionStateChanged: () => calls.push('state-changed'),
|
||||
})();
|
||||
|
||||
assert.deepEqual(deps.getJellyfinConfig(), { serverUrl: 'http://localhost' });
|
||||
assert.equal(deps.defaultDeviceId, 'device');
|
||||
assert.equal(deps.defaultClientName, 'SubMiner');
|
||||
assert.equal(deps.defaultClientVersion, '1.0');
|
||||
assert.equal(deps.getHostName(), 'workstation');
|
||||
assert.deepEqual(deps.getClientInfo(), {
|
||||
deviceId: 'workstation',
|
||||
clientName: 'SubMiner',
|
||||
clientVersion: '1.0',
|
||||
});
|
||||
assert.equal(deps.createRemoteSessionService({} as never), session);
|
||||
await deps.handlePlay({});
|
||||
await deps.handlePlaystate({});
|
||||
await deps.handleGeneralCommand({});
|
||||
deps.logInfo('connected');
|
||||
deps.logWarn('missing');
|
||||
assert.deepEqual(calls, ['play', 'playstate', 'general', 'info:connected', 'warn:missing']);
|
||||
deps.onSessionStateChanged?.();
|
||||
assert.deepEqual(calls, [
|
||||
'play',
|
||||
'playstate',
|
||||
'general',
|
||||
'info:connected',
|
||||
'warn:missing',
|
||||
'state-changed',
|
||||
]);
|
||||
});
|
||||
|
||||
test('stop jellyfin remote session main deps builder maps callbacks', () => {
|
||||
@@ -49,10 +71,12 @@ test('stop jellyfin remote session main deps builder maps callbacks', () => {
|
||||
getCurrentSession: () => session as never,
|
||||
setCurrentSession: () => calls.push('set-null'),
|
||||
clearActivePlayback: () => calls.push('clear'),
|
||||
onSessionStateChanged: () => calls.push('state-changed'),
|
||||
})();
|
||||
|
||||
assert.equal(deps.getCurrentSession(), session);
|
||||
deps.setCurrentSession(null);
|
||||
deps.clearActivePlayback();
|
||||
assert.deepEqual(calls, ['set-null', 'clear']);
|
||||
deps.onSessionStateChanged?.();
|
||||
assert.deepEqual(calls, ['set-null', 'clear', 'state-changed']);
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@ export function createBuildStartJellyfinRemoteSessionMainDepsHandler(
|
||||
getCurrentSession: () => deps.getCurrentSession(),
|
||||
setCurrentSession: (session) => deps.setCurrentSession(session),
|
||||
createRemoteSessionService: (options) => deps.createRemoteSessionService(options),
|
||||
getClientInfo: () => deps.getClientInfo(),
|
||||
getHostName: () => deps.getHostName(),
|
||||
defaultDeviceId: deps.defaultDeviceId,
|
||||
defaultClientName: deps.defaultClientName,
|
||||
defaultClientVersion: deps.defaultClientVersion,
|
||||
@@ -26,6 +28,7 @@ export function createBuildStartJellyfinRemoteSessionMainDepsHandler(
|
||||
handleGeneralCommand: (payload) => deps.handleGeneralCommand(payload),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
logWarn: (message: string, details?: unknown) => deps.logWarn(message, details),
|
||||
onSessionStateChanged: deps.onSessionStateChanged,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,5 +39,6 @@ export function createBuildStopJellyfinRemoteSessionMainDepsHandler(
|
||||
getCurrentSession: () => deps.getCurrentSession(),
|
||||
setCurrentSession: (session) => deps.setCurrentSession(session),
|
||||
clearActivePlayback: () => deps.clearActivePlayback(),
|
||||
onSessionStateChanged: deps.onSessionStateChanged,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -141,23 +141,140 @@ export function buildJellyfinSetupFormHtml(state: JellyfinSetupViewState): strin
|
||||
<meta charset="utf-8" />
|
||||
<title>Jellyfin Setup</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; --bg: #10130f; --panel: #191d17; --line: #414835; --text: #f0f2e8; --muted: #b6bca8; --accent: #a7d129; --danger: #ff786f; }
|
||||
body { font-family: Georgia, "Times New Roman", serif; margin: 0; background: radial-gradient(circle at 20% 0%, #24301b 0, #10130f 42%); color: var(--text); }
|
||||
main { padding: 22px; }
|
||||
h1 { margin: 0 0 8px; font-size: 24px; letter-spacing: 0; }
|
||||
p { margin: 0 0 16px; color: var(--muted); font-size: 13px; line-height: 1.45; }
|
||||
label { display: block; margin: 12px 0 5px; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
|
||||
input { width: 100%; box-sizing: border-box; padding: 10px 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; }
|
||||
button { padding: 10px 12px; border: 1px solid #6f831f; border-radius: 6px; font-weight: 700; cursor: pointer; background: var(--accent); color: #14170f; }
|
||||
button:disabled { cursor: wait; opacity: .68; }
|
||||
button.secondary { background: transparent; color: var(--text); border-color: var(--line); }
|
||||
button.danger { background: transparent; color: var(--danger); border-color: #6b332f; }
|
||||
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; }
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--ctp-red: #ed8796;
|
||||
--ctp-peach: #f5a97f;
|
||||
--ctp-yellow: #eed49f;
|
||||
--ctp-green: #a6da95;
|
||||
--ctp-blue: #8aadf4;
|
||||
--ctp-lavender: #b7bdf8;
|
||||
--ctp-text: #cad3f5;
|
||||
--ctp-subtext1: #b8c0e0;
|
||||
--ctp-subtext0: #a5adcb;
|
||||
--ctp-overlay2: #939ab7;
|
||||
--ctp-overlay1: #8087a2;
|
||||
--ctp-overlay0: #6e738d;
|
||||
--ctp-surface1: #494d64;
|
||||
--ctp-surface0: #363a4f;
|
||||
--ctp-base: #24273a;
|
||||
--ctp-mantle: #1e2030;
|
||||
--ctp-crust: #181926;
|
||||
--line: rgba(110, 115, 141, 0.28);
|
||||
--line-soft: rgba(110, 115, 141, 0.14);
|
||||
--text: var(--ctp-text);
|
||||
--muted: var(--ctp-subtext0);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { width: 100%; height: 100%; margin: 0; }
|
||||
html { background: var(--ctp-base); }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--ctp-base);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Yu Gothic", sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
main { padding: 32px 22px; max-width: 520px; margin: 0 auto; }
|
||||
h1 { margin: 0 0 6px; font-size: 20px; font-weight: 800; color: var(--ctp-text); letter-spacing: -0.01em; }
|
||||
p { margin: 0 0 18px; color: var(--muted); font-size: 13px; line-height: 1.5; }
|
||||
label { display: block; margin: 14px 0 6px; font-size: 11px; font-weight: 800; color: var(--ctp-overlay2); text-transform: uppercase; letter-spacing: 0.1em; }
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: rgba(24, 25, 38, 0.85);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
|
||||
}
|
||||
input::placeholder { color: var(--ctp-overlay0); }
|
||||
input:hover { border-color: rgba(138, 173, 244, 0.32); }
|
||||
input:focus {
|
||||
border-color: rgba(138, 173, 244, 0.65);
|
||||
background: rgba(24, 25, 38, 0.95);
|
||||
box-shadow: 0 0 0 3px rgba(138, 173, 244, 0.15);
|
||||
}
|
||||
button {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 140ms ease, border-color 140ms ease, color 140ms ease, transform 60ms ease;
|
||||
}
|
||||
button:active { transform: translateY(1px); }
|
||||
button:disabled { cursor: wait; opacity: 0.7; }
|
||||
button.primary {
|
||||
border-color: transparent;
|
||||
background: var(--ctp-blue);
|
||||
color: var(--ctp-crust);
|
||||
}
|
||||
button.primary:hover:not(:disabled) { filter: brightness(1.06); }
|
||||
button.primary:disabled {
|
||||
background: rgba(54, 58, 79, 0.55);
|
||||
color: var(--ctp-overlay0);
|
||||
border-color: var(--line);
|
||||
}
|
||||
button.secondary {
|
||||
background: rgba(54, 58, 79, 0.5);
|
||||
color: var(--text);
|
||||
}
|
||||
button.secondary:hover:not(:disabled) {
|
||||
border-color: rgba(138, 173, 244, 0.45);
|
||||
background: rgba(73, 77, 100, 0.6);
|
||||
color: var(--ctp-lavender);
|
||||
}
|
||||
button.danger {
|
||||
background: rgba(237, 135, 150, 0.12);
|
||||
color: var(--ctp-red);
|
||||
border-color: rgba(237, 135, 150, 0.45);
|
||||
}
|
||||
button.danger:hover:not(:disabled) {
|
||||
background: rgba(237, 135, 150, 0.22);
|
||||
}
|
||||
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; }
|
||||
.actions .primary { grid-column: 1 / -1; }
|
||||
.status { min-height: 18px; margin-top: 12px; font-size: 13px; color: var(--muted); }
|
||||
.status.success { color: var(--accent); }
|
||||
.status.error { color: var(--danger); }
|
||||
.hint { margin-top: 14px; font-size: 12px; color: var(--muted); }
|
||||
.status {
|
||||
min-height: 18px;
|
||||
margin-top: 14px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.status:empty { display: none; }
|
||||
.status.loading,
|
||||
.status.success,
|
||||
.status.error {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--ctp-surface0);
|
||||
font-weight: 600;
|
||||
}
|
||||
.status.success {
|
||||
border-color: rgba(166, 218, 149, 0.45);
|
||||
background: rgba(166, 218, 149, 0.1);
|
||||
color: var(--ctp-green);
|
||||
}
|
||||
.status.error {
|
||||
border-color: rgba(237, 135, 150, 0.55);
|
||||
background: rgba(237, 135, 150, 0.1);
|
||||
color: var(--ctp-red);
|
||||
}
|
||||
.hint {
|
||||
margin-top: 16px;
|
||||
font-size: 11.5px;
|
||||
color: var(--ctp-overlay2);
|
||||
line-height: 1.55;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createJellyfinSubtitleCacheIo } from './jellyfin-subtitle-cache-io';
|
||||
|
||||
test('jellyfin subtitle cache io downloads tracks to temp files and cleans cache dirs', async () => {
|
||||
const writes: Array<{ filePath: string; bytes: string }> = [];
|
||||
const removed: Array<{ dir: string; recursive: boolean; force: boolean }> = [];
|
||||
const cacheIo = createJellyfinSubtitleCacheIo({
|
||||
tmpDir: () => '/tmp',
|
||||
makeTempDir: async (prefix) => {
|
||||
assert.equal(prefix, '/tmp/subminer-jellyfin-subtitles-');
|
||||
return '/tmp/subminer-jellyfin-subtitles-abc';
|
||||
},
|
||||
writeFile: async (filePath, bytes) => {
|
||||
writes.push({ filePath, bytes: new TextDecoder().decode(bytes) });
|
||||
},
|
||||
removeDir: (dir, options) => {
|
||||
removed.push({ dir, ...options });
|
||||
},
|
||||
fetch: async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: async () => new TextEncoder().encode('subtitle body').buffer as ArrayBuffer,
|
||||
}),
|
||||
});
|
||||
|
||||
const cached = await cacheIo.cacheSubtitleTrack({
|
||||
index: 7,
|
||||
deliveryUrl: 'https://example.test/Items/1/Subtitles/7/Stream.ass?api_key=secret',
|
||||
});
|
||||
cacheIo.cleanupCachedSubtitles([cached.cleanupDir]);
|
||||
|
||||
assert.deepEqual(cached, {
|
||||
path: '/tmp/subminer-jellyfin-subtitles-abc/track-7.ass',
|
||||
cleanupDir: '/tmp/subminer-jellyfin-subtitles-abc',
|
||||
});
|
||||
assert.deepEqual(writes, [
|
||||
{
|
||||
filePath: '/tmp/subminer-jellyfin-subtitles-abc/track-7.ass',
|
||||
bytes: 'subtitle body',
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(removed, [
|
||||
{ dir: '/tmp/subminer-jellyfin-subtitles-abc', recursive: true, force: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test('jellyfin subtitle cache io removes temp dir when download fails', async () => {
|
||||
const removed: string[] = [];
|
||||
const cacheIo = createJellyfinSubtitleCacheIo({
|
||||
tmpDir: () => '/tmp',
|
||||
makeTempDir: async () => '/tmp/subminer-jellyfin-subtitles-failed',
|
||||
writeFile: async () => {},
|
||||
removeDir: (dir) => {
|
||||
removed.push(dir);
|
||||
},
|
||||
fetch: async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => cacheIo.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' }),
|
||||
/HTTP 500/,
|
||||
);
|
||||
assert.deepEqual(removed, ['/tmp/subminer-jellyfin-subtitles-failed']);
|
||||
});
|
||||
|
||||
test('jellyfin subtitle cache io awaits async temp cleanup when download fails', async () => {
|
||||
let removed = false;
|
||||
const cacheIo = createJellyfinSubtitleCacheIo({
|
||||
tmpDir: () => '/tmp',
|
||||
makeTempDir: async () => '/tmp/subminer-jellyfin-subtitles-failed',
|
||||
writeFile: async () => {},
|
||||
removeDir: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
removed = true;
|
||||
},
|
||||
fetch: async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
}),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => cacheIo.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' }),
|
||||
/HTTP 500/,
|
||||
);
|
||||
assert.equal(removed, true);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as path from 'path';
|
||||
|
||||
type JellyfinSubtitleCacheTrack = {
|
||||
index: number;
|
||||
deliveryUrl?: string | null;
|
||||
};
|
||||
|
||||
type JellyfinSubtitleCacheEntry = {
|
||||
path: string;
|
||||
cleanupDir: string;
|
||||
};
|
||||
|
||||
type FetchResponseLike = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
arrayBuffer: () => Promise<ArrayBuffer>;
|
||||
};
|
||||
|
||||
type JellyfinSubtitleCacheIoDeps = {
|
||||
tmpDir: () => string;
|
||||
makeTempDir: (prefix: string) => Promise<string>;
|
||||
writeFile: (filePath: string, bytes: Uint8Array) => Promise<void>;
|
||||
removeDir: (dir: string, options: { recursive: true; force: true }) => void | Promise<void>;
|
||||
fetch: (url: string) => Promise<FetchResponseLike>;
|
||||
};
|
||||
|
||||
function getSubtitleExtension(deliveryUrl: string): string {
|
||||
const urlPath = (() => {
|
||||
try {
|
||||
return new URL(deliveryUrl).pathname;
|
||||
} catch {
|
||||
return deliveryUrl;
|
||||
}
|
||||
})();
|
||||
return path.extname(urlPath).slice(0, 16) || '.srt';
|
||||
}
|
||||
|
||||
export function createJellyfinSubtitleCacheIo(deps: JellyfinSubtitleCacheIoDeps) {
|
||||
return {
|
||||
async cacheSubtitleTrack(
|
||||
track: JellyfinSubtitleCacheTrack,
|
||||
): Promise<JellyfinSubtitleCacheEntry> {
|
||||
if (!track.deliveryUrl) {
|
||||
throw new Error('Jellyfin subtitle track has no delivery URL');
|
||||
}
|
||||
|
||||
const cacheDir = await deps.makeTempDir(
|
||||
path.join(deps.tmpDir(), 'subminer-jellyfin-subtitles-'),
|
||||
);
|
||||
const subtitlePath = path.join(
|
||||
cacheDir,
|
||||
`track-${track.index}${getSubtitleExtension(track.deliveryUrl)}`,
|
||||
);
|
||||
try {
|
||||
const response = await deps.fetch(track.deliveryUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download Jellyfin subtitle (HTTP ${response.status})`);
|
||||
}
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
await deps.writeFile(subtitlePath, bytes);
|
||||
} catch (error) {
|
||||
try {
|
||||
await Promise.resolve(deps.removeDir(cacheDir, { recursive: true, force: true }));
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
return { path: subtitlePath, cleanupDir: cacheDir };
|
||||
},
|
||||
cleanupCachedSubtitles(dirs: string[]): void {
|
||||
for (const dir of dirs) {
|
||||
void Promise.resolve(deps.removeDir(dir, { recursive: true, force: true })).catch(() => {});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,24 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
|
||||
wait: async () => {
|
||||
calls.push('wait');
|
||||
},
|
||||
cacheSubtitleTrack: async () => {
|
||||
calls.push('cache');
|
||||
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}`),
|
||||
})();
|
||||
|
||||
@@ -21,6 +39,23 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
|
||||
assert.equal(typeof deps.getMpvClient()?.requestProperty, 'function');
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'auto']);
|
||||
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', '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',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,21 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler(
|
||||
getMpvClient: () => deps.getMpvClient(),
|
||||
sendMpvCommand: (command) => deps.sendMpvCommand(command),
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,13 +18,53 @@ type JellyfinSubtitleTrack = {
|
||||
index: number;
|
||||
language?: string;
|
||||
title?: string;
|
||||
codec?: string;
|
||||
isDefault?: boolean;
|
||||
isForced?: boolean;
|
||||
isExternal?: boolean;
|
||||
deliveryMethod?: string;
|
||||
deliveryUrl?: string | null;
|
||||
};
|
||||
|
||||
type CachedSubtitleTrack = {
|
||||
path: string;
|
||||
cleanupDir: string;
|
||||
};
|
||||
|
||||
type CachedExternalSubtitleTrack = CachedSubtitleTrack & {
|
||||
source: JellyfinSubtitleTrack;
|
||||
};
|
||||
|
||||
type JellyfinSubtitleDelayKey = {
|
||||
itemId: string;
|
||||
streamIndex: number;
|
||||
};
|
||||
|
||||
type MpvSubtitleTrack = {
|
||||
id: number;
|
||||
lang: string;
|
||||
title: string;
|
||||
external: boolean;
|
||||
externalFilename: string;
|
||||
};
|
||||
|
||||
type MpvClientLike = {
|
||||
connected?: boolean;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const TRACK_SELECTION_INITIAL_WAIT_MS = 250;
|
||||
const TRACK_SELECTION_RETRY_MS = 150;
|
||||
const TRACK_SELECTION_MAX_ATTEMPTS = 10;
|
||||
|
||||
export type PreloadJellyfinExternalSubtitlesHandler = ((params: {
|
||||
session: JellyfinSession;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
itemId: string;
|
||||
}) => Promise<void>) & {
|
||||
cleanupCachedSubtitles: () => void;
|
||||
};
|
||||
|
||||
function normalizeLang(value: unknown): string {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
@@ -58,17 +101,12 @@ function isLikelyHearingImpaired(title: string): boolean {
|
||||
}
|
||||
|
||||
function pickBestTrackId(
|
||||
tracks: Array<{
|
||||
id: number;
|
||||
lang: string;
|
||||
title: string;
|
||||
external: boolean;
|
||||
}>,
|
||||
tracks: MpvSubtitleTrack[],
|
||||
languageMatcher: (value: string) => boolean,
|
||||
excludeId: number | null = null,
|
||||
): number | null {
|
||||
const ranked = tracks
|
||||
.filter((track) => languageMatcher(track.lang))
|
||||
.filter((track) => languageMatcher(track.lang) || languageMatcher(track.title))
|
||||
.filter((track) => track.id !== excludeId)
|
||||
.map((track) => ({
|
||||
track,
|
||||
@@ -81,6 +119,192 @@ function pickBestTrackId(
|
||||
return ranked[0]?.track.id ?? null;
|
||||
}
|
||||
|
||||
function pickBestCachedTrackId(
|
||||
tracks: MpvSubtitleTrack[],
|
||||
cachedTracks: CachedExternalSubtitleTrack[],
|
||||
sourceMatcher: (value: string) => boolean,
|
||||
excludeId: number | null = null,
|
||||
): number | null {
|
||||
const cachedByPath = new Map(cachedTracks.map((track) => [track.path, track]));
|
||||
const ranked = tracks
|
||||
.map((track) => ({
|
||||
track,
|
||||
cached: cachedByPath.get(track.externalFilename),
|
||||
}))
|
||||
.filter(({ cached }) =>
|
||||
cached
|
||||
? sourceMatcher(cached.source.language || '') || sourceMatcher(cached.source.title || '')
|
||||
: false,
|
||||
)
|
||||
.filter(({ track }) => track.id !== excludeId)
|
||||
.map(({ track, cached }) => {
|
||||
const title = cached?.source.title || track.title;
|
||||
return {
|
||||
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),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.score - a.score);
|
||||
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);
|
||||
}
|
||||
|
||||
function hasExternalJapaneseTrack(tracks: MpvSubtitleTrack[]): boolean {
|
||||
return tracks.some((track) => track.external && isJapaneseTrack(track));
|
||||
}
|
||||
|
||||
function parseMpvSubtitleTracks(trackListRaw: unknown): MpvSubtitleTrack[] {
|
||||
return Array.isArray(trackListRaw)
|
||||
? trackListRaw
|
||||
.filter(
|
||||
(track): track is Record<string, unknown> =>
|
||||
Boolean(track) && typeof track === 'object' && track.type === 'sub',
|
||||
)
|
||||
.map((track) => ({
|
||||
id: parseTrackId(track.id),
|
||||
lang: String(track.lang || ''),
|
||||
title: String(track.title || ''),
|
||||
external: track.external === true,
|
||||
externalFilename: String(track['external-filename'] || ''),
|
||||
}))
|
||||
.filter((track): track is MpvSubtitleTrack => track.id !== null)
|
||||
: [];
|
||||
}
|
||||
|
||||
function hasExpectedExternalSubtitleTracks(
|
||||
tracks: MpvSubtitleTrack[],
|
||||
expectedExternalFilenames: string[],
|
||||
): boolean {
|
||||
if (expectedExternalFilenames.length === 0) {
|
||||
return true;
|
||||
}
|
||||
const loadedExternalFilenames = new Set(
|
||||
tracks.filter((track) => track.externalFilename).map((track) => track.externalFilename),
|
||||
);
|
||||
return expectedExternalFilenames.every((filePath) => loadedExternalFilenames.has(filePath));
|
||||
}
|
||||
|
||||
function parseTrackId(value: unknown): number | null {
|
||||
if (typeof value === 'string' && value.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const numeric =
|
||||
typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN;
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
async function readMpvSubtitleTracks(deps: {
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
}): Promise<MpvSubtitleTrack[] | null> {
|
||||
const client = deps.getMpvClient();
|
||||
if (!client || client.connected === false) {
|
||||
return null;
|
||||
}
|
||||
let trackListRaw: unknown;
|
||||
try {
|
||||
trackListRaw = await client.requestProperty('track-list');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return parseMpvSubtitleTracks(trackListRaw);
|
||||
}
|
||||
|
||||
async function waitForPreferredSubtitleTracks(
|
||||
deps: {
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
wait: (ms: number) => Promise<void>;
|
||||
},
|
||||
shouldWaitForExternalJapanese: boolean,
|
||||
expectedExternalFilenames: string[],
|
||||
): Promise<MpvSubtitleTrack[] | null> {
|
||||
let subtitleTracks: MpvSubtitleTrack[] = [];
|
||||
for (let attempt = 1; attempt <= TRACK_SELECTION_MAX_ATTEMPTS; attempt += 1) {
|
||||
const nextTracks = await readMpvSubtitleTracks(deps);
|
||||
if (nextTracks !== null) {
|
||||
subtitleTracks = nextTracks;
|
||||
if (
|
||||
(!shouldWaitForExternalJapanese || hasExternalJapaneseTrack(subtitleTracks)) &&
|
||||
hasExpectedExternalSubtitleTracks(subtitleTracks, expectedExternalFilenames)
|
||||
) {
|
||||
return subtitleTracks;
|
||||
}
|
||||
}
|
||||
if (attempt < TRACK_SELECTION_MAX_ATTEMPTS) {
|
||||
await deps.wait(TRACK_SELECTION_RETRY_MS);
|
||||
}
|
||||
}
|
||||
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,
|
||||
@@ -90,14 +314,41 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
sendMpvCommand: (command: Array<string | number>) => void;
|
||||
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;
|
||||
}) {
|
||||
return async (params: {
|
||||
}): PreloadJellyfinExternalSubtitlesHandler {
|
||||
const activeCacheDirs = new Set<string>();
|
||||
let preloadQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
function resetManagedSubtitleDelay(): void {
|
||||
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
|
||||
}
|
||||
|
||||
function cleanupActiveCache(): void {
|
||||
const dirs = [...activeCacheDirs];
|
||||
if (dirs.length === 0) return;
|
||||
deps.cleanupCachedSubtitles(dirs);
|
||||
for (const dir of dirs) {
|
||||
activeCacheDirs.delete(dir);
|
||||
}
|
||||
}
|
||||
|
||||
const runPreload = async (params: {
|
||||
session: JellyfinSession;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
itemId: string;
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
try {
|
||||
cleanupActiveCache();
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to cleanup Jellyfin cached subtitles', error);
|
||||
}
|
||||
const tracks = await deps.listJellyfinSubtitleTracks(
|
||||
params.session,
|
||||
params.clientInfo,
|
||||
@@ -105,11 +356,18 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
);
|
||||
const externalTracks = tracks.filter((track) => Boolean(track.deliveryUrl));
|
||||
if (externalTracks.length === 0) {
|
||||
deps.setActiveSubtitleDelayKey?.(null);
|
||||
resetManagedSubtitleDelay();
|
||||
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[] = [];
|
||||
for (const track of externalTracks) {
|
||||
if (!track.deliveryUrl || seenUrls.has(track.deliveryUrl)) {
|
||||
continue;
|
||||
@@ -117,36 +375,80 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
seenUrls.add(track.deliveryUrl);
|
||||
const labelBase = (track.title || track.language || '').trim();
|
||||
const label = labelBase || `Jellyfin Subtitle ${track.index}`;
|
||||
deps.sendMpvCommand(['sub-add', track.deliveryUrl, 'cached', label, track.language || '']);
|
||||
const cached = await deps.cacheSubtitleTrack(track);
|
||||
activeCacheDirs.add(cached.cleanupDir);
|
||||
cachedTracks.push({ ...cached, source: track });
|
||||
deps.sendMpvCommand(['sub-add', cached.path, 'auto', label, track.language || '']);
|
||||
}
|
||||
|
||||
await deps.wait(250);
|
||||
const trackListRaw = await deps.getMpvClient()?.requestProperty('track-list');
|
||||
const subtitleTracks = Array.isArray(trackListRaw)
|
||||
? trackListRaw
|
||||
.filter(
|
||||
(track): track is Record<string, unknown> =>
|
||||
Boolean(track) &&
|
||||
typeof track === 'object' &&
|
||||
track.type === 'sub' &&
|
||||
typeof track.id === 'number',
|
||||
)
|
||||
.map((track) => ({
|
||||
id: track.id as number,
|
||||
lang: String(track.lang || ''),
|
||||
title: String(track.title || ''),
|
||||
external: track.external === true,
|
||||
}))
|
||||
: [];
|
||||
await deps.wait(TRACK_SELECTION_INITIAL_WAIT_MS);
|
||||
const shouldWaitForExternalJapanese = externalTracks.some(
|
||||
(track) => isJapanese(track.language || '') || isJapanese(track.title || ''),
|
||||
);
|
||||
const subtitleTracks = await waitForPreferredSubtitleTracks(
|
||||
deps,
|
||||
shouldWaitForExternalJapanese,
|
||||
cachedTracks.map((track) => track.path),
|
||||
);
|
||||
if (
|
||||
shouldWaitForExternalJapanese &&
|
||||
(!subtitleTracks || !hasExternalJapaneseTrack(subtitleTracks))
|
||||
) {
|
||||
deps.logDebug('Timed out waiting for Jellyfin Japanese subtitle track', {
|
||||
itemId: params.itemId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const japanesePrimaryId = pickBestTrackId(subtitleTracks, isJapanese);
|
||||
const resolvedSubtitleTracks = subtitleTracks ?? [];
|
||||
const japanesePrimaryId =
|
||||
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 = pickBestTrackId(subtitleTracks, isEnglish, japanesePrimaryId);
|
||||
if (englishSecondaryId !== null) {
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]);
|
||||
}
|
||||
@@ -154,4 +456,20 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
|
||||
deps.logDebug('Failed to preload Jellyfin external subtitles', error);
|
||||
}
|
||||
};
|
||||
|
||||
const preload = (params: {
|
||||
session: JellyfinSession;
|
||||
clientInfo: JellyfinClientInfo;
|
||||
itemId: string;
|
||||
}): Promise<void> => {
|
||||
preloadQueue = preloadQueue.then(
|
||||
() => runPreload(params),
|
||||
() => runPreload(params),
|
||||
);
|
||||
return preloadQueue;
|
||||
};
|
||||
|
||||
return Object.assign(preload, {
|
||||
cleanupCachedSubtitles: cleanupActiveCache,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -194,6 +194,124 @@ test('stops active discovery from tray', async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses checked tray state to start discovery instead of blind toggling', async () => {
|
||||
const calls: string[] = [];
|
||||
let session: { advertiseNow: () => Promise<boolean> } | null = null;
|
||||
|
||||
await toggleJellyfinDiscoveryFromTray(
|
||||
{
|
||||
getRemoteSession: () => session,
|
||||
stopRemoteSession: () => calls.push('stop'),
|
||||
startRemoteSession: async (options) => {
|
||||
assert.deepEqual(options, { explicit: true });
|
||||
calls.push('start');
|
||||
session = {
|
||||
advertiseNow: async () => {
|
||||
calls.push('advertise');
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
refreshTrayMenu: () => calls.push('refresh'),
|
||||
logger: {
|
||||
info: (message) => calls.push(`info:${message}`),
|
||||
warn: (message) => calls.push(`warn:${message}`),
|
||||
error: (message) => calls.push(`error:${message}`),
|
||||
},
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
},
|
||||
{ desiredActive: true },
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'start',
|
||||
'advertise',
|
||||
'info:Jellyfin discovery started; cast target is visible in server sessions.',
|
||||
'osd:Jellyfin discovery started',
|
||||
'refresh',
|
||||
]);
|
||||
});
|
||||
|
||||
test('uses unchecked tray state to stop discovery without visibility probing', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await toggleJellyfinDiscoveryFromTray(
|
||||
{
|
||||
getRemoteSession: () => ({
|
||||
advertiseNow: async () => {
|
||||
calls.push('advertise');
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
stopRemoteSession: () => calls.push('stop'),
|
||||
startRemoteSession: async () => {
|
||||
calls.push('start');
|
||||
},
|
||||
refreshTrayMenu: () => calls.push('refresh'),
|
||||
logger: {
|
||||
info: (message) => calls.push(`info:${message}`),
|
||||
warn: (message) => calls.push(`warn:${message}`),
|
||||
error: (message) => calls.push(`error:${message}`),
|
||||
},
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
},
|
||||
{ desiredActive: false },
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'stop',
|
||||
'info:Jellyfin discovery stopped.',
|
||||
'osd:Jellyfin discovery stopped',
|
||||
'refresh',
|
||||
]);
|
||||
});
|
||||
|
||||
test('restarts active discovery when current session is not visible', async () => {
|
||||
const calls: string[] = [];
|
||||
let session: { advertiseNow: () => Promise<boolean> } | null = {
|
||||
advertiseNow: async () => {
|
||||
calls.push('advertise-stale');
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
await toggleJellyfinDiscoveryFromTray({
|
||||
getRemoteSession: () => session,
|
||||
stopRemoteSession: () => {
|
||||
calls.push('stop');
|
||||
session = null;
|
||||
},
|
||||
startRemoteSession: async (options) => {
|
||||
assert.deepEqual(options, { explicit: true });
|
||||
calls.push('start');
|
||||
session = {
|
||||
advertiseNow: async () => {
|
||||
calls.push('advertise-fresh');
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
refreshTrayMenu: () => calls.push('refresh'),
|
||||
logger: {
|
||||
info: (message) => calls.push(`info:${message}`),
|
||||
warn: (message) => calls.push(`warn:${message}`),
|
||||
error: (message) => calls.push(`error:${message}`),
|
||||
},
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'advertise-stale',
|
||||
'warn:Jellyfin discovery was active but not visible; restarting.',
|
||||
'stop',
|
||||
'start',
|
||||
'advertise-fresh',
|
||||
'info:Jellyfin discovery started; cast target is visible in server sessions.',
|
||||
'osd:Jellyfin discovery started',
|
||||
'refresh',
|
||||
]);
|
||||
});
|
||||
|
||||
test('warns and refreshes tray when explicit discovery cannot create a session', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
|
||||
@@ -66,16 +66,42 @@ export async function toggleJellyfinDiscoveryFromTray<TSession extends JellyfinT
|
||||
| 'logger'
|
||||
| 'showMpvOsd'
|
||||
>,
|
||||
options: { desiredActive?: boolean } = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
const activeSession = deps.getRemoteSession();
|
||||
if (activeSession) {
|
||||
deps.stopRemoteSession();
|
||||
deps.logger.info('Jellyfin discovery stopped.');
|
||||
deps.showMpvOsd('Jellyfin discovery stopped');
|
||||
if (options.desiredActive === false) {
|
||||
if (activeSession) {
|
||||
deps.stopRemoteSession();
|
||||
deps.logger.info('Jellyfin discovery stopped.');
|
||||
deps.showMpvOsd('Jellyfin discovery stopped');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeSession) {
|
||||
let visible = false;
|
||||
try {
|
||||
visible = await activeSession.advertiseNow();
|
||||
} catch {
|
||||
deps.logger.warn('Jellyfin discovery visibility check failed; restarting.');
|
||||
}
|
||||
|
||||
if (visible) {
|
||||
if (options.desiredActive === true) {
|
||||
deps.logger.info('Jellyfin discovery already active.');
|
||||
} else {
|
||||
deps.stopRemoteSession();
|
||||
deps.logger.info('Jellyfin discovery stopped.');
|
||||
deps.showMpvOsd('Jellyfin discovery stopped');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
deps.logger.warn('Jellyfin discovery was active but not visible; restarting.');
|
||||
deps.stopRemoteSession();
|
||||
}
|
||||
|
||||
await deps.startRemoteSession({ explicit: true });
|
||||
const remoteSession = deps.getRemoteSession();
|
||||
if (!remoteSession) {
|
||||
|
||||
@@ -175,3 +175,57 @@ test('managed local subtitle selection keeps waiting for primary after early sec
|
||||
['set_property', 'sid', 3],
|
||||
]);
|
||||
});
|
||||
|
||||
test('managed local subtitle selection keeps pending refresh after early primary-only track list', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const scheduled = new Map<number, () => void>();
|
||||
let nextTimerId = 1;
|
||||
|
||||
const runtime = createManagedLocalSubtitleSelectionRuntime({
|
||||
getCurrentMediaPath: () => '/videos/example.mkv',
|
||||
getMpvClient: () =>
|
||||
({
|
||||
connected: true,
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'track-list') {
|
||||
return [
|
||||
{ type: 'sub', id: 3, lang: 'ja', title: 'ja.srt', external: true },
|
||||
{ type: 'sub', id: 4, lang: 'en', title: 'en.srt', external: true },
|
||||
];
|
||||
}
|
||||
throw new Error(`Unexpected property: ${name}`);
|
||||
},
|
||||
}) as never,
|
||||
getPrimarySubtitleLanguages: () => [],
|
||||
getSecondarySubtitleLanguages: () => [],
|
||||
sendMpvCommand: (command) => {
|
||||
commands.push(command);
|
||||
},
|
||||
schedule: (callback) => {
|
||||
const timerId = nextTimerId++;
|
||||
scheduled.set(timerId, callback);
|
||||
return timerId as never;
|
||||
},
|
||||
clearScheduled: (timer) => {
|
||||
scheduled.delete(timer as never);
|
||||
},
|
||||
});
|
||||
|
||||
runtime.handleMediaPathChange('/videos/example.mkv');
|
||||
runtime.handleSubtitleTrackListChange([
|
||||
{ type: 'sub', id: 3, lang: 'ja', title: 'ja.srt', external: true },
|
||||
]);
|
||||
|
||||
assert.deepEqual(commands, [['set_property', 'sid', 3]]);
|
||||
assert.equal(scheduled.size, 1);
|
||||
|
||||
const refresh = [...scheduled.values()][0];
|
||||
assert.ok(refresh);
|
||||
refresh();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
['set_property', 'sid', 3],
|
||||
['set_property', 'secondary-sid', 4],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -212,12 +212,11 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
pendingTimer = null;
|
||||
};
|
||||
|
||||
const hasAppliedSelectionForCurrentMediaPath = (): boolean =>
|
||||
appliedPrimaryMediaPath === currentMediaPath && appliedSecondaryMediaPath === currentMediaPath;
|
||||
|
||||
const maybeApplySelection = (trackList: unknown[] | null): void => {
|
||||
if (
|
||||
!currentMediaPath ||
|
||||
(appliedPrimaryMediaPath === currentMediaPath &&
|
||||
appliedSecondaryMediaPath === currentMediaPath)
|
||||
) {
|
||||
if (!currentMediaPath || hasAppliedSelectionForCurrentMediaPath()) {
|
||||
return;
|
||||
}
|
||||
const selection = resolveManagedLocalSubtitleSelection({
|
||||
@@ -236,7 +235,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sid', selection.secondaryTrackId]);
|
||||
appliedSecondaryMediaPath = currentMediaPath;
|
||||
}
|
||||
if (appliedPrimaryMediaPath === currentMediaPath) {
|
||||
if (hasAppliedSelectionForCurrentMediaPath()) {
|
||||
clearPendingTimer();
|
||||
}
|
||||
};
|
||||
@@ -260,7 +259,7 @@ export function createManagedLocalSubtitleSelectionRuntime(deps: {
|
||||
|
||||
const scheduleRefresh = (): void => {
|
||||
clearPendingTimer();
|
||||
if (!currentMediaPath || appliedPrimaryMediaPath === currentMediaPath) {
|
||||
if (!currentMediaPath || hasAppliedSelectionForCurrentMediaPath()) {
|
||||
return;
|
||||
}
|
||||
pendingTimer = deps.schedule(() => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -168,6 +168,28 @@ test('media path change handler signals autoplay readiness from warm media path'
|
||||
]);
|
||||
});
|
||||
|
||||
test('media path change handler marks Jellyfin remote playback loaded from media path', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvMediaPathChangeHandler({
|
||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||
reportJellyfinRemoteStopped: () => calls.push('stopped'),
|
||||
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
|
||||
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
|
||||
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
|
||||
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
|
||||
syncImmersionMediaState: () => calls.push('sync'),
|
||||
markJellyfinRemotePlaybackLoaded: (path) => calls.push(`jellyfin-loaded:${path}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
});
|
||||
|
||||
handler({ path: 'https://stream.example/video.m3u8' });
|
||||
|
||||
assert.ok(calls.includes('jellyfin-loaded:https://stream.example/video.m3u8'));
|
||||
assert.equal(calls.includes('stopped'), false);
|
||||
});
|
||||
|
||||
test('media title change handler clears guess state without re-scheduling character dictionary sync', () => {
|
||||
const calls: string[] = [];
|
||||
const deps: Parameters<typeof createHandleMpvMediaTitleChangeHandler>[0] & {
|
||||
@@ -222,6 +244,36 @@ test('time-pos and pause handlers report progress with correct urgency', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('time-pos handler forces Jellyfin progress when mpv position jumps', () => {
|
||||
const calls: string[] = [];
|
||||
const timeHandler = createHandleMpvTimePosChangeHandler({
|
||||
recordPlaybackPosition: (time) => calls.push(`time:${time}`),
|
||||
reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`),
|
||||
refreshDiscordPresence: () => calls.push('presence'),
|
||||
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||
});
|
||||
|
||||
timeHandler({ time: 10 });
|
||||
timeHandler({ time: 11 });
|
||||
timeHandler({ time: 90 });
|
||||
timeHandler({ time: 30 });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'time:10',
|
||||
'progress:normal',
|
||||
'presence',
|
||||
'time:11',
|
||||
'progress:normal',
|
||||
'presence',
|
||||
'time:90',
|
||||
'progress:force',
|
||||
'presence',
|
||||
'time:30',
|
||||
'progress:force',
|
||||
'presence',
|
||||
]);
|
||||
});
|
||||
|
||||
test('time-pos handler passes fresh playback time to AniList post-watch', async () => {
|
||||
const watchedSeconds: unknown[] = [];
|
||||
const timeHandler = createHandleMpvTimePosChangeHandler({
|
||||
|
||||
@@ -4,6 +4,15 @@ type AnilistPostWatchRunOptions = {
|
||||
watchedSeconds?: number;
|
||||
};
|
||||
|
||||
const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5;
|
||||
|
||||
function isSeekLikeTimeChange(previousTime: number | null, nextTime: number): boolean {
|
||||
if (previousTime === null || !Number.isFinite(previousTime) || !Number.isFinite(nextTime)) {
|
||||
return false;
|
||||
}
|
||||
return Math.abs(nextTime - previousTime) >= SEEK_LIKE_TIME_DELTA_SECONDS;
|
||||
}
|
||||
|
||||
export function createHandleMpvSubtitleChangeHandler(deps: {
|
||||
setCurrentSubText: (text: string) => void;
|
||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||
@@ -59,6 +68,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
|
||||
syncImmersionMediaState: () => void;
|
||||
scheduleCharacterDictionarySync?: () => void;
|
||||
signalAutoplayReadyIfWarm?: (path: string) => void;
|
||||
markJellyfinRemotePlaybackLoaded?: (path: string) => void;
|
||||
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
}) {
|
||||
@@ -81,6 +91,7 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
|
||||
}
|
||||
deps.syncImmersionMediaState();
|
||||
if (normalizedPath.trim().length > 0) {
|
||||
deps.markJellyfinRemotePlaybackLoaded?.(normalizedPath);
|
||||
deps.scheduleCharacterDictionarySync?.();
|
||||
deps.signalAutoplayReadyIfWarm?.(normalizedPath);
|
||||
}
|
||||
@@ -113,9 +124,15 @@ export function createHandleMpvTimePosChangeHandler(deps: {
|
||||
logError?: (message: string, error: unknown) => void;
|
||||
onTimePosUpdate?: (time: number) => void;
|
||||
}) {
|
||||
let lastObservedTime: number | null = null;
|
||||
|
||||
return ({ time }: { time: number }): void => {
|
||||
const forceImmediate = isSeekLikeTimeChange(lastObservedTime, time);
|
||||
if (Number.isFinite(time)) {
|
||||
lastObservedTime = time;
|
||||
}
|
||||
deps.recordPlaybackPosition(time);
|
||||
deps.reportJellyfinRemoteProgress(false);
|
||||
deps.reportJellyfinRemoteProgress(forceImmediate);
|
||||
deps.refreshDiscordPresence();
|
||||
void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => {
|
||||
deps.logError?.('AniList post-watch update failed unexpectedly', error);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user