diff --git a/CHANGELOG.md b/CHANGELOG.md
index 873a18af..214a470f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
# Changelog
+## Unreleased
+
+### Fixed
+- AniList: Stopped post-watch tracking from sending a second progress update when the current episode was already satisfied by a ready retry item in the same watch-completion pass.
+
## v0.10.0 (2026-03-29)
### Changed
diff --git a/README.md b/README.md
index 1a4c2e3a..70e53035 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,12 @@ Local stats dashboard — watch time, anime library, vocabulary growth, mining t
+### Playlist Browser
+
+Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback.
+
+
+
### Integrations
diff --git a/backlog/tasks/task-255 - Add-overlay-playlist-browser-modal-for-sibling-video-files-and-mpv-queue.md b/backlog/tasks/task-255 - Add-overlay-playlist-browser-modal-for-sibling-video-files-and-mpv-queue.md
index a8d09979..a80abeab 100644
--- a/backlog/tasks/task-255 - Add-overlay-playlist-browser-modal-for-sibling-video-files-and-mpv-queue.md
+++ b/backlog/tasks/task-255 - Add-overlay-playlist-browser-modal-for-sibling-video-files-and-mpv-queue.md
@@ -1,9 +1,11 @@
---
id: TASK-255
title: Add overlay playlist browser modal for sibling video files and mpv queue
-status: To Do
-assignee: []
+status: In Progress
+assignee:
+ - '@codex'
created_date: '2026-03-30 05:46'
+updated_date: '2026-03-31 05:59'
labels:
- feature
- overlay
@@ -24,6 +26,62 @@ Add an in-session overlay modal that opens from a keybinding during active playb
- [ ] #2 The modal shows video files from the current media file's parent directory in best-effort episode order and highlights the current file when present.
- [ ] #3 The modal shows the active mpv playlist/queue with enough metadata to identify the current item and queued order.
- [ ] #4 The user can add a directory file to the mpv playlist, remove playlist items, and reorder playlist items from the modal using both mouse and keyboard interactions.
-- [ ] #5 Modal state stays in sync after playlist mutations so the rendered queue reflects mpv's current playlist order.
-- [ ] #6 Feature coverage includes automated tests for ordering/playlist behavior and docs or shortcut/help updates for the new modal.
+- [x] #5 Modal state stays in sync after playlist mutations so the rendered queue reflects mpv's current playlist order.
+- [x] #6 Feature coverage includes automated tests for ordering/playlist behavior and docs or shortcut/help updates for the new modal.
+
+## Implementation Plan
+
+
+1. Add playlist-browser domain types, IPC channels, overlay modal registration, special command, and default keybinding for Ctrl+Alt+P.
+2. Write failing tests for best-effort episode sorting and main playlist-browser runtime snapshot/mutation behavior.
+3. Implement playlist-browser main/runtime helpers for local sibling video discovery, mpv playlist normalization, and append/play/remove/move operations with refreshed snapshots.
+4. Wire preload and main-process IPC handlers that expose snapshot and mutation methods to the renderer.
+5. Write failing renderer and keyboard tests for modal open/close, split-pane interaction, keyboard controls, and degraded states.
+6. Implement playlist-browser modal markup, DOM/state, renderer composition, keyboard routing, and session-help labeling.
+7. Run targeted test lanes first, then the maintained verification gate relevant to the touched surfaces; update task notes/criteria as checks pass.
+
+2026-03-30 CodeRabbit follow-up: 1) add failing runtime coverage for unreadable playlist-browser file stat failures, 2) add failing renderer coverage for stale snapshot UI reset on refresh failure/close, 3) add failing renderer coverage to block playlist-browser open when another modal already owns the overlay, 4) implement minimal fixes, 5) rerun targeted tests plus typecheck for touched surfaces.
+
+2026-03-30 current CodeRabbit round: verify 4 unresolved threads, ignore already-fixed outdated dblclick thread if current code matches, add failing-first coverage for selection preservation / timestamp fixture consistency / string test-clock alignment, implement minimal fixes, rerun targeted tests plus typecheck.
+
+2026-03-30 latest CodeRabbit round on PR #37: 1) add failing coverage for negative fractional numeric __subminerTestNowMs input so nowMs() matches the string-backed path, 2) add failing coverage that playlist-browser modal tests restore absent window/document globals without leaving undefined-valued properties behind, 3) refactor repeated playlist-browser modal test harness into a shared setup/teardown fixture while preserving assertions, 4) implement minimal fixes, 5) rerun touched tests plus typecheck.
+
+2026-03-30 latest CodeRabbit follow-up after ff760ea: tighten the new cleanup regression so env.restore() always runs under assertion failure, and make the keydown test's append mock return a post-append mutated snapshot before exercising Ctrl+ArrowDown. Re-run targeted playlist-browser tests plus typecheck.
+
+
+## Implementation Notes
+
+
+Implemented overlay playlist browser modal with split directory/playlist panes, Ctrl+Alt+P keybinding, main/preload IPC, mpv queue mutations, and best-effort sibling episode sorting.
+
+Added tests for sort/runtime logic, IPC wiring, keyboard routing, and playlist-browser modal behavior.
+
+Verification: `bun run typecheck` passed; targeted playlist-browser and IPC tests passed; `bun run build` passed; `bun run test:smoke:dist` passed.
+
+Repo gate blockers outside this feature: `bun run test:fast` hits existing Bun `node:test` NotImplementedError cases plus unrelated immersion-tracker failures; `bun run test:env` fails in existing immersion-tracker sqlite tests.
+
+2026-03-30: Fixed playlist-browser local playback regression where subtitle track IDs leaked across episode jumps. `playPlaylistBrowserIndexRuntime` now reapplies local subtitle auto-selection defaults (`sub-auto=fuzzy`, `sid=auto`, `secondary-sid=auto`) before `playlist-play-index` for local filesystem targets only; remote playlist entries remain untouched. Added runtime regression tests for both paths.
+
+2026-03-30: Follow-up subtitle regression fix. Pre-jump `sid=auto` was ineffective because mpv resolved it against the current episode before `playlist-play-index`. Local playlist jumps now set `sub-auto=fuzzy`, switch episodes, then schedule a delayed rearm of `sid=auto` and `secondary-sid=auto` so selection happens against the new file's tracks. Added failing-first runtime coverage for delayed local rearm and remote no-op behavior.
+
+2026-03-30: Cleaned up playlist-browser runtime local-play subtitle-rearm flow by extracting focused helpers without changing behavior. Added public docs/readme coverage for the default `Ctrl+Alt+P` playlist browser keybinding and modal, plus changelog fragment `changes/260-playlist-browser.md`. Verification: `bun test src/main/runtime/playlist-browser-runtime.test.ts`, `bun run typecheck`, `bun run docs:test`, `bun run docs:build`, `bun run changelog:lint`, `bun run build`.
+
+2026-03-30: Pulled unresolved CodeRabbit review threads for PR #37. Actionable set is three items: unreadable-file stat error handling in playlist-browser runtime, stale playlist-browser DOM after failed refresh/close, and missing modal-ownership guard before opening the playlist-browser overlay. Proceeding test-first for each.
+
+2026-03-30: Addressed current CodeRabbit follow-up findings for PR #37. Fixed playlist-browser unreadable-file stat handling, stale playlist-browser DOM reset on refresh failure/close, modal-ownership guard before opening the playlist-browser overlay, async rejection surfacing for PLAYLIST_BROWSER_OPEN IPC commands, overlay bootstrap before playlist-browser open dispatch, texthooker option normalization in the mpv plugin, and superseded local subtitle-rearm suppression. Added targeted regressions plus new playlist-browser-open helper coverage. Verification: `bun test src/main/runtime/playlist-browser-runtime.test.ts src/main/runtime/playlist-browser-open.test.ts src/core/services/ipc-command.test.ts src/renderer/modals/playlist-browser.test.ts`, `lua scripts/test-plugin-start-gate.lua`, `bun run typecheck`, `bun run build`.
+
+Addressed CodeRabbit follow-ups on the playlist browser PR: clamped stale playingIndex values, failed mutation paths when MPV rejects send(), added temp-dir cleanup in runtime tests, and blocked action-button dblclick bubbling in the renderer. Verification: `bun run typecheck`, `bun run build`, `bun test src/main/runtime/playlist-browser-runtime.test.ts src/renderer/modals/playlist-browser.test.ts`.
+
+Additional follow-up: moved playlist-browser keydown handling ahead of keyboard-driven lookup controls so KeyH/ArrowLeft/ArrowRight and related chords are routed to the modal first. Verification refreshed with `bun test src/main/runtime/playlist-browser-runtime.test.ts src/renderer/modals/playlist-browser.test.ts src/renderer/handlers/keyboard.test.ts`, `bun run typecheck`, and `bun run build`.
+
+Split playlist-browser UI row rendering into `src/renderer/modals/playlist-browser-renderer.ts` and left `src/renderer/modals/playlist-browser.ts` as the controller/wiring layer. Moved playlist-browser IPC/runtime wiring into `src/main/runtime/playlist-browser-ipc.ts` and collapsed the `src/main.ts` registration block to use that helper. Verification after refactor: `bun run typecheck`, `bun run build`, `bun test src/main/runtime/playlist-browser-runtime.test.ts src/renderer/modals/playlist-browser.test.ts src/renderer/handlers/keyboard.test.ts`.
+
+2026-03-30 PR #37 unresolved CodeRabbit threads currently reduce to three likely-actionable items plus one outdated renderer dblclick thread to verify against HEAD before touching code.
+
+2026-03-30 Addressed latest unresolved CodeRabbit items on PR #37: preserved playlist-browser selection across mutation snapshots, taught nowMs() to honor string-backed test clocks so it stays aligned with currentDbTimestamp(), and normalized maintenance test timestamp fixtures to toDbTimestamp(). The older playlist-browser dblclick thread remains unresolved in GitHub state but current HEAD already contains that fix in playlist-browser-renderer.ts.
+
+2026-03-30 latest CodeRabbit remediation on PR #37: switched nowMs() numeric test-clock branch from Math.floor() to Math.trunc() so numeric and string-backed mock clocks agree for negative fractional values. Refactored playlist-browser modal tests onto a shared setup/teardown fixture that restores global window/document descriptors correctly, and added regression coverage that injected globals are deleted when originally absent. Verification: `bun test src/core/services/immersion-tracker/time.test.ts src/renderer/modals/playlist-browser.test.ts`, `bun run typecheck`.
+
+2026-03-30 CodeRabbit follow-up: wrapped the injected-globals cleanup regression in try/finally so restore always runs, and changed the keydown test append mock to return createMutationSnapshot() before exercising Ctrl+ArrowDown. Verified with `bun test src/renderer/modals/playlist-browser.test.ts` and `bun run typecheck`.
+
diff --git a/backlog/tasks/task-260 - Fix-macOS-overlay-subtitle-sidebar-passthrough-without-requiring-a-subtitle-hover-cycle.md b/backlog/tasks/task-260 - Fix-macOS-overlay-subtitle-sidebar-passthrough-without-requiring-a-subtitle-hover-cycle.md
new file mode 100644
index 00000000..86312619
--- /dev/null
+++ b/backlog/tasks/task-260 - Fix-macOS-overlay-subtitle-sidebar-passthrough-without-requiring-a-subtitle-hover-cycle.md
@@ -0,0 +1,67 @@
+---
+id: TASK-260
+title: >-
+ Fix macOS overlay subtitle sidebar passthrough without requiring a subtitle
+ hover cycle
+status: Done
+assignee:
+ - '@codex'
+created_date: '2026-03-31 00:58'
+updated_date: '2026-03-31 01:01'
+labels:
+ - bug
+ - macos
+ - overlay
+ - subtitle-sidebar
+ - passthrough
+dependencies: []
+references:
+ - >-
+ /Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.ts
+ - >-
+ /Users/sudacode/projects/japanese/SubMiner/src/renderer/overlay-mouse-ignore.ts
+ - /Users/sudacode/projects/japanese/SubMiner/src/renderer/handlers/mouse.ts
+ - /Users/sudacode/projects/japanese/SubMiner/src/main/overlay-runtime.ts
+ - >-
+ /Users/sudacode/projects/japanese/SubMiner/src/core/services/overlay-visibility.ts
+documentation:
+ - docs/workflow/verification.md
+priority: high
+---
+
+## Description
+
+
+On macOS, opening the overlay-layout subtitle sidebar should allow click-through outside the sidebar immediately. Users should not need to first hover subtitle content before passthrough/click-through starts working, including when no subtitle line is currently visible.
+
+
+## Acceptance Criteria
+
+- [x] #1 With the overlay-layout subtitle sidebar open on macOS, areas outside the sidebar pass clicks through immediately after open without requiring a prior subtitle hover.
+- [x] #2 When no subtitle line is currently visible, opening the subtitle sidebar still leaves non-sidebar overlay regions click-through on macOS.
+- [x] #3 Regression coverage exercises the first-open/idle passthrough path so overlay interactivity does not depend on a later hover cycle.
+
+
+## Implementation Plan
+
+
+1. Add/adjust focused overlay visibility regressions for the tracked macOS visible overlay so the default idle state stays click-through instead of forcing mouse interaction.
+2. Update main-process visible overlay visibility sync to keep the tracked macOS overlay passive by default and let renderer hover/sidebar state opt into interaction.
+3. Run focused verification for overlay visibility and any dependent runtime tests, then update task notes/criteria/final summary with the confirmed outcome.
+
+
+## Implementation Notes
+
+
+Investigation points to a main-process override on macOS: renderer sidebar open path already requests mouse passthrough outside the panel, but visible-overlay visibility sync still hard-sets the tracked overlay window interactive on macOS (`mouse-ignore:false`). Window-tracker focus/visibility resync can therefore undo renderer passthrough until a later hover cycle re-applies it.
+
+Added a failing regression in `src/core/services/overlay-visibility.test.ts` showing the tracked macOS visible overlay was still forced interactive by main-process visibility sync (`mouse-ignore:false`) instead of staying forwarded click-through.
+
+Updated `src/core/services/overlay-visibility.ts` so tracked macOS visible overlays now default to `setIgnoreMouseEvents(true, { forward: true })`, matching the renderer-side passthrough model and preventing window-tracker/focus resync from undoing idle sidebar clickthrough.
+
+
+## Final Summary
+
+
+Fixed the macOS subtitle-sidebar passthrough regression by changing tracked visible-overlay startup/visibility sync to stay click-through by default in the main process. Previously `updateVisibleOverlayVisibility` forced the macOS overlay window interactive, which could override renderer sidebar passthrough until a later hover cycle repaired it. Added a regression in `src/core/services/overlay-visibility.test.ts` and verified with `bun test src/core/services/overlay-visibility.test.ts`, `bun test src/renderer/modals/subtitle-sidebar.test.ts src/renderer/handlers/mouse.test.ts`, and `bun run typecheck`.
+
diff --git a/backlog/tasks/task-261 - Fix-immersion-tracker-SQLite-timestamp-truncation.md b/backlog/tasks/task-261 - Fix-immersion-tracker-SQLite-timestamp-truncation.md
new file mode 100644
index 00000000..f900d685
--- /dev/null
+++ b/backlog/tasks/task-261 - Fix-immersion-tracker-SQLite-timestamp-truncation.md
@@ -0,0 +1,29 @@
+---
+id: TASK-261
+title: Fix immersion tracker SQLite timestamp truncation
+status: In Progress
+assignee: []
+created_date: '2026-03-31 01:45'
+labels:
+ - immersion-tracker
+ - sqlite
+ - bug
+dependencies: []
+references:
+ - src/core/services/immersion-tracker
+priority: medium
+ordinal: 1200
+---
+
+## Description
+
+
+Current-epoch millisecond values are being truncated by the libsql driver when bound as numeric parameters, which corrupts session, telemetry, lifetime, and rollup timestamps.
+
+
+## Acceptance Criteria
+
+- [ ] #1 Current-epoch millisecond timestamps persist correctly in session, telemetry, lifetime, and rollup tables
+- [ ] #2 Startup backfill and destroy/finalize flows keep retained sessions and lifetime summaries consistent
+- [ ] #3 Regression tests cover the destroyed-session, startup backfill, and distinct-day/distinct-video lifetime semantics
+
diff --git a/backlog/tasks/task-262 - Fix-duplicate-AniList-post-watch-updates-for-watched-episodes.md b/backlog/tasks/task-262 - Fix-duplicate-AniList-post-watch-updates-for-watched-episodes.md
new file mode 100644
index 00000000..0fce26fe
--- /dev/null
+++ b/backlog/tasks/task-262 - Fix-duplicate-AniList-post-watch-updates-for-watched-episodes.md
@@ -0,0 +1,50 @@
+---
+id: TASK-262
+title: Fix duplicate AniList post-watch updates for watched episodes
+status: Done
+assignee:
+ - codex
+created_date: '2026-03-31 19:03'
+updated_date: '2026-03-31 19:05'
+labels:
+ - bug
+ - anilist
+dependencies: []
+---
+
+## Description
+
+
+Watching an episode can currently produce two AniList activity updates for the same episode. The duplicate happens when the post-watch flow drains a queued retry for the current episode and then proceeds to run the live post-watch update for that same media/episode in the same pass. User report says this reproduces both when crossing the watched threshold naturally and when using the mark-watched keybinding. Fix the duplicate so one successful watch produces at most one AniList progress update for a given mediaKey/episode pair.
+
+
+## Acceptance Criteria
+
+- [x] #1 A watched episode triggers at most one AniList post-watch progress update for a given media key and episode during a single post-watch pass, even if that episode already exists in the retry queue.
+- [x] #2 Both watched-threshold and manual mark-watched flows are protected by regression coverage for the duplicate-update case.
+- [x] #3 Relevant user-visible change note is added if required by repo policy.
+
+
+## Implementation Plan
+
+
+1. Reproduce the duplicate in a unit test around `createMaybeRunAnilistPostWatchUpdateHandler` by simulating a ready retry for the same `mediaKey::episode` the live path would also submit.
+2. Fix the handler so that after processing a queued retry, it does not perform a second live update when the retry already satisfied the current attempt key.
+3. Run focused AniList runtime tests and adjacent immersion tests to confirm both threshold-driven and manual mark-watched entry points stay covered through the shared post-watch path.
+
+
+## Implementation Notes
+
+
+Added a regression in `src/main/runtime/anilist-post-watch.test.ts` for the case where `processNextAnilistRetryUpdate()` already satisfies the current `mediaKey::episode` before the live path runs.
+
+Updated `createMaybeRunAnilistPostWatchUpdateHandler` to re-check `hasAttemptedUpdateKey(attemptKey)` immediately after draining the retry queue and short-circuit before a second live AniList submission.
+
+Verification: `bun test src/main/runtime/anilist-post-watch.test.ts src/main/runtime/anilist-post-watch-main-deps.test.ts`; `bun test src/core/services/immersion-tracker-service.test.ts --test-name-pattern 'recordPlaybackPosition marks watched at 85% completion|markActiveVideoWatched'`; `bun run typecheck`; `bun run changelog:lint`.
+
+
+## Final Summary
+
+
+Fixed duplicate AniList post-watch submissions by short-circuiting the live update path when a ready retry item already handled the current `mediaKey::episode` in the same pass. Added a focused regression test for the retry-plus-live duplicate scenario and a changelog fragment documenting the fix.
+
diff --git a/changes/260-playlist-browser.md b/changes/260-playlist-browser.md
new file mode 100644
index 00000000..1c443a29
--- /dev/null
+++ b/changes/260-playlist-browser.md
@@ -0,0 +1,5 @@
+type: added
+area: overlay
+
+- Added a playlist browser overlay modal for browsing sibling video files and the live mpv queue during playback.
+- Added the default `Ctrl+Alt+P` keybinding to open the playlist browser and manage queue order without leaving playback.
diff --git a/changes/261-macos-overlay-passthrough.md b/changes/261-macos-overlay-passthrough.md
new file mode 100644
index 00000000..0ecdcfe9
--- /dev/null
+++ b/changes/261-macos-overlay-passthrough.md
@@ -0,0 +1,5 @@
+type: fixed
+area: overlay
+
+- Keep tracked macOS visible overlays click-through by default so subtitle sidebar passthrough works immediately without requiring a subtitle hover cycle first.
+- Add regression coverage for the macOS visible-overlay passthrough default.
diff --git a/changes/262-anilist-post-watch-dedupe.md b/changes/262-anilist-post-watch-dedupe.md
new file mode 100644
index 00000000..5dc2477f
--- /dev/null
+++ b/changes/262-anilist-post-watch-dedupe.md
@@ -0,0 +1,5 @@
+type: fixed
+area: anilist
+
+- Stop AniList post-watch from sending a second progress update when the current episode was already satisfied by a ready retry item in the same watch-completion pass.
+- Add regression coverage for the retry-queue plus live-update duplicate path.
diff --git a/docs-site/configuration.md b/docs-site/configuration.md
index 0b6e2b69..b26b9e2c 100644
--- a/docs-site/configuration.md
+++ b/docs-site/configuration.md
@@ -471,6 +471,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
| `Space` | `["cycle", "pause"]` | Toggle pause |
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
+| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
@@ -507,7 +508,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
{ "key": "Space", "command": null }
```
-**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__sub-delay-next-line` shifts subtitle delay so the active line aligns to the next cue start in the active subtitle source. `__sub-delay-prev-line` shifts subtitle delay so the active line aligns to the previous cue start. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:[:next|prev]` cycles a runtime option value.
+**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__sub-delay-next-line` shifts subtitle delay so the active line aligns to the next cue start in the active subtitle source. `__sub-delay-prev-line` shifts subtitle delay so the active line aligns to the previous cue start. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:[:next|prev]` cycles a runtime option value.
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
diff --git a/docs-site/shortcuts.md b/docs-site/shortcuts.md
index 73521e2c..2310f549 100644
--- a/docs-site/shortcuts.md
+++ b/docs-site/shortcuts.md
@@ -40,6 +40,7 @@ These control playback and subtitle display. They require overlay window focus.
| `Space` | Toggle mpv pause |
| `J` | Cycle primary subtitle track |
| `Shift+J` | Cycle secondary subtitle track |
+| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
| `ArrowRight` | Seek forward 5 seconds |
| `ArrowLeft` | Seek backward 5 seconds |
| `ArrowUp` | Seek forward 60 seconds |
@@ -56,7 +57,7 @@ These control playback and subtitle display. They require overlay window focus.
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
-These keybindings can be overridden or disabled via the `keybindings` config array.
+These keybindings can be overridden or disabled via the `keybindings` config array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave).
diff --git a/docs-site/usage.md b/docs-site/usage.md
index b5287dbc..bbc67773 100644
--- a/docs-site/usage.md
+++ b/docs-site/usage.md
@@ -295,6 +295,8 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
`Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config.
:::
+Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback.
+
Hovering over subtitle text pauses mpv by default; leaving resumes it. Disable with `subtitleStyle.autoPauseVideoOnHover: false`. To also pause while the Yomitan popup is open, set `subtitleStyle.autoPauseVideoOnYomitanPopup: true`.
### Drag-and-Drop
diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua
index 50f72cf3..d4f9a723 100644
--- a/plugin/subminer/process.lua
+++ b/plugin/subminer/process.lua
@@ -34,6 +34,17 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_pause_until_ready, false)
end
+ local function resolve_texthooker_enabled(override_value)
+ if override_value ~= nil then
+ return options_helper.coerce_bool(override_value, false)
+ end
+ local raw_texthooker_enabled = opts.texthooker_enabled
+ if raw_texthooker_enabled == nil then
+ raw_texthooker_enabled = opts["texthooker-enabled"]
+ end
+ return options_helper.coerce_bool(raw_texthooker_enabled, false)
+ end
+
local function resolve_pause_until_ready_timeout_seconds()
local raw_timeout_seconds = opts.auto_start_pause_until_ready_timeout_seconds
if raw_timeout_seconds == nil then
@@ -192,10 +203,7 @@ function M.create(ctx)
table.insert(args, "--hide-visible-overlay")
end
- local texthooker_enabled = overrides.texthooker_enabled
- if texthooker_enabled == nil then
- texthooker_enabled = opts.texthooker_enabled
- end
+ local texthooker_enabled = resolve_texthooker_enabled(overrides.texthooker_enabled)
if texthooker_enabled then
table.insert(args, "--texthooker")
end
@@ -296,10 +304,7 @@ function M.create(ctx)
return
end
- local texthooker_enabled = overrides.texthooker_enabled
- if texthooker_enabled == nil then
- texthooker_enabled = opts.texthooker_enabled
- end
+ local texthooker_enabled = resolve_texthooker_enabled(overrides.texthooker_enabled)
local socket_path = overrides.socket_path or opts.socket_path
local should_pause_until_ready = (
overrides.auto_start_trigger == true
@@ -498,7 +503,7 @@ function M.create(ctx)
end
end)
- if opts.texthooker_enabled then
+ if resolve_texthooker_enabled(nil) then
ensure_texthooker_running(function() end)
end
end)
diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua
index 7b20ec28..dc4489fb 100644
--- a/scripts/test-plugin-start-gate.lua
+++ b/scripts/test-plugin-start-gate.lua
@@ -531,6 +531,31 @@ do
)
end
+do
+ local recorded, err = run_plugin_scenario({
+ process_list = "",
+ option_overrides = {
+ binary_path = binary_path,
+ auto_start = "yes",
+ auto_start_visible_overlay = "yes",
+ auto_start_pause_until_ready = "no",
+ socket_path = "/tmp/subminer-socket",
+ texthooker_enabled = "no",
+ },
+ input_ipc_server = "/tmp/subminer-socket",
+ media_title = "Random Movie",
+ files = {
+ [binary_path] = true,
+ },
+ })
+ assert_true(recorded ~= nil, "plugin failed to load for disabled texthooker auto-start scenario: " .. tostring(err))
+ fire_event(recorded, "file-loaded")
+ local start_call = find_start_call(recorded.async_calls)
+ assert_true(start_call ~= nil, "disabled texthooker auto-start should still issue --start command")
+ assert_true(not call_has_arg(start_call, "--texthooker"), "disabled texthooker should not include --texthooker on --start")
+ assert_true(find_control_call(recorded.async_calls, "--texthooker") == nil, "disabled texthooker should not issue a helper texthooker command")
+end
+
do
local recorded, err = run_plugin_scenario({
process_list = "",
diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts
index 4bc079fc..17051fe5 100644
--- a/src/config/definitions/domain-registry.test.ts
+++ b/src/config/definitions/domain-registry.test.ts
@@ -80,6 +80,7 @@ test('default keybindings include primary and secondary subtitle track cycling o
assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']);
assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-sid']);
assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyC'), ['__youtube-picker-open']);
+ assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyP'), ['__playlist-browser-open']);
});
test('default keybindings include fullscreen on F', () => {
diff --git a/src/config/definitions/shared.ts b/src/config/definitions/shared.ts
index 26ac978f..3cf34241 100644
--- a/src/config/definitions/shared.ts
+++ b/src/config/definitions/shared.ts
@@ -47,6 +47,7 @@ export const SPECIAL_COMMANDS = {
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
+ PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
} as const;
export const DEFAULT_KEYBINDINGS: NonNullable = [
@@ -66,6 +67,7 @@ export const DEFAULT_KEYBINDINGS: NonNullable = [
command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START],
},
{ key: 'Ctrl+Alt+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] },
+ { key: 'Ctrl+Alt+KeyP', command: [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN] },
{ key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
{ key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] },
{ key: 'KeyQ', command: ['quit'] },
diff --git a/src/core/services/immersion-tracker-service.test.ts b/src/core/services/immersion-tracker-service.test.ts
index e286f126..3134f129 100644
--- a/src/core/services/immersion-tracker-service.test.ts
+++ b/src/core/services/immersion-tracker-service.test.ts
@@ -5,6 +5,7 @@ import os from 'node:os';
import path from 'node:path';
import { toMonthKey } from './immersion-tracker/maintenance';
import { enqueueWrite } from './immersion-tracker/queue';
+import { toDbTimestamp } from './immersion-tracker/query-shared';
import { Database, type DatabaseSync } from './immersion-tracker/sqlite';
import { nowMs as trackerNowMs } from './immersion-tracker/time';
import {
@@ -185,7 +186,7 @@ test('destroy finalizes active session and persists final telemetry', async () =
const db = new Database(dbPath);
const sessionRow = db.prepare('SELECT ended_at_ms FROM imm_sessions LIMIT 1').get() as {
- ended_at_ms: number | null;
+ ended_at_ms: string | number | null;
} | null;
const telemetryCountRow = db
.prepare('SELECT COUNT(*) AS total FROM imm_session_telemetry')
@@ -193,7 +194,7 @@ test('destroy finalizes active session and persists final telemetry', async () =
db.close();
assert.ok(sessionRow);
- assert.ok(Number(sessionRow?.ended_at_ms ?? 0) > 0);
+ assert.notEqual(sessionRow?.ended_at_ms, null);
assert.ok(Number(telemetryCountRow.total) >= 2);
} finally {
tracker?.destroy();
@@ -504,7 +505,7 @@ test('rebuildLifetimeSummaries backfills retained ended sessions and resets stal
episodes_started: number;
episodes_completed: number;
anime_completed: number;
- last_rebuilt_ms: number | null;
+ last_rebuilt_ms: string | number | null;
} | null;
const appliedSessions = rebuildApi.db
.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions')
@@ -518,7 +519,7 @@ test('rebuildLifetimeSummaries backfills retained ended sessions and resets stal
assert.equal(globalRow?.episodes_started, 2);
assert.equal(globalRow?.episodes_completed, 2);
assert.equal(globalRow?.anime_completed, 1);
- assert.equal(globalRow?.last_rebuilt_ms, rebuild.rebuiltAtMs);
+ assert.equal(globalRow?.last_rebuilt_ms, toDbTimestamp(rebuild.rebuiltAtMs));
assert.equal(appliedSessions?.total, 2);
} finally {
tracker?.destroy();
@@ -629,97 +630,89 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
const startedAtMs = trackerNowMs() - 10_000;
const sampleMs = startedAtMs + 5_000;
- db.exec(`
- INSERT INTO imm_anime (
- anime_id,
- canonical_title,
- normalized_title_key,
- episodes_total,
- CREATED_DATE,
- LAST_UPDATE_DATE
- ) VALUES (
- 1,
- 'KonoSuba',
- 'konosuba',
- 10,
- ${startedAtMs},
- ${startedAtMs}
- );
+ db.prepare(
+ `
+ INSERT INTO imm_anime (
+ anime_id,
+ canonical_title,
+ normalized_title_key,
+ episodes_total,
+ CREATED_DATE,
+ LAST_UPDATE_DATE
+ ) VALUES (?, ?, ?, ?, ?, ?)
+ `,
+ ).run(1, 'KonoSuba', 'konosuba', 10, toDbTimestamp(startedAtMs), toDbTimestamp(startedAtMs));
- INSERT INTO imm_videos (
- video_id,
- video_key,
- canonical_title,
- anime_id,
- watched,
- source_type,
- duration_ms,
- CREATED_DATE,
- LAST_UPDATE_DATE
- ) VALUES (
- 1,
- 'local:/tmp/konosuba-s02e05.mkv',
- 'KonoSuba S02E05',
- 1,
- 1,
- 1,
- 0,
- ${startedAtMs},
- ${startedAtMs}
- );
+ db.prepare(
+ `
+ INSERT INTO imm_videos (
+ video_id,
+ video_key,
+ canonical_title,
+ anime_id,
+ watched,
+ source_type,
+ duration_ms,
+ CREATED_DATE,
+ LAST_UPDATE_DATE
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `,
+ ).run(
+ 1,
+ 'local:/tmp/konosuba-s02e05.mkv',
+ 'KonoSuba S02E05',
+ 1,
+ 1,
+ 1,
+ 0,
+ toDbTimestamp(startedAtMs),
+ toDbTimestamp(startedAtMs),
+ );
- INSERT INTO imm_sessions (
- session_id,
- session_uuid,
- video_id,
- started_at_ms,
- status,
- ended_media_ms,
- CREATED_DATE,
- LAST_UPDATE_DATE
- ) VALUES (
- 1,
- '11111111-1111-1111-1111-111111111111',
- 1,
- ${startedAtMs},
- 1,
- 321000,
- ${startedAtMs},
- ${sampleMs}
- );
+ db.prepare(
+ `
+ INSERT INTO imm_sessions (
+ session_id,
+ session_uuid,
+ video_id,
+ started_at_ms,
+ status,
+ ended_media_ms,
+ CREATED_DATE,
+ LAST_UPDATE_DATE
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ `,
+ ).run(
+ 1,
+ '11111111-1111-1111-1111-111111111111',
+ 1,
+ toDbTimestamp(startedAtMs),
+ 1,
+ 321000,
+ toDbTimestamp(startedAtMs),
+ toDbTimestamp(sampleMs),
+ );
- INSERT INTO imm_session_telemetry (
- session_id,
- sample_ms,
- total_watched_ms,
- active_watched_ms,
- lines_seen,
- tokens_seen,
- cards_mined,
- lookup_count,
- lookup_hits,
- pause_count,
- pause_ms,
- seek_forward_count,
- seek_backward_count,
- media_buffer_events
- ) VALUES (
- 1,
- ${sampleMs},
- 5000,
- 4000,
- 12,
- 120,
- 2,
- 5,
- 3,
- 1,
- 250,
- 1,
- 0,
- 0
- );
- `);
+ db.prepare(
+ `
+ INSERT INTO imm_session_telemetry (
+ session_id,
+ sample_ms,
+ total_watched_ms,
+ active_watched_ms,
+ lines_seen,
+ tokens_seen,
+ cards_mined,
+ lookup_count,
+ lookup_hits,
+ pause_count,
+ pause_ms,
+ seek_forward_count,
+ seek_backward_count,
+ media_buffer_events
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `,
+ ).run(1, toDbTimestamp(sampleMs), 5000, 4000, 12, 120, 2, 5, 3, 1, 250, 1, 0, 0);
tracker.destroy();
tracker = new Ctor({ dbPath });
@@ -734,7 +727,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
`,
)
.get() as {
- ended_at_ms: number | null;
+ ended_at_ms: string | number | null;
status: number;
ended_media_ms: number | null;
active_watched_ms: number;
@@ -769,7 +762,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
.get() as { total: number } | null;
assert.ok(sessionRow);
- assert.ok(Number(sessionRow?.ended_at_ms ?? 0) >= sampleMs);
+ assert.equal(sessionRow?.ended_at_ms, toDbTimestamp(sampleMs));
assert.equal(sessionRow?.status, 2);
assert.equal(sessionRow?.ended_media_ms, 321_000);
assert.equal(sessionRow?.active_watched_ms, 4000);
diff --git a/src/core/services/immersion-tracker-service.ts b/src/core/services/immersion-tracker-service.ts
index 159f1b6c..276cd3d6 100644
--- a/src/core/services/immersion-tracker-service.ts
+++ b/src/core/services/immersion-tracker-service.ts
@@ -309,6 +309,9 @@ export class ImmersionTrackerService {
private readonly eventsRetentionMs: number;
private readonly telemetryRetentionMs: number;
private readonly sessionsRetentionMs: number;
+ private readonly eventsRetentionDays: number | null;
+ private readonly telemetryRetentionDays: number | null;
+ private readonly sessionsRetentionDays: number | null;
private readonly dailyRollupRetentionMs: number;
private readonly monthlyRollupRetentionMs: number;
private readonly vacuumIntervalMs: number;
@@ -365,46 +368,54 @@ export class ImmersionTrackerService {
);
const retention = policy.retention ?? {};
- const daysToRetentionMs = (
+ const daysToRetentionWindow = (
value: number | undefined,
- fallbackMs: number,
+ fallbackDays: number,
maxDays: number,
- ): number => {
- const fallbackDays = Math.floor(fallbackMs / 86_400_000);
+ ): { ms: number; days: number | null } => {
const resolvedDays = resolveBoundedInt(value, fallbackDays, 0, maxDays);
- return resolvedDays === 0 ? Number.POSITIVE_INFINITY : resolvedDays * 86_400_000;
+ return {
+ ms: resolvedDays === 0 ? Number.POSITIVE_INFINITY : resolvedDays * 86_400_000,
+ days: resolvedDays === 0 ? null : resolvedDays,
+ };
};
- this.eventsRetentionMs = daysToRetentionMs(
+ const eventsRetention = daysToRetentionWindow(
retention.eventsDays,
- DEFAULT_EVENTS_RETENTION_MS,
+ 7,
3650,
);
- this.telemetryRetentionMs = daysToRetentionMs(
+ const telemetryRetention = daysToRetentionWindow(
retention.telemetryDays,
- DEFAULT_TELEMETRY_RETENTION_MS,
+ 30,
3650,
);
- this.sessionsRetentionMs = daysToRetentionMs(
+ const sessionsRetention = daysToRetentionWindow(
retention.sessionsDays,
- DEFAULT_SESSIONS_RETENTION_MS,
+ 30,
3650,
);
- this.dailyRollupRetentionMs = daysToRetentionMs(
+ this.eventsRetentionMs = eventsRetention.ms;
+ this.eventsRetentionDays = eventsRetention.days;
+ this.telemetryRetentionMs = telemetryRetention.ms;
+ this.telemetryRetentionDays = telemetryRetention.days;
+ this.sessionsRetentionMs = sessionsRetention.ms;
+ this.sessionsRetentionDays = sessionsRetention.days;
+ this.dailyRollupRetentionMs = daysToRetentionWindow(
retention.dailyRollupsDays,
- DEFAULT_DAILY_ROLLUP_RETENTION_MS,
+ 365,
36500,
- );
- this.monthlyRollupRetentionMs = daysToRetentionMs(
+ ).ms;
+ this.monthlyRollupRetentionMs = daysToRetentionWindow(
retention.monthlyRollupsDays,
- DEFAULT_MONTHLY_ROLLUP_RETENTION_MS,
+ 5 * 365,
36500,
- );
- this.vacuumIntervalMs = daysToRetentionMs(
+ ).ms;
+ this.vacuumIntervalMs = daysToRetentionWindow(
retention.vacuumIntervalDays,
- DEFAULT_VACUUM_INTERVAL_MS,
+ 7,
3650,
- );
+ ).ms;
this.db = new Database(this.dbPath);
applyPragmas(this.db);
ensureSchema(this.db);
@@ -1604,6 +1615,9 @@ export class ImmersionTrackerService {
eventsRetentionMs: this.eventsRetentionMs,
telemetryRetentionMs: this.telemetryRetentionMs,
sessionsRetentionMs: this.sessionsRetentionMs,
+ eventsRetentionDays: this.eventsRetentionDays ?? undefined,
+ telemetryRetentionDays: this.telemetryRetentionDays ?? undefined,
+ sessionsRetentionDays: this.sessionsRetentionDays ?? undefined,
});
}
if (
diff --git a/src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts b/src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts
index 5b877967..dc78c125 100644
--- a/src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts
+++ b/src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts
@@ -50,6 +50,7 @@ import {
updateAnimeAnilistInfo,
upsertCoverArt,
} from '../query-maintenance.js';
+import { getLocalEpochDay } from '../query-shared.js';
import { EVENT_CARD_MINED, EVENT_SUBTITLE_LINE, SOURCE_TYPE_LOCAL } from '../types.js';
function makeDbPath(): string {
@@ -360,9 +361,6 @@ test('split library helpers return anime/media session and analytics rows', () =
try {
const now = new Date();
- const todayLocalDay = Math.floor(
- new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 86_400_000,
- );
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Library Anime',
canonicalTitle: 'Library Anime',
@@ -398,6 +396,7 @@ test('split library helpers return anime/media session and analytics rows', () =
0,
).getTime();
const sessionId = startSessionRecord(db, videoId, startedAtMs).sessionId;
+ const todayLocalDay = getLocalEpochDay(db, startedAtMs);
finalizeSessionMetrics(db, sessionId, startedAtMs, {
endedAtMs: startedAtMs + 55_000,
totalWatchedMs: 55_000,
diff --git a/src/core/services/immersion-tracker/__tests__/query.test.ts b/src/core/services/immersion-tracker/__tests__/query.test.ts
index de56cec9..dc8bc45e 100644
--- a/src/core/services/immersion-tracker/__tests__/query.test.ts
+++ b/src/core/services/immersion-tracker/__tests__/query.test.ts
@@ -37,6 +37,11 @@ import {
getWordOccurrences,
upsertCoverArt,
} from '../query.js';
+import {
+ getShiftedLocalDaySec,
+ getStartOfLocalDayTimestamp,
+ toDbTimestamp,
+} from '../query-shared.js';
import {
SOURCE_TYPE_LOCAL,
SOURCE_TYPE_REMOTE,
@@ -81,29 +86,13 @@ function cleanupDbPath(dbPath: string): void {
}
}
-function withMockDate(fixedDate: Date, run: (realDate: typeof Date) => T): T {
- const realDate = Date;
- const fixedDateMs = fixedDate.getTime();
-
- class MockDate extends Date {
- constructor(...args: any[]) {
- if (args.length === 0) {
- super(fixedDateMs);
- } else {
- super(...(args as [any?, any?, any?, any?, any?, any?, any?]));
- }
- }
-
- static override now(): number {
- return fixedDateMs;
- }
- }
-
- globalThis.Date = MockDate as DateConstructor;
+function withMockNowMs(fixedDateMs: string | number, run: () => T): T {
+ const previousNowMs = globalThis.__subminerTestNowMs;
+ globalThis.__subminerTestNowMs = fixedDateMs;
try {
- return run(realDate);
+ return run();
} finally {
- globalThis.Date = realDate;
+ globalThis.__subminerTestNowMs = previousNowMs;
}
}
@@ -613,7 +602,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
] as const) {
stmts.telemetryInsertStmt.run(
sessionId,
- startedAtMs + 60_000,
+ `${startedAtMs + 60_000}`,
activeWatchedMs,
activeWatchedMs,
10,
@@ -626,8 +615,8 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
0,
0,
0,
- startedAtMs + 60_000,
- startedAtMs + 60_000,
+ `${startedAtMs + 60_000}`,
+ `${startedAtMs + 60_000}`,
);
db.prepare(
@@ -644,7 +633,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
WHERE session_id = ?
`,
).run(
- startedAtMs + activeWatchedMs,
+ `${startedAtMs + activeWatchedMs}`,
activeWatchedMs,
activeWatchedMs,
10,
@@ -687,8 +676,8 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
'名詞',
null,
null,
- Math.floor(dayOneStart / 1000),
- Math.floor(dayTwoStart / 1000),
+ String(Math.floor(dayOneStart / 1000)),
+ String(Math.floor(dayTwoStart / 1000)),
);
const dashboard = getTrendsDashboard(db, 'all', 'day');
@@ -743,18 +732,51 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
parseMetadataJson: null,
});
- const beforeMidnight = new Date(2026, 2, 1, 23, 30).getTime();
- const afterMidnight = new Date(2026, 2, 2, 0, 30).getTime();
- const firstSessionId = startSessionRecord(db, videoId, beforeMidnight).sessionId;
- const secondSessionId = startSessionRecord(db, videoId, afterMidnight).sessionId;
+ const boundaryMs = BigInt(getStartOfLocalDayTimestamp(db, '1772436600000'));
+ const beforeMidnight = (boundaryMs - 1n).toString();
+ const afterMidnight = (boundaryMs + 1n).toString();
+ const firstSessionId = 1;
+ const secondSessionId = 2;
+ const insertSession = db.prepare(
+ `
+ INSERT INTO imm_sessions (
+ session_id,
+ session_uuid,
+ video_id,
+ started_at_ms,
+ status,
+ CREATED_DATE,
+ LAST_UPDATE_DATE
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
+ `,
+ );
+ insertSession.run(
+ firstSessionId,
+ '11111111-1111-1111-1111-111111111111',
+ videoId,
+ beforeMidnight,
+ 1,
+ beforeMidnight,
+ beforeMidnight,
+ );
+ insertSession.run(
+ secondSessionId,
+ '22222222-2222-2222-2222-222222222222',
+ videoId,
+ afterMidnight,
+ 1,
+ afterMidnight,
+ afterMidnight,
+ );
for (const [sessionId, startedAtMs, tokensSeen, lookupCount] of [
[firstSessionId, beforeMidnight, 100, 4],
[secondSessionId, afterMidnight, 120, 6],
] as const) {
+ const endedAtMs = (BigInt(startedAtMs) + 60_000n).toString();
stmts.telemetryInsertStmt.run(
sessionId,
- startedAtMs + 60_000,
+ endedAtMs,
60_000,
60_000,
1,
@@ -767,8 +789,8 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
0,
0,
0,
- startedAtMs + 60_000,
- startedAtMs + 60_000,
+ endedAtMs,
+ endedAtMs,
);
db.prepare(
`
@@ -787,7 +809,7 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
WHERE session_id = ?
`,
).run(
- startedAtMs + 60_000,
+ endedAtMs,
60_000,
60_000,
1,
@@ -795,7 +817,7 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
lookupCount,
lookupCount,
lookupCount,
- startedAtMs + 60_000,
+ endedAtMs,
sessionId,
);
}
@@ -816,7 +838,7 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
- withMockDate(new Date(2026, 2, 1, 12, 0, 0), (RealDate) => {
+ withMockNowMs('1772395200000', () => {
try {
ensureSchema(db);
const stmts = createTrackerPreparedStatements(db);
@@ -862,18 +884,50 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
parseMetadataJson: null,
});
- const febStartedAtMs = new RealDate(2026, 1, 15, 20, 0, 0).getTime();
- const marStartedAtMs = new RealDate(2026, 2, 1, 9, 0, 0).getTime();
- const febSessionId = startSessionRecord(db, febVideoId, febStartedAtMs).sessionId;
- const marSessionId = startSessionRecord(db, marVideoId, marStartedAtMs).sessionId;
+ const febStartedAtMs = '1771214400000';
+ const marStartedAtMs = '1772384400000';
+ const febSessionId = 1;
+ const marSessionId = 2;
+ const insertSession = db.prepare(
+ `
+ INSERT INTO imm_sessions (
+ session_id,
+ session_uuid,
+ video_id,
+ started_at_ms,
+ status,
+ CREATED_DATE,
+ LAST_UPDATE_DATE
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
+ `,
+ );
+ insertSession.run(
+ febSessionId,
+ '33333333-3333-3333-3333-333333333333',
+ febVideoId,
+ febStartedAtMs,
+ 1,
+ febStartedAtMs,
+ febStartedAtMs,
+ );
+ insertSession.run(
+ marSessionId,
+ '44444444-4444-4444-4444-444444444444',
+ marVideoId,
+ marStartedAtMs,
+ 1,
+ marStartedAtMs,
+ marStartedAtMs,
+ );
for (const [sessionId, startedAtMs, tokensSeen, cardsMined, yomitanLookupCount] of [
[febSessionId, febStartedAtMs, 100, 2, 3],
[marSessionId, marStartedAtMs, 120, 4, 5],
] as const) {
+ const endedAtMs = (BigInt(startedAtMs) + 60_000n).toString();
stmts.telemetryInsertStmt.run(
sessionId,
- startedAtMs + 60_000,
+ endedAtMs,
30 * 60_000,
30 * 60_000,
4,
@@ -886,8 +940,8 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
0,
0,
0,
- startedAtMs + 60_000,
- startedAtMs + 60_000,
+ endedAtMs,
+ endedAtMs,
);
db.prepare(
`
@@ -907,7 +961,7 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
WHERE session_id = ?
`,
).run(
- startedAtMs + 60_000,
+ endedAtMs,
30 * 60_000,
30 * 60_000,
4,
@@ -916,7 +970,7 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
yomitanLookupCount,
yomitanLookupCount,
yomitanLookupCount,
- startedAtMs + 60_000,
+ endedAtMs,
sessionId,
);
}
@@ -937,10 +991,8 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
- const febEpochDay = Math.floor(febStartedAtMs / 86_400_000);
- const marEpochDay = Math.floor(marStartedAtMs / 86_400_000);
- insertDailyRollup.run(febEpochDay, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
- insertDailyRollup.run(marEpochDay, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
+ insertDailyRollup.run(20500, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
+ insertDailyRollup.run(20513, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
insertMonthlyRollup.run(202602, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
insertMonthlyRollup.run(202603, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
@@ -958,8 +1010,8 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
'名詞',
'',
'',
- Math.floor(febStartedAtMs / 1000),
- Math.floor(febStartedAtMs / 1000),
+ (BigInt(febStartedAtMs) / 1000n).toString(),
+ (BigInt(febStartedAtMs) / 1000n).toString(),
1,
);
db.prepare(
@@ -976,8 +1028,8 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
'名詞',
'',
'',
- Math.floor(marStartedAtMs / 1000),
- Math.floor(marStartedAtMs / 1000),
+ (BigInt(marStartedAtMs) / 1000n).toString(),
+ (BigInt(marStartedAtMs) / 1000n).toString(),
1,
);
@@ -1077,7 +1129,7 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', ()
const dbPath = makeDbPath();
const db = new Database(dbPath);
- withMockDate(new Date(2026, 2, 15, 12, 0, 0), (RealDate) => {
+ withMockNowMs('1773601200000', () => {
try {
ensureSchema(db);
@@ -1088,12 +1140,9 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', ()
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
- const justBeforeWeekBoundary = Math.floor(
- new RealDate(2026, 2, 7, 23, 30, 0).getTime() / 1000,
- );
- const justAfterWeekBoundary = Math.floor(
- new RealDate(2026, 2, 8, 0, 30, 0).getTime() / 1000,
- );
+ const weekBoundarySec = getShiftedLocalDaySec(db, '1773601200000', -7);
+ const justBeforeWeekBoundary = weekBoundarySec - 1;
+ const justAfterWeekBoundary = weekBoundarySec + 1;
insertWord.run(
'境界前',
'境界前',
@@ -1102,8 +1151,8 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', ()
'名詞',
'',
'',
- justBeforeWeekBoundary,
- justBeforeWeekBoundary,
+ String(justBeforeWeekBoundary),
+ String(justBeforeWeekBoundary),
1,
);
insertWord.run(
@@ -1114,8 +1163,8 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', ()
'名詞',
'',
'',
- justAfterWeekBoundary,
- justAfterWeekBoundary,
+ String(justAfterWeekBoundary),
+ String(justAfterWeekBoundary),
1,
);
@@ -1134,38 +1183,70 @@ test('getQueryHints counts new words by distinct headword first-seen time', () =
try {
ensureSchema(db);
+ withMockNowMs('1773601200000', () => {
+ const todayStartSec = 1_773_558_000;
+ const oneHourAgo = todayStartSec + 3_600;
+ const twoDaysAgo = todayStartSec - 2 * 86_400;
- const now = new Date();
- const todayStartSec =
- new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000;
- const oneHourAgo = todayStartSec + 3_600;
- const twoDaysAgo = todayStartSec - 2 * 86_400;
+ db.prepare(
+ `
+ INSERT INTO imm_words (
+ headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `,
+ ).run(
+ '知る',
+ '知った',
+ 'しった',
+ 'verb',
+ '動詞',
+ '',
+ '',
+ String(oneHourAgo),
+ String(oneHourAgo),
+ 1,
+ );
+ db.prepare(
+ `
+ INSERT INTO imm_words (
+ headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `,
+ ).run(
+ '知る',
+ '知っている',
+ 'しっている',
+ 'verb',
+ '動詞',
+ '',
+ '',
+ String(oneHourAgo),
+ String(oneHourAgo),
+ 1,
+ );
+ db.prepare(
+ `
+ INSERT INTO imm_words (
+ headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `,
+ ).run(
+ '猫',
+ '猫',
+ 'ねこ',
+ 'noun',
+ '名詞',
+ '',
+ '',
+ String(twoDaysAgo),
+ String(twoDaysAgo),
+ 1,
+ );
- db.prepare(
- `
- INSERT INTO imm_words (
- headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `,
- ).run('知る', '知った', 'しった', 'verb', '動詞', '', '', oneHourAgo, oneHourAgo, 1);
- db.prepare(
- `
- INSERT INTO imm_words (
- headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `,
- ).run('知る', '知っている', 'しっている', 'verb', '動詞', '', '', oneHourAgo, oneHourAgo, 1);
- db.prepare(
- `
- INSERT INTO imm_words (
- headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `,
- ).run('猫', '猫', 'ねこ', 'noun', '名詞', '', '', twoDaysAgo, twoDaysAgo, 1);
-
- const hints = getQueryHints(db);
- assert.equal(hints.newWordsToday, 1);
- assert.equal(hints.newWordsThisWeek, 2);
+ const hints = getQueryHints(db);
+ assert.equal(hints.newWordsToday, 1);
+ assert.equal(hints.newWordsThisWeek, 2);
+ });
} finally {
db.close();
cleanupDbPath(dbPath);
@@ -2020,7 +2101,7 @@ test('getSessionWordsByLine joins word occurrences through imm_words.id', () =>
try {
ensureSchema(db);
const stmts = createTrackerPreparedStatements(db);
- const startedAtMs = Date.UTC(2025, 0, 1, 12, 0, 0);
+ const startedAtMs = 1_735_732_800_000;
const videoId = getOrCreateVideoRecord(db, '/tmp/session-words-by-line.mkv', {
canonicalTitle: 'Episode',
sourcePath: '/tmp/session-words-by-line.mkv',
diff --git a/src/core/services/immersion-tracker/lifetime.ts b/src/core/services/immersion-tracker/lifetime.ts
index 82c5c8ad..e0aac328 100644
--- a/src/core/services/immersion-tracker/lifetime.ts
+++ b/src/core/services/immersion-tracker/lifetime.ts
@@ -1,6 +1,7 @@
import type { DatabaseSync } from './sqlite';
import { finalizeSessionRecord } from './session';
import { nowMs } from './time';
+import { toDbTimestamp } from './query-shared';
import type { LifetimeRebuildSummary, SessionState } from './types';
interface TelemetryRow {
@@ -41,8 +42,8 @@ interface LifetimeAnimeStateRow {
interface RetainedSessionRow {
sessionId: number;
videoId: number;
- startedAtMs: number;
- endedAtMs: number;
+ startedAtMs: number | string;
+ endedAtMs: number | string;
lastMediaMs: number | null;
totalWatchedMs: number;
activeWatchedMs: number;
@@ -65,25 +66,29 @@ function hasRetainedPriorSession(
startedAtMs: number,
currentSessionId: number,
): boolean {
- return (
- Number(
- (
- db
- .prepare(
- `
- SELECT COUNT(*) AS count
- FROM imm_sessions
- WHERE video_id = ?
- AND (
- started_at_ms < ?
- OR (started_at_ms = ? AND session_id < ?)
- )
- `,
+ const row = db
+ .prepare(
+ `
+ SELECT 1 AS found
+ FROM imm_sessions
+ WHERE video_id = ?
+ AND (
+ CAST(started_at_ms AS REAL) < CAST(? AS REAL)
+ OR (
+ CAST(started_at_ms AS REAL) = CAST(? AS REAL)
+ AND session_id < ?
)
- .get(videoId, startedAtMs, startedAtMs, currentSessionId) as ExistenceRow | null
- )?.count ?? 0,
- ) > 0
- );
+ )
+ LIMIT 1
+ `,
+ )
+ .get(
+ videoId,
+ toDbTimestamp(startedAtMs),
+ toDbTimestamp(startedAtMs),
+ currentSessionId,
+ ) as { found: number } | null;
+ return Boolean(row);
}
function isFirstSessionForLocalDay(
@@ -91,23 +96,37 @@ function isFirstSessionForLocalDay(
currentSessionId: number,
startedAtMs: number,
): boolean {
- return (
- (
- db
- .prepare(
- `
- SELECT COUNT(*) AS count
+ const row = db
+ .prepare(
+ `
+ SELECT 1 AS found
FROM imm_sessions
- WHERE date(started_at_ms / 1000, 'unixepoch', 'localtime') = date(? / 1000, 'unixepoch', 'localtime')
+ WHERE session_id != ?
+ AND CAST(
+ julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5
+ AS INTEGER
+ ) = CAST(
+ julianday(CAST(? AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5
+ AS INTEGER
+ )
AND (
- started_at_ms < ?
- OR (started_at_ms = ? AND session_id < ?)
+ CAST(started_at_ms AS REAL) < CAST(? AS REAL)
+ OR (
+ CAST(started_at_ms AS REAL) = CAST(? AS REAL)
+ AND session_id < ?
+ )
)
- `,
- )
- .get(startedAtMs, startedAtMs, startedAtMs, currentSessionId) as ExistenceRow | null
- )?.count === 0
- );
+ LIMIT 1
+ `,
+ )
+ .get(
+ currentSessionId,
+ toDbTimestamp(startedAtMs),
+ toDbTimestamp(startedAtMs),
+ toDbTimestamp(startedAtMs),
+ currentSessionId,
+ ) as { found: number } | null;
+ return !row;
}
function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void {
@@ -131,14 +150,14 @@ function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void {
LAST_UPDATE_DATE = ?
WHERE global_id = 1
`,
- ).run(nowMs, nowMs);
+ ).run(toDbTimestamp(nowMs), toDbTimestamp(nowMs));
}
function rebuildLifetimeSummariesInternal(
db: DatabaseSync,
rebuiltAtMs: number,
): LifetimeRebuildSummary {
- const sessions = db
+ const rows = db
.prepare(
`
SELECT
@@ -146,6 +165,7 @@ function rebuildLifetimeSummariesInternal(
video_id AS videoId,
started_at_ms AS startedAtMs,
ended_at_ms AS endedAtMs,
+ ended_media_ms AS lastMediaMs,
total_watched_ms AS totalWatchedMs,
active_watched_ms AS activeWatchedMs,
lines_seen AS linesSeen,
@@ -164,7 +184,19 @@ function rebuildLifetimeSummariesInternal(
ORDER BY started_at_ms ASC, session_id ASC
`,
)
- .all() as RetainedSessionRow[];
+ .all() as Array<
+ Omit & {
+ startedAtMs: number | string;
+ endedAtMs: number | string;
+ lastMediaMs: number | string | null;
+ }
+ >;
+ const sessions = rows.map((row) => ({
+ ...row,
+ startedAtMs: row.startedAtMs,
+ endedAtMs: row.endedAtMs,
+ lastMediaMs: row.lastMediaMs === null ? null : Number(row.lastMediaMs),
+ })) as RetainedSessionRow[];
resetLifetimeSummaries(db, rebuiltAtMs);
for (const session of sessions) {
@@ -181,9 +213,9 @@ function toRebuildSessionState(row: RetainedSessionRow): SessionState {
return {
sessionId: row.sessionId,
videoId: row.videoId,
- startedAtMs: row.startedAtMs,
+ startedAtMs: row.startedAtMs as number,
currentLineIndex: 0,
- lastWallClockMs: row.endedAtMs,
+ lastWallClockMs: row.endedAtMs as number,
lastMediaMs: row.lastMediaMs,
lastPauseStartMs: null,
isPaused: false,
@@ -206,7 +238,7 @@ function toRebuildSessionState(row: RetainedSessionRow): SessionState {
}
function getRetainedStaleActiveSessions(db: DatabaseSync): RetainedSessionRow[] {
- return db
+ const rows = db
.prepare(
`
SELECT
@@ -241,20 +273,32 @@ function getRetainedStaleActiveSessions(db: DatabaseSync): RetainedSessionRow[]
ORDER BY s.started_at_ms ASC, s.session_id ASC
`,
)
- .all() as RetainedSessionRow[];
+ .all() as Array<
+ Omit & {
+ startedAtMs: number | string;
+ endedAtMs: number | string;
+ lastMediaMs: number | string | null;
+ }
+ >;
+ return rows.map((row) => ({
+ ...row,
+ startedAtMs: row.startedAtMs,
+ endedAtMs: row.endedAtMs,
+ lastMediaMs: row.lastMediaMs === null ? null : Number(row.lastMediaMs),
+ })) as RetainedSessionRow[];
}
function upsertLifetimeMedia(
db: DatabaseSync,
videoId: number,
- nowMs: number,
+ nowMs: number | string,
activeMs: number,
cardsMined: number,
linesSeen: number,
tokensSeen: number,
completed: number,
- startedAtMs: number,
- endedAtMs: number,
+ startedAtMs: number | string,
+ endedAtMs: number | string,
): void {
db.prepare(
`
@@ -310,15 +354,15 @@ function upsertLifetimeMedia(
function upsertLifetimeAnime(
db: DatabaseSync,
animeId: number,
- nowMs: number,
+ nowMs: number | string,
activeMs: number,
cardsMined: number,
linesSeen: number,
tokensSeen: number,
episodesStartedDelta: number,
episodesCompletedDelta: number,
- startedAtMs: number,
- endedAtMs: number,
+ startedAtMs: number | string,
+ endedAtMs: number | string,
): void {
db.prepare(
`
@@ -377,8 +421,9 @@ function upsertLifetimeAnime(
export function applySessionLifetimeSummary(
db: DatabaseSync,
session: SessionState,
- endedAtMs: number,
+ endedAtMs: number | string,
): void {
+ const updatedAtMs = toDbTimestamp(nowMs());
const applyResult = db
.prepare(
`
@@ -393,7 +438,7 @@ export function applySessionLifetimeSummary(
ON CONFLICT(session_id) DO NOTHING
`,
)
- .run(session.sessionId, endedAtMs, nowMs(), nowMs());
+ .run(session.sessionId, endedAtMs, updatedAtMs, updatedAtMs);
if ((applyResult.changes ?? 0) <= 0) {
return;
@@ -468,7 +513,6 @@ export function applySessionLifetimeSummary(
? 1
: 0;
- const updatedAtMs = nowMs();
db.prepare(
`
UPDATE imm_lifetime_global
diff --git a/src/core/services/immersion-tracker/maintenance.test.ts b/src/core/services/immersion-tracker/maintenance.test.ts
index cdb62258..ab9af881 100644
--- a/src/core/services/immersion-tracker/maintenance.test.ts
+++ b/src/core/services/immersion-tracker/maintenance.test.ts
@@ -11,6 +11,7 @@ import {
toMonthKey,
} from './maintenance';
import { ensureSchema } from './storage';
+import { toDbTimestamp } from './query-shared';
function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-maintenance-test-'));
@@ -39,18 +40,18 @@ test('pruneRawRetention uses session retention separately from telemetry retenti
INSERT INTO imm_videos (
video_id, video_key, canonical_title, source_type, duration_ms, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (
- 1, 'local:/tmp/video.mkv', 'Video', 1, 0, ${nowMs}, ${nowMs}
+ 1, 'local:/tmp/video.mkv', 'Video', 1, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'
);
INSERT INTO imm_sessions (
session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE
) VALUES
- (1, 'session-1', 1, ${staleEndedAtMs - 1_000}, ${staleEndedAtMs}, 2, ${staleEndedAtMs}, ${staleEndedAtMs}),
- (2, 'session-2', 1, ${keptEndedAtMs - 1_000}, ${keptEndedAtMs}, 2, ${keptEndedAtMs}, ${keptEndedAtMs});
+ (1, 'session-1', 1, '${toDbTimestamp(staleEndedAtMs - 1_000)}', '${toDbTimestamp(staleEndedAtMs)}', 2, '${toDbTimestamp(staleEndedAtMs)}', '${toDbTimestamp(staleEndedAtMs)}'),
+ (2, 'session-2', 1, '${toDbTimestamp(keptEndedAtMs - 1_000)}', '${toDbTimestamp(keptEndedAtMs)}', 2, '${toDbTimestamp(keptEndedAtMs)}', '${toDbTimestamp(keptEndedAtMs)}');
INSERT INTO imm_session_telemetry (
session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
) VALUES
- (1, ${nowMs - 200_000_000}, 0, 0, ${nowMs}, ${nowMs}),
- (2, ${nowMs - 10_000_000}, 0, 0, ${nowMs}, ${nowMs});
+ (1, '${toDbTimestamp(nowMs - 200_000_000)}', 0, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'),
+ (2, '${toDbTimestamp(nowMs - 10_000_000)}', 0, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}');
`);
const result = pruneRawRetention(db, nowMs, {
@@ -94,22 +95,22 @@ test('pruneRawRetention skips disabled retention windows', () => {
INSERT INTO imm_videos (
video_id, video_key, canonical_title, source_type, duration_ms, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (
- 1, 'local:/tmp/video.mkv', 'Video', 1, 0, ${nowMs}, ${nowMs}
+ 1, 'local:/tmp/video.mkv', 'Video', 1, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'
);
INSERT INTO imm_sessions (
session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (
- 1, 'session-1', 1, ${nowMs - 1_000}, ${nowMs - 500}, 2, ${nowMs}, ${nowMs}
+ 1, 'session-1', 1, '${toDbTimestamp(nowMs - 1_000)}', '${toDbTimestamp(nowMs - 500)}', 2, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'
);
INSERT INTO imm_session_telemetry (
session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (
- 1, ${nowMs - 2_000}, 0, 0, ${nowMs}, ${nowMs}
+ 1, '${toDbTimestamp(nowMs - 2_000)}', 0, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'
);
INSERT INTO imm_session_events (
session_id, event_type, ts_ms, payload_json, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (
- 1, 1, ${nowMs - 3_000}, '{}', ${nowMs}, ${nowMs}
+ 1, 1, '${toDbTimestamp(nowMs - 3_000)}', '{}', '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'
);
`);
@@ -161,17 +162,17 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
INSERT INTO imm_videos (
video_id, video_key, canonical_title, source_type, duration_ms, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (
- 1, 'local:/tmp/video.mkv', 'Video', 1, 0, ${nowMs}, ${nowMs}
+ 1, 'local:/tmp/video.mkv', 'Video', 1, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'
);
INSERT INTO imm_sessions (
session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (
- 1, 'session-1', 1, ${nowMs - 200_000_000}, ${nowMs - 199_999_000}, 2, ${nowMs}, ${nowMs}
+ 1, 'session-1', 1, '${toDbTimestamp(nowMs - 200_000_000)}', '${toDbTimestamp(nowMs - 199_999_000)}', 2, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'
);
INSERT INTO imm_session_telemetry (
session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (
- 1, ${nowMs - 200_000_000}, 0, 0, ${nowMs}, ${nowMs}
+ 1, '${toDbTimestamp(nowMs - 200_000_000)}', 0, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'
);
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
@@ -183,7 +184,7 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (
- ${oldMonth}, 1, 1, 10, 1, 1, 1, ${nowMs}, ${nowMs}
+ ${oldMonth}, 1, 1, 10, 1, 1, 1, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'
);
`);
diff --git a/src/core/services/immersion-tracker/maintenance.ts b/src/core/services/immersion-tracker/maintenance.ts
index 1ed9bc9c..d225f0d2 100644
--- a/src/core/services/immersion-tracker/maintenance.ts
+++ b/src/core/services/immersion-tracker/maintenance.ts
@@ -1,13 +1,13 @@
import type { DatabaseSync } from './sqlite';
import { nowMs } from './time';
-import { toDbMs } from './query-shared';
+import { subtractDbTimestamp, toDbTimestamp } from './query-shared';
const ROLLUP_STATE_KEY = 'last_rollup_sample_ms';
const DAILY_MS = 86_400_000;
const ZERO_ID = 0;
interface RollupStateRow {
- state_value: number;
+ state_value: string;
}
interface RollupGroupRow {
@@ -51,12 +51,25 @@ export function pruneRawRetention(
eventsRetentionMs: number;
telemetryRetentionMs: number;
sessionsRetentionMs: number;
+ eventsRetentionDays?: number;
+ telemetryRetentionDays?: number;
+ sessionsRetentionDays?: number;
},
): RawRetentionResult {
+ const resolveCutoff = (
+ retentionMs: number,
+ retentionDays: number | undefined,
+ ): string => {
+ if (retentionDays !== undefined) {
+ return subtractDbTimestamp(currentMs, BigInt(retentionDays) * 86_400_000n);
+ }
+ return subtractDbTimestamp(currentMs, retentionMs);
+ };
+
const deletedSessionEvents = Number.isFinite(policy.eventsRetentionMs)
? (
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(
- toDbMs(currentMs - policy.eventsRetentionMs),
+ resolveCutoff(policy.eventsRetentionMs, policy.eventsRetentionDays),
) as { changes: number }
).changes
: 0;
@@ -64,14 +77,18 @@ export function pruneRawRetention(
? (
db
.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`)
- .run(toDbMs(currentMs - policy.telemetryRetentionMs)) as { changes: number }
+ .run(resolveCutoff(policy.telemetryRetentionMs, policy.telemetryRetentionDays)) as {
+ changes: number;
+ }
).changes
: 0;
const deletedEndedSessions = Number.isFinite(policy.sessionsRetentionMs)
? (
db
.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`)
- .run(toDbMs(currentMs - policy.sessionsRetentionMs)) as { changes: number }
+ .run(resolveCutoff(policy.sessionsRetentionMs, policy.sessionsRetentionDays)) as {
+ changes: number;
+ }
).changes
: 0;
@@ -115,14 +132,14 @@ export function pruneRollupRetention(
};
}
-function getLastRollupSampleMs(db: DatabaseSync): number {
+function getLastRollupSampleMs(db: DatabaseSync): string {
const row = db
.prepare(`SELECT state_value FROM imm_rollup_state WHERE state_key = ? LIMIT 1`)
.get(ROLLUP_STATE_KEY) as unknown as RollupStateRow | null;
- return row ? Number(row.state_value) : ZERO_ID;
+ return row ? row.state_value : String(ZERO_ID);
}
-function setLastRollupSampleMs(db: DatabaseSync, sampleMs: number | bigint): void {
+function setLastRollupSampleMs(db: DatabaseSync, sampleMs: number | bigint | string): void {
db.prepare(
`INSERT INTO imm_rollup_state (state_key, state_value)
VALUES (?, ?)
@@ -141,7 +158,7 @@ function resetRollups(db: DatabaseSync): void {
function upsertDailyRollupsForGroups(
db: DatabaseSync,
groups: Array<{ rollupDay: number; videoId: number }>,
- rollupNowMs: bigint,
+ rollupNowMs: number | string,
): void {
if (groups.length === 0) {
return;
@@ -217,7 +234,7 @@ function upsertDailyRollupsForGroups(
function upsertMonthlyRollupsForGroups(
db: DatabaseSync,
groups: Array<{ rollupMonth: number; videoId: number }>,
- rollupNowMs: bigint,
+ rollupNowMs: number | string,
): void {
if (groups.length === 0) {
return;
@@ -268,7 +285,7 @@ function upsertMonthlyRollupsForGroups(
function getAffectedRollupGroups(
db: DatabaseSync,
- lastRollupSampleMs: number,
+ lastRollupSampleMs: number | string,
): Array<{ rollupDay: number; rollupMonth: number; videoId: number }> {
return (
db
@@ -321,7 +338,7 @@ export function runRollupMaintenance(db: DatabaseSync, forceRebuild = false): vo
return;
}
- const rollupNowMs = toDbMs(nowMs());
+ const rollupNowMs = toDbTimestamp(nowMs());
const lastRollupSampleMs = getLastRollupSampleMs(db);
const maxSampleRow = db
@@ -356,7 +373,7 @@ export function runRollupMaintenance(db: DatabaseSync, forceRebuild = false): vo
try {
upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs);
upsertMonthlyRollupsForGroups(db, monthlyGroups, rollupNowMs);
- setLastRollupSampleMs(db, toDbMs(maxSampleRow.maxSampleMs ?? ZERO_ID));
+ setLastRollupSampleMs(db, toDbTimestamp(maxSampleRow.maxSampleMs ?? ZERO_ID));
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
@@ -365,7 +382,7 @@ export function runRollupMaintenance(db: DatabaseSync, forceRebuild = false): vo
}
export function rebuildRollupsInTransaction(db: DatabaseSync): void {
- const rollupNowMs = toDbMs(nowMs());
+ const rollupNowMs = toDbTimestamp(nowMs());
const maxSampleRow = db
.prepare('SELECT MAX(sample_ms) AS maxSampleMs FROM imm_session_telemetry')
.get() as unknown as RollupTelemetryResult | null;
@@ -377,7 +394,7 @@ export function rebuildRollupsInTransaction(db: DatabaseSync): void {
const affectedGroups = getAffectedRollupGroups(db, ZERO_ID);
if (affectedGroups.length === 0) {
- setLastRollupSampleMs(db, toDbMs(maxSampleRow.maxSampleMs ?? ZERO_ID));
+ setLastRollupSampleMs(db, toDbTimestamp(maxSampleRow.maxSampleMs ?? ZERO_ID));
return;
}
@@ -396,7 +413,7 @@ export function rebuildRollupsInTransaction(db: DatabaseSync): void {
upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs);
upsertMonthlyRollupsForGroups(db, monthlyGroups, rollupNowMs);
- setLastRollupSampleMs(db, toDbMs(maxSampleRow.maxSampleMs ?? ZERO_ID));
+ setLastRollupSampleMs(db, toDbTimestamp(maxSampleRow.maxSampleMs ?? ZERO_ID));
}
export function runOptimizeMaintenance(db: DatabaseSync): void {
diff --git a/src/core/services/immersion-tracker/query-lexical.ts b/src/core/services/immersion-tracker/query-lexical.ts
index 004a7139..5e6ac68d 100644
--- a/src/core/services/immersion-tracker/query-lexical.ts
+++ b/src/core/services/immersion-tracker/query-lexical.ts
@@ -12,6 +12,7 @@ import type {
WordDetailRow,
WordOccurrenceRow,
} from './types';
+import { fromDbTimestamp } from './query-shared';
export function getVocabularyStats(
db: DatabaseSync,
@@ -134,7 +135,11 @@ export function getSessionEvents(
SELECT event_type AS eventType, ts_ms AS tsMs, payload_json AS payload
FROM imm_session_events WHERE session_id = ? ORDER BY ts_ms ASC LIMIT ?
`);
- return stmt.all(sessionId, limit) as SessionEventRow[];
+ const rows = stmt.all(sessionId, limit) as Array;
+ return rows.map((row) => ({
+ ...row,
+ tsMs: fromDbTimestamp(row.tsMs) ?? 0,
+ }));
}
const placeholders = eventTypes.map(() => '?').join(', ');
@@ -145,7 +150,13 @@ export function getSessionEvents(
ORDER BY ts_ms ASC
LIMIT ?
`);
- return stmt.all(sessionId, ...eventTypes, limit) as SessionEventRow[];
+ const rows = stmt.all(sessionId, ...eventTypes, limit) as Array;
+ return rows.map((row) => ({
+ ...row,
+ tsMs: fromDbTimestamp(row.tsMs) ?? 0,
+ }));
}
export function getWordDetail(db: DatabaseSync, wordId: number): WordDetailRow | null {
diff --git a/src/core/services/immersion-tracker/query-library.ts b/src/core/services/immersion-tracker/query-library.ts
index cd03d6b6..13df7d1d 100644
--- a/src/core/services/immersion-tracker/query-library.ts
+++ b/src/core/services/immersion-tracker/query-library.ts
@@ -16,10 +16,10 @@ import type {
StreakCalendarRow,
WatchTimePerAnimeRow,
} from './types';
-import { ACTIVE_SESSION_METRICS_CTE, resolvedCoverBlobExpr } from './query-shared';
+import { ACTIVE_SESSION_METRICS_CTE, fromDbTimestamp, resolvedCoverBlobExpr } from './query-shared';
export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
- return db
+ const rows = db
.prepare(
`
SELECT
@@ -40,11 +40,15 @@ export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
ORDER BY totalActiveMs DESC, lm.last_watched_ms DESC, canonicalTitle ASC
`,
)
- .all() as unknown as AnimeLibraryRow[];
+ .all() as Array;
+ return rows.map((row) => ({
+ ...row,
+ lastWatchedMs: fromDbTimestamp(row.lastWatchedMs) ?? 0,
+ }));
}
export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRow | null {
- return db
+ const row = db
.prepare(
`
${ACTIVE_SESSION_METRICS_CTE}
@@ -75,7 +79,13 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo
GROUP BY a.anime_id
`,
)
- .get(animeId) as unknown as AnimeDetailRow | null;
+ .get(animeId) as (AnimeDetailRow & { lastWatchedMs: number | string }) | null;
+ return row
+ ? {
+ ...row,
+ lastWatchedMs: fromDbTimestamp(row.lastWatchedMs) ?? 0,
+ }
+ : null;
}
export function getAnimeAnilistEntries(db: DatabaseSync, animeId: number): AnimeAnilistEntryRow[] {
@@ -98,7 +108,7 @@ export function getAnimeAnilistEntries(db: DatabaseSync, animeId: number): Anime
}
export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisodeRow[] {
- return db
+ const rows = db
.prepare(
`
${ACTIVE_SESSION_METRICS_CTE}
@@ -168,11 +178,21 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod
v.video_id ASC
`,
)
- .all(animeId) as unknown as AnimeEpisodeRow[];
+ .all(animeId) as Array<
+ AnimeEpisodeRow & {
+ endedMediaMs: number | string | null;
+ lastWatchedMs: number | string;
+ }
+ >;
+ return rows.map((row) => ({
+ ...row,
+ endedMediaMs: fromDbTimestamp(row.endedMediaMs),
+ lastWatchedMs: fromDbTimestamp(row.lastWatchedMs) ?? 0,
+ }));
}
export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
- return db
+ const rows = db
.prepare(
`
SELECT
@@ -205,7 +225,11 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
ORDER BY lm.last_watched_ms DESC
`,
)
- .all() as unknown as MediaLibraryRow[];
+ .all() as Array;
+ return rows.map((row) => ({
+ ...row,
+ lastWatchedMs: fromDbTimestamp(row.lastWatchedMs) ?? 0,
+ }));
}
export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRow | null {
@@ -253,7 +277,7 @@ export function getMediaSessions(
videoId: number,
limit = 100,
): SessionSummaryQueryRow[] {
- return db
+ const rows = db
.prepare(
`
${ACTIVE_SESSION_METRICS_CTE}
@@ -279,7 +303,17 @@ export function getMediaSessions(
LIMIT ?
`,
)
- .all(videoId, limit) as unknown as SessionSummaryQueryRow[];
+ .all(videoId, limit) as Array<
+ SessionSummaryQueryRow & {
+ startedAtMs: number | string;
+ endedAtMs: number | string | null;
+ }
+ >;
+ return rows.map((row) => ({
+ ...row,
+ startedAtMs: fromDbTimestamp(row.startedAtMs) ?? 0,
+ endedAtMs: fromDbTimestamp(row.endedAtMs),
+ }));
}
export function getMediaDailyRollups(
@@ -351,7 +385,7 @@ export function getAnimeDailyRollups(
export function getAnimeCoverArt(db: DatabaseSync, animeId: number): MediaArtRow | null {
const resolvedCoverBlob = resolvedCoverBlobExpr('a', 'cab');
- return db
+ const row = db
.prepare(
`
SELECT
@@ -372,12 +406,18 @@ export function getAnimeCoverArt(db: DatabaseSync, animeId: number): MediaArtRow
LIMIT 1
`,
)
- .get(animeId) as unknown as MediaArtRow | null;
+ .get(animeId) as (MediaArtRow & { fetchedAtMs: number | string }) | null;
+ return row
+ ? {
+ ...row,
+ fetchedAtMs: fromDbTimestamp(row.fetchedAtMs) ?? 0,
+ }
+ : null;
}
export function getCoverArt(db: DatabaseSync, videoId: number): MediaArtRow | null {
const resolvedCoverBlob = resolvedCoverBlobExpr('a', 'cab');
- return db
+ const row = db
.prepare(
`
SELECT
@@ -394,7 +434,13 @@ export function getCoverArt(db: DatabaseSync, videoId: number): MediaArtRow | nu
WHERE a.video_id = ?
`,
)
- .get(videoId) as unknown as MediaArtRow | null;
+ .get(videoId) as (MediaArtRow & { fetchedAtMs: number | string }) | null;
+ return row
+ ? {
+ ...row,
+ fetchedAtMs: fromDbTimestamp(row.fetchedAtMs) ?? 0,
+ }
+ : null;
}
export function getStreakCalendar(db: DatabaseSync, days = 90): StreakCalendarRow[] {
@@ -510,7 +556,7 @@ export function getEpisodeWords(db: DatabaseSync, videoId: number, limit = 50):
}
export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSummaryQueryRow[] {
- return db
+ const rows = db
.prepare(
`
${ACTIVE_SESSION_METRICS_CTE}
@@ -533,7 +579,17 @@ export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSu
ORDER BY s.started_at_ms DESC
`,
)
- .all(videoId) as SessionSummaryQueryRow[];
+ .all(videoId) as Array<
+ SessionSummaryQueryRow & {
+ startedAtMs: number | string;
+ endedAtMs: number | string | null;
+ }
+ >;
+ return rows.map((row) => ({
+ ...row,
+ startedAtMs: fromDbTimestamp(row.startedAtMs) ?? 0,
+ endedAtMs: fromDbTimestamp(row.endedAtMs),
+ }));
}
export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): EpisodeCardEventRow[] {
@@ -552,7 +608,7 @@ export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): Episode
.all(videoId) as Array<{
eventId: number;
sessionId: number;
- tsMs: number;
+ tsMs: number | string;
cardsDelta: number;
payloadJson: string | null;
}>;
@@ -568,7 +624,7 @@ export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): Episode
return {
eventId: row.eventId,
sessionId: row.sessionId,
- tsMs: row.tsMs,
+ tsMs: fromDbTimestamp(row.tsMs) ?? 0,
cardsDelta: row.cardsDelta,
noteIds,
};
diff --git a/src/core/services/immersion-tracker/query-maintenance.ts b/src/core/services/immersion-tracker/query-maintenance.ts
index 022386fe..00b687c2 100644
--- a/src/core/services/immersion-tracker/query-maintenance.ts
+++ b/src/core/services/immersion-tracker/query-maintenance.ts
@@ -17,6 +17,7 @@ import {
getAffectedWordIdsForVideo,
refreshLexicalAggregates,
toDbMs,
+ toDbTimestamp,
} from './query-shared';
type CleanupVocabularyRow = {
@@ -351,7 +352,7 @@ export function upsertCoverArt(
)
.get(videoId) as { coverBlobHash: string | null } | undefined;
const sharedCoverBlobHash = findSharedCoverBlobHash(db, videoId, art.anilistId, art.coverUrl);
- const fetchedAtMs = toDbMs(nowMs());
+ const fetchedAtMs = toDbTimestamp(nowMs());
const coverBlob = normalizeCoverBlobBytes(art.coverBlob);
const computedCoverBlobHash =
coverBlob && coverBlob.length > 0
@@ -444,7 +445,7 @@ export function updateAnimeAnilistInfo(
info.titleEnglish,
info.titleNative,
info.episodesTotal,
- toDbMs(nowMs()),
+ toDbTimestamp(nowMs()),
row.anime_id,
);
}
@@ -452,7 +453,7 @@ export function updateAnimeAnilistInfo(
export function markVideoWatched(db: DatabaseSync, videoId: number, watched: boolean): void {
db.prepare('UPDATE imm_videos SET watched = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?').run(
watched ? 1 : 0,
- toDbMs(nowMs()),
+ toDbTimestamp(nowMs()),
videoId,
);
}
diff --git a/src/core/services/immersion-tracker/query-sessions.ts b/src/core/services/immersion-tracker/query-sessions.ts
index 50224bd8..2d068656 100644
--- a/src/core/services/immersion-tracker/query-sessions.ts
+++ b/src/core/services/immersion-tracker/query-sessions.ts
@@ -1,11 +1,17 @@
import type { DatabaseSync } from './sqlite';
-import { nowMs } from './time';
import type {
ImmersionSessionRollupRow,
SessionSummaryQueryRow,
SessionTimelineRow,
} from './types';
-import { ACTIVE_SESSION_METRICS_CTE } from './query-shared';
+import {
+ ACTIVE_SESSION_METRICS_CTE,
+ currentDbTimestamp,
+ fromDbTimestamp,
+ getLocalEpochDay,
+ getShiftedLocalDaySec,
+ toDbTimestamp,
+} from './query-shared';
export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummaryQueryRow[] {
const prepared = db.prepare(`
@@ -33,7 +39,15 @@ export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummar
ORDER BY s.started_at_ms DESC
LIMIT ?
`);
- return prepared.all(limit) as unknown as SessionSummaryQueryRow[];
+ const rows = prepared.all(limit) as Array;
+ return rows.map((row) => ({
+ ...row,
+ startedAtMs: fromDbTimestamp(row.startedAtMs) ?? 0,
+ endedAtMs: fromDbTimestamp(row.endedAtMs),
+ }));
}
export function getSessionTimeline(
@@ -55,11 +69,23 @@ export function getSessionTimeline(
`;
if (limit === undefined) {
- return db.prepare(select).all(sessionId) as unknown as SessionTimelineRow[];
+ const rows = db.prepare(select).all(sessionId) as Array;
+ return rows.map((row) => ({
+ ...row,
+ sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,
+ }));
}
- return db
+ const rows = db
.prepare(`${select}\n LIMIT ?`)
- .all(sessionId, limit) as unknown as SessionTimelineRow[];
+ .all(sessionId, limit) as Array;
+ return rows.map((row) => ({
+ ...row,
+ sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,
+ }));
}
/** Returns all distinct headwords in the vocabulary table (global). */
@@ -129,35 +155,50 @@ export function getSessionWordsByLine(
}
function getNewWordCounts(db: DatabaseSync): { newWordsToday: number; newWordsThisWeek: number } {
- const now = new Date();
- const todayStartSec = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000;
- const weekAgoSec =
- new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7).getTime() / 1000;
+ const currentTimestamp = currentDbTimestamp();
+ const todayStartSec = getShiftedLocalDaySec(db, currentTimestamp, 0);
+ const weekAgoSec = getShiftedLocalDaySec(db, currentTimestamp, -7);
- const row = db
+ const rows = db
.prepare(
`
- WITH headword_first_seen AS (
- SELECT
- headword,
- MIN(first_seen) AS first_seen
- FROM imm_words
- WHERE first_seen IS NOT NULL
- AND headword IS NOT NULL
- AND headword != ''
- GROUP BY headword
- )
SELECT
- COALESCE(SUM(CASE WHEN first_seen >= ? THEN 1 ELSE 0 END), 0) AS today,
- COALESCE(SUM(CASE WHEN first_seen >= ? THEN 1 ELSE 0 END), 0) AS week
- FROM headword_first_seen
+ headword,
+ first_seen AS firstSeen
+ FROM imm_words
+ WHERE first_seen IS NOT NULL
+ AND headword IS NOT NULL
+ AND headword != ''
`,
)
- .get(todayStartSec, weekAgoSec) as { today: number; week: number } | null;
+ .all() as Array<{ headword: string; firstSeen: number | string }>;
+
+ const firstSeenByHeadword = new Map();
+ for (const row of rows) {
+ const firstSeen = Number(row.firstSeen);
+ if (!Number.isFinite(firstSeen)) {
+ continue;
+ }
+ const previous = firstSeenByHeadword.get(row.headword);
+ if (previous === undefined || firstSeen < previous) {
+ firstSeenByHeadword.set(row.headword, firstSeen);
+ }
+ }
+
+ let today = 0;
+ let week = 0;
+ for (const firstSeen of firstSeenByHeadword.values()) {
+ if (firstSeen >= todayStartSec) {
+ today += 1;
+ }
+ if (firstSeen >= weekAgoSec) {
+ week += 1;
+ }
+ }
return {
- newWordsToday: Number(row?.today ?? 0),
- newWordsThisWeek: Number(row?.week ?? 0),
+ newWordsToday: today,
+ newWordsThisWeek: week,
};
}
@@ -203,10 +244,8 @@ export function getQueryHints(db: DatabaseSync): {
animeCompleted: number;
} | null;
- const now = new Date();
- const todayLocal = Math.floor(
- new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 86_400_000,
- );
+ const currentTimestamp = currentDbTimestamp();
+ const todayLocal = getLocalEpochDay(db, currentTimestamp);
const episodesToday =
(
@@ -215,13 +254,16 @@ export function getQueryHints(db: DatabaseSync): {
`
SELECT COUNT(DISTINCT s.video_id) AS count
FROM imm_sessions s
- WHERE CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) = ?
+ WHERE CAST(
+ julianday(CAST(s.started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5
+ AS INTEGER
+ ) = ?
`,
)
.get(todayLocal) as { count: number }
)?.count ?? 0;
- const thirtyDaysAgoMs = nowMs() - 30 * 86400000;
+ const thirtyDaysAgoMs = getShiftedLocalDaySec(db, currentTimestamp, -30).toString() + '000';
const activeAnimeCount =
(
db
diff --git a/src/core/services/immersion-tracker/query-shared.ts b/src/core/services/immersion-tracker/query-shared.ts
index c5d8312b..2634ce6e 100644
--- a/src/core/services/immersion-tracker/query-shared.ts
+++ b/src/core/services/immersion-tracker/query-shared.ts
@@ -1,4 +1,5 @@
import type { DatabaseSync } from './sqlite';
+import { nowMs } from './time';
export const ACTIVE_SESSION_METRICS_CTE = `
WITH active_session_metrics AS (
@@ -280,3 +281,213 @@ export function toDbMs(ms: number | bigint): bigint {
}
return BigInt(Math.trunc(ms));
}
+
+function normalizeTimestampString(value: string): string {
+ const trimmed = value.trim();
+ if (!trimmed) {
+ throw new TypeError(`Invalid database timestamp: ${value}`);
+ }
+
+ const integerLike = /^(-?)(\d+)(?:\.0+)?$/.exec(trimmed);
+ if (integerLike) {
+ const sign = integerLike[1] ?? '';
+ const digits = (integerLike[2] ?? '0').replace(/^0+(?=\d)/, '');
+ return `${sign}${digits || '0'}`;
+ }
+
+ const parsed = Number(trimmed);
+ if (!Number.isFinite(parsed)) {
+ throw new TypeError(`Invalid database timestamp: ${value}`);
+ }
+ return JSON.stringify(Math.trunc(parsed));
+}
+
+export function toDbTimestamp(ms: number | bigint | string): string {
+ const normalizeParsed = (parsed: number): string => JSON.stringify(Math.trunc(parsed));
+
+ if (typeof ms === 'bigint') {
+ return ms.toString();
+ }
+ if (typeof ms === 'string') {
+ return normalizeTimestampString(ms);
+ }
+ if (!Number.isFinite(ms)) {
+ throw new TypeError(`Invalid database timestamp: ${ms}`);
+ }
+ return normalizeParsed(ms);
+}
+
+export function currentDbTimestamp(): string {
+ const testNowMs = globalThis.__subminerTestNowMs;
+ if (typeof testNowMs === 'string') {
+ return normalizeTimestampString(testNowMs);
+ }
+ if (typeof testNowMs === 'number' && Number.isFinite(testNowMs)) {
+ return toDbTimestamp(testNowMs);
+ }
+ return toDbTimestamp(nowMs());
+}
+
+export function subtractDbTimestamp(
+ timestampMs: number | bigint | string,
+ deltaMs: number | bigint,
+): string {
+ return (BigInt(toDbTimestamp(timestampMs)) - BigInt(deltaMs)).toString();
+}
+
+export function fromDbTimestamp(ms: number | bigint | string | null | undefined): number | null {
+ if (ms === null || ms === undefined) {
+ return null;
+ }
+ if (typeof ms === 'number') {
+ return ms;
+ }
+ if (typeof ms === 'bigint') {
+ return Number(ms);
+ }
+ return Number(ms);
+}
+
+function getNumericCalendarValue(
+ db: DatabaseSync,
+ sql: string,
+ timestampMs: number | bigint | string,
+): number {
+ const row = db.prepare(sql).get(toDbTimestamp(timestampMs)) as
+ | { value: number | string | null }
+ | undefined;
+ return Number(row?.value ?? 0);
+}
+
+export function getLocalEpochDay(
+ db: DatabaseSync,
+ timestampMs: number | bigint | string,
+): number {
+ return getNumericCalendarValue(
+ db,
+ `
+ SELECT CAST(
+ julianday(CAST(? AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5
+ AS INTEGER
+ ) AS value
+ `,
+ timestampMs,
+ );
+}
+
+export function getLocalMonthKey(
+ db: DatabaseSync,
+ timestampMs: number | bigint | string,
+): number {
+ return getNumericCalendarValue(
+ db,
+ `
+ SELECT CAST(
+ strftime('%Y%m', CAST(? AS REAL) / 1000, 'unixepoch', 'localtime')
+ AS INTEGER
+ ) AS value
+ `,
+ timestampMs,
+ );
+}
+
+export function getLocalDayOfWeek(
+ db: DatabaseSync,
+ timestampMs: number | bigint | string,
+): number {
+ return getNumericCalendarValue(
+ db,
+ `
+ SELECT CAST(
+ strftime('%w', CAST(? AS REAL) / 1000, 'unixepoch', 'localtime')
+ AS INTEGER
+ ) AS value
+ `,
+ timestampMs,
+ );
+}
+
+export function getLocalHourOfDay(
+ db: DatabaseSync,
+ timestampMs: number | bigint | string,
+): number {
+ return getNumericCalendarValue(
+ db,
+ `
+ SELECT CAST(
+ strftime('%H', CAST(? AS REAL) / 1000, 'unixepoch', 'localtime')
+ AS INTEGER
+ ) AS value
+ `,
+ timestampMs,
+ );
+}
+
+export function getStartOfLocalDaySec(
+ db: DatabaseSync,
+ timestampMs: number | bigint | string,
+): number {
+ return getNumericCalendarValue(
+ db,
+ `
+ SELECT CAST(
+ strftime(
+ '%s',
+ CAST(? AS REAL) / 1000,
+ 'unixepoch',
+ 'localtime',
+ 'start of day',
+ 'utc'
+ ) AS INTEGER
+ ) AS value
+ `,
+ timestampMs,
+ );
+}
+
+export function getStartOfLocalDayTimestamp(
+ db: DatabaseSync,
+ timestampMs: number | bigint | string,
+): string {
+ return `${getStartOfLocalDaySec(db, timestampMs)}000`;
+}
+
+export function getShiftedLocalDayTimestamp(
+ db: DatabaseSync,
+ timestampMs: number | bigint | string,
+ dayOffset: number,
+): string {
+ const normalizedDayOffset = Math.trunc(dayOffset);
+ const modifier = normalizedDayOffset >= 0 ? `+${normalizedDayOffset} days` : `${normalizedDayOffset} days`;
+ const row = db
+ .prepare(
+ `
+ SELECT strftime(
+ '%s',
+ CAST(? AS REAL) / 1000,
+ 'unixepoch',
+ 'localtime',
+ 'start of day',
+ '${modifier}',
+ 'utc'
+ ) AS value
+ `,
+ )
+ .get(toDbTimestamp(timestampMs)) as { value: string | number | null } | undefined;
+ return `${row?.value ?? '0'}000`;
+}
+
+export function getShiftedLocalDaySec(
+ db: DatabaseSync,
+ timestampMs: number | bigint | string,
+ dayOffset: number,
+): number {
+ return Number(BigInt(getShiftedLocalDayTimestamp(db, timestampMs, dayOffset)) / 1000n);
+}
+
+export function getStartOfLocalDayMs(
+ db: DatabaseSync,
+ timestampMs: number | bigint | string,
+): number {
+ return getStartOfLocalDaySec(db, timestampMs) * 1000;
+}
diff --git a/src/core/services/immersion-tracker/query-trends.ts b/src/core/services/immersion-tracker/query-trends.ts
index c72fae4f..4e7d2dcb 100644
--- a/src/core/services/immersion-tracker/query-trends.ts
+++ b/src/core/services/immersion-tracker/query-trends.ts
@@ -1,6 +1,16 @@
import type { DatabaseSync } from './sqlite';
import type { ImmersionSessionRollupRow } from './types';
-import { ACTIVE_SESSION_METRICS_CTE, makePlaceholders } from './query-shared';
+import {
+ ACTIVE_SESSION_METRICS_CTE,
+ currentDbTimestamp,
+ getLocalDayOfWeek,
+ getLocalEpochDay,
+ getLocalHourOfDay,
+ getLocalMonthKey,
+ getShiftedLocalDayTimestamp,
+ makePlaceholders,
+ toDbTimestamp,
+} from './query-shared';
import { getDailyRollups, getMonthlyRollups } from './query-sessions';
type TrendRange = '7d' | '30d' | '90d' | 'all';
@@ -19,6 +29,10 @@ interface TrendPerAnimePoint {
interface TrendSessionMetricRow {
startedAtMs: number;
+ epochDay: number;
+ monthKey: number;
+ dayOfWeek: number;
+ hourOfDay: number;
videoId: number | null;
canonicalTitle: string | null;
animeTitle: string | null;
@@ -73,64 +87,64 @@ const TREND_DAY_LIMITS: Record, number> = {
'90d': 90,
};
+const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function getTrendDayLimit(range: TrendRange): number {
return range === 'all' ? 365 : TREND_DAY_LIMITS[range];
}
-function getTrendMonthlyLimit(range: TrendRange): number {
+function getTrendMonthlyLimit(db: DatabaseSync, range: TrendRange): number {
if (range === 'all') {
return 120;
}
- const now = new Date();
- const cutoff = new Date(
- now.getFullYear(),
- now.getMonth(),
- now.getDate() - (TREND_DAY_LIMITS[range] - 1),
- );
- return Math.max(1, (now.getFullYear() - cutoff.getFullYear()) * 12 + now.getMonth() - cutoff.getMonth() + 1);
+ const currentTimestamp = currentDbTimestamp();
+ const todayStartMs = getShiftedLocalDayTimestamp(db, currentTimestamp, 0);
+ const cutoffMs = getShiftedLocalDayTimestamp(db, currentTimestamp, -(TREND_DAY_LIMITS[range] - 1));
+ const currentMonthKey = getLocalMonthKey(db, todayStartMs);
+ const cutoffMonthKey = getLocalMonthKey(db, cutoffMs);
+ const currentYear = Math.floor(currentMonthKey / 100);
+ const currentMonth = currentMonthKey % 100;
+ const cutoffYear = Math.floor(cutoffMonthKey / 100);
+ const cutoffMonth = cutoffMonthKey % 100;
+ return Math.max(1, (currentYear - cutoffYear) * 12 + currentMonth - cutoffMonth + 1);
}
-function getTrendCutoffMs(range: TrendRange): number | null {
+function getTrendCutoffMs(db: DatabaseSync, range: TrendRange): string | null {
if (range === 'all') {
return null;
}
- const dayLimit = getTrendDayLimit(range);
- const now = new Date();
- const localMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
- return localMidnight - (dayLimit - 1) * 86_400_000;
+ return getShiftedLocalDayTimestamp(db, currentDbTimestamp(), -(getTrendDayLimit(range) - 1));
+}
+
+function dayPartsFromEpochDay(epochDay: number): { year: number; month: number; day: number } {
+ const z = epochDay + 719468;
+ const era = Math.floor(z / 146097);
+ const doe = z - era * 146097;
+ const yoe = Math.floor(
+ (doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365,
+ );
+ let year = yoe + era * 400;
+ const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
+ const mp = Math.floor((5 * doy + 2) / 153);
+ const day = doy - Math.floor((153 * mp + 2) / 5) + 1;
+ const month = mp < 10 ? mp + 3 : mp - 9;
+ if (month <= 2) {
+ year += 1;
+ }
+ return { year, month, day };
}
function makeTrendLabel(value: number): string {
if (value > 100_000) {
const year = Math.floor(value / 100);
const month = value % 100;
- return new Date(Date.UTC(year, month - 1, 1)).toLocaleDateString(undefined, {
- month: 'short',
- year: '2-digit',
- });
+ return `${MONTH_NAMES[month - 1]} ${String(year).slice(-2)}`;
}
- return new Date(value * 86_400_000).toLocaleDateString(undefined, {
- month: 'short',
- day: 'numeric',
- });
-}
-
-function getLocalEpochDay(timestampMs: number): number {
- const date = new Date(timestampMs);
- return Math.floor((timestampMs - date.getTimezoneOffset() * 60_000) / 86_400_000);
-}
-
-function getLocalDateForEpochDay(epochDay: number): Date {
- const utcDate = new Date(epochDay * 86_400_000);
- return new Date(utcDate.getTime() + utcDate.getTimezoneOffset() * 60_000);
-}
-
-function getLocalMonthKey(timestampMs: number): number {
- const date = new Date(timestampMs);
- return date.getFullYear() * 100 + date.getMonth() + 1;
+ const { month, day } = dayPartsFromEpochDay(value);
+ return `${MONTH_NAMES[month - 1]} ${day}`;
}
function getTrendSessionWordCount(session: Pick): number {
@@ -189,7 +203,7 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
const totals = new Array(7).fill(0);
for (const session of sessions) {
- totals[new Date(session.startedAtMs).getDay()] += session.activeWatchedMs;
+ totals[session.dayOfWeek] += session.activeWatchedMs;
}
return DAY_NAMES.map((name, index) => ({
label: name,
@@ -200,7 +214,7 @@ function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChar
function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
const totals = new Array(24).fill(0);
for (const session of sessions) {
- totals[new Date(session.startedAtMs).getHours()] += session.activeWatchedMs;
+ totals[session.hourOfDay] += session.activeWatchedMs;
}
return totals.map((ms, index) => ({
label: `${String(index).padStart(2, '0')}:00`,
@@ -209,10 +223,8 @@ function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoin
}
function dayLabel(epochDay: number): string {
- return getLocalDateForEpochDay(epochDay).toLocaleDateString(undefined, {
- month: 'short',
- day: 'numeric',
- });
+ const { month, day } = dayPartsFromEpochDay(epochDay);
+ return `${MONTH_NAMES[month - 1]} ${day}`;
}
function buildSessionSeriesByDay(
@@ -221,8 +233,7 @@ function buildSessionSeriesByDay(
): TrendChartPoint[] {
const byDay = new Map();
for (const session of sessions) {
- const epochDay = getLocalEpochDay(session.startedAtMs);
- byDay.set(epochDay, (byDay.get(epochDay) ?? 0) + getValue(session));
+ byDay.set(session.epochDay, (byDay.get(session.epochDay) ?? 0) + getValue(session));
}
return Array.from(byDay.entries())
.sort(([left], [right]) => left - right)
@@ -235,8 +246,7 @@ function buildSessionSeriesByMonth(
): TrendChartPoint[] {
const byMonth = new Map();
for (const session of sessions) {
- const monthKey = getLocalMonthKey(session.startedAtMs);
- byMonth.set(monthKey, (byMonth.get(monthKey) ?? 0) + getValue(session));
+ byMonth.set(session.monthKey, (byMonth.get(session.monthKey) ?? 0) + getValue(session));
}
return Array.from(byMonth.entries())
.sort(([left], [right]) => left - right)
@@ -251,8 +261,7 @@ function buildLookupsPerHundredWords(
const wordsByBucket = new Map();
for (const session of sessions) {
- const bucketKey =
- groupBy === 'month' ? getLocalMonthKey(session.startedAtMs) : getLocalEpochDay(session.startedAtMs);
+ const bucketKey = groupBy === 'month' ? session.monthKey : session.epochDay;
lookupsByBucket.set(
bucketKey,
(lookupsByBucket.get(bucketKey) ?? 0) + session.yomitanLookupCount,
@@ -282,7 +291,7 @@ function buildPerAnimeFromSessions(
for (const session of sessions) {
const animeTitle = resolveTrendAnimeTitle(session);
- const epochDay = getLocalEpochDay(session.startedAtMs);
+ const epochDay = session.epochDay;
const dayMap = byAnime.get(animeTitle) ?? new Map();
dayMap.set(epochDay, (dayMap.get(epochDay) ?? 0) + getValue(session));
byAnime.set(animeTitle, dayMap);
@@ -303,7 +312,7 @@ function buildLookupsPerHundredPerAnime(sessions: TrendSessionMetricRow[]): Tren
for (const session of sessions) {
const animeTitle = resolveTrendAnimeTitle(session);
- const epochDay = getLocalEpochDay(session.startedAtMs);
+ const epochDay = session.epochDay;
const lookupMap = lookups.get(animeTitle) ?? new Map();
lookupMap.set(epochDay, (lookupMap.get(epochDay) ?? 0) + session.yomitanLookupCount);
@@ -498,9 +507,10 @@ function buildEpisodesPerMonthFromRollups(rollups: ImmersionSessionRollupRow[]):
function getTrendSessionMetrics(
db: DatabaseSync,
- cutoffMs: number | null,
+ cutoffMs: string | null,
): TrendSessionMetricRow[] {
const whereClause = cutoffMs === null ? '' : 'WHERE s.started_at_ms >= ?';
+ const cutoffValue = cutoffMs === null ? null : toDbTimestamp(cutoffMs);
const prepared = db.prepare(`
${ACTIVE_SESSION_METRICS_CTE}
SELECT
@@ -520,14 +530,27 @@ function getTrendSessionMetrics(
ORDER BY s.started_at_ms ASC
`);
- return (cutoffMs === null ? prepared.all() : prepared.all(cutoffMs)) as TrendSessionMetricRow[];
+ const rows = (cutoffValue === null ? prepared.all() : prepared.all(cutoffValue)) as Array<
+ TrendSessionMetricRow & { startedAtMs: number | string }
+ >;
+ return rows.map((row) => ({
+ ...row,
+ startedAtMs: 0,
+ epochDay: getLocalEpochDay(db, row.startedAtMs),
+ monthKey: getLocalMonthKey(db, row.startedAtMs),
+ dayOfWeek: getLocalDayOfWeek(db, row.startedAtMs),
+ hourOfDay: getLocalHourOfDay(db, row.startedAtMs),
+ }));
}
-function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: number | null): TrendChartPoint[] {
+function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: string | null): TrendChartPoint[] {
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
const prepared = db.prepare(`
SELECT
- CAST(julianday(first_seen, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS epochDay,
+ CAST(
+ julianday(CAST(first_seen AS REAL), 'unixepoch', 'localtime') - 2440587.5
+ AS INTEGER
+ ) AS epochDay,
COUNT(*) AS wordCount
FROM imm_words
WHERE first_seen IS NOT NULL
@@ -537,7 +560,7 @@ function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: number | null): TrendCh
`);
const rows = (
- cutoffMs === null ? prepared.all() : prepared.all(Math.floor(cutoffMs / 1000))
+ cutoffMs === null ? prepared.all() : prepared.all((BigInt(cutoffMs) / 1000n).toString())
) as Array<{
epochDay: number;
wordCount: number;
@@ -549,11 +572,14 @@ function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: number | null): TrendCh
}));
}
-function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: number | null): TrendChartPoint[] {
+function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: string | null): TrendChartPoint[] {
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
const prepared = db.prepare(`
SELECT
- CAST(strftime('%Y%m', first_seen, 'unixepoch', 'localtime') AS INTEGER) AS monthKey,
+ CAST(
+ strftime('%Y%m', CAST(first_seen AS REAL), 'unixepoch', 'localtime')
+ AS INTEGER
+ ) AS monthKey,
COUNT(*) AS wordCount
FROM imm_words
WHERE first_seen IS NOT NULL
@@ -563,7 +589,7 @@ function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: number | null): Trend
`);
const rows = (
- cutoffMs === null ? prepared.all() : prepared.all(Math.floor(cutoffMs / 1000))
+ cutoffMs === null ? prepared.all() : prepared.all((BigInt(cutoffMs) / 1000n).toString())
) as Array<{
monthKey: number;
wordCount: number;
@@ -581,8 +607,8 @@ export function getTrendsDashboard(
groupBy: TrendGroupBy = 'day',
): TrendsDashboardQueryResult {
const dayLimit = getTrendDayLimit(range);
- const monthlyLimit = getTrendMonthlyLimit(range);
- const cutoffMs = getTrendCutoffMs(range);
+ const monthlyLimit = getTrendMonthlyLimit(db, range);
+ const cutoffMs = getTrendCutoffMs(db, range);
const useMonthlyBuckets = groupBy === 'month';
const dailyRollups = getDailyRollups(db, dayLimit);
const monthlyRollups = getMonthlyRollups(db, monthlyLimit);
diff --git a/src/core/services/immersion-tracker/session.ts b/src/core/services/immersion-tracker/session.ts
index b0484f65..787c246d 100644
--- a/src/core/services/immersion-tracker/session.ts
+++ b/src/core/services/immersion-tracker/session.ts
@@ -4,7 +4,7 @@ import { createInitialSessionState } from './reducer';
import { nowMs } from './time';
import { SESSION_STATUS_ACTIVE, SESSION_STATUS_ENDED } from './types';
import type { SessionState } from './types';
-import { toDbMs } from './query-shared';
+import { toDbMs, toDbTimestamp } from './query-shared';
export function startSessionRecord(
db: DatabaseSync,
@@ -25,10 +25,10 @@ export function startSessionRecord(
.run(
sessionUuid,
videoId,
- toDbMs(startedAtMs),
+ toDbTimestamp(startedAtMs),
SESSION_STATUS_ACTIVE,
- toDbMs(startedAtMs),
- toDbMs(createdAtMs),
+ toDbTimestamp(startedAtMs),
+ toDbTimestamp(createdAtMs),
);
const sessionId = Number(result.lastInsertRowid);
return {
@@ -40,7 +40,7 @@ export function startSessionRecord(
export function finalizeSessionRecord(
db: DatabaseSync,
sessionState: SessionState,
- endedAtMs = nowMs(),
+ endedAtMs: number | string = nowMs(),
): void {
db.prepare(
`
@@ -66,7 +66,7 @@ export function finalizeSessionRecord(
WHERE session_id = ?
`,
).run(
- toDbMs(endedAtMs),
+ toDbTimestamp(endedAtMs),
SESSION_STATUS_ENDED,
sessionState.lastMediaMs === null ? null : toDbMs(sessionState.lastMediaMs),
sessionState.totalWatchedMs,
@@ -82,7 +82,7 @@ export function finalizeSessionRecord(
sessionState.seekForwardCount,
sessionState.seekBackwardCount,
sessionState.mediaBufferEvents,
- toDbMs(nowMs()),
+ toDbTimestamp(nowMs()),
sessionState.sessionId,
);
}
diff --git a/src/core/services/immersion-tracker/storage-session.test.ts b/src/core/services/immersion-tracker/storage-session.test.ts
index d00e09bd..d84a8496 100644
--- a/src/core/services/immersion-tracker/storage-session.test.ts
+++ b/src/core/services/immersion-tracker/storage-session.test.ts
@@ -143,10 +143,10 @@ test('ensureSchema creates immersion core tables', () => {
const rollupStateRow = db
.prepare('SELECT state_value FROM imm_rollup_state WHERE state_key = ?')
.get('last_rollup_sample_ms') as {
- state_value: number;
+ state_value: string;
} | null;
assert.ok(rollupStateRow);
- assert.equal(rollupStateRow?.state_value, 0);
+ assert.equal(Number(rollupStateRow?.state_value ?? 0), 0);
} finally {
db.close();
cleanupDbPath(dbPath);
@@ -965,12 +965,12 @@ test('start/finalize session updates ended_at and status', () => {
const row = db
.prepare('SELECT ended_at_ms, status FROM imm_sessions WHERE session_id = ?')
.get(sessionId) as {
- ended_at_ms: number | null;
+ ended_at_ms: string | null;
status: number;
} | null;
assert.ok(row);
- assert.equal(row?.ended_at_ms, endedAtMs);
+ assert.equal(Number(row?.ended_at_ms ?? 0), endedAtMs);
assert.equal(row?.status, SESSION_STATUS_ENDED);
} finally {
db.close();
diff --git a/src/core/services/immersion-tracker/storage.ts b/src/core/services/immersion-tracker/storage.ts
index ce8833cc..98496868 100644
--- a/src/core/services/immersion-tracker/storage.ts
+++ b/src/core/services/immersion-tracker/storage.ts
@@ -4,7 +4,7 @@ import type { DatabaseSync } from './sqlite';
import { nowMs } from './time';
import { SCHEMA_VERSION } from './types';
import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types';
-import { toDbMs } from './query-shared';
+import { toDbMs, toDbTimestamp } from './query-shared';
export interface TrackerPreparedStatements {
telemetryInsertStmt: ReturnType;
@@ -130,7 +130,7 @@ function deduplicateExistingCoverArtRows(db: DatabaseSync): void {
return;
}
- const nowMsValue = toDbMs(nowMs());
+ const nowMsValue = toDbTimestamp(nowMs());
const upsertBlobStmt = db.prepare(`
INSERT INTO imm_cover_art_blobs (blob_hash, cover_blob, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?)
@@ -275,7 +275,7 @@ function parseLegacyAnimeBackfillCandidate(
}
function ensureLifetimeSummaryTables(db: DatabaseSync): void {
- const nowMsValue = toDbMs(nowMs());
+ const nowMsValue = toDbTimestamp(nowMs());
db.exec(`
CREATE TABLE IF NOT EXISTS imm_lifetime_global(
@@ -287,9 +287,9 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void {
episodes_started INTEGER NOT NULL DEFAULT 0,
episodes_completed INTEGER NOT NULL DEFAULT 0,
anime_completed INTEGER NOT NULL DEFAULT 0,
- last_rebuilt_ms INTEGER,
- CREATED_DATE INTEGER,
- LAST_UPDATE_DATE INTEGER
+ last_rebuilt_ms TEXT,
+ CREATED_DATE TEXT,
+ LAST_UPDATE_DATE TEXT
)
`);
@@ -332,10 +332,10 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void {
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
episodes_started INTEGER NOT NULL DEFAULT 0,
episodes_completed INTEGER NOT NULL DEFAULT 0,
- first_watched_ms INTEGER,
- last_watched_ms INTEGER,
- CREATED_DATE INTEGER,
- LAST_UPDATE_DATE INTEGER,
+ first_watched_ms TEXT,
+ last_watched_ms TEXT,
+ CREATED_DATE TEXT,
+ LAST_UPDATE_DATE TEXT,
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE
)
`);
@@ -349,10 +349,10 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void {
total_lines_seen INTEGER NOT NULL DEFAULT 0,
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
completed INTEGER NOT NULL DEFAULT 0,
- first_watched_ms INTEGER,
- last_watched_ms INTEGER,
- CREATED_DATE INTEGER,
- LAST_UPDATE_DATE INTEGER,
+ first_watched_ms TEXT,
+ last_watched_ms TEXT,
+ CREATED_DATE TEXT,
+ LAST_UPDATE_DATE TEXT,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
)
`);
@@ -360,9 +360,9 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void {
db.exec(`
CREATE TABLE IF NOT EXISTS imm_lifetime_applied_sessions(
session_id INTEGER PRIMARY KEY,
- applied_at_ms INTEGER NOT NULL,
- CREATED_DATE INTEGER,
- LAST_UPDATE_DATE INTEGER,
+ applied_at_ms TEXT NOT NULL,
+ CREATED_DATE TEXT,
+ LAST_UPDATE_DATE TEXT,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
)
`);
@@ -405,13 +405,13 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
input.titleEnglish,
input.titleNative,
input.metadataJson,
- toDbMs(nowMs()),
+ toDbTimestamp(nowMs()),
existing.anime_id,
);
return existing.anime_id;
}
- const nowMsValue = toDbMs(nowMs());
+ const nowMsValue = toDbTimestamp(nowMs());
const result = db
.prepare(
`
@@ -471,7 +471,7 @@ export function linkVideoToAnimeRecord(
input.parserSource,
input.parserConfidence,
input.parseMetadataJson,
- toDbMs(nowMs()),
+ toDbTimestamp(nowMs()),
videoId,
);
}
@@ -562,13 +562,13 @@ export function ensureSchema(db: DatabaseSync): void {
db.exec(`
CREATE TABLE IF NOT EXISTS imm_schema_version (
schema_version INTEGER PRIMARY KEY,
- applied_at_ms INTEGER NOT NULL
+ applied_at_ms TEXT NOT NULL
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS imm_rollup_state(
state_key TEXT PRIMARY KEY,
- state_value INTEGER NOT NULL
+ state_value TEXT NOT NULL
);
`);
db.exec(`
@@ -597,8 +597,8 @@ export function ensureSchema(db: DatabaseSync): void {
episodes_total INTEGER,
description TEXT,
metadata_json TEXT,
- CREATED_DATE INTEGER,
- LAST_UPDATE_DATE INTEGER
+ CREATED_DATE TEXT,
+ LAST_UPDATE_DATE TEXT
);
`);
db.exec(`
@@ -625,8 +625,8 @@ export function ensureSchema(db: DatabaseSync): void {
bitrate_kbps INTEGER, audio_codec_id INTEGER,
hash_sha256 TEXT, screenshot_path TEXT,
metadata_json TEXT,
- CREATED_DATE INTEGER,
- LAST_UPDATE_DATE INTEGER,
+ CREATED_DATE TEXT,
+ LAST_UPDATE_DATE TEXT,
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL
);
`);
@@ -635,7 +635,7 @@ export function ensureSchema(db: DatabaseSync): void {
session_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_uuid TEXT NOT NULL UNIQUE,
video_id INTEGER NOT NULL,
- started_at_ms INTEGER NOT NULL, ended_at_ms INTEGER,
+ started_at_ms TEXT NOT NULL, ended_at_ms TEXT,
status INTEGER NOT NULL,
locale_id INTEGER, target_lang_id INTEGER,
difficulty_tier INTEGER, subtitle_mode INTEGER,
@@ -653,8 +653,8 @@ export function ensureSchema(db: DatabaseSync): void {
seek_forward_count INTEGER NOT NULL DEFAULT 0,
seek_backward_count INTEGER NOT NULL DEFAULT 0,
media_buffer_events INTEGER NOT NULL DEFAULT 0,
- CREATED_DATE INTEGER,
- LAST_UPDATE_DATE INTEGER,
+ CREATED_DATE TEXT,
+ LAST_UPDATE_DATE TEXT,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id)
);
`);
@@ -662,7 +662,7 @@ export function ensureSchema(db: DatabaseSync): void {
CREATE TABLE IF NOT EXISTS imm_session_telemetry(
telemetry_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
- sample_ms INTEGER NOT NULL,
+ sample_ms TEXT NOT NULL,
total_watched_ms INTEGER NOT NULL DEFAULT 0,
active_watched_ms INTEGER NOT NULL DEFAULT 0,
lines_seen INTEGER NOT NULL DEFAULT 0,
@@ -676,8 +676,8 @@ export function ensureSchema(db: DatabaseSync): void {
seek_forward_count INTEGER NOT NULL DEFAULT 0,
seek_backward_count INTEGER NOT NULL DEFAULT 0,
media_buffer_events INTEGER NOT NULL DEFAULT 0,
- CREATED_DATE INTEGER,
- LAST_UPDATE_DATE INTEGER,
+ CREATED_DATE TEXT,
+ LAST_UPDATE_DATE TEXT,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
);
`);
@@ -693,8 +693,8 @@ export function ensureSchema(db: DatabaseSync): void {
tokens_delta INTEGER NOT NULL DEFAULT 0,
cards_delta INTEGER NOT NULL DEFAULT 0,
payload_json TEXT,
- CREATED_DATE INTEGER,
- LAST_UPDATE_DATE INTEGER,
+ CREATED_DATE TEXT,
+ LAST_UPDATE_DATE TEXT,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
);
`);
@@ -710,8 +710,8 @@ export function ensureSchema(db: DatabaseSync): void {
cards_per_hour REAL,
tokens_per_min REAL,
lookup_hit_rate REAL,
- CREATED_DATE INTEGER,
- LAST_UPDATE_DATE INTEGER,
+ CREATED_DATE TEXT,
+ LAST_UPDATE_DATE TEXT,
PRIMARY KEY (rollup_day, video_id)
);
`);
@@ -724,8 +724,8 @@ export function ensureSchema(db: DatabaseSync): void {
total_lines_seen INTEGER NOT NULL DEFAULT 0,
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
total_cards INTEGER NOT NULL DEFAULT 0,
- CREATED_DATE INTEGER,
- LAST_UPDATE_DATE INTEGER,
+ CREATED_DATE TEXT,
+ LAST_UPDATE_DATE TEXT,
PRIMARY KEY (rollup_month, video_id)
);
`);
@@ -806,9 +806,9 @@ export function ensureSchema(db: DatabaseSync): void {
title_romaji TEXT,
title_english TEXT,
episodes_total INTEGER,
- fetched_at_ms INTEGER NOT NULL,
- CREATED_DATE INTEGER,
- LAST_UPDATE_DATE INTEGER,
+ fetched_at_ms TEXT NOT NULL,
+ CREATED_DATE TEXT,
+ LAST_UPDATE_DATE TEXT,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
);
`);
@@ -827,9 +827,9 @@ export function ensureSchema(db: DatabaseSync): void {
uploader_url TEXT,
description TEXT,
metadata_json TEXT,
- fetched_at_ms INTEGER NOT NULL,
- CREATED_DATE INTEGER,
- LAST_UPDATE_DATE INTEGER,
+ fetched_at_ms TEXT NOT NULL,
+ CREATED_DATE TEXT,
+ LAST_UPDATE_DATE TEXT,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
);
`);
@@ -837,26 +837,26 @@ export function ensureSchema(db: DatabaseSync): void {
CREATE TABLE IF NOT EXISTS imm_cover_art_blobs(
blob_hash TEXT PRIMARY KEY,
cover_blob BLOB NOT NULL,
- CREATED_DATE INTEGER,
- LAST_UPDATE_DATE INTEGER
+ CREATED_DATE TEXT,
+ LAST_UPDATE_DATE TEXT
);
`);
if (currentVersion?.schema_version === 1) {
- addColumnIfMissing(db, 'imm_videos', 'CREATED_DATE');
- addColumnIfMissing(db, 'imm_videos', 'LAST_UPDATE_DATE');
- addColumnIfMissing(db, 'imm_sessions', 'CREATED_DATE');
- addColumnIfMissing(db, 'imm_sessions', 'LAST_UPDATE_DATE');
- addColumnIfMissing(db, 'imm_session_telemetry', 'CREATED_DATE');
- addColumnIfMissing(db, 'imm_session_telemetry', 'LAST_UPDATE_DATE');
- addColumnIfMissing(db, 'imm_session_events', 'CREATED_DATE');
- addColumnIfMissing(db, 'imm_session_events', 'LAST_UPDATE_DATE');
- addColumnIfMissing(db, 'imm_daily_rollups', 'CREATED_DATE');
- addColumnIfMissing(db, 'imm_daily_rollups', 'LAST_UPDATE_DATE');
- addColumnIfMissing(db, 'imm_monthly_rollups', 'CREATED_DATE');
- addColumnIfMissing(db, 'imm_monthly_rollups', 'LAST_UPDATE_DATE');
+ addColumnIfMissing(db, 'imm_videos', 'CREATED_DATE', 'TEXT');
+ addColumnIfMissing(db, 'imm_videos', 'LAST_UPDATE_DATE', 'TEXT');
+ addColumnIfMissing(db, 'imm_sessions', 'CREATED_DATE', 'TEXT');
+ addColumnIfMissing(db, 'imm_sessions', 'LAST_UPDATE_DATE', 'TEXT');
+ addColumnIfMissing(db, 'imm_session_telemetry', 'CREATED_DATE', 'TEXT');
+ addColumnIfMissing(db, 'imm_session_telemetry', 'LAST_UPDATE_DATE', 'TEXT');
+ addColumnIfMissing(db, 'imm_session_events', 'CREATED_DATE', 'TEXT');
+ addColumnIfMissing(db, 'imm_session_events', 'LAST_UPDATE_DATE', 'TEXT');
+ addColumnIfMissing(db, 'imm_daily_rollups', 'CREATED_DATE', 'TEXT');
+ addColumnIfMissing(db, 'imm_daily_rollups', 'LAST_UPDATE_DATE', 'TEXT');
+ addColumnIfMissing(db, 'imm_monthly_rollups', 'CREATED_DATE', 'TEXT');
+ addColumnIfMissing(db, 'imm_monthly_rollups', 'LAST_UPDATE_DATE', 'TEXT');
- const migratedAtMs = toDbMs(nowMs());
+ const migratedAtMs = toDbTimestamp(nowMs());
db.prepare(
`
UPDATE imm_videos
@@ -1243,7 +1243,7 @@ export function ensureSchema(db: DatabaseSync): void {
db.exec(`
INSERT INTO imm_schema_version(schema_version, applied_at_ms)
- VALUES (${SCHEMA_VERSION}, ${toDbMs(nowMs())})
+ VALUES (${SCHEMA_VERSION}, ${toDbTimestamp(nowMs())})
ON CONFLICT DO NOTHING
`);
}
@@ -1401,7 +1401,7 @@ function incrementKanjiAggregate(
}
export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedStatements): void {
- const currentMs = toDbMs(nowMs());
+ const currentMs = toDbTimestamp(nowMs());
if (write.kind === 'telemetry') {
if (
write.totalWatchedMs === undefined ||
@@ -1420,7 +1420,7 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
) {
throw new Error('Incomplete telemetry write');
}
- const telemetrySampleMs = toDbMs(write.sampleMs ?? Number(currentMs));
+ const telemetrySampleMs = toDbTimestamp(write.sampleMs ?? Number(currentMs));
stmts.telemetryInsertStmt.run(
write.sessionId,
telemetrySampleMs,
@@ -1495,7 +1495,7 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
stmts.eventInsertStmt.run(
write.sessionId,
- toDbMs(write.sampleMs ?? Number(currentMs)),
+ toDbTimestamp(write.sampleMs ?? Number(currentMs)),
write.eventType ?? 0,
write.lineIndex ?? null,
write.segmentStartMs ?? null,
@@ -1530,11 +1530,11 @@ export function getOrCreateVideoRecord(
LAST_UPDATE_DATE = ?
WHERE video_id = ?
`,
- ).run(details.canonicalTitle || 'unknown', toDbMs(nowMs()), existing.video_id);
+ ).run(details.canonicalTitle || 'unknown', toDbTimestamp(nowMs()), existing.video_id);
return existing.video_id;
}
- const currentMs = toDbMs(nowMs());
+ const currentMs = toDbTimestamp(nowMs());
const insert = db.prepare(`
INSERT INTO imm_videos (
video_key, canonical_title, source_type, source_path, source_url,
@@ -1604,7 +1604,7 @@ export function updateVideoMetadataRecord(
metadata.hashSha256,
metadata.screenshotPath,
metadata.metadataJson,
- toDbMs(nowMs()),
+ toDbTimestamp(nowMs()),
videoId,
);
}
@@ -1622,7 +1622,7 @@ export function updateVideoTitleRecord(
LAST_UPDATE_DATE = ?
WHERE video_id = ?
`,
- ).run(canonicalTitle, toDbMs(nowMs()), videoId);
+ ).run(canonicalTitle, toDbTimestamp(nowMs()), videoId);
}
export function upsertYoutubeVideoMetadata(
@@ -1630,7 +1630,7 @@ export function upsertYoutubeVideoMetadata(
videoId: number,
metadata: YoutubeVideoMetadata,
): void {
- const currentMs = toDbMs(nowMs());
+ const currentMs = toDbTimestamp(nowMs());
db.prepare(
`
INSERT INTO imm_youtube_videos (
diff --git a/src/core/services/immersion-tracker/time.test.ts b/src/core/services/immersion-tracker/time.test.ts
index 08c5f54a..75ff5e18 100644
--- a/src/core/services/immersion-tracker/time.test.ts
+++ b/src/core/services/immersion-tracker/time.test.ts
@@ -5,3 +5,25 @@ import { nowMs } from './time.js';
test('nowMs returns wall-clock epoch milliseconds', () => {
assert.ok(nowMs() > 1_600_000_000_000);
});
+
+test('nowMs honors string-backed test clock values', () => {
+ const previousNowMs = globalThis.__subminerTestNowMs;
+ globalThis.__subminerTestNowMs = '123.9';
+
+ try {
+ assert.equal(nowMs(), 123);
+ } finally {
+ globalThis.__subminerTestNowMs = previousNowMs;
+ }
+});
+
+test('nowMs truncates negative numeric test clock values', () => {
+ const previousNowMs = globalThis.__subminerTestNowMs;
+ globalThis.__subminerTestNowMs = -1.9;
+
+ try {
+ assert.equal(nowMs(), -1);
+ } finally {
+ globalThis.__subminerTestNowMs = previousNowMs;
+ }
+});
diff --git a/src/core/services/immersion-tracker/time.ts b/src/core/services/immersion-tracker/time.ts
index 8ea20816..8c8a94ca 100644
--- a/src/core/services/immersion-tracker/time.ts
+++ b/src/core/services/immersion-tracker/time.ts
@@ -1,4 +1,26 @@
+declare global {
+ var __subminerTestNowMs: number | string | undefined;
+}
+
+function getMockNowMs(testNowMs: number | string | undefined): number | null {
+ if (typeof testNowMs === 'number' && Number.isFinite(testNowMs)) {
+ return Math.trunc(testNowMs);
+ }
+ if (typeof testNowMs === 'string') {
+ const parsed = Number(testNowMs.trim());
+ if (Number.isFinite(parsed)) {
+ return Math.trunc(parsed);
+ }
+ }
+ return null;
+}
+
export function nowMs(): number {
+ const mockedNowMs = getMockNowMs(globalThis.__subminerTestNowMs);
+ if (mockedNowMs !== null) {
+ return mockedNowMs;
+ }
+
const perf = globalThis.performance;
if (perf && Number.isFinite(perf.timeOrigin)) {
return Math.floor(perf.timeOrigin + perf.now());
diff --git a/src/core/services/ipc-command.test.ts b/src/core/services/ipc-command.test.ts
index f9c63b2f..4888fdfc 100644
--- a/src/core/services/ipc-command.test.ts
+++ b/src/core/services/ipc-command.test.ts
@@ -16,6 +16,7 @@ function createOptions(overrides: Partial {
calls.push('subsync');
@@ -26,6 +27,9 @@ function createOptions(overrides: Partial {
calls.push('youtube-picker');
},
+ openPlaylistBrowser: () => {
+ calls.push('playlist-browser');
+ },
runtimeOptionsCycle: () => ({ ok: true }),
showMpvOsd: (text) => {
osd.push(text);
@@ -110,6 +114,28 @@ test('handleMpvCommandFromIpc dispatches special youtube picker open command', (
assert.deepEqual(osd, []);
});
+test('handleMpvCommandFromIpc dispatches special playlist browser open command', async () => {
+ const { options, calls, sentCommands, osd } = createOptions();
+ handleMpvCommandFromIpc(['__playlist-browser-open'], options);
+ await new Promise((resolve) => setImmediate(resolve));
+ assert.deepEqual(calls, ['playlist-browser']);
+ assert.deepEqual(sentCommands, []);
+ assert.deepEqual(osd, []);
+});
+
+test('handleMpvCommandFromIpc surfaces playlist browser open rejections via mpv osd', async () => {
+ const { options, osd } = createOptions({
+ openPlaylistBrowser: async () => {
+ throw new Error('overlay failed');
+ },
+ });
+
+ handleMpvCommandFromIpc(['__playlist-browser-open'], options);
+ await new Promise((resolve) => setImmediate(resolve));
+
+ assert.deepEqual(osd, ['Playlist browser failed: overlay failed']);
+});
+
test('handleMpvCommandFromIpc does not forward commands while disconnected', () => {
const { options, sentCommands, osd } = createOptions({
isMpvConnected: () => false,
diff --git a/src/core/services/ipc-command.ts b/src/core/services/ipc-command.ts
index 166ac687..1a9e4144 100644
--- a/src/core/services/ipc-command.ts
+++ b/src/core/services/ipc-command.ts
@@ -15,10 +15,12 @@ export interface HandleMpvCommandFromIpcOptions {
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string;
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string;
YOUTUBE_PICKER_OPEN: string;
+ PLAYLIST_BROWSER_OPEN: string;
};
triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void;
openYoutubeTrackPicker: () => void | Promise;
+ openPlaylistBrowser: () => void | Promise;
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void;
mpvReplaySubtitle: () => void;
@@ -97,6 +99,16 @@ export function handleMpvCommandFromIpc(
return;
}
+ if (first === options.specialCommands.PLAYLIST_BROWSER_OPEN) {
+ Promise.resolve()
+ .then(() => options.openPlaylistBrowser())
+ .catch((error) => {
+ const message = error instanceof Error ? error.message : String(error);
+ options.showMpvOsd(`Playlist browser failed: ${message}`);
+ });
+ return;
+ }
+
if (
first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START ||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START
diff --git a/src/core/services/ipc.test.ts b/src/core/services/ipc.test.ts
index e5dae341..28ff2d47 100644
--- a/src/core/services/ipc.test.ts
+++ b/src/core/services/ipc.test.ts
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
-import type { SubtitleSidebarSnapshot } from '../../types';
+import type { PlaylistBrowserSnapshot, SubtitleSidebarSnapshot } from '../../types';
interface FakeIpcRegistrar {
on: Map void>;
@@ -148,6 +148,19 @@ function createRegisterIpcDeps(overrides: Partial = {}): IpcServ
getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
+ getPlaylistBrowserSnapshot: async () => ({
+ directoryPath: null,
+ directoryAvailable: false,
+ directoryStatus: '',
+ directoryItems: [],
+ playlistItems: [],
+ playingIndex: null,
+ currentFilePath: null,
+ }),
+ appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
+ playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
+ removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
+ movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
immersionTracker: null,
...overrides,
@@ -241,6 +254,19 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
return { ok: true, message: 'done' };
},
appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }),
+ getPlaylistBrowserSnapshot: async () => ({
+ directoryPath: '/tmp',
+ directoryAvailable: true,
+ directoryStatus: '/tmp',
+ directoryItems: [],
+ playlistItems: [],
+ playingIndex: 0,
+ currentFilePath: '/tmp/current.mkv',
+ }),
+ appendPlaylistBrowserFile: async () => ({ ok: true, message: 'append' }),
+ playPlaylistBrowserIndex: async () => ({ ok: true, message: 'play' }),
+ removePlaylistBrowserIndex: async () => ({ ok: true, message: 'remove' }),
+ movePlaylistBrowserIndex: async () => ({ ok: true, message: 'move' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
});
@@ -256,10 +282,83 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
ok: true,
message: 'done',
});
+ assert.equal((await deps.getPlaylistBrowserSnapshot()).directoryAvailable, true);
+ assert.deepEqual(await deps.appendPlaylistBrowserFile('/tmp/new.mkv'), {
+ ok: true,
+ message: 'append',
+ });
+ 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.equal(deps.getPlaybackPaused(), true);
});
+test('registerIpcHandlers exposes playlist browser snapshot and mutations', async () => {
+ const { registrar, handlers } = createFakeIpcRegistrar();
+ const calls: Array<[string, unknown[]]> = [];
+ registerIpcHandlers(
+ createRegisterIpcDeps({
+ getPlaylistBrowserSnapshot: async () => ({
+ directoryPath: '/tmp/videos',
+ directoryAvailable: true,
+ directoryStatus: '/tmp/videos',
+ directoryItems: [],
+ playlistItems: [],
+ playingIndex: 1,
+ currentFilePath: '/tmp/videos/ep2.mkv',
+ }),
+ appendPlaylistBrowserFile: async (filePath) => {
+ calls.push(['append', [filePath]]);
+ return { ok: true, message: 'append-ok' };
+ },
+ playPlaylistBrowserIndex: async (index) => {
+ calls.push(['play', [index]]);
+ return { ok: true, message: 'play-ok' };
+ },
+ removePlaylistBrowserIndex: async (index) => {
+ calls.push(['remove', [index]]);
+ return { ok: true, message: 'remove-ok' };
+ },
+ movePlaylistBrowserIndex: async (index, direction) => {
+ calls.push(['move', [index, direction]]);
+ return { ok: true, message: 'move-ok' };
+ },
+ }),
+ registrar,
+ );
+
+ const snapshot = (await handlers.handle.get(IPC_CHANNELS.request.getPlaylistBrowserSnapshot)?.(
+ {},
+ )) as PlaylistBrowserSnapshot | undefined;
+ const append = await handlers.handle.get(IPC_CHANNELS.request.appendPlaylistBrowserFile)?.(
+ {},
+ '/tmp/videos/ep3.mkv',
+ );
+ const play = await handlers.handle.get(IPC_CHANNELS.request.playPlaylistBrowserIndex)?.({}, 2);
+ const remove = await handlers.handle.get(IPC_CHANNELS.request.removePlaylistBrowserIndex)?.(
+ {},
+ 2,
+ );
+ const move = await handlers.handle.get(IPC_CHANNELS.request.movePlaylistBrowserIndex)?.(
+ {},
+ 2,
+ -1,
+ );
+
+ assert.equal(snapshot?.playingIndex, 1);
+ assert.deepEqual(append, { ok: true, message: 'append-ok' });
+ assert.deepEqual(play, { ok: true, message: 'play-ok' });
+ assert.deepEqual(remove, { ok: true, message: 'remove-ok' });
+ assert.deepEqual(move, { ok: true, message: 'move-ok' });
+ assert.deepEqual(calls, [
+ ['append', ['/tmp/videos/ep3.mkv']],
+ ['play', [2]],
+ ['remove', [2]],
+ ['move', [2, -1]],
+ ]);
+});
+
test('registerIpcHandlers rejects malformed runtime-option payloads', async () => {
const { registrar, handlers } = createFakeIpcRegistrar();
const calls: Array<{ id: string; value: unknown }> = [];
@@ -311,6 +410,19 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
+ getPlaylistBrowserSnapshot: async () => ({
+ directoryPath: null,
+ directoryAvailable: false,
+ directoryStatus: '',
+ directoryItems: [],
+ playlistItems: [],
+ playingIndex: null,
+ currentFilePath: null,
+ }),
+ appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
+ playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
+ removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
+ movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
},
registrar,
@@ -618,6 +730,19 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
+ getPlaylistBrowserSnapshot: async () => ({
+ directoryPath: null,
+ directoryAvailable: false,
+ directoryStatus: '',
+ directoryItems: [],
+ playlistItems: [],
+ playingIndex: null,
+ currentFilePath: null,
+ }),
+ appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
+ playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
+ removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
+ movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
},
registrar,
@@ -685,6 +810,19 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
+ getPlaylistBrowserSnapshot: async () => ({
+ directoryPath: null,
+ directoryAvailable: false,
+ directoryStatus: '',
+ directoryItems: [],
+ playlistItems: [],
+ playingIndex: null,
+ currentFilePath: null,
+ }),
+ appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
+ playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
+ removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
+ movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
},
registrar,
@@ -755,6 +893,19 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
+ getPlaylistBrowserSnapshot: async () => ({
+ directoryPath: null,
+ directoryAvailable: false,
+ directoryStatus: '',
+ directoryItems: [],
+ playlistItems: [],
+ playingIndex: null,
+ currentFilePath: null,
+ }),
+ appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
+ playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
+ removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
+ movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
},
registrar,
diff --git a/src/core/services/ipc.ts b/src/core/services/ipc.ts
index a20374fa..ff781457 100644
--- a/src/core/services/ipc.ts
+++ b/src/core/services/ipc.ts
@@ -2,6 +2,8 @@ import electron from 'electron';
import type { IpcMainEvent } from 'electron';
import type {
ControllerConfigUpdate,
+ PlaylistBrowserMutationResult,
+ PlaylistBrowserSnapshot,
ControllerPreferenceUpdate,
ResolvedControllerConfig,
RuntimeOptionId,
@@ -78,6 +80,14 @@ export interface IpcServiceDeps {
getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
+ getPlaylistBrowserSnapshot: () => Promise;
+ appendPlaylistBrowserFile: (filePath: string) => Promise;
+ playPlaylistBrowserIndex: (index: number) => Promise;
+ removePlaylistBrowserIndex: (index: number) => Promise;
+ movePlaylistBrowserIndex: (
+ index: number,
+ direction: 1 | -1,
+ ) => Promise;
immersionTracker?: {
recordYomitanLookup: () => void;
getSessionSummaries: (limit?: number) => Promise;
@@ -183,6 +193,14 @@ export interface IpcDepsRuntimeOptions {
getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
+ getPlaylistBrowserSnapshot: () => Promise;
+ appendPlaylistBrowserFile: (filePath: string) => Promise;
+ playPlaylistBrowserIndex: (index: number) => Promise;
+ removePlaylistBrowserIndex: (index: number) => Promise;
+ movePlaylistBrowserIndex: (
+ index: number,
+ direction: 1 | -1,
+ ) => Promise;
getImmersionTracker?: () => IpcServiceDeps['immersionTracker'];
}
@@ -246,6 +264,11 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
getAnilistQueueStatus: options.getAnilistQueueStatus,
retryAnilistQueueNow: options.retryAnilistQueueNow,
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
+ getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot,
+ appendPlaylistBrowserFile: options.appendPlaylistBrowserFile,
+ playPlaylistBrowserIndex: options.playPlaylistBrowserIndex,
+ removePlaylistBrowserIndex: options.removePlaylistBrowserIndex,
+ movePlaylistBrowserIndex: options.movePlaylistBrowserIndex,
get immersionTracker() {
return options.getImmersionTracker?.() ?? null;
},
@@ -510,6 +533,44 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.appendClipboardVideoToQueue();
});
+ ipc.handle(IPC_CHANNELS.request.getPlaylistBrowserSnapshot, async () => {
+ return await deps.getPlaylistBrowserSnapshot();
+ });
+
+ ipc.handle(IPC_CHANNELS.request.appendPlaylistBrowserFile, async (_event, filePath: unknown) => {
+ if (typeof filePath !== 'string' || filePath.trim().length === 0) {
+ return { ok: false, message: 'Invalid playlist browser file path.' };
+ }
+ return await deps.appendPlaylistBrowserFile(filePath);
+ });
+
+ ipc.handle(IPC_CHANNELS.request.playPlaylistBrowserIndex, async (_event, index: unknown) => {
+ if (!Number.isSafeInteger(index) || (index as number) < 0) {
+ return { ok: false, message: 'Invalid playlist browser index.' };
+ }
+ return await deps.playPlaylistBrowserIndex(index as number);
+ });
+
+ ipc.handle(IPC_CHANNELS.request.removePlaylistBrowserIndex, async (_event, index: unknown) => {
+ if (!Number.isSafeInteger(index) || (index as number) < 0) {
+ return { ok: false, message: 'Invalid playlist browser index.' };
+ }
+ return await deps.removePlaylistBrowserIndex(index as number);
+ });
+
+ ipc.handle(
+ IPC_CHANNELS.request.movePlaylistBrowserIndex,
+ async (_event, index: unknown, direction: unknown) => {
+ if (!Number.isSafeInteger(index) || (index as number) < 0) {
+ return { ok: false, message: 'Invalid playlist browser index.' };
+ }
+ if (direction !== 1 && direction !== -1) {
+ return { ok: false, message: 'Invalid playlist browser move direction.' };
+ }
+ return await deps.movePlaylistBrowserIndex(index as number, direction as 1 | -1);
+ },
+ );
+
// Stats request handlers
ipc.handle(IPC_CHANNELS.request.statsGetOverview, async () => {
const tracker = deps.immersionTracker;
diff --git a/src/core/services/overlay-visibility.test.ts b/src/core/services/overlay-visibility.test.ts
index a9928280..b43d7d79 100644
--- a/src/core/services/overlay-visibility.test.ts
+++ b/src/core/services/overlay-visibility.test.ts
@@ -238,7 +238,7 @@ test('visible overlay stays hidden while a modal window is active', () => {
assert.ok(!calls.includes('update-bounds'));
});
-test('macOS tracked visible overlay stays visible without passively stealing focus', () => {
+test('macOS tracked visible overlay stays click-through without passively stealing focus', () => {
const { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = {
isTracking: () => true,
@@ -270,7 +270,7 @@ test('macOS tracked visible overlay stays visible without passively stealing foc
isWindowsPlatform: false,
} as never);
- assert.ok(calls.includes('mouse-ignore:false:plain'));
+ assert.ok(calls.includes('mouse-ignore:true:forward'));
assert.ok(calls.includes('show'));
assert.ok(!calls.includes('focus'));
});
diff --git a/src/core/services/overlay-visibility.ts b/src/core/services/overlay-visibility.ts
index 080b2322..c74e6bbc 100644
--- a/src/core/services/overlay-visibility.ts
+++ b/src/core/services/overlay-visibility.ts
@@ -37,7 +37,7 @@ export function updateVisibleOverlayVisibility(args: {
const showPassiveVisibleOverlay = (): void => {
const forceMousePassthrough = args.forceMousePassthrough === true;
- if (args.isWindowsPlatform || forceMousePassthrough) {
+ if (args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough) {
mainWindow.setIgnoreMouseEvents(true, { forward: true });
} else {
mainWindow.setIgnoreMouseEvents(false);
diff --git a/src/main.ts b/src/main.ts
index 78ce4b0f..a1645ded 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -31,6 +31,7 @@ import {
screen,
} from 'electron';
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
+import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
import { mergeAiConfig } from './ai/config';
@@ -427,6 +428,7 @@ import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
+import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
import {
createFrequencyDictionaryRuntimeService,
@@ -1929,6 +1931,23 @@ function openRuntimeOptionsPalette(): void {
overlayVisibilityComposer.openRuntimeOptionsPalette();
}
+function openPlaylistBrowser(): void {
+ if (!appState.mpvClient?.connected) {
+ showMpvOsd('Playlist browser requires active playback.');
+ return;
+ }
+ const opened = openPlaylistBrowserRuntime({
+ ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
+ ensureOverlayWindowsReadyForVisibilityActions: () =>
+ ensureOverlayWindowsReadyForVisibilityActions(),
+ sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
+ sendToActiveOverlayWindow(channel, payload, runtimeOptions),
+ });
+ if (!opened) {
+ showMpvOsd('Playlist browser overlay unavailable.');
+ }
+}
+
function getResolvedConfig() {
return configService.getConfig();
}
@@ -4109,11 +4128,14 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
showMpvOsd: (text) => showMpvOsd(text),
});
+const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient);
+
const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
mpvCommandMainDeps: {
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
+ openPlaylistBrowser: () => openPlaylistBrowser(),
cycleRuntimeOption: (id, direction) => {
if (!appState.runtimeOptionsManager) {
return { ok: false, error: 'Runtime options manager unavailable' };
@@ -4290,6 +4312,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(),
+ ...playlistBrowserMainDeps,
getImmersionTracker: () => appState.immersionTracker,
},
ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({
diff --git a/src/main/dependencies.ts b/src/main/dependencies.ts
index f5113bac..74a87a34 100644
--- a/src/main/dependencies.ts
+++ b/src/main/dependencies.ts
@@ -93,6 +93,11 @@ export interface MainIpcRuntimeServiceDepsParams {
getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus'];
retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow'];
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
+ getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot'];
+ appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile'];
+ playPlaylistBrowserIndex: IpcDepsRuntimeOptions['playPlaylistBrowserIndex'];
+ removePlaylistBrowserIndex: IpcDepsRuntimeOptions['removePlaylistBrowserIndex'];
+ movePlaylistBrowserIndex: IpcDepsRuntimeOptions['movePlaylistBrowserIndex'];
getImmersionTracker?: IpcDepsRuntimeOptions['getImmersionTracker'];
}
@@ -193,6 +198,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig'];
openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette'];
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
+ openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
@@ -247,6 +253,11 @@ export function createMainIpcRuntimeServiceDeps(
getAnilistQueueStatus: params.getAnilistQueueStatus,
retryAnilistQueueNow: params.retryAnilistQueueNow,
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
+ getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot,
+ appendPlaylistBrowserFile: params.appendPlaylistBrowserFile,
+ playPlaylistBrowserIndex: params.playPlaylistBrowserIndex,
+ removePlaylistBrowserIndex: params.removePlaylistBrowserIndex,
+ movePlaylistBrowserIndex: params.movePlaylistBrowserIndex,
getImmersionTracker: params.getImmersionTracker,
};
}
@@ -358,6 +369,7 @@ export function createMpvCommandRuntimeServiceDeps(
triggerSubsyncFromConfig: params.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: params.openRuntimeOptionsPalette,
openYoutubeTrackPicker: params.openYoutubeTrackPicker,
+ openPlaylistBrowser: params.openPlaylistBrowser,
runtimeOptionsCycle: params.runtimeOptionsCycle,
showMpvOsd: params.showMpvOsd,
mpvReplaySubtitle: params.mpvReplaySubtitle,
diff --git a/src/main/ipc-mpv-command.ts b/src/main/ipc-mpv-command.ts
index aefea49d..4fa34174 100644
--- a/src/main/ipc-mpv-command.ts
+++ b/src/main/ipc-mpv-command.ts
@@ -13,6 +13,7 @@ export interface MpvCommandFromIpcRuntimeDeps {
triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void;
openYoutubeTrackPicker: () => void | Promise;
+ openPlaylistBrowser: () => void | Promise;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void;
replayCurrentSubtitle: () => void;
@@ -35,6 +36,7 @@ export function handleMpvCommandFromIpcRuntime(
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openYoutubeTrackPicker: deps.openYoutubeTrackPicker,
+ openPlaylistBrowser: deps.openPlaylistBrowser,
runtimeOptionsCycle: deps.cycleRuntimeOption,
showMpvOsd: deps.showMpvOsd,
mpvReplaySubtitle: deps.replayCurrentSubtitle,
diff --git a/src/main/runtime/anilist-post-watch.test.ts b/src/main/runtime/anilist-post-watch.test.ts
index b4461b55..94f2db6b 100644
--- a/src/main/runtime/anilist-post-watch.test.ts
+++ b/src/main/runtime/anilist-post-watch.test.ts
@@ -125,3 +125,54 @@ test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirel
await handler();
assert.deepEqual(calls, []);
});
+
+test('createMaybeRunAnilistPostWatchUpdateHandler does not live-update after retry already handled current attempt key', async () => {
+ const calls: string[] = [];
+ const attemptedKeys = new Set();
+ const mediaKey = '/tmp/video.mkv';
+ const attemptKey = buildAnilistAttemptKey(mediaKey, 1);
+ const handler = createMaybeRunAnilistPostWatchUpdateHandler({
+ getInFlight: () => false,
+ setInFlight: (value) => calls.push(`inflight:${value}`),
+ getResolvedConfig: () => ({}),
+ isAnilistTrackingEnabled: () => true,
+ getCurrentMediaKey: () => mediaKey,
+ hasMpvClient: () => true,
+ getTrackedMediaKey: () => mediaKey,
+ resetTrackedMedia: () => {},
+ getWatchedSeconds: () => 1000,
+ maybeProbeAnilistDuration: async () => 1000,
+ ensureAnilistMediaGuess: async () => ({ title: 'Show', season: null, episode: 1 }),
+ hasAttemptedUpdateKey: (key) => attemptedKeys.has(key),
+ processNextAnilistRetryUpdate: async () => {
+ attemptedKeys.add(attemptKey);
+ calls.push('process-retry');
+ return { ok: true, message: 'retry ok' };
+ },
+ refreshAnilistClientSecretState: async () => 'token',
+ enqueueRetry: () => calls.push('enqueue'),
+ markRetryFailure: () => calls.push('mark-failure'),
+ markRetrySuccess: () => calls.push('mark-success'),
+ refreshRetryQueueState: () => calls.push('refresh'),
+ updateAnilistPostWatchProgress: async () => {
+ calls.push('update');
+ return { status: 'updated', message: 'updated ok' };
+ },
+ rememberAttemptedUpdateKey: (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();
+
+ assert.equal(calls.includes('update'), false);
+ assert.equal(calls.includes('enqueue'), false);
+ assert.equal(calls.includes('mark-failure'), false);
+ assert.deepEqual(calls, ['inflight:true', 'process-retry', 'inflight:false']);
+});
diff --git a/src/main/runtime/anilist-post-watch.ts b/src/main/runtime/anilist-post-watch.ts
index 74fa8495..89bc3cc1 100644
--- a/src/main/runtime/anilist-post-watch.ts
+++ b/src/main/runtime/anilist-post-watch.ts
@@ -165,6 +165,9 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
deps.setInFlight(true);
try {
await deps.processNextAnilistRetryUpdate();
+ if (deps.hasAttemptedUpdateKey(attemptKey)) {
+ return;
+ }
const accessToken = await deps.refreshAnilistClientSecretState();
if (!accessToken) {
diff --git a/src/main/runtime/composers/ipc-runtime-composer.test.ts b/src/main/runtime/composers/ipc-runtime-composer.test.ts
index 6f404b25..9b3df025 100644
--- a/src/main/runtime/composers/ipc-runtime-composer.test.ts
+++ b/src/main/runtime/composers/ipc-runtime-composer.test.ts
@@ -11,6 +11,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {},
+ openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: true }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},
@@ -68,6 +69,20 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
getAnilistQueueStatus: () => ({}) as never,
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
+ getPlaylistBrowserSnapshot: async () =>
+ ({
+ directoryPath: null,
+ directoryAvailable: false,
+ directoryStatus: '',
+ directoryItems: [],
+ playlistItems: [],
+ playingIndex: null,
+ currentFilePath: null,
+ }) as never,
+ appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
+ playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
+ removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
+ movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
},
ankiJimakuDeps: {
diff --git a/src/main/runtime/ipc-bridge-actions-main-deps.test.ts b/src/main/runtime/ipc-bridge-actions-main-deps.test.ts
index 7dd5665e..4d1a3d8a 100644
--- a/src/main/runtime/ipc-bridge-actions-main-deps.test.ts
+++ b/src/main/runtime/ipc-bridge-actions-main-deps.test.ts
@@ -14,6 +14,7 @@ test('ipc bridge action main deps builders map callbacks', async () => {
triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {},
+ openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},
diff --git a/src/main/runtime/ipc-bridge-actions.test.ts b/src/main/runtime/ipc-bridge-actions.test.ts
index d4e142ac..5c237f14 100644
--- a/src/main/runtime/ipc-bridge-actions.test.ts
+++ b/src/main/runtime/ipc-bridge-actions.test.ts
@@ -11,6 +11,7 @@ test('handle mpv command handler forwards command and built deps', () => {
triggerSubsyncFromConfig: () => {},
openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {},
+ openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: () => {},
replayCurrentSubtitle: () => {},
diff --git a/src/main/runtime/ipc-mpv-command-main-deps.test.ts b/src/main/runtime/ipc-mpv-command-main-deps.test.ts
index edb61865..ebd59a19 100644
--- a/src/main/runtime/ipc-mpv-command-main-deps.test.ts
+++ b/src/main/runtime/ipc-mpv-command-main-deps.test.ts
@@ -10,6 +10,9 @@ test('ipc mpv command main deps builder maps callbacks', () => {
openYoutubeTrackPicker: () => {
calls.push('youtube-picker');
},
+ openPlaylistBrowser: () => {
+ calls.push('playlist-browser');
+ },
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: (text) => calls.push(`osd:${text}`),
replayCurrentSubtitle: () => calls.push('replay'),
@@ -26,6 +29,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
deps.triggerSubsyncFromConfig();
deps.openRuntimeOptionsPalette();
void deps.openYoutubeTrackPicker();
+ void deps.openPlaylistBrowser();
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
deps.showMpvOsd('hello');
deps.replayCurrentSubtitle();
@@ -39,6 +43,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
'subsync',
'palette',
'youtube-picker',
+ 'playlist-browser',
'osd:hello',
'replay',
'next',
diff --git a/src/main/runtime/ipc-mpv-command-main-deps.ts b/src/main/runtime/ipc-mpv-command-main-deps.ts
index fafca8d3..fd373179 100644
--- a/src/main/runtime/ipc-mpv-command-main-deps.ts
+++ b/src/main/runtime/ipc-mpv-command-main-deps.ts
@@ -7,6 +7,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
+ openPlaylistBrowser: () => deps.openPlaylistBrowser(),
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
showMpvOsd: (text: string) => deps.showMpvOsd(text),
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),
diff --git a/src/main/runtime/playlist-browser-ipc.ts b/src/main/runtime/playlist-browser-ipc.ts
new file mode 100644
index 00000000..3dbecf43
--- /dev/null
+++ b/src/main/runtime/playlist-browser-ipc.ts
@@ -0,0 +1,46 @@
+import type { RegisterIpcRuntimeServicesParams } from '../ipc-runtime';
+import {
+ appendPlaylistBrowserFileRuntime,
+ getPlaylistBrowserSnapshotRuntime,
+ movePlaylistBrowserIndexRuntime,
+ playPlaylistBrowserIndexRuntime,
+ removePlaylistBrowserIndexRuntime,
+ type PlaylistBrowserRuntimeDeps,
+} from './playlist-browser-runtime';
+
+type PlaylistBrowserMainDeps = Pick<
+ RegisterIpcRuntimeServicesParams['mainDeps'],
+ | 'getPlaylistBrowserSnapshot'
+ | 'appendPlaylistBrowserFile'
+ | 'playPlaylistBrowserIndex'
+ | 'removePlaylistBrowserIndex'
+ | 'movePlaylistBrowserIndex'
+>;
+
+export type PlaylistBrowserIpcRuntime = {
+ playlistBrowserRuntimeDeps: PlaylistBrowserRuntimeDeps;
+ playlistBrowserMainDeps: PlaylistBrowserMainDeps;
+};
+
+export function createPlaylistBrowserIpcRuntime(
+ getMpvClient: PlaylistBrowserRuntimeDeps['getMpvClient'],
+): PlaylistBrowserIpcRuntime {
+ const playlistBrowserRuntimeDeps: PlaylistBrowserRuntimeDeps = {
+ getMpvClient,
+ };
+
+ return {
+ playlistBrowserRuntimeDeps,
+ playlistBrowserMainDeps: {
+ getPlaylistBrowserSnapshot: () => getPlaylistBrowserSnapshotRuntime(playlistBrowserRuntimeDeps),
+ appendPlaylistBrowserFile: (filePath: string) =>
+ appendPlaylistBrowserFileRuntime(playlistBrowserRuntimeDeps, filePath),
+ playPlaylistBrowserIndex: (index: number) =>
+ playPlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index),
+ removePlaylistBrowserIndex: (index: number) =>
+ removePlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index),
+ movePlaylistBrowserIndex: (index: number, direction: 1 | -1) =>
+ movePlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index, direction),
+ },
+ };
+}
diff --git a/src/main/runtime/playlist-browser-open.test.ts b/src/main/runtime/playlist-browser-open.test.ts
new file mode 100644
index 00000000..970d10e9
--- /dev/null
+++ b/src/main/runtime/playlist-browser-open.test.ts
@@ -0,0 +1,28 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { IPC_CHANNELS } from '../../shared/ipc/contracts';
+import { openPlaylistBrowser } from './playlist-browser-open';
+
+test('playlist browser open bootstraps overlay runtime before dispatching the modal event', () => {
+ const calls: string[] = [];
+
+ const opened = openPlaylistBrowser({
+ ensureOverlayStartupPrereqs: () => {
+ calls.push('prereqs');
+ },
+ ensureOverlayWindowsReadyForVisibilityActions: () => {
+ calls.push('windows');
+ },
+ sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => {
+ calls.push(`send:${channel}`);
+ assert.equal(payload, undefined);
+ assert.deepEqual(runtimeOptions, {
+ restoreOnModalClose: 'playlist-browser',
+ });
+ return true;
+ },
+ });
+
+ assert.equal(opened, true);
+ assert.deepEqual(calls, ['prereqs', 'windows', `send:${IPC_CHANNELS.event.playlistBrowserOpen}`]);
+});
diff --git a/src/main/runtime/playlist-browser-open.ts b/src/main/runtime/playlist-browser-open.ts
new file mode 100644
index 00000000..ba4ce1f8
--- /dev/null
+++ b/src/main/runtime/playlist-browser-open.ts
@@ -0,0 +1,23 @@
+import type { OverlayHostedModal } from '../../shared/ipc/contracts';
+import { IPC_CHANNELS } from '../../shared/ipc/contracts';
+
+const PLAYLIST_BROWSER_MODAL: OverlayHostedModal = 'playlist-browser';
+
+export function openPlaylistBrowser(deps: {
+ ensureOverlayStartupPrereqs: () => void;
+ ensureOverlayWindowsReadyForVisibilityActions: () => void;
+ sendToActiveOverlayWindow: (
+ channel: string,
+ payload?: unknown,
+ runtimeOptions?: {
+ restoreOnModalClose?: OverlayHostedModal;
+ preferModalWindow?: boolean;
+ },
+ ) => boolean;
+}): boolean {
+ deps.ensureOverlayStartupPrereqs();
+ deps.ensureOverlayWindowsReadyForVisibilityActions();
+ return deps.sendToActiveOverlayWindow(IPC_CHANNELS.event.playlistBrowserOpen, undefined, {
+ restoreOnModalClose: PLAYLIST_BROWSER_MODAL,
+ });
+}
diff --git a/src/main/runtime/playlist-browser-runtime.test.ts b/src/main/runtime/playlist-browser-runtime.test.ts
new file mode 100644
index 00000000..6c0e2433
--- /dev/null
+++ b/src/main/runtime/playlist-browser-runtime.test.ts
@@ -0,0 +1,487 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import test, { type TestContext } from 'node:test';
+
+import type { PlaylistBrowserQueueItem } from '../../types';
+import {
+ appendPlaylistBrowserFileRuntime,
+ getPlaylistBrowserSnapshotRuntime,
+ movePlaylistBrowserIndexRuntime,
+ playPlaylistBrowserIndexRuntime,
+ removePlaylistBrowserIndexRuntime,
+} from './playlist-browser-runtime';
+
+type FakePlaylistEntry = {
+ current?: boolean;
+ playing?: boolean;
+ filename: string;
+ title?: string;
+ id?: number;
+};
+
+function createTempVideoDir(t: TestContext): string {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-playlist-browser-'));
+ t.after(() => {
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+ return dir;
+}
+
+function createFakeMpvClient(options: {
+ currentVideoPath: string;
+ playlist: FakePlaylistEntry[];
+ connected?: boolean;
+}) {
+ let playlist = options.playlist.map((item, index) => ({
+ id: item.id ?? index + 1,
+ current: item.current ?? false,
+ playing: item.playing ?? item.current ?? false,
+ filename: item.filename,
+ title: item.title ?? null,
+ }));
+ const commands: Array<(string | number)[]> = [];
+
+ const syncFlags = (): void => {
+ let playingIndex = playlist.findIndex((item) => item.current || item.playing);
+ if (playingIndex < 0 && playlist.length > 0) {
+ playingIndex = 0;
+ }
+ playlist = playlist.map((item, index) => ({
+ ...item,
+ current: index === playingIndex,
+ playing: index === playingIndex,
+ }));
+ };
+
+ syncFlags();
+
+ return {
+ connected: options.connected ?? true,
+ currentVideoPath: options.currentVideoPath,
+ async requestProperty(name: string): Promise {
+ if (name === 'playlist') {
+ return playlist;
+ }
+ if (name === 'playlist-playing-pos') {
+ return playlist.findIndex((item) => item.current || item.playing);
+ }
+ if (name === 'path') {
+ return this.currentVideoPath;
+ }
+ throw new Error(`Unexpected property: ${name}`);
+ },
+ send(payload: { command: unknown[] }): boolean {
+ const command = payload.command as (string | number)[];
+ commands.push(command);
+ const [action, first, second] = command;
+ if (action === 'loadfile' && typeof first === 'string' && second === 'append') {
+ playlist.push({
+ id: playlist.length + 1,
+ filename: first,
+ title: null,
+ current: false,
+ playing: false,
+ });
+ syncFlags();
+ return true;
+ }
+ if (action === 'playlist-play-index' && typeof first === 'number' && playlist[first]) {
+ playlist = playlist.map((item, index) => ({
+ ...item,
+ current: index === first,
+ playing: index === first,
+ }));
+ this.currentVideoPath = playlist[first]!.filename;
+ return true;
+ }
+ if (action === 'playlist-remove' && typeof first === 'number' && playlist[first]) {
+ const removingCurrent = playlist[first]!.current || playlist[first]!.playing;
+ playlist.splice(first, 1);
+ if (removingCurrent) {
+ syncFlags();
+ this.currentVideoPath =
+ playlist.find((item) => item.current || item.playing)?.filename ?? this.currentVideoPath;
+ }
+ return true;
+ }
+ if (
+ action === 'playlist-move' &&
+ typeof first === 'number' &&
+ typeof second === 'number' &&
+ playlist[first]
+ ) {
+ const [moved] = playlist.splice(first, 1);
+ playlist.splice(second, 0, moved!);
+ syncFlags();
+ return true;
+ }
+ return true;
+ },
+ getCommands(): Array<(string | number)[]> {
+ return commands;
+ },
+ };
+}
+
+test('getPlaylistBrowserSnapshotRuntime lists sibling videos in best-effort episode order', async (t) => {
+ const dir = createTempVideoDir(t);
+ const episode2 = path.join(dir, 'Show - S01E02.mkv');
+ const episode1 = path.join(dir, 'Show - S01E01.mkv');
+ const special = path.join(dir, 'Show - Special.mp4');
+ const ignored = path.join(dir, 'notes.txt');
+ fs.writeFileSync(episode2, '');
+ fs.writeFileSync(episode1, '');
+ fs.writeFileSync(special, '');
+ fs.writeFileSync(ignored, '');
+
+ const mpvClient = createFakeMpvClient({
+ currentVideoPath: episode2,
+ playlist: [
+ { filename: episode1, current: false, playing: false, title: 'Episode 1' },
+ { filename: episode2, current: true, playing: true, title: 'Episode 2' },
+ ],
+ });
+
+ const snapshot = await getPlaylistBrowserSnapshotRuntime({
+ getMpvClient: () => mpvClient,
+ });
+
+ assert.equal(snapshot.directoryAvailable, true);
+ assert.equal(snapshot.directoryPath, dir);
+ assert.equal(snapshot.currentFilePath, episode2);
+ assert.equal(snapshot.playingIndex, 1);
+ assert.deepEqual(
+ snapshot.directoryItems.map((item) => [item.basename, item.isCurrentFile]),
+ [
+ ['Show - S01E01.mkv', false],
+ ['Show - S01E02.mkv', true],
+ ['Show - Special.mp4', false],
+ ],
+ );
+ assert.deepEqual(
+ snapshot.playlistItems.map((item) => ({
+ index: item.index,
+ displayLabel: item.displayLabel,
+ current: item.current,
+ })),
+ [
+ { index: 0, displayLabel: 'Episode 1', current: false },
+ { index: 1, displayLabel: 'Episode 2', current: true },
+ ],
+ );
+});
+
+test('getPlaylistBrowserSnapshotRuntime clamps stale playing index to the playlist bounds', async (t) => {
+ const dir = createTempVideoDir(t);
+ const episode1 = path.join(dir, 'Show - S01E01.mkv');
+ const episode2 = path.join(dir, 'Show - S01E02.mkv');
+ fs.writeFileSync(episode1, '');
+ fs.writeFileSync(episode2, '');
+
+ const mpvClient = createFakeMpvClient({
+ currentVideoPath: episode1,
+ playlist: [
+ { filename: episode1, current: true, playing: true, title: 'Episode 1' },
+ { filename: episode2, title: 'Episode 2' },
+ ],
+ });
+ const requestProperty = mpvClient.requestProperty.bind(mpvClient);
+ mpvClient.requestProperty = async (name: string): Promise => {
+ if (name === 'playlist-playing-pos') {
+ return 99;
+ }
+ return requestProperty(name);
+ };
+
+ const snapshot = await getPlaylistBrowserSnapshotRuntime({
+ getMpvClient: () => mpvClient,
+ });
+
+ assert.equal(snapshot.playingIndex, 1);
+});
+
+test('getPlaylistBrowserSnapshotRuntime degrades directory pane for remote media', async () => {
+ const mpvClient = createFakeMpvClient({
+ currentVideoPath: 'https://example.com/video.m3u8',
+ playlist: [{ filename: 'https://example.com/video.m3u8', current: true }],
+ });
+
+ const snapshot = await getPlaylistBrowserSnapshotRuntime({
+ getMpvClient: () => mpvClient,
+ });
+
+ assert.equal(snapshot.directoryAvailable, false);
+ assert.equal(snapshot.directoryItems.length, 0);
+ assert.match(snapshot.directoryStatus, /local filesystem/i);
+ assert.equal(snapshot.playlistItems.length, 1);
+});
+
+test('playlist-browser mutation runtimes mutate queue and return refreshed snapshots', async (t) => {
+ const dir = createTempVideoDir(t);
+ const episode1 = path.join(dir, 'Show - S01E01.mkv');
+ const episode2 = path.join(dir, 'Show - S01E02.mkv');
+ const episode3 = path.join(dir, 'Show - S01E03.mkv');
+ fs.writeFileSync(episode1, '');
+ fs.writeFileSync(episode2, '');
+ fs.writeFileSync(episode3, '');
+
+ const mpvClient = createFakeMpvClient({
+ currentVideoPath: episode1,
+ playlist: [
+ { filename: episode1, current: true, title: 'Episode 1' },
+ { filename: episode2, title: 'Episode 2' },
+ ],
+ });
+
+ const scheduled: Array<{ callback: () => void; delayMs: number }> = [];
+ const deps = {
+ getMpvClient: () => mpvClient,
+ schedule: (callback: () => void, delayMs: number) => {
+ scheduled.push({ callback, delayMs });
+ },
+ };
+
+ const appendResult = await appendPlaylistBrowserFileRuntime(deps, episode3);
+ assert.equal(appendResult.ok, true);
+ assert.deepEqual(mpvClient.getCommands().at(-1), ['loadfile', episode3, 'append']);
+ assert.deepEqual(
+ appendResult.snapshot?.playlistItems.map((item) => item.path),
+ [episode1, episode2, episode3],
+ );
+
+ const moveResult = await movePlaylistBrowserIndexRuntime(deps, 2, -1);
+ assert.equal(moveResult.ok, true);
+ assert.deepEqual(mpvClient.getCommands().at(-1), ['playlist-move', 2, 1]);
+ assert.deepEqual(
+ moveResult.snapshot?.playlistItems.map((item) => item.path),
+ [episode1, episode3, episode2],
+ );
+
+ const playResult = await playPlaylistBrowserIndexRuntime(deps, 1);
+ assert.equal(playResult.ok, true);
+ assert.deepEqual(mpvClient.getCommands().slice(-2), [
+ ['set_property', 'sub-auto', 'fuzzy'],
+ ['playlist-play-index', 1],
+ ]);
+ assert.deepEqual(scheduled.map((entry) => entry.delayMs), [400]);
+ scheduled[0]?.callback();
+ assert.deepEqual(mpvClient.getCommands().slice(-2), [
+ ['set_property', 'sid', 'auto'],
+ ['set_property', 'secondary-sid', 'auto'],
+ ]);
+ assert.equal(playResult.snapshot?.playingIndex, 1);
+
+ const removeResult = await removePlaylistBrowserIndexRuntime(deps, 2);
+ assert.equal(removeResult.ok, true);
+ assert.deepEqual(mpvClient.getCommands().at(-1), ['playlist-remove', 2]);
+ assert.deepEqual(
+ removeResult.snapshot?.playlistItems.map((item) => item.path),
+ [episode1, episode3],
+ );
+});
+
+test('playlist-browser mutation runtimes report MPV send rejection', async (t) => {
+ const dir = createTempVideoDir(t);
+ const episode1 = path.join(dir, 'Show - S01E01.mkv');
+ const episode2 = path.join(dir, 'Show - S01E02.mkv');
+ const episode3 = path.join(dir, 'Show - S01E03.mkv');
+ fs.writeFileSync(episode1, '');
+ fs.writeFileSync(episode2, '');
+ fs.writeFileSync(episode3, '');
+
+ const mpvClient = createFakeMpvClient({
+ currentVideoPath: episode1,
+ playlist: [
+ { filename: episode1, current: true, title: 'Episode 1' },
+ { filename: episode2, title: 'Episode 2' },
+ { filename: episode3, title: 'Episode 3' },
+ ],
+ });
+ const scheduled: Array<{ callback: () => void; delayMs: number }> = [];
+ mpvClient.send = () => false;
+ const deps = {
+ getMpvClient: () => mpvClient,
+ schedule: (callback: () => void, delayMs: number) => {
+ scheduled.push({ callback, delayMs });
+ },
+ };
+
+ const appendResult = await appendPlaylistBrowserFileRuntime(deps, episode3);
+ assert.equal(appendResult.ok, false);
+ assert.equal(appendResult.snapshot, undefined);
+
+ const playResult = await playPlaylistBrowserIndexRuntime(deps, 1);
+ assert.equal(playResult.ok, false);
+ assert.equal(playResult.snapshot, undefined);
+ assert.deepEqual(scheduled, []);
+
+ const removeResult = await removePlaylistBrowserIndexRuntime(deps, 1);
+ assert.equal(removeResult.ok, false);
+ assert.equal(removeResult.snapshot, undefined);
+
+ const moveResult = await movePlaylistBrowserIndexRuntime(deps, 1, 1);
+ assert.equal(moveResult.ok, false);
+ assert.equal(moveResult.snapshot, undefined);
+});
+
+test('appendPlaylistBrowserFileRuntime returns an error result when statSync throws', async (t) => {
+ const dir = createTempVideoDir(t);
+ const episode1 = path.join(dir, 'Show - S01E01.mkv');
+ fs.writeFileSync(episode1, '');
+
+ const mutableFs = fs as typeof fs & { statSync: typeof fs.statSync };
+ const originalStatSync = mutableFs.statSync;
+ mutableFs.statSync = ((targetPath: fs.PathLike) => {
+ if (path.resolve(String(targetPath)) === episode1) {
+ throw new Error('EACCES');
+ }
+ return originalStatSync(targetPath);
+ }) as typeof fs.statSync;
+
+ try {
+ const result = await appendPlaylistBrowserFileRuntime(
+ {
+ getMpvClient: () =>
+ createFakeMpvClient({
+ currentVideoPath: episode1,
+ playlist: [{ filename: episode1, current: true }],
+ }),
+ },
+ episode1,
+ );
+
+ assert.deepEqual(result, {
+ ok: false,
+ message: 'Playlist browser file is not readable.',
+ });
+ } finally {
+ mutableFs.statSync = originalStatSync;
+ }
+});
+
+test('movePlaylistBrowserIndexRuntime rejects top and bottom boundary moves', async (t) => {
+ const dir = createTempVideoDir(t);
+ const episode1 = path.join(dir, 'Show - S01E01.mkv');
+ const episode2 = path.join(dir, 'Show - S01E02.mkv');
+ fs.writeFileSync(episode1, '');
+ fs.writeFileSync(episode2, '');
+
+ const mpvClient = createFakeMpvClient({
+ currentVideoPath: episode1,
+ playlist: [
+ { filename: episode1, current: true },
+ { filename: episode2 },
+ ],
+ });
+
+ const deps = {
+ getMpvClient: () => mpvClient,
+ };
+
+ const moveUp = await movePlaylistBrowserIndexRuntime(deps, 0, -1);
+ assert.deepEqual(moveUp, {
+ ok: false,
+ message: 'Playlist item is already at the top.',
+ });
+
+ const moveDown = await movePlaylistBrowserIndexRuntime(deps, 1, 1);
+ assert.deepEqual(moveDown, {
+ ok: false,
+ message: 'Playlist item is already at the bottom.',
+ });
+});
+
+test('getPlaylistBrowserSnapshotRuntime normalizes playlist labels from title then filename', async (t) => {
+ const dir = createTempVideoDir(t);
+ const episode1 = path.join(dir, 'Show - S01E01.mkv');
+ fs.writeFileSync(episode1, '');
+
+ const mpvClient = createFakeMpvClient({
+ currentVideoPath: episode1,
+ playlist: [{ filename: episode1, current: true, title: '' }],
+ });
+
+ const snapshot = await getPlaylistBrowserSnapshotRuntime({
+ getMpvClient: () => mpvClient,
+ });
+
+ const item = snapshot.playlistItems[0] as PlaylistBrowserQueueItem;
+ assert.equal(item.displayLabel, 'Show - S01E01.mkv');
+ assert.equal(item.path, episode1);
+});
+
+test('playPlaylistBrowserIndexRuntime skips local subtitle reset for remote playlist entries', async () => {
+ const scheduled: Array<{ callback: () => void; delayMs: number }> = [];
+ const mpvClient = createFakeMpvClient({
+ currentVideoPath: 'https://example.com/video-1.m3u8',
+ playlist: [
+ { filename: 'https://example.com/video-1.m3u8', current: true, title: 'Episode 1' },
+ { filename: 'https://example.com/video-2.m3u8', title: 'Episode 2' },
+ ],
+ });
+
+ const result = await playPlaylistBrowserIndexRuntime(
+ {
+ getMpvClient: () => mpvClient,
+ schedule: (callback, delayMs) => {
+ scheduled.push({ callback, delayMs });
+ },
+ },
+ 1,
+ );
+
+ assert.equal(result.ok, true);
+ assert.deepEqual(mpvClient.getCommands().slice(-1), [['playlist-play-index', 1]]);
+ assert.equal(scheduled.length, 0);
+});
+
+test('playPlaylistBrowserIndexRuntime ignores superseded local subtitle rearm callbacks', async (t) => {
+ const dir = createTempVideoDir(t);
+ const episode1 = path.join(dir, 'Show - S01E01.mkv');
+ const episode2 = path.join(dir, 'Show - S01E02.mkv');
+ const episode3 = path.join(dir, 'Show - S01E03.mkv');
+ fs.writeFileSync(episode1, '');
+ fs.writeFileSync(episode2, '');
+ fs.writeFileSync(episode3, '');
+
+ const scheduled: Array<() => void> = [];
+ const mpvClient = createFakeMpvClient({
+ currentVideoPath: episode1,
+ playlist: [
+ { filename: episode1, current: true, title: 'Episode 1' },
+ { filename: episode2, title: 'Episode 2' },
+ { filename: episode3, title: 'Episode 3' },
+ ],
+ });
+
+ const deps = {
+ getMpvClient: () => mpvClient,
+ schedule: (callback: () => void) => {
+ scheduled.push(callback);
+ },
+ };
+
+ const firstPlay = await playPlaylistBrowserIndexRuntime(deps, 1);
+ const secondPlay = await playPlaylistBrowserIndexRuntime(deps, 2);
+
+ assert.equal(firstPlay.ok, true);
+ assert.equal(secondPlay.ok, true);
+ assert.equal(scheduled.length, 2);
+
+ scheduled[0]?.();
+ scheduled[1]?.();
+
+ assert.deepEqual(
+ mpvClient.getCommands().slice(-6),
+ [
+ ['set_property', 'sub-auto', 'fuzzy'],
+ ['playlist-play-index', 1],
+ ['set_property', 'sub-auto', 'fuzzy'],
+ ['playlist-play-index', 2],
+ ['set_property', 'sid', 'auto'],
+ ['set_property', 'secondary-sid', 'auto'],
+ ],
+ );
+});
diff --git a/src/main/runtime/playlist-browser-runtime.ts b/src/main/runtime/playlist-browser-runtime.ts
new file mode 100644
index 00000000..2a9324a2
--- /dev/null
+++ b/src/main/runtime/playlist-browser-runtime.ts
@@ -0,0 +1,361 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import type {
+ PlaylistBrowserDirectoryItem,
+ PlaylistBrowserMutationResult,
+ PlaylistBrowserQueueItem,
+ PlaylistBrowserSnapshot,
+} from '../../types';
+import { isRemoteMediaPath } from '../../jimaku/utils';
+import { hasVideoExtension } from '../../shared/video-extensions';
+import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort';
+
+type PlaylistLike = {
+ filename?: unknown;
+ title?: unknown;
+ id?: unknown;
+ current?: unknown;
+ playing?: unknown;
+};
+
+type MpvPlaylistBrowserClientLike = {
+ connected: boolean;
+ currentVideoPath?: string | null;
+ requestProperty?: (name: string) => Promise;
+ send: (payload: { command: unknown[]; request_id?: number }) => boolean;
+};
+
+export type PlaylistBrowserRuntimeDeps = {
+ getMpvClient: () => MpvPlaylistBrowserClientLike | null;
+ schedule?: (callback: () => void, delayMs: number) => void;
+};
+
+const pendingLocalSubtitleSelectionRearms = new WeakMap();
+
+function trimToNull(value: unknown): string | null {
+ if (typeof value !== 'string') return null;
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : null;
+}
+
+async function readProperty(
+ client: MpvPlaylistBrowserClientLike | null,
+ name: string,
+): Promise {
+ if (!client?.requestProperty) return null;
+ try {
+ return await client.requestProperty(name);
+ } catch {
+ return null;
+ }
+}
+
+async function resolveCurrentFilePath(
+ client: MpvPlaylistBrowserClientLike | null,
+): Promise {
+ const currentVideoPath = trimToNull(client?.currentVideoPath);
+ if (currentVideoPath) return currentVideoPath;
+ return trimToNull(await readProperty(client, 'path'));
+}
+
+function resolveDirectorySnapshot(
+ currentFilePath: string | null,
+): Pick {
+ if (!currentFilePath) {
+ return {
+ directoryAvailable: false,
+ directoryItems: [],
+ directoryPath: null,
+ directoryStatus: 'Current media path is unavailable.',
+ };
+ }
+
+ if (isRemoteMediaPath(currentFilePath)) {
+ return {
+ directoryAvailable: false,
+ directoryItems: [],
+ directoryPath: null,
+ directoryStatus: 'Directory browser requires a local filesystem video.',
+ };
+ }
+
+ const resolvedPath = path.resolve(currentFilePath);
+ const directoryPath = path.dirname(resolvedPath);
+ try {
+ const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
+ const videoPaths = entries
+ .filter((entry) => entry.isFile())
+ .map((entry) => entry.name)
+ .filter((name) => hasVideoExtension(path.extname(name)))
+ .map((name) => path.join(directoryPath, name));
+
+ const directoryItems: PlaylistBrowserDirectoryItem[] = sortPlaylistBrowserDirectoryItems(
+ videoPaths,
+ ).map((item) => ({
+ ...item,
+ isCurrentFile: item.path === resolvedPath,
+ }));
+
+ return {
+ directoryAvailable: true,
+ directoryItems,
+ directoryPath,
+ directoryStatus: directoryPath,
+ };
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ return {
+ directoryAvailable: false,
+ directoryItems: [],
+ directoryPath,
+ directoryStatus: `Could not read parent directory: ${message}`,
+ };
+ }
+}
+
+function normalizePlaylistItems(raw: unknown): PlaylistBrowserQueueItem[] {
+ if (!Array.isArray(raw)) return [];
+ return raw.map((entry, index) => {
+ const item = (entry ?? {}) as PlaylistLike;
+ const filename = trimToNull(item.filename) ?? '';
+ const title = trimToNull(item.title);
+ const normalizedPath =
+ filename && !isRemoteMediaPath(filename) ? path.resolve(filename) : trimToNull(filename);
+ return {
+ index,
+ id: typeof item.id === 'number' ? item.id : null,
+ filename,
+ title,
+ displayLabel:
+ title ?? (path.basename(filename || '') || filename || `Playlist item ${index + 1}`),
+ current: item.current === true,
+ playing: item.playing === true,
+ path: normalizedPath,
+ };
+ });
+}
+
+function ensureConnectedClient(
+ deps: PlaylistBrowserRuntimeDeps,
+): MpvPlaylistBrowserClientLike | { ok: false; message: string } {
+ const client = deps.getMpvClient();
+ if (!client?.connected) {
+ return {
+ ok: false,
+ message: 'MPV is not connected.',
+ };
+ }
+ return client;
+}
+
+function buildRejectedCommandResult(): PlaylistBrowserMutationResult {
+ return {
+ ok: false,
+ message: 'Could not send command to MPV.',
+ };
+}
+
+async function getPlaylistItemsFromClient(
+ client: MpvPlaylistBrowserClientLike | null,
+): Promise {
+ return normalizePlaylistItems(await readProperty(client, 'playlist'));
+}
+
+function resolvePlayingIndex(
+ playlistItems: PlaylistBrowserQueueItem[],
+ playingPosValue: unknown,
+): number | null {
+ if (playlistItems.length === 0) {
+ return null;
+ }
+ if (typeof playingPosValue === 'number' && Number.isInteger(playingPosValue)) {
+ return Math.min(Math.max(playingPosValue, 0), playlistItems.length - 1);
+ }
+ const playingIndex = playlistItems.findIndex((item) => item.current || item.playing);
+ return playingIndex >= 0 ? playingIndex : null;
+}
+
+export async function getPlaylistBrowserSnapshotRuntime(
+ deps: PlaylistBrowserRuntimeDeps,
+): Promise {
+ const client = deps.getMpvClient();
+ const currentFilePath = await resolveCurrentFilePath(client);
+ const [playlistItems, playingPosValue] = await Promise.all([
+ getPlaylistItemsFromClient(client),
+ readProperty(client, 'playlist-playing-pos'),
+ ]);
+
+ return {
+ ...resolveDirectorySnapshot(currentFilePath),
+ playlistItems,
+ playingIndex: resolvePlayingIndex(playlistItems, playingPosValue),
+ currentFilePath,
+ };
+}
+
+async function validatePlaylistIndex(
+ deps: PlaylistBrowserRuntimeDeps,
+ index: number,
+): Promise<
+ | { ok: false; message: string }
+ | { ok: true; client: MpvPlaylistBrowserClientLike; playlistItems: PlaylistBrowserQueueItem[] }
+> {
+ const client = ensureConnectedClient(deps);
+ if ('ok' in client) {
+ return client;
+ }
+ const playlistItems = await getPlaylistItemsFromClient(client);
+ if (!Number.isInteger(index) || index < 0 || index >= playlistItems.length) {
+ return {
+ ok: false,
+ message: 'Playlist item not found.',
+ };
+ }
+ return {
+ ok: true,
+ client,
+ playlistItems,
+ };
+}
+
+async function buildMutationResult(
+ message: string,
+ deps: PlaylistBrowserRuntimeDeps,
+): Promise {
+ return {
+ ok: true,
+ message,
+ snapshot: await getPlaylistBrowserSnapshotRuntime(deps),
+ };
+}
+
+function rearmLocalSubtitleSelection(client: MpvPlaylistBrowserClientLike): void {
+ client.send({ command: ['set_property', 'sid', 'auto'] });
+ client.send({ command: ['set_property', 'secondary-sid', 'auto'] });
+}
+
+function prepareLocalSubtitleAutoload(client: MpvPlaylistBrowserClientLike): void {
+ client.send({ command: ['set_property', 'sub-auto', 'fuzzy'] });
+}
+
+function isLocalPlaylistItem(
+ item: PlaylistBrowserQueueItem | null | undefined,
+): item is PlaylistBrowserQueueItem & { path: string } {
+ return Boolean(item?.path && !isRemoteMediaPath(item.path));
+}
+
+function scheduleLocalSubtitleSelectionRearm(
+ deps: PlaylistBrowserRuntimeDeps,
+ client: MpvPlaylistBrowserClientLike,
+ expectedPath: string,
+): void {
+ const nextToken = (pendingLocalSubtitleSelectionRearms.get(client) ?? 0) + 1;
+ pendingLocalSubtitleSelectionRearms.set(client, nextToken);
+ (deps.schedule ?? setTimeout)(() => {
+ if (pendingLocalSubtitleSelectionRearms.get(client) !== nextToken) return;
+ pendingLocalSubtitleSelectionRearms.delete(client);
+ const currentPath = trimToNull(client.currentVideoPath);
+ if (currentPath && path.resolve(currentPath) !== expectedPath) {
+ return;
+ }
+ rearmLocalSubtitleSelection(client);
+ }, 400);
+}
+
+export async function appendPlaylistBrowserFileRuntime(
+ deps: PlaylistBrowserRuntimeDeps,
+ filePath: string,
+): Promise {
+ const client = ensureConnectedClient(deps);
+ if ('ok' in client) {
+ return client;
+ }
+ const resolvedPath = path.resolve(filePath);
+ let stats: fs.Stats;
+ try {
+ stats = fs.statSync(resolvedPath);
+ } catch {
+ return {
+ ok: false,
+ message: 'Playlist browser file is not readable.',
+ };
+ }
+ if (!stats.isFile()) {
+ return {
+ ok: false,
+ message: 'Playlist browser file is not readable.',
+ };
+ }
+
+ if (!client.send({ command: ['loadfile', resolvedPath, 'append'] })) {
+ return buildRejectedCommandResult();
+ }
+ return buildMutationResult(`Queued ${path.basename(resolvedPath)}`, deps);
+}
+
+export async function playPlaylistBrowserIndexRuntime(
+ deps: PlaylistBrowserRuntimeDeps,
+ index: number,
+): Promise {
+ const result = await validatePlaylistIndex(deps, index);
+ if (!result.ok) {
+ return result;
+ }
+
+ const targetItem = result.playlistItems[index] ?? null;
+ if (isLocalPlaylistItem(targetItem)) {
+ prepareLocalSubtitleAutoload(result.client);
+ }
+ if (!result.client.send({ command: ['playlist-play-index', index] })) {
+ return buildRejectedCommandResult();
+ }
+ if (isLocalPlaylistItem(targetItem)) {
+ scheduleLocalSubtitleSelectionRearm(deps, result.client, path.resolve(targetItem.path));
+ }
+ return buildMutationResult(`Playing playlist item ${index + 1}`, deps);
+}
+
+export async function removePlaylistBrowserIndexRuntime(
+ deps: PlaylistBrowserRuntimeDeps,
+ index: number,
+): Promise {
+ const result = await validatePlaylistIndex(deps, index);
+ if (!result.ok) {
+ return result;
+ }
+
+ if (!result.client.send({ command: ['playlist-remove', index] })) {
+ return buildRejectedCommandResult();
+ }
+ return buildMutationResult(`Removed playlist item ${index + 1}`, deps);
+}
+
+export async function movePlaylistBrowserIndexRuntime(
+ deps: PlaylistBrowserRuntimeDeps,
+ index: number,
+ direction: -1 | 1,
+): Promise {
+ const result = await validatePlaylistIndex(deps, index);
+ if (!result.ok) {
+ return result;
+ }
+
+ const targetIndex = index + direction;
+ if (targetIndex < 0) {
+ return {
+ ok: false,
+ message: 'Playlist item is already at the top.',
+ };
+ }
+ if (targetIndex >= result.playlistItems.length) {
+ return {
+ ok: false,
+ message: 'Playlist item is already at the bottom.',
+ };
+ }
+
+ if (!result.client.send({ command: ['playlist-move', index, targetIndex] })) {
+ return buildRejectedCommandResult();
+ }
+ return buildMutationResult(`Moved playlist item ${index + 1}`, deps);
+}
diff --git a/src/main/runtime/playlist-browser-sort.test.ts b/src/main/runtime/playlist-browser-sort.test.ts
new file mode 100644
index 00000000..f84cc8cd
--- /dev/null
+++ b/src/main/runtime/playlist-browser-sort.test.ts
@@ -0,0 +1,50 @@
+import assert from 'node:assert/strict';
+import path from 'node:path';
+import test from 'node:test';
+
+import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort';
+
+test('sortPlaylistBrowserDirectoryItems prefers parsed season and episode order', () => {
+ const root = '/library/show';
+ const items = sortPlaylistBrowserDirectoryItems([
+ path.join(root, 'Show - S01E10.mkv'),
+ path.join(root, 'Show - S01E02.mkv'),
+ path.join(root, 'Show - S01E01.mkv'),
+ path.join(root, 'Show - Episode 7.mkv'),
+ path.join(root, 'Show - 01x03.mkv'),
+ ]);
+
+ assert.deepEqual(
+ items.map((item) => item.basename),
+ [
+ 'Show - S01E01.mkv',
+ 'Show - S01E02.mkv',
+ 'Show - 01x03.mkv',
+ 'Show - Episode 7.mkv',
+ 'Show - S01E10.mkv',
+ ],
+ );
+ assert.deepEqual(
+ items.map((item) => item.episodeLabel),
+ ['S1E1', 'S1E2', 'S1E3', 'E7', 'S1E10'],
+ );
+});
+
+test('sortPlaylistBrowserDirectoryItems falls back to deterministic natural ordering', () => {
+ const root = '/library/show';
+ const items = sortPlaylistBrowserDirectoryItems([
+ path.join(root, 'Show Part 10.mkv'),
+ path.join(root, 'Show Part 2.mkv'),
+ path.join(root, 'Show Part 1.mkv'),
+ path.join(root, 'Show Special.mkv'),
+ ]);
+
+ assert.deepEqual(
+ items.map((item) => item.basename),
+ ['Show Part 1.mkv', 'Show Part 2.mkv', 'Show Part 10.mkv', 'Show Special.mkv'],
+ );
+ assert.deepEqual(
+ items.map((item) => item.episodeLabel),
+ [null, null, null, null],
+ );
+});
diff --git a/src/main/runtime/playlist-browser-sort.ts b/src/main/runtime/playlist-browser-sort.ts
new file mode 100644
index 00000000..b254a475
--- /dev/null
+++ b/src/main/runtime/playlist-browser-sort.ts
@@ -0,0 +1,129 @@
+import path from 'node:path';
+
+type ParsedEpisodeKey = {
+ season: number | null;
+ episode: number;
+};
+
+type SortToken = string | number;
+
+export type PlaylistBrowserSortedDirectoryItem = {
+ path: string;
+ basename: string;
+ episodeLabel: string | null;
+};
+
+const COLLATOR = new Intl.Collator(undefined, {
+ numeric: true,
+ sensitivity: 'base',
+});
+
+function parseEpisodeKey(basename: string): ParsedEpisodeKey | null {
+ const name = basename.replace(/\.[^.]+$/, '');
+ const seasonEpisode = name.match(/(?:^|[^a-z0-9])s(\d{1,2})\s*e(\d{1,3})(?:$|[^a-z0-9])/i);
+ if (seasonEpisode) {
+ return {
+ season: Number(seasonEpisode[1]),
+ episode: Number(seasonEpisode[2]),
+ };
+ }
+
+ const seasonByX = name.match(/(?:^|[^a-z0-9])(\d{1,2})x(\d{1,3})(?:$|[^a-z0-9])/i);
+ if (seasonByX) {
+ return {
+ season: Number(seasonByX[1]),
+ episode: Number(seasonByX[2]),
+ };
+ }
+
+ const namedEpisode = name.match(
+ /(?:^|[^a-z0-9])(?:ep|episode|第)\s*(\d{1,3})(?:\s*(?:話|episode|ep))?(?:$|[^a-z0-9])/i,
+ );
+ if (namedEpisode) {
+ return {
+ season: null,
+ episode: Number(namedEpisode[1]),
+ };
+ }
+
+ return null;
+}
+
+function buildEpisodeLabel(parsed: ParsedEpisodeKey | null): string | null {
+ if (!parsed) return null;
+ if (parsed.season !== null) {
+ return `S${parsed.season}E${parsed.episode}`;
+ }
+ return `E${parsed.episode}`;
+}
+
+function tokenizeNaturalSort(basename: string): SortToken[] {
+ return basename
+ .toLowerCase()
+ .split(/(\d+)/)
+ .filter((token) => token.length > 0)
+ .map((token) => (/^\d+$/.test(token) ? Number(token) : token));
+}
+
+function compareNaturalTokens(left: SortToken[], right: SortToken[]): number {
+ const maxLength = Math.max(left.length, right.length);
+ for (let index = 0; index < maxLength; index += 1) {
+ const a = left[index];
+ const b = right[index];
+ if (a === undefined) return -1;
+ if (b === undefined) return 1;
+ if (typeof a === 'number' && typeof b === 'number') {
+ if (a !== b) return a - b;
+ continue;
+ }
+ const comparison = COLLATOR.compare(String(a), String(b));
+ if (comparison !== 0) return comparison;
+ }
+ return 0;
+}
+
+export function sortPlaylistBrowserDirectoryItems(
+ paths: string[],
+): PlaylistBrowserSortedDirectoryItem[] {
+ return paths
+ .map((pathValue) => {
+ const basename = path.basename(pathValue);
+ const parsed = parseEpisodeKey(basename);
+ return {
+ path: pathValue,
+ basename,
+ parsed,
+ episodeLabel: buildEpisodeLabel(parsed),
+ naturalTokens: tokenizeNaturalSort(basename),
+ };
+ })
+ .sort((left, right) => {
+ if (left.parsed && right.parsed) {
+ if (
+ left.parsed.season !== null &&
+ right.parsed.season !== null &&
+ left.parsed.season !== right.parsed.season
+ ) {
+ return left.parsed.season - right.parsed.season;
+ }
+ if (left.parsed.episode !== right.parsed.episode) {
+ return left.parsed.episode - right.parsed.episode;
+ }
+ } else if (left.parsed && !right.parsed) {
+ return -1;
+ } else if (!left.parsed && right.parsed) {
+ return 1;
+ }
+
+ const naturalComparison = compareNaturalTokens(left.naturalTokens, right.naturalTokens);
+ if (naturalComparison !== 0) {
+ return naturalComparison;
+ }
+ return COLLATOR.compare(left.basename, right.basename);
+ })
+ .map(({ path: itemPath, basename, episodeLabel }) => ({
+ path: itemPath,
+ basename,
+ episodeLabel,
+ }));
+}
diff --git a/src/preload.ts b/src/preload.ts
index 8d0299d8..bc112f6a 100644
--- a/src/preload.ts
+++ b/src/preload.ts
@@ -38,6 +38,8 @@ import type {
SubsyncManualRunRequest,
SubsyncResult,
ClipboardAppendResult,
+ PlaylistBrowserMutationResult,
+ PlaylistBrowserSnapshot,
KikuFieldGroupingRequestData,
KikuFieldGroupingChoice,
KikuMergePreviewRequest,
@@ -126,6 +128,7 @@ const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload payload as YoutubePickerOpenPayload,
);
+const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.playlistBrowserOpen);
const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener(
IPC_CHANNELS.event.youtubePickerCancel,
);
@@ -322,11 +325,25 @@ const electronAPI: ElectronAPI = {
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
onOpenJimaku: onOpenJimakuEvent,
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
+ onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
appendClipboardVideoToQueue: (): Promise =>
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
+ getPlaylistBrowserSnapshot: (): Promise =>
+ ipcRenderer.invoke(IPC_CHANNELS.request.getPlaylistBrowserSnapshot),
+ appendPlaylistBrowserFile: (pathValue: string): Promise =>
+ ipcRenderer.invoke(IPC_CHANNELS.request.appendPlaylistBrowserFile, pathValue),
+ playPlaylistBrowserIndex: (index: number): Promise =>
+ ipcRenderer.invoke(IPC_CHANNELS.request.playPlaylistBrowserIndex, index),
+ removePlaylistBrowserIndex: (index: number): Promise =>
+ ipcRenderer.invoke(IPC_CHANNELS.request.removePlaylistBrowserIndex, index),
+ movePlaylistBrowserIndex: (
+ index: number,
+ direction: 1 | -1,
+ ): Promise =>
+ ipcRenderer.invoke(IPC_CHANNELS.request.movePlaylistBrowserIndex, index, direction),
youtubePickerResolve: (
request: YoutubePickerResolveRequest,
): Promise =>
diff --git a/src/renderer/handlers/keyboard.test.ts b/src/renderer/handlers/keyboard.test.ts
index a197098e..d81f1cbe 100644
--- a/src/renderer/handlers/keyboard.test.ts
+++ b/src/renderer/handlers/keyboard.test.ts
@@ -294,6 +294,7 @@ function createKeyboardHandlerHarness() {
let controllerSelectOpenCount = 0;
let controllerDebugOpenCount = 0;
let controllerSelectKeydownCount = 0;
+ let playlistBrowserKeydownCount = 0;
const createWordNode = (left: number) => ({
classList: createClassList(),
@@ -333,6 +334,10 @@ function createKeyboardHandlerHarness() {
},
handleControllerDebugKeydown: () => false,
handleYoutubePickerKeydown: () => false,
+ handlePlaylistBrowserKeydown: () => {
+ playlistBrowserKeydownCount += 1;
+ return true;
+ },
handleSessionHelpKeydown: () => false,
openSessionHelpModal: () => {},
appendClipboardVideoToQueue: () => {},
@@ -352,6 +357,7 @@ function createKeyboardHandlerHarness() {
controllerSelectOpenCount: () => controllerSelectOpenCount,
controllerDebugOpenCount: () => controllerDebugOpenCount,
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
+ playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
setWordCount: (count: number) => {
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
},
@@ -623,6 +629,49 @@ test('keyboard mode: controller select modal handles arrow keys before yomitan p
}
});
+test('keyboard mode: playlist browser modal handles arrow keys before yomitan popup', async () => {
+ const { ctx, testGlobals, handlers, playlistBrowserKeydownCount } =
+ createKeyboardHandlerHarness();
+
+ try {
+ await handlers.setupMpvInputForwarding();
+ ctx.state.playlistBrowserModalOpen = true;
+ ctx.state.yomitanPopupVisible = true;
+ testGlobals.setPopupVisible(true);
+
+ testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
+
+ assert.equal(playlistBrowserKeydownCount(), 1);
+ assert.equal(
+ testGlobals.commandEvents.some(
+ (event) => event.type === 'forwardKeyDown' && event.code === 'ArrowDown',
+ ),
+ false,
+ );
+ } finally {
+ testGlobals.restore();
+ }
+});
+
+test('keyboard mode: playlist browser modal handles h before lookup controls', async () => {
+ const { ctx, testGlobals, handlers, playlistBrowserKeydownCount } =
+ createKeyboardHandlerHarness();
+
+ try {
+ await handlers.setupMpvInputForwarding();
+ handlers.handleKeyboardModeToggleRequested();
+ ctx.state.playlistBrowserModalOpen = true;
+ ctx.state.keyboardSelectedWordIndex = 2;
+
+ testGlobals.dispatchKeydown({ key: 'h', code: 'KeyH' });
+
+ assert.equal(playlistBrowserKeydownCount(), 1);
+ assert.equal(ctx.state.keyboardSelectedWordIndex, 2);
+ } finally {
+ testGlobals.restore();
+ }
+});
+
test('keyboard mode: configured stats toggle works even while popup is open', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness();
diff --git a/src/renderer/handlers/keyboard.ts b/src/renderer/handlers/keyboard.ts
index ac4b294e..a98bcea9 100644
--- a/src/renderer/handlers/keyboard.ts
+++ b/src/renderer/handlers/keyboard.ts
@@ -16,6 +16,7 @@ export function createKeyboardHandlers(
handleKikuKeydown: (e: KeyboardEvent) => boolean;
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
+ handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
@@ -815,6 +816,12 @@ export function createKeyboardHandlers(
return;
}
+ if (ctx.state.playlistBrowserModalOpen) {
+ if (options.handlePlaylistBrowserKeydown(e)) {
+ return;
+ }
+ }
+
if (handleKeyboardDrivenModeLookupControls(e)) {
e.preventDefault();
return;
diff --git a/src/renderer/index.html b/src/renderer/index.html
index c16b8edf..2221946a 100644
--- a/src/renderer/index.html
+++ b/src/renderer/index.html
@@ -320,6 +320,35 @@
+