diff --git a/changes/character-dictionary-resolution-cache.md b/changes/character-dictionary-resolution-cache.md new file mode 100644 index 00000000..fa9b0f9a --- /dev/null +++ b/changes/character-dictionary-resolution-cache.md @@ -0,0 +1,4 @@ +type: fixed +area: character-dictionary + +- Reused cached character-dictionary media matches so loading a title with an existing snapshot no longer sends another AniList search request. diff --git a/changes/fix-anilist-timepos-threshold.md b/changes/fix-anilist-timepos-threshold.md new file mode 100644 index 00000000..b457c13a --- /dev/null +++ b/changes/fix-anilist-timepos-threshold.md @@ -0,0 +1,6 @@ +type: fixed +area: anilist + +- Used fresh mpv time-position, duration, and subtitle timing events for AniList post-watch threshold checks so progress updates still fire when playback reaches or skips past the watched threshold. +- Prefer season-specific AniList search results for multi-season files before falling back to the base title. +- Show a clear AniList message when the matched season is not in Planning or Watching instead of silently queueing an impossible progress update. diff --git a/changes/fix-macos-overlay-layering.md b/changes/fix-macos-overlay-layering.md new file mode 100644 index 00000000..5c616459 --- /dev/null +++ b/changes/fix-macos-overlay-layering.md @@ -0,0 +1,10 @@ +type: fixed +area: overlay + +- Hid the macOS visible overlay when mpv is no longer the foreground target so other apps and Spaces are not covered by SubMiner subtitles. +- Kept the macOS overlay layered above active mpv while stats mouse passthrough is enabled, and treated the frontmost mpv app as the focus signal. +- Opened the stats overlay inactive on macOS so it appears over fullscreen mpv instead of switching back to SubMiner's original desktop. +- Preserved the active mpv focus state through transient macOS helper misses so subtitles do not flicker while mpv remains foreground. +- Kept fullscreen macOS overlays stable when mpv remains frontmost but window geometry temporarily disappears from the macOS window APIs. +- Released the macOS overlay when the helper reports mpv is no longer foreground so other apps are no longer covered. +- Reduced macOS window-tracker background work by preferring the compiled helper and slowing polls while mpv is stably focused. diff --git a/changes/native-updater-crash.md b/changes/native-updater-crash.md index 417ef56f..669a10c1 100644 --- a/changes/native-updater-crash.md +++ b/changes/native-updater-crash.md @@ -1,4 +1,4 @@ type: fixed area: updates -- Avoided native `electron-updater` checks where they are unsafe, so tray and background update checks continue through GitHub release metadata without crashing the app. +- Kept signed macOS app updates on the native updater path while preventing eager Squirrel install checks before the user confirms restart. diff --git a/docs-site/anilist-integration.md b/docs-site/anilist-integration.md index bec91aff..6338c2ff 100644 --- a/docs-site/anilist-integration.md +++ b/docs-site/anilist-integration.md @@ -17,8 +17,8 @@ AniList integration is opt-in. To enable it: { "anilist": { "enabled": true, - "accessToken": "" - } + "accessToken": "", + }, } ``` @@ -37,20 +37,20 @@ SubMiner monitors playback and triggers an AniList progress update when an episo The update flow: 1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename. It tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable. -2. **AniList search** -- The detected title is searched against the AniList GraphQL API. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count. -3. **Progress check** -- SubMiner fetches your current list entry for the matched media. If your recorded progress already meets or exceeds the detected episode, the update is skipped. +2. **AniList search** -- The detected title is searched against the AniList GraphQL API. For season 2 and later files, SubMiner searches the season-specific title first, then falls back to the base title. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count. +3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped. 4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`. ## Update Queue and Retry Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff. -| Parameter | Value | -| --- | --- | -| Initial backoff | 30 seconds | -| Maximum backoff | 6 hours | -| Maximum attempts | 8 | -| Queue capacity | 500 items | +| Parameter | Value | +| ---------------- | ---------- | +| Initial backoff | 30 seconds | +| Maximum backoff | 6 hours | +| Maximum attempts | 8 | +| Queue capacity | 500 items | After 8 failed attempts, the update is moved to a dead-letter queue and no longer retried automatically. The queue is persisted across restarts so no updates are lost if SubMiner exits before a retry succeeds. @@ -85,36 +85,37 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w "collapsibleSections": { "description": false, "characterInformation": false, - "voicedBy": false - } - } - } + "voicedBy": false, + }, + }, + }, } ``` -| Option | Values | Description | -| --- | --- | --- | -| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | -| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) | -| `characterDictionary.enabled` | `true`, `false` | Enable auto-sync of the merged character dictionary from AniList (default: `false`) | -| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) | -| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one | -| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded | +| Option | Values | Description | +| ------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ | +| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | +| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) | +| `characterDictionary.enabled` | `true`, `false` | Enable auto-sync of the merged character dictionary from AniList (default: `false`) | +| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) | +| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one | +| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded | See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format. ## CLI Commands -| Command | Description | -| --- | --- | -| `--anilist-setup` | Open AniList setup/auth flow helper window | -| `--anilist-status` | Print current token resolution state and retry queue counters | -| `--anilist-logout` | Clear stored AniList token from local persisted state | -| `--anilist-retry-queue` | Process one ready retry queue item immediately | +| Command | Description | +| ----------------------- | ------------------------------------------------------------- | +| `--anilist-setup` | Open AniList setup/auth flow helper window | +| `--anilist-status` | Print current token resolution state and retry queue counters | +| `--anilist-logout` | Clear stored AniList token from local persisted state | +| `--anilist-retry-queue` | Process one ready retry queue item immediately | ## Troubleshooting - **Updates not triggering:** Confirm `anilist.enabled` is `true`. SubMiner requires at least 85% of the episode watched and a minimum of 10 minutes. Short episodes or partial watches will not trigger an update. +- **Update not possible:** Add the season to your AniList Planning or Watching list first. SubMiner will not create new AniList list entries automatically. - **Wrong episode or title matched:** Detection quality is best when `guessit` is installed and on your `PATH`. Without it, SubMiner falls back to internal filename parsing which can be less accurate with unusual naming conventions. - **Token issues:** Run `--anilist-status` to check token state. If the token is invalid or expired, run `--anilist-setup` or `--anilist-logout` and re-authenticate. - **Updates failing repeatedly:** Run `--anilist-status` to see retry queue counters. Items that fail 8 times are moved to the dead-letter queue. Check network connectivity and AniList API status. diff --git a/docs-site/character-dictionary.md b/docs-site/character-dictionary.md index 0bfdfb15..82e1857e 100644 --- a/docs-site/character-dictionary.md +++ b/docs-site/character-dictionary.md @@ -35,7 +35,7 @@ Character dictionary sync is disabled by default. To turn it on: ``` ::: tip -The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached snapshot. +The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached media match and snapshot without a fresh AniList lookup. ::: ::: warning @@ -139,7 +139,7 @@ When `characterDictionary.enabled` is `true`, SubMiner runs an auto-sync routine 5. **importing** — Push the ZIP into Yomitan. Waits for Yomitan mutation readiness (7-second timeout per operation). 6. **ready** — Dictionary is live. Character names will match on the next subtitle line. -**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`: +**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`. AniList media matches are cached separately in `character-dictionaries/anilist-resolution-cache.json` so snapshot hits do not need another AniList search. ```jsonc { diff --git a/release/prerelease-notes.md b/release/prerelease-notes.md deleted file mode 100644 index 6edd4e2e..00000000 --- a/release/prerelease-notes.md +++ /dev/null @@ -1,36 +0,0 @@ -> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release. - -## Highlights -### Added - -**Auto-Update:** The tray and `subminer -u` now check for SubMiner releases and prompt you to install updates, covering the app itself, the command-line launcher, and Linux rofi themes. Update notifications are configurable, downloads are checksum-verified, and an opt-in prerelease channel lets you receive beta and RC builds. - -**First-Run Setup:** The app can now install Bun and the `subminer` command-line launcher during first run on Linux, macOS, and Windows. On Windows, a `subminer.cmd` PATH shim lets you type `subminer` in any terminal without manually adding `SubMiner.exe` to PATH. - -### Fixed - -**macOS Overlay:** Controls on the mpv window are now clickable before you hover the subtitle bars. Transient mpv window focus misses no longer hide the overlay; minimizing mpv still hides it as expected. - -**Subtitle Sync:** The subtitle sync modal on macOS now opens reliably: no more flash-and-hide on the first attempt or stale modal state left after syncing. - -**Updater:** Update checks on Linux now use GitHub release metadata instead of the native Electron updater, preventing tray app crashes. macOS builds that cannot auto-install updates show a manual install prompt instead of a non-functional restart prompt. macOS update dialogs are brought to the front when `subminer --update` is run from the launcher. - -**Linux Command-Line Updater:** `subminer -u` now performs release updates directly from the launcher without requiring the tray app, and reports "up to date" without downloading assets when already on the latest version. - -**Launcher Setup:** Linux first-run launcher installs now build with a valid Bun shebang. `subminer app --setup` correctly opens the setup flow even when SubMiner is already running in the background. On macOS, first-run setup recognizes existing launchers in Homebrew or user PATH directories, and manual installs avoid Homebrew-managed locations. The setup window now quits automatically after first-run setup completes, returning control to the terminal. - -**Tray App:** Fixed several issues with tray-launched Yomitan settings: closing the window no longer quits the tray app, a close-only menu replaces the default native menu, and settings loading no longer blocks other tray actions. An in-page close button is now available on Hyprland, where native window controls may be absent. Disabled the embedded popup preview in the settings window to prevent renderer hangs. Fixed a startup race condition that could leave extension loading in an error state, and corrected focus handling for the session help modal when mpv is not running. - -**Build:** One-shot `make clean build install` flows now correctly pick up the AppImage built in the same invocation. - -## Installation - -See the README and docs/installation guide for full setup steps. - -## Assets - -- Linux: `SubMiner.AppImage` -- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip` -- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher - -Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`. diff --git a/scripts/get-mpv-window-macos.swift b/scripts/get-mpv-window-macos.swift index 059c951b..61e01fd7 100644 --- a/scripts/get-mpv-window-macos.swift +++ b/scripts/get-mpv-window-macos.swift @@ -7,7 +7,7 @@ // It works with both bundled and unbundled mpv installations. // // Usage: swift get-mpv-window-macos.swift -// Output: "x,y,width,height" or "not-found" +// Output: "x,y,width,height,focused", "minimized", "active", "inactive", or "not-found" // import Cocoa @@ -25,9 +25,16 @@ private struct WindowState { let focused: Bool } +private struct FrontmostApplicationState { + let pid: pid_t + let isMpv: Bool +} + private enum WindowLookupResult { case visible(WindowState) case minimized + case active + case inactive } private let targetMpvSocketPath: String? = { @@ -146,8 +153,41 @@ private func geometryFromAXWindow(_ axWindow: AXUIElement) -> WindowGeometry? { return geometry } -private func frontmostApplicationPid() -> pid_t? { - NSWorkspace.shared.frontmostApplication?.processIdentifier +private func frontmostApplicationState() -> FrontmostApplicationState? { + guard let app = NSWorkspace.shared.frontmostApplication else { + return nil + } + + return FrontmostApplicationState( + pid: app.processIdentifier, + isMpv: app.localizedName.map(normalizedMpvName) ?? false + ) +} + +private func isFocusedMpvWindow(ownerPid: pid_t, frontmost: FrontmostApplicationState?) -> Bool { + guard let frontmost = frontmost else { + return false + } + + if frontmost.pid == ownerPid { + return true + } + + return frontmost.isMpv && windowHasTargetSocket(ownerPid) +} + +private func isFrontmostTargetMpv(_ frontmost: FrontmostApplicationState?) -> Bool { + guard let frontmost = frontmost, frontmost.isMpv else { + return false + } + + if windowHasTargetSocket(frontmost.pid) { + return true + } + + // When macOS says mpv is frontmost but geometry APIs miss, keep the + // overlay stable even if ps cannot expose the socket argument. + return targetMpvSocketPath != nil } private func windowStateFromAccessibilityAPI() -> WindowLookupResult? { @@ -158,7 +198,7 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? { return normalizedMpvName(name) } - let frontmostPid = frontmostApplicationPid() + let frontmost = frontmostApplicationState() var foundMinimizedTargetWindow = false for app in runningApps { @@ -198,7 +238,7 @@ private func windowStateFromAccessibilityAPI() -> WindowLookupResult? { return .visible( WindowState( geometry: geometry, - focused: frontmostPid == windowPid + focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost) ) ) } @@ -217,7 +257,7 @@ private func windowStateFromCoreGraphics() -> WindowState? { // Use on-screen layer-0 windows to avoid off-screen helpers/shadows. let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements] let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] ?? [] - let frontmostPid = frontmostApplicationPid() + let frontmost = frontmostApplicationState() for window in windowList { guard let ownerName = window[kCGWindowOwnerName as String] as? String, @@ -260,7 +300,7 @@ private func windowStateFromCoreGraphics() -> WindowState? { return WindowState( geometry: geometry, - focused: frontmostPid == ownerPid + focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost) ) } @@ -274,6 +314,13 @@ private let lookupResult: WindowLookupResult? = { if let cgWindow = windowStateFromCoreGraphics() { return .visible(cgWindow) } + let frontmost = frontmostApplicationState() + if isFrontmostTargetMpv(frontmost) { + return .active + } + if frontmost != nil { + return .inactive + } return nil }() @@ -285,6 +332,10 @@ if let result = lookupResult { ) case .minimized: print("minimized") + case .active: + print("active") + case .inactive: + print("inactive") } } else { print("not-found") diff --git a/scripts/get-mpv-window-macos.test.ts b/scripts/get-mpv-window-macos.test.ts index 0f955cf6..59baab6f 100644 --- a/scripts/get-mpv-window-macos.test.ts +++ b/scripts/get-mpv-window-macos.test.ts @@ -31,3 +31,64 @@ test('minimized Accessibility windows are validated by PID and socket before rep 'target socket must be validated before accepting a minimized window', ); }); + +test('focused mpv window follows the frontmost mpv app signal', () => { + const focusHelperIndex = source.indexOf('private func isFocusedMpvWindow'); + assert.notEqual(focusHelperIndex, -1); + + const nextFunctionIndex = source.indexOf('\nprivate func ', focusHelperIndex + 1); + const focusHelperBody = source.slice(focusHelperIndex, nextFunctionIndex); + + assert.ok( + focusHelperBody.includes('frontmost.pid == ownerPid'), + 'matching frontmost PID should mark the mpv window focused', + ); + assert.ok( + focusHelperBody.includes('frontmost.isMpv && windowHasTargetSocket(ownerPid)'), + 'frontmost mpv app should mark the target mpv window focused even when PIDs differ', + ); + assert.ok( + source.includes('focused: isFocusedMpvWindow(ownerPid: windowPid, frontmost: frontmost)'), + 'Accessibility path should use the shared focused mpv helper', + ); + assert.ok( + source.includes('focused: isFocusedMpvWindow(ownerPid: ownerPid, frontmost: frontmost)'), + 'CoreGraphics path should use the shared focused mpv helper', + ); +}); + +test('frontmost mpv app emits active state when geometry lookup misses', () => { + assert.ok( + /case\s+\.active:/.test(source), + 'helper should expose an active state without window geometry', + ); + assert.ok( + source.includes('if windowHasTargetSocket(frontmost.pid)'), + 'active state should still accept a matching target socket when available', + ); + assert.ok( + source.includes('return targetMpvSocketPath != nil'), + 'active state should preserve frontmost mpv even if command-line socket detection fails', + ); + assert.ok( + source.includes('return .active'), + 'lookup should preserve active mpv state after geometry lookup misses', + ); + assert.ok(source.includes('print("active")'), 'active state should be printed for the tracker'); +}); + +test('frontmost non-mpv app emits inactive state when geometry lookup misses', () => { + assert.ok( + /case\s+\.inactive:/.test(source), + 'helper should expose an inactive state without window geometry', + ); + assert.ok( + source.includes('if frontmost != nil'), + 'helper should distinguish a known non-mpv frontmost app from an unknown miss', + ); + assert.ok(source.includes('return .inactive'), 'known non-mpv focus should return inactive'); + assert.ok( + source.includes('print("inactive")'), + 'inactive state should be printed for the tracker', + ); +}); diff --git a/src/core/services/anilist/anilist-update-queue.test.ts b/src/core/services/anilist/anilist-update-queue.test.ts index 4f0901c7..54a56836 100644 --- a/src/core/services/anilist/anilist-update-queue.test.ts +++ b/src/core/services/anilist/anilist-update-queue.test.ts @@ -32,10 +32,16 @@ test('anilist update queue enqueues, snapshots, and dequeues success', () => { const loggerState = createLogger(); const queue = createAnilistUpdateQueue(queueFile, loggerState.logger); - queue.enqueue('k1', 'Demo', 1); + queue.enqueue('k1', 'Demo', 1, 2); const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER); assert.deepEqual(snapshot, { pending: 1, ready: 1, deadLetter: 0 }); - assert.equal(queue.nextReady(Number.MAX_SAFE_INTEGER)?.key, 'k1'); + assert.deepEqual( + { + key: queue.nextReady(Number.MAX_SAFE_INTEGER)?.key, + season: queue.nextReady(Number.MAX_SAFE_INTEGER)?.season, + }, + { key: 'k1', season: 2 }, + ); queue.markSuccess('k1'); assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), { diff --git a/src/core/services/anilist/anilist-update-queue.ts b/src/core/services/anilist/anilist-update-queue.ts index d51d3583..776a700f 100644 --- a/src/core/services/anilist/anilist-update-queue.ts +++ b/src/core/services/anilist/anilist-update-queue.ts @@ -9,6 +9,7 @@ const MAX_ITEMS = 500; export interface AnilistQueuedUpdate { key: string; title: string; + season?: number | null; episode: number; createdAt: number; attemptCount: number; @@ -28,7 +29,7 @@ export interface AnilistRetryQueueSnapshot { } export interface AnilistUpdateQueue { - enqueue: (key: string, title: string, episode: number) => void; + enqueue: (key: string, title: string, episode: number, season?: number | null) => void; nextReady: (nowMs?: number) => AnilistQueuedUpdate | null; markSuccess: (key: string) => void; markFailure: (key: string, reason: string, nowMs?: number) => void; @@ -106,7 +107,7 @@ export function createAnilistUpdateQueue( load(); return { - enqueue(key: string, title: string, episode: number): void { + enqueue(key: string, title: string, episode: number, season: number | null = null): void { const existing = pending.find((item) => item.key === key); if (existing) { return; @@ -117,6 +118,7 @@ export function createAnilistUpdateQueue( pending.push({ key, title, + season, episode, createdAt: Date.now(), attemptCount: 0, diff --git a/src/core/services/anilist/anilist-updater.test.ts b/src/core/services/anilist/anilist-updater.test.ts index 81dd59d0..9a09e689 100644 --- a/src/core/services/anilist/anilist-updater.test.ts +++ b/src/core/services/anilist/anilist-updater.test.ts @@ -265,6 +265,125 @@ test('updateAnilistPostWatchProgress skips when progress already reached', async } }); +test('updateAnilistPostWatchProgress returns non-retryable error when media is not planning or watching', async () => { + const originalFetch = globalThis.fetch; + let call = 0; + globalThis.fetch = (async () => { + call += 1; + if (call === 1) { + return createJsonResponse({ + data: { + Page: { + media: [{ id: 33, episodes: 12, title: { english: 'Missing Show' } }], + }, + }, + }); + } + return createJsonResponse({ + data: { + Media: { id: 33, mediaListEntry: null }, + }, + }); + }) as typeof fetch; + + try { + const result = await updateAnilistPostWatchProgress('token', 'Missing Show', 2); + assert.equal(result.status, 'error'); + assert.equal(result.retryable, false); + assert.match(result.message, /not in your AniList Planning or Watching list/i); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('updateAnilistPostWatchProgress prefers season-specific AniList matches', async () => { + const originalFetch = globalThis.fetch; + const searchTerms: string[] = []; + let call = 0; + globalThis.fetch = (async (_input, init) => { + call += 1; + const body = JSON.parse(String(init?.body)) as { variables?: Record }; + if (call === 1) { + searchTerms.push(String(body.variables?.search)); + return createJsonResponse({ + data: { + Page: { + media: [ + { id: 202, episodes: 12, title: { english: 'Demo Show Season 2' } }, + { id: 101, episodes: 12, title: { english: 'Demo Show' } }, + ], + }, + }, + }); + } + if (call === 2) { + assert.equal(body.variables?.mediaId, 202); + return createJsonResponse({ + data: { + Media: { id: 202, mediaListEntry: null }, + }, + }); + } + return createJsonResponse({ + data: { + SaveMediaListEntry: { progress: 2, status: 'CURRENT' }, + }, + }); + }) as typeof fetch; + + try { + const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 2, { + season: 2, + }); + assert.deepEqual(searchTerms, ['Demo Show Season 2']); + assert.equal(result.status, 'error'); + assert.equal(result.retryable, false); + assert.match(result.message, /not in your AniList Planning or Watching list/i); + assert.equal(call, 2); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('updateAnilistPostWatchProgress does not update rewatching entries', async () => { + const originalFetch = globalThis.fetch; + let call = 0; + globalThis.fetch = (async () => { + call += 1; + if (call === 1) { + return createJsonResponse({ + data: { + Page: { + media: [{ id: 44, episodes: 12, title: { english: 'Rewatch Show' } }], + }, + }, + }); + } + if (call === 2) { + return createJsonResponse({ + data: { + Media: { id: 44, mediaListEntry: { progress: 0, status: 'REPEATING' } }, + }, + }); + } + return createJsonResponse({ + data: { + SaveMediaListEntry: { progress: 2, status: 'CURRENT' }, + }, + }); + }) as typeof fetch; + + try { + const result = await updateAnilistPostWatchProgress('token', 'Rewatch Show', 2); + assert.equal(result.status, 'error'); + assert.equal(result.retryable, false); + assert.match(result.message, /marked repeating on AniList/i); + assert.equal(call, 2); + } finally { + globalThis.fetch = originalFetch; + } +}); + test('updateAnilistPostWatchProgress returns error when search fails', async () => { const originalFetch = globalThis.fetch; globalThis.fetch = (async () => diff --git a/src/core/services/anilist/anilist-updater.ts b/src/core/services/anilist/anilist-updater.ts index 72830054..5956bc2e 100644 --- a/src/core/services/anilist/anilist-updater.ts +++ b/src/core/services/anilist/anilist-updater.ts @@ -18,10 +18,12 @@ export interface AnilistMediaGuess { export interface AnilistPostWatchUpdateResult { status: 'updated' | 'skipped' | 'error'; message: string; + retryable?: boolean; } export interface AnilistPostWatchUpdateOptions { rateLimiter?: AnilistRateLimiter; + season?: number | null; } interface AnilistGraphQlError { @@ -156,6 +158,28 @@ function normalizeTitle(text: string): string { return text.trim().toLowerCase().replace(/\s+/g, ' '); } +function titleMentionsSeason(title: string, season: number): boolean { + const normalized = normalizeTitle(title); + return ( + normalized.includes(`season ${season}`) || + normalized.includes(`s${String(season).padStart(2, '0')}`) || + normalized.includes(`s${season}`) + ); +} + +function buildSearchCandidates(title: string, season: number | null | undefined): string[] { + const trimmed = title.trim(); + if (!trimmed) return []; + const candidates = + typeof season === 'number' && + Number.isInteger(season) && + season > 1 && + !titleMentionsSeason(trimmed, season) + ? [`${trimmed} Season ${season}`, trimmed] + : [trimmed]; + return candidates.filter((candidate, index, all) => all.indexOf(candidate) === index); +} + async function anilistGraphQl( accessToken: string, query: string, @@ -226,6 +250,15 @@ function pickBestSearchResult( return { id: selected.id, title: selectedTitle }; } +function isUpdateableListStatus(status: string | null | undefined): boolean { + return status === 'CURRENT' || status === 'PLANNING'; +} + +function formatListStatus(status: string | null | undefined): string { + if (!status) return 'not in your AniList Planning or Watching list'; + return `marked ${status.toLowerCase().replace(/_/g, ' ')} on AniList`; +} + export async function guessAnilistMediaInfo( mediaPath: string | null, mediaTitle: string | null, @@ -279,27 +312,42 @@ export async function updateAnilistPostWatchProgress( episode: number, options: AnilistPostWatchUpdateOptions = {}, ): Promise { - const searchResponse = await anilistGraphQl( - accessToken, - ` - query ($search: String!) { - Page(perPage: 5) { - media(search: $search, type: ANIME) { - id - episodes - title { - romaji - english - native + let media: NonNullable['media']> = []; + let searchError: string | null = null; + let pickTitle = title; + const searchCandidates = buildSearchCandidates(title, options.season); + for (const search of searchCandidates) { + const searchResponse = await anilistGraphQl( + accessToken, + ` + query ($search: String!) { + Page(perPage: 5) { + media(search: $search, type: ANIME) { + id + episodes + title { + romaji + english + native + } } } } - } - `, - { search: title }, - options, - ); - const searchError = firstErrorMessage(searchResponse); + `, + { search }, + options, + ); + searchError = firstErrorMessage(searchResponse); + if (searchError) { + break; + } + media = searchResponse.data?.Page?.media ?? []; + if (media.length > 0) { + pickTitle = search; + break; + } + } + if (searchError) { return { status: 'error', @@ -307,8 +355,7 @@ export async function updateAnilistPostWatchProgress( }; } - const media = searchResponse.data?.Page?.media ?? []; - const picked = pickBestSearchResult(title, episode, media); + const picked = pickBestSearchResult(pickTitle, episode, media); if (!picked) { return { status: 'error', message: 'AniList search returned no matches.' }; } @@ -337,7 +384,16 @@ export async function updateAnilistPostWatchProgress( }; } - const currentProgress = entryResponse.data?.Media?.mediaListEntry?.progress ?? 0; + const entry = entryResponse.data?.Media?.mediaListEntry ?? null; + if (!entry || !isUpdateableListStatus(entry.status)) { + return { + status: 'error', + retryable: false, + message: `AniList update not possible: "${picked.title}" is ${formatListStatus(entry?.status)}. Add it to Planning or Watching, then mark watched again.`, + }; + } + + const currentProgress = entry.progress ?? 0; if (typeof currentProgress === 'number' && currentProgress >= episode) { return { status: 'skipped', diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts index 46e2ec80..8b812225 100644 --- a/src/core/services/ipc.test.ts +++ b/src/core/services/ipc.test.ts @@ -218,6 +218,9 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { getMainWindow: () => null, getVisibleOverlayVisibility: () => false, onOverlayModalClosed: () => {}, + onOverlayMouseInteractionChanged: (active) => { + calls.push(`overlay-interaction:${active}`); + }, openYomitanSettings: () => {}, quitApp: () => {}, toggleVisibleOverlay: () => {}, @@ -281,6 +284,7 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { assert.deepEqual(deps.getAnilistStatus(), { tokenStatus: 'resolved' }); deps.clearAnilistToken(); deps.openAnilistSetup(); + deps.onOverlayMouseInteractionChanged?.(true, null); assert.deepEqual(deps.getAnilistQueueStatus(), { pending: 1, ready: 0, @@ -298,10 +302,37 @@ test('createIpcDepsRuntime wires AniList handlers', async () => { assert.deepEqual(await deps.playPlaylistBrowserIndex(2), { ok: true, message: 'play' }); assert.deepEqual(await deps.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' }); assert.deepEqual(await deps.movePlaylistBrowserIndex(2, -1), { ok: true, message: 'move' }); - assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']); + assert.deepEqual(calls, [ + 'clearAnilistToken', + 'openAnilistSetup', + 'overlay-interaction:true', + 'retryAnilistQueueNow', + ]); assert.equal(deps.getPlaybackPaused(), true); }); +test('registerIpcHandlers maps setIgnoreMouseEvents to overlay interaction active state', () => { + const { registrar, handlers } = createFakeIpcRegistrar(); + const calls: string[] = []; + + registerIpcHandlers( + createRegisterIpcDeps({ + onOverlayMouseInteractionChanged: (active) => { + calls.push(`overlay-interaction:${active}`); + }, + }), + registrar, + ); + + const handler = handlers.on.get(IPC_CHANNELS.command.setIgnoreMouseEvents); + assert.equal(typeof handler, 'function'); + + handler?.({}, true, { forward: true }); + handler?.({}, false, {}); + + assert.deepEqual(calls, ['overlay-interaction:false', 'overlay-interaction:true']); +}); + test('registerIpcHandlers runs AniList update after manual mark watched succeeds', async () => { const { registrar, handlers } = createFakeIpcRegistrar(); const calls: string[] = []; diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts index 89c04da1..f6b0cf58 100644 --- a/src/core/services/ipc.ts +++ b/src/core/services/ipc.ts @@ -44,6 +44,10 @@ export interface IpcServiceDeps { modal: OverlayHostedModal, senderWindow: ElectronBrowserWindow | null, ) => void; + onOverlayMouseInteractionChanged?: ( + active: boolean, + senderWindow: ElectronBrowserWindow | null, + ) => void; openYomitanSettings: () => void; quitApp: () => void; toggleDevTools: () => void; @@ -175,6 +179,10 @@ export interface IpcDepsRuntimeOptions { modal: OverlayHostedModal, senderWindow: ElectronBrowserWindow | null, ) => void; + onOverlayMouseInteractionChanged?: ( + active: boolean, + senderWindow: ElectronBrowserWindow | null, + ) => void; openYomitanSettings: () => void; quitApp: () => void; toggleVisibleOverlay: () => void; @@ -233,6 +241,7 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService return { onOverlayModalClosed: options.onOverlayModalClosed, onOverlayModalOpened: options.onOverlayModalOpened, + onOverlayMouseInteractionChanged: options.onOverlayMouseInteractionChanged, openYomitanSettings: options.openYomitanSettings, quitApp: options.quitApp, toggleDevTools: () => { @@ -349,6 +358,7 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar if (senderWindow && !senderWindow.isDestroyed()) { senderWindow.setIgnoreMouseEvents(ignore, parsedOptions); } + deps.onOverlayMouseInteractionChanged?.(!ignore, senderWindow); }, ); diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts index 26f40d10..564c98c6 100644 --- a/src/core/services/overlay-visibility.test.ts +++ b/src/core/services/overlay-visibility.test.ts @@ -42,6 +42,11 @@ function createMainWindowRecorder() { setAlwaysOnTop: (flag: boolean) => { calls.push(`always-on-top:${flag}`); }, + setVisibleOnAllWorkspaces: (flag: boolean, options?: { visibleOnFullScreen?: boolean }) => { + calls.push( + `all-workspaces:${flag}:${options?.visibleOnFullScreen === true ? 'fullscreen' : 'plain'}`, + ); + }, setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => { calls.push(`mouse-ignore:${ignore}:${options?.forward === true ? 'forward' : 'plain'}`); }, @@ -538,11 +543,12 @@ test('forced passthrough still shows tracked overlay while bound to mpv on Windo assert.ok(calls.includes('sync-windows-z-order')); }); -test('forced mouse passthrough drops macOS tracked overlay below higher-priority windows', () => { +test('forced mouse passthrough keeps macOS tracked overlay above active mpv', () => { const { window, calls } = createMainWindowRecorder(); const tracker: WindowTrackerStub = { isTracking: () => true, getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => true, }; updateVisibleOverlayVisibility({ @@ -571,8 +577,53 @@ test('forced mouse passthrough drops macOS tracked overlay below higher-priority forceMousePassthrough: true, } as never); + assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('ensure-level')); + assert.ok(calls.includes('enforce-order')); + assert.ok(!calls.includes('always-on-top:false')); +}); + +test('forced mouse passthrough still hides macOS tracked overlay when mpv loses foreground', () => { + const { window, calls } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => 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, + forceMousePassthrough: true, + } as never); + assert.ok(calls.includes('mouse-ignore:true:forward')); assert.ok(calls.includes('always-on-top:false')); + assert.ok(calls.includes('all-workspaces:false:plain')); + assert.ok(calls.includes('hide')); assert.ok(!calls.includes('ensure-level')); assert.ok(!calls.includes('enforce-order')); }); @@ -916,7 +967,8 @@ test('macOS tracked visible overlay starts click-through without passively steal } as never); assert.ok(calls.includes('mouse-ignore:true:forward')); - assert.ok(calls.includes('show')); + assert.ok(calls.includes('show-inactive')); + assert.ok(!calls.includes('show')); assert.ok(!calls.includes('focus')); }); @@ -1009,7 +1061,7 @@ test('macOS keeps active mpv overlay visible and click-through during tracker re assert.deepEqual(osdMessages, []); }); -test('macOS tracked overlay releases topmost level when mpv loses foreground', () => { +test('macOS tracked overlay hides when mpv loses foreground', () => { const { window, calls } = createMainWindowRecorder(); const tracker: WindowTrackerStub = { isTracking: () => true, @@ -1017,6 +1069,9 @@ test('macOS tracked overlay releases topmost level when mpv loses foreground', ( isTargetWindowFocused: () => false, }; + window.show(); + calls.length = 0; + updateVisibleOverlayVisibility({ visibleOverlayVisible: true, mainWindow: window as never, @@ -1046,14 +1101,202 @@ test('macOS tracked overlay releases topmost level when mpv loses foreground', ( assert.ok(calls.includes('sync-layer')); assert.ok(calls.includes('mouse-ignore:true:forward')); assert.ok(calls.includes('always-on-top:false')); - assert.ok(calls.includes('show')); + assert.ok(calls.includes('all-workspaces:false:plain')); + assert.ok(calls.includes('hide')); assert.ok(calls.includes('sync-shortcuts')); assert.ok(!calls.includes('ensure-level')); assert.ok(!calls.includes('enforce-order')); assert.ok(!calls.includes('focus')); + assert.ok(!calls.includes('show')); +}); + +test('macOS keeps tracked overlay visible while overlay interaction is active after mpv loses foreground', () => { + const { window, calls, setFocused } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => false, + }; + + window.show(); + setFocused(false); + calls.length = 0; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + overlayInteractionActive: 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, + } as never); + + assert.ok(calls.includes('update-bounds')); + assert.ok(calls.includes('sync-layer')); + assert.ok(calls.includes('mouse-ignore:false:plain')); + 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 lets an active overlay receive mouse input instead of forcing passthrough', () => { + const { window, calls, setFocused } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => false, + }; + + window.show(); + setFocused(false); + calls.length = 0; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + overlayInteractionActive: 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, + } as never); + + assert.ok(calls.includes('mouse-ignore:false:plain')); + assert.ok(!calls.includes('mouse-ignore:true:forward')); + assert.ok(!calls.includes('hide')); +}); + +test('macOS focuses an active overlay so lookup trigger keys reach it', () => { + const { window, calls, setFocused } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => false, + }; + + window.show(); + setFocused(false); + calls.length = 0; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + overlayInteractionActive: 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, + } as never); + + assert.ok(calls.includes('mouse-ignore:false:plain')); + assert.ok(calls.includes('focus')); + assert.ok(!calls.includes('hide')); +}); + +test('macOS tracked overlay passively reappears when mpv regains foreground', () => { + const { window, calls } = createMainWindowRecorder(); + let targetFocused = false; + const tracker: WindowTrackerStub = { + isTracking: () => true, + getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }), + isTargetWindowFocused: () => targetFocused, + }; + + window.show(); + calls.length = 0; + + const run = () => + 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, + } as never); + + run(); + assert.ok(calls.includes('hide')); + + calls.length = 0; + targetFocused = true; + run(); + + assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('ensure-level')); + assert.ok(calls.includes('show-inactive')); + assert.ok(calls.includes('enforce-order')); + assert.ok(!calls.includes('show')); + assert.ok(!calls.includes('focus')); +}); + test('macOS preserves an already visible active mpv overlay while tracker is temporarily not ready', () => { const { window, calls } = createMainWindowRecorder(); const osdMessages: string[] = []; @@ -1141,7 +1384,8 @@ test('forced mouse passthrough keeps macOS tracked overlay passive while visible } as never); assert.ok(calls.includes('mouse-ignore:true:forward')); - assert.ok(calls.includes('show')); + assert.ok(calls.includes('show-inactive')); + assert.ok(!calls.includes('show')); assert.ok(!calls.includes('focus')); }); @@ -1438,7 +1682,7 @@ test('macOS preserves visible overlay during transient tracker loss with retaine assert.ok(!calls.includes('show')); }); -test('macOS preserves visible overlay level during non-minimized tracker loss', () => { +test('macOS hides visible overlay during tracker loss after mpv loses foreground', () => { const { window, calls } = createMainWindowRecorder(); const tracker: WindowTrackerStub = { isTracking: () => false, @@ -1477,13 +1721,114 @@ test('macOS preserves visible overlay level during non-minimized tracker loss', }, } as never); + assert.ok(calls.includes('sync-layer')); + assert.ok(calls.includes('mouse-ignore:true:forward')); + assert.ok(calls.includes('always-on-top:false')); + assert.ok(calls.includes('all-workspaces:false:plain')); + assert.ok(calls.includes('hide')); + assert.ok(calls.includes('sync-shortcuts')); + assert.ok(!calls.includes('ensure-level')); + assert.ok(!calls.includes('enforce-order')); + assert.ok(!calls.includes('loading-osd')); +}); + +test('macOS keeps a focused overlay visible during tracker loss', () => { + const { window, calls, setFocused } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => false, + getGeometry: () => null, + isTargetWindowFocused: () => false, + isTargetWindowMinimized: () => false, + }; + + window.show(); + setFocused(true); + 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, + showOverlayLoadingOsd: () => { + calls.push('loading-osd'); + }, + } as never); + 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('hide')); + assert.ok(!calls.includes('loading-osd')); +}); + +test('macOS keeps an interactive overlay visible during tracker loss even when Electron focus drops', () => { + const { window, calls, setFocused } = createMainWindowRecorder(); + const tracker: WindowTrackerStub = { + isTracking: () => false, + getGeometry: () => null, + isTargetWindowFocused: () => false, + isTargetWindowMinimized: () => false, + }; + + window.show(); + setFocused(false); + calls.length = 0; + + updateVisibleOverlayVisibility({ + visibleOverlayVisible: true, + overlayInteractionActive: 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, + showOverlayLoadingOsd: () => { + calls.push('loading-osd'); + }, + } as never); + + assert.ok(calls.includes('sync-layer')); + assert.ok(calls.includes('mouse-ignore:false:plain')); + 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')); assert.ok(!calls.includes('loading-osd')); }); diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts index e05cd7e5..0924651b 100644 --- a/src/core/services/overlay-visibility.ts +++ b/src/core/services/overlay-visibility.ts @@ -15,6 +15,17 @@ function setOverlayWindowOpacity(window: BrowserWindow, opacity: number): void { opacityCapableWindow.setOpacity?.(opacity); } +function releaseOverlayWindowLevel(window: BrowserWindow): void { + window.setAlwaysOnTop(false); + const allWorkspacesWindow = window as BrowserWindow & { + setVisibleOnAllWorkspaces?: ( + visible: boolean, + options?: { visibleOnFullScreen?: boolean }, + ) => void; + }; + allWorkspacesWindow.setVisibleOnAllWorkspaces?.(false, { visibleOnFullScreen: false }); +} + function clearPendingWindowsOverlayReveal(window: BrowserWindow): void { const pendingTimeout = pendingWindowsOverlayRevealTimeoutByWindow.get(window); if (!pendingTimeout) { @@ -52,6 +63,7 @@ export function updateVisibleOverlayVisibility(args: { visibleOverlayVisible: boolean; modalActive?: boolean; forceMousePassthrough?: boolean; + overlayInteractionActive?: boolean; mainWindow: BrowserWindow | null; windowTracker: BaseWindowTracker | null; lastKnownWindowsForegroundProcessName?: string | null; @@ -78,6 +90,7 @@ export function updateVisibleOverlayVisibility(args: { } const mainWindow = args.mainWindow; + const overlayInteractionActive = args.overlayInteractionActive === true; if (args.modalActive) { if (args.isWindowsPlatform) { @@ -93,23 +106,26 @@ export function updateVisibleOverlayVisibility(args: { const forceMousePassthrough = args.forceMousePassthrough === true; const wasVisible = mainWindow.isVisible(); const isVisibleOverlayFocused = - typeof mainWindow.isFocused === 'function' && mainWindow.isFocused(); + overlayInteractionActive || + (typeof mainWindow.isFocused === 'function' && mainWindow.isFocused()); const windowTracker = args.windowTracker; const canReportMacOSTargetMinimized = args.isMacOSPlatform && typeof windowTracker?.isTargetWindowMinimized === 'function'; const isTrackedMacOSTargetMinimized = canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true; + const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.(); const hasTransientMacOSTrackerLoss = args.isMacOSPlatform && canReportMacOSTargetMinimized && !!windowTracker && !windowTracker.isTracking() && !isTrackedMacOSTargetMinimized && + trackedMacOSTargetFocused !== false && mainWindow.isVisible(); const isTrackedMacOSTargetFocused = hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker ? true - : (args.windowTracker.isTargetWindowFocused?.() ?? true); + : (trackedMacOSTargetFocused ?? true); const shouldReleaseMacOSOverlayLevel = args.isMacOSPlatform && !!args.windowTracker && @@ -117,7 +133,7 @@ export function updateVisibleOverlayVisibility(args: { !isVisibleOverlayFocused && !isTrackedMacOSTargetFocused; // Renderer hover tracking temporarily disables this for subtitle and popup interaction. - const shouldUseMacOSMousePassthrough = args.isMacOSPlatform; + const shouldUseMacOSMousePassthrough = args.isMacOSPlatform && !overlayInteractionActive; const shouldDefaultToPassthrough = args.isWindowsPlatform || forceMousePassthrough || shouldReleaseMacOSOverlayLevel; const windowsForegroundProcessName = @@ -159,14 +175,22 @@ export function updateVisibleOverlayVisibility(args: { mainWindow.setIgnoreMouseEvents(false); } + if (shouldReleaseMacOSOverlayLevel) { + releaseOverlayWindowLevel(mainWindow); + if (wasVisible) { + mainWindow.hide(); + } + return false; + } + if (shouldBindTrackedWindowsOverlay) { // On Windows, z-order is enforced by the OS via the owner window mechanism // (SetWindowLongPtr GWLP_HWNDPARENT). The overlay is always above mpv // without any manual z-order management. - } else if (!forceMousePassthrough && !shouldReleaseMacOSOverlayLevel) { + } else if (!forceMousePassthrough || args.isMacOSPlatform) { args.ensureOverlayWindowLevel(mainWindow); } else { - mainWindow.setAlwaysOnTop(false); + releaseOverlayWindowLevel(mainWindow); } if (!wasVisible) { const hasWebContents = @@ -179,16 +203,20 @@ 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 && shouldIgnoreMouseEvents) { - setOverlayWindowOpacity(mainWindow, 0); + } else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) { + if (args.isWindowsPlatform) { + setOverlayWindowOpacity(mainWindow, 0); + } mainWindow.showInactive(); mainWindow.setIgnoreMouseEvents(true, { forward: true }); - scheduleWindowsOverlayReveal( - mainWindow, - shouldBindTrackedWindowsOverlay - ? (window) => args.syncWindowsOverlayToMpvZOrder?.(window) - : undefined, - ); + if (args.isWindowsPlatform) { + scheduleWindowsOverlayReveal( + mainWindow, + shouldBindTrackedWindowsOverlay + ? (window) => args.syncWindowsOverlayToMpvZOrder?.(window) + : undefined, + ); + } } else { if (args.isWindowsPlatform) { setOverlayWindowOpacity(mainWindow, 0); @@ -209,6 +237,16 @@ export function updateVisibleOverlayVisibility(args: { args.syncWindowsOverlayToMpvZOrder?.(mainWindow); } + if ( + args.isMacOSPlatform && + overlayInteractionActive && + !forceMousePassthrough && + typeof mainWindow.isFocused === 'function' && + !mainWindow.isFocused() + ) { + mainWindow.focus(); + } + if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) { mainWindow.focus(); } @@ -216,6 +254,11 @@ export function updateVisibleOverlayVisibility(args: { return !shouldReleaseMacOSOverlayLevel; }; + const shouldEnforceVisibleOverlayLayerOrder = (shouldEnforceLayerOrder: boolean): boolean => + shouldEnforceLayerOrder && + !args.isWindowsPlatform && + (!args.forceMousePassthrough || args.isMacOSPlatform === true); + const maybeShowOverlayLoadingOsd = (): void => { if (!args.isMacOSPlatform || !args.showOverlayLoadingOsd) { return; @@ -258,7 +301,7 @@ export function updateVisibleOverlayVisibility(args: { } args.syncPrimaryOverlayWindowLayer('visible'); const shouldEnforceLayerOrder = showPassiveVisibleOverlay(); - if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) { + if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) { args.enforceOverlayLayerOrder(); } args.syncOverlayShortcuts(); @@ -290,6 +333,7 @@ export function updateVisibleOverlayVisibility(args: { const hasRetainedTrackedGeometry = args.windowTracker.getGeometry() !== null; const hasActiveMacOSTargetSignal = args.isMacOSPlatform && (args.windowTracker.isTargetWindowFocused?.() ?? false); + const hasActiveMacOSOverlaySignal = args.isMacOSPlatform && overlayInteractionActive; const canReportMacOSTargetMinimized = args.isMacOSPlatform && typeof args.windowTracker.isTargetWindowMinimized === 'function'; const isTrackedMacOSTargetMinimized = @@ -298,6 +342,7 @@ export function updateVisibleOverlayVisibility(args: { (args.isMacOSPlatform && !isTrackedMacOSTargetMinimized && (hasRetainedTrackedGeometry || + (mainWindow.isVisible() && hasActiveMacOSOverlaySignal) || (mainWindow.isVisible() && hasActiveMacOSTargetSignal) || (canReportMacOSTargetMinimized && mainWindow.isVisible()))) || (args.isWindowsPlatform && @@ -315,7 +360,7 @@ export function updateVisibleOverlayVisibility(args: { } args.syncPrimaryOverlayWindowLayer('visible'); const shouldEnforceLayerOrder = showPassiveVisibleOverlay(); - if (shouldEnforceLayerOrder && !args.forceMousePassthrough && !args.isWindowsPlatform) { + if (shouldEnforceVisibleOverlayLayerOrder(shouldEnforceLayerOrder)) { args.enforceOverlayLayerOrder(); } args.syncOverlayShortcuts(); diff --git a/src/core/services/overlay-window-input.ts b/src/core/services/overlay-window-input.ts index 54e0c1b7..c98ef58e 100644 --- a/src/core/services/overlay-window-input.ts +++ b/src/core/services/overlay-window-input.ts @@ -66,15 +66,16 @@ export function handleOverlayWindowBlurred(options: { isOverlayVisible: (kind: OverlayWindowKind) => boolean; ensureOverlayWindowLevel: () => void; moveWindowTop: () => void; - onWindowsVisibleOverlayBlur?: () => void; + onVisibleOverlayBlur?: () => void; platform?: NodeJS.Platform; }): boolean { const platform = options.platform ?? process.platform; if (platform === 'win32' && options.kind === 'visible') { - options.onWindowsVisibleOverlayBlur?.(); + options.onVisibleOverlayBlur?.(); return false; } if (platform === 'darwin' && options.kind === 'visible') { + options.onVisibleOverlayBlur?.(); return false; } diff --git a/src/core/services/overlay-window.test.ts b/src/core/services/overlay-window.test.ts index b69e4346..49521ee7 100644 --- a/src/core/services/overlay-window.test.ts +++ b/src/core/services/overlay-window.test.ts @@ -136,14 +136,14 @@ test('handleOverlayWindowBlurred notifies Windows visible overlay blur callback moveWindowTop: () => { calls.push('move-top'); }, - onWindowsVisibleOverlayBlur: () => { - calls.push('windows-visible-blur'); + onVisibleOverlayBlur: () => { + calls.push('visible-blur'); }, platform: 'win32', }); assert.equal(handled, false); - assert.deepEqual(calls, ['windows-visible-blur']); + assert.deepEqual(calls, ['visible-blur']); }); test('handleOverlayWindowBlurred skips macOS visible overlay restacking after focus loss', () => { @@ -166,7 +166,7 @@ test('handleOverlayWindowBlurred skips macOS visible overlay restacking after fo assert.deepEqual(calls, []); }); -test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visible overlay blur', () => { +test('handleOverlayWindowBlurred notifies macOS visible overlay blur callback without restacking', () => { const calls: string[] = []; const handled = handleOverlayWindowBlurred({ @@ -179,14 +179,14 @@ test('handleOverlayWindowBlurred leaves Windows callback inactive on macOS visib moveWindowTop: () => { calls.push('move-top'); }, - onWindowsVisibleOverlayBlur: () => { - calls.push('windows-visible-blur'); + onVisibleOverlayBlur: () => { + calls.push('visible-blur'); }, platform: 'darwin', }); assert.equal(handled, false); - assert.deepEqual(calls, []); + assert.deepEqual(calls, ['visible-blur']); }); test('handleOverlayWindowBlurred preserves active visible/modal window stacking', () => { diff --git a/src/core/services/overlay-window.ts b/src/core/services/overlay-window.ts index bb30f98f..4dccaebf 100644 --- a/src/core/services/overlay-window.ts +++ b/src/core/services/overlay-window.ts @@ -180,7 +180,7 @@ export function createOverlayWindow( moveWindowTop: () => { window.moveTop(); }, - onWindowsVisibleOverlayBlur: + onVisibleOverlayBlur: kind === 'visible' ? () => options.onVisibleWindowBlurred?.() : undefined, }); }); diff --git a/src/core/services/stats-window-runtime.ts b/src/core/services/stats-window-runtime.ts index 2ffbce95..73945717 100644 --- a/src/core/services/stats-window-runtime.ts +++ b/src/core/services/stats-window-runtime.ts @@ -9,6 +9,8 @@ type StatsWindowLevelController = Pick>; type StatsWindowBoundsController = Pick; +type StatsWindowPresentationController = Pick & + Partial>; function isBareToggleKeyInput(input: Electron.Input, toggleKey: string): boolean { return ( @@ -104,6 +106,23 @@ export function promoteStatsWindowLevel( window.moveTop(); } +export function presentStatsWindow( + window: StatsWindowPresentationController, + platform: NodeJS.Platform = process.platform, +): void { + if (platform === 'darwin') { + if (window.showInactive) { + window.showInactive(); + } else { + window.show(); + } + return; + } + + window.show(); + window.focus(); +} + export function buildStatsWindowLoadFileOptions(apiBaseUrl?: string): { query: Record; } { diff --git a/src/core/services/stats-window.test.ts b/src/core/services/stats-window.test.ts index cc599afa..da2e0dd3 100644 --- a/src/core/services/stats-window.test.ts +++ b/src/core/services/stats-window.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { buildStatsWindowLoadFileOptions, buildStatsWindowOptions, + presentStatsWindow, promoteStatsWindowLevel, resolveStatsWindowOuterBoundsForContent, shouldHideStatsWindowForInput, @@ -230,3 +231,45 @@ test('promoteStatsWindowLevel raises stats above overlay level on Windows', () = assert.deepEqual(calls, ['always-on-top:true:screen-saver:2', 'move-top']); }); + +test('presentStatsWindow shows inactive on macOS to stay on the fullscreen mpv Space', () => { + const calls: string[] = []; + + presentStatsWindow( + { + show: () => { + calls.push('show'); + }, + showInactive: () => { + calls.push('show-inactive'); + }, + focus: () => { + calls.push('focus'); + }, + } as never, + 'darwin', + ); + + assert.deepEqual(calls, ['show-inactive']); +}); + +test('presentStatsWindow shows and focuses on non-macOS platforms', () => { + const calls: string[] = []; + + presentStatsWindow( + { + show: () => { + calls.push('show'); + }, + showInactive: () => { + calls.push('show-inactive'); + }, + focus: () => { + calls.push('focus'); + }, + } as never, + 'linux', + ); + + assert.deepEqual(calls, ['show', 'focus']); +}); diff --git a/src/core/services/stats-window.ts b/src/core/services/stats-window.ts index 83aade76..70a3057c 100644 --- a/src/core/services/stats-window.ts +++ b/src/core/services/stats-window.ts @@ -5,6 +5,7 @@ import { IPC_CHANNELS } from '../../shared/ipc/contracts.js'; import { buildStatsWindowLoadFileOptions, buildStatsWindowOptions, + presentStatsWindow, promoteStatsWindowLevel, resolveStatsWindowOuterBoundsForContent, shouldHideStatsWindowForInput, @@ -49,14 +50,13 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo const bounds = options.resolveBounds(); let placementBounds = syncStatsWindowBounds(window, bounds); promoteStatsWindowLevel(window); - window.show(); + presentStatsWindow(window); placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds; if ( !ensureHyprlandWindowFloatingByTitle({ title: STATS_WINDOW_TITLE, bounds: placementBounds }) ) { placementBounds = syncStatsWindowBounds(window, bounds) ?? placementBounds; } - window.focus(); options.onVisibilityChanged?.(true); promoteStatsWindowLevel(window); } diff --git a/src/main.ts b/src/main.ts index 383903f1..b4402ea0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2069,6 +2069,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( getModalActive: () => overlayModalInputState.getModalInputExclusive(), getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(), getForceMousePassthrough: () => appState.statsOverlayVisible, + getOverlayInteractionActive: () => visibleOverlayInteractionActive, getWindowTracker: () => appState.windowTracker, getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName, getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(), @@ -2112,23 +2113,24 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService( })(), ); -const WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const; +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; -let windowsVisibleOverlayBlurRefreshTimeouts: Array> = []; +let visibleOverlayBlurRefreshTimeouts: Array> = []; let windowsVisibleOverlayZOrderRetryTimeouts: Array> = []; let windowsVisibleOverlayZOrderSyncInFlight = false; let windowsVisibleOverlayZOrderSyncQueued = false; let windowsVisibleOverlayForegroundPollInterval: ReturnType | null = null; let lastWindowsVisibleOverlayForegroundProcessName: string | null = null; let lastWindowsVisibleOverlayBlurredAtMs = 0; +let visibleOverlayInteractionActive = false; -function clearWindowsVisibleOverlayBlurRefreshTimeouts(): void { - for (const timeout of windowsVisibleOverlayBlurRefreshTimeouts) { +function clearVisibleOverlayBlurRefreshTimeouts(): void { + for (const timeout of visibleOverlayBlurRefreshTimeouts) { clearTimeout(timeout); } - windowsVisibleOverlayBlurRefreshTimeouts = []; + visibleOverlayBlurRefreshTimeouts = []; } function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void { @@ -2329,20 +2331,22 @@ function clearWindowsVisibleOverlayForegroundPollLoop(): void { } function scheduleVisibleOverlayBlurRefresh(): void { - if (process.platform !== 'win32') { + if (process.platform !== 'win32' && process.platform !== 'darwin') { return; } - lastWindowsVisibleOverlayBlurredAtMs = Date.now(); - clearWindowsVisibleOverlayBlurRefreshTimeouts(); - for (const delayMs of WINDOWS_VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) { + if (process.platform === 'win32') { + lastWindowsVisibleOverlayBlurredAtMs = Date.now(); + } + clearVisibleOverlayBlurRefreshTimeouts(); + for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) { const refreshTimeout = setTimeout(() => { - windowsVisibleOverlayBlurRefreshTimeouts = windowsVisibleOverlayBlurRefreshTimeouts.filter( + visibleOverlayBlurRefreshTimeouts = visibleOverlayBlurRefreshTimeouts.filter( (timeout) => timeout !== refreshTimeout, ); overlayVisibilityRuntime.updateVisibleOverlayVisibility(); }, delayMs); - windowsVisibleOverlayBlurRefreshTimeouts.push(refreshTimeout); + visibleOverlayBlurRefreshTimeouts.push(refreshTimeout); } } @@ -3043,6 +3047,7 @@ const { resetAnilistMediaTracking, getAnilistMediaGuessRuntimeState, setAnilistMediaGuessRuntimeState, + recordAnilistMediaDuration, resetAnilistMediaGuessState, maybeProbeAnilistDuration, ensureAnilistMediaGuess, @@ -3146,6 +3151,13 @@ const { ); }, }, + recordMediaDurationMainDeps: { + getCurrentMediaKey: () => getCurrentAnilistMediaKey(), + getState: () => getAnilistMediaGuessRuntimeState(), + setState: (state) => { + setAnilistMediaGuessRuntimeState(state); + }, + }, resetMediaGuessStateMainDeps: { setMediaGuess: (value) => { anilistMediaGuessRuntimeState = transitionAnilistMediaGuessRuntimeState( @@ -3197,9 +3209,10 @@ const { ); }, refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(), - updateAnilistPostWatchProgress: (accessToken, title, episode) => + updateAnilistPostWatchProgress: (accessToken, title, episode, season) => updateAnilistPostWatchProgress(accessToken, title, episode, { rateLimiter: anilistRateLimiter, + season, }), markSuccess: (key) => { anilistUpdateQueue.markSuccess(key); @@ -3230,13 +3243,13 @@ const { resetAnilistMediaTracking(mediaKey); }, getWatchedSeconds: () => appState.mpvClient?.currentTimePos ?? Number.NaN, - maybeProbeAnilistDuration: (mediaKey) => maybeProbeAnilistDuration(mediaKey), + maybeProbeAnilistDuration: (mediaKey, options) => maybeProbeAnilistDuration(mediaKey, options), ensureAnilistMediaGuess: (mediaKey) => ensureAnilistMediaGuess(mediaKey), hasAttemptedUpdateKey: (key) => anilistAttemptedUpdateKeys.has(key), processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(), refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(), - enqueueRetry: (key, title, episode) => { - anilistUpdateQueue.enqueue(key, title, episode); + enqueueRetry: (key, title, episode, season) => { + anilistUpdateQueue.enqueue(key, title, episode, season); }, markRetryFailure: (key, message) => { anilistUpdateQueue.markFailure(key, message); @@ -3245,9 +3258,10 @@ const { anilistUpdateQueue.markSuccess(key); }, refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(), - updateAnilistPostWatchProgress: (accessToken, title, episode) => + updateAnilistPostWatchProgress: (accessToken, title, episode, season) => updateAnilistPostWatchProgress(accessToken, title, episode, { rateLimiter: anilistRateLimiter, + season, }), rememberAttemptedUpdateKey: (key) => { rememberAnilistAttemptedUpdate(key); @@ -3984,7 +3998,10 @@ const { reportJellyfinRemoteStopped: () => { void reportJellyfinRemoteStopped(); }, - maybeRunAnilistPostWatchUpdate: () => maybeRunAnilistPostWatchUpdate(), + maybeRunAnilistPostWatchUpdate: (options) => maybeRunAnilistPostWatchUpdate(options), + recordAnilistMediaDuration: (durationSec) => { + recordAnilistMediaDuration(durationSec); + }, logSubtitleTimingError: (message, error) => logger.error(message, error), broadcastToOverlayWindows: (channel, payload) => { broadcastToOverlayWindows(channel, payload); @@ -5126,6 +5143,20 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ onOverlayModalOpened: (modal) => { overlayModalRuntime.notifyOverlayModalOpened(modal); }, + onOverlayMouseInteractionChanged: (active, senderWindow) => { + const mainWindow = overlayManager.getMainWindow(); + if (!mainWindow || senderWindow !== mainWindow) { + return; + } + if (visibleOverlayInteractionActive === active) { + if (active && process.platform === 'darwin' && !mainWindow.isFocused()) { + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + } + return; + } + visibleOverlayInteractionActive = active; + overlayVisibilityRuntime.updateVisibleOverlayVisibility(); + }, onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request), openYomitanSettings: () => openYomitanSettings(), quitApp: () => requestAppQuit(), diff --git a/src/main/character-dictionary-runtime.test.ts b/src/main/character-dictionary-runtime.test.ts index 666e6c4a..13f365d7 100644 --- a/src/main/character-dictionary-runtime.test.ts +++ b/src/main/character-dictionary-runtime.test.ts @@ -1459,7 +1459,7 @@ test('generateForCurrentMedia preserves duplicate surface forms across different } }); -test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data', async () => { +test('getOrCreateCurrentSnapshot reuses cached media resolution without AniList requests', async () => { const userDataPath = makeTempDir(); const originalFetch = globalThis.fetch; let searchQueryCount = 0; @@ -1567,11 +1567,18 @@ test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data', }); const first = await runtime.getOrCreateCurrentSnapshot(); + assert.equal(searchQueryCount, 1); + assert.equal(characterQueryCount, 1); + + fs.rmSync(path.join(userDataPath, 'character-dictionaries', 'anilist-resolution-cache.json'), { + force: true, + }); + const second = await runtime.getOrCreateCurrentSnapshot(); assert.equal(first.fromCache, false); assert.equal(second.fromCache, true); - assert.equal(searchQueryCount, 2); + assert.equal(searchQueryCount, 1); assert.equal(characterQueryCount, 1); assert.equal( fs.existsSync(path.join(userDataPath, 'character-dictionaries', 'cache.json')), diff --git a/src/main/character-dictionary-runtime.ts b/src/main/character-dictionary-runtime.ts index 74cee0bb..408ccb8c 100644 --- a/src/main/character-dictionary-runtime.ts +++ b/src/main/character-dictionary-runtime.ts @@ -15,7 +15,10 @@ import { getMergedZipPath, getSnapshotPath, normalizeMergedMediaIds, + readCachedMediaResolution, + readCachedSnapshots, readSnapshot, + writeCachedMediaResolution, writeSnapshot, } from './character-dictionary-runtime/cache'; import { @@ -41,6 +44,7 @@ import type { CharacterDictionaryManualSelectionResult, CharacterDictionaryManualSelectionSnapshot, CharacterDictionaryRuntimeDeps, + CharacterDictionarySnapshot, CharacterDictionarySnapshotImage, CharacterDictionarySnapshotProgress, CharacterDictionarySnapshotProgressCallbacks, @@ -204,6 +208,26 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar }; }; + const findCachedSnapshotForSeriesKey = ( + seriesKey: string, + ): CharacterDictionarySnapshot | null => { + return ( + readCachedSnapshots(outputDir).find((snapshot) => { + const snapshotSeriesKey = buildCharacterDictionarySeriesKey({ + mediaPath: null, + mediaTitle: snapshot.mediaTitle, + guess: { + title: snapshot.mediaTitle, + season: null, + episode: null, + source: 'fallback', + }, + }); + return snapshotSeriesKey === seriesKey; + }) ?? null + ); + }; + const resolveCurrentMedia = async ( targetPath?: string, beforeRequest?: () => Promise, @@ -228,7 +252,43 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar staleMediaIds: override.staleMediaIds, }; } + + const cachedResolution = readCachedMediaResolution(outputDir, seriesKey); + if (cachedResolution) { + const cachedSnapshot = readSnapshot(getSnapshotPath(outputDir, cachedResolution.mediaId)); + if (cachedSnapshot) { + deps.logInfo?.( + `[dictionary] cached AniList match: ${cachedSnapshot.mediaTitle} -> AniList ${cachedSnapshot.mediaId}`, + ); + return { + id: cachedSnapshot.mediaId, + title: cachedSnapshot.mediaTitle, + }; + } + } + + const cachedSnapshot = findCachedSnapshotForSeriesKey(seriesKey); + if (cachedSnapshot) { + writeCachedMediaResolution(outputDir, { + seriesKey, + mediaId: cachedSnapshot.mediaId, + mediaTitle: cachedSnapshot.mediaTitle, + }); + deps.logInfo?.( + `[dictionary] cached snapshot match: ${cachedSnapshot.mediaTitle} -> AniList ${cachedSnapshot.mediaId}`, + ); + return { + id: cachedSnapshot.mediaId, + title: cachedSnapshot.mediaTitle, + }; + } + const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest); + writeCachedMediaResolution(outputDir, { + seriesKey, + mediaId: resolved.id, + mediaTitle: resolved.title, + }); deps.logInfo?.(`[dictionary] AniList match: ${resolved.title} -> AniList ${resolved.id}`); return resolved; }; diff --git a/src/main/character-dictionary-runtime/cache.ts b/src/main/character-dictionary-runtime/cache.ts index db5d57a0..0d5c6e68 100644 --- a/src/main/character-dictionary-runtime/cache.ts +++ b/src/main/character-dictionary-runtime/cache.ts @@ -21,6 +21,102 @@ export function getMergedZipPath(outputDir: string): string { return path.join(outputDir, 'merged.zip'); } +type MediaResolutionCacheEntry = { + seriesKey: string; + mediaId: number; + mediaTitle: string; +}; + +type MediaResolutionCacheFile = { + entries?: MediaResolutionCacheEntry[]; +}; + +function getMediaResolutionCachePath(outputDir: string): string { + return path.join(outputDir, 'anilist-resolution-cache.json'); +} + +function normalizeMediaResolutionEntry(value: unknown): MediaResolutionCacheEntry | null { + if (!value || typeof value !== 'object') return null; + const raw = value as Partial; + const seriesKey = typeof raw.seriesKey === 'string' ? raw.seriesKey.trim() : ''; + const mediaTitle = typeof raw.mediaTitle === 'string' ? raw.mediaTitle.trim() : ''; + if (typeof raw.mediaId !== 'number' || !Number.isFinite(raw.mediaId)) return null; + const mediaId = Math.floor(raw.mediaId); + if (!seriesKey || mediaId <= 0 || !mediaTitle) return null; + return { + seriesKey, + mediaId, + mediaTitle, + }; +} + +function readMediaResolutionEntries(outputDir: string): MediaResolutionCacheEntry[] { + try { + const parsed = JSON.parse( + fs.readFileSync(getMediaResolutionCachePath(outputDir), 'utf8'), + ) as MediaResolutionCacheFile; + if (!Array.isArray(parsed.entries)) return []; + const byKey = new Map(); + for (const value of parsed.entries) { + const normalized = normalizeMediaResolutionEntry(value); + if (normalized) byKey.set(normalized.seriesKey, normalized); + } + return [...byKey.values()]; + } catch { + return []; + } +} + +function writeMediaResolutionEntries( + outputDir: string, + entries: MediaResolutionCacheEntry[], +): void { + ensureDir(outputDir); + fs.writeFileSync( + getMediaResolutionCachePath(outputDir), + JSON.stringify({ entries }, null, 2), + 'utf8', + ); +} + +export function readCachedMediaResolution( + outputDir: string, + seriesKey: string, +): MediaResolutionCacheEntry | null { + const normalizedKey = seriesKey.trim(); + if (!normalizedKey) return null; + return ( + readMediaResolutionEntries(outputDir).find((entry) => entry.seriesKey === normalizedKey) ?? null + ); +} + +export function writeCachedMediaResolution( + outputDir: string, + entry: MediaResolutionCacheEntry, +): void { + const normalized = normalizeMediaResolutionEntry(entry); + if (!normalized) return; + const remaining = readMediaResolutionEntries(outputDir).filter( + (existing) => existing.seriesKey !== normalized.seriesKey, + ); + writeMediaResolutionEntries(outputDir, [...remaining, normalized]); +} + +export function readCachedSnapshots(outputDir: string): CharacterDictionarySnapshot[] { + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(getSnapshotsDir(outputDir), { withFileTypes: true }); + } catch { + return []; + } + + return entries + .filter((entry) => entry.isFile() && /^anilist-\d+\.json$/.test(entry.name)) + .sort((left, right) => left.name.localeCompare(right.name)) + .map((entry) => readSnapshot(path.join(getSnapshotsDir(outputDir), entry.name))) + .filter((snapshot): snapshot is CharacterDictionarySnapshot => snapshot !== null); +} + export function readSnapshot(snapshotPath: string): CharacterDictionarySnapshot | null { try { const raw = fs.readFileSync(snapshotPath, 'utf8'); diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts index 10420956..209e2530 100644 --- a/src/main/dependencies.ts +++ b/src/main/dependencies.ts @@ -57,6 +57,7 @@ export interface MainIpcRuntimeServiceDepsParams { getVisibleOverlayVisibility: IpcDepsRuntimeOptions['getVisibleOverlayVisibility']; onOverlayModalClosed: IpcDepsRuntimeOptions['onOverlayModalClosed']; onOverlayModalOpened?: IpcDepsRuntimeOptions['onOverlayModalOpened']; + onOverlayMouseInteractionChanged?: IpcDepsRuntimeOptions['onOverlayMouseInteractionChanged']; onYoutubePickerResolve: IpcDepsRuntimeOptions['onYoutubePickerResolve']; openYomitanSettings: IpcDepsRuntimeOptions['openYomitanSettings']; quitApp: IpcDepsRuntimeOptions['quitApp']; @@ -229,6 +230,7 @@ export function createMainIpcRuntimeServiceDeps( getVisibleOverlayVisibility: params.getVisibleOverlayVisibility, onOverlayModalClosed: params.onOverlayModalClosed, onOverlayModalOpened: params.onOverlayModalOpened, + onOverlayMouseInteractionChanged: params.onOverlayMouseInteractionChanged, onYoutubePickerResolve: params.onYoutubePickerResolve, openYomitanSettings: params.openYomitanSettings, quitApp: params.quitApp, diff --git a/src/main/overlay-visibility-runtime.ts b/src/main/overlay-visibility-runtime.ts index fff9b868..b1f101e7 100644 --- a/src/main/overlay-visibility-runtime.ts +++ b/src/main/overlay-visibility-runtime.ts @@ -11,6 +11,7 @@ export interface OverlayVisibilityRuntimeDeps { getModalActive: () => boolean; getVisibleOverlayVisible: () => boolean; getForceMousePassthrough: () => boolean; + getOverlayInteractionActive?: () => boolean; getWindowTracker: () => BaseWindowTracker | null; getLastKnownWindowsForegroundProcessName?: () => string | null; getWindowsOverlayProcessName?: () => string | null; @@ -49,6 +50,7 @@ export function createOverlayVisibilityRuntimeService( visibleOverlayVisible, modalActive: deps.getModalActive(), forceMousePassthrough, + overlayInteractionActive: deps.getOverlayInteractionActive?.() ?? false, mainWindow, windowTracker, lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(), diff --git a/src/main/runtime/anilist-media-guess.test.ts b/src/main/runtime/anilist-media-guess.test.ts index 77e01d84..cb2dd215 100644 --- a/src/main/runtime/anilist-media-guess.test.ts +++ b/src/main/runtime/anilist-media-guess.test.ts @@ -30,6 +30,36 @@ test('maybeProbeAnilistDuration updates state with probed duration', async () => assert.equal(state.mediaDurationSec, 321); }); +test('maybeProbeAnilistDuration force option bypasses retry interval', async () => { + let state: AnilistMediaGuessRuntimeState = { + mediaKey: '/tmp/video.mkv', + mediaDurationSec: null, + mediaGuess: null, + mediaGuessPromise: null, + lastDurationProbeAtMs: 1900, + }; + let requestCount = 0; + const probe = createMaybeProbeAnilistDurationHandler({ + getState: () => state, + setState: (next) => { + state = next; + }, + durationRetryIntervalMs: 1000, + now: () => 2000, + requestMpvDuration: async () => { + requestCount += 1; + return 321; + }, + logWarn: () => {}, + }); + + const duration = await probe('/tmp/video.mkv', { force: true }); + + assert.equal(duration, 321); + assert.equal(requestCount, 1); + assert.equal(state.mediaDurationSec, 321); +}); + test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => { let state: AnilistMediaGuessRuntimeState = { mediaKey: '/tmp/video.mkv', diff --git a/src/main/runtime/anilist-media-guess.ts b/src/main/runtime/anilist-media-guess.ts index aed73d48..d4a419e4 100644 --- a/src/main/runtime/anilist-media-guess.ts +++ b/src/main/runtime/anilist-media-guess.ts @@ -14,6 +14,10 @@ type GuessAnilistMediaInfo = ( mediaTitle: string | null, ) => Promise; +type AnilistDurationProbeOptions = { + force?: boolean; +}; + export function createMaybeProbeAnilistDurationHandler(deps: { getState: () => AnilistMediaGuessRuntimeState; setState: (state: AnilistMediaGuessRuntimeState) => void; @@ -22,7 +26,10 @@ export function createMaybeProbeAnilistDurationHandler(deps: { requestMpvDuration: () => Promise; logWarn: (message: string, error: unknown) => void; }) { - return async (mediaKey: string): Promise => { + return async ( + mediaKey: string, + options: AnilistDurationProbeOptions = {}, + ): Promise => { const state = deps.getState(); if (state.mediaKey !== mediaKey) { return null; @@ -34,7 +41,7 @@ export function createMaybeProbeAnilistDurationHandler(deps: { return state.mediaDurationSec; } const now = deps.now(); - if (now - state.lastDurationProbeAtMs < deps.durationRetryIntervalMs) { + if (!options.force && now - state.lastDurationProbeAtMs < deps.durationRetryIntervalMs) { return null; } diff --git a/src/main/runtime/anilist-media-state-main-deps.test.ts b/src/main/runtime/anilist-media-state-main-deps.test.ts index f5515b7c..eef7d466 100644 --- a/src/main/runtime/anilist-media-state-main-deps.test.ts +++ b/src/main/runtime/anilist-media-state-main-deps.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { createBuildGetAnilistMediaGuessRuntimeStateMainDepsHandler, createBuildGetCurrentAnilistMediaKeyMainDepsHandler, + createBuildRecordAnilistMediaDurationMainDepsHandler, createBuildResetAnilistMediaGuessStateMainDepsHandler, createBuildResetAnilistMediaTrackingMainDepsHandler, createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler, @@ -70,3 +71,32 @@ test('reset anilist media guess state main deps builder maps callbacks', () => { deps.setMediaGuessPromise(null); assert.deepEqual(calls, ['guess', 'promise']); }); + +test('record anilist media duration main deps builder maps callbacks', () => { + const calls: string[] = []; + const state = { + mediaKey: '/tmp/video.mkv', + mediaDurationSec: null, + mediaGuess: null, + mediaGuessPromise: null, + lastDurationProbeAtMs: 0, + }; + const deps = createBuildRecordAnilistMediaDurationMainDepsHandler({ + getCurrentMediaKey: () => { + calls.push('key'); + return '/tmp/video.mkv'; + }, + getState: () => { + calls.push('get'); + return state; + }, + setState: () => { + calls.push('set'); + }, + })(); + + assert.equal(deps.getCurrentMediaKey(), '/tmp/video.mkv'); + deps.getState(); + deps.setState(state); + assert.deepEqual(calls, ['key', 'get', 'set']); +}); diff --git a/src/main/runtime/anilist-media-state-main-deps.ts b/src/main/runtime/anilist-media-state-main-deps.ts index c0139b59..01194458 100644 --- a/src/main/runtime/anilist-media-state-main-deps.ts +++ b/src/main/runtime/anilist-media-state-main-deps.ts @@ -1,6 +1,7 @@ import type { createGetAnilistMediaGuessRuntimeStateHandler, createGetCurrentAnilistMediaKeyHandler, + createRecordAnilistMediaDurationHandler, createResetAnilistMediaGuessStateHandler, createResetAnilistMediaTrackingHandler, createSetAnilistMediaGuessRuntimeStateHandler, @@ -18,6 +19,9 @@ type GetAnilistMediaGuessRuntimeStateMainDeps = Parameters< type SetAnilistMediaGuessRuntimeStateMainDeps = Parameters< typeof createSetAnilistMediaGuessRuntimeStateHandler >[0]; +type RecordAnilistMediaDurationMainDeps = Parameters< + typeof createRecordAnilistMediaDurationHandler +>[0]; type ResetAnilistMediaGuessStateMainDeps = Parameters< typeof createResetAnilistMediaGuessStateHandler >[0]; @@ -66,6 +70,16 @@ export function createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler( }); } +export function createBuildRecordAnilistMediaDurationMainDepsHandler( + deps: RecordAnilistMediaDurationMainDeps, +) { + return (): RecordAnilistMediaDurationMainDeps => ({ + getCurrentMediaKey: () => deps.getCurrentMediaKey(), + getState: () => deps.getState(), + setState: (state) => deps.setState(state), + }); +} + export function createBuildResetAnilistMediaGuessStateMainDepsHandler( deps: ResetAnilistMediaGuessStateMainDeps, ) { diff --git a/src/main/runtime/anilist-media-state.test.ts b/src/main/runtime/anilist-media-state.test.ts index 26b58aa9..ebfbcb64 100644 --- a/src/main/runtime/anilist-media-state.test.ts +++ b/src/main/runtime/anilist-media-state.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { createGetAnilistMediaGuessRuntimeStateHandler, createGetCurrentAnilistMediaKeyHandler, + createRecordAnilistMediaDurationHandler, createResetAnilistMediaGuessStateHandler, createResetAnilistMediaTrackingHandler, createSetAnilistMediaGuessRuntimeStateHandler, @@ -176,3 +177,57 @@ test('reset anilist media guess state clears guess and in-flight promise', () => assert.equal(state.mediaDurationSec, 240); assert.equal(state.lastDurationProbeAtMs, 321); }); + +test('record anilist media duration stores observed mpv duration for current media', () => { + const existingPromise = Promise.resolve(null); + let state = { + mediaKey: '/tmp/video.mkv' as string | null, + mediaDurationSec: null as number | null, + mediaGuess: { title: 'guess' } as { title: string } | null, + mediaGuessPromise: existingPromise as Promise | null, + lastDurationProbeAtMs: 321, + }; + + const recordDuration = createRecordAnilistMediaDurationHandler({ + getCurrentMediaKey: () => '/tmp/video.mkv', + getState: () => state as never, + setState: (nextState) => { + state = nextState as never; + }, + }); + + recordDuration(1440); + + assert.equal(state.mediaDurationSec, 1440); + assert.deepEqual(state.mediaGuess, { title: 'guess' }); + assert.equal(state.mediaGuessPromise, existingPromise); + assert.equal(state.lastDurationProbeAtMs, 321); +}); + +test('record anilist media duration resets stale media state when media key changes', () => { + let state = { + mediaKey: '/tmp/old.mkv' as string | null, + mediaDurationSec: 120 as number | null, + mediaGuess: { title: 'old' } as { title: string } | null, + mediaGuessPromise: Promise.resolve(null) as Promise | null, + lastDurationProbeAtMs: 321, + }; + + const recordDuration = createRecordAnilistMediaDurationHandler({ + getCurrentMediaKey: () => '/tmp/new.mkv', + getState: () => state as never, + setState: (nextState) => { + state = nextState as never; + }, + }); + + recordDuration(1440); + + assert.deepEqual(state, { + mediaKey: '/tmp/new.mkv', + mediaDurationSec: 1440, + mediaGuess: null, + mediaGuessPromise: null, + lastDurationProbeAtMs: 0, + }); +}); diff --git a/src/main/runtime/anilist-media-state.ts b/src/main/runtime/anilist-media-state.ts index 1660b67e..8903318d 100644 --- a/src/main/runtime/anilist-media-state.ts +++ b/src/main/runtime/anilist-media-state.ts @@ -61,6 +61,37 @@ export function createSetAnilistMediaGuessRuntimeStateHandler(deps: { }; } +export function createRecordAnilistMediaDurationHandler(deps: { + getCurrentMediaKey: () => string | null; + getState: () => AnilistMediaGuessRuntimeState; + setState: (state: AnilistMediaGuessRuntimeState) => void; +}) { + return (durationSec: number): void => { + if (!Number.isFinite(durationSec) || durationSec <= 0) { + return; + } + const mediaKey = deps.getCurrentMediaKey(); + if (!mediaKey) { + return; + } + const state = deps.getState(); + if (state.mediaKey === mediaKey) { + deps.setState({ + ...state, + mediaDurationSec: durationSec, + }); + return; + } + deps.setState({ + mediaKey, + mediaDurationSec: durationSec, + mediaGuess: null, + mediaGuessPromise: null, + lastDurationProbeAtMs: 0, + }); + }; +} + export function createResetAnilistMediaGuessStateHandler(deps: { setMediaGuess: (value: AnilistMediaGuessRuntimeState['mediaGuess']) => void; setMediaGuessPromise: (value: AnilistMediaGuessRuntimeState['mediaGuessPromise']) => void; diff --git a/src/main/runtime/anilist-post-watch-main-deps.test.ts b/src/main/runtime/anilist-post-watch-main-deps.test.ts index fc3c7fe9..86b0604f 100644 --- a/src/main/runtime/anilist-post-watch-main-deps.test.ts +++ b/src/main/runtime/anilist-post-watch-main-deps.test.ts @@ -13,7 +13,10 @@ test('process next anilist retry update main deps builder maps callbacks', async setLastAttemptAt: () => calls.push('attempt'), setLastError: () => calls.push('error'), refreshAnilistClientSecretState: async () => 'token', - updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }), + updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({ + status: 'updated', + message: `ok:${season}`, + }), markSuccess: () => calls.push('success'), rememberAttemptedUpdateKey: () => calls.push('remember'), markFailure: () => calls.push('failure'), @@ -26,9 +29,9 @@ test('process next anilist retry update main deps builder maps callbacks', async deps.setLastAttemptAt(1); deps.setLastError('x'); assert.equal(await deps.refreshAnilistClientSecretState(), 'token'); - assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1), { + assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1, 2), { status: 'updated', - message: 'ok', + message: 'ok:2', }); deps.markSuccess('k'); deps.rememberAttemptedUpdateKey('k'); @@ -58,16 +61,22 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy getTrackedMediaKey: () => 'media', resetTrackedMedia: () => calls.push('reset'), getWatchedSeconds: () => 100, - maybeProbeAnilistDuration: async () => 120, + maybeProbeAnilistDuration: async (_mediaKey, options) => { + calls.push(`probe:${options?.force === true}`); + return 120; + }, ensureAnilistMediaGuess: async () => ({ title: 'x', season: null, episode: 1 }), hasAttemptedUpdateKey: () => false, processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'ok' }), refreshAnilistClientSecretState: async () => 'token', - enqueueRetry: () => calls.push('enqueue'), + enqueueRetry: (_key, _title, _episode, season) => calls.push(`enqueue:${season}`), markRetryFailure: () => calls.push('retry-fail'), markRetrySuccess: () => calls.push('retry-ok'), refreshRetryQueueState: () => calls.push('refresh'), - updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'done' }), + updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({ + status: 'updated', + message: `done:${season}`, + }), rememberAttemptedUpdateKey: () => calls.push('remember'), showMpvOsd: () => calls.push('osd'), logInfo: (message) => calls.push(`info:${message}`), @@ -84,7 +93,7 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy assert.equal(deps.getTrackedMediaKey(), 'media'); deps.resetTrackedMedia('media'); assert.equal(deps.getWatchedSeconds(), 100); - assert.equal(await deps.maybeProbeAnilistDuration('media'), 120); + assert.equal(await deps.maybeProbeAnilistDuration('media', { force: true }), 120); assert.deepEqual(await deps.ensureAnilistMediaGuess('media'), { title: 'x', season: null, @@ -93,13 +102,13 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy assert.equal(deps.hasAttemptedUpdateKey('k'), false); assert.deepEqual(await deps.processNextAnilistRetryUpdate(), { ok: true, message: 'ok' }); assert.equal(await deps.refreshAnilistClientSecretState(), 'token'); - deps.enqueueRetry('k', 't', 1); + deps.enqueueRetry('k', 't', 1, 2); deps.markRetryFailure('k', 'bad'); deps.markRetrySuccess('k'); deps.refreshRetryQueueState(); - assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1), { + assert.deepEqual(await deps.updateAnilistPostWatchProgress('token', 't', 1, 2), { status: 'updated', - message: 'done', + message: 'done:2', }); deps.rememberAttemptedUpdateKey('k'); deps.showMpvOsd('ok'); @@ -110,7 +119,8 @@ test('maybe run anilist post watch update main deps builder maps callbacks', asy assert.deepEqual(calls, [ 'in-flight', 'reset', - 'enqueue', + 'probe:true', + 'enqueue:2', 'retry-fail', 'retry-ok', 'refresh', diff --git a/src/main/runtime/anilist-post-watch-main-deps.ts b/src/main/runtime/anilist-post-watch-main-deps.ts index 7a011bda..c0edcdd6 100644 --- a/src/main/runtime/anilist-post-watch-main-deps.ts +++ b/src/main/runtime/anilist-post-watch-main-deps.ts @@ -19,8 +19,12 @@ export function createBuildProcessNextAnilistRetryUpdateMainDepsHandler( setLastAttemptAt: (value: number) => deps.setLastAttemptAt(value), setLastError: (value: string | null) => deps.setLastError(value), refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(), - updateAnilistPostWatchProgress: (accessToken: string, title: string, episode: number) => - deps.updateAnilistPostWatchProgress(accessToken, title, episode), + updateAnilistPostWatchProgress: ( + accessToken: string, + title: string, + episode: number, + season?: number | null, + ) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season), markSuccess: (key: string) => deps.markSuccess(key), rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key), markFailure: (key: string, message: string) => deps.markFailure(key, message), @@ -42,18 +46,23 @@ export function createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler( getTrackedMediaKey: () => deps.getTrackedMediaKey(), resetTrackedMedia: (mediaKey) => deps.resetTrackedMedia(mediaKey), getWatchedSeconds: () => deps.getWatchedSeconds(), - maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey), + maybeProbeAnilistDuration: (mediaKey: string, options) => + deps.maybeProbeAnilistDuration(mediaKey, options), ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey), hasAttemptedUpdateKey: (key: string) => deps.hasAttemptedUpdateKey(key), processNextAnilistRetryUpdate: () => deps.processNextAnilistRetryUpdate(), refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(), - enqueueRetry: (key: string, title: string, episode: number) => - deps.enqueueRetry(key, title, episode), + enqueueRetry: (key: string, title: string, episode: number, season?: number | null) => + deps.enqueueRetry(key, title, episode, season), markRetryFailure: (key: string, message: string) => deps.markRetryFailure(key, message), markRetrySuccess: (key: string) => deps.markRetrySuccess(key), refreshRetryQueueState: () => deps.refreshRetryQueueState(), - updateAnilistPostWatchProgress: (accessToken: string, title: string, episode: number) => - deps.updateAnilistPostWatchProgress(accessToken, title, episode), + updateAnilistPostWatchProgress: ( + accessToken: string, + title: string, + episode: number, + season?: number | null, + ) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season), rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key), showMpvOsd: (message: string) => deps.showMpvOsd(message), logInfo: (message: string) => deps.logInfo(message), diff --git a/src/main/runtime/anilist-post-watch.test.ts b/src/main/runtime/anilist-post-watch.test.ts index 83923a11..47bda6bc 100644 --- a/src/main/runtime/anilist-post-watch.test.ts +++ b/src/main/runtime/anilist-post-watch.test.ts @@ -20,12 +20,15 @@ test('rememberAnilistAttemptedUpdateKey evicts oldest beyond max size', () => { test('createProcessNextAnilistRetryUpdateHandler handles successful retry', async () => { const calls: string[] = []; const handler = createProcessNextAnilistRetryUpdateHandler({ - nextReady: () => ({ key: 'k1', title: 'Show', season: null, episode: 1 }), + nextReady: () => ({ key: 'k1', title: 'Show', season: 2, episode: 1 }), refreshRetryQueueState: () => calls.push('refresh'), setLastAttemptAt: () => calls.push('attempt'), setLastError: (value) => calls.push(`error:${value ?? 'null'}`), refreshAnilistClientSecretState: async () => 'token', - updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'updated ok' }), + updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, season) => ({ + status: 'updated', + message: `updated ok:${season}`, + }), markSuccess: () => calls.push('success'), rememberAttemptedUpdateKey: () => calls.push('remember'), markFailure: () => calls.push('failure'), @@ -34,7 +37,7 @@ test('createProcessNextAnilistRetryUpdateHandler handles successful retry', asyn }); const result = await handler(); - assert.deepEqual(result, { ok: true, message: 'updated ok' }); + assert.deepEqual(result, { ok: true, message: 'updated ok:2' }); assert.ok(calls.includes('success')); assert.ok(calls.includes('remember')); }); @@ -93,7 +96,7 @@ test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched upda calls.push('probe'); return 1000; }, - ensureAnilistMediaGuess: async () => ({ title: 'Show', episode: 3 }), + ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 3 }), hasAttemptedUpdateKey: () => false, processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }), refreshAnilistClientSecretState: async () => 'token', @@ -121,6 +124,106 @@ test('createMaybeRunAnilistPostWatchUpdateHandler force-runs manual watched upda assert.ok(calls.includes('osd:updated ok')); }); +test('createMaybeRunAnilistPostWatchUpdateHandler shows permanent AniList update errors without queueing retry', async () => { + const calls: string[] = []; + const attemptedKeys = new Set(); + let updateCalls = 0; + const handler = createMaybeRunAnilistPostWatchUpdateHandler({ + getInFlight: () => false, + setInFlight: (value) => calls.push(`inflight:${value}`), + getResolvedConfig: () => ({}), + isAnilistTrackingEnabled: () => true, + getCurrentMediaKey: () => '/tmp/video.mkv', + hasMpvClient: () => true, + getTrackedMediaKey: () => '/tmp/video.mkv', + resetTrackedMedia: () => {}, + getWatchedSeconds: () => 1000, + maybeProbeAnilistDuration: async () => 1000, + ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 2 }), + hasAttemptedUpdateKey: (key) => attemptedKeys.has(key), + 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 () => { + updateCalls += 1; + return { + status: 'error', + retryable: false, + message: + 'AniList update not possible: Show is not in your AniList Planning or Watching list.', + }; + }, + rememberAttemptedUpdateKey: (key) => { + attemptedKeys.add(key); + calls.push(`remember:${key}`); + }, + 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(); + await handler(); + + assert.equal(updateCalls, 1); + assert.equal(calls.includes('enqueue'), false); + assert.equal(calls.includes('mark-failure'), false); + assert.ok(calls.some((call) => call.startsWith('remember:'))); + assert.ok(calls.includes('refresh')); + assert.ok(calls.some((call) => call.startsWith('osd:AniList update not possible'))); + assert.ok(calls.some((call) => call.startsWith('warn:AniList update not possible'))); +}); + +test('createMaybeRunAnilistPostWatchUpdateHandler uses provided watched seconds from time-position events', async () => { + const calls: string[] = []; + let durationProbeOptions: unknown = null; + const handler = createMaybeRunAnilistPostWatchUpdateHandler({ + getInFlight: () => false, + setInFlight: (value) => calls.push(`inflight:${value}`), + getResolvedConfig: () => ({}), + isAnilistTrackingEnabled: () => true, + getCurrentMediaKey: () => '/tmp/video.mkv', + hasMpvClient: () => true, + getTrackedMediaKey: () => '/tmp/video.mkv', + resetTrackedMedia: () => {}, + getWatchedSeconds: () => 0, + maybeProbeAnilistDuration: async (_mediaKey, options) => { + durationProbeOptions = options; + return 1000; + }, + ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 2, episode: 8 }), + 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 (_accessToken, _title, _episode, season) => { + calls.push(`update:${season}`); + 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({ watchedSeconds: 850 }); + + assert.deepEqual(durationProbeOptions, { force: true }); + assert.ok(calls.includes('update:2')); + 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; diff --git a/src/main/runtime/anilist-post-watch.ts b/src/main/runtime/anilist-post-watch.ts index 47a9a3d4..be3b0f7d 100644 --- a/src/main/runtime/anilist-post-watch.ts +++ b/src/main/runtime/anilist-post-watch.ts @@ -2,22 +2,30 @@ import { isYoutubeMediaPath } from './youtube-playback'; type AnilistGuess = { title: string; + season: number | null; episode: number | null; }; type AnilistUpdateResult = { status: 'updated' | 'skipped' | 'error'; message: string; + retryable?: boolean; }; type RetryQueueItem = { key: string; title: string; + season?: number | null; episode: number; }; type AnilistPostWatchRunOptions = { force?: boolean; + watchedSeconds?: number; +}; + +type AnilistDurationProbeOptions = { + force?: boolean; }; export function buildAnilistAttemptKey(mediaKey: string, episode: number): string { @@ -49,6 +57,7 @@ export function createProcessNextAnilistRetryUpdateHandler(deps: { accessToken: string, title: string, episode: number, + season?: number | null, ) => Promise; markSuccess: (key: string) => void; rememberAttemptedUpdateKey: (key: string) => void; @@ -74,6 +83,7 @@ export function createProcessNextAnilistRetryUpdateHandler(deps: { accessToken, queued.title, queued.episode, + queued.season ?? null, ); if (result.status === 'updated' || result.status === 'skipped') { deps.markSuccess(queued.key); @@ -101,12 +111,15 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: { getTrackedMediaKey: () => string | null; resetTrackedMedia: (mediaKey: string | null) => void; getWatchedSeconds: () => number; - maybeProbeAnilistDuration: (mediaKey: string) => Promise; + maybeProbeAnilistDuration: ( + mediaKey: string, + options?: AnilistDurationProbeOptions, + ) => Promise; ensureAnilistMediaGuess: (mediaKey: string) => Promise; hasAttemptedUpdateKey: (key: string) => boolean; processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>; refreshAnilistClientSecretState: () => Promise; - enqueueRetry: (key: string, title: string, episode: number) => void; + enqueueRetry: (key: string, title: string, episode: number, season?: number | null) => void; markRetryFailure: (key: string, message: string) => void; markRetrySuccess: (key: string) => void; refreshRetryQueueState: () => void; @@ -114,6 +127,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: { accessToken: string, title: string, episode: number, + season?: number | null, ) => Promise; rememberAttemptedUpdateKey: (key: string) => void; showMpvOsd: (message: string) => void; @@ -146,7 +160,10 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: { let watchedSeconds = 0; if (!force) { - watchedSeconds = deps.getWatchedSeconds(); + watchedSeconds = + typeof options.watchedSeconds === 'number' && Number.isFinite(options.watchedSeconds) + ? options.watchedSeconds + : deps.getWatchedSeconds(); if (!Number.isFinite(watchedSeconds) || watchedSeconds < deps.minWatchSeconds) { return; } @@ -155,7 +172,10 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: { deps.setInFlight(true); try { if (!force) { - const duration = await deps.maybeProbeAnilistDuration(mediaKey); + const duration = await deps.maybeProbeAnilistDuration(mediaKey, { + force: + typeof options.watchedSeconds === 'number' && Number.isFinite(options.watchedSeconds), + }); if (!duration || duration <= 0) { return; } @@ -181,7 +201,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: { const accessToken = await deps.refreshAnilistClientSecretState(); if (!accessToken) { - deps.enqueueRetry(attemptKey, guess.title, guess.episode); + deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season); deps.markRetryFailure(attemptKey, 'cannot authenticate without anilist.accessToken'); deps.refreshRetryQueueState(); deps.showMpvOsd('AniList: access token not configured'); @@ -192,6 +212,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: { accessToken, guess.title, guess.episode, + guess.season, ); if (result.status === 'updated') { deps.rememberAttemptedUpdateKey(attemptKey); @@ -209,7 +230,15 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: { return; } - deps.enqueueRetry(attemptKey, guess.title, guess.episode); + if (result.retryable === false) { + deps.rememberAttemptedUpdateKey(attemptKey); + deps.refreshRetryQueueState(); + deps.showMpvOsd(result.message); + deps.logWarn(result.message); + return; + } + + deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season); deps.markRetryFailure(attemptKey, result.message); deps.refreshRetryQueueState(); deps.showMpvOsd(`AniList: ${result.message}`); diff --git a/src/main/runtime/composers/anilist-tracking-composer.test.ts b/src/main/runtime/composers/anilist-tracking-composer.test.ts index 5b6e4f8f..d78d62c1 100644 --- a/src/main/runtime/composers/anilist-tracking-composer.test.ts +++ b/src/main/runtime/composers/anilist-tracking-composer.test.ts @@ -80,6 +80,23 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call lastDurationProbeAtMsState = value; }, }, + recordMediaDurationMainDeps: { + getCurrentMediaKey: () => 'media-key', + getState: () => ({ + mediaKey: mediaKeyState, + mediaDurationSec: mediaDurationSecState, + mediaGuess: mediaGuessState, + mediaGuessPromise: mediaGuessPromiseState, + lastDurationProbeAtMs: lastDurationProbeAtMsState, + }), + setState: (state) => { + mediaKeyState = state.mediaKey; + mediaDurationSecState = state.mediaDurationSec; + mediaGuessState = state.mediaGuess; + mediaGuessPromiseState = state.mediaGuessPromise; + lastDurationProbeAtMsState = state.lastDurationProbeAtMs; + }, + }, resetMediaGuessStateMainDeps: { setMediaGuess: (value) => { mediaGuessState = value; @@ -192,6 +209,7 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call assert.equal(typeof composed.resetAnilistMediaTracking, 'function'); assert.equal(typeof composed.getAnilistMediaGuessRuntimeState, 'function'); assert.equal(typeof composed.setAnilistMediaGuessRuntimeState, 'function'); + assert.equal(typeof composed.recordAnilistMediaDuration, 'function'); assert.equal(typeof composed.resetAnilistMediaGuessState, 'function'); assert.equal(typeof composed.maybeProbeAnilistDuration, 'function'); assert.equal(typeof composed.ensureAnilistMediaGuess, 'function'); @@ -216,6 +234,9 @@ test('composeAnilistTrackingHandlers returns callable handlers and forwards call }); assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 90); + composed.recordAnilistMediaDuration(180); + assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaDurationSec, 180); + composed.resetAnilistMediaGuessState(); assert.equal(composed.getAnilistMediaGuessRuntimeState().mediaGuess, null); diff --git a/src/main/runtime/composers/anilist-tracking-composer.ts b/src/main/runtime/composers/anilist-tracking-composer.ts index 42822458..74205fda 100644 --- a/src/main/runtime/composers/anilist-tracking-composer.ts +++ b/src/main/runtime/composers/anilist-tracking-composer.ts @@ -5,6 +5,7 @@ import { createBuildMaybeProbeAnilistDurationMainDepsHandler, createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler, createBuildProcessNextAnilistRetryUpdateMainDepsHandler, + createBuildRecordAnilistMediaDurationMainDepsHandler, createBuildRefreshAnilistClientSecretStateMainDepsHandler, createBuildResetAnilistMediaGuessStateMainDepsHandler, createBuildResetAnilistMediaTrackingMainDepsHandler, @@ -15,6 +16,7 @@ import { createMaybeProbeAnilistDurationHandler, createMaybeRunAnilistPostWatchUpdateHandler, createProcessNextAnilistRetryUpdateHandler, + createRecordAnilistMediaDurationHandler, createRefreshAnilistClientSecretStateHandler, createResetAnilistMediaGuessStateHandler, createResetAnilistMediaTrackingHandler, @@ -38,6 +40,9 @@ export type AnilistTrackingComposerOptions = ComposerInputs<{ setMediaGuessRuntimeStateMainDeps: Parameters< typeof createBuildSetAnilistMediaGuessRuntimeStateMainDepsHandler >[0]; + recordMediaDurationMainDeps: Parameters< + typeof createBuildRecordAnilistMediaDurationMainDepsHandler + >[0]; resetMediaGuessStateMainDeps: Parameters< typeof createBuildResetAnilistMediaGuessStateMainDepsHandler >[0]; @@ -63,6 +68,7 @@ export type AnilistTrackingComposerResult = ComposerOutputs<{ setAnilistMediaGuessRuntimeState: ReturnType< typeof createSetAnilistMediaGuessRuntimeStateHandler >; + recordAnilistMediaDuration: ReturnType; resetAnilistMediaGuessState: ReturnType; maybeProbeAnilistDuration: ReturnType; ensureAnilistMediaGuess: ReturnType; @@ -94,6 +100,9 @@ export function composeAnilistTrackingHandlers( options.setMediaGuessRuntimeStateMainDeps, )(), ); + const recordAnilistMediaDuration = createRecordAnilistMediaDurationHandler( + createBuildRecordAnilistMediaDurationMainDepsHandler(options.recordMediaDurationMainDeps)(), + ); const resetAnilistMediaGuessState = createResetAnilistMediaGuessStateHandler( createBuildResetAnilistMediaGuessStateMainDepsHandler(options.resetMediaGuessStateMainDeps)(), ); @@ -120,6 +129,7 @@ export function composeAnilistTrackingHandlers( resetAnilistMediaTracking, getAnilistMediaGuessRuntimeState, setAnilistMediaGuessRuntimeState, + recordAnilistMediaDuration, resetAnilistMediaGuessState, maybeProbeAnilistDuration, ensureAnilistMediaGuess, diff --git a/src/main/runtime/mpv-client-event-bindings.test.ts b/src/main/runtime/mpv-client-event-bindings.test.ts index 0a32eb74..035a8664 100644 --- a/src/main/runtime/mpv-client-event-bindings.test.ts +++ b/src/main/runtime/mpv-client-event-bindings.test.ts @@ -97,20 +97,38 @@ test('mpv connection handler keeps overlay-initialized non-youtube sessions aliv assert.deepEqual(calls, ['presence-refresh', 'report-stop']); }); -test('mpv subtitle timing handler ignores blank subtitle lines', () => { +test('mpv subtitle timing handler skips blank subtitle recording but still checks AniList time', () => { const calls: string[] = []; const handler = createHandleMpvSubtitleTimingHandler({ recordImmersionSubtitleLine: () => calls.push('immersion'), hasSubtitleTimingTracker: () => true, recordSubtitleTiming: () => calls.push('timing'), - maybeRunAnilistPostWatchUpdate: async () => { - calls.push('post-watch'); + maybeRunAnilistPostWatchUpdate: async (options) => { + calls.push(`post-watch:${options?.watchedSeconds}`); }, logError: () => calls.push('error'), }); handler({ text: ' ', start: 1, end: 2 }); - assert.deepEqual(calls, []); + assert.deepEqual(calls, ['post-watch:2']); +}); + +test('mpv subtitle timing handler runs AniList without timing tracker and passes subtitle time', () => { + const calls: string[] = []; + const handler = createHandleMpvSubtitleTimingHandler({ + recordImmersionSubtitleLine: (text, start, end) => + calls.push(`immersion:${text}:${start}:${end}`), + hasSubtitleTimingTracker: () => false, + recordSubtitleTiming: () => calls.push('timing'), + maybeRunAnilistPostWatchUpdate: async (options) => { + calls.push(`post-watch:${options?.watchedSeconds}`); + }, + logError: () => calls.push('error'), + }); + + handler({ text: 'line', start: 899, end: 901 }); + + assert.deepEqual(calls, ['immersion:line:899:901', 'post-watch:901']); }); test('mpv event bindings register all expected events', () => { diff --git a/src/main/runtime/mpv-client-event-bindings.ts b/src/main/runtime/mpv-client-event-bindings.ts index 0036cbfd..22d7666d 100644 --- a/src/main/runtime/mpv-client-event-bindings.ts +++ b/src/main/runtime/mpv-client-event-bindings.ts @@ -19,6 +19,10 @@ type MpvEventClient = { on: (event: K, handler: (payload: any) => void) => void; }; +type AnilistPostWatchRunOptions = { + watchedSeconds?: number; +}; + export function createHandleMpvConnectionChangeHandler(deps: { reportJellyfinRemoteStopped: () => void; refreshDiscordPresence: () => void; @@ -57,15 +61,22 @@ export function createHandleMpvSubtitleTimingHandler(deps: { recordImmersionSubtitleLine: (text: string, start: number, end: number) => void; hasSubtitleTimingTracker: () => boolean; recordSubtitleTiming: (text: string, start: number, end: number) => void; - maybeRunAnilistPostWatchUpdate: () => Promise; + maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise; logError: (message: string, error: unknown) => void; }) { return ({ text, start, end }: { text: string; start: number; end: number }): void => { - if (!text.trim()) return; - deps.recordImmersionSubtitleLine(text, start, end); - if (!deps.hasSubtitleTimingTracker()) return; - deps.recordSubtitleTiming(text, start, end); - void deps.maybeRunAnilistPostWatchUpdate().catch((error) => { + const watchedSeconds = Math.max( + Number.isFinite(start) ? start : 0, + Number.isFinite(end) ? end : 0, + ); + const options = watchedSeconds > 0 ? { watchedSeconds } : undefined; + if (text.trim()) { + deps.recordImmersionSubtitleLine(text, start, end); + if (deps.hasSubtitleTimingTracker()) { + deps.recordSubtitleTiming(text, start, end); + } + } + void deps.maybeRunAnilistPostWatchUpdate(options).catch((error) => { deps.logError('AniList post-watch update failed unexpectedly', error); }); }; diff --git a/src/main/runtime/mpv-main-event-actions.test.ts b/src/main/runtime/mpv-main-event-actions.test.ts index dc862687..604301c0 100644 --- a/src/main/runtime/mpv-main-event-actions.test.ts +++ b/src/main/runtime/mpv-main-event-actions.test.ts @@ -223,6 +223,23 @@ test('time-pos and pause handlers report progress with correct urgency', () => { ]); }); +test('time-pos handler passes fresh playback time to AniList post-watch', async () => { + const watchedSeconds: unknown[] = []; + const timeHandler = createHandleMpvTimePosChangeHandler({ + recordPlaybackPosition: () => {}, + reportJellyfinRemoteProgress: () => {}, + refreshDiscordPresence: () => {}, + maybeRunAnilistPostWatchUpdate: async (options) => { + watchedSeconds.push(options?.watchedSeconds); + }, + }); + + timeHandler({ time: 850 }); + await Promise.resolve(); + + assert.deepEqual(watchedSeconds, [850]); +}); + test('time-pos handler logs post-watch update rejection without blocking later handlers', async () => { const calls: string[] = []; const timeHandler = createHandleMpvTimePosChangeHandler({ diff --git a/src/main/runtime/mpv-main-event-actions.ts b/src/main/runtime/mpv-main-event-actions.ts index b2a5952a..d255cf12 100644 --- a/src/main/runtime/mpv-main-event-actions.ts +++ b/src/main/runtime/mpv-main-event-actions.ts @@ -1,5 +1,9 @@ import type { SubtitleData } from '../../types'; +type AnilistPostWatchRunOptions = { + watchedSeconds?: number; +}; + export function createHandleMpvSubtitleChangeHandler(deps: { setCurrentSubText: (text: string) => void; getImmediateSubtitlePayload?: (text: string) => SubtitleData | null; @@ -105,7 +109,7 @@ export function createHandleMpvTimePosChangeHandler(deps: { recordPlaybackPosition: (time: number) => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; refreshDiscordPresence: () => void; - maybeRunAnilistPostWatchUpdate?: () => Promise; + maybeRunAnilistPostWatchUpdate?: (options?: AnilistPostWatchRunOptions) => Promise; logError?: (message: string, error: unknown) => void; onTimePosUpdate?: (time: number) => void; }) { @@ -113,7 +117,7 @@ export function createHandleMpvTimePosChangeHandler(deps: { deps.recordPlaybackPosition(time); deps.reportJellyfinRemoteProgress(false); deps.refreshDiscordPresence(); - void deps.maybeRunAnilistPostWatchUpdate?.().catch((error) => { + void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => { deps.logError?.('AniList post-watch update failed unexpectedly', error); }); deps.onTimePosUpdate?.(time); diff --git a/src/main/runtime/mpv-main-event-bindings.test.ts b/src/main/runtime/mpv-main-event-bindings.test.ts index ef06556a..ca44f9b6 100644 --- a/src/main/runtime/mpv-main-event-bindings.test.ts +++ b/src/main/runtime/mpv-main-event-bindings.test.ts @@ -23,8 +23,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { recordImmersionSubtitleLine: (text) => calls.push(`immersion:${text}`), hasSubtitleTimingTracker: () => false, recordSubtitleTiming: () => calls.push('record-timing'), - maybeRunAnilistPostWatchUpdate: async () => { - calls.push('post-watch'); + maybeRunAnilistPostWatchUpdate: async (options) => { + calls.push(`post-watch:${options?.watchedSeconds ?? 'none'}`); }, logSubtitleTimingError: () => calls.push('subtitle-error'), setCurrentSubText: (text) => calls.push(`set-sub:${text}`), @@ -74,6 +74,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { handlers.get('subtitle-track-list-change')?.({ trackList: [] }); handlers.get('media-path-change')?.({ path: '' }); handlers.get('media-title-change')?.({ title: 'Episode 1' }); + handlers.get('subtitle-timing')?.({ text: 'timed line', start: 899, end: 901 }); handlers.get('time-pos-change')?.({ time: 2.5 }); handlers.get('pause-change')?.({ paused: true }); @@ -87,6 +88,7 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { assert.ok(calls.includes('restore-mpv-sub')); assert.ok(calls.includes('reset-guess-state')); assert.ok(calls.includes('notify-title:Episode 1')); + assert.ok(calls.includes('post-watch:901')); assert.ok(calls.includes('progress:normal')); assert.ok(calls.includes('progress:force')); assert.ok(calls.includes('presence-refresh')); diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts index 4641b983..b7518b61 100644 --- a/src/main/runtime/mpv-main-event-bindings.ts +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -18,6 +18,10 @@ import { type MpvEventClient = Parameters>[0]; +type AnilistPostWatchRunOptions = { + watchedSeconds?: number; +}; + export function createBindMpvMainEventHandlersHandler(deps: { reportJellyfinRemoteStopped: () => void; syncOverlayMpvSubtitleSuppression: () => void; @@ -34,7 +38,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { recordImmersionSubtitleLine: (text: string, start: number, end: number) => void; hasSubtitleTimingTracker: () => boolean; recordSubtitleTiming: (text: string, start: number, end: number) => void; - maybeRunAnilistPostWatchUpdate: () => Promise; + maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise; logSubtitleTimingError: (message: string, error: unknown) => void; setCurrentSubText: (text: string) => void; @@ -103,7 +107,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { deps.recordImmersionSubtitleLine(text, start, end), hasSubtitleTimingTracker: () => deps.hasSubtitleTimingTracker(), recordSubtitleTiming: (text, start, end) => deps.recordSubtitleTiming(text, start, end), - maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(), + maybeRunAnilistPostWatchUpdate: (options) => deps.maybeRunAnilistPostWatchUpdate(options), logError: (message, error) => deps.logSubtitleTimingError(message, error), }); const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({ @@ -149,7 +153,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { reportJellyfinRemoteProgress: (forceImmediate) => deps.reportJellyfinRemoteProgress(forceImmediate), refreshDiscordPresence: () => deps.refreshDiscordPresence(), - maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(), + maybeRunAnilistPostWatchUpdate: (options) => deps.maybeRunAnilistPostWatchUpdate(options), logError: (message, error) => deps.logSubtitleTimingError(message, error), onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time), }); diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index 5a97c8e8..10569e21 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -16,6 +16,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`), handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`), recordPlaybackPosition: (time: number) => calls.push(`immersion-time:${time}`), + recordMediaDuration: (durationSec: number) => calls.push(`immersion-duration:${durationSec}`), recordPauseState: (paused: boolean) => calls.push(`immersion-pause:${paused}`), }, subtitleTimingTracker: { @@ -40,6 +41,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as maybeRunAnilistPostWatchUpdate: async () => { calls.push('anilist-post-watch'); }, + recordAnilistMediaDuration: (durationSec) => calls.push(`anilist-duration:${durationSec}`), logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`), broadcastToOverlayWindows: (channel, payload) => calls.push(`broadcast:${channel}:${String(payload)}`), @@ -95,6 +97,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as deps.resetAnilistMediaGuessState(); deps.notifyImmersionTitleUpdate('title'); deps.recordPlaybackPosition(10); + deps.recordMediaDuration(1234); deps.reportJellyfinRemoteProgress(true); deps.onFullscreenChange?.(true); deps.recordPauseState(true); @@ -118,6 +121,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as assert.ok(calls.includes('presence-refresh')); assert.ok(calls.includes('restore-mpv-sub')); assert.ok(calls.includes('reset-sidebar-layout')); + assert.ok(calls.includes('immersion-duration:1234')); + assert.ok(calls.includes('anilist-duration:1234')); }); test('mpv main event main deps wire subtitle callbacks without suppression gate', () => { diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index a703ba13..9874bf11 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -1,5 +1,9 @@ import type { MergedToken, SubtitleData } from '../../types'; +type AnilistPostWatchRunOptions = { + watchedSeconds?: number; +}; + export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { appState: { initialArgs?: { @@ -42,7 +46,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { quitApp: () => void; reportJellyfinRemoteStopped: () => void; syncOverlayMpvSubtitleSuppression: () => void; - maybeRunAnilistPostWatchUpdate: () => Promise; + maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise; + recordAnilistMediaDuration?: (durationSec: number) => void; logSubtitleTimingError: (message: string, error: unknown) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void; getImmediateSubtitlePayload?: (text: string) => SubtitleData | null; @@ -126,7 +131,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker), recordSubtitleTiming: (text: string, start: number, end: number) => deps.appState.subtitleTimingTracker?.recordSubtitle?.(text, start, end), - maybeRunAnilistPostWatchUpdate: () => deps.maybeRunAnilistPostWatchUpdate(), + maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => + deps.maybeRunAnilistPostWatchUpdate(options), logSubtitleTimingError: (message: string, error: unknown) => deps.logSubtitleTimingError(message, error), setCurrentSubText: (text: string) => { @@ -179,6 +185,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { recordMediaDuration: (durationSec: number) => { deps.ensureImmersionTrackerInitialized(); deps.appState.immersionTracker?.recordMediaDuration?.(durationSec); + deps.recordAnilistMediaDuration?.(durationSec); }, reportJellyfinRemoteProgress: (forceImmediate: boolean) => deps.reportJellyfinRemoteProgress(forceImmediate), diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts index eee7c1fd..624d9ca3 100644 --- a/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.test.ts @@ -15,6 +15,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb getModalActive: () => true, getVisibleOverlayVisible: () => true, getForceMousePassthrough: () => true, + getOverlayInteractionActive: () => true, getWindowTracker: () => tracker, getLastKnownWindowsForegroundProcessName: () => 'mpv', getWindowsOverlayProcessName: () => 'subminer', @@ -40,6 +41,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb assert.equal(deps.getModalActive(), true); assert.equal(deps.getVisibleOverlayVisible(), true); assert.equal(deps.getForceMousePassthrough(), true); + assert.equal(deps.getOverlayInteractionActive?.(), true); assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv'); assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer'); assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true); diff --git a/src/main/runtime/overlay-visibility-runtime-main-deps.ts b/src/main/runtime/overlay-visibility-runtime-main-deps.ts index f4b7761f..525728a6 100644 --- a/src/main/runtime/overlay-visibility-runtime-main-deps.ts +++ b/src/main/runtime/overlay-visibility-runtime-main-deps.ts @@ -10,6 +10,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler( getModalActive: () => deps.getModalActive(), getVisibleOverlayVisible: () => deps.getVisibleOverlayVisible(), getForceMousePassthrough: () => deps.getForceMousePassthrough(), + getOverlayInteractionActive: () => deps.getOverlayInteractionActive?.() ?? false, getWindowTracker: () => deps.getWindowTracker(), getLastKnownWindowsForegroundProcessName: () => deps.getLastKnownWindowsForegroundProcessName?.() ?? null, diff --git a/src/main/runtime/update/app-updater.test.ts b/src/main/runtime/update/app-updater.test.ts index a2c9f9ba..3cf4b647 100644 --- a/src/main/runtime/update/app-updater.test.ts +++ b/src/main/runtime/update/app-updater.test.ts @@ -18,8 +18,12 @@ type UpdaterLogger = { test('configureAutoUpdater disables eager update behavior and suppresses info logging', () => { const logged: string[] = []; - const updater: ElectronAutoUpdaterLike & { logger?: UpdaterLogger | null } = { + const updater: ElectronAutoUpdaterLike & { + autoInstallOnAppQuit: boolean; + logger?: UpdaterLogger | null; + } = { autoDownload: true, + autoInstallOnAppQuit: true, allowPrerelease: true, allowDowngrade: true, logger: null, @@ -31,6 +35,7 @@ test('configureAutoUpdater disables eager update behavior and suppresses info lo configureAutoUpdater(updater, (message) => logged.push(message)); assert.equal(updater.autoDownload, false); + assert.equal(updater.autoInstallOnAppQuit, false); assert.equal(updater.allowPrerelease, false); assert.equal(updater.allowDowngrade, false); assert.ok(updater.logger); @@ -180,16 +185,18 @@ test('mac native updater is unsupported for ad-hoc signed app bundles', async () assert.deepEqual(logged, ['Skipping native macOS updater because this build is ad-hoc signed.']); }); -test('mac native updater is supported for Developer ID signed app bundles', async () => { +test('mac native updater supports Developer ID signed packaged app bundles', async () => { + const logged: string[] = []; const supported = await isNativeUpdaterSupported({ platform: 'darwin', isPackaged: true, execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner', - readCodeSignature: () => - ['Authority=Developer ID Application: Example', 'TeamIdentifier=ABCDE12345'].join('\n'), + log: (message) => logged.push(message), + readCodeSignature: async () => 'Authority=Developer ID Application: Kyle Yasuda (EPJ9P4RWTC)', }); assert.equal(supported, true); + assert.deepEqual(logged, []); }); test('linux native updater is unsupported even for writable direct AppImage installs', async () => { diff --git a/src/main/runtime/update/app-updater.ts b/src/main/runtime/update/app-updater.ts index 0076a6dc..b00fa090 100644 --- a/src/main/runtime/update/app-updater.ts +++ b/src/main/runtime/update/app-updater.ts @@ -20,6 +20,7 @@ export interface ElectronUpdaterLoggerLike { export interface ElectronAutoUpdaterLike { autoDownload: boolean; + autoInstallOnAppQuit?: boolean; allowPrerelease: boolean; allowDowngrade: boolean; logger?: ElectronUpdaterLoggerLike | null; @@ -120,6 +121,8 @@ export function configureAutoUpdater( channel: UpdateChannel = 'stable', ): ElectronAutoUpdaterLike { updater.autoDownload = false; + // On macOS this avoids invoking Squirrel until the explicit restart/install step. + updater.autoInstallOnAppQuit = false; updater.allowPrerelease = channel === 'prerelease'; updater.allowDowngrade = false; updater.logger = { diff --git a/src/window-trackers/macos-tracker.test.ts b/src/window-trackers/macos-tracker.test.ts index fe6e1d82..4dcedaa6 100644 --- a/src/window-trackers/macos-tracker.test.ts +++ b/src/window-trackers/macos-tracker.test.ts @@ -1,6 +1,13 @@ import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import test from 'node:test'; -import { MacOSWindowTracker, parseMacOSHelperOutput } from './macos-tracker'; +import { + isCompiledMacOSHelperCurrent, + MacOSWindowTracker, + parseMacOSHelperOutput, +} from './macos-tracker'; test('parseMacOSHelperOutput parses minimized state', () => { assert.deepEqual(parseMacOSHelperOutput('minimized'), { @@ -10,6 +17,99 @@ test('parseMacOSHelperOutput parses minimized state', () => { }); }); +test('parseMacOSHelperOutput parses active focused state without geometry', () => { + assert.deepEqual(parseMacOSHelperOutput('active'), { + geometry: null, + focused: true, + active: true, + }); +}); + +test('parseMacOSHelperOutput parses inactive state without geometry', () => { + assert.deepEqual(parseMacOSHelperOutput('inactive'), { + geometry: null, + focused: false, + inactive: true, + }); +}); + +test('isCompiledMacOSHelperCurrent rejects binaries older than the Swift source', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'subminer-macos-helper-')); + try { + const binaryPath = join(tempDir, 'get-mpv-window-macos'); + const sourcePath = join(tempDir, 'get-mpv-window-macos.swift'); + writeFileSync(binaryPath, 'binary'); + writeFileSync(sourcePath, 'source'); + + const older = new Date('2026-01-01T00:00:00Z'); + const newer = new Date('2026-01-01T00:00:05Z'); + utimesSync(binaryPath, older, older); + utimesSync(sourcePath, newer, newer); + + assert.equal(isCompiledMacOSHelperCurrent(binaryPath, sourcePath), false); + + utimesSync(binaryPath, newer, newer); + utimesSync(sourcePath, older, older); + + assert.equal(isCompiledMacOSHelperCurrent(binaryPath, sourcePath), true); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +}); + +test('MacOSWindowTracker slows polling while focused target is stable', async () => { + const scheduledDelays: number[] = []; + let callIndex = 0; + const tracker = new MacOSWindowTracker('/tmp/mpv.sock', { + resolveHelper: () => ({ + helperPath: 'helper', + helperType: 'binary', + }), + runHelper: async () => { + callIndex += 1; + return { stdout: '10,20,1280,720,1', stderr: '' }; + }, + fastPollIntervalMs: 250, + stablePollIntervalMs: 1_000, + setPollTimeout: ((_callback: () => void, delayMs: number) => { + scheduledDelays.push(delayMs); + return {} as ReturnType; + }) as never, + clearPollTimeout: (() => {}) as never, + } as never); + + tracker.start(); + await new Promise((resolve) => setTimeout(resolve, 0)); + tracker.stop(); + + assert.equal(callIndex, 1); + assert.deepEqual(scheduledDelays, [1_000]); +}); + +test('MacOSWindowTracker keeps fast polling while target is not focused', async () => { + const scheduledDelays: number[] = []; + const tracker = new MacOSWindowTracker('/tmp/mpv.sock', { + resolveHelper: () => ({ + helperPath: 'helper', + helperType: 'binary', + }), + runHelper: async () => ({ stdout: '10,20,1280,720,0', stderr: '' }), + fastPollIntervalMs: 250, + stablePollIntervalMs: 1_000, + setPollTimeout: ((_callback: () => void, delayMs: number) => { + scheduledDelays.push(delayMs); + return {} as ReturnType; + }) as never, + clearPollTimeout: (() => {}) as never, + } as never); + + tracker.start(); + await new Promise((resolve) => setTimeout(resolve, 0)); + tracker.stop(); + + assert.deepEqual(scheduledDelays, [250]); +}); + test('MacOSWindowTracker keeps the last geometry through a single helper miss', async () => { let callIndex = 0; const outputs = [ @@ -55,10 +155,221 @@ test('MacOSWindowTracker keeps the last geometry through a single helper miss', }); }); +test('MacOSWindowTracker preserves target focus on helper not-found while retaining geometry', async () => { + let callIndex = 0; + const focusChanges: boolean[] = []; + const outputs = [ + { stdout: '10,20,1280,720,1', stderr: '' }, + { stdout: 'not-found', stderr: '' }, + ]; + + const tracker = new MacOSWindowTracker('/tmp/mpv.sock', { + resolveHelper: () => ({ + helperPath: 'helper.swift', + helperType: 'swift', + }), + runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!, + trackingLossGraceMs: 1_500, + }); + tracker.onWindowFocusChange = (focused) => { + focusChanges.push(focused); + }; + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(tracker.isTargetWindowFocused(), true); + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(tracker.isTracking(), true); + assert.deepEqual(tracker.getGeometry(), { + x: 10, + y: 20, + width: 1280, + height: 720, + }); + assert.equal(tracker.isTargetWindowFocused(), true); + assert.deepEqual(focusChanges, [true]); +}); + +test('MacOSWindowTracker keeps focused fullscreen target through active helper misses after grace', async () => { + let callIndex = 0; + let now = 1_000; + const outputs = [ + { stdout: '10,20,1280,720,1', stderr: '' }, + { stdout: 'active', stderr: '' }, + { stdout: 'active', stderr: '' }, + ]; + + const tracker = new MacOSWindowTracker('/tmp/mpv.sock', { + resolveHelper: () => ({ + helperPath: 'helper.swift', + helperType: 'swift', + }), + runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!, + now: () => now, + trackingLossGraceMs: 500, + }); + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(tracker.isTracking(), true); + assert.equal(tracker.isTargetWindowFocused(), true); + + now += 1_000; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(tracker.isTracking(), true); + + now += 1_000; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(tracker.isTracking(), true); + assert.equal(tracker.isTargetWindowFocused(), true); + assert.deepEqual(tracker.getGeometry(), { + x: 10, + y: 20, + width: 1280, + height: 720, + }); +}); + +test('MacOSWindowTracker drops previously focused target after repeated not-found misses exceed grace', async () => { + let callIndex = 0; + let now = 1_000; + const focusChanges: boolean[] = []; + const outputs = [ + { stdout: '10,20,1280,720,1', stderr: '' }, + { stdout: 'not-found', stderr: '' }, + { stdout: 'not-found', stderr: '' }, + ]; + + const tracker = new MacOSWindowTracker('/tmp/mpv.sock', { + resolveHelper: () => ({ + helperPath: 'helper.swift', + helperType: 'swift', + }), + runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!, + now: () => now, + trackingLossGraceMs: 500, + }); + tracker.onWindowFocusChange = (focused) => { + focusChanges.push(focused); + }; + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(tracker.isTracking(), true); + assert.equal(tracker.isTargetWindowFocused(), true); + + now += 1_000; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(tracker.isTracking(), true); + assert.equal(tracker.isTargetWindowFocused(), true); + assert.deepEqual(tracker.getGeometry(), { + x: 10, + y: 20, + width: 1280, + height: 720, + }); + assert.deepEqual(focusChanges, [true]); + + now += 1_000; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(tracker.isTracking(), false); + assert.equal(tracker.isTargetWindowFocused(), false); + assert.equal(tracker.getGeometry(), null); + assert.deepEqual(focusChanges, [true, false]); +}); + +test('MacOSWindowTracker drops previously focused target after repeated helper execution failures exceed grace', async () => { + let callIndex = 0; + let now = 1_000; + const focusChanges: boolean[] = []; + + const tracker = new MacOSWindowTracker('/tmp/mpv.sock', { + resolveHelper: () => ({ + helperPath: 'helper.swift', + helperType: 'swift', + }), + runHelper: async () => { + callIndex += 1; + if (callIndex === 1) { + return { stdout: '10,20,1280,720,1', stderr: '' }; + } + throw Object.assign(new Error('helper timed out'), { stderr: 'timeout' }); + }, + now: () => now, + trackingLossGraceMs: 500, + }); + tracker.onWindowFocusChange = (focused) => { + focusChanges.push(focused); + }; + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + now += 1_000; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + now += 1_000; + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(tracker.isTracking(), false); + assert.equal(tracker.isTargetWindowFocused(), false); + assert.equal(tracker.getGeometry(), null); + assert.deepEqual(focusChanges, [true, false]); +}); + +test('MacOSWindowTracker marks target unfocused on explicit inactive helper signal', async () => { + let callIndex = 0; + const focusChanges: boolean[] = []; + const outputs = [ + { stdout: '10,20,1280,720,1', stderr: '' }, + { stdout: 'inactive', stderr: '' }, + ]; + + const tracker = new MacOSWindowTracker('/tmp/mpv.sock', { + resolveHelper: () => ({ + helperPath: 'helper.swift', + helperType: 'swift', + }), + runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!, + trackingLossGraceMs: 1_500, + }); + tracker.onWindowFocusChange = (focused) => { + focusChanges.push(focused); + }; + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(tracker.isTracking(), true); + assert.deepEqual(tracker.getGeometry(), { + x: 10, + y: 20, + width: 1280, + height: 720, + }); + assert.equal(tracker.isTargetWindowFocused(), false); + assert.deepEqual(focusChanges, [true, false]); +}); + test('MacOSWindowTracker drops tracking after consecutive helper misses', async () => { let callIndex = 0; const outputs = [ - { stdout: '10,20,1280,720,1', stderr: '' }, + { stdout: '10,20,1280,720,0', stderr: '' }, { stdout: 'not-found', stderr: '' }, { stdout: 'not-found', stderr: '' }, ]; @@ -75,6 +386,7 @@ test('MacOSWindowTracker drops tracking after consecutive helper misses', async (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(tracker.isTracking(), true); + assert.equal(tracker.isTargetWindowFocused(), false); (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -84,6 +396,7 @@ test('MacOSWindowTracker drops tracking after consecutive helper misses', async await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(tracker.isTracking(), false); assert.equal(tracker.getGeometry(), null); + assert.equal(tracker.isTargetWindowFocused(), false); }); test('MacOSWindowTracker keeps tracking through repeated helper misses inside grace window', async () => { @@ -137,7 +450,7 @@ test('MacOSWindowTracker drops tracking after grace window expires', async () => let callIndex = 0; let now = 1_000; const outputs = [ - { stdout: '10,20,1280,720,1', stderr: '' }, + { stdout: '10,20,1280,720,0', stderr: '' }, { stdout: 'not-found', stderr: '' }, { stdout: 'not-found', stderr: '' }, { stdout: 'not-found', stderr: '' }, @@ -156,6 +469,7 @@ test('MacOSWindowTracker drops tracking after grace window expires', async () => (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); await new Promise((resolve) => setTimeout(resolve, 0)); assert.equal(tracker.isTracking(), true); + assert.equal(tracker.isTargetWindowFocused(), false); now += 250; (tracker as unknown as { pollGeometry: () => void }).pollGeometry(); diff --git a/src/window-trackers/macos-tracker.ts b/src/window-trackers/macos-tracker.ts index 7c3cd9c1..761f1500 100644 --- a/src/window-trackers/macos-tracker.ts +++ b/src/window-trackers/macos-tracker.ts @@ -25,6 +25,8 @@ import { createLogger } from '../logger'; import type { WindowGeometry } from '../types'; const log = createLogger('tracker').child('macos'); +const MACOS_FAST_POLL_INTERVAL_MS = 250; +const MACOS_STABLE_FOCUSED_POLL_INTERVAL_MS = 1_000; type MacOSTrackerRunnerResult = { stdout: string; @@ -42,6 +44,10 @@ type MacOSTrackerDeps = { trackingLossGraceMs?: number; minimizedTrackingLossGraceMs?: number; now?: () => number; + fastPollIntervalMs?: number; + stablePollIntervalMs?: number; + setPollTimeout?: typeof setTimeout; + clearPollTimeout?: typeof clearTimeout; }; export type MacOSHelperWindowState = @@ -49,11 +55,29 @@ export type MacOSHelperWindowState = geometry: WindowGeometry; focused: boolean; minimized?: false; + active?: false; + inactive?: false; + } + | { + geometry: null; + focused: true; + active: true; + minimized?: false; + inactive?: false; + } + | { + geometry: null; + focused: false; + inactive: true; + active?: false; + minimized?: false; } | { geometry: null; focused: false; minimized: true; + active?: false; + inactive?: false; }; function runHelperWithExecFile( @@ -90,6 +114,25 @@ function runHelperWithExecFile( }); } +export function isCompiledMacOSHelperCurrent( + binaryPath: string, + sourcePath: string, + helperFs: Pick = fs, +): boolean { + if (!helperFs.existsSync(binaryPath)) { + return false; + } + if (!helperFs.existsSync(sourcePath)) { + return true; + } + + try { + return helperFs.statSync(binaryPath).mtimeMs >= helperFs.statSync(sourcePath).mtimeMs; + } catch { + return false; + } +} + export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState | null { const trimmed = result.trim(); if (trimmed === 'minimized') { @@ -99,6 +142,20 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState | minimized: true, }; } + if (trimmed === 'active') { + return { + geometry: null, + focused: true, + active: true, + }; + } + if (trimmed === 'inactive') { + return { + geometry: null, + focused: false, + inactive: true, + }; + } if (!trimmed || trimmed === 'not-found') { return null; } @@ -138,8 +195,9 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState | } export class MacOSWindowTracker extends BaseWindowTracker { - private pollInterval: ReturnType | null = null; + private pollTimeout: ReturnType | null = null; private pollInFlight = false; + private started = false; private helperPath: string | null = null; private helperType: 'binary' | 'swift' | null = null; private lastExecErrorFingerprint: string | null = null; @@ -154,6 +212,10 @@ export class MacOSWindowTracker extends BaseWindowTracker { private readonly trackingLossGraceMs: number; private readonly minimizedTrackingLossGraceMs: number; private readonly now: () => number; + private readonly fastPollIntervalMs: number; + private readonly stablePollIntervalMs: number; + private readonly setPollTimeout: typeof setTimeout; + private readonly clearPollTimeout: typeof clearTimeout; private consecutiveMisses = 0; private trackingLossStartedAtMs: number | null = null; private targetWindowMinimized = false; @@ -169,6 +231,16 @@ export class MacOSWindowTracker extends BaseWindowTracker { Math.floor(deps.minimizedTrackingLossGraceMs ?? 500), ); this.now = deps.now ?? (() => Date.now()); + this.fastPollIntervalMs = Math.max( + 50, + Math.floor(deps.fastPollIntervalMs ?? MACOS_FAST_POLL_INTERVAL_MS), + ); + this.stablePollIntervalMs = Math.max( + this.fastPollIntervalMs, + Math.floor(deps.stablePollIntervalMs ?? MACOS_STABLE_FOCUSED_POLL_INTERVAL_MS), + ); + this.setPollTimeout = deps.setPollTimeout ?? setTimeout; + this.clearPollTimeout = deps.clearPollTimeout ?? clearTimeout; const resolvedHelper = deps.resolveHelper?.() ?? null; if (resolvedHelper) { this.helperPath = resolvedHelper.helperPath; @@ -216,15 +288,15 @@ export class MacOSWindowTracker extends BaseWindowTracker { return true; } - private detectHelper(): void { - const shouldFilterBySocket = this.targetMpvSocketPath !== null; - - // Fall back to Swift helper first when filtering by socket path to avoid - // stale prebuilt binaries that don't support the new socket filter argument. - const swiftPath = path.join(__dirname, '..', '..', 'scripts', 'get-mpv-window-macos.swift'); - if (shouldFilterBySocket && this.tryUseHelper(swiftPath, 'swift')) { - return; + private tryUseCompiledHelper(candidatePath: string, sourcePath: string): boolean { + if (!isCompiledMacOSHelperCurrent(candidatePath, sourcePath)) { + return false; } + return this.tryUseHelper(candidatePath, 'binary'); + } + + private detectHelper(): void { + const swiftPath = path.join(__dirname, '..', '..', 'scripts', 'get-mpv-window-macos.swift'); // Prefer resources path (outside asar) in packaged apps. const resourcesPath = process.resourcesPath; @@ -235,9 +307,21 @@ export class MacOSWindowTracker extends BaseWindowTracker { } } - // Dist binary path (development / unpacked installs). - const distBinaryPath = path.join(__dirname, '..', '..', 'scripts', 'get-mpv-window-macos'); - if (this.tryUseHelper(distBinaryPath, 'binary')) { + // Built source runs from dist/window-trackers, so the compiled helper is a sibling of dist. + const bundledBinaryPath = path.join(__dirname, '..', 'scripts', 'get-mpv-window-macos'); + if (this.tryUseCompiledHelper(bundledBinaryPath, swiftPath)) { + return; + } + + // Source-tree/manual helper build path. + const sourceTreeBinaryPath = path.join( + __dirname, + '..', + '..', + 'scripts', + 'get-mpv-window-macos', + ); + if (this.tryUseCompiledHelper(sourceTreeBinaryPath, swiftPath)) { return; } @@ -269,15 +353,16 @@ export class MacOSWindowTracker extends BaseWindowTracker { } start(): void { - this.pollInterval = setInterval(() => this.pollGeometry(), 250); + if (this.started) { + return; + } + this.started = true; this.pollGeometry(); } stop(): void { - if (this.pollInterval) { - clearInterval(this.pollInterval); - this.pollInterval = null; - } + this.started = false; + this.clearScheduledPoll(); } override isTargetWindowMinimized(): boolean { @@ -303,7 +388,21 @@ export class MacOSWindowTracker extends BaseWindowTracker { return this.now() - this.trackingLossStartedAtMs > graceMs; } + private shouldPreserveFocusedTargetOnMiss(): boolean { + return this.isTracking() && this.isTargetWindowFocused() && this.getGeometry() !== null; + } + private registerTrackingMiss(graceMs = this.trackingLossGraceMs): void { + if (this.shouldPreserveFocusedTargetOnMiss()) { + if (this.trackingLossStartedAtMs === null) { + this.trackingLossStartedAtMs = this.now(); + return; + } + if (this.now() - this.trackingLossStartedAtMs <= graceMs) { + return; + } + } + this.consecutiveMisses += 1; if (this.shouldDropTracking(graceMs)) { this.updateGeometry(null); @@ -311,6 +410,39 @@ export class MacOSWindowTracker extends BaseWindowTracker { } } + private resolveNextPollIntervalMs(): number { + if ( + this.isTracking() && + this.isTargetWindowFocused() && + !this.targetWindowMinimized && + this.getGeometry() !== null + ) { + return this.stablePollIntervalMs; + } + + return this.fastPollIntervalMs; + } + + private clearScheduledPoll(): void { + if (!this.pollTimeout) { + return; + } + + this.clearPollTimeout(this.pollTimeout); + this.pollTimeout = null; + } + + private scheduleNextPoll(): void { + if (!this.started || this.pollTimeout) { + return; + } + + this.pollTimeout = this.setPollTimeout(() => { + this.pollTimeout = null; + this.pollGeometry(); + }, this.resolveNextPollIntervalMs()); + } + private pollGeometry(): void { if (this.pollInFlight || !this.helperPath || !this.helperType) { return; @@ -327,10 +459,22 @@ export class MacOSWindowTracker extends BaseWindowTracker { this.registerTrackingMiss(this.minimizedTrackingLossGraceMs); return; } + if (parsed.active) { + this.resetTrackingLossState(); + this.targetWindowMinimized = false; + this.updateTargetWindowFocused(true); + return; + } + if (parsed.inactive) { + this.targetWindowMinimized = false; + this.updateTargetWindowFocused(false); + this.registerTrackingMiss(); + return; + } this.resetTrackingLossState(); this.targetWindowMinimized = false; - this.updateFocus(parsed.focused); - this.updateGeometry(parsed.geometry); + this.updateGeometry(parsed.geometry, parsed.focused); + this.updateTargetWindowFocused(parsed.focused); return; } @@ -352,6 +496,7 @@ export class MacOSWindowTracker extends BaseWindowTracker { }) .finally(() => { this.pollInFlight = false; + this.scheduleNextPoll(); }); } }