feat(tokenizer): use Yomitan word classes for subtitle POS filtering (#57)

* feat(tokenizer): use Yomitan word classes for subtitle POS filtering

- Carry matched headword wordClasses from termsFind into YomitanScanToken
- Map recognized Yomitan wordClasses to SubMiner coarse POS before annotation
- MeCab enrichment now fills only missing POS fields, preserving existing coarse pos1
- Exclude standalone grammar particles, して helper fragments, and single-kana surfaces from annotations
- Respect source-text punctuation gaps when counting N+1 sentence words
- Preserve known-word highlight on excluded kanji-containing tokens
- Add backlog tasks 304 (N+1 boundary bug) and 305 (wordClasses POS, done)

* fix(tokenizer): preserve annotation and enrichment behavior

* fix: restore jlpt subtitle underlines

* fix: exclude kana-only n+1 targets

* fix: refresh overlay on Hyprland fullscreen

* fix: address fullscreen and n-plus-one review notes

* fix: address CodeRabbit review comments

* fix: accept modified digits for multi-line sentence mining

* Cancel pending Linux MPV fullscreen overlay refresh bursts

- return a cancel handle from the Linux refresh burst scheduler
- clear pending refresh bursts when overlays hide or windows close
- tighten the burst test polling to wait for the async refresh

* fix: suppress N+1 for kana-only candidates and fix minSentenceWords coun

- Treat kana-only tokens with surrounding subtitle punctuation (…, ―, etc.) as kana-only so they are not promoted to N+1 targets
- Exclude unknown tokens filtered from N+1 targeting from the minSentenceWords count so filtered kana-only unknowns cannot satisfy sentence length threshold
- Add regression tests for kana-only candidate suppression and filtered-unknown padding cases

* Suppress subtitle annotations for grammar fragments

- Hide annotation metadata for auxiliary inflection and ja-nai endings
- Preserve lexical `くれる` forms and add regression coverage

* Fix kana-only N+1 tokenizer regression test

- Use a pure-kana fixture for the subtitle token N+1 case
- Update task notes for the latest CodeRabbit follow-up

* Fix managed playback exit and tokenizer grammar splits

- Ignore background stats daemons during regular app startup
- Split standalone grammar endings before applying annotations
- Clear helper-span annotations for auxiliary-only tokens

* fix: refresh current subtitle after known-word mining

* fix: suppress sigh interjection annotations

* fix: preserve jlpt underline color after lookup

* Replace grammar-ending permutations with shared matcher; preserve word a

- Extract `grammar-ending.ts` with `isStandaloneGrammarEndingText` / `isSubtitleGrammarEndingText` pattern matchers
- Replace `STANDALONE_GRAMMAR_ENDINGS` set in parser-selection-stage with shared matcher
- Replace generated phrase sets in subtitle-annotation-filter with shared matcher
- Remove stale duplicate subtitle-exclusion constants and helpers from annotation-stage
- Manual clipboard card updates now write only to the sentence audio field, leaving word/expression audio untouched

* fix: CI changelog, annotation options threading, and Jellyfin quit

- Add `type: fixed` / `area:` frontmatter to `changes/319` to pass `changelog:lint`
- Thread `TokenizerAnnotationOptions` through `stripSubtitleAnnotationMetadata` so `sourceText` is honored
- Include `jellyfinPlay` in `shouldQuitOnDisconnectWhenOverlayRuntimeInitialized` predicate
- Make mouse test `elementFromPoint` stubs coordinate-sensitive
- Make Lua test `.tmp` mkdir portable on Windows

* Preserve overlay across macOS flaps and mpv playlist changes

- keep visible overlays alive during transient macOS tracker loss
- reuse the running mpv overlay path on playlist navigation
- update regression coverage and changelog fragments

* fix: restore stats daemon deferral

* fix: keep subtitle prefetch alive after cache hits

* Fix JLPT underline color drift and AniList skipped-threshold sync

- Replace JLPT `text-decoration` underlines with `border-bottom` so Chromium selection/hover cannot repaint them to another annotation's color
- Lock JLPT underline color for combined annotation selectors (known, n+1, frequency) and character hover/selection states
- Trigger AniList post-watch check on every mpv time-position update to catch skipped completion thresholds
- Fall back to filename-parser season/episode when guessit omits them

* fix: address coderabbit feedback

* fix: sync AniList after seeked completion

* fix: preserve ordinal frequency annotations

* fix: preserve known highlighting for filtered tokens

* fix: address PR #57 CodeRabbit feedback

- Acquire AniList post-watch in-flight lock before async gating to prevent duplicate writes
- Isolate manual watched mark result from AniList post-watch callback failures
- Report known-word cache clears as mutations during immediate append when state existed
- Add regression tests for each fix

* fix: stop AniList setup reopening on Linux when keyring token exists

- Gate setup success on token persistence: `saveToken` now returns `boolean`; on failure, keeps the setup window open instead of reporting success
- Config reload passes `allowSetupPrompt: false` so playback reloads don't re-open the setup window
- Add regression test for persistence-failure path

* fix: suppress known highlights for subtitle particles

* fix: retry transient AniList safeStorage failures

* fix: hide overlay focus ring

* fix: align Hyprland fullscreen overlays

* fix: restore subtitle playback keybindings

* fix: align Hyprland overlay windows to mpv and stop pinning them

- Force-apply exact Hyprland move/resize/setprop dispatches when bounds are provided
- Stop pinning overlay windows; toggle pin off when Hyprland reports pinned=true
- Compensate stats overlay outer placement for Electron/Wayland content insets
- Make stats overlay window and page opaque so mpv cannot show through transparent insets
- Constrain stats app to h-screen with internal scroll so content covers mpv from y=0
- Lock overlay/stats window titles against page-title-updated events
- Add regression coverage for placement dispatches, inset compensation, and CSS overlay mode

* fix: retain frequency rank for honorific prefix-noun tokens

- Add `shouldAllowHonorificPrefixNounFrequency` to exempt お/ご/御 + noun merged tokens from frequency exclusion
- Add regression test for `ご機嫌` asserting rank 5484 is preserved after MeCab enrichment and annotation
- Close TASK-341

* fix: map openCharacterDictionary session action to --open-character-dict

- Add missing Lua CLI dispatch entry for openCharacterDictionary
- Add regression test for Alt+Meta+A binding and CLI flag forwarding

* fix: keep macOS overlay interactive while mpv remains active

- Overlay no longer hides or becomes click-through during tracker refreshes when mpv is the focused window
- Preserve already-visible overlay when tracker is temporarily not ready but mpv target signal is active
- Add regression tests for active-mpv tracker refresh and transient tracker-not-ready paths

* fix: address coderabbit subtitle follow-ups

* fix: resolve media detail from sessions when lifetime summary is absent

- Change `getMediaDetail` JOIN to LEFT JOIN on `imm_lifetime_media` and fall back to aggregated session metrics when no lifetime row exists
- Add filter `AND (lm.video_id IS NOT NULL OR s.session_id IS NOT NULL)` to keep results valid
- Add regression test covering the session-visible / media-detail-missing mismatch

* fix: address PR-57 CodeRabbit findings and CI failures

- use filtered word counts in media detail session token aggregation
- cancel fullscreen refresh burst on exit via updateLinuxMpvFullscreenOverlayRefreshBurst
- guard Hyprland JSON.parse in try/catch; exclude windowtitle from geometry events
- narrow focus suppression from :focus to :focus-visible
- apply JLPT lock selectors to word-name-match tokens (N1–N5)

* fix: macOS overlay z-order and Yomitan compound token known highlighting

- Release always-on-top when tracked mpv loses foreground on macOS
- Skip visible overlay blur restacking on macOS to avoid covering unrelated windows
- Prefer Yomitan internal parse tokens over fragmented scanner output for known-word decisions
- Add regression tests for both behaviors

* fix: macOS visible-overlay blur no longer invokes Windows-only blur call

- Split win32/darwin branches in handleOverlayWindowBlurred so darwin visible blur returns early without calling onWindowsVisibleOverlayBlur
- Add regression test asserting Windows callback stays inactive on macOS visible overlay blur
- Close TASK-347
This commit is contained in:
2026-05-12 12:08:09 -07:00
committed by GitHub
parent b68d17614d
commit 430373f010
176 changed files with 8174 additions and 569 deletions
+101
View File
@@ -77,6 +77,107 @@ test('createMaybeRunAnilistPostWatchUpdateHandler queues when token missing', as
assert.ok(calls.includes('inflight:false'));
});
test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched updates below threshold', async () => {
const calls: string[] = [];
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => false,
setInFlight: (value) => calls.push(`inflight:${value}`),
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => '/tmp/video.mkv',
hasMpvClient: () => false,
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 0,
maybeProbeAnilistDuration: async () => {
calls.push('probe');
return 1000;
},
ensureAnilistMediaGuess: async () => ({ title: 'Show', episode: 3 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => 'token',
enqueueRetry: () => calls.push('enqueue'),
markRetryFailure: () => calls.push('mark-failure'),
markRetrySuccess: () => calls.push('mark-success'),
refreshRetryQueueState: () => calls.push('refresh'),
updateAnilistPostWatchProgress: async () => {
calls.push('update');
return { status: 'updated', message: 'updated ok' };
},
rememberAttemptedUpdateKey: () => calls.push('remember'),
showMpvOsd: (message) => calls.push(`osd:${message}`),
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
await handler({ force: true });
assert.equal(calls.includes('probe'), false);
assert.ok(calls.includes('update'));
assert.ok(calls.includes('remember'));
assert.ok(calls.includes('osd:updated ok'));
});
test('createMaybeRunAnilistPostWatchUpdateHandler blocks concurrent runs before async gating', async () => {
const calls: string[] = [];
let inFlight = false;
let resolveDuration!: (duration: number) => void;
const durationPromise = new Promise<number>((resolve) => {
resolveDuration = resolve;
});
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
getInFlight: () => inFlight,
setInFlight: (value) => {
inFlight = value;
calls.push(`inflight:${value}`);
},
getResolvedConfig: () => ({}),
isAnilistTrackingEnabled: () => true,
getCurrentMediaKey: () => '/tmp/video.mkv',
hasMpvClient: () => true,
getTrackedMediaKey: () => '/tmp/video.mkv',
resetTrackedMedia: () => {},
getWatchedSeconds: () => 1000,
maybeProbeAnilistDuration: async () => {
calls.push('probe');
return await durationPromise;
},
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 1 }),
hasAttemptedUpdateKey: () => false,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
refreshAnilistClientSecretState: async () => 'token',
enqueueRetry: () => calls.push('enqueue'),
markRetryFailure: () => calls.push('mark-failure'),
markRetrySuccess: () => calls.push('mark-success'),
refreshRetryQueueState: () => calls.push('refresh'),
updateAnilistPostWatchProgress: async () => {
calls.push('update');
return { status: 'updated', message: 'updated ok' };
},
rememberAttemptedUpdateKey: () => calls.push('remember'),
showMpvOsd: (message) => calls.push(`osd:${message}`),
logInfo: (message) => calls.push(`info:${message}`),
logWarn: (message) => calls.push(`warn:${message}`),
minWatchSeconds: 600,
minWatchRatio: 0.85,
});
const firstRun = handler();
assert.deepEqual(calls, ['inflight:true', 'probe']);
await handler();
assert.deepEqual(calls, ['inflight:true', 'probe']);
resolveDuration(1000);
await firstRun;
assert.equal(calls.filter((call) => call === 'update').length, 1);
assert.equal(calls.at(-1), 'inflight:false');
});
test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirely', async () => {
const calls: string[] = [];
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
+33 -23
View File
@@ -16,6 +16,10 @@ type RetryQueueItem = {
episode: number;
};
type AnilistPostWatchRunOptions = {
force?: boolean;
};
export function buildAnilistAttemptKey(mediaKey: string, episode: number): string {
return `${mediaKey}::${episode}`;
}
@@ -118,10 +122,11 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
minWatchSeconds: number;
minWatchRatio: number;
}) {
return async (): Promise<void> => {
return async (options: AnilistPostWatchRunOptions = {}): Promise<void> => {
if (deps.getInFlight()) {
return;
}
const force = options.force === true;
const resolved = deps.getResolvedConfig();
if (!deps.isAnilistTrackingEnabled(resolved)) {
@@ -129,7 +134,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
}
const mediaKey = deps.getCurrentMediaKey();
if (!mediaKey || !deps.hasMpvClient()) {
if (!mediaKey || (!force && !deps.hasMpvClient())) {
return;
}
if (isYoutubeMediaPath(mediaKey)) {
@@ -139,31 +144,36 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
deps.resetTrackedMedia(mediaKey);
}
const watchedSeconds = deps.getWatchedSeconds();
if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) {
return;
}
const duration = await deps.maybeProbeAnilistDuration(mediaKey);
if (!duration || duration <= 0) {
return;
}
if (watchedSeconds / duration < deps.minWatchRatio) {
return;
}
const guess = await deps.ensureAnilistMediaGuess(mediaKey);
if (!guess?.title || !guess.episode || guess.episode <= 0) {
return;
}
const attemptKey = buildAnilistAttemptKey(mediaKey, guess.episode);
if (deps.hasAttemptedUpdateKey(attemptKey)) {
return;
let watchedSeconds = 0;
if (!force) {
watchedSeconds = deps.getWatchedSeconds();
if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) {
return;
}
}
deps.setInFlight(true);
try {
if (!force) {
const duration = await deps.maybeProbeAnilistDuration(mediaKey);
if (!duration || duration <= 0) {
return;
}
if (watchedSeconds / duration < deps.minWatchRatio) {
return;
}
}
const guess = await deps.ensureAnilistMediaGuess(mediaKey);
if (!guess?.title || !guess.episode || guess.episode <= 0) {
return;
}
const attemptKey = buildAnilistAttemptKey(mediaKey, guess.episode);
if (deps.hasAttemptedUpdateKey(attemptKey)) {
return;
}
await deps.processNextAnilistRetryUpdate();
if (deps.hasAttemptedUpdateKey(attemptKey)) {
return;
@@ -27,7 +27,10 @@ test('consume anilist setup token main deps builder maps callbacks', () => {
const calls: string[] = [];
const deps = createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler({
consumeAnilistSetupCallbackUrl: () => true,
saveToken: () => calls.push('save'),
saveToken: () => {
calls.push('save');
return true;
},
setCachedToken: () => calls.push('cache'),
setResolvedState: () => calls.push('resolved'),
setSetupPageOpened: () => calls.push('opened'),
@@ -38,7 +41,7 @@ test('consume anilist setup token main deps builder maps callbacks', () => {
assert.equal(
deps.consumeAnilistSetupCallbackUrl({
rawUrl: 'subminer://anilist-setup',
saveToken: () => {},
saveToken: () => true,
setCachedToken: () => {},
setResolvedState: () => {},
setSetupPageOpened: () => {},
@@ -22,7 +22,7 @@ test('createNotifyAnilistSetupHandler sends OSD when mpv client exists', () => {
test('createConsumeAnilistSetupTokenFromUrlHandler delegates with deps', () => {
const consume = createConsumeAnilistSetupTokenFromUrlHandler({
consumeAnilistSetupCallbackUrl: (input) => input.rawUrl.includes('access_token=ok'),
saveToken: () => {},
saveToken: () => true,
setCachedToken: () => {},
setResolvedState: () => {},
setSetupPageOpened: () => {},
+2 -2
View File
@@ -1,14 +1,14 @@
export type ConsumeAnilistSetupTokenDeps = {
consumeAnilistSetupCallbackUrl: (input: {
rawUrl: string;
saveToken: (token: string) => void;
saveToken: (token: string) => boolean;
setCachedToken: (token: string) => void;
setResolvedState: (resolvedAt: number) => void;
setSetupPageOpened: (opened: boolean) => void;
onSuccess: () => void;
closeWindow: () => void;
}) => boolean;
saveToken: (token: string) => void;
saveToken: (token: string) => boolean;
setCachedToken: (token: string) => void;
setResolvedState: (resolvedAt: number) => void;
setSetupPageOpened: (opened: boolean) => void;
+31 -3
View File
@@ -90,7 +90,10 @@ test('consumeAnilistSetupCallbackUrl persists token and closes window for callba
Date.now = () => 120_000;
const handled = consumeAnilistSetupCallbackUrl({
rawUrl: 'https://anilist.subminer.moe/#access_token=saved-token',
saveToken: (value: string) => events.push(`save:${value}`),
saveToken: (value: string) => {
events.push(`save:${value}`);
return true;
},
setCachedToken: (value: string) => events.push(`cache:${value}`),
setResolvedState: (timestampMs: number) =>
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
@@ -120,7 +123,10 @@ test('consumeAnilistSetupCallbackUrl persists token for subminer deep link URL',
Date.now = () => 120_000;
const handled = consumeAnilistSetupCallbackUrl({
rawUrl: 'subminer://anilist-setup?access_token=saved-token',
saveToken: (value: string) => events.push(`save:${value}`),
saveToken: (value: string) => {
events.push(`save:${value}`);
return true;
},
setCachedToken: (value: string) => events.push(`cache:${value}`),
setResolvedState: (timestampMs: number) =>
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
@@ -143,11 +149,33 @@ test('consumeAnilistSetupCallbackUrl persists token for subminer deep link URL',
}
});
test('consumeAnilistSetupCallbackUrl keeps setup open when token persistence fails', () => {
const events: string[] = [];
const handled = consumeAnilistSetupCallbackUrl({
rawUrl: 'subminer://anilist-setup?access_token=saved-token',
saveToken: (value: string) => {
events.push(`save:${value}`);
return false;
},
setCachedToken: () => events.push('cache'),
setResolvedState: () => events.push('state'),
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
onSuccess: () => events.push('success'),
closeWindow: () => events.push('close'),
});
assert.equal(handled, true);
assert.deepEqual(events, ['save:saved-token', 'opened:true']);
});
test('consumeAnilistSetupCallbackUrl ignores non-callback URLs', () => {
const events: string[] = [];
const handled = consumeAnilistSetupCallbackUrl({
rawUrl: 'https://anilist.co/settings/developer',
saveToken: () => events.push('save'),
saveToken: () => {
events.push('save');
return true;
},
setCachedToken: () => events.push('cache'),
setResolvedState: () => events.push('state'),
setSetupPageOpened: () => events.push('opened'),
+6 -2
View File
@@ -10,7 +10,7 @@ export type BuildAnilistSetupUrlDeps = {
export type ConsumeAnilistSetupCallbackUrlDeps = {
rawUrl: string;
saveToken: (token: string) => void;
saveToken: (token: string) => boolean;
setCachedToken: (token: string) => void;
setResolvedState: (resolvedAt: number) => void;
setSetupPageOpened: (opened: boolean) => void;
@@ -71,8 +71,12 @@ export function consumeAnilistSetupCallbackUrl(deps: ConsumeAnilistSetupCallback
return false;
}
if (!deps.saveToken(token)) {
deps.setSetupPageOpened(true);
return true;
}
const resolvedAt = Date.now();
deps.saveToken(token);
deps.setCachedToken(token);
deps.setResolvedState(resolvedAt);
deps.setSetupPageOpened(false);
@@ -18,6 +18,8 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
stopTexthookerService: () => calls.push('stop-texthooker'),
clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-poll'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
calls.push('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'),
destroyMainOverlayWindow: () => calls.push('destroy-main-overlay-window'),
destroyModalOverlayWindow: () => calls.push('destroy-modal-overlay-window'),
destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'),
@@ -42,10 +44,11 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
});
cleanup();
assert.equal(calls.length, 29);
assert.equal(calls.length, 30);
assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
});
@@ -7,6 +7,7 @@ export function createOnWillQuitCleanupHandler(deps: {
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
destroyMainOverlayWindow: () => void;
destroyModalOverlayWindow: () => void;
destroyYomitanParserWindow: () => void;
@@ -38,6 +39,7 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.stopSubtitleWebsocket();
deps.stopTexthookerService();
deps.clearWindowsVisibleOverlayForegroundPollLoop();
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts();
deps.destroyMainOverlayWindow();
deps.destroyModalOverlayWindow();
deps.destroyYomitanParserWindow();
@@ -20,6 +20,8 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
stopTexthookerService: () => calls.push('stop-texthooker'),
clearWindowsVisibleOverlayForegroundPollLoop: () =>
calls.push('clear-windows-visible-overlay-foreground-poll-loop'),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
calls.push('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'),
getMainOverlayWindow: () => ({
isDestroyed: () => false,
destroy: () => calls.push('destroy-main-overlay-window'),
@@ -88,6 +90,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
assert.ok(calls.includes('stop-jellyfin-remote'));
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'));
assert.equal(reconnectTimer, null);
assert.equal(immersionTracker, null);
});
@@ -103,6 +106,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => ({
isDestroyed: () => true,
destroy: () => calls.push('destroy-main-overlay-window'),
@@ -26,6 +26,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void;
clearWindowsVisibleOverlayForegroundPollLoop: () => void;
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => void;
getMainOverlayWindow: () => DestroyableWindow | null;
clearMainOverlayWindow: () => void;
getModalOverlayWindow: () => DestroyableWindow | null;
@@ -67,6 +68,8 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
stopTexthookerService: () => deps.stopTexthookerService(),
clearWindowsVisibleOverlayForegroundPollLoop: () =>
deps.clearWindowsVisibleOverlayForegroundPollLoop(),
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () =>
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts(),
destroyMainOverlayWindow: () => {
const window = deps.getMainOverlayWindow();
if (!window) return;
@@ -15,7 +15,7 @@ test('composeAnilistSetupHandlers returns callable setup handlers', () => {
},
consumeTokenDeps: {
consumeAnilistSetupCallbackUrl: () => false,
saveToken: () => {},
saveToken: () => true,
setCachedToken: () => {},
setResolvedState: () => {},
setSetupPageOpened: () => {},
@@ -22,6 +22,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
getMainOverlayWindow: () => null,
clearMainOverlayWindow: () => {},
getModalOverlayWindow: () => null,
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
clearLinuxMpvFullscreenOverlayRefreshTimeouts,
updateLinuxMpvFullscreenOverlayRefreshBurst,
scheduleLinuxVisibleOverlayFullscreenRefreshBurst,
} from './linux-mpv-fullscreen-overlay-refresh';
test('linux mpv fullscreen overlay refresh burst schedules overlay refresh work on linux', async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
const calls: string[] = [];
try {
scheduleLinuxVisibleOverlayFullscreenRefreshBurst({
overlayManager: {
getMainWindow: () =>
({
hide: () => calls.push('hide'),
isDestroyed: () => false,
isVisible: () => true,
showInactive: () => calls.push('showInactive'),
}) as never,
getVisibleOverlayVisible: () => true,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
},
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
});
const deadline = Date.now() + 200;
while (!calls.includes('updateVisibleOverlayVisibility') && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.ok(calls.includes('updateVisibleOverlayVisibility'));
assert.ok(calls.includes('hide'));
assert.ok(calls.includes('showInactive'));
assert.ok(calls.includes('ensureOverlayWindowLevel'));
} finally {
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
test('linux mpv fullscreen overlay refresh update cancels burst when fullscreen exits', async () => {
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
const calls: string[] = [];
try {
const deps = {
overlayManager: {
getMainWindow: () =>
({
hide: () => calls.push('hide'),
isDestroyed: () => false,
isVisible: () => true,
showInactive: () => calls.push('showInactive'),
}) as never,
getVisibleOverlayVisible: () => true,
},
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => calls.push('updateVisibleOverlayVisibility'),
},
ensureOverlayWindowLevel: () => calls.push('ensureOverlayWindowLevel'),
};
const cancel = updateLinuxMpvFullscreenOverlayRefreshBurst(true, deps, null);
const nextCancel = updateLinuxMpvFullscreenOverlayRefreshBurst(false, deps, cancel);
await new Promise((resolve) => setTimeout(resolve, 80));
assert.equal(nextCancel, null);
assert.deepEqual(calls, []);
} finally {
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor);
}
}
});
@@ -0,0 +1,83 @@
type LinuxMpvFullscreenOverlayWindow = {
hide: () => void;
isDestroyed: () => boolean;
isVisible: () => boolean;
showInactive: () => void;
};
export type LinuxMpvFullscreenOverlayRefreshDeps = {
overlayManager: {
getMainWindow: () => LinuxMpvFullscreenOverlayWindow | null;
getVisibleOverlayVisible: () => boolean;
};
overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => void;
};
ensureOverlayWindowLevel: (window: LinuxMpvFullscreenOverlayWindow) => void;
};
export type CancelLinuxMpvFullscreenOverlayRefreshBurst = () => void;
const LINUX_MPV_FULLSCREEN_OVERLAY_REFRESH_DELAYS_MS = [0, 50, 150, 300, 600] as const;
let linuxMpvFullscreenOverlayRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
function clearLinuxMpvFullscreenOverlayRefreshTimeouts(): void {
for (const timeout of linuxMpvFullscreenOverlayRefreshTimeouts) {
clearTimeout(timeout);
}
linuxMpvFullscreenOverlayRefreshTimeouts = [];
}
function refreshLinuxVisibleOverlayAfterMpvFullscreenChange(
deps: LinuxMpvFullscreenOverlayRefreshDeps,
): void {
if (process.platform !== 'linux' || !deps.overlayManager.getVisibleOverlayVisible()) {
return;
}
deps.overlayVisibilityRuntime.updateVisibleOverlayVisibility();
const mainWindow = deps.overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) {
return;
}
mainWindow.hide();
mainWindow.showInactive();
deps.ensureOverlayWindowLevel(mainWindow);
}
export function scheduleLinuxVisibleOverlayFullscreenRefreshBurst(
deps: LinuxMpvFullscreenOverlayRefreshDeps,
): CancelLinuxMpvFullscreenOverlayRefreshBurst {
if (process.platform !== 'linux') {
return () => {};
}
clearLinuxMpvFullscreenOverlayRefreshTimeouts();
for (const delayMs of LINUX_MPV_FULLSCREEN_OVERLAY_REFRESH_DELAYS_MS) {
const refreshTimeout = setTimeout(() => {
linuxMpvFullscreenOverlayRefreshTimeouts = linuxMpvFullscreenOverlayRefreshTimeouts.filter(
(timeout) => timeout !== refreshTimeout,
);
refreshLinuxVisibleOverlayAfterMpvFullscreenChange(deps);
}, delayMs);
refreshTimeout.unref?.();
linuxMpvFullscreenOverlayRefreshTimeouts.push(refreshTimeout);
}
return clearLinuxMpvFullscreenOverlayRefreshTimeouts;
}
export function updateLinuxMpvFullscreenOverlayRefreshBurst(
isFullscreen: boolean,
deps: LinuxMpvFullscreenOverlayRefreshDeps,
cancelCurrentBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null,
): CancelLinuxMpvFullscreenOverlayRefreshBurst | null {
cancelCurrentBurst?.();
if (!isFullscreen) {
return null;
}
return scheduleLinuxVisibleOverlayFullscreenRefreshBurst(deps);
}
export { clearLinuxMpvFullscreenOverlayRefreshTimeouts };
@@ -128,6 +128,7 @@ test('mpv event bindings register all expected events', () => {
onTimePosChange: () => {},
onDurationChange: () => {},
onPauseChange: () => {},
onFullscreenChange: () => {},
onSubtitleMetricsChange: () => {},
onSecondarySubtitleVisibility: () => {},
});
@@ -151,6 +152,7 @@ test('mpv event bindings register all expected events', () => {
'time-pos-change',
'duration-change',
'pause-change',
'fullscreen-change',
'subtitle-metrics-change',
'secondary-subtitle-visibility',
]);
@@ -11,6 +11,7 @@ type MpvBindingEventName =
| 'time-pos-change'
| 'duration-change'
| 'pause-change'
| 'fullscreen-change'
| 'subtitle-metrics-change'
| 'secondary-subtitle-visibility';
@@ -83,6 +84,7 @@ export function createBindMpvClientEventHandlers(deps: {
onTimePosChange: (payload: { time: number }) => void;
onDurationChange: (payload: { duration: number }) => void;
onPauseChange: (payload: { paused: boolean }) => void;
onFullscreenChange: (payload: { fullscreen: boolean }) => void;
onSubtitleMetricsChange: (payload: { patch: Record<string, unknown> }) => void;
onSecondarySubtitleVisibility: (payload: { visible: boolean }) => void;
}) {
@@ -99,6 +101,7 @@ export function createBindMpvClientEventHandlers(deps: {
mpvClient.on('time-pos-change', deps.onTimePosChange);
mpvClient.on('duration-change', deps.onDurationChange);
mpvClient.on('pause-change', deps.onPauseChange);
mpvClient.on('fullscreen-change', deps.onFullscreenChange);
mpvClient.on('subtitle-metrics-change', deps.onSubtitleMetricsChange);
mpvClient.on('secondary-subtitle-visibility', deps.onSecondarySubtitleVisibility);
};
@@ -49,8 +49,37 @@ test('subtitle change handler broadcasts cached annotated payload immediately wh
assert.deepEqual(calls, [
'set:line',
'lookup:line',
'broadcast:annotated',
'process:line',
'broadcast:annotated',
'presence',
]);
});
test('subtitle change handler emits cached annotation after forwarding the subtitle change', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleChangeHandler({
setCurrentSubText: (text) => calls.push(`set:${text}`),
getImmediateSubtitlePayload: (text) => {
calls.push(`lookup:${text}`);
return { text, tokens: [] };
},
emitImmediateSubtitle: (payload) => {
calls.push(`emit:${payload.tokens === null ? 'plain' : 'annotated'}`);
},
broadcastSubtitle: (payload) => {
calls.push(`broadcast:${payload.tokens === null ? 'plain' : 'annotated'}`);
},
onSubtitleChange: (text) => calls.push(`process:${text}`),
refreshDiscordPresence: () => calls.push('presence'),
});
handler({ text: 'line' });
assert.deepEqual(calls, [
'set:line',
'lookup:line',
'process:line',
'emit:annotated',
'presence',
]);
});
@@ -170,6 +199,10 @@ test('time-pos and pause handlers report progress with correct urgency', () => {
recordPlaybackPosition: (time) => calls.push(`time:${time}`),
reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`),
refreshDiscordPresence: () => calls.push('presence'),
maybeRunAnilistPostWatchUpdate: async () => {
calls.push('post-watch');
},
logError: () => calls.push('post-watch-error'),
});
const pauseHandler = createHandleMpvPauseChangeHandler({
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
@@ -183,12 +216,48 @@ test('time-pos and pause handlers report progress with correct urgency', () => {
'time:12.5',
'progress:normal',
'presence',
'post-watch',
'pause:yes',
'progress:force',
'presence',
]);
});
test('time-pos handler logs post-watch update rejection without blocking later handlers', async () => {
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 () => {
calls.push('post-watch');
throw new Error('boom');
},
logError: (message, error) => calls.push(`error:${message}:${(error as Error).message}`),
});
const pauseHandler = createHandleMpvPauseChangeHandler({
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
reportJellyfinRemoteProgress: (force) => calls.push(`progress:${force ? 'force' : 'normal'}`),
refreshDiscordPresence: () => calls.push('presence'),
});
timeHandler({ time: 12.5 });
pauseHandler({ paused: true });
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(calls, [
'time:12.5',
'progress:normal',
'presence',
'post-watch',
'pause:yes',
'progress:force',
'presence',
'error:AniList post-watch update failed unexpectedly:boom',
]);
});
test('subtitle metrics change handler forwards patch payload', () => {
let received: Record<string, unknown> | null = null;
const handler = createHandleMpvSubtitleMetricsChangeHandler({
+7 -1
View File
@@ -12,14 +12,15 @@ export function createHandleMpvSubtitleChangeHandler(deps: {
deps.setCurrentSubText(text);
const immediatePayload = deps.getImmediateSubtitlePayload?.(text) ?? null;
if (immediatePayload) {
deps.onSubtitleChange(text);
(deps.emitImmediateSubtitle ?? deps.broadcastSubtitle)(immediatePayload);
} else {
deps.broadcastSubtitle({
text,
tokens: null,
});
deps.onSubtitleChange(text);
}
deps.onSubtitleChange(text);
deps.refreshDiscordPresence();
};
}
@@ -104,12 +105,17 @@ export function createHandleMpvTimePosChangeHandler(deps: {
recordPlaybackPosition: (time: number) => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
refreshDiscordPresence: () => void;
maybeRunAnilistPostWatchUpdate?: () => Promise<void>;
logError?: (message: string, error: unknown) => void;
onTimePosUpdate?: (time: number) => void;
}) {
return ({ time }: { time: number }): void => {
deps.recordPlaybackPosition(time);
deps.reportJellyfinRemoteProgress(false);
deps.refreshDiscordPresence();
void deps.maybeRunAnilistPostWatchUpdate?.().catch((error) => {
deps.logError?.('AniList post-watch update failed unexpectedly', error);
});
deps.onTimePosUpdate?.(time);
};
}
@@ -68,6 +68,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
recordMediaDuration: (durationSec: number) => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
onTimePosUpdate?: (time: number) => void;
onFullscreenChange?: (fullscreen: boolean) => void;
recordPauseState: (paused: boolean) => void;
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
@@ -148,6 +149,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteProgress: (forceImmediate) =>
deps.reportJellyfinRemoteProgress(forceImmediate),
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(),
logError: (message, error) => deps.logSubtitleTimingError(message, error),
onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time),
});
const handleMpvPauseChange = createHandleMpvPauseChangeHandler({
@@ -177,6 +180,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
onTimePosChange: handleMpvTimePosChange,
onDurationChange: ({ duration }) => deps.recordMediaDuration(duration),
onPauseChange: handleMpvPauseChange,
onFullscreenChange: ({ fullscreen }) => deps.onFullscreenChange?.(fullscreen),
onSubtitleMetricsChange: handleMpvSubtitleMetricsChange,
onSecondarySubtitleVisibility: handleMpvSecondarySubtitleVisibility,
})(mpvClient);
@@ -57,6 +57,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
updateCurrentMediaTitle: (title) => calls.push(`title:${title}`),
resetAnilistMediaGuessState: () => calls.push('reset-guess'),
reportJellyfinRemoteProgress: (forceImmediate) => calls.push(`progress:${forceImmediate}`),
onFullscreenChange: (fullscreen) => calls.push(`fullscreen:${fullscreen}`),
updateSubtitleRenderMetrics: () => calls.push('metrics'),
refreshDiscordPresence: () => calls.push('presence-refresh'),
})();
@@ -95,6 +96,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.notifyImmersionTitleUpdate('title');
deps.recordPlaybackPosition(10);
deps.reportJellyfinRemoteProgress(true);
deps.onFullscreenChange?.(true);
deps.recordPauseState(true);
deps.updateSubtitleRenderMetrics({});
deps.setPreviousSecondarySubVisibility(true);
@@ -112,6 +114,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('autoplay:/tmp/video'));
assert.ok(calls.includes('metrics'));
assert.ok(calls.includes('fullscreen:true'));
assert.ok(calls.includes('presence-refresh'));
assert.ok(calls.includes('restore-mpv-sub'));
assert.ok(calls.includes('reset-sidebar-layout'));
@@ -159,6 +162,48 @@ test('mpv main event main deps wire subtitle callbacks without suppression gate'
assert.equal(typeof deps.setCurrentSubText, 'function');
});
test('mpv main event main deps treat managed playback as quit-on-disconnect', () => {
const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState: {
initialArgs: { managedPlayback: true },
overlayRuntimeInitialized: false,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
},
getQuitOnDisconnectArmed: () => true,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
resetSubtitleSidebarEmbeddedLayout: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
})();
assert.equal(deps.hasInitialPlaybackQuitOnDisconnectArg(), true);
assert.equal(deps.shouldQuitOnDisconnectWhenOverlayRuntimeInitialized(), true);
});
test('flushPlaybackPositionOnMediaPathClear ignores disconnected mpv time-pos reads', async () => {
const recorded: number[] = [];
const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({
+17 -5
View File
@@ -2,7 +2,11 @@ import type { MergedToken, SubtitleData } from '../../types';
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
appState: {
initialArgs?: { jellyfinPlay?: unknown; youtubePlay?: unknown } | null;
initialArgs?: {
jellyfinPlay?: unknown;
managedPlayback?: unknown;
youtubePlay?: unknown;
} | null;
overlayRuntimeInitialized: boolean;
mpvClient: {
connected?: boolean;
@@ -60,6 +64,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
resetAnilistMediaGuessState: () => void;
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
onTimePosUpdate?: (time: number) => void;
onFullscreenChange?: (fullscreen: boolean) => void;
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
refreshDiscordPresence: () => void;
ensureImmersionTrackerInitialized: () => void;
@@ -73,15 +78,19 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
deps.ensureImmersionTrackerInitialized();
deps.appState.immersionTracker?.recordPlaybackPosition?.(normalizedTimeSec);
};
const hasInitialPlaybackQuitOnDisconnectArg = (): boolean =>
Boolean(
deps.appState.initialArgs?.managedPlayback ||
deps.appState.initialArgs?.jellyfinPlay ||
deps.appState.initialArgs?.youtubePlay,
);
return () => ({
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
hasInitialPlaybackQuitOnDisconnectArg: () =>
Boolean(deps.appState.initialArgs?.jellyfinPlay || deps.appState.initialArgs?.youtubePlay),
hasInitialPlaybackQuitOnDisconnectArg,
isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized,
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () =>
Boolean(deps.appState.initialArgs?.youtubePlay),
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: hasInitialPlaybackQuitOnDisconnectArg,
isQuitOnDisconnectArmed: () => deps.getQuitOnDisconnectArmed(),
scheduleQuitCheck: (callback: () => void) => deps.scheduleQuitCheck(callback),
isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected),
@@ -176,6 +185,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
onTimePosUpdate: deps.onTimePosUpdate
? (time: number) => deps.onTimePosUpdate!(time)
: undefined,
onFullscreenChange: deps.onFullscreenChange
? (fullscreen: boolean) => deps.onFullscreenChange!(fullscreen)
: undefined,
recordPauseState: (paused: boolean) => {
deps.appState.playbackPaused = paused;
deps.ensureImmersionTrackerInitialized();
+1 -1
View File
@@ -50,7 +50,7 @@ test('createReloadConfigHandler runs success flow with warnings', async () => {
assert.equal(showedWarningDialog, process.platform === 'darwin');
assert.ok(calls.some((entry) => entry.includes('actual=10 fallback=250')));
assert.ok(calls.includes('hotReload:start'));
assert.deepEqual(refreshCalls, [{ force: true }]);
assert.deepEqual(refreshCalls, [{ force: true, allowSetupPrompt: false }]);
});
test('createReloadConfigHandler fails startup for parse errors', () => {
+5 -2
View File
@@ -27,7 +27,10 @@ export type ReloadConfigRuntimeDeps = {
logWarning: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
startConfigHotReload: () => void;
refreshAnilistClientSecretState: (options: { force: boolean }) => Promise<unknown>;
refreshAnilistClientSecretState: (options: {
force: boolean;
allowSetupPrompt?: boolean;
}) => Promise<unknown>;
failHandlers: {
logError: (details: string) => void;
showErrorBox: (title: string, details: string) => void;
@@ -72,7 +75,7 @@ export function createReloadConfigHandler(deps: ReloadConfigRuntimeDeps): () =>
}
deps.startConfigHotReload();
void deps.refreshAnilistClientSecretState({ force: true });
void deps.refreshAnilistClientSecretState({ force: true, allowSetupPrompt: false });
};
}
@@ -42,7 +42,7 @@ test('stats server routing defers to a live background daemon from another proce
processAlive: true,
});
assert.deepEqual(handler(), { url: 'http://127.0.0.1:7979', source: 'foreign' });
assert.deepEqual(handler(), { url: 'http://127.0.0.1:7979', source: 'background' });
assert.deepEqual(calls, ['readBackgroundState', 'isProcessAlive']);
});
+2 -4
View File
@@ -14,9 +14,7 @@ function formatStatsServerUrl(port: number): string {
return `http://127.0.0.1:${port}`;
}
export type EnsureStatsServerUrlResult =
| { url: string; source: 'foreign' }
| { url: string; source: 'local' };
export type EnsureStatsServerUrlResult = { url: string; source: 'background' | 'local' };
export function createEnsureStatsServerUrlHandler(
deps: EnsureStatsServerUrlDeps,
@@ -30,7 +28,7 @@ export function createEnsureStatsServerUrlHandler(
} else if (!deps.isProcessAlive(state.pid)) {
deps.removeBackgroundState();
} else if (state.pid !== deps.currentPid) {
return { url: formatStatsServerUrl(state.port), source: 'foreign' };
return { url: formatStatsServerUrl(state.port), source: 'background' };
}
if (!deps.hasLocalStatsServer()) {