Add playlist browser overlay modal (#37)

This commit is contained in:
2026-03-31 12:28:29 -07:00
committed by GitHub
parent f9a4039ad2
commit d51e7fe401
71 changed files with 4586 additions and 643 deletions

View File

@@ -1,5 +1,10 @@
# Changelog # 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) ## v0.10.0 (2026-03-29)
### Changed ### Changed

View File

@@ -63,6 +63,12 @@ Local stats dashboard — watch time, anime library, vocabulary growth, mining t
<br> <br>
### 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.
<br>
### Integrations ### Integrations
<table> <table>

View File

@@ -1,9 +1,11 @@
--- ---
id: TASK-255 id: TASK-255
title: Add overlay playlist browser modal for sibling video files and mpv queue title: Add overlay playlist browser modal for sibling video files and mpv queue
status: To Do status: In Progress
assignee: [] assignee:
- '@codex'
created_date: '2026-03-30 05:46' created_date: '2026-03-30 05:46'
updated_date: '2026-03-31 05:59'
labels: labels:
- feature - feature
- overlay - 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. - [ ] #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. - [ ] #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. - [ ] #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. - [x] #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] #6 Feature coverage includes automated tests for ordering/playlist behavior and docs or shortcut/help updates for the new modal.
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
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.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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`.
<!-- SECTION:NOTES:END -->

View File

@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
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.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
Current-epoch millisecond values are being truncated by the libsql driver when bound as numeric parameters, which corrupts session, telemetry, lifetime, and rollup timestamps.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #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
<!-- AC:END -->

View File

@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
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.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -471,6 +471,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
| `Space` | `["cycle", "pause"]` | Toggle pause | | `Space` | `["cycle", "pause"]` | Toggle pause |
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track | | `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary 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 | | `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds | | `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
| `ArrowLeft` | `["seek", -5]` | Seek backward 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 } { "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:<id>[: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:<id>[:next|prev]` cycles a runtime option value.
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.) **Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)

View File

