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:
2026-05-24 18:40:56 -07:00
committed by GitHub
parent da3c971ee6
commit b1bdeabca8
193 changed files with 7975 additions and 771 deletions
+10 -3
View File
@@ -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,
+2 -27
View File
@@ -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',
-3
View File
@@ -371,9 +371,6 @@ export function applyIntegrationConfig(context: ResolveContext): void {
const stringKeys = [
'serverUrl',
'username',
'deviceId',
'clientName',
'clientVersion',
'defaultLibraryId',
'iconCacheDir',
'transcodeVideoCodec',
-4
View File
@@ -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',
]) {
-4
View File
@@ -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',
+38
View File
@@ -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({
+6 -3
View File
@@ -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>();
+13 -1
View File
@@ -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;
+107 -2
View File
@@ -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);
+6
View File
@@ -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,
+38
View File
@@ -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[] = [];
+5 -7
View File
@@ -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;
}
}
+6 -1
View File
@@ -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;
}
+23 -6
View File
@@ -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,
+2 -2
View File
@@ -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 });
+51 -1
View File
@@ -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>> = [];
+9 -3
View File
@@ -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;
+153 -5
View File
@@ -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 = {
+24 -3
View File
@@ -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();
}
+29
View File
@@ -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);
});
+49 -1
View File
@@ -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,
+87
View File
@@ -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[] = [];
+82 -1
View File
@@ -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();
}
+41
View File
@@ -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;
+1
View File
@@ -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: () =>
+8 -1
View File
@@ -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);
});
+153
View File
@@ -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;
}
+10 -10
View File
@@ -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
+71
View File
@@ -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);
+74 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+59
View File
@@ -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\(\)/);
+15 -3
View File
@@ -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',
},
+1 -1
View File
@@ -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();
};
+2
View File
@@ -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);
+47 -1
View File
@@ -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;
+6 -1
View File
@@ -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(),
});
}
+80 -1
View File
@@ -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;
+16
View File
@@ -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: {
+1
View File
@@ -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';
+33 -7
View File
@@ -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: () => {},
-9
View File
@@ -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,
});
}
+32 -18
View File
@@ -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',
});
});
+13 -11
View File
@@ -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,
});
}
+607 -12
View File
@@ -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',
],
);
});
+136 -29
View File
@@ -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)[]> = [];
+14 -1
View File
@@ -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);
});
+164 -12
View File
@@ -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,
});
}
+133 -16
View File
@@ -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
+349 -31
View File
@@ -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[] = [];
+30 -4
View File
@@ -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],
]);
});
+6 -7
View File
@@ -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',
+4 -3
View File
@@ -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({
+18 -1
View File
@@ -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