Files
SubMiner/src/main-entry-runtime.test.ts
T
sudacode b1bdeabca8 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
2026-05-24 18:40:56 -07:00

404 lines
12 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
configureEarlyAppPaths,
normalizeLaunchMpvExtraArgs,
normalizeStartupArgv,
normalizeLaunchMpvTargets,
resolveStatsDaemonCommandAction,
sanitizeHelpEnv,
sanitizeLaunchMpvEnv,
sanitizeStartupEnv,
sanitizeBackgroundEnv,
shouldDetachBackgroundLaunch,
shouldHandleHelpOnlyAtEntry,
shouldHandleLaunchMpvAtEntry,
shouldHandleStatsDaemonCommandAtEntry,
hasTransportedStartupArgs,
shouldForwardStartupArgvViaAppControl,
applyEarlyLinuxCommandLineSwitches,
resolveLinuxPasswordStoreValue,
} from './main-entry-runtime';
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
const originalPlatform = process.platform;
try {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage'], {}), [
'SubMiner.AppImage',
'--start',
'--background',
]);
assert.deepEqual(
normalizeStartupArgv(['SubMiner.AppImage', '--password-store', 'gnome-libsecret'], {}),
['SubMiner.AppImage', '--password-store', 'gnome-libsecret', '--start', '--background'],
);
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage', '--background'], {}), [
'SubMiner.AppImage',
'--background',
'--start',
]);
assert.deepEqual(normalizeStartupArgv(['SubMiner.AppImage', '--help'], {}), [
'SubMiner.AppImage',
'--help',
]);
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
test('normalizeStartupArgv defaults no-arg Windows startup to --start only', () => {
const originalPlatform = process.platform;
try {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
assert.deepEqual(normalizeStartupArgv(['SubMiner.exe'], {}), ['SubMiner.exe', '--start']);
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
test('normalizeStartupArgv uses transported AppImage args instead of raw Electron args', () => {
assert.deepEqual(
normalizeStartupArgv(['SubMiner.AppImage', '--background'], {
SUBMINER_APP_ARGC: '2',
SUBMINER_APP_ARG_0: '--stop',
SUBMINER_APP_ARG_1: '--socket',
}),
['SubMiner.AppImage', '--stop', '--socket'],
);
});
test('normalizeStartupArgv defaults empty transported AppImage args to background startup', () => {
const originalPlatform = process.platform;
try {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
assert.deepEqual(
normalizeStartupArgv(['SubMiner.AppImage', '--background'], {
SUBMINER_APP_ARGC: '0',
}),
['SubMiner.AppImage', '--start', '--background'],
);
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
test('normalizeStartupArgv defaults passive-only transported AppImage args to background startup', () => {
const originalPlatform = process.platform;
try {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
assert.deepEqual(
normalizeStartupArgv(['SubMiner.AppImage'], {
SUBMINER_APP_ARGC: '2',
SUBMINER_APP_ARG_0: '--password-store',
SUBMINER_APP_ARG_1: 'gnome-libsecret',
}),
['SubMiner.AppImage', '--password-store', 'gnome-libsecret', '--start', '--background'],
);
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
test('hasTransportedStartupArgs detects env-carried app args', () => {
assert.equal(hasTransportedStartupArgs({ SUBMINER_APP_ARGC: '1' }), true);
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);
assert.equal(shouldHandleHelpOnlyAtEntry(['--start'], {}), false);
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], { ELECTRON_RUN_AS_NODE: '1' }), false);
});
test('launch-mpv entry helpers detect and normalize targets', () => {
assert.equal(shouldHandleLaunchMpvAtEntry(['SubMiner.exe', '--launch-mpv'], {}), true);
assert.equal(
shouldHandleLaunchMpvAtEntry(['SubMiner.exe', '--launch-mpv'], { ELECTRON_RUN_AS_NODE: '1' }),
false,
);
assert.deepEqual(normalizeLaunchMpvTargets(['SubMiner.exe', '--launch-mpv']), []);
assert.deepEqual(normalizeLaunchMpvTargets(['SubMiner.exe', '--launch-mpv', 'C:\\a.mkv']), [
'C:\\a.mkv',
]);
assert.deepEqual(
normalizeLaunchMpvExtraArgs([
'SubMiner.exe',
'--launch-mpv',
'--sub-file',
'track.srt',
'C:\\a.mkv',
]),
['--sub-file', 'track.srt'],
);
assert.deepEqual(
normalizeLaunchMpvTargets([
'SubMiner.exe',
'--launch-mpv',
'--sub-file',
'track.srt',
'C:\\a.mkv',
]),
['C:\\a.mkv'],
);
assert.deepEqual(
normalizeLaunchMpvExtraArgs([
'SubMiner.exe',
'--launch-mpv',
'--profile=subminer',
'--pause=yes',
'C:\\a.mkv',
]),
['--profile=subminer', '--pause=yes'],
);
assert.deepEqual(
normalizeLaunchMpvExtraArgs([
'SubMiner.exe',
'--launch-mpv',
'--input-ipc-server',
'\\\\.\\pipe\\custom-subminer-socket',
'--alang',
'ja,jpn',
'C:\\a.mkv',
]),
['--input-ipc-server', '\\\\.\\pipe\\custom-subminer-socket', '--alang', 'ja,jpn'],
);
assert.deepEqual(
normalizeLaunchMpvExtraArgs(['SubMiner.exe', '--launch-mpv', '--fullscreen', 'C:\\a.mkv']),
['--fullscreen'],
);
assert.deepEqual(
normalizeLaunchMpvTargets([
'SubMiner.exe',
'--launch-mpv',
'--input-ipc-server',
'\\\\.\\pipe\\custom-subminer-socket',
'--alang',
'ja,jpn',
'C:\\a.mkv',
'C:\\b.mkv',
]),
['C:\\a.mkv', 'C:\\b.mkv'],
);
assert.deepEqual(
normalizeLaunchMpvTargets(['SubMiner.exe', '--launch-mpv', '--fullscreen', 'C:\\a.mkv']),
['C:\\a.mkv'],
);
assert.deepEqual(
normalizeLaunchMpvExtraArgs([
'SubMiner.exe',
'--launch-mpv',
'--msg-level',
'all=warn',
'C:\\a.mkv',
]),
['--msg-level', 'all=warn'],
);
});
test('stats-daemon entry helper detects internal daemon commands', () => {
assert.equal(
shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--stats-daemon-start'], {}),
true,
);
assert.equal(
shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--stats-daemon-stop'], {}),
true,
);
assert.equal(
shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--stats-daemon-start'], {
ELECTRON_RUN_AS_NODE: '1',
}),
false,
);
assert.equal(shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--start'], {}), false);
});
test('stats-daemon entry helper detects public background stats commands', () => {
assert.equal(
shouldHandleStatsDaemonCommandAtEntry(
['SubMiner.AppImage', '--stats', '--stats-background'],
{},
),
true,
);
assert.equal(
shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--stats', '--stats-stop'], {}),
true,
);
assert.equal(
shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--stats-background'], {}),
true,
);
assert.equal(
shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--stats-background'], {
ELECTRON_RUN_AS_NODE: '1',
}),
false,
);
assert.equal(shouldHandleStatsDaemonCommandAtEntry(['SubMiner.AppImage', '--stats'], {}), false);
});
test('stats-daemon entry helper resolves daemon action for public and internal commands', () => {
assert.equal(
resolveStatsDaemonCommandAction(['SubMiner.AppImage', '--stats-daemon-start']),
'start',
);
assert.equal(
resolveStatsDaemonCommandAction(['SubMiner.AppImage', '--stats-daemon-stop']),
'stop',
);
assert.equal(
resolveStatsDaemonCommandAction(['SubMiner.AppImage', '--stats', '--stats-background']),
'start',
);
assert.equal(
resolveStatsDaemonCommandAction(['SubMiner.AppImage', '--stats', '--stats-stop']),
'stop',
);
assert.equal(resolveStatsDaemonCommandAction(['SubMiner.AppImage', '--stats']), null);
});
test('sanitizeStartupEnv suppresses warnings and lsfg layer', () => {
const env = sanitizeStartupEnv({
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
});
assert.equal(env.NODE_NO_WARNINGS, '1');
assert.equal('VK_INSTANCE_LAYERS' in env, false);
});
test('sanitizeHelpEnv suppresses warnings and lsfg layer', () => {
const env = sanitizeHelpEnv({
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
});
assert.equal(env.NODE_NO_WARNINGS, '1');
assert.equal('VK_INSTANCE_LAYERS' in env, false);
});
test('sanitizeLaunchMpvEnv suppresses warnings and lsfg layer', () => {
const env = sanitizeLaunchMpvEnv({
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
});
assert.equal(env.NODE_NO_WARNINGS, '1');
assert.equal('VK_INSTANCE_LAYERS' in env, false);
});
test('sanitizeBackgroundEnv marks background child and keeps warning suppression', () => {
const env = sanitizeBackgroundEnv({
VK_INSTANCE_LAYERS: 'foo:lsfg-vk:bar',
});
assert.equal(env.SUBMINER_BACKGROUND_CHILD, '1');
assert.equal(env.NODE_NO_WARNINGS, '1');
assert.equal('VK_INSTANCE_LAYERS' in env, false);
});
test('shouldDetachBackgroundLaunch only for first background invocation', () => {
assert.equal(shouldDetachBackgroundLaunch(['--background'], {}), true);
assert.equal(
shouldDetachBackgroundLaunch(['--background'], { SUBMINER_BACKGROUND_CHILD: '1' }),
false,
);
assert.equal(
shouldDetachBackgroundLaunch(['--background'], { ELECTRON_RUN_AS_NODE: '1' }),
false,
);
assert.equal(shouldDetachBackgroundLaunch(['--start'], {}), false);
});
test('configureEarlyAppPaths pins userData to canonical SubMiner config dir', () => {
const calls: string[] = [];
const userDataPath = configureEarlyAppPaths(
{
setName: (name) => {
calls.push(`name:${name}`);
},
setPath: (key, value) => {
calls.push(`path:${key}:${value}`);
},
},
{
platform: 'linux',
homeDir: '/home/tester',
xdgConfigHome: '/tmp/xdg',
existsSync: (candidate) => candidate === '/tmp/xdg/subminer/config.jsonc',
},
);
assert.equal(userDataPath, '/tmp/xdg/SubMiner');
assert.deepEqual(calls, ['name:SubMiner', 'path:userData:/tmp/xdg/SubMiner']);
});