@@ -40,6 +40,7 @@ These control playback and subtitle display. They require overlay window focus.
| `Space` | Toggle mpv pause | | `Space` | Toggle mpv pause |
| `J` | Cycle primary subtitle track | | `J` | Cycle primary subtitle track |
| `Shift+J` | Cycle secondary subtitle track | | `Shift+J` | Cycle secondary subtitle track |
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
| `ArrowRight` | Seek forward 5 seconds | | `ArrowRight` | Seek forward 5 seconds |
| `ArrowLeft` | Seek backward 5 seconds | | `ArrowLeft` | Seek backward 5 seconds |
| `ArrowUp` | Seek forward 60 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) | | `Right-click + drag` | Reposition subtitles (on subtitle area) |
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | | `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). Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave).

View File

@@ -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. `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`. 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 ### Drag-and-Drop

View File

@@ -34,6 +34,17 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_pause_until_ready, false) return options_helper.coerce_bool(raw_pause_until_ready, false)
end 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 function resolve_pause_until_ready_timeout_seconds()
local raw_timeout_seconds = opts.auto_start_pause_until_ready_timeout_seconds local raw_timeout_seconds = opts.auto_start_pause_until_ready_timeout_seconds
if raw_timeout_seconds == nil then if raw_timeout_seconds == nil then
@@ -192,10 +203,7 @@ function M.create(ctx)
table.insert(args, "--hide-visible-overlay") table.insert(args, "--hide-visible-overlay")
end end
local texthooker_enabled = overrides.texthooker_enabled local texthooker_enabled = resolve_texthooker_enabled(overrides.texthooker_enabled)
if texthooker_enabled == nil then
texthooker_enabled = opts.texthooker_enabled
end
if texthooker_enabled then if texthooker_enabled then
table.insert(args, "--texthooker") table.insert(args, "--texthooker")
end end
@@ -296,10 +304,7 @@ function M.create(ctx)
return return
end end
local texthooker_enabled = overrides.texthooker_enabled local texthooker_enabled = resolve_texthooker_enabled(overrides.texthooker_enabled)
if texthooker_enabled == nil then
texthooker_enabled = opts.texthooker_enabled
end
local socket_path = overrides.socket_path or opts.socket_path local socket_path = overrides.socket_path or opts.socket_path
local should_pause_until_ready = ( local should_pause_until_ready = (
overrides.auto_start_trigger == true overrides.auto_start_trigger == true
@@ -498,7 +503,7 @@ function M.create(ctx)
end end
end) end)
if opts.texthooker_enabled then if resolve_texthooker_enabled(nil) then
ensure_texthooker_running(function() end) ensure_texthooker_running(function() end)
end end
end) end)

View File

@@ -531,6 +531,31 @@ do
) )
end 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 do
local recorded, err = run_plugin_scenario({ local recorded, err = run_plugin_scenario({
process_list = "", process_list = "",

View File

@@ -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('KeyJ'), ['cycle', 'sid']);
assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-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+KeyC'), ['__youtube-picker-open']);
assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyP'), ['__playlist-browser-open']);
}); });
test('default keybindings include fullscreen on F', () => { test('default keybindings include fullscreen on F', () => {

View File

@@ -47,6 +47,7 @@ export const SPECIAL_COMMANDS = {
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line', SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line', SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open', YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
} as const; } as const;
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
@@ -66,6 +67,7 @@ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START], command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START],
}, },
{ key: 'Ctrl+Alt+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] }, { 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+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
{ key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] }, { key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] },
{ key: 'KeyQ', command: ['quit'] }, { key: 'KeyQ', command: ['quit'] },

View File

@@ -5,6 +5,7 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { toMonthKey } from './immersion-tracker/maintenance'; import { toMonthKey } from './immersion-tracker/maintenance';
import { enqueueWrite } from './immersion-tracker/queue'; import { enqueueWrite } from './immersion-tracker/queue';
import { toDbTimestamp } from './immersion-tracker/query-shared';
import { Database, type DatabaseSync } from './immersion-tracker/sqlite'; import { Database, type DatabaseSync } from './immersion-tracker/sqlite';
import { nowMs as trackerNowMs } from './immersion-tracker/time'; import { nowMs as trackerNowMs } from './immersion-tracker/time';
import { import {
@@ -185,7 +186,7 @@ test('destroy finalizes active session and persists final telemetry', async () =
const db = new Database(dbPath); const db = new Database(dbPath);
const sessionRow = db.prepare('SELECT ended_at_ms FROM imm_sessions LIMIT 1').get() as { 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; } | null;
const telemetryCountRow = db const telemetryCountRow = db
.prepare('SELECT COUNT(*) AS total FROM imm_session_telemetry') .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(); db.close();
assert.ok(sessionRow); 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); assert.ok(Number(telemetryCountRow.total) >= 2);
} finally { } finally {
tracker?.destroy(); tracker?.destroy();
@@ -504,7 +505,7 @@ test('rebuildLifetimeSummaries backfills retained ended sessions and resets stal
episodes_started: number; episodes_started: number;
episodes_completed: number; episodes_completed: number;
anime_completed: number; anime_completed: number;
last_rebuilt_ms: number | null; last_rebuilt_ms: string | number | null;
} | null; } | null;
const appliedSessions = rebuildApi.db const appliedSessions = rebuildApi.db
.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions') .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_started, 2);
assert.equal(globalRow?.episodes_completed, 2); assert.equal(globalRow?.episodes_completed, 2);
assert.equal(globalRow?.anime_completed, 1); 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); assert.equal(appliedSessions?.total, 2);
} finally { } finally {
tracker?.destroy(); tracker?.destroy();
@@ -629,7 +630,8 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
const startedAtMs = trackerNowMs() - 10_000; const startedAtMs = trackerNowMs() - 10_000;
const sampleMs = startedAtMs + 5_000; const sampleMs = startedAtMs + 5_000;
db.exec(` db.prepare(
`
INSERT INTO imm_anime ( INSERT INTO imm_anime (
anime_id, anime_id,
canonical_title, canonical_title,
@@ -637,15 +639,12 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
episodes_total, episodes_total,
CREATED_DATE, CREATED_DATE,
LAST_UPDATE_DATE LAST_UPDATE_DATE
) VALUES ( ) VALUES (?, ?, ?, ?, ?, ?)
1, `,
'KonoSuba', ).run(1, 'KonoSuba', 'konosuba', 10, toDbTimestamp(startedAtMs), toDbTimestamp(startedAtMs));
'konosuba',
10,
${startedAtMs},
${startedAtMs}
);
db.prepare(
`
INSERT INTO imm_videos ( INSERT INTO imm_videos (
video_id, video_id,
video_key, video_key,
@@ -656,7 +655,9 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
duration_ms, duration_ms,
CREATED_DATE, CREATED_DATE,
LAST_UPDATE_DATE LAST_UPDATE_DATE
) VALUES ( ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
1, 1,
'local:/tmp/konosuba-s02e05.mkv', 'local:/tmp/konosuba-s02e05.mkv',
'KonoSuba S02E05', 'KonoSuba S02E05',
@@ -664,10 +665,12 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
1, 1,
1, 1,
0, 0,
${startedAtMs}, toDbTimestamp(startedAtMs),
${startedAtMs} toDbTimestamp(startedAtMs),
); );
db.prepare(
`
INSERT INTO imm_sessions ( INSERT INTO imm_sessions (
session_id, session_id,
session_uuid, session_uuid,
@@ -677,17 +680,21 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
ended_media_ms, ended_media_ms,
CREATED_DATE, CREATED_DATE,
LAST_UPDATE_DATE LAST_UPDATE_DATE
) VALUES ( ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
1, 1,
'11111111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111',
1, 1,
${startedAtMs}, toDbTimestamp(startedAtMs),
1, 1,
321000, 321000,
${startedAtMs}, toDbTimestamp(startedAtMs),
${sampleMs} toDbTimestamp(sampleMs),
); );
db.prepare(
`
INSERT INTO imm_session_telemetry ( INSERT INTO imm_session_telemetry (
session_id, session_id,
sample_ms, sample_ms,
@@ -703,23 +710,9 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
seek_forward_count, seek_forward_count,
seek_backward_count, seek_backward_count,
media_buffer_events media_buffer_events
) VALUES ( ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1, `,
${sampleMs}, ).run(1, toDbTimestamp(sampleMs), 5000, 4000, 12, 120, 2, 5, 3, 1, 250, 1, 0, 0);
5000,
4000,
12,
120,
2,
5,
3,
1,
250,
1,
0,
0
);
`);
tracker.destroy(); tracker.destroy();
tracker = new Ctor({ dbPath }); tracker = new Ctor({ dbPath });
@@ -734,7 +727,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
`, `,
) )
.get() as { .get() as {
ended_at_ms: number | null; ended_at_ms: string | number | null;
status: number; status: number;
ended_media_ms: number | null; ended_media_ms: number | null;
active_watched_ms: number; active_watched_ms: number;
@@ -769,7 +762,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
.get() as { total: number } | null; .get() as { total: number } | null;
assert.ok(sessionRow); 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?.status, 2);
assert.equal(sessionRow?.ended_media_ms, 321_000); assert.equal(sessionRow?.ended_media_ms, 321_000);
assert.equal(sessionRow?.active_watched_ms, 4000); assert.equal(sessionRow?.active_watched_ms, 4000);

View File

@@ -309,6 +309,9 @@ export class ImmersionTrackerService {
private readonly eventsRetentionMs: number; private readonly eventsRetentionMs: number;
private readonly telemetryRetentionMs: number; private readonly telemetryRetentionMs: number;
private readonly sessionsRetentionMs: 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 dailyRollupRetentionMs: number;
private readonly monthlyRollupRetentionMs: number; private readonly monthlyRollupRetentionMs: number;
private readonly vacuumIntervalMs: number; private readonly vacuumIntervalMs: number;
@@ -365,46 +368,54 @@ export class ImmersionTrackerService {
); );
const retention = policy.retention ?? {}; const retention = policy.retention ?? {};
const daysToRetentionMs = ( const daysToRetentionWindow = (
value: number | undefined, value: number | undefined,
fallbackMs: number, fallbackDays: number,
maxDays: number, maxDays: number,
): number => { ): { ms: number; days: number | null } => {
const fallbackDays = Math.floor(fallbackMs / 86_400_000);
const resolvedDays = resolveBoundedInt(value, fallbackDays, 0, maxDays); 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, retention.eventsDays,
DEFAULT_EVENTS_RETENTION_MS, 7,
3650, 3650,
); );
this.telemetryRetentionMs = daysToRetentionMs( const telemetryRetention = daysToRetentionWindow(
retention.telemetryDays, retention.telemetryDays,
DEFAULT_TELEMETRY_RETENTION_MS, 30,
3650, 3650,
); );
this.sessionsRetentionMs = daysToRetentionMs( const sessionsRetention = daysToRetentionWindow(
retention.sessionsDays, retention.sessionsDays,
DEFAULT_SESSIONS_RETENTION_MS, 30,
3650, 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, retention.dailyRollupsDays,
DEFAULT_DAILY_ROLLUP_RETENTION_MS, 365,
36500, 36500,
); ).ms;
this.monthlyRollupRetentionMs = daysToRetentionMs( this.monthlyRollupRetentionMs = daysToRetentionWindow(
retention.monthlyRollupsDays, retention.monthlyRollupsDays,
DEFAULT_MONTHLY_ROLLUP_RETENTION_MS, 5 * 365,
36500, 36500,
); ).ms;
this.vacuumIntervalMs = daysToRetentionMs( this.vacuumIntervalMs = daysToRetentionWindow(
retention.vacuumIntervalDays, retention.vacuumIntervalDays,
DEFAULT_VACUUM_INTERVAL_MS, 7,
3650, 3650,
); ).ms;
this.db = new Database(this.dbPath); this.db = new Database(this.dbPath);
applyPragmas(this.db); applyPragmas(this.db);
ensureSchema(this.db); ensureSchema(this.db);
@@ -1604,6 +1615,9 @@ export class ImmersionTrackerService {
eventsRetentionMs: this.eventsRetentionMs, eventsRetentionMs: this.eventsRetentionMs,
telemetryRetentionMs: this.telemetryRetentionMs, telemetryRetentionMs: this.telemetryRetentionMs,
sessionsRetentionMs: this.sessionsRetentionMs, sessionsRetentionMs: this.sessionsRetentionMs,
eventsRetentionDays: this.eventsRetentionDays ?? undefined,
telemetryRetentionDays: this.telemetryRetentionDays ?? undefined,
sessionsRetentionDays: this.sessionsRetentionDays ?? undefined,
}); });
} }
if ( if (

View File

@@ -50,6 +50,7 @@ import {
updateAnimeAnilistInfo, updateAnimeAnilistInfo,
upsertCoverArt, upsertCoverArt,
} from '../query-maintenance.js'; } from '../query-maintenance.js';
import { getLocalEpochDay } from '../query-shared.js';
import { EVENT_CARD_MINED, EVENT_SUBTITLE_LINE, SOURCE_TYPE_LOCAL } from '../types.js'; import { EVENT_CARD_MINED, EVENT_SUBTITLE_LINE, SOURCE_TYPE_LOCAL } from '../types.js';
function makeDbPath(): string { function makeDbPath(): string {
@@ -360,9 +361,6 @@ test('split library helpers return anime/media session and analytics rows', () =
try { try {
const now = new Date(); const now = new Date();
const todayLocalDay = Math.floor(
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 86_400_000,
);
const animeId = getOrCreateAnimeRecord(db, { const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Library Anime', parsedTitle: 'Library Anime',
canonicalTitle: 'Library Anime', canonicalTitle: 'Library Anime',
@@ -398,6 +396,7 @@ test('split library helpers return anime/media session and analytics rows', () =
0, 0,
).getTime(); ).getTime();
const sessionId = startSessionRecord(db, videoId, startedAtMs).sessionId; const sessionId = startSessionRecord(db, videoId, startedAtMs).sessionId;
const todayLocalDay = getLocalEpochDay(db, startedAtMs);
finalizeSessionMetrics(db, sessionId, startedAtMs, { finalizeSessionMetrics(db, sessionId, startedAtMs, {
endedAtMs: startedAtMs + 55_000, endedAtMs: startedAtMs + 55_000,
totalWatchedMs: 55_000, totalWatchedMs: 55_000,

View File

@@ -37,6 +37,11 @@ import {
getWordOccurrences, getWordOccurrences,
upsertCoverArt, upsertCoverArt,
} from '../query.js'; } from '../query.js';
import {
getShiftedLocalDaySec,
getStartOfLocalDayTimestamp,
toDbTimestamp,
} from '../query-shared.js';
import { import {
SOURCE_TYPE_LOCAL, SOURCE_TYPE_LOCAL,
SOURCE_TYPE_REMOTE, SOURCE_TYPE_REMOTE,
@@ -81,29 +86,13 @@ function cleanupDbPath(dbPath: string): void {
} }
} }
function withMockDate<T>(fixedDate: Date, run: (realDate: typeof Date) => T): T { function withMockNowMs<T>(fixedDateMs: string | number, run: () => T): T {
const realDate = Date; const previousNowMs = globalThis.__subminerTestNowMs;
const fixedDateMs = fixedDate.getTime(); globalThis.__subminerTestNowMs = fixedDateMs;
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;
try { try {
return run(realDate); return run();
} finally { } finally {
globalThis.Date = realDate; globalThis.__subminerTestNowMs = previousNowMs;
} }
} }
@@ -613,7 +602,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
] as const) { ] as const) {
stmts.telemetryInsertStmt.run( stmts.telemetryInsertStmt.run(
sessionId, sessionId,
startedAtMs + 60_000, `${startedAtMs + 60_000}`,
activeWatchedMs, activeWatchedMs,
activeWatchedMs, activeWatchedMs,
10, 10,
@@ -626,8 +615,8 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
0, 0,
0, 0,
0, 0,
startedAtMs + 60_000, `${startedAtMs + 60_000}`,
startedAtMs + 60_000, `${startedAtMs + 60_000}`,
); );
db.prepare( db.prepare(
@@ -644,7 +633,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
WHERE session_id = ? WHERE session_id = ?
`, `,
).run( ).run(
startedAtMs + activeWatchedMs, `${startedAtMs + activeWatchedMs}`,
activeWatchedMs, activeWatchedMs,
activeWatchedMs, activeWatchedMs,
10, 10,
@@ -687,8 +676,8 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
'名詞', '名詞',
null, null,
null, null,
Math.floor(dayOneStart / 1000), String(Math.floor(dayOneStart / 1000)),
Math.floor(dayTwoStart / 1000), String(Math.floor(dayTwoStart / 1000)),
); );
const dashboard = getTrendsDashboard(db, 'all', 'day'); const dashboard = getTrendsDashboard(db, 'all', 'day');
@@ -743,18 +732,51 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
parseMetadataJson: null, parseMetadataJson: null,
}); });
const beforeMidnight = new Date(2026, 2, 1, 23, 30).getTime(); const boundaryMs = BigInt(getStartOfLocalDayTimestamp(db, '1772436600000'));
const afterMidnight = new Date(2026, 2, 2, 0, 30).getTime(); const beforeMidnight = (boundaryMs - 1n).toString();
const firstSessionId = startSessionRecord(db, videoId, beforeMidnight).sessionId; const afterMidnight = (boundaryMs + 1n).toString();
const secondSessionId = startSessionRecord(db, videoId, afterMidnight).sessionId; 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 [ for (const [sessionId, startedAtMs, tokensSeen, lookupCount] of [
[firstSessionId, beforeMidnight, 100, 4], [firstSessionId, beforeMidnight, 100, 4],
[secondSessionId, afterMidnight, 120, 6], [secondSessionId, afterMidnight, 120, 6],
] as const) { ] as const) {
const endedAtMs = (BigInt(startedAtMs) + 60_000n).toString();
stmts.telemetryInsertStmt.run( stmts.telemetryInsertStmt.run(
sessionId, sessionId,
startedAtMs + 60_000, endedAtMs,
60_000, 60_000,
60_000, 60_000,
1, 1,
@@ -767,8 +789,8 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
0, 0,
0, 0,
0, 0,
startedAtMs + 60_000, endedAtMs,
startedAtMs + 60_000, endedAtMs,
); );
db.prepare( db.prepare(
` `
@@ -787,7 +809,7 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
WHERE session_id = ? WHERE session_id = ?
`, `,
).run( ).run(
startedAtMs + 60_000, endedAtMs,
60_000, 60_000,
60_000, 60_000,
1, 1,
@@ -795,7 +817,7 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
lookupCount, lookupCount,
lookupCount, lookupCount,
lookupCount, lookupCount,
startedAtMs + 60_000, endedAtMs,
sessionId, 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', () => { test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);
withMockDate(new Date(2026, 2, 1, 12, 0, 0), (RealDate) => { withMockNowMs('1772395200000', () => {
try { try {
ensureSchema(db); ensureSchema(db);
const stmts = createTrackerPreparedStatements(db); const stmts = createTrackerPreparedStatements(db);
@@ -862,18 +884,50 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
parseMetadataJson: null, parseMetadataJson: null,
}); });
const febStartedAtMs = new RealDate(2026, 1, 15, 20, 0, 0).getTime(); const febStartedAtMs = '1771214400000';
const marStartedAtMs = new RealDate(2026, 2, 1, 9, 0, 0).getTime(); const marStartedAtMs = '1772384400000';
const febSessionId = startSessionRecord(db, febVideoId, febStartedAtMs).sessionId; const febSessionId = 1;
const marSessionId = startSessionRecord(db, marVideoId, marStartedAtMs).sessionId; 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 [ for (const [sessionId, startedAtMs, tokensSeen, cardsMined, yomitanLookupCount] of [
[febSessionId, febStartedAtMs, 100, 2, 3], [febSessionId, febStartedAtMs, 100, 2, 3],
[marSessionId, marStartedAtMs, 120, 4, 5], [marSessionId, marStartedAtMs, 120, 4, 5],
] as const) { ] as const) {
const endedAtMs = (BigInt(startedAtMs) + 60_000n).toString();
stmts.telemetryInsertStmt.run( stmts.telemetryInsertStmt.run(
sessionId, sessionId,
startedAtMs + 60_000, endedAtMs,
30 * 60_000, 30 * 60_000,
30 * 60_000, 30 * 60_000,
4, 4,
@@ -886,8 +940,8 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
0, 0,
0, 0,
0, 0,
startedAtMs + 60_000, endedAtMs,
startedAtMs + 60_000, endedAtMs,
); );
db.prepare( db.prepare(
` `
@@ -907,7 +961,7 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
WHERE session_id = ? WHERE session_id = ?
`, `,
).run( ).run(
startedAtMs + 60_000, endedAtMs,
30 * 60_000, 30 * 60_000,
30 * 60_000, 30 * 60_000,
4, 4,
@@ -916,7 +970,7 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
yomitanLookupCount, yomitanLookupCount,
yomitanLookupCount, yomitanLookupCount,
yomitanLookupCount, yomitanLookupCount,
startedAtMs + 60_000, endedAtMs,
sessionId, sessionId,
); );
} }
@@ -937,10 +991,8 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
); );
const febEpochDay = Math.floor(febStartedAtMs / 86_400_000); insertDailyRollup.run(20500, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
const marEpochDay = Math.floor(marStartedAtMs / 86_400_000); insertDailyRollup.run(20513, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
insertDailyRollup.run(febEpochDay, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
insertDailyRollup.run(marEpochDay, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
insertMonthlyRollup.run(202602, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs); insertMonthlyRollup.run(202602, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
insertMonthlyRollup.run(202603, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs); 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), (BigInt(febStartedAtMs) / 1000n).toString(),
Math.floor(febStartedAtMs / 1000), (BigInt(febStartedAtMs) / 1000n).toString(),
1, 1,
); );
db.prepare( db.prepare(
@@ -976,8 +1028,8 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
'名詞', '名詞',
'', '',
'', '',
Math.floor(marStartedAtMs / 1000), (BigInt(marStartedAtMs) / 1000n).toString(),
Math.floor(marStartedAtMs / 1000), (BigInt(marStartedAtMs) / 1000n).toString(),
1, 1,
); );
@@ -1077,7 +1129,7 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', ()
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);
withMockDate(new Date(2026, 2, 15, 12, 0, 0), (RealDate) => { withMockNowMs('1773601200000', () => {
try { try {
ensureSchema(db); ensureSchema(db);
@@ -1088,12 +1140,9 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', ()
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
); );
const justBeforeWeekBoundary = Math.floor( const weekBoundarySec = getShiftedLocalDaySec(db, '1773601200000', -7);
new RealDate(2026, 2, 7, 23, 30, 0).getTime() / 1000, const justBeforeWeekBoundary = weekBoundarySec - 1;
); const justAfterWeekBoundary = weekBoundarySec + 1;
const justAfterWeekBoundary = Math.floor(
new RealDate(2026, 2, 8, 0, 30, 0).getTime() / 1000,
);
insertWord.run( insertWord.run(
'境界前', '境界前',
'境界前', '境界前',
@@ -1102,8 +1151,8 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', ()
'名詞', '名詞',
'', '',
'', '',
justBeforeWeekBoundary, String(justBeforeWeekBoundary),
justBeforeWeekBoundary, String(justBeforeWeekBoundary),
1, 1,
); );
insertWord.run( insertWord.run(
@@ -1114,8 +1163,8 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', ()
'名詞', '名詞',
'', '',
'', '',
justAfterWeekBoundary, String(justAfterWeekBoundary),
justAfterWeekBoundary, String(justAfterWeekBoundary),
1, 1,
); );
@@ -1134,10 +1183,8 @@ test('getQueryHints counts new words by distinct headword first-seen time', () =
try { try {
ensureSchema(db); ensureSchema(db);
withMockNowMs('1773601200000', () => {
const now = new Date(); const todayStartSec = 1_773_558_000;
const todayStartSec =
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000;
const oneHourAgo = todayStartSec + 3_600; const oneHourAgo = todayStartSec + 3_600;
const twoDaysAgo = todayStartSec - 2 * 86_400; const twoDaysAgo = todayStartSec - 2 * 86_400;
@@ -1147,25 +1194,59 @@ test('getQueryHints counts new words by distinct headword first-seen time', () =
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
).run('知る', '知った', 'しった', 'verb', '動詞', '', '', oneHourAgo, oneHourAgo, 1); ).run(
'知る',
'知った',
'しった',
'verb',
'動詞',
'',
'',
String(oneHourAgo),
String(oneHourAgo),
1,
);
db.prepare( db.prepare(
` `
INSERT INTO imm_words ( INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
).run('知る', '知っている', 'しっている', 'verb', '動詞', '', '', oneHourAgo, oneHourAgo, 1); ).run(
'知る',
'知っている',
'しっている',
'verb',
'動詞',
'',
'',
String(oneHourAgo),
String(oneHourAgo),
1,
);
db.prepare( db.prepare(
` `
INSERT INTO imm_words ( INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
).run('猫', '猫', 'ねこ', 'noun', '名詞', '', '', twoDaysAgo, twoDaysAgo, 1); ).run(
'猫',
'猫',
'ねこ',
'noun',
'名詞',
'',
'',
String(twoDaysAgo),
String(twoDaysAgo),
1,
);
const hints = getQueryHints(db); const hints = getQueryHints(db);
assert.equal(hints.newWordsToday, 1); assert.equal(hints.newWordsToday, 1);
assert.equal(hints.newWordsThisWeek, 2); assert.equal(hints.newWordsThisWeek, 2);
});
} finally { } finally {
db.close(); db.close();
cleanupDbPath(dbPath); cleanupDbPath(dbPath);
@@ -2020,7 +2101,7 @@ test('getSessionWordsByLine joins word occurrences through imm_words.id', () =>
try { try {
ensureSchema(db); ensureSchema(db);
const stmts = createTrackerPreparedStatements(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', { const videoId = getOrCreateVideoRecord(db, '/tmp/session-words-by-line.mkv', {
canonicalTitle: 'Episode', canonicalTitle: 'Episode',
sourcePath: '/tmp/session-words-by-line.mkv', sourcePath: '/tmp/session-words-by-line.mkv',

View File

@@ -1,6 +1,7 @@
import type { DatabaseSync } from './sqlite'; import type { DatabaseSync } from './sqlite';
import { finalizeSessionRecord } from './session'; import { finalizeSessionRecord } from './session';
import { nowMs } from './time'; import { nowMs } from './time';
import { toDbTimestamp } from './query-shared';
import type { LifetimeRebuildSummary, SessionState } from './types'; import type { LifetimeRebuildSummary, SessionState } from './types';
interface TelemetryRow { interface TelemetryRow {
@@ -41,8 +42,8 @@ interface LifetimeAnimeStateRow {
interface RetainedSessionRow { interface RetainedSessionRow {
sessionId: number; sessionId: number;
videoId: number; videoId: number;
startedAtMs: number; startedAtMs: number | string;
endedAtMs: number; endedAtMs: number | string;
lastMediaMs: number | null; lastMediaMs: number | null;
totalWatchedMs: number; totalWatchedMs: number;
activeWatchedMs: number; activeWatchedMs: number;
@@ -65,25 +66,29 @@ function hasRetainedPriorSession(
startedAtMs: number, startedAtMs: number,
currentSessionId: number, currentSessionId: number,
): boolean { ): boolean {
return ( const row = db
Number(
(
db
.prepare( .prepare(
` `
SELECT COUNT(*) AS count SELECT 1 AS found
FROM imm_sessions FROM imm_sessions
WHERE video_id = ? WHERE video_id = ?
AND ( AND (
started_at_ms < ? CAST(started_at_ms AS REAL) < CAST(? AS REAL)
OR (started_at_ms = ? AND session_id < ?) OR (
CAST(started_at_ms AS REAL) = CAST(? AS REAL)
AND session_id < ?
) )
)
LIMIT 1
`, `,
) )
.get(videoId, startedAtMs, startedAtMs, currentSessionId) as ExistenceRow | null .get(
)?.count ?? 0, videoId,
) > 0 toDbTimestamp(startedAtMs),
); toDbTimestamp(startedAtMs),
currentSessionId,
) as { found: number } | null;
return Boolean(row);
} }
function isFirstSessionForLocalDay( function isFirstSessionForLocalDay(
@@ -91,23 +96,37 @@ function isFirstSessionForLocalDay(
currentSessionId: number, currentSessionId: number,
startedAtMs: number, startedAtMs: number,
): boolean { ): boolean {
return ( const row = db
(
db
.prepare( .prepare(
` `
SELECT COUNT(*) AS count SELECT 1 AS found
FROM imm_sessions FROM imm_sessions
WHERE date(started_at_ms / 1000, 'unixepoch', 'localtime') = date(? / 1000, 'unixepoch', 'localtime') WHERE session_id != ?
AND ( AND CAST(
started_at_ms < ? julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5
OR (started_at_ms = ? AND session_id < ?) AS INTEGER
) = CAST(
julianday(CAST(? AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5
AS INTEGER
) )
AND (
CAST(started_at_ms AS REAL) < CAST(? AS REAL)
OR (
CAST(started_at_ms AS REAL) = CAST(? AS REAL)
AND session_id < ?
)
)
LIMIT 1
`, `,
) )
.get(startedAtMs, startedAtMs, startedAtMs, currentSessionId) as ExistenceRow | null .get(
)?.count === 0 currentSessionId,
); toDbTimestamp(startedAtMs),
toDbTimestamp(startedAtMs),
toDbTimestamp(startedAtMs),
currentSessionId,
) as { found: number } | null;
return !row;
} }
function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void { function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void {
@@ -131,14 +150,14 @@ function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void {
LAST_UPDATE_DATE = ? LAST_UPDATE_DATE = ?
WHERE global_id = 1 WHERE global_id = 1
`, `,
).run(nowMs, nowMs); ).run(toDbTimestamp(nowMs), toDbTimestamp(nowMs));
} }
function rebuildLifetimeSummariesInternal( function rebuildLifetimeSummariesInternal(
db: DatabaseSync, db: DatabaseSync,
rebuiltAtMs: number, rebuiltAtMs: number,
): LifetimeRebuildSummary { ): LifetimeRebuildSummary {
const sessions = db const rows = db
.prepare( .prepare(
` `
SELECT SELECT
@@ -146,6 +165,7 @@ function rebuildLifetimeSummariesInternal(
video_id AS videoId, video_id AS videoId,
started_at_ms AS startedAtMs, started_at_ms AS startedAtMs,
ended_at_ms AS endedAtMs, ended_at_ms AS endedAtMs,
ended_media_ms AS lastMediaMs,
total_watched_ms AS totalWatchedMs, total_watched_ms AS totalWatchedMs,
active_watched_ms AS activeWatchedMs, active_watched_ms AS activeWatchedMs,
lines_seen AS linesSeen, lines_seen AS linesSeen,
@@ -164,7 +184,19 @@ function rebuildLifetimeSummariesInternal(
ORDER BY started_at_ms ASC, session_id ASC ORDER BY started_at_ms ASC, session_id ASC
`, `,
) )
.all() as RetainedSessionRow[]; .all() as Array<
Omit<RetainedSessionRow, 'startedAtMs' | 'endedAtMs' | 'lastMediaMs'> & {
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); resetLifetimeSummaries(db, rebuiltAtMs);
for (const session of sessions) { for (const session of sessions) {
@@ -181,9 +213,9 @@ function toRebuildSessionState(row: RetainedSessionRow): SessionState {
return { return {
sessionId: row.sessionId, sessionId: row.sessionId,
videoId: row.videoId, videoId: row.videoId,
startedAtMs: row.startedAtMs, startedAtMs: row.startedAtMs as number,
currentLineIndex: 0, currentLineIndex: 0,
lastWallClockMs: row.endedAtMs, lastWallClockMs: row.endedAtMs as number,
lastMediaMs: row.lastMediaMs, lastMediaMs: row.lastMediaMs,
lastPauseStartMs: null, lastPauseStartMs: null,
isPaused: false, isPaused: false,
@@ -206,7 +238,7 @@ function toRebuildSessionState(row: RetainedSessionRow): SessionState {
} }
function getRetainedStaleActiveSessions(db: DatabaseSync): RetainedSessionRow[] { function getRetainedStaleActiveSessions(db: DatabaseSync): RetainedSessionRow[] {
return db const rows = db
.prepare( .prepare(
` `
SELECT SELECT
@@ -241,20 +273,32 @@ function getRetainedStaleActiveSessions(db: DatabaseSync): RetainedSessionRow[]
ORDER BY s.started_at_ms ASC, s.session_id ASC ORDER BY s.started_at_ms ASC, s.session_id ASC
`, `,
) )
.all() as RetainedSessionRow[]; .all() as Array<
Omit<RetainedSessionRow, 'startedAtMs' | 'endedAtMs' | 'lastMediaMs'> & {
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( function upsertLifetimeMedia(
db: DatabaseSync, db: DatabaseSync,
videoId: number, videoId: number,
nowMs: number, nowMs: number | string,
activeMs: number, activeMs: number,
cardsMined: number, cardsMined: number,
linesSeen: number, linesSeen: number,
tokensSeen: number, tokensSeen: number,
completed: number, completed: number,
startedAtMs: number, startedAtMs: number | string,
endedAtMs: number, endedAtMs: number | string,
): void { ): void {
db.prepare( db.prepare(
` `
@@ -310,15 +354,15 @@ function upsertLifetimeMedia(
function upsertLifetimeAnime( function upsertLifetimeAnime(
db: DatabaseSync, db: DatabaseSync,
animeId: number, animeId: number,
nowMs: number, nowMs: number | string,
activeMs: number, activeMs: number,
cardsMined: number, cardsMined: number,
linesSeen: number, linesSeen: number,
tokensSeen: number, tokensSeen: number,
episodesStartedDelta: number, episodesStartedDelta: number,
episodesCompletedDelta: number, episodesCompletedDelta: number,
startedAtMs: number, startedAtMs: number | string,
endedAtMs: number, endedAtMs: number | string,
): void { ): void {
db.prepare( db.prepare(
` `
@@ -377,8 +421,9 @@ function upsertLifetimeAnime(
export function applySessionLifetimeSummary( export function applySessionLifetimeSummary(
db: DatabaseSync, db: DatabaseSync,
session: SessionState, session: SessionState,
endedAtMs: number, endedAtMs: number | string,
): void { ): void {
const updatedAtMs = toDbTimestamp(nowMs());
const applyResult = db const applyResult = db
.prepare( .prepare(
` `
@@ -393,7 +438,7 @@ export function applySessionLifetimeSummary(
ON CONFLICT(session_id) DO NOTHING ON CONFLICT(session_id) DO NOTHING
`, `,
) )
.run(session.sessionId, endedAtMs, nowMs(), nowMs()); .run(session.sessionId, endedAtMs, updatedAtMs, updatedAtMs);
if ((applyResult.changes ?? 0) <= 0) { if ((applyResult.changes ?? 0) <= 0) {
return; return;
@@ -468,7 +513,6 @@ export function applySessionLifetimeSummary(
? 1 ? 1
: 0; : 0;
const updatedAtMs = nowMs();
db.prepare( db.prepare(
` `
UPDATE imm_lifetime_global UPDATE imm_lifetime_global

View File

@@ -11,6 +11,7 @@ import {
toMonthKey, toMonthKey,
} from './maintenance'; } from './maintenance';
import { ensureSchema } from './storage'; import { ensureSchema } from './storage';
import { toDbTimestamp } from './query-shared';
function makeDbPath(): string { function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-maintenance-test-')); 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 ( INSERT INTO imm_videos (
video_id, video_key, canonical_title, source_type, duration_ms, CREATED_DATE, LAST_UPDATE_DATE video_id, video_key, canonical_title, source_type, duration_ms, CREATED_DATE, LAST_UPDATE_DATE
) VALUES ( ) 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 ( INSERT INTO imm_sessions (
session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE
) VALUES ) VALUES
(1, 'session-1', 1, ${staleEndedAtMs - 1_000}, ${staleEndedAtMs}, 2, ${staleEndedAtMs}, ${staleEndedAtMs}), (1, 'session-1', 1, '${toDbTimestamp(staleEndedAtMs - 1_000)}', '${toDbTimestamp(staleEndedAtMs)}', 2, '${toDbTimestamp(staleEndedAtMs)}', '${toDbTimestamp(staleEndedAtMs)}'),
(2, 'session-2', 1, ${keptEndedAtMs - 1_000}, ${keptEndedAtMs}, 2, ${keptEndedAtMs}, ${keptEndedAtMs}); (2, 'session-2', 1, '${toDbTimestamp(keptEndedAtMs - 1_000)}', '${toDbTimestamp(keptEndedAtMs)}', 2, '${toDbTimestamp(keptEndedAtMs)}', '${toDbTimestamp(keptEndedAtMs)}');
INSERT INTO imm_session_telemetry ( INSERT INTO imm_session_telemetry (
session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
) VALUES ) VALUES
(1, ${nowMs - 200_000_000}, 0, 0, ${nowMs}, ${nowMs}), (1, '${toDbTimestamp(nowMs - 200_000_000)}', 0, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'),
(2, ${nowMs - 10_000_000}, 0, 0, ${nowMs}, ${nowMs}); (2, '${toDbTimestamp(nowMs - 10_000_000)}', 0, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}');
`); `);
const result = pruneRawRetention(db, nowMs, { const result = pruneRawRetention(db, nowMs, {
@@ -94,22 +95,22 @@ test('pruneRawRetention skips disabled retention windows', () => {
INSERT INTO imm_videos ( INSERT INTO imm_videos (
video_id, video_key, canonical_title, source_type, duration_ms, CREATED_DATE, LAST_UPDATE_DATE video_id, video_key, canonical_title, source_type, duration_ms, CREATED_DATE, LAST_UPDATE_DATE
) VALUES ( ) 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 ( INSERT INTO imm_sessions (
session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE
) VALUES ( ) 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 ( INSERT INTO imm_session_telemetry (
session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
) VALUES ( ) 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 ( INSERT INTO imm_session_events (
session_id, event_type, ts_ms, payload_json, CREATED_DATE, LAST_UPDATE_DATE session_id, event_type, ts_ms, payload_json, CREATED_DATE, LAST_UPDATE_DATE
) VALUES ( ) 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 ( INSERT INTO imm_videos (
video_id, video_key, canonical_title, source_type, duration_ms, CREATED_DATE, LAST_UPDATE_DATE video_id, video_key, canonical_title, source_type, duration_ms, CREATED_DATE, LAST_UPDATE_DATE
) VALUES ( ) 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 ( INSERT INTO imm_sessions (
session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE
) VALUES ( ) 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 ( INSERT INTO imm_session_telemetry (
session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
) VALUES ( ) 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 ( INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, 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, rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES ( ) VALUES (
${oldMonth}, 1, 1, 10, 1, 1, 1, ${nowMs}, ${nowMs} ${oldMonth}, 1, 1, 10, 1, 1, 1, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'
); );
`); `);

View File

@@ -1,13 +1,13 @@
import type { DatabaseSync } from './sqlite'; import type { DatabaseSync } from './sqlite';
import { nowMs } from './time'; import { nowMs } from './time';
import { toDbMs } from './query-shared'; import { subtractDbTimestamp, toDbTimestamp } from './query-shared';
const ROLLUP_STATE_KEY = 'last_rollup_sample_ms'; const ROLLUP_STATE_KEY = 'last_rollup_sample_ms';
const DAILY_MS = 86_400_000; const DAILY_MS = 86_400_000;
const ZERO_ID = 0; const ZERO_ID = 0;
interface RollupStateRow { interface RollupStateRow {
state_value: number; state_value: string;
} }
interface RollupGroupRow { interface RollupGroupRow {
@@ -51,12 +51,25 @@ export function pruneRawRetention(
eventsRetentionMs: number; eventsRetentionMs: number;
telemetryRetentionMs: number; telemetryRetentionMs: number;
sessionsRetentionMs: number; sessionsRetentionMs: number;
eventsRetentionDays?: number;
telemetryRetentionDays?: number;
sessionsRetentionDays?: number;
}, },
): RawRetentionResult { ): 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) const deletedSessionEvents = Number.isFinite(policy.eventsRetentionMs)
? ( ? (
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run( db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(
toDbMs(currentMs - policy.eventsRetentionMs), resolveCutoff(policy.eventsRetentionMs, policy.eventsRetentionDays),
) as { changes: number } ) as { changes: number }
).changes ).changes
: 0; : 0;
@@ -64,14 +77,18 @@ export function pruneRawRetention(
? ( ? (
db db
.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`) .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 ).changes
: 0; : 0;
const deletedEndedSessions = Number.isFinite(policy.sessionsRetentionMs) const deletedEndedSessions = Number.isFinite(policy.sessionsRetentionMs)
? ( ? (
db db
.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`) .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 ).changes
: 0; : 0;
@@ -115,14 +132,14 @@ export function pruneRollupRetention(
}; };
} }
function getLastRollupSampleMs(db: DatabaseSync): number { function getLastRollupSampleMs(db: DatabaseSync): string {
const row = db const row = db
.prepare(`SELECT state_value FROM imm_rollup_state WHERE state_key = ? LIMIT 1`) .prepare(`SELECT state_value FROM imm_rollup_state WHERE state_key = ? LIMIT 1`)
.get(ROLLUP_STATE_KEY) as unknown as RollupStateRow | null; .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( db.prepare(
`INSERT INTO imm_rollup_state (state_key, state_value) `INSERT INTO imm_rollup_state (state_key, state_value)
VALUES (?, ?) VALUES (?, ?)
@@ -141,7 +158,7 @@ function resetRollups(db: DatabaseSync): void {
function upsertDailyRollupsForGroups( function upsertDailyRollupsForGroups(
db: DatabaseSync, db: DatabaseSync,
groups: Array<{ rollupDay: number; videoId: number }>, groups: Array<{ rollupDay: number; videoId: number }>,
rollupNowMs: bigint, rollupNowMs: number | string,
): void { ): void {
if (groups.length === 0) { if (groups.length === 0) {
return; return;
@@ -217,7 +234,7 @@ function upsertDailyRollupsForGroups(
function upsertMonthlyRollupsForGroups( function upsertMonthlyRollupsForGroups(
db: DatabaseSync, db: DatabaseSync,
groups: Array<{ rollupMonth: number; videoId: number }>, groups: Array<{ rollupMonth: number; videoId: number }>,
rollupNowMs: bigint, rollupNowMs: number | string,
): void { ): void {
if (groups.length === 0) { if (groups.length === 0) {
return; return;
@@ -268,7 +285,7 @@ function upsertMonthlyRollupsForGroups(
function getAffectedRollupGroups( function getAffectedRollupGroups(
db: DatabaseSync, db: DatabaseSync,
lastRollupSampleMs: number, lastRollupSampleMs: number | string,
): Array<{ rollupDay: number; rollupMonth: number; videoId: number }> { ): Array<{ rollupDay: number; rollupMonth: number; videoId: number }> {
return ( return (
db db
@@ -321,7 +338,7 @@ export function runRollupMaintenance(db: DatabaseSync, forceRebuild = false): vo
return; return;
} }
const rollupNowMs = toDbMs(nowMs()); const rollupNowMs = toDbTimestamp(nowMs());
const lastRollupSampleMs = getLastRollupSampleMs(db); const lastRollupSampleMs = getLastRollupSampleMs(db);
const maxSampleRow = db const maxSampleRow = db
@@ -356,7 +373,7 @@ export function runRollupMaintenance(db: DatabaseSync, forceRebuild = false): vo
try { try {
upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs); upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs);
upsertMonthlyRollupsForGroups(db, monthlyGroups, rollupNowMs); upsertMonthlyRollupsForGroups(db, monthlyGroups, rollupNowMs);
setLastRollupSampleMs(db, toDbMs(maxSampleRow.maxSampleMs ?? ZERO_ID)); setLastRollupSampleMs(db, toDbTimestamp(maxSampleRow.maxSampleMs ?? ZERO_ID));
db.exec('COMMIT'); db.exec('COMMIT');
} catch (error) { } catch (error) {
db.exec('ROLLBACK'); db.exec('ROLLBACK');
@@ -365,7 +382,7 @@ export function runRollupMaintenance(db: DatabaseSync, forceRebuild = false): vo
} }
export function rebuildRollupsInTransaction(db: DatabaseSync): void { export function rebuildRollupsInTransaction(db: DatabaseSync): void {
const rollupNowMs = toDbMs(nowMs()); const rollupNowMs = toDbTimestamp(nowMs());
const maxSampleRow = db const maxSampleRow = db
.prepare('SELECT MAX(sample_ms) AS maxSampleMs FROM imm_session_telemetry') .prepare('SELECT MAX(sample_ms) AS maxSampleMs FROM imm_session_telemetry')
.get() as unknown as RollupTelemetryResult | null; .get() as unknown as RollupTelemetryResult | null;
@@ -377,7 +394,7 @@ export function rebuildRollupsInTransaction(db: DatabaseSync): void {
const affectedGroups = getAffectedRollupGroups(db, ZERO_ID); const affectedGroups = getAffectedRollupGroups(db, ZERO_ID);
if (affectedGroups.length === 0) { if (affectedGroups.length === 0) {
setLastRollupSampleMs(db, toDbMs(maxSampleRow.maxSampleMs ?? ZERO_ID)); setLastRollupSampleMs(db, toDbTimestamp(maxSampleRow.maxSampleMs ?? ZERO_ID));
return; return;
} }
@@ -396,7 +413,7 @@ export function rebuildRollupsInTransaction(db: DatabaseSync): void {
upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs); upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs);
upsertMonthlyRollupsForGroups(db, monthlyGroups, 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 { export function runOptimizeMaintenance(db: DatabaseSync): void {

View File

@@ -12,6 +12,7 @@ import type {
WordDetailRow, WordDetailRow,
WordOccurrenceRow, WordOccurrenceRow,
} from './types'; } from './types';
import { fromDbTimestamp } from './query-shared';
export function getVocabularyStats( export function getVocabularyStats(
db: DatabaseSync, db: DatabaseSync,
@@ -134,7 +135,11 @@ export function getSessionEvents(
SELECT event_type AS eventType, ts_ms AS tsMs, payload_json AS payload 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 ? 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<SessionEventRow & { tsMs: number | string }>;
return rows.map((row) => ({
...row,
tsMs: fromDbTimestamp(row.tsMs) ?? 0,
}));
} }
const placeholders = eventTypes.map(() => '?').join(', '); const placeholders = eventTypes.map(() => '?').join(', ');
@@ -145,7 +150,13 @@ export function getSessionEvents(
ORDER BY ts_ms ASC ORDER BY ts_ms ASC
LIMIT ? LIMIT ?
`); `);
return stmt.all(sessionId, ...eventTypes, limit) as SessionEventRow[]; const rows = stmt.all(sessionId, ...eventTypes, limit) as Array<SessionEventRow & {
tsMs: number | string;
}>;
return rows.map((row) => ({
...row,
tsMs: fromDbTimestamp(row.tsMs) ?? 0,
}));
} }
export function getWordDetail(db: DatabaseSync, wordId: number): WordDetailRow | null { export function getWordDetail(db: DatabaseSync, wordId: number): WordDetailRow | null {

View File

@@ -16,10 +16,10 @@ import type {
StreakCalendarRow, StreakCalendarRow,
WatchTimePerAnimeRow, WatchTimePerAnimeRow,
} from './types'; } 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[] { export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
return db const rows = db
.prepare( .prepare(
` `
SELECT SELECT
@@ -40,11 +40,15 @@ export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
ORDER BY totalActiveMs DESC, lm.last_watched_ms DESC, canonicalTitle ASC ORDER BY totalActiveMs DESC, lm.last_watched_ms DESC, canonicalTitle ASC
`, `,
) )
.all() as unknown as AnimeLibraryRow[]; .all() as Array<AnimeLibraryRow & { lastWatchedMs: number | string }>;
return rows.map((row) => ({
...row,
lastWatchedMs: fromDbTimestamp(row.lastWatchedMs) ?? 0,
}));
} }
export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRow | null { export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRow | null {
return db const row = db
.prepare( .prepare(
` `
${ACTIVE_SESSION_METRICS_CTE} ${ACTIVE_SESSION_METRICS_CTE}
@@ -75,7 +79,13 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo
GROUP BY a.anime_id 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[] { 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[] { export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisodeRow[] {
return db const rows = db
.prepare( .prepare(
` `
${ACTIVE_SESSION_METRICS_CTE} ${ACTIVE_SESSION_METRICS_CTE}
@@ -168,11 +178,21 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod
v.video_id ASC 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[] { export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
return db const rows = db
.prepare( .prepare(
` `
SELECT SELECT
@@ -205,7 +225,11 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
ORDER BY lm.last_watched_ms DESC ORDER BY lm.last_watched_ms DESC
`, `,
) )
.all() as unknown as MediaLibraryRow[]; .all() as Array<MediaLibraryRow & { lastWatchedMs: number | string }>;
return rows.map((row) => ({
...row,
lastWatchedMs: fromDbTimestamp(row.lastWatchedMs) ?? 0,
}));
} }
export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRow | null { export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRow | null {
@@ -253,7 +277,7 @@ export function getMediaSessions(
videoId: number, videoId: number,
limit = 100, limit = 100,
): SessionSummaryQueryRow[] { ): SessionSummaryQueryRow[] {
return db const rows = db
.prepare( .prepare(
` `
${ACTIVE_SESSION_METRICS_CTE} ${ACTIVE_SESSION_METRICS_CTE}
@@ -279,7 +303,17 @@ export function getMediaSessions(
LIMIT ? 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( export function getMediaDailyRollups(
@@ -351,7 +385,7 @@ export function getAnimeDailyRollups(
export function getAnimeCoverArt(db: DatabaseSync, animeId: number): MediaArtRow | null { export function getAnimeCoverArt(db: DatabaseSync, animeId: number): MediaArtRow | null {
const resolvedCoverBlob = resolvedCoverBlobExpr('a', 'cab'); const resolvedCoverBlob = resolvedCoverBlobExpr('a', 'cab');
return db const row = db
.prepare( .prepare(
` `
SELECT SELECT
@@ -372,12 +406,18 @@ export function getAnimeCoverArt(db: DatabaseSync, animeId: number): MediaArtRow
LIMIT 1 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 { export function getCoverArt(db: DatabaseSync, videoId: number): MediaArtRow | null {
const resolvedCoverBlob = resolvedCoverBlobExpr('a', 'cab'); const resolvedCoverBlob = resolvedCoverBlobExpr('a', 'cab');
return db const row = db
.prepare( .prepare(
` `
SELECT SELECT
@@ -394,7 +434,13 @@ export function getCoverArt(db: DatabaseSync, videoId: number): MediaArtRow | nu
WHERE a.video_id = ? 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[] { 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[] { export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSummaryQueryRow[] {
return db const rows = db
.prepare( .prepare(
` `
${ACTIVE_SESSION_METRICS_CTE} ${ACTIVE_SESSION_METRICS_CTE}
@@ -533,7 +579,17 @@ export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSu
ORDER BY s.started_at_ms DESC 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[] { export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): EpisodeCardEventRow[] {
@@ -552,7 +608,7 @@ export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): Episode
.all(videoId) as Array<{ .all(videoId) as Array<{
eventId: number; eventId: number;
sessionId: number; sessionId: number;
tsMs: number; tsMs: number | string;
cardsDelta: number; cardsDelta: number;
payloadJson: string | null; payloadJson: string | null;
}>; }>;
@@ -568,7 +624,7 @@ export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): Episode
return { return {
eventId: row.eventId, eventId: row.eventId,
sessionId: row.sessionId, sessionId: row.sessionId,
tsMs: row.tsMs, tsMs: fromDbTimestamp(row.tsMs) ?? 0,
cardsDelta: row.cardsDelta, cardsDelta: row.cardsDelta,
noteIds, noteIds,
}; };

View File

@@ -17,6 +17,7 @@ import {
getAffectedWordIdsForVideo, getAffectedWordIdsForVideo,
refreshLexicalAggregates, refreshLexicalAggregates,
toDbMs, toDbMs,
toDbTimestamp,
} from './query-shared'; } from './query-shared';
type CleanupVocabularyRow = { type CleanupVocabularyRow = {
@@ -351,7 +352,7 @@ export function upsertCoverArt(
) )
.get(videoId) as { coverBlobHash: string | null } | undefined; .get(videoId) as { coverBlobHash: string | null } | undefined;
const sharedCoverBlobHash = findSharedCoverBlobHash(db, videoId, art.anilistId, art.coverUrl); const sharedCoverBlobHash = findSharedCoverBlobHash(db, videoId, art.anilistId, art.coverUrl);
const fetchedAtMs = toDbMs(nowMs()); const fetchedAtMs = toDbTimestamp(nowMs());
const coverBlob = normalizeCoverBlobBytes(art.coverBlob); const coverBlob = normalizeCoverBlobBytes(art.coverBlob);
const computedCoverBlobHash = const computedCoverBlobHash =
coverBlob && coverBlob.length > 0 coverBlob && coverBlob.length > 0
@@ -444,7 +445,7 @@ export function updateAnimeAnilistInfo(
info.titleEnglish, info.titleEnglish,
info.titleNative, info.titleNative,
info.episodesTotal, info.episodesTotal,
toDbMs(nowMs()), toDbTimestamp(nowMs()),
row.anime_id, row.anime_id,
); );
} }
@@ -452,7 +453,7 @@ export function updateAnimeAnilistInfo(
export function markVideoWatched(db: DatabaseSync, videoId: number, watched: boolean): void { export function markVideoWatched(db: DatabaseSync, videoId: number, watched: boolean): void {
db.prepare('UPDATE imm_videos SET watched = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?').run( db.prepare('UPDATE imm_videos SET watched = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?').run(
watched ? 1 : 0, watched ? 1 : 0,
toDbMs(nowMs()), toDbTimestamp(nowMs()),
videoId, videoId,
); );
} }

View File

@@ -1,11 +1,17 @@
import type { DatabaseSync } from './sqlite'; import type { DatabaseSync } from './sqlite';
import { nowMs } from './time';
import type { import type {
ImmersionSessionRollupRow, ImmersionSessionRollupRow,
SessionSummaryQueryRow, SessionSummaryQueryRow,
SessionTimelineRow, SessionTimelineRow,
} from './types'; } 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[] { export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummaryQueryRow[] {
const prepared = db.prepare(` const prepared = db.prepare(`
@@ -33,7 +39,15 @@ export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummar
ORDER BY s.started_at_ms DESC ORDER BY s.started_at_ms DESC
LIMIT ? LIMIT ?
`); `);
return prepared.all(limit) as unknown as SessionSummaryQueryRow[]; const rows = prepared.all(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 getSessionTimeline( export function getSessionTimeline(
@@ -55,11 +69,23 @@ export function getSessionTimeline(
`; `;
if (limit === undefined) { if (limit === undefined) {
return db.prepare(select).all(sessionId) as unknown as SessionTimelineRow[]; const rows = db.prepare(select).all(sessionId) as Array<SessionTimelineRow & {
sampleMs: number | string;
}>;
return rows.map((row) => ({
...row,
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,
}));
} }
return db const rows = db
.prepare(`${select}\n LIMIT ?`) .prepare(`${select}\n LIMIT ?`)
.all(sessionId, limit) as unknown as SessionTimelineRow[]; .all(sessionId, limit) as Array<SessionTimelineRow & {
sampleMs: number | string;
}>;
return rows.map((row) => ({
...row,
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,
}));
} }
/** Returns all distinct headwords in the vocabulary table (global). */ /** Returns all distinct headwords in the vocabulary table (global). */
@@ -129,35 +155,50 @@ export function getSessionWordsByLine(
} }
function getNewWordCounts(db: DatabaseSync): { newWordsToday: number; newWordsThisWeek: number } { function getNewWordCounts(db: DatabaseSync): { newWordsToday: number; newWordsThisWeek: number } {
const now = new Date(); const currentTimestamp = currentDbTimestamp();
const todayStartSec = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000; const todayStartSec = getShiftedLocalDaySec(db, currentTimestamp, 0);
const weekAgoSec = const weekAgoSec = getShiftedLocalDaySec(db, currentTimestamp, -7);
new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7).getTime() / 1000;
const row = db const rows = db
.prepare( .prepare(
` `
WITH headword_first_seen AS (
SELECT SELECT
headword, headword,
MIN(first_seen) AS first_seen first_seen AS firstSeen
FROM imm_words FROM imm_words
WHERE first_seen IS NOT NULL WHERE first_seen IS NOT NULL
AND headword IS NOT NULL AND headword IS NOT NULL
AND headword != '' 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
`, `,
) )
.get(todayStartSec, weekAgoSec) as { today: number; week: number } | null; .all() as Array<{ headword: string; firstSeen: number | string }>;
const firstSeenByHeadword = new Map<string, number>();
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 { return {
newWordsToday: Number(row?.today ?? 0), newWordsToday: today,
newWordsThisWeek: Number(row?.week ?? 0), newWordsThisWeek: week,
}; };
} }
@@ -203,10 +244,8 @@ export function getQueryHints(db: DatabaseSync): {
animeCompleted: number; animeCompleted: number;
} | null; } | null;
const now = new Date(); const currentTimestamp = currentDbTimestamp();
const todayLocal = Math.floor( const todayLocal = getLocalEpochDay(db, currentTimestamp);
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 86_400_000,
);
const episodesToday = const episodesToday =
( (
@@ -215,13 +254,16 @@ export function getQueryHints(db: DatabaseSync): {
` `
SELECT COUNT(DISTINCT s.video_id) AS count SELECT COUNT(DISTINCT s.video_id) AS count
FROM imm_sessions s 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 } .get(todayLocal) as { count: number }
)?.count ?? 0; )?.count ?? 0;
const thirtyDaysAgoMs = nowMs() - 30 * 86400000; const thirtyDaysAgoMs = getShiftedLocalDaySec(db, currentTimestamp, -30).toString() + '000';
const activeAnimeCount = const activeAnimeCount =
( (
db db

View File

@@ -1,4 +1,5 @@
import type { DatabaseSync } from './sqlite'; import type { DatabaseSync } from './sqlite';
import { nowMs } from './time';
export const ACTIVE_SESSION_METRICS_CTE = ` export const ACTIVE_SESSION_METRICS_CTE = `
WITH active_session_metrics AS ( WITH active_session_metrics AS (
@@ -280,3 +281,213 @@ export function toDbMs(ms: number | bigint): bigint {
} }
return BigInt(Math.trunc(ms)); 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;
}

View File

@@ -1,6 +1,16 @@
import type { DatabaseSync } from './sqlite'; import type { DatabaseSync } from './sqlite';
import type { ImmersionSessionRollupRow } from './types'; 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'; import { getDailyRollups, getMonthlyRollups } from './query-sessions';
type TrendRange = '7d' | '30d' | '90d' | 'all'; type TrendRange = '7d' | '30d' | '90d' | 'all';
@@ -19,6 +29,10 @@ interface TrendPerAnimePoint {
interface TrendSessionMetricRow { interface TrendSessionMetricRow {
startedAtMs: number; startedAtMs: number;
epochDay: number;
monthKey: number;
dayOfWeek: number;
hourOfDay: number;
videoId: number | null; videoId: number | null;
canonicalTitle: string | null; canonicalTitle: string | null;
animeTitle: string | null; animeTitle: string | null;
@@ -73,64 +87,64 @@ const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
'90d': 90, '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']; const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function getTrendDayLimit(range: TrendRange): number { function getTrendDayLimit(range: TrendRange): number {
return range === 'all' ? 365 : TREND_DAY_LIMITS[range]; return range === 'all' ? 365 : TREND_DAY_LIMITS[range];
} }
function getTrendMonthlyLimit(range: TrendRange): number { function getTrendMonthlyLimit(db: DatabaseSync, range: TrendRange): number {
if (range === 'all') { if (range === 'all') {
return 120; return 120;
} }
const now = new Date(); const currentTimestamp = currentDbTimestamp();
const cutoff = new Date( const todayStartMs = getShiftedLocalDayTimestamp(db, currentTimestamp, 0);
now.getFullYear(), const cutoffMs = getShiftedLocalDayTimestamp(db, currentTimestamp, -(TREND_DAY_LIMITS[range] - 1));
now.getMonth(), const currentMonthKey = getLocalMonthKey(db, todayStartMs);
now.getDate() - (TREND_DAY_LIMITS[range] - 1), const cutoffMonthKey = getLocalMonthKey(db, cutoffMs);
); const currentYear = Math.floor(currentMonthKey / 100);
return Math.max(1, (now.getFullYear() - cutoff.getFullYear()) * 12 + now.getMonth() - cutoff.getMonth() + 1); 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') { if (range === 'all') {
return null; return null;
} }
const dayLimit = getTrendDayLimit(range); return getShiftedLocalDayTimestamp(db, currentDbTimestamp(), -(getTrendDayLimit(range) - 1));
const now = new Date(); }
const localMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
return localMidnight - (dayLimit - 1) * 86_400_000; 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 { function makeTrendLabel(value: number): string {
if (value > 100_000) { if (value > 100_000) {
const year = Math.floor(value / 100); const year = Math.floor(value / 100);
const month = value % 100; const month = value % 100;
return new Date(Date.UTC(year, month - 1, 1)).toLocaleDateString(undefined, { return `${MONTH_NAMES[month - 1]} ${String(year).slice(-2)}`;
month: 'short',
year: '2-digit',
});
} }
return new Date(value * 86_400_000).toLocaleDateString(undefined, { const { month, day } = dayPartsFromEpochDay(value);
month: 'short', return `${MONTH_NAMES[month - 1]} ${day}`;
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;
} }
function getTrendSessionWordCount(session: Pick<TrendSessionMetricRow, 'tokensSeen'>): number { function getTrendSessionWordCount(session: Pick<TrendSessionMetricRow, 'tokensSeen'>): number {
@@ -189,7 +203,7 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChartPoint[] { function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
const totals = new Array(7).fill(0); const totals = new Array(7).fill(0);
for (const session of sessions) { for (const session of sessions) {
totals[new Date(session.startedAtMs).getDay()] += session.activeWatchedMs; totals[session.dayOfWeek] += session.activeWatchedMs;
} }
return DAY_NAMES.map((name, index) => ({ return DAY_NAMES.map((name, index) => ({
label: name, label: name,
@@ -200,7 +214,7 @@ function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChar
function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoint[] { function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
const totals = new Array(24).fill(0); const totals = new Array(24).fill(0);
for (const session of sessions) { for (const session of sessions) {
totals[new Date(session.startedAtMs).getHours()] += session.activeWatchedMs; totals[session.hourOfDay] += session.activeWatchedMs;
} }
return totals.map((ms, index) => ({ return totals.map((ms, index) => ({
label: `${String(index).padStart(2, '0')}:00`, label: `${String(index).padStart(2, '0')}:00`,
@@ -209,10 +223,8 @@ function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoin
} }
function dayLabel(epochDay: number): string { function dayLabel(epochDay: number): string {
return getLocalDateForEpochDay(epochDay).toLocaleDateString(undefined, { const { month, day } = dayPartsFromEpochDay(epochDay);
month: 'short', return `${MONTH_NAMES[month - 1]} ${day}`;
day: 'numeric',
});
} }
function buildSessionSeriesByDay( function buildSessionSeriesByDay(
@@ -221,8 +233,7 @@ function buildSessionSeriesByDay(
): TrendChartPoint[] { ): TrendChartPoint[] {
const byDay = new Map<number, number>(); const byDay = new Map<number, number>();
for (const session of sessions) { for (const session of sessions) {
const epochDay = getLocalEpochDay(session.startedAtMs); byDay.set(session.epochDay, (byDay.get(session.epochDay) ?? 0) + getValue(session));
byDay.set(epochDay, (byDay.get(epochDay) ?? 0) + getValue(session));
} }
return Array.from(byDay.entries()) return Array.from(byDay.entries())
.sort(([left], [right]) => left - right) .sort(([left], [right]) => left - right)
@@ -235,8 +246,7 @@ function buildSessionSeriesByMonth(
): TrendChartPoint[] { ): TrendChartPoint[] {
const byMonth = new Map<number, number>(); const byMonth = new Map<number, number>();
for (const session of sessions) { for (const session of sessions) {
const monthKey = getLocalMonthKey(session.startedAtMs); byMonth.set(session.monthKey, (byMonth.get(session.monthKey) ?? 0) + getValue(session));
byMonth.set(monthKey, (byMonth.get(monthKey) ?? 0) + getValue(session));
} }
return Array.from(byMonth.entries()) return Array.from(byMonth.entries())
.sort(([left], [right]) => left - right) .sort(([left], [right]) => left - right)
@@ -251,8 +261,7 @@ function buildLookupsPerHundredWords(
const wordsByBucket = new Map<number, number>(); const wordsByBucket = new Map<number, number>();
for (const session of sessions) { for (const session of sessions) {
const bucketKey = const bucketKey = groupBy === 'month' ? session.monthKey : session.epochDay;
groupBy === 'month' ? getLocalMonthKey(session.startedAtMs) : getLocalEpochDay(session.startedAtMs);
lookupsByBucket.set( lookupsByBucket.set(
bucketKey, bucketKey,
(lookupsByBucket.get(bucketKey) ?? 0) + session.yomitanLookupCount, (lookupsByBucket.get(bucketKey) ?? 0) + session.yomitanLookupCount,
@@ -282,7 +291,7 @@ function buildPerAnimeFromSessions(
for (const session of sessions) { for (const session of sessions) {
const animeTitle = resolveTrendAnimeTitle(session); const animeTitle = resolveTrendAnimeTitle(session);
const epochDay = getLocalEpochDay(session.startedAtMs); const epochDay = session.epochDay;
const dayMap = byAnime.get(animeTitle) ?? new Map(); const dayMap = byAnime.get(animeTitle) ?? new Map();
dayMap.set(epochDay, (dayMap.get(epochDay) ?? 0) + getValue(session)); dayMap.set(epochDay, (dayMap.get(epochDay) ?? 0) + getValue(session));
byAnime.set(animeTitle, dayMap); byAnime.set(animeTitle, dayMap);
@@ -303,7 +312,7 @@ function buildLookupsPerHundredPerAnime(sessions: TrendSessionMetricRow[]): Tren
for (const session of sessions) { for (const session of sessions) {
const animeTitle = resolveTrendAnimeTitle(session); const animeTitle = resolveTrendAnimeTitle(session);
const epochDay = getLocalEpochDay(session.startedAtMs); const epochDay = session.epochDay;
const lookupMap = lookups.get(animeTitle) ?? new Map(); const lookupMap = lookups.get(animeTitle) ?? new Map();
lookupMap.set(epochDay, (lookupMap.get(epochDay) ?? 0) + session.yomitanLookupCount); lookupMap.set(epochDay, (lookupMap.get(epochDay) ?? 0) + session.yomitanLookupCount);
@@ -498,9 +507,10 @@ function buildEpisodesPerMonthFromRollups(rollups: ImmersionSessionRollupRow[]):
function getTrendSessionMetrics( function getTrendSessionMetrics(
db: DatabaseSync, db: DatabaseSync,
cutoffMs: number | null, cutoffMs: string | null,
): TrendSessionMetricRow[] { ): TrendSessionMetricRow[] {
const whereClause = cutoffMs === null ? '' : 'WHERE s.started_at_ms >= ?'; const whereClause = cutoffMs === null ? '' : 'WHERE s.started_at_ms >= ?';
const cutoffValue = cutoffMs === null ? null : toDbTimestamp(cutoffMs);
const prepared = db.prepare(` const prepared = db.prepare(`
${ACTIVE_SESSION_METRICS_CTE} ${ACTIVE_SESSION_METRICS_CTE}
SELECT SELECT
@@ -520,14 +530,27 @@ function getTrendSessionMetrics(
ORDER BY s.started_at_ms ASC 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 whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
const prepared = db.prepare(` const prepared = db.prepare(`
SELECT 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 COUNT(*) AS wordCount
FROM imm_words FROM imm_words
WHERE first_seen IS NOT NULL WHERE first_seen IS NOT NULL
@@ -537,7 +560,7 @@ function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: number | null): TrendCh
`); `);
const rows = ( const rows = (
cutoffMs === null ? prepared.all() : prepared.all(Math.floor(cutoffMs / 1000)) cutoffMs === null ? prepared.all() : prepared.all((BigInt(cutoffMs) / 1000n).toString())
) as Array<{ ) as Array<{
epochDay: number; epochDay: number;
wordCount: 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 whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
const prepared = db.prepare(` const prepared = db.prepare(`
SELECT 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 COUNT(*) AS wordCount
FROM imm_words FROM imm_words
WHERE first_seen IS NOT NULL WHERE first_seen IS NOT NULL
@@ -563,7 +589,7 @@ function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: number | null): Trend
`); `);
const rows = ( const rows = (
cutoffMs === null ? prepared.all() : prepared.all(Math.floor(cutoffMs / 1000)) cutoffMs === null ? prepared.all() : prepared.all((BigInt(cutoffMs) / 1000n).toString())
) as Array<{ ) as Array<{
monthKey: number; monthKey: number;
wordCount: number; wordCount: number;
@@ -581,8 +607,8 @@ export function getTrendsDashboard(
groupBy: TrendGroupBy = 'day', groupBy: TrendGroupBy = 'day',
): TrendsDashboardQueryResult { ): TrendsDashboardQueryResult {
const dayLimit = getTrendDayLimit(range); const dayLimit = getTrendDayLimit(range);
const monthlyLimit = getTrendMonthlyLimit(range); const monthlyLimit = getTrendMonthlyLimit(db, range);
const cutoffMs = getTrendCutoffMs(range); const cutoffMs = getTrendCutoffMs(db, range);
const useMonthlyBuckets = groupBy === 'month'; const useMonthlyBuckets = groupBy === 'month';
const dailyRollups = getDailyRollups(db, dayLimit); const dailyRollups = getDailyRollups(db, dayLimit);
const monthlyRollups = getMonthlyRollups(db, monthlyLimit); const monthlyRollups = getMonthlyRollups(db, monthlyLimit);

View File

@@ -4,7 +4,7 @@ import { createInitialSessionState } from './reducer';
import { nowMs } from './time'; import { nowMs } from './time';
import { SESSION_STATUS_ACTIVE, SESSION_STATUS_ENDED } from './types'; import { SESSION_STATUS_ACTIVE, SESSION_STATUS_ENDED } from './types';
import type { SessionState } from './types'; import type { SessionState } from './types';
import { toDbMs } from './query-shared'; import { toDbMs, toDbTimestamp } from './query-shared';
export function startSessionRecord( export function startSessionRecord(
db: DatabaseSync, db: DatabaseSync,
@@ -25,10 +25,10 @@ export function startSessionRecord(
.run( .run(
sessionUuid, sessionUuid,
videoId, videoId,
toDbMs(startedAtMs), toDbTimestamp(startedAtMs),
SESSION_STATUS_ACTIVE, SESSION_STATUS_ACTIVE,
toDbMs(startedAtMs), toDbTimestamp(startedAtMs),
toDbMs(createdAtMs), toDbTimestamp(createdAtMs),
); );
const sessionId = Number(result.lastInsertRowid); const sessionId = Number(result.lastInsertRowid);
return { return {
@@ -40,7 +40,7 @@ export function startSessionRecord(
export function finalizeSessionRecord( export function finalizeSessionRecord(
db: DatabaseSync, db: DatabaseSync,
sessionState: SessionState, sessionState: SessionState,
endedAtMs = nowMs(), endedAtMs: number | string = nowMs(),
): void { ): void {
db.prepare( db.prepare(
` `
@@ -66,7 +66,7 @@ export function finalizeSessionRecord(
WHERE session_id = ? WHERE session_id = ?
`, `,
).run( ).run(
toDbMs(endedAtMs), toDbTimestamp(endedAtMs),
SESSION_STATUS_ENDED, SESSION_STATUS_ENDED,
sessionState.lastMediaMs === null ? null : toDbMs(sessionState.lastMediaMs), sessionState.lastMediaMs === null ? null : toDbMs(sessionState.lastMediaMs),
sessionState.totalWatchedMs, sessionState.totalWatchedMs,
@@ -82,7 +82,7 @@ export function finalizeSessionRecord(
sessionState.seekForwardCount, sessionState.seekForwardCount,
sessionState.seekBackwardCount, sessionState.seekBackwardCount,
sessionState.mediaBufferEvents, sessionState.mediaBufferEvents,
toDbMs(nowMs()), toDbTimestamp(nowMs()),
sessionState.sessionId, sessionState.sessionId,
); );
} }

View File

@@ -143,10 +143,10 @@ test('ensureSchema creates immersion core tables', () => {
const rollupStateRow = db const rollupStateRow = db
.prepare('SELECT state_value FROM imm_rollup_state WHERE state_key = ?') .prepare('SELECT state_value FROM imm_rollup_state WHERE state_key = ?')
.get('last_rollup_sample_ms') as { .get('last_rollup_sample_ms') as {
state_value: number; state_value: string;
} | null; } | null;
assert.ok(rollupStateRow); assert.ok(rollupStateRow);
assert.equal(rollupStateRow?.state_value, 0); assert.equal(Number(rollupStateRow?.state_value ?? 0), 0);
} finally { } finally {
db.close(); db.close();
cleanupDbPath(dbPath); cleanupDbPath(dbPath);
@@ -965,12 +965,12 @@ test('start/finalize session updates ended_at and status', () => {
const row = db const row = db
.prepare('SELECT ended_at_ms, status FROM imm_sessions WHERE session_id = ?') .prepare('SELECT ended_at_ms, status FROM imm_sessions WHERE session_id = ?')
.get(sessionId) as { .get(sessionId) as {
ended_at_ms: number | null; ended_at_ms: string | null;
status: number; status: number;
} | null; } | null;
assert.ok(row); 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); assert.equal(row?.status, SESSION_STATUS_ENDED);
} finally { } finally {
db.close(); db.close();

View File

@@ -4,7 +4,7 @@ import type { DatabaseSync } from './sqlite';
import { nowMs } from './time'; import { nowMs } from './time';
import { SCHEMA_VERSION } from './types'; import { SCHEMA_VERSION } from './types';
import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types'; import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types';
import { toDbMs } from './query-shared'; import { toDbMs, toDbTimestamp } from './query-shared';
export interface TrackerPreparedStatements { export interface TrackerPreparedStatements {
telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>; telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>;
@@ -130,7 +130,7 @@ function deduplicateExistingCoverArtRows(db: DatabaseSync): void {
return; return;
} }
const nowMsValue = toDbMs(nowMs()); const nowMsValue = toDbTimestamp(nowMs());
const upsertBlobStmt = db.prepare(` const upsertBlobStmt = db.prepare(`
INSERT INTO imm_cover_art_blobs (blob_hash, cover_blob, CREATED_DATE, LAST_UPDATE_DATE) INSERT INTO imm_cover_art_blobs (blob_hash, cover_blob, CREATED_DATE, LAST_UPDATE_DATE)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
@@ -275,7 +275,7 @@ function parseLegacyAnimeBackfillCandidate(
} }
function ensureLifetimeSummaryTables(db: DatabaseSync): void { function ensureLifetimeSummaryTables(db: DatabaseSync): void {
const nowMsValue = toDbMs(nowMs()); const nowMsValue = toDbTimestamp(nowMs());
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS imm_lifetime_global( 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_started INTEGER NOT NULL DEFAULT 0,
episodes_completed INTEGER NOT NULL DEFAULT 0, episodes_completed INTEGER NOT NULL DEFAULT 0,
anime_completed INTEGER NOT NULL DEFAULT 0, anime_completed INTEGER NOT NULL DEFAULT 0,
last_rebuilt_ms INTEGER, last_rebuilt_ms TEXT,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER LAST_UPDATE_DATE TEXT
) )
`); `);
@@ -332,10 +332,10 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void {
total_tokens_seen INTEGER NOT NULL DEFAULT 0, total_tokens_seen INTEGER NOT NULL DEFAULT 0,
episodes_started INTEGER NOT NULL DEFAULT 0, episodes_started INTEGER NOT NULL DEFAULT 0,
episodes_completed INTEGER NOT NULL DEFAULT 0, episodes_completed INTEGER NOT NULL DEFAULT 0,
first_watched_ms INTEGER, first_watched_ms TEXT,
last_watched_ms INTEGER, last_watched_ms TEXT,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE 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_lines_seen INTEGER NOT NULL DEFAULT 0,
total_tokens_seen INTEGER NOT NULL DEFAULT 0, total_tokens_seen INTEGER NOT NULL DEFAULT 0,
completed INTEGER NOT NULL DEFAULT 0, completed INTEGER NOT NULL DEFAULT 0,
first_watched_ms INTEGER, first_watched_ms TEXT,
last_watched_ms INTEGER, last_watched_ms TEXT,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
) )
`); `);
@@ -360,9 +360,9 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void {
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS imm_lifetime_applied_sessions( CREATE TABLE IF NOT EXISTS imm_lifetime_applied_sessions(
session_id INTEGER PRIMARY KEY, session_id INTEGER PRIMARY KEY,
applied_at_ms INTEGER NOT NULL, applied_at_ms TEXT NOT NULL,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE 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.titleEnglish,
input.titleNative, input.titleNative,
input.metadataJson, input.metadataJson,
toDbMs(nowMs()), toDbTimestamp(nowMs()),
existing.anime_id, existing.anime_id,
); );
return existing.anime_id; return existing.anime_id;
} }
const nowMsValue = toDbMs(nowMs()); const nowMsValue = toDbTimestamp(nowMs());
const result = db const result = db
.prepare( .prepare(
` `
@@ -471,7 +471,7 @@ export function linkVideoToAnimeRecord(
input.parserSource, input.parserSource,
input.parserConfidence, input.parserConfidence,
input.parseMetadataJson, input.parseMetadataJson,
toDbMs(nowMs()), toDbTimestamp(nowMs()),
videoId, videoId,
); );
} }
@@ -562,13 +562,13 @@ export function ensureSchema(db: DatabaseSync): void {
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS imm_schema_version ( CREATE TABLE IF NOT EXISTS imm_schema_version (
schema_version INTEGER PRIMARY KEY, schema_version INTEGER PRIMARY KEY,
applied_at_ms INTEGER NOT NULL applied_at_ms TEXT NOT NULL
); );
`); `);
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS imm_rollup_state( CREATE TABLE IF NOT EXISTS imm_rollup_state(
state_key TEXT PRIMARY KEY, state_key TEXT PRIMARY KEY,
state_value INTEGER NOT NULL state_value TEXT NOT NULL
); );
`); `);
db.exec(` db.exec(`
@@ -597,8 +597,8 @@ export function ensureSchema(db: DatabaseSync): void {
episodes_total INTEGER, episodes_total INTEGER,
description TEXT, description TEXT,
metadata_json TEXT, metadata_json TEXT,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER LAST_UPDATE_DATE TEXT
); );
`); `);
db.exec(` db.exec(`
@@ -625,8 +625,8 @@ export function ensureSchema(db: DatabaseSync): void {
bitrate_kbps INTEGER, audio_codec_id INTEGER, bitrate_kbps INTEGER, audio_codec_id INTEGER,
hash_sha256 TEXT, screenshot_path TEXT, hash_sha256 TEXT, screenshot_path TEXT,
metadata_json TEXT, metadata_json TEXT,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL 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_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_uuid TEXT NOT NULL UNIQUE, session_uuid TEXT NOT NULL UNIQUE,
video_id INTEGER NOT NULL, 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, status INTEGER NOT NULL,
locale_id INTEGER, target_lang_id INTEGER, locale_id INTEGER, target_lang_id INTEGER,
difficulty_tier INTEGER, subtitle_mode 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_forward_count INTEGER NOT NULL DEFAULT 0,
seek_backward_count INTEGER NOT NULL DEFAULT 0, seek_backward_count INTEGER NOT NULL DEFAULT 0,
media_buffer_events INTEGER NOT NULL DEFAULT 0, media_buffer_events INTEGER NOT NULL DEFAULT 0,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) 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( CREATE TABLE IF NOT EXISTS imm_session_telemetry(
telemetry_id INTEGER PRIMARY KEY AUTOINCREMENT, telemetry_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL, session_id INTEGER NOT NULL,
sample_ms INTEGER NOT NULL, sample_ms TEXT NOT NULL,
total_watched_ms INTEGER NOT NULL DEFAULT 0, total_watched_ms INTEGER NOT NULL DEFAULT 0,
active_watched_ms INTEGER NOT NULL DEFAULT 0, active_watched_ms INTEGER NOT NULL DEFAULT 0,
lines_seen 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_forward_count INTEGER NOT NULL DEFAULT 0,
seek_backward_count INTEGER NOT NULL DEFAULT 0, seek_backward_count INTEGER NOT NULL DEFAULT 0,
media_buffer_events INTEGER NOT NULL DEFAULT 0, media_buffer_events INTEGER NOT NULL DEFAULT 0,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE 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, tokens_delta INTEGER NOT NULL DEFAULT 0,
cards_delta INTEGER NOT NULL DEFAULT 0, cards_delta INTEGER NOT NULL DEFAULT 0,
payload_json TEXT, payload_json TEXT,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE 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, cards_per_hour REAL,
tokens_per_min REAL, tokens_per_min REAL,
lookup_hit_rate REAL, lookup_hit_rate REAL,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
PRIMARY KEY (rollup_day, video_id) 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_lines_seen INTEGER NOT NULL DEFAULT 0,
total_tokens_seen INTEGER NOT NULL DEFAULT 0, total_tokens_seen INTEGER NOT NULL DEFAULT 0,
total_cards INTEGER NOT NULL DEFAULT 0, total_cards INTEGER NOT NULL DEFAULT 0,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
PRIMARY KEY (rollup_month, video_id) PRIMARY KEY (rollup_month, video_id)
); );
`); `);
@@ -806,9 +806,9 @@ export function ensureSchema(db: DatabaseSync): void {
title_romaji TEXT, title_romaji TEXT,
title_english TEXT, title_english TEXT,
episodes_total INTEGER, episodes_total INTEGER,
fetched_at_ms INTEGER NOT NULL, fetched_at_ms TEXT NOT NULL,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE 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, uploader_url TEXT,
description TEXT, description TEXT,
metadata_json TEXT, metadata_json TEXT,
fetched_at_ms INTEGER NOT NULL, fetched_at_ms TEXT NOT NULL,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE 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( CREATE TABLE IF NOT EXISTS imm_cover_art_blobs(
blob_hash TEXT PRIMARY KEY, blob_hash TEXT PRIMARY KEY,
cover_blob BLOB NOT NULL, cover_blob BLOB NOT NULL,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER LAST_UPDATE_DATE TEXT
); );
`); `);
if (currentVersion?.schema_version === 1) { if (currentVersion?.schema_version === 1) {
addColumnIfMissing(db, 'imm_videos', 'CREATED_DATE'); addColumnIfMissing(db, 'imm_videos', 'CREATED_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_videos', 'LAST_UPDATE_DATE'); addColumnIfMissing(db, 'imm_videos', 'LAST_UPDATE_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_sessions', 'CREATED_DATE'); addColumnIfMissing(db, 'imm_sessions', 'CREATED_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_sessions', 'LAST_UPDATE_DATE'); addColumnIfMissing(db, 'imm_sessions', 'LAST_UPDATE_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_session_telemetry', 'CREATED_DATE'); addColumnIfMissing(db, 'imm_session_telemetry', 'CREATED_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_session_telemetry', 'LAST_UPDATE_DATE'); addColumnIfMissing(db, 'imm_session_telemetry', 'LAST_UPDATE_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_session_events', 'CREATED_DATE'); addColumnIfMissing(db, 'imm_session_events', 'CREATED_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_session_events', 'LAST_UPDATE_DATE'); addColumnIfMissing(db, 'imm_session_events', 'LAST_UPDATE_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_daily_rollups', 'CREATED_DATE'); addColumnIfMissing(db, 'imm_daily_rollups', 'CREATED_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_daily_rollups', 'LAST_UPDATE_DATE'); addColumnIfMissing(db, 'imm_daily_rollups', 'LAST_UPDATE_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_monthly_rollups', 'CREATED_DATE'); addColumnIfMissing(db, 'imm_monthly_rollups', 'CREATED_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_monthly_rollups', 'LAST_UPDATE_DATE'); addColumnIfMissing(db, 'imm_monthly_rollups', 'LAST_UPDATE_DATE', 'TEXT');
const migratedAtMs = toDbMs(nowMs()); const migratedAtMs = toDbTimestamp(nowMs());
db.prepare( db.prepare(
` `
UPDATE imm_videos UPDATE imm_videos
@@ -1243,7 +1243,7 @@ export function ensureSchema(db: DatabaseSync): void {
db.exec(` db.exec(`
INSERT INTO imm_schema_version(schema_version, applied_at_ms) INSERT INTO imm_schema_version(schema_version, applied_at_ms)
VALUES (${SCHEMA_VERSION}, ${toDbMs(nowMs())}) VALUES (${SCHEMA_VERSION}, ${toDbTimestamp(nowMs())})
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
`); `);
} }
@@ -1401,7 +1401,7 @@ function incrementKanjiAggregate(
} }
export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedStatements): void { export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedStatements): void {
const currentMs = toDbMs(nowMs()); const currentMs = toDbTimestamp(nowMs());
if (write.kind === 'telemetry') { if (write.kind === 'telemetry') {
if ( if (
write.totalWatchedMs === undefined || write.totalWatchedMs === undefined ||
@@ -1420,7 +1420,7 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
) { ) {
throw new Error('Incomplete telemetry write'); throw new Error('Incomplete telemetry write');
} }
const telemetrySampleMs = toDbMs(write.sampleMs ?? Number(currentMs)); const telemetrySampleMs = toDbTimestamp(write.sampleMs ?? Number(currentMs));
stmts.telemetryInsertStmt.run( stmts.telemetryInsertStmt.run(
write.sessionId, write.sessionId,
telemetrySampleMs, telemetrySampleMs,
@@ -1495,7 +1495,7 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
stmts.eventInsertStmt.run( stmts.eventInsertStmt.run(
write.sessionId, write.sessionId,
toDbMs(write.sampleMs ?? Number(currentMs)), toDbTimestamp(write.sampleMs ?? Number(currentMs)),
write.eventType ?? 0, write.eventType ?? 0,
write.lineIndex ?? null, write.lineIndex ?? null,
write.segmentStartMs ?? null, write.segmentStartMs ?? null,
@@ -1530,11 +1530,11 @@ export function getOrCreateVideoRecord(
LAST_UPDATE_DATE = ? LAST_UPDATE_DATE = ?
WHERE video_id = ? 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; return existing.video_id;
} }
const currentMs = toDbMs(nowMs()); const currentMs = toDbTimestamp(nowMs());
const insert = db.prepare(` const insert = db.prepare(`
INSERT INTO imm_videos ( INSERT INTO imm_videos (
video_key, canonical_title, source_type, source_path, source_url, video_key, canonical_title, source_type, source_path, source_url,
@@ -1604,7 +1604,7 @@ export function updateVideoMetadataRecord(
metadata.hashSha256, metadata.hashSha256,
metadata.screenshotPath, metadata.screenshotPath,
metadata.metadataJson, metadata.metadataJson,
toDbMs(nowMs()), toDbTimestamp(nowMs()),
videoId, videoId,
); );
} }
@@ -1622,7 +1622,7 @@ export function updateVideoTitleRecord(
LAST_UPDATE_DATE = ? LAST_UPDATE_DATE = ?
WHERE video_id = ? WHERE video_id = ?
`, `,
).run(canonicalTitle, toDbMs(nowMs()), videoId); ).run(canonicalTitle, toDbTimestamp(nowMs()), videoId);
} }
export function upsertYoutubeVideoMetadata( export function upsertYoutubeVideoMetadata(
@@ -1630,7 +1630,7 @@ export function upsertYoutubeVideoMetadata(
videoId: number, videoId: number,
metadata: YoutubeVideoMetadata, metadata: YoutubeVideoMetadata,
): void { ): void {
const currentMs = toDbMs(nowMs()); const currentMs = toDbTimestamp(nowMs());
db.prepare( db.prepare(
` `
INSERT INTO imm_youtube_videos ( INSERT INTO imm_youtube_videos (

View File

@@ -5,3 +5,25 @@ import { nowMs } from './time.js';
test('nowMs returns wall-clock epoch milliseconds', () => { test('nowMs returns wall-clock epoch milliseconds', () => {
assert.ok(nowMs() > 1_600_000_000_000); 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;
}
});

View File

@@ -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 { export function nowMs(): number {
const mockedNowMs = getMockNowMs(globalThis.__subminerTestNowMs);
if (mockedNowMs !== null) {
return mockedNowMs;
}
const perf = globalThis.performance; const perf = globalThis.performance;
if (perf && Number.isFinite(perf.timeOrigin)) { if (perf && Number.isFinite(perf.timeOrigin)) {
return Math.floor(perf.timeOrigin + perf.now()); return Math.floor(perf.timeOrigin + perf.now());

View File

@@ -16,6 +16,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line', SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line', SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
YOUTUBE_PICKER_OPEN: '__youtube-picker-open', YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
}, },
triggerSubsyncFromConfig: () => { triggerSubsyncFromConfig: () => {
calls.push('subsync'); calls.push('subsync');
@@ -26,6 +27,9 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
openYoutubeTrackPicker: () => { openYoutubeTrackPicker: () => {
calls.push('youtube-picker'); calls.push('youtube-picker');
}, },
openPlaylistBrowser: () => {
calls.push('playlist-browser');
},
runtimeOptionsCycle: () => ({ ok: true }), runtimeOptionsCycle: () => ({ ok: true }),
showMpvOsd: (text) => { showMpvOsd: (text) => {
osd.push(text); osd.push(text);
@@ -110,6 +114,28 @@ test('handleMpvCommandFromIpc dispatches special youtube picker open command', (
assert.deepEqual(osd, []); 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', () => { test('handleMpvCommandFromIpc does not forward commands while disconnected', () => {
const { options, sentCommands, osd } = createOptions({ const { options, sentCommands, osd } = createOptions({
isMpvConnected: () => false, isMpvConnected: () => false,

View File

@@ -15,10 +15,12 @@ export interface HandleMpvCommandFromIpcOptions {
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string; SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string;
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string; SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string;
YOUTUBE_PICKER_OPEN: string; YOUTUBE_PICKER_OPEN: string;
PLAYLIST_BROWSER_OPEN: string;
}; };
triggerSubsyncFromConfig: () => void; triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
openYoutubeTrackPicker: () => void | Promise<void>; openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => void | Promise<void>;
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
mpvReplaySubtitle: () => void; mpvReplaySubtitle: () => void;
@@ -97,6 +99,16 @@ export function handleMpvCommandFromIpc(
return; 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 ( if (
first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START || first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START ||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START first === options.specialCommands.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START

View File

@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc'; import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
import { IPC_CHANNELS } from '../../shared/ipc/contracts'; import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import type { SubtitleSidebarSnapshot } from '../../types'; import type { PlaylistBrowserSnapshot, SubtitleSidebarSnapshot } from '../../types';
interface FakeIpcRegistrar { interface FakeIpcRegistrar {
on: Map<string, (event: unknown, ...args: unknown[]) => void>; on: Map<string, (event: unknown, ...args: unknown[]) => void>;
@@ -148,6 +148,19 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
getAnilistQueueStatus: () => ({}), getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ 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' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
immersionTracker: null, immersionTracker: null,
...overrides, ...overrides,
@@ -241,6 +254,19 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
return { ok: true, message: 'done' }; return { ok: true, message: 'done' };
}, },
appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }), 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' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
}); });
@@ -256,10 +282,83 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
ok: true, ok: true,
message: 'done', 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.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']);
assert.equal(deps.getPlaybackPaused(), true); 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 () => { test('registerIpcHandlers rejects malformed runtime-option payloads', async () => {
const { registrar, handlers } = createFakeIpcRegistrar(); const { registrar, handlers } = createFakeIpcRegistrar();
const calls: Array<{ id: string; value: unknown }> = []; const calls: Array<{ id: string; value: unknown }> = [];
@@ -311,6 +410,19 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
getAnilistQueueStatus: () => ({}), getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ 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' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
}, },
registrar, registrar,
@@ -618,6 +730,19 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
getAnilistQueueStatus: () => ({}), getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ 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' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
}, },
registrar, registrar,
@@ -685,6 +810,19 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
getAnilistQueueStatus: () => ({}), getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ 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' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
}, },
registrar, registrar,
@@ -755,6 +893,19 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
getAnilistQueueStatus: () => ({}), getAnilistQueueStatus: () => ({}),
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ 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' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
}, },
registrar, registrar,

View File

@@ -2,6 +2,8 @@ import electron from 'electron';
import type { IpcMainEvent } from 'electron'; import type { IpcMainEvent } from 'electron';
import type { import type {
ControllerConfigUpdate, ControllerConfigUpdate,
PlaylistBrowserMutationResult,
PlaylistBrowserSnapshot,
ControllerPreferenceUpdate, ControllerPreferenceUpdate,
ResolvedControllerConfig, ResolvedControllerConfig,
RuntimeOptionId, RuntimeOptionId,
@@ -78,6 +80,14 @@ export interface IpcServiceDeps {
getAnilistQueueStatus: () => unknown; getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string }; appendClipboardVideoToQueue: () => { ok: boolean; message: string };
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
removePlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
movePlaylistBrowserIndex: (
index: number,
direction: 1 | -1,
) => Promise<PlaylistBrowserMutationResult>;
immersionTracker?: { immersionTracker?: {
recordYomitanLookup: () => void; recordYomitanLookup: () => void;
getSessionSummaries: (limit?: number) => Promise<unknown>; getSessionSummaries: (limit?: number) => Promise<unknown>;
@@ -183,6 +193,14 @@ export interface IpcDepsRuntimeOptions {
getAnilistQueueStatus: () => unknown; getAnilistQueueStatus: () => unknown;
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>; retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
appendClipboardVideoToQueue: () => { ok: boolean; message: string }; appendClipboardVideoToQueue: () => { ok: boolean; message: string };
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
appendPlaylistBrowserFile: (filePath: string) => Promise<PlaylistBrowserMutationResult>;
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
removePlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
movePlaylistBrowserIndex: (
index: number,
direction: 1 | -1,
) => Promise<PlaylistBrowserMutationResult>;
getImmersionTracker?: () => IpcServiceDeps['immersionTracker']; getImmersionTracker?: () => IpcServiceDeps['immersionTracker'];
} }
@@ -246,6 +264,11 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
getAnilistQueueStatus: options.getAnilistQueueStatus, getAnilistQueueStatus: options.getAnilistQueueStatus,
retryAnilistQueueNow: options.retryAnilistQueueNow, retryAnilistQueueNow: options.retryAnilistQueueNow,
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue, appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot,
appendPlaylistBrowserFile: options.appendPlaylistBrowserFile,
playPlaylistBrowserIndex: options.playPlaylistBrowserIndex,
removePlaylistBrowserIndex: options.removePlaylistBrowserIndex,
movePlaylistBrowserIndex: options.movePlaylistBrowserIndex,
get immersionTracker() { get immersionTracker() {
return options.getImmersionTracker?.() ?? null; return options.getImmersionTracker?.() ?? null;
}, },
@@ -510,6 +533,44 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
return deps.appendClipboardVideoToQueue(); 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 // Stats request handlers
ipc.handle(IPC_CHANNELS.request.statsGetOverview, async () => { ipc.handle(IPC_CHANNELS.request.statsGetOverview, async () => {
const tracker = deps.immersionTracker; const tracker = deps.immersionTracker;

View File

@@ -238,7 +238,7 @@ test('visible overlay stays hidden while a modal window is active', () => {
assert.ok(!calls.includes('update-bounds')); 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 { window, calls } = createMainWindowRecorder();
const tracker: WindowTrackerStub = { const tracker: WindowTrackerStub = {
isTracking: () => true, isTracking: () => true,
@@ -270,7 +270,7 @@ test('macOS tracked visible overlay stays visible without passively stealing foc
isWindowsPlatform: false, isWindowsPlatform: false,
} as never); } 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('show'));
assert.ok(!calls.includes('focus')); assert.ok(!calls.includes('focus'));
}); });

View File

@@ -37,7 +37,7 @@ export function updateVisibleOverlayVisibility(args: {
const showPassiveVisibleOverlay = (): void => { const showPassiveVisibleOverlay = (): void => {
const forceMousePassthrough = args.forceMousePassthrough === true; const forceMousePassthrough = args.forceMousePassthrough === true;
if (args.isWindowsPlatform || forceMousePassthrough) { if (args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough) {
mainWindow.setIgnoreMouseEvents(true, { forward: true }); mainWindow.setIgnoreMouseEvents(true, { forward: true });
} else { } else {
mainWindow.setIgnoreMouseEvents(false); mainWindow.setIgnoreMouseEvents(false);

View File

@@ -31,6 +31,7 @@ import {
screen, screen,
} from 'electron'; } from 'electron';
import { applyControllerConfigUpdate } from './main/controller-config-update.js'; 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 { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
import { mergeAiConfig } from './ai/config'; import { mergeAiConfig } from './ai/config';
@@ -427,6 +428,7 @@ import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
import { createOverlayModalRuntimeService } from './main/overlay-runtime'; import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state'; import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open'; import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime'; import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
import { import {
createFrequencyDictionaryRuntimeService, createFrequencyDictionaryRuntimeService,
@@ -1929,6 +1931,23 @@ function openRuntimeOptionsPalette(): void {
overlayVisibilityComposer.openRuntimeOptionsPalette(); 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() { function getResolvedConfig() {
return configService.getConfig(); return configService.getConfig();
} }
@@ -4109,11 +4128,14 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
showMpvOsd: (text) => showMpvOsd(text), showMpvOsd: (text) => showMpvOsd(text),
}); });
const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient);
const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
mpvCommandMainDeps: { mpvCommandMainDeps: {
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
openPlaylistBrowser: () => openPlaylistBrowser(),
cycleRuntimeOption: (id, direction) => { cycleRuntimeOption: (id, direction) => {
if (!appState.runtimeOptionsManager) { if (!appState.runtimeOptionsManager) {
return { ok: false, error: 'Runtime options manager unavailable' }; return { ok: false, error: 'Runtime options manager unavailable' };
@@ -4290,6 +4312,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(), getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(), retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(), appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(),
...playlistBrowserMainDeps,
getImmersionTracker: () => appState.immersionTracker, getImmersionTracker: () => appState.immersionTracker,
}, },
ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({ ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({

View File

@@ -93,6 +93,11 @@ export interface MainIpcRuntimeServiceDepsParams {
getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus']; getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus'];
retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow']; retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow'];
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue']; appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot'];
appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile'];
playPlaylistBrowserIndex: IpcDepsRuntimeOptions['playPlaylistBrowserIndex'];
removePlaylistBrowserIndex: IpcDepsRuntimeOptions['removePlaylistBrowserIndex'];
movePlaylistBrowserIndex: IpcDepsRuntimeOptions['movePlaylistBrowserIndex'];
getImmersionTracker?: IpcDepsRuntimeOptions['getImmersionTracker']; getImmersionTracker?: IpcDepsRuntimeOptions['getImmersionTracker'];
} }
@@ -193,6 +198,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig']; triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig'];
openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette']; openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette'];
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker']; openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd']; showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle']; mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle']; mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
@@ -247,6 +253,11 @@ export function createMainIpcRuntimeServiceDeps(
getAnilistQueueStatus: params.getAnilistQueueStatus, getAnilistQueueStatus: params.getAnilistQueueStatus,
retryAnilistQueueNow: params.retryAnilistQueueNow, retryAnilistQueueNow: params.retryAnilistQueueNow,
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue, appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot,
appendPlaylistBrowserFile: params.appendPlaylistBrowserFile,
playPlaylistBrowserIndex: params.playPlaylistBrowserIndex,
removePlaylistBrowserIndex: params.removePlaylistBrowserIndex,
movePlaylistBrowserIndex: params.movePlaylistBrowserIndex,
getImmersionTracker: params.getImmersionTracker, getImmersionTracker: params.getImmersionTracker,
}; };
} }
@@ -358,6 +369,7 @@ export function createMpvCommandRuntimeServiceDeps(
triggerSubsyncFromConfig: params.triggerSubsyncFromConfig, triggerSubsyncFromConfig: params.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: params.openRuntimeOptionsPalette, openRuntimeOptionsPalette: params.openRuntimeOptionsPalette,
openYoutubeTrackPicker: params.openYoutubeTrackPicker, openYoutubeTrackPicker: params.openYoutubeTrackPicker,
openPlaylistBrowser: params.openPlaylistBrowser,
runtimeOptionsCycle: params.runtimeOptionsCycle, runtimeOptionsCycle: params.runtimeOptionsCycle,
showMpvOsd: params.showMpvOsd, showMpvOsd: params.showMpvOsd,
mpvReplaySubtitle: params.mpvReplaySubtitle, mpvReplaySubtitle: params.mpvReplaySubtitle,

View File

@@ -13,6 +13,7 @@ export interface MpvCommandFromIpcRuntimeDeps {
triggerSubsyncFromConfig: () => void; triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
openYoutubeTrackPicker: () => void | Promise<void>; openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => void | Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
showMpvOsd: (text: string) => void; showMpvOsd: (text: string) => void;
replayCurrentSubtitle: () => void; replayCurrentSubtitle: () => void;
@@ -35,6 +36,7 @@ export function handleMpvCommandFromIpcRuntime(
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig, triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openYoutubeTrackPicker: deps.openYoutubeTrackPicker, openYoutubeTrackPicker: deps.openYoutubeTrackPicker,
openPlaylistBrowser: deps.openPlaylistBrowser,
runtimeOptionsCycle: deps.cycleRuntimeOption, runtimeOptionsCycle: deps.cycleRuntimeOption,
showMpvOsd: deps.showMpvOsd, showMpvOsd: deps.showMpvOsd,
mpvReplaySubtitle: deps.replayCurrentSubtitle, mpvReplaySubtitle: deps.replayCurrentSubtitle,

View File

@@ -125,3 +125,54 @@ test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirel
await handler(); await handler();
assert.deepEqual(calls, []); assert.deepEqual(calls, []);
}); });
test('createMaybeRunAnilistPostWatchUpdateHandler does not live-update after retry already handled current attempt key', async () => {
const calls: string[] = [];
const attemptedKeys = new Set<string>();
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']);
});

View File

@@ -165,6 +165,9 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
deps.setInFlight(true); deps.setInFlight(true);
try { try {
await deps.processNextAnilistRetryUpdate(); await deps.processNextAnilistRetryUpdate();
if (deps.hasAttemptedUpdateKey(attemptKey)) {
return;
}
const accessToken = await deps.refreshAnilistClientSecretState(); const accessToken = await deps.refreshAnilistClientSecretState();
if (!accessToken) { if (!accessToken) {

View File

@@ -11,6 +11,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
triggerSubsyncFromConfig: async () => {}, triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {}, openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {}, openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: true }), cycleRuntimeOption: () => ({ ok: true }),
showMpvOsd: () => {}, showMpvOsd: () => {},
replayCurrentSubtitle: () => {}, replayCurrentSubtitle: () => {},
@@ -68,6 +69,20 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
getAnilistQueueStatus: () => ({}) as never, getAnilistQueueStatus: () => ({}) as never,
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }), retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
appendClipboardVideoToQueue: () => ({ 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' }), onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
}, },
ankiJimakuDeps: { ankiJimakuDeps: {

View File

@@ -14,6 +14,7 @@ test('ipc bridge action main deps builders map callbacks', async () => {
triggerSubsyncFromConfig: async () => {}, triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {}, openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {}, openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: () => {}, showMpvOsd: () => {},
replayCurrentSubtitle: () => {}, replayCurrentSubtitle: () => {},

View File

@@ -11,6 +11,7 @@ test('handle mpv command handler forwards command and built deps', () => {
triggerSubsyncFromConfig: () => {}, triggerSubsyncFromConfig: () => {},
openRuntimeOptionsPalette: () => {}, openRuntimeOptionsPalette: () => {},
openYoutubeTrackPicker: () => {}, openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: () => {}, showMpvOsd: () => {},
replayCurrentSubtitle: () => {}, replayCurrentSubtitle: () => {},

View File

@@ -10,6 +10,9 @@ test('ipc mpv command main deps builder maps callbacks', () => {
openYoutubeTrackPicker: () => { openYoutubeTrackPicker: () => {
calls.push('youtube-picker'); calls.push('youtube-picker');
}, },
openPlaylistBrowser: () => {
calls.push('playlist-browser');
},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
showMpvOsd: (text) => calls.push(`osd:${text}`), showMpvOsd: (text) => calls.push(`osd:${text}`),
replayCurrentSubtitle: () => calls.push('replay'), replayCurrentSubtitle: () => calls.push('replay'),
@@ -26,6 +29,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
deps.triggerSubsyncFromConfig(); deps.triggerSubsyncFromConfig();
deps.openRuntimeOptionsPalette(); deps.openRuntimeOptionsPalette();
void deps.openYoutubeTrackPicker(); void deps.openYoutubeTrackPicker();
void deps.openPlaylistBrowser();
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' }); assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
deps.showMpvOsd('hello'); deps.showMpvOsd('hello');
deps.replayCurrentSubtitle(); deps.replayCurrentSubtitle();
@@ -39,6 +43,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
'subsync', 'subsync',
'palette', 'palette',
'youtube-picker', 'youtube-picker',
'playlist-browser',
'osd:hello', 'osd:hello',
'replay', 'replay',
'next', 'next',

View File

@@ -7,6 +7,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(), openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
openPlaylistBrowser: () => deps.openPlaylistBrowser(),
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction), cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
showMpvOsd: (text: string) => deps.showMpvOsd(text), showMpvOsd: (text: string) => deps.showMpvOsd(text),
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(), replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),

View File

@@ -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),
},
};
}

View File

@@ -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}`]);
});

View File

@@ -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,
});
}

View File

@@ -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<unknown> {
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<unknown> => {
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'],
],
);
});

View File

@@ -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<unknown>;
send: (payload: { command: unknown[]; request_id?: number }) => boolean;
};
export type PlaylistBrowserRuntimeDeps = {
getMpvClient: () => MpvPlaylistBrowserClientLike | null;
schedule?: (callback: () => void, delayMs: number) => void;
};
const pendingLocalSubtitleSelectionRearms = new WeakMap<MpvPlaylistBrowserClientLike, number>();
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<unknown> {
if (!client?.requestProperty) return null;
try {
return await client.requestProperty(name);
} catch {
return null;
}
}
async function resolveCurrentFilePath(
client: MpvPlaylistBrowserClientLike | null,
): Promise<string | null> {
const currentVideoPath = trimToNull(client?.currentVideoPath);
if (currentVideoPath) return currentVideoPath;
return trimToNull(await readProperty(client, 'path'));
}
function resolveDirectorySnapshot(
currentFilePath: string | null,
): Pick<PlaylistBrowserSnapshot, 'directoryAvailable' | 'directoryItems' | 'directoryPath' | 'directoryStatus'> {
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<PlaylistBrowserQueueItem[]> {
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<PlaylistBrowserSnapshot> {
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<PlaylistBrowserMutationResult> {
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<PlaylistBrowserMutationResult> {
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<PlaylistBrowserMutationResult> {
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<PlaylistBrowserMutationResult> {
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<PlaylistBrowserMutationResult> {
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);
}

View File

@@ -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],
);
});

View File

@@ -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,
}));
}

View File

@@ -38,6 +38,8 @@ import type {
SubsyncManualRunRequest, SubsyncManualRunRequest,
SubsyncResult, SubsyncResult,
ClipboardAppendResult, ClipboardAppendResult,
PlaylistBrowserMutationResult,
PlaylistBrowserSnapshot,
KikuFieldGroupingRequestData, KikuFieldGroupingRequestData,
KikuFieldGroupingChoice, KikuFieldGroupingChoice,
KikuMergePreviewRequest, KikuMergePreviewRequest,
@@ -126,6 +128,7 @@ const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload<Youtube
IPC_CHANNELS.event.youtubePickerOpen, IPC_CHANNELS.event.youtubePickerOpen,
(payload) => payload as YoutubePickerOpenPayload, (payload) => payload as YoutubePickerOpenPayload,
); );
const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.playlistBrowserOpen);
const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener( const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener(
IPC_CHANNELS.event.youtubePickerCancel, IPC_CHANNELS.event.youtubePickerCancel,
); );
@@ -322,11 +325,25 @@ const electronAPI: ElectronAPI = {
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent, onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
onOpenJimaku: onOpenJimakuEvent, onOpenJimaku: onOpenJimakuEvent,
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent, onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent, onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent, onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent, onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> => appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue), ipcRenderer.invoke(IPC_CHANNELS.request.appendClipboardVideoToQueue),
getPlaylistBrowserSnapshot: (): Promise<PlaylistBrowserSnapshot> =>
ipcRenderer.invoke(IPC_CHANNELS.request.getPlaylistBrowserSnapshot),
appendPlaylistBrowserFile: (pathValue: string): Promise<PlaylistBrowserMutationResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.appendPlaylistBrowserFile, pathValue),
playPlaylistBrowserIndex: (index: number): Promise<PlaylistBrowserMutationResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.playPlaylistBrowserIndex, index),
removePlaylistBrowserIndex: (index: number): Promise<PlaylistBrowserMutationResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.removePlaylistBrowserIndex, index),
movePlaylistBrowserIndex: (
index: number,
direction: 1 | -1,
): Promise<PlaylistBrowserMutationResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.movePlaylistBrowserIndex, index, direction),
youtubePickerResolve: ( youtubePickerResolve: (
request: YoutubePickerResolveRequest, request: YoutubePickerResolveRequest,
): Promise<YoutubePickerResolveResult> => ): Promise<YoutubePickerResolveResult> =>

View File

@@ -294,6 +294,7 @@ function createKeyboardHandlerHarness() {
let controllerSelectOpenCount = 0; let controllerSelectOpenCount = 0;
let controllerDebugOpenCount = 0; let controllerDebugOpenCount = 0;
let controllerSelectKeydownCount = 0; let controllerSelectKeydownCount = 0;
let playlistBrowserKeydownCount = 0;
const createWordNode = (left: number) => ({ const createWordNode = (left: number) => ({
classList: createClassList(), classList: createClassList(),
@@ -333,6 +334,10 @@ function createKeyboardHandlerHarness() {
}, },
handleControllerDebugKeydown: () => false, handleControllerDebugKeydown: () => false,
handleYoutubePickerKeydown: () => false, handleYoutubePickerKeydown: () => false,
handlePlaylistBrowserKeydown: () => {
playlistBrowserKeydownCount += 1;
return true;
},
handleSessionHelpKeydown: () => false, handleSessionHelpKeydown: () => false,
openSessionHelpModal: () => {}, openSessionHelpModal: () => {},
appendClipboardVideoToQueue: () => {}, appendClipboardVideoToQueue: () => {},
@@ -352,6 +357,7 @@ function createKeyboardHandlerHarness() {
controllerSelectOpenCount: () => controllerSelectOpenCount, controllerSelectOpenCount: () => controllerSelectOpenCount,
controllerDebugOpenCount: () => controllerDebugOpenCount, controllerDebugOpenCount: () => controllerDebugOpenCount,
controllerSelectKeydownCount: () => controllerSelectKeydownCount, controllerSelectKeydownCount: () => controllerSelectKeydownCount,
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
setWordCount: (count: number) => { setWordCount: (count: number) => {
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70)); 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 () => { test('keyboard mode: configured stats toggle works even while popup is open', async () => {
const { handlers, testGlobals } = createKeyboardHandlerHarness(); const { handlers, testGlobals } = createKeyboardHandlerHarness();

View File

@@ -16,6 +16,7 @@ export function createKeyboardHandlers(
handleKikuKeydown: (e: KeyboardEvent) => boolean; handleKikuKeydown: (e: KeyboardEvent) => boolean;
handleJimakuKeydown: (e: KeyboardEvent) => boolean; handleJimakuKeydown: (e: KeyboardEvent) => boolean;
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean; handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean; handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean; handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean; handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
@@ -815,6 +816,12 @@ export function createKeyboardHandlers(
return; return;
} }
if (ctx.state.playlistBrowserModalOpen) {
if (options.handlePlaylistBrowserKeydown(e)) {
return;
}
}
if (handleKeyboardDrivenModeLookupControls(e)) { if (handleKeyboardDrivenModeLookupControls(e)) {
e.preventDefault(); e.preventDefault();
return; return;

View File

@@ -320,6 +320,35 @@
</div> </div>
</div> </div>
</div> </div>
<div id="playlistBrowserModal" class="modal hidden" aria-hidden="true">
<div class="modal-content playlist-browser-content">
<div class="modal-header">
<div class="modal-title">Playlist Browser</div>
<button id="playlistBrowserClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body playlist-browser-body">
<div id="playlistBrowserTitle" class="playlist-browser-title"></div>
<div id="playlistBrowserStatus" class="playlist-browser-status"></div>
<div class="playlist-browser-grid">
<div class="playlist-browser-pane">
<div class="playlist-browser-pane-title">Directory</div>
<ul id="playlistBrowserDirectoryList" class="playlist-browser-list"></ul>
</div>
<div class="playlist-browser-pane">
<div class="playlist-browser-pane-title">Playlist</div>
<ul id="playlistBrowserPlaylistList" class="playlist-browser-list"></ul>
</div>
</div>
<div class="playlist-browser-footer">
<span>Tab switch pane</span>
<span>Enter activate</span>
<span>Delete remove</span>
<span>Ctrl/Cmd+Arrows reorder</span>
<span>Esc close</span>
</div>
</div>
</div>
</div>
</div> </div>
<script type="module" src="renderer.js"></script> <script type="module" src="renderer.js"></script>
</body> </body>

View File

@@ -0,0 +1,144 @@
import type {
PlaylistBrowserDirectoryItem,
PlaylistBrowserQueueItem,
} from '../../types';
import type { RendererContext } from '../context';
type PlaylistBrowserRowRenderActions = {
appendDirectoryItem: (filePath: string) => void;
movePlaylistItem: (index: number, direction: 1 | -1) => void;
playPlaylistItem: (index: number) => void;
removePlaylistItem: (index: number) => void;
render: () => void;
};
function createActionButton(label: string, onClick: () => void): HTMLButtonElement {
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
button.className = 'playlist-browser-action';
button.addEventListener('click', (event) => {
event.stopPropagation();
onClick();
});
button.addEventListener('dblclick', (event) => {
event.preventDefault?.();
event.stopPropagation();
});
return button;
}
export function renderPlaylistBrowserDirectoryRow(
ctx: RendererContext,
item: PlaylistBrowserDirectoryItem,
index: number,
actions: PlaylistBrowserRowRenderActions,
): HTMLElement {
const row = document.createElement('li');
row.className = 'playlist-browser-row';
if (item.isCurrentFile) row.classList.add('current');
if (
ctx.state.playlistBrowserActivePane === 'directory' &&
ctx.state.playlistBrowserSelectedDirectoryIndex === index
) {
row.classList.add('active');
}
const main = document.createElement('div');
main.className = 'playlist-browser-row-main';
const label = document.createElement('div');
label.className = 'playlist-browser-row-label';
label.textContent = item.basename;
const meta = document.createElement('div');
meta.className = 'playlist-browser-row-meta';
meta.textContent = item.isCurrentFile
? item.episodeLabel
? `${item.episodeLabel} · Current file`
: 'Current file'
: item.episodeLabel ?? 'Video file';
main.append(label, meta);
const trailing = document.createElement('div');
trailing.className = 'playlist-browser-row-trailing';
if (item.episodeLabel) {
const badge = document.createElement('div');
badge.className = 'playlist-browser-chip';
badge.textContent = item.episodeLabel;
trailing.appendChild(badge);
}
trailing.appendChild(
createActionButton('Add', () => {
void actions.appendDirectoryItem(item.path);
}),
);
row.append(main, trailing);
row.addEventListener('click', () => {
ctx.state.playlistBrowserActivePane = 'directory';
ctx.state.playlistBrowserSelectedDirectoryIndex = index;
actions.render();
});
row.addEventListener('dblclick', () => {
ctx.state.playlistBrowserSelectedDirectoryIndex = index;
void actions.appendDirectoryItem(item.path);
});
return row;
}
export function renderPlaylistBrowserPlaylistRow(
ctx: RendererContext,
item: PlaylistBrowserQueueItem,
index: number,
actions: PlaylistBrowserRowRenderActions,
): HTMLElement {
const row = document.createElement('li');
row.className = 'playlist-browser-row';
if (item.current || item.playing) row.classList.add('current');
if (
ctx.state.playlistBrowserActivePane === 'playlist' &&
ctx.state.playlistBrowserSelectedPlaylistIndex === index
) {
row.classList.add('active');
}
const main = document.createElement('div');
main.className = 'playlist-browser-row-main';
const label = document.createElement('div');
label.className = 'playlist-browser-row-label';
label.textContent = `${index + 1}. ${item.displayLabel}`;
const meta = document.createElement('div');
meta.className = 'playlist-browser-row-meta';
meta.textContent = item.current || item.playing ? 'Playing now' : 'Queued';
const submeta = document.createElement('div');
submeta.className = 'playlist-browser-row-submeta';
submeta.textContent = item.filename;
main.append(label, meta, submeta);
const trailing = document.createElement('div');
trailing.className = 'playlist-browser-row-actions';
trailing.append(
createActionButton('Play', () => {
void actions.playPlaylistItem(item.index);
}),
createActionButton('Up', () => {
void actions.movePlaylistItem(item.index, -1);
}),
createActionButton('Down', () => {
void actions.movePlaylistItem(item.index, 1);
}),
createActionButton('Remove', () => {
void actions.removePlaylistItem(item.index);
}),
);
row.append(main, trailing);
row.addEventListener('click', () => {
ctx.state.playlistBrowserActivePane = 'playlist';
ctx.state.playlistBrowserSelectedPlaylistIndex = index;
actions.render();
});
row.addEventListener('dblclick', () => {
ctx.state.playlistBrowserSelectedPlaylistIndex = index;
void actions.playPlaylistItem(item.index);
});
return row;
}

View File

@@ -0,0 +1,659 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ElectronAPI, PlaylistBrowserSnapshot } from '../../types';
import { createRendererState } from '../state.js';
import { createPlaylistBrowserModal } from './playlist-browser.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) tokens.add(entry);
},
remove: (...entries: string[]) => {
for (const entry of entries) tokens.delete(entry);
},
contains: (entry: string) => tokens.has(entry),
toggle: (entry: string, force?: boolean) => {
if (force === true) tokens.add(entry);
else if (force === false) tokens.delete(entry);
else if (tokens.has(entry)) tokens.delete(entry);
else tokens.add(entry);
},
};
}
function createFakeElement() {
const attributes = new Map<string, string>();
return {
textContent: '',
innerHTML: '',
children: [] as unknown[],
listeners: new Map<string, Array<(event?: unknown) => void>>(),
classList: createClassList(['hidden']),
appendChild(child: unknown) {
this.children.push(child);
return child;
},
append(...children: unknown[]) {
this.children.push(...children);
},
replaceChildren(...children: unknown[]) {
this.children = [...children];
},
addEventListener(type: string, listener: (event?: unknown) => void) {
const bucket = this.listeners.get(type) ?? [];
bucket.push(listener);
this.listeners.set(type, bucket);
},
setAttribute(name: string, value: string) {
attributes.set(name, value);
},
getAttribute(name: string) {
return attributes.get(name) ?? null;
},
focus() {},
};
}
function createPlaylistRow() {
return {
className: '',
classList: createClassList(),
dataset: {} as Record<string, string>,
textContent: '',
children: [] as unknown[],
listeners: new Map<string, Array<(event?: unknown) => void>>(),
append(...children: unknown[]) {
this.children.push(...children);
},
appendChild(child: unknown) {
this.children.push(child);
return child;
},
addEventListener(type: string, listener: (event?: unknown) => void) {
const bucket = this.listeners.get(type) ?? [];
bucket.push(listener);
this.listeners.set(type, bucket);
},
setAttribute() {},
};
}
function createListStub() {
return {
innerHTML: '',
children: [] as ReturnType<typeof createPlaylistRow>[],
appendChild(child: ReturnType<typeof createPlaylistRow>) {
this.children.push(child);
return child;
},
replaceChildren(...children: ReturnType<typeof createPlaylistRow>[]) {
this.children = [...children];
},
};
}
function createSnapshot(): PlaylistBrowserSnapshot {
return {
directoryPath: '/tmp/show',
directoryAvailable: true,
directoryStatus: '/tmp/show',
currentFilePath: '/tmp/show/Show - S01E02.mkv',
playingIndex: 1,
directoryItems: [
{
path: '/tmp/show/Show - S01E01.mkv',
basename: 'Show - S01E01.mkv',
episodeLabel: 'S1E1',
isCurrentFile: false,
},
{
path: '/tmp/show/Show - S01E02.mkv',
basename: 'Show - S01E02.mkv',
episodeLabel: 'S1E2',
isCurrentFile: true,
},
],
playlistItems: [
{
index: 0,
id: 1,
filename: '/tmp/show/Show - S01E01.mkv',
title: 'Episode 1',
displayLabel: 'Episode 1',
current: false,
playing: false,
path: '/tmp/show/Show - S01E01.mkv',
},
{
index: 1,
id: 2,
filename: '/tmp/show/Show - S01E02.mkv',
title: 'Episode 2',
displayLabel: 'Episode 2',
current: true,
playing: true,
path: '/tmp/show/Show - S01E02.mkv',
},
],
};
}
function createMutationSnapshot(): PlaylistBrowserSnapshot {
return {
directoryPath: '/tmp/show',
directoryAvailable: true,
directoryStatus: '/tmp/show',
currentFilePath: '/tmp/show/Show - S01E02.mkv',
playingIndex: 0,
directoryItems: [
{
path: '/tmp/show/Show - S01E01.mkv',
basename: 'Show - S01E01.mkv',
episodeLabel: 'S1E1',
isCurrentFile: false,
},
{
path: '/tmp/show/Show - S01E02.mkv',
basename: 'Show - S01E02.mkv',
episodeLabel: 'S1E2',
isCurrentFile: true,
},
{
path: '/tmp/show/Show - S01E03.mkv',
basename: 'Show - S01E03.mkv',
episodeLabel: 'S1E3',
isCurrentFile: false,
},
],
playlistItems: [
{
index: 1,
id: 2,
filename: '/tmp/show/Show - S01E02.mkv',
title: 'Episode 2',
displayLabel: 'Episode 2',
current: true,
playing: true,
path: '/tmp/show/Show - S01E02.mkv',
},
{
index: 2,
id: 3,
filename: '/tmp/show/Show - S01E03.mkv',
title: 'Episode 3',
displayLabel: 'Episode 3',
current: false,
playing: false,
path: '/tmp/show/Show - S01E03.mkv',
},
{
index: 0,
id: 1,
filename: '/tmp/show/Show - S01E01.mkv',
title: 'Episode 1',
displayLabel: 'Episode 1',
current: false,
playing: false,
path: '/tmp/show/Show - S01E01.mkv',
},
],
};
}
function restoreGlobalDescriptor<K extends keyof typeof globalThis>(
key: K,
descriptor: PropertyDescriptor | undefined,
) {
if (descriptor) {
Object.defineProperty(globalThis, key, descriptor);
return;
}
Reflect.deleteProperty(globalThis, key);
}
function createPlaylistBrowserDomFixture() {
return {
overlay: {
classList: createClassList(),
focus: () => {},
},
playlistBrowserModal: createFakeElement(),
playlistBrowserTitle: createFakeElement(),
playlistBrowserStatus: createFakeElement(),
playlistBrowserDirectoryList: createListStub(),
playlistBrowserPlaylistList: createListStub(),
playlistBrowserClose: createFakeElement(),
};
}
function createPlaylistBrowserElectronApi(overrides?: Partial<ElectronAPI>): ElectronAPI {
return {
getPlaylistBrowserSnapshot: async () => createSnapshot(),
notifyOverlayModalOpened: () => {},
notifyOverlayModalClosed: () => {},
focusMainWindow: async () => {},
setIgnoreMouseEvents: () => {},
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok', snapshot: createSnapshot() }),
...overrides,
} as ElectronAPI;
}
function setupPlaylistBrowserModalTest(options?: {
electronApi?: Partial<ElectronAPI>;
shouldToggleMouseIgnore?: boolean;
}) {
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
const state = createRendererState();
const dom = createPlaylistBrowserDomFixture();
const ctx = {
state,
platform: {
shouldToggleMouseIgnore: options?.shouldToggleMouseIgnore ?? false,
},
dom,
};
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: createPlaylistBrowserElectronApi(options?.electronApi),
focus: () => {},
} satisfies { electronAPI: ElectronAPI; focus: () => void },
writable: true,
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
createElement: () => createPlaylistRow(),
},
writable: true,
});
return {
state,
dom,
createModal(overrides: Partial<Parameters<typeof createPlaylistBrowserModal>[1]> = {}) {
return createPlaylistBrowserModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
...overrides,
});
},
restore() {
restoreGlobalDescriptor('window', previousWindowDescriptor);
restoreGlobalDescriptor('document', previousDocumentDescriptor);
},
};
}
test('playlist browser test cleanup must delete injected globals that were originally absent', () => {
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
const env = setupPlaylistBrowserModalTest();
try {
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), true);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), true);
} finally {
env.restore();
}
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
assert.equal(typeof globalThis.window, 'undefined');
assert.equal(typeof globalThis.document, 'undefined');
});
test('playlist browser modal opens with playlist-focused current item selection', async () => {
const notifications: string[] = [];
const env = setupPlaylistBrowserModalTest({
electronApi: {
notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`),
notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`),
},
});
try {
const modal = env.createModal();
await modal.openPlaylistBrowserModal();
assert.equal(env.state.playlistBrowserModalOpen, true);
assert.equal(env.state.playlistBrowserActivePane, 'playlist');
assert.equal(env.state.playlistBrowserSelectedPlaylistIndex, 1);
assert.equal(env.state.playlistBrowserSelectedDirectoryIndex, 1);
assert.equal(env.dom.playlistBrowserDirectoryList.children.length, 2);
assert.equal(env.dom.playlistBrowserPlaylistList.children.length, 2);
assert.equal(env.dom.playlistBrowserDirectoryList.children[0]?.children.length, 2);
assert.equal(env.dom.playlistBrowserPlaylistList.children[0]?.children.length, 2);
assert.deepEqual(notifications, ['open:playlist-browser']);
} finally {
env.restore();
}
});
test('playlist browser modal action buttons stop double-click propagation', async () => {
const env = setupPlaylistBrowserModalTest();
try {
const modal = env.createModal();
await modal.openPlaylistBrowserModal();
const row =
env.dom.playlistBrowserDirectoryList.children[0] as
| ReturnType<typeof createPlaylistRow>
| undefined;
const trailing = row?.children?.[1] as ReturnType<typeof createPlaylistRow> | undefined;
const button =
trailing?.children?.at(-1) as
| { listeners?: Map<string, Array<(event?: unknown) => void>> }
| undefined;
const dblclickHandler = button?.listeners?.get('dblclick')?.[0];
assert.equal(typeof dblclickHandler, 'function');
let stopped = false;
dblclickHandler?.({
stopPropagation: () => {
stopped = true;
},
});
assert.equal(stopped, true);
} finally {
env.restore();
}
});
test('playlist browser preserves prior selection across mutation snapshots', async () => {
const env = setupPlaylistBrowserModalTest({
electronApi: {
getPlaylistBrowserSnapshot: async () => ({
...createSnapshot(),
directoryItems: [
...createSnapshot().directoryItems,
{
path: '/tmp/show/Show - S01E03.mkv',
basename: 'Show - S01E03.mkv',
episodeLabel: 'S1E3',
isCurrentFile: false,
},
],
playlistItems: [
...createSnapshot().playlistItems,
{
index: 2,
id: 3,
filename: '/tmp/show/Show - S01E03.mkv',
title: 'Episode 3',
displayLabel: 'Episode 3',
current: false,
playing: false,
path: '/tmp/show/Show - S01E03.mkv',
},
],
}),
appendPlaylistBrowserFile: async () => ({
ok: true,
message: 'Queued file',
snapshot: createMutationSnapshot(),
}),
},
});
try {
const modal = env.createModal();
await modal.openPlaylistBrowserModal();
env.state.playlistBrowserActivePane = 'directory';
env.state.playlistBrowserSelectedDirectoryIndex = 2;
env.state.playlistBrowserSelectedPlaylistIndex = 0;
await modal.handlePlaylistBrowserKeydown({
key: 'Enter',
code: 'Enter',
preventDefault: () => {},
ctrlKey: false,
metaKey: false,
shiftKey: false,
} as never);
assert.equal(env.state.playlistBrowserSelectedDirectoryIndex, 2);
assert.equal(env.state.playlistBrowserSelectedPlaylistIndex, 2);
} finally {
env.restore();
}
});
test('playlist browser modal keydown routes append, remove, reorder, tab switch, and play', async () => {
const calls: Array<[string, unknown[]]> = [];
const notifications: string[] = [];
const env = setupPlaylistBrowserModalTest({
electronApi: {
notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`),
notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`),
appendPlaylistBrowserFile: async (filePath: string) => {
calls.push(['append', [filePath]]);
return { ok: true, message: 'append-ok', snapshot: createMutationSnapshot() };
},
playPlaylistBrowserIndex: async (index: number) => {
calls.push(['play', [index]]);
return { ok: true, message: 'play-ok', snapshot: createSnapshot() };
},
removePlaylistBrowserIndex: async (index: number) => {
calls.push(['remove', [index]]);
return { ok: true, message: 'remove-ok', snapshot: createSnapshot() };
},
movePlaylistBrowserIndex: async (index: number, direction: -1 | 1) => {
calls.push(['move', [index, direction]]);
return { ok: true, message: 'move-ok', snapshot: createSnapshot() };
},
},
});
try {
const modal = env.createModal();
await modal.openPlaylistBrowserModal();
const preventDefault = () => {};
env.state.playlistBrowserActivePane = 'directory';
env.state.playlistBrowserSelectedDirectoryIndex = 0;
await modal.handlePlaylistBrowserKeydown({
key: 'Enter',
code: 'Enter',
preventDefault,
ctrlKey: false,
metaKey: false,
shiftKey: false,
} as never);
await modal.handlePlaylistBrowserKeydown({
key: 'Tab',
code: 'Tab',
preventDefault,
ctrlKey: false,
metaKey: false,
shiftKey: false,
} as never);
assert.equal(env.state.playlistBrowserActivePane, 'playlist');
await modal.handlePlaylistBrowserKeydown({
key: 'ArrowDown',
code: 'ArrowDown',
preventDefault,
ctrlKey: true,
metaKey: false,
shiftKey: false,
} as never);
await modal.handlePlaylistBrowserKeydown({
key: 'Delete',
code: 'Delete',
preventDefault,
ctrlKey: false,
metaKey: false,
shiftKey: false,
} as never);
await modal.handlePlaylistBrowserKeydown({
key: 'Enter',
code: 'Enter',
preventDefault,
ctrlKey: false,
metaKey: false,
shiftKey: false,
} as never);
assert.deepEqual(calls, [
['append', ['/tmp/show/Show - S01E01.mkv']],
['move', [1, 1]],
['remove', [1]],
['play', [1]],
]);
assert.equal(env.state.playlistBrowserModalOpen, false);
assert.deepEqual(notifications, ['open:playlist-browser', 'close:playlist-browser']);
} finally {
env.restore();
}
});
test('playlist browser keeps modal open when playing selected queue item fails', async () => {
const notifications: string[] = [];
const env = setupPlaylistBrowserModalTest({
electronApi: {
notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`),
notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`),
playPlaylistBrowserIndex: async () => ({ ok: false, message: 'play failed' }),
},
});
try {
const modal = env.createModal();
await modal.openPlaylistBrowserModal();
assert.equal(env.state.playlistBrowserModalOpen, true);
await modal.handlePlaylistBrowserKeydown({
key: 'Enter',
code: 'Enter',
preventDefault: () => {},
ctrlKey: false,
metaKey: false,
shiftKey: false,
} as never);
assert.equal(env.state.playlistBrowserModalOpen, true);
assert.equal(env.dom.playlistBrowserStatus.textContent, 'play failed');
assert.equal(env.dom.playlistBrowserStatus.classList.contains('error'), true);
assert.deepEqual(notifications, ['open:playlist-browser']);
} finally {
env.restore();
}
});
test('playlist browser refresh failure clears stale rendered rows and reports the error', async () => {
const notifications: string[] = [];
let refreshShouldFail = false;
const env = setupPlaylistBrowserModalTest({
electronApi: {
getPlaylistBrowserSnapshot: async () => {
if (refreshShouldFail) {
throw new Error('snapshot failed');
}
return createSnapshot();
},
notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`),
notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`),
},
});
try {
const modal = env.createModal();
await modal.openPlaylistBrowserModal();
assert.equal(env.dom.playlistBrowserDirectoryList.children.length, 2);
assert.equal(env.dom.playlistBrowserPlaylistList.children.length, 2);
refreshShouldFail = true;
await modal.refreshSnapshot();
assert.equal(env.state.playlistBrowserSnapshot, null);
assert.equal(env.dom.playlistBrowserDirectoryList.children.length, 0);
assert.equal(env.dom.playlistBrowserPlaylistList.children.length, 0);
assert.equal(env.dom.playlistBrowserTitle.textContent, 'Playlist Browser');
assert.equal(env.dom.playlistBrowserStatus.textContent, 'snapshot failed');
assert.equal(env.dom.playlistBrowserStatus.classList.contains('error'), true);
assert.deepEqual(notifications, ['open:playlist-browser']);
} finally {
env.restore();
}
});
test('playlist browser close clears rendered snapshot ui', async () => {
const notifications: string[] = [];
const env = setupPlaylistBrowserModalTest({
electronApi: {
notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`),
notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`),
},
});
try {
const modal = env.createModal();
await modal.openPlaylistBrowserModal();
assert.equal(env.dom.playlistBrowserDirectoryList.children.length, 2);
assert.equal(env.dom.playlistBrowserPlaylistList.children.length, 2);
modal.closePlaylistBrowserModal();
assert.equal(env.state.playlistBrowserSnapshot, null);
assert.equal(env.state.playlistBrowserStatus, '');
assert.equal(env.dom.playlistBrowserDirectoryList.children.length, 0);
assert.equal(env.dom.playlistBrowserPlaylistList.children.length, 0);
assert.equal(env.dom.playlistBrowserTitle.textContent, 'Playlist Browser');
assert.equal(env.dom.playlistBrowserStatus.textContent, '');
assert.deepEqual(notifications, ['open:playlist-browser', 'close:playlist-browser']);
} finally {
env.restore();
}
});
test('playlist browser open is ignored while another modal is already open', async () => {
const notifications: string[] = [];
let snapshotCalls = 0;
const env = setupPlaylistBrowserModalTest({
electronApi: {
getPlaylistBrowserSnapshot: async () => {
snapshotCalls += 1;
return createSnapshot();
},
notifyOverlayModalOpened: (modal: string) => notifications.push(`open:${modal}`),
notifyOverlayModalClosed: (modal: string) => notifications.push(`close:${modal}`),
},
});
try {
const modal = env.createModal({
modalStateReader: { isAnyModalOpen: () => true },
});
await modal.openPlaylistBrowserModal();
assert.equal(env.state.playlistBrowserModalOpen, false);
assert.equal(snapshotCalls, 0);
assert.equal(env.dom.overlay.classList.contains('interactive'), false);
assert.deepEqual(notifications, []);
} finally {
env.restore();
}
});

View File

@@ -0,0 +1,407 @@
import type {
PlaylistBrowserDirectoryItem,
PlaylistBrowserMutationResult,
PlaylistBrowserQueueItem,
PlaylistBrowserSnapshot,
} from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
import {
renderPlaylistBrowserDirectoryRow,
renderPlaylistBrowserPlaylistRow,
} from './playlist-browser-renderer.js';
function clampIndex(index: number, length: number): number {
if (length <= 0) return 0;
return Math.min(Math.max(index, 0), length - 1);
}
function buildDefaultStatus(snapshot: PlaylistBrowserSnapshot): string {
const directoryCount = snapshot.directoryItems.length;
const playlistCount = snapshot.playlistItems.length;
if (!snapshot.directoryAvailable) {
return `${snapshot.directoryStatus} ${playlistCount > 0 ? `· ${playlistCount} queued` : ''}`.trim();
}
return `${directoryCount} sibling videos · ${playlistCount} queued`;
}
function getDefaultDirectorySelectionIndex(snapshot: PlaylistBrowserSnapshot): number {
const directoryIndex = snapshot.directoryItems.findIndex((item) => item.isCurrentFile);
return clampIndex(directoryIndex >= 0 ? directoryIndex : 0, snapshot.directoryItems.length);
}
function getDefaultPlaylistSelectionIndex(snapshot: PlaylistBrowserSnapshot): number {
const playlistIndex =
snapshot.playingIndex ?? snapshot.playlistItems.findIndex((item) => item.current || item.playing);
return clampIndex(playlistIndex >= 0 ? playlistIndex : 0, snapshot.playlistItems.length);
}
function resolvePreservedIndex<T>(
previousIndex: number,
previousItems: T[],
nextItems: T[],
matchIndex: (previousItem: T) => number,
): number {
if (nextItems.length <= 0) return 0;
if (previousItems.length <= 0) return clampIndex(previousIndex, nextItems.length);
const normalizedPreviousIndex = clampIndex(previousIndex, previousItems.length);
const previousItem = previousItems[normalizedPreviousIndex];
const matchedIndex = previousItem ? matchIndex(previousItem) : -1;
return clampIndex(matchedIndex >= 0 ? matchedIndex : normalizedPreviousIndex, nextItems.length);
}
function resolveDirectorySelectionIndex(
snapshot: PlaylistBrowserSnapshot,
previousSnapshot: PlaylistBrowserSnapshot,
previousIndex: number,
): number {
return resolvePreservedIndex(
previousIndex,
previousSnapshot.directoryItems,
snapshot.directoryItems,
(previousItem: PlaylistBrowserDirectoryItem) =>
snapshot.directoryItems.findIndex((item) => item.path === previousItem.path),
);
}
function resolvePlaylistSelectionIndex(
snapshot: PlaylistBrowserSnapshot,
previousSnapshot: PlaylistBrowserSnapshot,
previousIndex: number,
): number {
return resolvePreservedIndex(
previousIndex,
previousSnapshot.playlistItems,
snapshot.playlistItems,
(previousItem: PlaylistBrowserQueueItem) => {
if (previousItem.id !== null) {
const byId = snapshot.playlistItems.findIndex((item) => item.id === previousItem.id);
if (byId >= 0) return byId;
}
if (previousItem.path) {
return snapshot.playlistItems.findIndex((item) => item.path === previousItem.path);
}
return -1;
},
);
}
export function createPlaylistBrowserModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
function setStatus(message: string, isError = false): void {
ctx.state.playlistBrowserStatus = message;
ctx.dom.playlistBrowserStatus.textContent = message;
ctx.dom.playlistBrowserStatus.classList.toggle('error', isError);
}
function getSnapshot(): PlaylistBrowserSnapshot | null {
return ctx.state.playlistBrowserSnapshot;
}
function resetSnapshotUi(): void {
ctx.state.playlistBrowserSnapshot = null;
ctx.state.playlistBrowserStatus = '';
ctx.state.playlistBrowserSelectedDirectoryIndex = 0;
ctx.state.playlistBrowserSelectedPlaylistIndex = 0;
ctx.dom.playlistBrowserTitle.textContent = 'Playlist Browser';
ctx.dom.playlistBrowserDirectoryList.replaceChildren();
ctx.dom.playlistBrowserPlaylistList.replaceChildren();
ctx.dom.playlistBrowserStatus.textContent = '';
ctx.dom.playlistBrowserStatus.classList.remove('error');
}
function syncSelection(
snapshot: PlaylistBrowserSnapshot,
previousSnapshot: PlaylistBrowserSnapshot | null,
): void {
if (!previousSnapshot) {
ctx.state.playlistBrowserSelectedDirectoryIndex = getDefaultDirectorySelectionIndex(snapshot);
ctx.state.playlistBrowserSelectedPlaylistIndex = getDefaultPlaylistSelectionIndex(snapshot);
return;
}
ctx.state.playlistBrowserSelectedDirectoryIndex = resolveDirectorySelectionIndex(
snapshot,
previousSnapshot,
ctx.state.playlistBrowserSelectedDirectoryIndex,
);
ctx.state.playlistBrowserSelectedPlaylistIndex = resolvePlaylistSelectionIndex(
snapshot,
previousSnapshot,
ctx.state.playlistBrowserSelectedPlaylistIndex,
);
}
function render(): void {
const snapshot = getSnapshot();
if (!snapshot) {
ctx.dom.playlistBrowserDirectoryList.replaceChildren();
ctx.dom.playlistBrowserPlaylistList.replaceChildren();
return;
}
ctx.dom.playlistBrowserTitle.textContent = snapshot.directoryPath ?? 'Playlist Browser';
ctx.dom.playlistBrowserStatus.textContent =
ctx.state.playlistBrowserStatus || buildDefaultStatus(snapshot);
ctx.dom.playlistBrowserDirectoryList.replaceChildren(
...snapshot.directoryItems.map((item, index) =>
renderPlaylistBrowserDirectoryRow(ctx, item, index, {
appendDirectoryItem,
movePlaylistItem,
playPlaylistItem,
removePlaylistItem,
render,
}),
),
);
ctx.dom.playlistBrowserPlaylistList.replaceChildren(
...snapshot.playlistItems.map((item, index) =>
renderPlaylistBrowserPlaylistRow(ctx, item, index, {
appendDirectoryItem,
movePlaylistItem,
playPlaylistItem,
removePlaylistItem,
render,
}),
),
);
}
function applySnapshot(snapshot: PlaylistBrowserSnapshot): void {
const previousSnapshot = ctx.state.playlistBrowserSnapshot;
ctx.state.playlistBrowserSnapshot = snapshot;
syncSelection(snapshot, previousSnapshot);
render();
}
async function refreshSnapshot(): Promise<void> {
try {
const snapshot = await window.electronAPI.getPlaylistBrowserSnapshot();
ctx.state.playlistBrowserStatus = '';
applySnapshot(snapshot);
setStatus(
buildDefaultStatus(snapshot),
!snapshot.directoryAvailable && snapshot.directoryStatus.length > 0,
);
} catch (error) {
resetSnapshotUi();
setStatus(error instanceof Error ? error.message : String(error), true);
}
}
async function handleMutation(
action: Promise<PlaylistBrowserMutationResult>,
fallbackMessage: string,
): Promise<void> {
const result = await action;
if (!result.ok) {
setStatus(result.message, true);
return;
}
setStatus(result.message || fallbackMessage, false);
if (result.snapshot) {
applySnapshot(result.snapshot);
return;
}
await refreshSnapshot();
}
async function appendDirectoryItem(filePath: string): Promise<void> {
await handleMutation(window.electronAPI.appendPlaylistBrowserFile(filePath), 'Queued file');
}
async function playPlaylistItem(index: number): Promise<void> {
const result = await window.electronAPI.playPlaylistBrowserIndex(index);
if (!result.ok) {
setStatus(result.message, true);
return;
}
closePlaylistBrowserModal();
}
async function removePlaylistItem(index: number): Promise<void> {
await handleMutation(window.electronAPI.removePlaylistBrowserIndex(index), 'Removed queue item');
}
async function movePlaylistItem(index: number, direction: 1 | -1): Promise<void> {
await handleMutation(
window.electronAPI.movePlaylistBrowserIndex(index, direction),
'Moved queue item',
);
}
async function openPlaylistBrowserModal(): Promise<void> {
if (ctx.state.playlistBrowserModalOpen) {
await refreshSnapshot();
return;
}
if (options.modalStateReader.isAnyModalOpen()) {
return;
}
ctx.state.playlistBrowserModalOpen = true;
ctx.state.playlistBrowserActivePane = 'playlist';
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
ctx.dom.playlistBrowserModal.classList.remove('hidden');
ctx.dom.playlistBrowserModal.setAttribute('aria-hidden', 'false');
window.electronAPI.notifyOverlayModalOpened('playlist-browser');
await refreshSnapshot();
}
function closePlaylistBrowserModal(): void {
if (!ctx.state.playlistBrowserModalOpen) return;
ctx.state.playlistBrowserModalOpen = false;
resetSnapshotUi();
ctx.dom.playlistBrowserModal.classList.add('hidden');
ctx.dom.playlistBrowserModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('playlist-browser');
options.syncSettingsModalSubtitleSuppression();
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
}
function moveSelection(delta: number): void {
const snapshot = getSnapshot();
if (!snapshot) return;
if (ctx.state.playlistBrowserActivePane === 'directory') {
ctx.state.playlistBrowserSelectedDirectoryIndex = clampIndex(
ctx.state.playlistBrowserSelectedDirectoryIndex + delta,
snapshot.directoryItems.length,
);
} else {
ctx.state.playlistBrowserSelectedPlaylistIndex = clampIndex(
ctx.state.playlistBrowserSelectedPlaylistIndex + delta,
snapshot.playlistItems.length,
);
}
render();
}
function jumpSelection(target: 'start' | 'end'): void {
const snapshot = getSnapshot();
if (!snapshot) return;
const length =
ctx.state.playlistBrowserActivePane === 'directory'
? snapshot.directoryItems.length
: snapshot.playlistItems.length;
const nextIndex = target === 'start' ? 0 : Math.max(0, length - 1);
if (ctx.state.playlistBrowserActivePane === 'directory') {
ctx.state.playlistBrowserSelectedDirectoryIndex = nextIndex;
} else {
ctx.state.playlistBrowserSelectedPlaylistIndex = nextIndex;
}
render();
}
function activateSelection(): void {
const snapshot = getSnapshot();
if (!snapshot) return;
if (ctx.state.playlistBrowserActivePane === 'directory') {
const item = snapshot.directoryItems[ctx.state.playlistBrowserSelectedDirectoryIndex];
if (item) {
void appendDirectoryItem(item.path);
}
return;
}
const item = snapshot.playlistItems[ctx.state.playlistBrowserSelectedPlaylistIndex];
if (item) {
void playPlaylistItem(item.index);
}
}
function handlePlaylistBrowserKeydown(event: KeyboardEvent): boolean {
if (!ctx.state.playlistBrowserModalOpen) return false;
if (event.key === 'Escape') {
event.preventDefault();
closePlaylistBrowserModal();
return true;
}
if (event.key === 'Tab') {
event.preventDefault();
ctx.state.playlistBrowserActivePane =
ctx.state.playlistBrowserActivePane === 'directory' ? 'playlist' : 'directory';
render();
return true;
}
if (event.key === 'Home') {
event.preventDefault();
jumpSelection('start');
return true;
}
if (event.key === 'End') {
event.preventDefault();
jumpSelection('end');
return true;
}
if (event.key === 'ArrowUp' && (event.ctrlKey || event.metaKey)) {
if (ctx.state.playlistBrowserActivePane === 'playlist') {
event.preventDefault();
const item = getSnapshot()?.playlistItems[ctx.state.playlistBrowserSelectedPlaylistIndex];
if (item) {
void movePlaylistItem(item.index, -1);
}
return true;
}
}
if (event.key === 'ArrowDown' && (event.ctrlKey || event.metaKey)) {
if (ctx.state.playlistBrowserActivePane === 'playlist') {
event.preventDefault();
const item = getSnapshot()?.playlistItems[ctx.state.playlistBrowserSelectedPlaylistIndex];
if (item) {
void movePlaylistItem(item.index, 1);
}
return true;
}
}
if (event.key === 'ArrowUp') {
event.preventDefault();
moveSelection(-1);
return true;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
moveSelection(1);
return true;
}
if (event.key === 'Enter') {
event.preventDefault();
activateSelection();
return true;
}
if (event.key === 'Delete' || event.key === 'Backspace') {
if (ctx.state.playlistBrowserActivePane === 'playlist') {
event.preventDefault();
const item = getSnapshot()?.playlistItems[ctx.state.playlistBrowserSelectedPlaylistIndex];
if (item) {
void removePlaylistItem(item.index);
}
return true;
}
}
return false;
}
function wireDomEvents(): void {
ctx.dom.playlistBrowserClose.addEventListener('click', () => {
closePlaylistBrowserModal();
});
}
return {
openPlaylistBrowserModal,
closePlaylistBrowserModal,
handlePlaylistBrowserKeydown,
refreshSnapshot,
wireDomEvents,
};
}

View File

@@ -130,6 +130,7 @@ function describeCommand(command: (string | number)[]): string {
} }
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls'; if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options'; if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle'; if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle'; if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) { if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
@@ -164,6 +165,7 @@ function sectionForCommand(command: (string | number)[]): string {
if ( if (
first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN || first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN ||
first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN ||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX) first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
) { ) {
return 'Runtime settings'; return 'Runtime settings';

View File

@@ -33,6 +33,7 @@ import { createControllerDebugModal } from './modals/controller-debug.js';
import { createControllerSelectModal } from './modals/controller-select.js'; import { createControllerSelectModal } from './modals/controller-select.js';
import { createJimakuModal } from './modals/jimaku.js'; import { createJimakuModal } from './modals/jimaku.js';
import { createKikuModal } from './modals/kiku.js'; import { createKikuModal } from './modals/kiku.js';
import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
import { createSessionHelpModal } from './modals/session-help.js'; import { createSessionHelpModal } from './modals/session-help.js';
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js'; import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js'; import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
@@ -71,7 +72,8 @@ function isAnySettingsModalOpen(): boolean {
ctx.state.kikuModalOpen || ctx.state.kikuModalOpen ||
ctx.state.jimakuModalOpen || ctx.state.jimakuModalOpen ||
ctx.state.youtubePickerModalOpen || ctx.state.youtubePickerModalOpen ||
ctx.state.sessionHelpModalOpen ctx.state.sessionHelpModalOpen ||
ctx.state.playlistBrowserModalOpen
); );
} }
@@ -85,6 +87,7 @@ function isAnyModalOpen(): boolean {
ctx.state.subsyncModalOpen || ctx.state.subsyncModalOpen ||
ctx.state.youtubePickerModalOpen || ctx.state.youtubePickerModalOpen ||
ctx.state.sessionHelpModalOpen || ctx.state.sessionHelpModalOpen ||
ctx.state.playlistBrowserModalOpen ||
ctx.state.subtitleSidebarModalOpen ctx.state.subtitleSidebarModalOpen
); );
} }
@@ -153,12 +156,17 @@ const youtubePickerModal = createYoutubeTrackPickerModal(ctx, {
restorePointerInteractionState: mouseHandlers.restorePointerInteractionState, restorePointerInteractionState: mouseHandlers.restorePointerInteractionState,
syncSettingsModalSubtitleSuppression, syncSettingsModalSubtitleSuppression,
}); });
const playlistBrowserModal = createPlaylistBrowserModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const keyboardHandlers = createKeyboardHandlers(ctx, { const keyboardHandlers = createKeyboardHandlers(ctx, {
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown, handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown, handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
handleKikuKeydown: kikuModal.handleKikuKeydown, handleKikuKeydown: kikuModal.handleKikuKeydown,
handleJimakuKeydown: jimakuModal.handleJimakuKeydown, handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown, handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown,
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown, handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown, handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown, handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
@@ -209,6 +217,7 @@ function getActiveModal(): string | null {
if (ctx.state.subtitleSidebarModalOpen) return 'subtitle-sidebar'; if (ctx.state.subtitleSidebarModalOpen) return 'subtitle-sidebar';
if (ctx.state.jimakuModalOpen) return 'jimaku'; if (ctx.state.jimakuModalOpen) return 'jimaku';
if (ctx.state.youtubePickerModalOpen) return 'youtube-track-picker'; if (ctx.state.youtubePickerModalOpen) return 'youtube-track-picker';
if (ctx.state.playlistBrowserModalOpen) return 'playlist-browser';
if (ctx.state.kikuModalOpen) return 'kiku'; if (ctx.state.kikuModalOpen) return 'kiku';
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options'; if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
if (ctx.state.subsyncModalOpen) return 'subsync'; if (ctx.state.subsyncModalOpen) return 'subsync';
@@ -232,6 +241,9 @@ function dismissActiveUiAfterError(): void {
if (ctx.state.youtubePickerModalOpen) { if (ctx.state.youtubePickerModalOpen) {
youtubePickerModal.closeYoutubePickerModal(); youtubePickerModal.closeYoutubePickerModal();
} }
if (ctx.state.playlistBrowserModalOpen) {
playlistBrowserModal.closePlaylistBrowserModal();
}
if (ctx.state.runtimeOptionsModalOpen) { if (ctx.state.runtimeOptionsModalOpen) {
runtimeOptionsModal.closeRuntimeOptionsModal(); runtimeOptionsModal.closeRuntimeOptionsModal();
} }
@@ -439,6 +451,11 @@ function registerModalOpenHandlers(): void {
youtubePickerModal.openYoutubePickerModal(payload); youtubePickerModal.openYoutubePickerModal(payload);
}); });
}); });
window.electronAPI.onOpenPlaylistBrowser(() => {
runGuardedAsync('playlist-browser:open', async () => {
await playlistBrowserModal.openPlaylistBrowserModal();
});
});
window.electronAPI.onCancelYoutubeTrackPicker(() => { window.electronAPI.onCancelYoutubeTrackPicker(() => {
runGuarded('youtube:picker-cancel', () => { runGuarded('youtube:picker-cancel', () => {
youtubePickerModal.closeYoutubePickerModal(); youtubePickerModal.closeYoutubePickerModal();
@@ -518,6 +535,11 @@ async function init(): Promise<void> {
runGuarded('subtitle-position:update', () => { runGuarded('subtitle-position:update', () => {
positioning.applyStoredSubtitlePosition(position, 'media-change'); positioning.applyStoredSubtitlePosition(position, 'media-change');
measurementReporter.schedule(); measurementReporter.schedule();
if (ctx.state.playlistBrowserModalOpen) {
runGuardedAsync('playlist-browser:refresh-on-media-change', async () => {
await playlistBrowserModal.refreshSnapshot();
});
}
}); });
}); });
@@ -572,6 +594,7 @@ async function init(): Promise<void> {
jimakuModal.wireDomEvents(); jimakuModal.wireDomEvents();
youtubePickerModal.wireDomEvents(); youtubePickerModal.wireDomEvents();
playlistBrowserModal.wireDomEvents();
kikuModal.wireDomEvents(); kikuModal.wireDomEvents();
runtimeOptionsModal.wireDomEvents(); runtimeOptionsModal.wireDomEvents();
subsyncModal.wireDomEvents(); subsyncModal.wireDomEvents();

View File

@@ -1,4 +1,5 @@
import type { import type {
PlaylistBrowserSnapshot,
ControllerButtonSnapshot, ControllerButtonSnapshot,
ControllerDeviceInfo, ControllerDeviceInfo,
ResolvedControllerConfig, ResolvedControllerConfig,
@@ -78,6 +79,12 @@ export type RendererState = {
sessionHelpModalOpen: boolean; sessionHelpModalOpen: boolean;
sessionHelpSelectedIndex: number; sessionHelpSelectedIndex: number;
playlistBrowserModalOpen: boolean;
playlistBrowserSnapshot: PlaylistBrowserSnapshot | null;
playlistBrowserStatus: string;
playlistBrowserActivePane: 'directory' | 'playlist';
playlistBrowserSelectedDirectoryIndex: number;
playlistBrowserSelectedPlaylistIndex: number;
subtitleSidebarCues: SubtitleCue[]; subtitleSidebarCues: SubtitleCue[];
subtitleSidebarActiveCueIndex: number; subtitleSidebarActiveCueIndex: number;
subtitleSidebarToggleKey: string; subtitleSidebarToggleKey: string;
@@ -175,6 +182,12 @@ export function createRendererState(): RendererState {
sessionHelpModalOpen: false, sessionHelpModalOpen: false,
sessionHelpSelectedIndex: 0, sessionHelpSelectedIndex: 0,
playlistBrowserModalOpen: false,
playlistBrowserSnapshot: null,
playlistBrowserStatus: '',
playlistBrowserActivePane: 'playlist',
playlistBrowserSelectedDirectoryIndex: 0,
playlistBrowserSelectedPlaylistIndex: 0,
subtitleSidebarCues: [], subtitleSidebarCues: [],
subtitleSidebarActiveCueIndex: -1, subtitleSidebarActiveCueIndex: -1,
subtitleSidebarToggleKey: 'Backslash', subtitleSidebarToggleKey: 'Backslash',

File diff suppressed because it is too large Load Diff

View File

@@ -96,6 +96,13 @@ export type RendererDom = {
sessionHelpStatus: HTMLDivElement; sessionHelpStatus: HTMLDivElement;
sessionHelpFilter: HTMLInputElement; sessionHelpFilter: HTMLInputElement;
sessionHelpContent: HTMLDivElement; sessionHelpContent: HTMLDivElement;
playlistBrowserModal: HTMLDivElement;
playlistBrowserTitle: HTMLDivElement;
playlistBrowserStatus: HTMLDivElement;
playlistBrowserDirectoryList: HTMLUListElement;
playlistBrowserPlaylistList: HTMLUListElement;
playlistBrowserClose: HTMLButtonElement;
}; };
function getRequiredElement<T extends HTMLElement>(id: string): T { function getRequiredElement<T extends HTMLElement>(id: string): T {
@@ -211,5 +218,12 @@ export function resolveRendererDom(): RendererDom {
sessionHelpStatus: getRequiredElement<HTMLDivElement>('sessionHelpStatus'), sessionHelpStatus: getRequiredElement<HTMLDivElement>('sessionHelpStatus'),
sessionHelpFilter: getRequiredElement<HTMLInputElement>('sessionHelpFilter'), sessionHelpFilter: getRequiredElement<HTMLInputElement>('sessionHelpFilter'),
sessionHelpContent: getRequiredElement<HTMLDivElement>('sessionHelpContent'), sessionHelpContent: getRequiredElement<HTMLDivElement>('sessionHelpContent'),
playlistBrowserModal: getRequiredElement<HTMLDivElement>('playlistBrowserModal'),
playlistBrowserTitle: getRequiredElement<HTMLDivElement>('playlistBrowserTitle'),
playlistBrowserStatus: getRequiredElement<HTMLDivElement>('playlistBrowserStatus'),
playlistBrowserDirectoryList: getRequiredElement<HTMLUListElement>('playlistBrowserDirectoryList'),
playlistBrowserPlaylistList: getRequiredElement<HTMLUListElement>('playlistBrowserPlaylistList'),
playlistBrowserClose: getRequiredElement<HTMLButtonElement>('playlistBrowserClose'),
}; };
} }

View File

@@ -6,6 +6,7 @@ export const OVERLAY_HOSTED_MODALS = [
'subsync', 'subsync',
'jimaku', 'jimaku',
'youtube-track-picker', 'youtube-track-picker',
'playlist-browser',
'kiku', 'kiku',
'controller-select', 'controller-select',
'controller-debug', 'controller-debug',
@@ -67,6 +68,11 @@ export const IPC_CHANNELS = {
getAnilistQueueStatus: 'anilist:get-queue-status', getAnilistQueueStatus: 'anilist:get-queue-status',
retryAnilistNow: 'anilist:retry-now', retryAnilistNow: 'anilist:retry-now',
appendClipboardVideoToQueue: 'clipboard:append-video-to-queue', appendClipboardVideoToQueue: 'clipboard:append-video-to-queue',
getPlaylistBrowserSnapshot: 'playlist-browser:get-snapshot',
appendPlaylistBrowserFile: 'playlist-browser:append-file',
playPlaylistBrowserIndex: 'playlist-browser:play-index',
removePlaylistBrowserIndex: 'playlist-browser:remove-index',
movePlaylistBrowserIndex: 'playlist-browser:move-index',
jimakuGetMediaInfo: 'jimaku:get-media-info', jimakuGetMediaInfo: 'jimaku:get-media-info',
jimakuSearchEntries: 'jimaku:search-entries', jimakuSearchEntries: 'jimaku:search-entries',
jimakuListFiles: 'jimaku:list-files', jimakuListFiles: 'jimaku:list-files',
@@ -100,6 +106,7 @@ export const IPC_CHANNELS = {
jimakuOpen: 'jimaku:open', jimakuOpen: 'jimaku:open',
youtubePickerOpen: 'youtube:picker-open', youtubePickerOpen: 'youtube:picker-open',
youtubePickerCancel: 'youtube:picker-cancel', youtubePickerCancel: 'youtube:picker-cancel',
playlistBrowserOpen: 'playlist-browser:open',
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested', keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
lookupWindowToggleRequested: 'lookup-window-toggle:requested', lookupWindowToggleRequested: 'lookup-window-toggle:requested',
configHotReload: 'config:hot-reload', configHotReload: 'config:hot-reload',

View File

@@ -76,6 +76,40 @@ export interface SubsyncResult {
message: string; message: string;
} }
export interface PlaylistBrowserDirectoryItem {
path: string;
basename: string;
episodeLabel?: string | null;
isCurrentFile: boolean;
}
export interface PlaylistBrowserQueueItem {
index: number;
id: number | null;
filename: string;
title: string | null;
displayLabel: string;
current: boolean;
playing: boolean;
path: string | null;
}
export interface PlaylistBrowserSnapshot {
directoryPath: string | null;
directoryAvailable: boolean;
directoryStatus: string;
directoryItems: PlaylistBrowserDirectoryItem[];
playlistItems: PlaylistBrowserQueueItem[];
playingIndex: number | null;
currentFilePath: string | null;
}
export interface PlaylistBrowserMutationResult {
ok: boolean;
message: string;
snapshot?: PlaylistBrowserSnapshot;
}
export type ControllerButtonBinding = export type ControllerButtonBinding =
| 'none' | 'none'
| 'select' | 'select'
@@ -354,10 +388,19 @@ export interface ElectronAPI {
onOpenRuntimeOptions: (callback: () => void) => void; onOpenRuntimeOptions: (callback: () => void) => void;
onOpenJimaku: (callback: () => void) => void; onOpenJimaku: (callback: () => void) => void;
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void; onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
onOpenPlaylistBrowser: (callback: () => void) => void;
onCancelYoutubeTrackPicker: (callback: () => void) => void; onCancelYoutubeTrackPicker: (callback: () => void) => void;
onKeyboardModeToggleRequested: (callback: () => void) => void; onKeyboardModeToggleRequested: (callback: () => void) => void;
onLookupWindowToggleRequested: (callback: () => void) => void; onLookupWindowToggleRequested: (callback: () => void) => void;
appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>; appendClipboardVideoToQueue: () => Promise<ClipboardAppendResult>;
getPlaylistBrowserSnapshot: () => Promise<PlaylistBrowserSnapshot>;
appendPlaylistBrowserFile: (path: string) => Promise<PlaylistBrowserMutationResult>;
playPlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
removePlaylistBrowserIndex: (index: number) => Promise<PlaylistBrowserMutationResult>;
movePlaylistBrowserIndex: (
index: number,
direction: 1 | -1,
) => Promise<PlaylistBrowserMutationResult>;
youtubePickerResolve: ( youtubePickerResolve: (
request: YoutubePickerResolveRequest, request: YoutubePickerResolveRequest,
) => Promise<YoutubePickerResolveResult>; ) => Promise<YoutubePickerResolveResult>;
@@ -367,6 +410,7 @@ export interface ElectronAPI {
| 'subsync' | 'subsync'
| 'jimaku' | 'jimaku'
| 'youtube-track-picker' | 'youtube-track-picker'
| 'playlist-browser'
| 'kiku' | 'kiku'
| 'controller-select' | 'controller-select'
| 'controller-debug' | 'controller-debug'
@@ -378,6 +422,7 @@ export interface ElectronAPI {
| 'subsync' | 'subsync'
| 'jimaku' | 'jimaku'
| 'youtube-track-picker' | 'youtube-track-picker'
| 'playlist-browser'
| 'kiku' | 'kiku'
| 'controller-select' | 'controller-select'
| 'controller-debug' | 'controller-debug'