mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-01 06:12:07 -07:00
Add playlist browser overlay modal (#37)
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
- AniList: Stopped post-watch tracking from sending a second progress update when the current episode was already satisfied by a ready retry item in the same watch-completion pass.
|
||||
|
||||
## v0.10.0 (2026-03-29)
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -63,6 +63,12 @@ Local stats dashboard — watch time, anime library, vocabulary growth, mining t
|
||||
|
||||
<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
|
||||
|
||||
<table>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
---
|
||||
id: TASK-255
|
||||
title: Add overlay playlist browser modal for sibling video files and mpv queue
|
||||
status: To Do
|
||||
assignee: []
|
||||
status: In Progress
|
||||
assignee:
|
||||
- '@codex'
|
||||
created_date: '2026-03-30 05:46'
|
||||
updated_date: '2026-03-31 05:59'
|
||||
labels:
|
||||
- feature
|
||||
- overlay
|
||||
@@ -24,6 +26,62 @@ Add an in-session overlay modal that opens from a keybinding during active playb
|
||||
- [ ] #2 The modal shows video files from the current media file's parent directory in best-effort episode order and highlights the current file when present.
|
||||
- [ ] #3 The modal shows the active mpv playlist/queue with enough metadata to identify the current item and queued order.
|
||||
- [ ] #4 The user can add a directory file to the mpv playlist, remove playlist items, and reorder playlist items from the modal using both mouse and keyboard interactions.
|
||||
- [ ] #5 Modal state stays in sync after playlist mutations so the rendered queue reflects mpv's current playlist order.
|
||||
- [ ] #6 Feature coverage includes automated tests for ordering/playlist behavior and docs or shortcut/help updates for the new modal.
|
||||
- [x] #5 Modal state stays in sync after playlist mutations so the rendered queue reflects mpv's current playlist order.
|
||||
- [x] #6 Feature coverage includes automated tests for ordering/playlist behavior and docs or shortcut/help updates for the new modal.
|
||||
<!-- 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 -->
|
||||
|
||||
@@ -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 -->
|
||||
@@ -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 -->
|
||||
@@ -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 -->
|
||||
5
changes/260-playlist-browser.md
Normal file
5
changes/260-playlist-browser.md
Normal 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.
|
||||
5
changes/261-macos-overlay-passthrough.md
Normal file
5
changes/261-macos-overlay-passthrough.md
Normal 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.
|
||||
5
changes/262-anilist-post-watch-dedupe.md
Normal file
5
changes/262-anilist-post-watch-dedupe.md
Normal 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.
|
||||
@@ -471,6 +471,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
||||
| `Space` | `["cycle", "pause"]` | Toggle pause |
|
||||
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
|
||||
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
|
||||
| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
|
||||
| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
|
||||
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
||||
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
|
||||
@@ -507,7 +508,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
||||
{ "key": "Space", "command": null }
|
||||
```
|
||||
|
||||
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__sub-delay-next-line` shifts subtitle delay so the active line aligns to the next cue start in the active subtitle source. `__sub-delay-prev-line` shifts subtitle delay so the active line aligns to the previous cue start. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<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.)
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ These control playback and subtitle display. They require overlay window focus.
|
||||
| `Space` | Toggle mpv pause |
|
||||
| `J` | Cycle primary subtitle track |
|
||||
| `Shift+J` | Cycle secondary subtitle track |
|
||||
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
|
||||
| `ArrowRight` | Seek forward 5 seconds |
|
||||
| `ArrowLeft` | Seek backward 5 seconds |
|
||||
| `ArrowUp` | Seek forward 60 seconds |
|
||||
@@ -56,7 +57,7 @@ These control playback and subtitle display. They require overlay window focus.
|
||||
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
|
||||
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
|
||||
|
||||
These keybindings can be overridden or disabled via the `keybindings` config array.
|
||||
These keybindings can be overridden or disabled via the `keybindings` config array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
|
||||
|
||||
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave).
|
||||
|
||||
|
||||
@@ -295,6 +295,8 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
|
||||
`Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config.
|
||||
:::
|
||||
|
||||
Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback.
|
||||
|
||||
Hovering over subtitle text pauses mpv by default; leaving resumes it. Disable with `subtitleStyle.autoPauseVideoOnHover: false`. To also pause while the Yomitan popup is open, set `subtitleStyle.autoPauseVideoOnYomitanPopup: true`.
|
||||
|
||||
### Drag-and-Drop
|
||||
|
||||
@@ -34,6 +34,17 @@ function M.create(ctx)
|
||||
return options_helper.coerce_bool(raw_pause_until_ready, false)
|
||||
end
|
||||
|
||||
local function resolve_texthooker_enabled(override_value)
|
||||
if override_value ~= nil then
|
||||
return options_helper.coerce_bool(override_value, false)
|
||||
end
|
||||
local raw_texthooker_enabled = opts.texthooker_enabled
|
||||
if raw_texthooker_enabled == nil then
|
||||
raw_texthooker_enabled = opts["texthooker-enabled"]
|
||||
end
|
||||
return options_helper.coerce_bool(raw_texthooker_enabled, false)
|
||||
end
|
||||
|
||||
local function resolve_pause_until_ready_timeout_seconds()
|
||||
local raw_timeout_seconds = opts.auto_start_pause_until_ready_timeout_seconds
|
||||
if raw_timeout_seconds == nil then
|
||||
@@ -192,10 +203,7 @@ function M.create(ctx)
|
||||
table.insert(args, "--hide-visible-overlay")
|
||||
end
|
||||
|
||||
local texthooker_enabled = overrides.texthooker_enabled
|
||||
if texthooker_enabled == nil then
|
||||
texthooker_enabled = opts.texthooker_enabled
|
||||
end
|
||||
local texthooker_enabled = resolve_texthooker_enabled(overrides.texthooker_enabled)
|
||||
if texthooker_enabled then
|
||||
table.insert(args, "--texthooker")
|
||||
end
|
||||
@@ -296,10 +304,7 @@ function M.create(ctx)
|
||||
return
|
||||
end
|
||||
|
||||
local texthooker_enabled = overrides.texthooker_enabled
|
||||
if texthooker_enabled == nil then
|
||||
texthooker_enabled = opts.texthooker_enabled
|
||||
end
|
||||
local texthooker_enabled = resolve_texthooker_enabled(overrides.texthooker_enabled)
|
||||
local socket_path = overrides.socket_path or opts.socket_path
|
||||
local should_pause_until_ready = (
|
||||
overrides.auto_start_trigger == true
|
||||
@@ -498,7 +503,7 @@ function M.create(ctx)
|
||||
end
|
||||
end)
|
||||
|
||||
if opts.texthooker_enabled then
|
||||
if resolve_texthooker_enabled(nil) then
|
||||
ensure_texthooker_running(function() end)
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -531,6 +531,31 @@ do
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "no",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
texthooker_enabled = "no",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
media_title = "Random Movie",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for disabled texthooker auto-start scenario: " .. tostring(err))
|
||||
fire_event(recorded, "file-loaded")
|
||||
local start_call = find_start_call(recorded.async_calls)
|
||||
assert_true(start_call ~= nil, "disabled texthooker auto-start should still issue --start command")
|
||||
assert_true(not call_has_arg(start_call, "--texthooker"), "disabled texthooker should not include --texthooker on --start")
|
||||
assert_true(find_control_call(recorded.async_calls, "--texthooker") == nil, "disabled texthooker should not issue a helper texthooker command")
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
|
||||
@@ -80,6 +80,7 @@ test('default keybindings include primary and secondary subtitle track cycling o
|
||||
assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']);
|
||||
assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-sid']);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyC'), ['__youtube-picker-open']);
|
||||
assert.deepEqual(keybindingMap.get('Ctrl+Alt+KeyP'), ['__playlist-browser-open']);
|
||||
});
|
||||
|
||||
test('default keybindings include fullscreen on F', () => {
|
||||
|
||||
@@ -47,6 +47,7 @@ export const SPECIAL_COMMANDS = {
|
||||
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
|
||||
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
|
||||
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
||||
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
||||
@@ -66,6 +67,7 @@ export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
||||
command: [SPECIAL_COMMANDS.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START],
|
||||
},
|
||||
{ key: 'Ctrl+Alt+KeyC', command: [SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN] },
|
||||
{ key: 'Ctrl+Alt+KeyP', command: [SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN] },
|
||||
{ key: 'Ctrl+Shift+KeyH', command: [SPECIAL_COMMANDS.REPLAY_SUBTITLE] },
|
||||
{ key: 'Ctrl+Shift+KeyL', command: [SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE] },
|
||||
{ key: 'KeyQ', command: ['quit'] },
|
||||
|
||||
@@ -5,6 +5,7 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { toMonthKey } from './immersion-tracker/maintenance';
|
||||
import { enqueueWrite } from './immersion-tracker/queue';
|
||||
import { toDbTimestamp } from './immersion-tracker/query-shared';
|
||||
import { Database, type DatabaseSync } from './immersion-tracker/sqlite';
|
||||
import { nowMs as trackerNowMs } from './immersion-tracker/time';
|
||||
import {
|
||||
@@ -185,7 +186,7 @@ test('destroy finalizes active session and persists final telemetry', async () =
|
||||
|
||||
const db = new Database(dbPath);
|
||||
const sessionRow = db.prepare('SELECT ended_at_ms FROM imm_sessions LIMIT 1').get() as {
|
||||
ended_at_ms: number | null;
|
||||
ended_at_ms: string | number | null;
|
||||
} | null;
|
||||
const telemetryCountRow = db
|
||||
.prepare('SELECT COUNT(*) AS total FROM imm_session_telemetry')
|
||||
@@ -193,7 +194,7 @@ test('destroy finalizes active session and persists final telemetry', async () =
|
||||
db.close();
|
||||
|
||||
assert.ok(sessionRow);
|
||||
assert.ok(Number(sessionRow?.ended_at_ms ?? 0) > 0);
|
||||
assert.notEqual(sessionRow?.ended_at_ms, null);
|
||||
assert.ok(Number(telemetryCountRow.total) >= 2);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
@@ -504,7 +505,7 @@ test('rebuildLifetimeSummaries backfills retained ended sessions and resets stal
|
||||
episodes_started: number;
|
||||
episodes_completed: number;
|
||||
anime_completed: number;
|
||||
last_rebuilt_ms: number | null;
|
||||
last_rebuilt_ms: string | number | null;
|
||||
} | null;
|
||||
const appliedSessions = rebuildApi.db
|
||||
.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions')
|
||||
@@ -518,7 +519,7 @@ test('rebuildLifetimeSummaries backfills retained ended sessions and resets stal
|
||||
assert.equal(globalRow?.episodes_started, 2);
|
||||
assert.equal(globalRow?.episodes_completed, 2);
|
||||
assert.equal(globalRow?.anime_completed, 1);
|
||||
assert.equal(globalRow?.last_rebuilt_ms, rebuild.rebuiltAtMs);
|
||||
assert.equal(globalRow?.last_rebuilt_ms, toDbTimestamp(rebuild.rebuiltAtMs));
|
||||
assert.equal(appliedSessions?.total, 2);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
@@ -629,7 +630,8 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
|
||||
const startedAtMs = trackerNowMs() - 10_000;
|
||||
const sampleMs = startedAtMs + 5_000;
|
||||
|
||||
db.exec(`
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_anime (
|
||||
anime_id,
|
||||
canonical_title,
|
||||
@@ -637,15 +639,12 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
|
||||
episodes_total,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1,
|
||||
'KonoSuba',
|
||||
'konosuba',
|
||||
10,
|
||||
${startedAtMs},
|
||||
${startedAtMs}
|
||||
);
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(1, 'KonoSuba', 'konosuba', 10, toDbTimestamp(startedAtMs), toDbTimestamp(startedAtMs));
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_videos (
|
||||
video_id,
|
||||
video_key,
|
||||
@@ -656,7 +655,9 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
|
||||
duration_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
1,
|
||||
'local:/tmp/konosuba-s02e05.mkv',
|
||||
'KonoSuba S02E05',
|
||||
@@ -664,10 +665,12 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
${startedAtMs},
|
||||
${startedAtMs}
|
||||
toDbTimestamp(startedAtMs),
|
||||
toDbTimestamp(startedAtMs),
|
||||
);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_sessions (
|
||||
session_id,
|
||||
session_uuid,
|
||||
@@ -677,17 +680,21 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
|
||||
ended_media_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
1,
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
1,
|
||||
${startedAtMs},
|
||||
toDbTimestamp(startedAtMs),
|
||||
1,
|
||||
321000,
|
||||
${startedAtMs},
|
||||
${sampleMs}
|
||||
toDbTimestamp(startedAtMs),
|
||||
toDbTimestamp(sampleMs),
|
||||
);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_session_telemetry (
|
||||
session_id,
|
||||
sample_ms,
|
||||
@@ -703,23 +710,9 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
|
||||
seek_forward_count,
|
||||
seek_backward_count,
|
||||
media_buffer_events
|
||||
) VALUES (
|
||||
1,
|
||||
${sampleMs},
|
||||
5000,
|
||||
4000,
|
||||
12,
|
||||
120,
|
||||
2,
|
||||
5,
|
||||
3,
|
||||
1,
|
||||
250,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
);
|
||||
`);
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(1, toDbTimestamp(sampleMs), 5000, 4000, 12, 120, 2, 5, 3, 1, 250, 1, 0, 0);
|
||||
|
||||
tracker.destroy();
|
||||
tracker = new Ctor({ dbPath });
|
||||
@@ -734,7 +727,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
|
||||
`,
|
||||
)
|
||||
.get() as {
|
||||
ended_at_ms: number | null;
|
||||
ended_at_ms: string | number | null;
|
||||
status: number;
|
||||
ended_media_ms: number | null;
|
||||
active_watched_ms: number;
|
||||
@@ -769,7 +762,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
|
||||
.get() as { total: number } | null;
|
||||
|
||||
assert.ok(sessionRow);
|
||||
assert.ok(Number(sessionRow?.ended_at_ms ?? 0) >= sampleMs);
|
||||
assert.equal(sessionRow?.ended_at_ms, toDbTimestamp(sampleMs));
|
||||
assert.equal(sessionRow?.status, 2);
|
||||
assert.equal(sessionRow?.ended_media_ms, 321_000);
|
||||
assert.equal(sessionRow?.active_watched_ms, 4000);
|
||||
|
||||
@@ -309,6 +309,9 @@ export class ImmersionTrackerService {
|
||||
private readonly eventsRetentionMs: number;
|
||||
private readonly telemetryRetentionMs: number;
|
||||
private readonly sessionsRetentionMs: number;
|
||||
private readonly eventsRetentionDays: number | null;
|
||||
private readonly telemetryRetentionDays: number | null;
|
||||
private readonly sessionsRetentionDays: number | null;
|
||||
private readonly dailyRollupRetentionMs: number;
|
||||
private readonly monthlyRollupRetentionMs: number;
|
||||
private readonly vacuumIntervalMs: number;
|
||||
@@ -365,46 +368,54 @@ export class ImmersionTrackerService {
|
||||
);
|
||||
|
||||
const retention = policy.retention ?? {};
|
||||
const daysToRetentionMs = (
|
||||
const daysToRetentionWindow = (
|
||||
value: number | undefined,
|
||||
fallbackMs: number,
|
||||
fallbackDays: number,
|
||||
maxDays: number,
|
||||
): number => {
|
||||
const fallbackDays = Math.floor(fallbackMs / 86_400_000);
|
||||
): { ms: number; days: number | null } => {
|
||||
const resolvedDays = resolveBoundedInt(value, fallbackDays, 0, maxDays);
|
||||
return resolvedDays === 0 ? Number.POSITIVE_INFINITY : resolvedDays * 86_400_000;
|
||||
return {
|
||||
ms: resolvedDays === 0 ? Number.POSITIVE_INFINITY : resolvedDays * 86_400_000,
|
||||
days: resolvedDays === 0 ? null : resolvedDays,
|
||||
};
|
||||
};
|
||||
|
||||
this.eventsRetentionMs = daysToRetentionMs(
|
||||
const eventsRetention = daysToRetentionWindow(
|
||||
retention.eventsDays,
|
||||
DEFAULT_EVENTS_RETENTION_MS,
|
||||
7,
|
||||
3650,
|
||||
);
|
||||
this.telemetryRetentionMs = daysToRetentionMs(
|
||||
const telemetryRetention = daysToRetentionWindow(
|
||||
retention.telemetryDays,
|
||||
DEFAULT_TELEMETRY_RETENTION_MS,
|
||||
30,
|
||||
3650,
|
||||
);
|
||||
this.sessionsRetentionMs = daysToRetentionMs(
|
||||
const sessionsRetention = daysToRetentionWindow(
|
||||
retention.sessionsDays,
|
||||
DEFAULT_SESSIONS_RETENTION_MS,
|
||||
30,
|
||||
3650,
|
||||
);
|
||||
this.dailyRollupRetentionMs = daysToRetentionMs(
|
||||
this.eventsRetentionMs = eventsRetention.ms;
|
||||
this.eventsRetentionDays = eventsRetention.days;
|
||||
this.telemetryRetentionMs = telemetryRetention.ms;
|
||||
this.telemetryRetentionDays = telemetryRetention.days;
|
||||
this.sessionsRetentionMs = sessionsRetention.ms;
|
||||
this.sessionsRetentionDays = sessionsRetention.days;
|
||||
this.dailyRollupRetentionMs = daysToRetentionWindow(
|
||||
retention.dailyRollupsDays,
|
||||
DEFAULT_DAILY_ROLLUP_RETENTION_MS,
|
||||
365,
|
||||
36500,
|
||||
);
|
||||
this.monthlyRollupRetentionMs = daysToRetentionMs(
|
||||
).ms;
|
||||
this.monthlyRollupRetentionMs = daysToRetentionWindow(
|
||||
retention.monthlyRollupsDays,
|
||||
DEFAULT_MONTHLY_ROLLUP_RETENTION_MS,
|
||||
5 * 365,
|
||||
36500,
|
||||
);
|
||||
this.vacuumIntervalMs = daysToRetentionMs(
|
||||
).ms;
|
||||
this.vacuumIntervalMs = daysToRetentionWindow(
|
||||
retention.vacuumIntervalDays,
|
||||
DEFAULT_VACUUM_INTERVAL_MS,
|
||||
7,
|
||||
3650,
|
||||
);
|
||||
).ms;
|
||||
this.db = new Database(this.dbPath);
|
||||
applyPragmas(this.db);
|
||||
ensureSchema(this.db);
|
||||
@@ -1604,6 +1615,9 @@ export class ImmersionTrackerService {
|
||||
eventsRetentionMs: this.eventsRetentionMs,
|
||||
telemetryRetentionMs: this.telemetryRetentionMs,
|
||||
sessionsRetentionMs: this.sessionsRetentionMs,
|
||||
eventsRetentionDays: this.eventsRetentionDays ?? undefined,
|
||||
telemetryRetentionDays: this.telemetryRetentionDays ?? undefined,
|
||||
sessionsRetentionDays: this.sessionsRetentionDays ?? undefined,
|
||||
});
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
updateAnimeAnilistInfo,
|
||||
upsertCoverArt,
|
||||
} from '../query-maintenance.js';
|
||||
import { getLocalEpochDay } from '../query-shared.js';
|
||||
import { EVENT_CARD_MINED, EVENT_SUBTITLE_LINE, SOURCE_TYPE_LOCAL } from '../types.js';
|
||||
|
||||
function makeDbPath(): string {
|
||||
@@ -360,9 +361,6 @@ test('split library helpers return anime/media session and analytics rows', () =
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const todayLocalDay = Math.floor(
|
||||
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 86_400_000,
|
||||
);
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'Library Anime',
|
||||
canonicalTitle: 'Library Anime',
|
||||
@@ -398,6 +396,7 @@ test('split library helpers return anime/media session and analytics rows', () =
|
||||
0,
|
||||
).getTime();
|
||||
const sessionId = startSessionRecord(db, videoId, startedAtMs).sessionId;
|
||||
const todayLocalDay = getLocalEpochDay(db, startedAtMs);
|
||||
finalizeSessionMetrics(db, sessionId, startedAtMs, {
|
||||
endedAtMs: startedAtMs + 55_000,
|
||||
totalWatchedMs: 55_000,
|
||||
|
||||
@@ -37,6 +37,11 @@ import {
|
||||
getWordOccurrences,
|
||||
upsertCoverArt,
|
||||
} from '../query.js';
|
||||
import {
|
||||
getShiftedLocalDaySec,
|
||||
getStartOfLocalDayTimestamp,
|
||||
toDbTimestamp,
|
||||
} from '../query-shared.js';
|
||||
import {
|
||||
SOURCE_TYPE_LOCAL,
|
||||
SOURCE_TYPE_REMOTE,
|
||||
@@ -81,29 +86,13 @@ function cleanupDbPath(dbPath: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function withMockDate<T>(fixedDate: Date, run: (realDate: typeof Date) => T): T {
|
||||
const realDate = Date;
|
||||
const fixedDateMs = fixedDate.getTime();
|
||||
|
||||
class MockDate extends Date {
|
||||
constructor(...args: any[]) {
|
||||
if (args.length === 0) {
|
||||
super(fixedDateMs);
|
||||
} else {
|
||||
super(...(args as [any?, any?, any?, any?, any?, any?, any?]));
|
||||
}
|
||||
}
|
||||
|
||||
static override now(): number {
|
||||
return fixedDateMs;
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.Date = MockDate as DateConstructor;
|
||||
function withMockNowMs<T>(fixedDateMs: string | number, run: () => T): T {
|
||||
const previousNowMs = globalThis.__subminerTestNowMs;
|
||||
globalThis.__subminerTestNowMs = fixedDateMs;
|
||||
try {
|
||||
return run(realDate);
|
||||
return run();
|
||||
} finally {
|
||||
globalThis.Date = realDate;
|
||||
globalThis.__subminerTestNowMs = previousNowMs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -613,7 +602,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
||||
] as const) {
|
||||
stmts.telemetryInsertStmt.run(
|
||||
sessionId,
|
||||
startedAtMs + 60_000,
|
||||
`${startedAtMs + 60_000}`,
|
||||
activeWatchedMs,
|
||||
activeWatchedMs,
|
||||
10,
|
||||
@@ -626,8 +615,8 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
startedAtMs + 60_000,
|
||||
startedAtMs + 60_000,
|
||||
`${startedAtMs + 60_000}`,
|
||||
`${startedAtMs + 60_000}`,
|
||||
);
|
||||
|
||||
db.prepare(
|
||||
@@ -644,7 +633,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
||||
WHERE session_id = ?
|
||||
`,
|
||||
).run(
|
||||
startedAtMs + activeWatchedMs,
|
||||
`${startedAtMs + activeWatchedMs}`,
|
||||
activeWatchedMs,
|
||||
activeWatchedMs,
|
||||
10,
|
||||
@@ -687,8 +676,8 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
||||
'名詞',
|
||||
null,
|
||||
null,
|
||||
Math.floor(dayOneStart / 1000),
|
||||
Math.floor(dayTwoStart / 1000),
|
||||
String(Math.floor(dayOneStart / 1000)),
|
||||
String(Math.floor(dayTwoStart / 1000)),
|
||||
);
|
||||
|
||||
const dashboard = getTrendsDashboard(db, 'all', 'day');
|
||||
@@ -743,18 +732,51 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
||||
parseMetadataJson: null,
|
||||
});
|
||||
|
||||
const beforeMidnight = new Date(2026, 2, 1, 23, 30).getTime();
|
||||
const afterMidnight = new Date(2026, 2, 2, 0, 30).getTime();
|
||||
const firstSessionId = startSessionRecord(db, videoId, beforeMidnight).sessionId;
|
||||
const secondSessionId = startSessionRecord(db, videoId, afterMidnight).sessionId;
|
||||
const boundaryMs = BigInt(getStartOfLocalDayTimestamp(db, '1772436600000'));
|
||||
const beforeMidnight = (boundaryMs - 1n).toString();
|
||||
const afterMidnight = (boundaryMs + 1n).toString();
|
||||
const firstSessionId = 1;
|
||||
const secondSessionId = 2;
|
||||
const insertSession = db.prepare(
|
||||
`
|
||||
INSERT INTO imm_sessions (
|
||||
session_id,
|
||||
session_uuid,
|
||||
video_id,
|
||||
started_at_ms,
|
||||
status,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
insertSession.run(
|
||||
firstSessionId,
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
videoId,
|
||||
beforeMidnight,
|
||||
1,
|
||||
beforeMidnight,
|
||||
beforeMidnight,
|
||||
);
|
||||
insertSession.run(
|
||||
secondSessionId,
|
||||
'22222222-2222-2222-2222-222222222222',
|
||||
videoId,
|
||||
afterMidnight,
|
||||
1,
|
||||
afterMidnight,
|
||||
afterMidnight,
|
||||
);
|
||||
|
||||
for (const [sessionId, startedAtMs, tokensSeen, lookupCount] of [
|
||||
[firstSessionId, beforeMidnight, 100, 4],
|
||||
[secondSessionId, afterMidnight, 120, 6],
|
||||
] as const) {
|
||||
const endedAtMs = (BigInt(startedAtMs) + 60_000n).toString();
|
||||
stmts.telemetryInsertStmt.run(
|
||||
sessionId,
|
||||
startedAtMs + 60_000,
|
||||
endedAtMs,
|
||||
60_000,
|
||||
60_000,
|
||||
1,
|
||||
@@ -767,8 +789,8 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
startedAtMs + 60_000,
|
||||
startedAtMs + 60_000,
|
||||
endedAtMs,
|
||||
endedAtMs,
|
||||
);
|
||||
db.prepare(
|
||||
`
|
||||
@@ -787,7 +809,7 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
||||
WHERE session_id = ?
|
||||
`,
|
||||
).run(
|
||||
startedAtMs + 60_000,
|
||||
endedAtMs,
|
||||
60_000,
|
||||
60_000,
|
||||
1,
|
||||
@@ -795,7 +817,7 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
||||
lookupCount,
|
||||
lookupCount,
|
||||
lookupCount,
|
||||
startedAtMs + 60_000,
|
||||
endedAtMs,
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
@@ -816,7 +838,7 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
||||
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
withMockDate(new Date(2026, 2, 1, 12, 0, 0), (RealDate) => {
|
||||
withMockNowMs('1772395200000', () => {
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const stmts = createTrackerPreparedStatements(db);
|
||||
@@ -862,18 +884,50 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
|
||||
parseMetadataJson: null,
|
||||
});
|
||||
|
||||
const febStartedAtMs = new RealDate(2026, 1, 15, 20, 0, 0).getTime();
|
||||
const marStartedAtMs = new RealDate(2026, 2, 1, 9, 0, 0).getTime();
|
||||
const febSessionId = startSessionRecord(db, febVideoId, febStartedAtMs).sessionId;
|
||||
const marSessionId = startSessionRecord(db, marVideoId, marStartedAtMs).sessionId;
|
||||
const febStartedAtMs = '1771214400000';
|
||||
const marStartedAtMs = '1772384400000';
|
||||
const febSessionId = 1;
|
||||
const marSessionId = 2;
|
||||
const insertSession = db.prepare(
|
||||
`
|
||||
INSERT INTO imm_sessions (
|
||||
session_id,
|
||||
session_uuid,
|
||||
video_id,
|
||||
started_at_ms,
|
||||
status,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
insertSession.run(
|
||||
febSessionId,
|
||||
'33333333-3333-3333-3333-333333333333',
|
||||
febVideoId,
|
||||
febStartedAtMs,
|
||||
1,
|
||||
febStartedAtMs,
|
||||
febStartedAtMs,
|
||||
);
|
||||
insertSession.run(
|
||||
marSessionId,
|
||||
'44444444-4444-4444-4444-444444444444',
|
||||
marVideoId,
|
||||
marStartedAtMs,
|
||||
1,
|
||||
marStartedAtMs,
|
||||
marStartedAtMs,
|
||||
);
|
||||
|
||||
for (const [sessionId, startedAtMs, tokensSeen, cardsMined, yomitanLookupCount] of [
|
||||
[febSessionId, febStartedAtMs, 100, 2, 3],
|
||||
[marSessionId, marStartedAtMs, 120, 4, 5],
|
||||
] as const) {
|
||||
const endedAtMs = (BigInt(startedAtMs) + 60_000n).toString();
|
||||
stmts.telemetryInsertStmt.run(
|
||||
sessionId,
|
||||
startedAtMs + 60_000,
|
||||
endedAtMs,
|
||||
30 * 60_000,
|
||||
30 * 60_000,
|
||||
4,
|
||||
@@ -886,8 +940,8 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
startedAtMs + 60_000,
|
||||
startedAtMs + 60_000,
|
||||
endedAtMs,
|
||||
endedAtMs,
|
||||
);
|
||||
db.prepare(
|
||||
`
|
||||
@@ -907,7 +961,7 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
|
||||
WHERE session_id = ?
|
||||
`,
|
||||
).run(
|
||||
startedAtMs + 60_000,
|
||||
endedAtMs,
|
||||
30 * 60_000,
|
||||
30 * 60_000,
|
||||
4,
|
||||
@@ -916,7 +970,7 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
|
||||
yomitanLookupCount,
|
||||
yomitanLookupCount,
|
||||
yomitanLookupCount,
|
||||
startedAtMs + 60_000,
|
||||
endedAtMs,
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
@@ -937,10 +991,8 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
const febEpochDay = Math.floor(febStartedAtMs / 86_400_000);
|
||||
const marEpochDay = Math.floor(marStartedAtMs / 86_400_000);
|
||||
insertDailyRollup.run(febEpochDay, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
|
||||
insertDailyRollup.run(marEpochDay, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
|
||||
insertDailyRollup.run(20500, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
|
||||
insertDailyRollup.run(20513, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
|
||||
insertMonthlyRollup.run(202602, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
|
||||
insertMonthlyRollup.run(202603, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
|
||||
|
||||
@@ -958,8 +1010,8 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
|
||||
'名詞',
|
||||
'',
|
||||
'',
|
||||
Math.floor(febStartedAtMs / 1000),
|
||||
Math.floor(febStartedAtMs / 1000),
|
||||
(BigInt(febStartedAtMs) / 1000n).toString(),
|
||||
(BigInt(febStartedAtMs) / 1000n).toString(),
|
||||
1,
|
||||
);
|
||||
db.prepare(
|
||||
@@ -976,8 +1028,8 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
|
||||
'名詞',
|
||||
'',
|
||||
'',
|
||||
Math.floor(marStartedAtMs / 1000),
|
||||
Math.floor(marStartedAtMs / 1000),
|
||||
(BigInt(marStartedAtMs) / 1000n).toString(),
|
||||
(BigInt(marStartedAtMs) / 1000n).toString(),
|
||||
1,
|
||||
);
|
||||
|
||||
@@ -1077,7 +1129,7 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', ()
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
withMockDate(new Date(2026, 2, 15, 12, 0, 0), (RealDate) => {
|
||||
withMockNowMs('1773601200000', () => {
|
||||
try {
|
||||
ensureSchema(db);
|
||||
|
||||
@@ -1088,12 +1140,9 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', ()
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
);
|
||||
const justBeforeWeekBoundary = Math.floor(
|
||||
new RealDate(2026, 2, 7, 23, 30, 0).getTime() / 1000,
|
||||
);
|
||||
const justAfterWeekBoundary = Math.floor(
|
||||
new RealDate(2026, 2, 8, 0, 30, 0).getTime() / 1000,
|
||||
);
|
||||
const weekBoundarySec = getShiftedLocalDaySec(db, '1773601200000', -7);
|
||||
const justBeforeWeekBoundary = weekBoundarySec - 1;
|
||||
const justAfterWeekBoundary = weekBoundarySec + 1;
|
||||
insertWord.run(
|
||||
'境界前',
|
||||
'境界前',
|
||||
@@ -1102,8 +1151,8 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', ()
|
||||
'名詞',
|
||||
'',
|
||||
'',
|
||||
justBeforeWeekBoundary,
|
||||
justBeforeWeekBoundary,
|
||||
String(justBeforeWeekBoundary),
|
||||
String(justBeforeWeekBoundary),
|
||||
1,
|
||||
);
|
||||
insertWord.run(
|
||||
@@ -1114,8 +1163,8 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', ()
|
||||
'名詞',
|
||||
'',
|
||||
'',
|
||||
justAfterWeekBoundary,
|
||||
justAfterWeekBoundary,
|
||||
String(justAfterWeekBoundary),
|
||||
String(justAfterWeekBoundary),
|
||||
1,
|
||||
);
|
||||
|
||||
@@ -1134,10 +1183,8 @@ test('getQueryHints counts new words by distinct headword first-seen time', () =
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
|
||||
const now = new Date();
|
||||
const todayStartSec =
|
||||
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000;
|
||||
withMockNowMs('1773601200000', () => {
|
||||
const todayStartSec = 1_773_558_000;
|
||||
const oneHourAgo = todayStartSec + 3_600;
|
||||
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
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run('知る', '知った', 'しった', 'verb', '動詞', '', '', oneHourAgo, oneHourAgo, 1);
|
||||
).run(
|
||||
'知る',
|
||||
'知った',
|
||||
'しった',
|
||||
'verb',
|
||||
'動詞',
|
||||
'',
|
||||
'',
|
||||
String(oneHourAgo),
|
||||
String(oneHourAgo),
|
||||
1,
|
||||
);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_words (
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run('知る', '知っている', 'しっている', 'verb', '動詞', '', '', oneHourAgo, oneHourAgo, 1);
|
||||
).run(
|
||||
'知る',
|
||||
'知っている',
|
||||
'しっている',
|
||||
'verb',
|
||||
'動詞',
|
||||
'',
|
||||
'',
|
||||
String(oneHourAgo),
|
||||
String(oneHourAgo),
|
||||
1,
|
||||
);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_words (
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run('猫', '猫', 'ねこ', 'noun', '名詞', '', '', twoDaysAgo, twoDaysAgo, 1);
|
||||
).run(
|
||||
'猫',
|
||||
'猫',
|
||||
'ねこ',
|
||||
'noun',
|
||||
'名詞',
|
||||
'',
|
||||
'',
|
||||
String(twoDaysAgo),
|
||||
String(twoDaysAgo),
|
||||
1,
|
||||
);
|
||||
|
||||
const hints = getQueryHints(db);
|
||||
assert.equal(hints.newWordsToday, 1);
|
||||
assert.equal(hints.newWordsThisWeek, 2);
|
||||
});
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
@@ -2020,7 +2101,7 @@ test('getSessionWordsByLine joins word occurrences through imm_words.id', () =>
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const stmts = createTrackerPreparedStatements(db);
|
||||
const startedAtMs = Date.UTC(2025, 0, 1, 12, 0, 0);
|
||||
const startedAtMs = 1_735_732_800_000;
|
||||
const videoId = getOrCreateVideoRecord(db, '/tmp/session-words-by-line.mkv', {
|
||||
canonicalTitle: 'Episode',
|
||||
sourcePath: '/tmp/session-words-by-line.mkv',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { finalizeSessionRecord } from './session';
|
||||
import { nowMs } from './time';
|
||||
import { toDbTimestamp } from './query-shared';
|
||||
import type { LifetimeRebuildSummary, SessionState } from './types';
|
||||
|
||||
interface TelemetryRow {
|
||||
@@ -41,8 +42,8 @@ interface LifetimeAnimeStateRow {
|
||||
interface RetainedSessionRow {
|
||||
sessionId: number;
|
||||
videoId: number;
|
||||
startedAtMs: number;
|
||||
endedAtMs: number;
|
||||
startedAtMs: number | string;
|
||||
endedAtMs: number | string;
|
||||
lastMediaMs: number | null;
|
||||
totalWatchedMs: number;
|
||||
activeWatchedMs: number;
|
||||
@@ -65,25 +66,29 @@ function hasRetainedPriorSession(
|
||||
startedAtMs: number,
|
||||
currentSessionId: number,
|
||||
): boolean {
|
||||
return (
|
||||
Number(
|
||||
(
|
||||
db
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(*) AS count
|
||||
SELECT 1 AS found
|
||||
FROM imm_sessions
|
||||
WHERE video_id = ?
|
||||
AND (
|
||||
started_at_ms < ?
|
||||
OR (started_at_ms = ? AND session_id < ?)
|
||||
CAST(started_at_ms AS REAL) < CAST(? AS REAL)
|
||||
OR (
|
||||
CAST(started_at_ms AS REAL) = CAST(? AS REAL)
|
||||
AND session_id < ?
|
||||
)
|
||||
)
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(videoId, startedAtMs, startedAtMs, currentSessionId) as ExistenceRow | null
|
||||
)?.count ?? 0,
|
||||
) > 0
|
||||
);
|
||||
.get(
|
||||
videoId,
|
||||
toDbTimestamp(startedAtMs),
|
||||
toDbTimestamp(startedAtMs),
|
||||
currentSessionId,
|
||||
) as { found: number } | null;
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
function isFirstSessionForLocalDay(
|
||||
@@ -91,23 +96,37 @@ function isFirstSessionForLocalDay(
|
||||
currentSessionId: number,
|
||||
startedAtMs: number,
|
||||
): boolean {
|
||||
return (
|
||||
(
|
||||
db
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(*) AS count
|
||||
SELECT 1 AS found
|
||||
FROM imm_sessions
|
||||
WHERE date(started_at_ms / 1000, 'unixepoch', 'localtime') = date(? / 1000, 'unixepoch', 'localtime')
|
||||
AND (
|
||||
started_at_ms < ?
|
||||
OR (started_at_ms = ? AND session_id < ?)
|
||||
WHERE session_id != ?
|
||||
AND CAST(
|
||||
julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5
|
||||
AS INTEGER
|
||||
) = CAST(
|
||||
julianday(CAST(? AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5
|
||||
AS INTEGER
|
||||
)
|
||||
AND (
|
||||
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
|
||||
)?.count === 0
|
||||
);
|
||||
.get(
|
||||
currentSessionId,
|
||||
toDbTimestamp(startedAtMs),
|
||||
toDbTimestamp(startedAtMs),
|
||||
toDbTimestamp(startedAtMs),
|
||||
currentSessionId,
|
||||
) as { found: number } | null;
|
||||
return !row;
|
||||
}
|
||||
|
||||
function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void {
|
||||
@@ -131,14 +150,14 @@ function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void {
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE global_id = 1
|
||||
`,
|
||||
).run(nowMs, nowMs);
|
||||
).run(toDbTimestamp(nowMs), toDbTimestamp(nowMs));
|
||||
}
|
||||
|
||||
function rebuildLifetimeSummariesInternal(
|
||||
db: DatabaseSync,
|
||||
rebuiltAtMs: number,
|
||||
): LifetimeRebuildSummary {
|
||||
const sessions = db
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
@@ -146,6 +165,7 @@ function rebuildLifetimeSummariesInternal(
|
||||
video_id AS videoId,
|
||||
started_at_ms AS startedAtMs,
|
||||
ended_at_ms AS endedAtMs,
|
||||
ended_media_ms AS lastMediaMs,
|
||||
total_watched_ms AS totalWatchedMs,
|
||||
active_watched_ms AS activeWatchedMs,
|
||||
lines_seen AS linesSeen,
|
||||
@@ -164,7 +184,19 @@ function rebuildLifetimeSummariesInternal(
|
||||
ORDER BY started_at_ms ASC, session_id ASC
|
||||
`,
|
||||
)
|
||||
.all() as RetainedSessionRow[];
|
||||
.all() as Array<
|
||||
Omit<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);
|
||||
for (const session of sessions) {
|
||||
@@ -181,9 +213,9 @@ function toRebuildSessionState(row: RetainedSessionRow): SessionState {
|
||||
return {
|
||||
sessionId: row.sessionId,
|
||||
videoId: row.videoId,
|
||||
startedAtMs: row.startedAtMs,
|
||||
startedAtMs: row.startedAtMs as number,
|
||||
currentLineIndex: 0,
|
||||
lastWallClockMs: row.endedAtMs,
|
||||
lastWallClockMs: row.endedAtMs as number,
|
||||
lastMediaMs: row.lastMediaMs,
|
||||
lastPauseStartMs: null,
|
||||
isPaused: false,
|
||||
@@ -206,7 +238,7 @@ function toRebuildSessionState(row: RetainedSessionRow): SessionState {
|
||||
}
|
||||
|
||||
function getRetainedStaleActiveSessions(db: DatabaseSync): RetainedSessionRow[] {
|
||||
return db
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
@@ -241,20 +273,32 @@ function getRetainedStaleActiveSessions(db: DatabaseSync): RetainedSessionRow[]
|
||||
ORDER BY s.started_at_ms ASC, s.session_id ASC
|
||||
`,
|
||||
)
|
||||
.all() as RetainedSessionRow[];
|
||||
.all() as Array<
|
||||
Omit<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(
|
||||
db: DatabaseSync,
|
||||
videoId: number,
|
||||
nowMs: number,
|
||||
nowMs: number | string,
|
||||
activeMs: number,
|
||||
cardsMined: number,
|
||||
linesSeen: number,
|
||||
tokensSeen: number,
|
||||
completed: number,
|
||||
startedAtMs: number,
|
||||
endedAtMs: number,
|
||||
startedAtMs: number | string,
|
||||
endedAtMs: number | string,
|
||||
): void {
|
||||
db.prepare(
|
||||
`
|
||||
@@ -310,15 +354,15 @@ function upsertLifetimeMedia(
|
||||
function upsertLifetimeAnime(
|
||||
db: DatabaseSync,
|
||||
animeId: number,
|
||||
nowMs: number,
|
||||
nowMs: number | string,
|
||||
activeMs: number,
|
||||
cardsMined: number,
|
||||
linesSeen: number,
|
||||
tokensSeen: number,
|
||||
episodesStartedDelta: number,
|
||||
episodesCompletedDelta: number,
|
||||
startedAtMs: number,
|
||||
endedAtMs: number,
|
||||
startedAtMs: number | string,
|
||||
endedAtMs: number | string,
|
||||
): void {
|
||||
db.prepare(
|
||||
`
|
||||
@@ -377,8 +421,9 @@ function upsertLifetimeAnime(
|
||||
export function applySessionLifetimeSummary(
|
||||
db: DatabaseSync,
|
||||
session: SessionState,
|
||||
endedAtMs: number,
|
||||
endedAtMs: number | string,
|
||||
): void {
|
||||
const updatedAtMs = toDbTimestamp(nowMs());
|
||||
const applyResult = db
|
||||
.prepare(
|
||||
`
|
||||
@@ -393,7 +438,7 @@ export function applySessionLifetimeSummary(
|
||||
ON CONFLICT(session_id) DO NOTHING
|
||||
`,
|
||||
)
|
||||
.run(session.sessionId, endedAtMs, nowMs(), nowMs());
|
||||
.run(session.sessionId, endedAtMs, updatedAtMs, updatedAtMs);
|
||||
|
||||
if ((applyResult.changes ?? 0) <= 0) {
|
||||
return;
|
||||
@@ -468,7 +513,6 @@ export function applySessionLifetimeSummary(
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
const updatedAtMs = nowMs();
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_lifetime_global
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
toMonthKey,
|
||||
} from './maintenance';
|
||||
import { ensureSchema } from './storage';
|
||||
import { toDbTimestamp } from './query-shared';
|
||||
|
||||
function makeDbPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-maintenance-test-'));
|
||||
@@ -39,18 +40,18 @@ test('pruneRawRetention uses session retention separately from telemetry retenti
|
||||
INSERT INTO imm_videos (
|
||||
video_id, video_key, canonical_title, source_type, duration_ms, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1, 'local:/tmp/video.mkv', 'Video', 1, 0, ${nowMs}, ${nowMs}
|
||||
1, 'local:/tmp/video.mkv', 'Video', 1, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'
|
||||
);
|
||||
INSERT INTO imm_sessions (
|
||||
session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(1, 'session-1', 1, ${staleEndedAtMs - 1_000}, ${staleEndedAtMs}, 2, ${staleEndedAtMs}, ${staleEndedAtMs}),
|
||||
(2, 'session-2', 1, ${keptEndedAtMs - 1_000}, ${keptEndedAtMs}, 2, ${keptEndedAtMs}, ${keptEndedAtMs});
|
||||
(1, 'session-1', 1, '${toDbTimestamp(staleEndedAtMs - 1_000)}', '${toDbTimestamp(staleEndedAtMs)}', 2, '${toDbTimestamp(staleEndedAtMs)}', '${toDbTimestamp(staleEndedAtMs)}'),
|
||||
(2, 'session-2', 1, '${toDbTimestamp(keptEndedAtMs - 1_000)}', '${toDbTimestamp(keptEndedAtMs)}', 2, '${toDbTimestamp(keptEndedAtMs)}', '${toDbTimestamp(keptEndedAtMs)}');
|
||||
INSERT INTO imm_session_telemetry (
|
||||
session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(1, ${nowMs - 200_000_000}, 0, 0, ${nowMs}, ${nowMs}),
|
||||
(2, ${nowMs - 10_000_000}, 0, 0, ${nowMs}, ${nowMs});
|
||||
(1, '${toDbTimestamp(nowMs - 200_000_000)}', 0, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'),
|
||||
(2, '${toDbTimestamp(nowMs - 10_000_000)}', 0, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}');
|
||||
`);
|
||||
|
||||
const result = pruneRawRetention(db, nowMs, {
|
||||
@@ -94,22 +95,22 @@ test('pruneRawRetention skips disabled retention windows', () => {
|
||||
INSERT INTO imm_videos (
|
||||
video_id, video_key, canonical_title, source_type, duration_ms, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1, 'local:/tmp/video.mkv', 'Video', 1, 0, ${nowMs}, ${nowMs}
|
||||
1, 'local:/tmp/video.mkv', 'Video', 1, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'
|
||||
);
|
||||
INSERT INTO imm_sessions (
|
||||
session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1, 'session-1', 1, ${nowMs - 1_000}, ${nowMs - 500}, 2, ${nowMs}, ${nowMs}
|
||||
1, 'session-1', 1, '${toDbTimestamp(nowMs - 1_000)}', '${toDbTimestamp(nowMs - 500)}', 2, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'
|
||||
);
|
||||
INSERT INTO imm_session_telemetry (
|
||||
session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1, ${nowMs - 2_000}, 0, 0, ${nowMs}, ${nowMs}
|
||||
1, '${toDbTimestamp(nowMs - 2_000)}', 0, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'
|
||||
);
|
||||
INSERT INTO imm_session_events (
|
||||
session_id, event_type, ts_ms, payload_json, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1, 1, ${nowMs - 3_000}, '{}', ${nowMs}, ${nowMs}
|
||||
1, 1, '${toDbTimestamp(nowMs - 3_000)}', '{}', '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'
|
||||
);
|
||||
`);
|
||||
|
||||
@@ -161,17 +162,17 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
|
||||
INSERT INTO imm_videos (
|
||||
video_id, video_key, canonical_title, source_type, duration_ms, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1, 'local:/tmp/video.mkv', 'Video', 1, 0, ${nowMs}, ${nowMs}
|
||||
1, 'local:/tmp/video.mkv', 'Video', 1, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'
|
||||
);
|
||||
INSERT INTO imm_sessions (
|
||||
session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1, 'session-1', 1, ${nowMs - 200_000_000}, ${nowMs - 199_999_000}, 2, ${nowMs}, ${nowMs}
|
||||
1, 'session-1', 1, '${toDbTimestamp(nowMs - 200_000_000)}', '${toDbTimestamp(nowMs - 199_999_000)}', 2, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'
|
||||
);
|
||||
INSERT INTO imm_session_telemetry (
|
||||
session_id, sample_ms, total_watched_ms, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1, ${nowMs - 200_000_000}, 0, 0, ${nowMs}, ${nowMs}
|
||||
1, '${toDbTimestamp(nowMs - 200_000_000)}', 0, 0, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'
|
||||
);
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
@@ -183,7 +184,7 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
|
||||
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
${oldMonth}, 1, 1, 10, 1, 1, 1, ${nowMs}, ${nowMs}
|
||||
${oldMonth}, 1, 1, 10, 1, 1, 1, '${toDbTimestamp(nowMs)}', '${toDbTimestamp(nowMs)}'
|
||||
);
|
||||
`);
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { nowMs } from './time';
|
||||
import { toDbMs } from './query-shared';
|
||||
import { subtractDbTimestamp, toDbTimestamp } from './query-shared';
|
||||
|
||||
const ROLLUP_STATE_KEY = 'last_rollup_sample_ms';
|
||||
const DAILY_MS = 86_400_000;
|
||||
const ZERO_ID = 0;
|
||||
|
||||
interface RollupStateRow {
|
||||
state_value: number;
|
||||
state_value: string;
|
||||
}
|
||||
|
||||
interface RollupGroupRow {
|
||||
@@ -51,12 +51,25 @@ export function pruneRawRetention(
|
||||
eventsRetentionMs: number;
|
||||
telemetryRetentionMs: number;
|
||||
sessionsRetentionMs: number;
|
||||
eventsRetentionDays?: number;
|
||||
telemetryRetentionDays?: number;
|
||||
sessionsRetentionDays?: number;
|
||||
},
|
||||
): RawRetentionResult {
|
||||
const resolveCutoff = (
|
||||
retentionMs: number,
|
||||
retentionDays: number | undefined,
|
||||
): string => {
|
||||
if (retentionDays !== undefined) {
|
||||
return subtractDbTimestamp(currentMs, BigInt(retentionDays) * 86_400_000n);
|
||||
}
|
||||
return subtractDbTimestamp(currentMs, retentionMs);
|
||||
};
|
||||
|
||||
const deletedSessionEvents = Number.isFinite(policy.eventsRetentionMs)
|
||||
? (
|
||||
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(
|
||||
toDbMs(currentMs - policy.eventsRetentionMs),
|
||||
resolveCutoff(policy.eventsRetentionMs, policy.eventsRetentionDays),
|
||||
) as { changes: number }
|
||||
).changes
|
||||
: 0;
|
||||
@@ -64,14 +77,18 @@ export function pruneRawRetention(
|
||||
? (
|
||||
db
|
||||
.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`)
|
||||
.run(toDbMs(currentMs - policy.telemetryRetentionMs)) as { changes: number }
|
||||
.run(resolveCutoff(policy.telemetryRetentionMs, policy.telemetryRetentionDays)) as {
|
||||
changes: number;
|
||||
}
|
||||
).changes
|
||||
: 0;
|
||||
const deletedEndedSessions = Number.isFinite(policy.sessionsRetentionMs)
|
||||
? (
|
||||
db
|
||||
.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`)
|
||||
.run(toDbMs(currentMs - policy.sessionsRetentionMs)) as { changes: number }
|
||||
.run(resolveCutoff(policy.sessionsRetentionMs, policy.sessionsRetentionDays)) as {
|
||||
changes: number;
|
||||
}
|
||||
).changes
|
||||
: 0;
|
||||
|
||||
@@ -115,14 +132,14 @@ export function pruneRollupRetention(
|
||||
};
|
||||
}
|
||||
|
||||
function getLastRollupSampleMs(db: DatabaseSync): number {
|
||||
function getLastRollupSampleMs(db: DatabaseSync): string {
|
||||
const row = db
|
||||
.prepare(`SELECT state_value FROM imm_rollup_state WHERE state_key = ? LIMIT 1`)
|
||||
.get(ROLLUP_STATE_KEY) as unknown as RollupStateRow | null;
|
||||
return row ? Number(row.state_value) : ZERO_ID;
|
||||
return row ? row.state_value : String(ZERO_ID);
|
||||
}
|
||||
|
||||
function setLastRollupSampleMs(db: DatabaseSync, sampleMs: number | bigint): void {
|
||||
function setLastRollupSampleMs(db: DatabaseSync, sampleMs: number | bigint | string): void {
|
||||
db.prepare(
|
||||
`INSERT INTO imm_rollup_state (state_key, state_value)
|
||||
VALUES (?, ?)
|
||||
@@ -141,7 +158,7 @@ function resetRollups(db: DatabaseSync): void {
|
||||
function upsertDailyRollupsForGroups(
|
||||
db: DatabaseSync,
|
||||
groups: Array<{ rollupDay: number; videoId: number }>,
|
||||
rollupNowMs: bigint,
|
||||
rollupNowMs: number | string,
|
||||
): void {
|
||||
if (groups.length === 0) {
|
||||
return;
|
||||
@@ -217,7 +234,7 @@ function upsertDailyRollupsForGroups(
|
||||
function upsertMonthlyRollupsForGroups(
|
||||
db: DatabaseSync,
|
||||
groups: Array<{ rollupMonth: number; videoId: number }>,
|
||||
rollupNowMs: bigint,
|
||||
rollupNowMs: number | string,
|
||||
): void {
|
||||
if (groups.length === 0) {
|
||||
return;
|
||||
@@ -268,7 +285,7 @@ function upsertMonthlyRollupsForGroups(
|
||||
|
||||
function getAffectedRollupGroups(
|
||||
db: DatabaseSync,
|
||||
lastRollupSampleMs: number,
|
||||
lastRollupSampleMs: number | string,
|
||||
): Array<{ rollupDay: number; rollupMonth: number; videoId: number }> {
|
||||
return (
|
||||
db
|
||||
@@ -321,7 +338,7 @@ export function runRollupMaintenance(db: DatabaseSync, forceRebuild = false): vo
|
||||
return;
|
||||
}
|
||||
|
||||
const rollupNowMs = toDbMs(nowMs());
|
||||
const rollupNowMs = toDbTimestamp(nowMs());
|
||||
const lastRollupSampleMs = getLastRollupSampleMs(db);
|
||||
|
||||
const maxSampleRow = db
|
||||
@@ -356,7 +373,7 @@ export function runRollupMaintenance(db: DatabaseSync, forceRebuild = false): vo
|
||||
try {
|
||||
upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs);
|
||||
upsertMonthlyRollupsForGroups(db, monthlyGroups, rollupNowMs);
|
||||
setLastRollupSampleMs(db, toDbMs(maxSampleRow.maxSampleMs ?? ZERO_ID));
|
||||
setLastRollupSampleMs(db, toDbTimestamp(maxSampleRow.maxSampleMs ?? ZERO_ID));
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
@@ -365,7 +382,7 @@ export function runRollupMaintenance(db: DatabaseSync, forceRebuild = false): vo
|
||||
}
|
||||
|
||||
export function rebuildRollupsInTransaction(db: DatabaseSync): void {
|
||||
const rollupNowMs = toDbMs(nowMs());
|
||||
const rollupNowMs = toDbTimestamp(nowMs());
|
||||
const maxSampleRow = db
|
||||
.prepare('SELECT MAX(sample_ms) AS maxSampleMs FROM imm_session_telemetry')
|
||||
.get() as unknown as RollupTelemetryResult | null;
|
||||
@@ -377,7 +394,7 @@ export function rebuildRollupsInTransaction(db: DatabaseSync): void {
|
||||
|
||||
const affectedGroups = getAffectedRollupGroups(db, ZERO_ID);
|
||||
if (affectedGroups.length === 0) {
|
||||
setLastRollupSampleMs(db, toDbMs(maxSampleRow.maxSampleMs ?? ZERO_ID));
|
||||
setLastRollupSampleMs(db, toDbTimestamp(maxSampleRow.maxSampleMs ?? ZERO_ID));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -396,7 +413,7 @@ export function rebuildRollupsInTransaction(db: DatabaseSync): void {
|
||||
|
||||
upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs);
|
||||
upsertMonthlyRollupsForGroups(db, monthlyGroups, rollupNowMs);
|
||||
setLastRollupSampleMs(db, toDbMs(maxSampleRow.maxSampleMs ?? ZERO_ID));
|
||||
setLastRollupSampleMs(db, toDbTimestamp(maxSampleRow.maxSampleMs ?? ZERO_ID));
|
||||
}
|
||||
|
||||
export function runOptimizeMaintenance(db: DatabaseSync): void {
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
WordDetailRow,
|
||||
WordOccurrenceRow,
|
||||
} from './types';
|
||||
import { fromDbTimestamp } from './query-shared';
|
||||
|
||||
export function getVocabularyStats(
|
||||
db: DatabaseSync,
|
||||
@@ -134,7 +135,11 @@ export function getSessionEvents(
|
||||
SELECT event_type AS eventType, ts_ms AS tsMs, payload_json AS payload
|
||||
FROM imm_session_events WHERE session_id = ? ORDER BY ts_ms ASC LIMIT ?
|
||||
`);
|
||||
return stmt.all(sessionId, limit) as SessionEventRow[];
|
||||
const rows = stmt.all(sessionId, limit) as Array<SessionEventRow & { tsMs: number | string }>;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
tsMs: fromDbTimestamp(row.tsMs) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
const placeholders = eventTypes.map(() => '?').join(', ');
|
||||
@@ -145,7 +150,13 @@ export function getSessionEvents(
|
||||
ORDER BY ts_ms ASC
|
||||
LIMIT ?
|
||||
`);
|
||||
return stmt.all(sessionId, ...eventTypes, limit) as SessionEventRow[];
|
||||
const rows = stmt.all(sessionId, ...eventTypes, limit) as Array<SessionEventRow & {
|
||||
tsMs: number | string;
|
||||
}>;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
tsMs: fromDbTimestamp(row.tsMs) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getWordDetail(db: DatabaseSync, wordId: number): WordDetailRow | null {
|
||||
|
||||
@@ -16,10 +16,10 @@ import type {
|
||||
StreakCalendarRow,
|
||||
WatchTimePerAnimeRow,
|
||||
} from './types';
|
||||
import { ACTIVE_SESSION_METRICS_CTE, resolvedCoverBlobExpr } from './query-shared';
|
||||
import { ACTIVE_SESSION_METRICS_CTE, fromDbTimestamp, resolvedCoverBlobExpr } from './query-shared';
|
||||
|
||||
export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
|
||||
return db
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
@@ -40,11 +40,15 @@ export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
|
||||
ORDER BY totalActiveMs DESC, lm.last_watched_ms DESC, canonicalTitle ASC
|
||||
`,
|
||||
)
|
||||
.all() as unknown as AnimeLibraryRow[];
|
||||
.all() as Array<AnimeLibraryRow & { lastWatchedMs: number | string }>;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
lastWatchedMs: fromDbTimestamp(row.lastWatchedMs) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRow | null {
|
||||
return db
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
${ACTIVE_SESSION_METRICS_CTE}
|
||||
@@ -75,7 +79,13 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo
|
||||
GROUP BY a.anime_id
|
||||
`,
|
||||
)
|
||||
.get(animeId) as unknown as AnimeDetailRow | null;
|
||||
.get(animeId) as (AnimeDetailRow & { lastWatchedMs: number | string }) | null;
|
||||
return row
|
||||
? {
|
||||
...row,
|
||||
lastWatchedMs: fromDbTimestamp(row.lastWatchedMs) ?? 0,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getAnimeAnilistEntries(db: DatabaseSync, animeId: number): AnimeAnilistEntryRow[] {
|
||||
@@ -98,7 +108,7 @@ export function getAnimeAnilistEntries(db: DatabaseSync, animeId: number): Anime
|
||||
}
|
||||
|
||||
export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisodeRow[] {
|
||||
return db
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
${ACTIVE_SESSION_METRICS_CTE}
|
||||
@@ -168,11 +178,21 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod
|
||||
v.video_id ASC
|
||||
`,
|
||||
)
|
||||
.all(animeId) as unknown as AnimeEpisodeRow[];
|
||||
.all(animeId) as Array<
|
||||
AnimeEpisodeRow & {
|
||||
endedMediaMs: number | string | null;
|
||||
lastWatchedMs: number | string;
|
||||
}
|
||||
>;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
endedMediaMs: fromDbTimestamp(row.endedMediaMs),
|
||||
lastWatchedMs: fromDbTimestamp(row.lastWatchedMs) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
|
||||
return db
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
@@ -205,7 +225,11 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
|
||||
ORDER BY lm.last_watched_ms DESC
|
||||
`,
|
||||
)
|
||||
.all() as unknown as MediaLibraryRow[];
|
||||
.all() as Array<MediaLibraryRow & { lastWatchedMs: number | string }>;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
lastWatchedMs: fromDbTimestamp(row.lastWatchedMs) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export function getMediaDetail(db: DatabaseSync, videoId: number): MediaDetailRow | null {
|
||||
@@ -253,7 +277,7 @@ export function getMediaSessions(
|
||||
videoId: number,
|
||||
limit = 100,
|
||||
): SessionSummaryQueryRow[] {
|
||||
return db
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
${ACTIVE_SESSION_METRICS_CTE}
|
||||
@@ -279,7 +303,17 @@ export function getMediaSessions(
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(videoId, limit) as unknown as SessionSummaryQueryRow[];
|
||||
.all(videoId, limit) as Array<
|
||||
SessionSummaryQueryRow & {
|
||||
startedAtMs: number | string;
|
||||
endedAtMs: number | string | null;
|
||||
}
|
||||
>;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
startedAtMs: fromDbTimestamp(row.startedAtMs) ?? 0,
|
||||
endedAtMs: fromDbTimestamp(row.endedAtMs),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getMediaDailyRollups(
|
||||
@@ -351,7 +385,7 @@ export function getAnimeDailyRollups(
|
||||
|
||||
export function getAnimeCoverArt(db: DatabaseSync, animeId: number): MediaArtRow | null {
|
||||
const resolvedCoverBlob = resolvedCoverBlobExpr('a', 'cab');
|
||||
return db
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
@@ -372,12 +406,18 @@ export function getAnimeCoverArt(db: DatabaseSync, animeId: number): MediaArtRow
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(animeId) as unknown as MediaArtRow | null;
|
||||
.get(animeId) as (MediaArtRow & { fetchedAtMs: number | string }) | null;
|
||||
return row
|
||||
? {
|
||||
...row,
|
||||
fetchedAtMs: fromDbTimestamp(row.fetchedAtMs) ?? 0,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getCoverArt(db: DatabaseSync, videoId: number): MediaArtRow | null {
|
||||
const resolvedCoverBlob = resolvedCoverBlobExpr('a', 'cab');
|
||||
return db
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
@@ -394,7 +434,13 @@ export function getCoverArt(db: DatabaseSync, videoId: number): MediaArtRow | nu
|
||||
WHERE a.video_id = ?
|
||||
`,
|
||||
)
|
||||
.get(videoId) as unknown as MediaArtRow | null;
|
||||
.get(videoId) as (MediaArtRow & { fetchedAtMs: number | string }) | null;
|
||||
return row
|
||||
? {
|
||||
...row,
|
||||
fetchedAtMs: fromDbTimestamp(row.fetchedAtMs) ?? 0,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
export function getStreakCalendar(db: DatabaseSync, days = 90): StreakCalendarRow[] {
|
||||
@@ -510,7 +556,7 @@ export function getEpisodeWords(db: DatabaseSync, videoId: number, limit = 50):
|
||||
}
|
||||
|
||||
export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSummaryQueryRow[] {
|
||||
return db
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
${ACTIVE_SESSION_METRICS_CTE}
|
||||
@@ -533,7 +579,17 @@ export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSu
|
||||
ORDER BY s.started_at_ms DESC
|
||||
`,
|
||||
)
|
||||
.all(videoId) as SessionSummaryQueryRow[];
|
||||
.all(videoId) as Array<
|
||||
SessionSummaryQueryRow & {
|
||||
startedAtMs: number | string;
|
||||
endedAtMs: number | string | null;
|
||||
}
|
||||
>;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
startedAtMs: fromDbTimestamp(row.startedAtMs) ?? 0,
|
||||
endedAtMs: fromDbTimestamp(row.endedAtMs),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): EpisodeCardEventRow[] {
|
||||
@@ -552,7 +608,7 @@ export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): Episode
|
||||
.all(videoId) as Array<{
|
||||
eventId: number;
|
||||
sessionId: number;
|
||||
tsMs: number;
|
||||
tsMs: number | string;
|
||||
cardsDelta: number;
|
||||
payloadJson: string | null;
|
||||
}>;
|
||||
@@ -568,7 +624,7 @@ export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): Episode
|
||||
return {
|
||||
eventId: row.eventId,
|
||||
sessionId: row.sessionId,
|
||||
tsMs: row.tsMs,
|
||||
tsMs: fromDbTimestamp(row.tsMs) ?? 0,
|
||||
cardsDelta: row.cardsDelta,
|
||||
noteIds,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getAffectedWordIdsForVideo,
|
||||
refreshLexicalAggregates,
|
||||
toDbMs,
|
||||
toDbTimestamp,
|
||||
} from './query-shared';
|
||||
|
||||
type CleanupVocabularyRow = {
|
||||
@@ -351,7 +352,7 @@ export function upsertCoverArt(
|
||||
)
|
||||
.get(videoId) as { coverBlobHash: string | null } | undefined;
|
||||
const sharedCoverBlobHash = findSharedCoverBlobHash(db, videoId, art.anilistId, art.coverUrl);
|
||||
const fetchedAtMs = toDbMs(nowMs());
|
||||
const fetchedAtMs = toDbTimestamp(nowMs());
|
||||
const coverBlob = normalizeCoverBlobBytes(art.coverBlob);
|
||||
const computedCoverBlobHash =
|
||||
coverBlob && coverBlob.length > 0
|
||||
@@ -444,7 +445,7 @@ export function updateAnimeAnilistInfo(
|
||||
info.titleEnglish,
|
||||
info.titleNative,
|
||||
info.episodesTotal,
|
||||
toDbMs(nowMs()),
|
||||
toDbTimestamp(nowMs()),
|
||||
row.anime_id,
|
||||
);
|
||||
}
|
||||
@@ -452,7 +453,7 @@ export function updateAnimeAnilistInfo(
|
||||
export function markVideoWatched(db: DatabaseSync, videoId: number, watched: boolean): void {
|
||||
db.prepare('UPDATE imm_videos SET watched = ?, LAST_UPDATE_DATE = ? WHERE video_id = ?').run(
|
||||
watched ? 1 : 0,
|
||||
toDbMs(nowMs()),
|
||||
toDbTimestamp(nowMs()),
|
||||
videoId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { nowMs } from './time';
|
||||
import type {
|
||||
ImmersionSessionRollupRow,
|
||||
SessionSummaryQueryRow,
|
||||
SessionTimelineRow,
|
||||
} from './types';
|
||||
import { ACTIVE_SESSION_METRICS_CTE } from './query-shared';
|
||||
import {
|
||||
ACTIVE_SESSION_METRICS_CTE,
|
||||
currentDbTimestamp,
|
||||
fromDbTimestamp,
|
||||
getLocalEpochDay,
|
||||
getShiftedLocalDaySec,
|
||||
toDbTimestamp,
|
||||
} from './query-shared';
|
||||
|
||||
export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummaryQueryRow[] {
|
||||
const prepared = db.prepare(`
|
||||
@@ -33,7 +39,15 @@ export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummar
|
||||
ORDER BY s.started_at_ms DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return prepared.all(limit) as unknown as SessionSummaryQueryRow[];
|
||||
const rows = prepared.all(limit) as Array<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(
|
||||
@@ -55,11 +69,23 @@ export function getSessionTimeline(
|
||||
`;
|
||||
|
||||
if (limit === undefined) {
|
||||
return db.prepare(select).all(sessionId) as unknown as SessionTimelineRow[];
|
||||
const rows = db.prepare(select).all(sessionId) as Array<SessionTimelineRow & {
|
||||
sampleMs: number | string;
|
||||
}>;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,
|
||||
}));
|
||||
}
|
||||
return db
|
||||
const rows = db
|
||||
.prepare(`${select}\n LIMIT ?`)
|
||||
.all(sessionId, limit) as unknown as SessionTimelineRow[];
|
||||
.all(sessionId, limit) as Array<SessionTimelineRow & {
|
||||
sampleMs: number | string;
|
||||
}>;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Returns all distinct headwords in the vocabulary table (global). */
|
||||
@@ -129,35 +155,50 @@ export function getSessionWordsByLine(
|
||||
}
|
||||
|
||||
function getNewWordCounts(db: DatabaseSync): { newWordsToday: number; newWordsThisWeek: number } {
|
||||
const now = new Date();
|
||||
const todayStartSec = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000;
|
||||
const weekAgoSec =
|
||||
new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7).getTime() / 1000;
|
||||
const currentTimestamp = currentDbTimestamp();
|
||||
const todayStartSec = getShiftedLocalDaySec(db, currentTimestamp, 0);
|
||||
const weekAgoSec = getShiftedLocalDaySec(db, currentTimestamp, -7);
|
||||
|
||||
const row = db
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
WITH headword_first_seen AS (
|
||||
SELECT
|
||||
headword,
|
||||
MIN(first_seen) AS first_seen
|
||||
first_seen AS firstSeen
|
||||
FROM imm_words
|
||||
WHERE first_seen IS NOT NULL
|
||||
AND headword IS NOT NULL
|
||||
AND headword != ''
|
||||
GROUP BY headword
|
||||
)
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN first_seen >= ? THEN 1 ELSE 0 END), 0) AS today,
|
||||
COALESCE(SUM(CASE WHEN first_seen >= ? THEN 1 ELSE 0 END), 0) AS week
|
||||
FROM headword_first_seen
|
||||
`,
|
||||
)
|
||||
.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 {
|
||||
newWordsToday: Number(row?.today ?? 0),
|
||||
newWordsThisWeek: Number(row?.week ?? 0),
|
||||
newWordsToday: today,
|
||||
newWordsThisWeek: week,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -203,10 +244,8 @@ export function getQueryHints(db: DatabaseSync): {
|
||||
animeCompleted: number;
|
||||
} | null;
|
||||
|
||||
const now = new Date();
|
||||
const todayLocal = Math.floor(
|
||||
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 86_400_000,
|
||||
);
|
||||
const currentTimestamp = currentDbTimestamp();
|
||||
const todayLocal = getLocalEpochDay(db, currentTimestamp);
|
||||
|
||||
const episodesToday =
|
||||
(
|
||||
@@ -215,13 +254,16 @@ export function getQueryHints(db: DatabaseSync): {
|
||||
`
|
||||
SELECT COUNT(DISTINCT s.video_id) AS count
|
||||
FROM imm_sessions s
|
||||
WHERE CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) = ?
|
||||
WHERE CAST(
|
||||
julianday(CAST(s.started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5
|
||||
AS INTEGER
|
||||
) = ?
|
||||
`,
|
||||
)
|
||||
.get(todayLocal) as { count: number }
|
||||
)?.count ?? 0;
|
||||
|
||||
const thirtyDaysAgoMs = nowMs() - 30 * 86400000;
|
||||
const thirtyDaysAgoMs = getShiftedLocalDaySec(db, currentTimestamp, -30).toString() + '000';
|
||||
const activeAnimeCount =
|
||||
(
|
||||
db
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { nowMs } from './time';
|
||||
|
||||
export const ACTIVE_SESSION_METRICS_CTE = `
|
||||
WITH active_session_metrics AS (
|
||||
@@ -280,3 +281,213 @@ export function toDbMs(ms: number | bigint): bigint {
|
||||
}
|
||||
return BigInt(Math.trunc(ms));
|
||||
}
|
||||
|
||||
function normalizeTimestampString(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new TypeError(`Invalid database timestamp: ${value}`);
|
||||
}
|
||||
|
||||
const integerLike = /^(-?)(\d+)(?:\.0+)?$/.exec(trimmed);
|
||||
if (integerLike) {
|
||||
const sign = integerLike[1] ?? '';
|
||||
const digits = (integerLike[2] ?? '0').replace(/^0+(?=\d)/, '');
|
||||
return `${sign}${digits || '0'}`;
|
||||
}
|
||||
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new TypeError(`Invalid database timestamp: ${value}`);
|
||||
}
|
||||
return JSON.stringify(Math.trunc(parsed));
|
||||
}
|
||||
|
||||
export function toDbTimestamp(ms: number | bigint | string): string {
|
||||
const normalizeParsed = (parsed: number): string => JSON.stringify(Math.trunc(parsed));
|
||||
|
||||
if (typeof ms === 'bigint') {
|
||||
return ms.toString();
|
||||
}
|
||||
if (typeof ms === 'string') {
|
||||
return normalizeTimestampString(ms);
|
||||
}
|
||||
if (!Number.isFinite(ms)) {
|
||||
throw new TypeError(`Invalid database timestamp: ${ms}`);
|
||||
}
|
||||
return normalizeParsed(ms);
|
||||
}
|
||||
|
||||
export function currentDbTimestamp(): string {
|
||||
const testNowMs = globalThis.__subminerTestNowMs;
|
||||
if (typeof testNowMs === 'string') {
|
||||
return normalizeTimestampString(testNowMs);
|
||||
}
|
||||
if (typeof testNowMs === 'number' && Number.isFinite(testNowMs)) {
|
||||
return toDbTimestamp(testNowMs);
|
||||
}
|
||||
return toDbTimestamp(nowMs());
|
||||
}
|
||||
|
||||
export function subtractDbTimestamp(
|
||||
timestampMs: number | bigint | string,
|
||||
deltaMs: number | bigint,
|
||||
): string {
|
||||
return (BigInt(toDbTimestamp(timestampMs)) - BigInt(deltaMs)).toString();
|
||||
}
|
||||
|
||||
export function fromDbTimestamp(ms: number | bigint | string | null | undefined): number | null {
|
||||
if (ms === null || ms === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof ms === 'number') {
|
||||
return ms;
|
||||
}
|
||||
if (typeof ms === 'bigint') {
|
||||
return Number(ms);
|
||||
}
|
||||
return Number(ms);
|
||||
}
|
||||
|
||||
function getNumericCalendarValue(
|
||||
db: DatabaseSync,
|
||||
sql: string,
|
||||
timestampMs: number | bigint | string,
|
||||
): number {
|
||||
const row = db.prepare(sql).get(toDbTimestamp(timestampMs)) as
|
||||
| { value: number | string | null }
|
||||
| undefined;
|
||||
return Number(row?.value ?? 0);
|
||||
}
|
||||
|
||||
export function getLocalEpochDay(
|
||||
db: DatabaseSync,
|
||||
timestampMs: number | bigint | string,
|
||||
): number {
|
||||
return getNumericCalendarValue(
|
||||
db,
|
||||
`
|
||||
SELECT CAST(
|
||||
julianday(CAST(? AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5
|
||||
AS INTEGER
|
||||
) AS value
|
||||
`,
|
||||
timestampMs,
|
||||
);
|
||||
}
|
||||
|
||||
export function getLocalMonthKey(
|
||||
db: DatabaseSync,
|
||||
timestampMs: number | bigint | string,
|
||||
): number {
|
||||
return getNumericCalendarValue(
|
||||
db,
|
||||
`
|
||||
SELECT CAST(
|
||||
strftime('%Y%m', CAST(? AS REAL) / 1000, 'unixepoch', 'localtime')
|
||||
AS INTEGER
|
||||
) AS value
|
||||
`,
|
||||
timestampMs,
|
||||
);
|
||||
}
|
||||
|
||||
export function getLocalDayOfWeek(
|
||||
db: DatabaseSync,
|
||||
timestampMs: number | bigint | string,
|
||||
): number {
|
||||
return getNumericCalendarValue(
|
||||
db,
|
||||
`
|
||||
SELECT CAST(
|
||||
strftime('%w', CAST(? AS REAL) / 1000, 'unixepoch', 'localtime')
|
||||
AS INTEGER
|
||||
) AS value
|
||||
`,
|
||||
timestampMs,
|
||||
);
|
||||
}
|
||||
|
||||
export function getLocalHourOfDay(
|
||||
db: DatabaseSync,
|
||||
timestampMs: number | bigint | string,
|
||||
): number {
|
||||
return getNumericCalendarValue(
|
||||
db,
|
||||
`
|
||||
SELECT CAST(
|
||||
strftime('%H', CAST(? AS REAL) / 1000, 'unixepoch', 'localtime')
|
||||
AS INTEGER
|
||||
) AS value
|
||||
`,
|
||||
timestampMs,
|
||||
);
|
||||
}
|
||||
|
||||
export function getStartOfLocalDaySec(
|
||||
db: DatabaseSync,
|
||||
timestampMs: number | bigint | string,
|
||||
): number {
|
||||
return getNumericCalendarValue(
|
||||
db,
|
||||
`
|
||||
SELECT CAST(
|
||||
strftime(
|
||||
'%s',
|
||||
CAST(? AS REAL) / 1000,
|
||||
'unixepoch',
|
||||
'localtime',
|
||||
'start of day',
|
||||
'utc'
|
||||
) AS INTEGER
|
||||
) AS value
|
||||
`,
|
||||
timestampMs,
|
||||
);
|
||||
}
|
||||
|
||||
export function getStartOfLocalDayTimestamp(
|
||||
db: DatabaseSync,
|
||||
timestampMs: number | bigint | string,
|
||||
): string {
|
||||
return `${getStartOfLocalDaySec(db, timestampMs)}000`;
|
||||
}
|
||||
|
||||
export function getShiftedLocalDayTimestamp(
|
||||
db: DatabaseSync,
|
||||
timestampMs: number | bigint | string,
|
||||
dayOffset: number,
|
||||
): string {
|
||||
const normalizedDayOffset = Math.trunc(dayOffset);
|
||||
const modifier = normalizedDayOffset >= 0 ? `+${normalizedDayOffset} days` : `${normalizedDayOffset} days`;
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT strftime(
|
||||
'%s',
|
||||
CAST(? AS REAL) / 1000,
|
||||
'unixepoch',
|
||||
'localtime',
|
||||
'start of day',
|
||||
'${modifier}',
|
||||
'utc'
|
||||
) AS value
|
||||
`,
|
||||
)
|
||||
.get(toDbTimestamp(timestampMs)) as { value: string | number | null } | undefined;
|
||||
return `${row?.value ?? '0'}000`;
|
||||
}
|
||||
|
||||
export function getShiftedLocalDaySec(
|
||||
db: DatabaseSync,
|
||||
timestampMs: number | bigint | string,
|
||||
dayOffset: number,
|
||||
): number {
|
||||
return Number(BigInt(getShiftedLocalDayTimestamp(db, timestampMs, dayOffset)) / 1000n);
|
||||
}
|
||||
|
||||
export function getStartOfLocalDayMs(
|
||||
db: DatabaseSync,
|
||||
timestampMs: number | bigint | string,
|
||||
): number {
|
||||
return getStartOfLocalDaySec(db, timestampMs) * 1000;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import type { ImmersionSessionRollupRow } from './types';
|
||||
import { ACTIVE_SESSION_METRICS_CTE, makePlaceholders } from './query-shared';
|
||||
import {
|
||||
ACTIVE_SESSION_METRICS_CTE,
|
||||
currentDbTimestamp,
|
||||
getLocalDayOfWeek,
|
||||
getLocalEpochDay,
|
||||
getLocalHourOfDay,
|
||||
getLocalMonthKey,
|
||||
getShiftedLocalDayTimestamp,
|
||||
makePlaceholders,
|
||||
toDbTimestamp,
|
||||
} from './query-shared';
|
||||
import { getDailyRollups, getMonthlyRollups } from './query-sessions';
|
||||
|
||||
type TrendRange = '7d' | '30d' | '90d' | 'all';
|
||||
@@ -19,6 +29,10 @@ interface TrendPerAnimePoint {
|
||||
|
||||
interface TrendSessionMetricRow {
|
||||
startedAtMs: number;
|
||||
epochDay: number;
|
||||
monthKey: number;
|
||||
dayOfWeek: number;
|
||||
hourOfDay: number;
|
||||
videoId: number | null;
|
||||
canonicalTitle: string | null;
|
||||
animeTitle: string | null;
|
||||
@@ -73,64 +87,64 @@ const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
|
||||
'90d': 90,
|
||||
};
|
||||
|
||||
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
function getTrendDayLimit(range: TrendRange): number {
|
||||
return range === 'all' ? 365 : TREND_DAY_LIMITS[range];
|
||||
}
|
||||
|
||||
function getTrendMonthlyLimit(range: TrendRange): number {
|
||||
function getTrendMonthlyLimit(db: DatabaseSync, range: TrendRange): number {
|
||||
if (range === 'all') {
|
||||
return 120;
|
||||
}
|
||||
const now = new Date();
|
||||
const cutoff = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate() - (TREND_DAY_LIMITS[range] - 1),
|
||||
);
|
||||
return Math.max(1, (now.getFullYear() - cutoff.getFullYear()) * 12 + now.getMonth() - cutoff.getMonth() + 1);
|
||||
const currentTimestamp = currentDbTimestamp();
|
||||
const todayStartMs = getShiftedLocalDayTimestamp(db, currentTimestamp, 0);
|
||||
const cutoffMs = getShiftedLocalDayTimestamp(db, currentTimestamp, -(TREND_DAY_LIMITS[range] - 1));
|
||||
const currentMonthKey = getLocalMonthKey(db, todayStartMs);
|
||||
const cutoffMonthKey = getLocalMonthKey(db, cutoffMs);
|
||||
const currentYear = Math.floor(currentMonthKey / 100);
|
||||
const currentMonth = currentMonthKey % 100;
|
||||
const cutoffYear = Math.floor(cutoffMonthKey / 100);
|
||||
const cutoffMonth = cutoffMonthKey % 100;
|
||||
return Math.max(1, (currentYear - cutoffYear) * 12 + currentMonth - cutoffMonth + 1);
|
||||
}
|
||||
|
||||
function getTrendCutoffMs(range: TrendRange): number | null {
|
||||
function getTrendCutoffMs(db: DatabaseSync, range: TrendRange): string | null {
|
||||
if (range === 'all') {
|
||||
return null;
|
||||
}
|
||||
const dayLimit = getTrendDayLimit(range);
|
||||
const now = new Date();
|
||||
const localMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
return localMidnight - (dayLimit - 1) * 86_400_000;
|
||||
return getShiftedLocalDayTimestamp(db, currentDbTimestamp(), -(getTrendDayLimit(range) - 1));
|
||||
}
|
||||
|
||||
function dayPartsFromEpochDay(epochDay: number): { year: number; month: number; day: number } {
|
||||
const z = epochDay + 719468;
|
||||
const era = Math.floor(z / 146097);
|
||||
const doe = z - era * 146097;
|
||||
const yoe = Math.floor(
|
||||
(doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365,
|
||||
);
|
||||
let year = yoe + era * 400;
|
||||
const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
|
||||
const mp = Math.floor((5 * doy + 2) / 153);
|
||||
const day = doy - Math.floor((153 * mp + 2) / 5) + 1;
|
||||
const month = mp < 10 ? mp + 3 : mp - 9;
|
||||
if (month <= 2) {
|
||||
year += 1;
|
||||
}
|
||||
return { year, month, day };
|
||||
}
|
||||
|
||||
function makeTrendLabel(value: number): string {
|
||||
if (value > 100_000) {
|
||||
const year = Math.floor(value / 100);
|
||||
const month = value % 100;
|
||||
return new Date(Date.UTC(year, month - 1, 1)).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
year: '2-digit',
|
||||
});
|
||||
return `${MONTH_NAMES[month - 1]} ${String(year).slice(-2)}`;
|
||||
}
|
||||
|
||||
return new Date(value * 86_400_000).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function getLocalEpochDay(timestampMs: number): number {
|
||||
const date = new Date(timestampMs);
|
||||
return Math.floor((timestampMs - date.getTimezoneOffset() * 60_000) / 86_400_000);
|
||||
}
|
||||
|
||||
function getLocalDateForEpochDay(epochDay: number): Date {
|
||||
const utcDate = new Date(epochDay * 86_400_000);
|
||||
return new Date(utcDate.getTime() + utcDate.getTimezoneOffset() * 60_000);
|
||||
}
|
||||
|
||||
function getLocalMonthKey(timestampMs: number): number {
|
||||
const date = new Date(timestampMs);
|
||||
return date.getFullYear() * 100 + date.getMonth() + 1;
|
||||
const { month, day } = dayPartsFromEpochDay(value);
|
||||
return `${MONTH_NAMES[month - 1]} ${day}`;
|
||||
}
|
||||
|
||||
function getTrendSessionWordCount(session: Pick<TrendSessionMetricRow, 'tokensSeen'>): number {
|
||||
@@ -189,7 +203,7 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
|
||||
function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
|
||||
const totals = new Array(7).fill(0);
|
||||
for (const session of sessions) {
|
||||
totals[new Date(session.startedAtMs).getDay()] += session.activeWatchedMs;
|
||||
totals[session.dayOfWeek] += session.activeWatchedMs;
|
||||
}
|
||||
return DAY_NAMES.map((name, index) => ({
|
||||
label: name,
|
||||
@@ -200,7 +214,7 @@ function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChar
|
||||
function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
|
||||
const totals = new Array(24).fill(0);
|
||||
for (const session of sessions) {
|
||||
totals[new Date(session.startedAtMs).getHours()] += session.activeWatchedMs;
|
||||
totals[session.hourOfDay] += session.activeWatchedMs;
|
||||
}
|
||||
return totals.map((ms, index) => ({
|
||||
label: `${String(index).padStart(2, '0')}:00`,
|
||||
@@ -209,10 +223,8 @@ function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoin
|
||||
}
|
||||
|
||||
function dayLabel(epochDay: number): string {
|
||||
return getLocalDateForEpochDay(epochDay).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
const { month, day } = dayPartsFromEpochDay(epochDay);
|
||||
return `${MONTH_NAMES[month - 1]} ${day}`;
|
||||
}
|
||||
|
||||
function buildSessionSeriesByDay(
|
||||
@@ -221,8 +233,7 @@ function buildSessionSeriesByDay(
|
||||
): TrendChartPoint[] {
|
||||
const byDay = new Map<number, number>();
|
||||
for (const session of sessions) {
|
||||
const epochDay = getLocalEpochDay(session.startedAtMs);
|
||||
byDay.set(epochDay, (byDay.get(epochDay) ?? 0) + getValue(session));
|
||||
byDay.set(session.epochDay, (byDay.get(session.epochDay) ?? 0) + getValue(session));
|
||||
}
|
||||
return Array.from(byDay.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
@@ -235,8 +246,7 @@ function buildSessionSeriesByMonth(
|
||||
): TrendChartPoint[] {
|
||||
const byMonth = new Map<number, number>();
|
||||
for (const session of sessions) {
|
||||
const monthKey = getLocalMonthKey(session.startedAtMs);
|
||||
byMonth.set(monthKey, (byMonth.get(monthKey) ?? 0) + getValue(session));
|
||||
byMonth.set(session.monthKey, (byMonth.get(session.monthKey) ?? 0) + getValue(session));
|
||||
}
|
||||
return Array.from(byMonth.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
@@ -251,8 +261,7 @@ function buildLookupsPerHundredWords(
|
||||
const wordsByBucket = new Map<number, number>();
|
||||
|
||||
for (const session of sessions) {
|
||||
const bucketKey =
|
||||
groupBy === 'month' ? getLocalMonthKey(session.startedAtMs) : getLocalEpochDay(session.startedAtMs);
|
||||
const bucketKey = groupBy === 'month' ? session.monthKey : session.epochDay;
|
||||
lookupsByBucket.set(
|
||||
bucketKey,
|
||||
(lookupsByBucket.get(bucketKey) ?? 0) + session.yomitanLookupCount,
|
||||
@@ -282,7 +291,7 @@ function buildPerAnimeFromSessions(
|
||||
|
||||
for (const session of sessions) {
|
||||
const animeTitle = resolveTrendAnimeTitle(session);
|
||||
const epochDay = getLocalEpochDay(session.startedAtMs);
|
||||
const epochDay = session.epochDay;
|
||||
const dayMap = byAnime.get(animeTitle) ?? new Map();
|
||||
dayMap.set(epochDay, (dayMap.get(epochDay) ?? 0) + getValue(session));
|
||||
byAnime.set(animeTitle, dayMap);
|
||||
@@ -303,7 +312,7 @@ function buildLookupsPerHundredPerAnime(sessions: TrendSessionMetricRow[]): Tren
|
||||
|
||||
for (const session of sessions) {
|
||||
const animeTitle = resolveTrendAnimeTitle(session);
|
||||
const epochDay = getLocalEpochDay(session.startedAtMs);
|
||||
const epochDay = session.epochDay;
|
||||
|
||||
const lookupMap = lookups.get(animeTitle) ?? new Map();
|
||||
lookupMap.set(epochDay, (lookupMap.get(epochDay) ?? 0) + session.yomitanLookupCount);
|
||||
@@ -498,9 +507,10 @@ function buildEpisodesPerMonthFromRollups(rollups: ImmersionSessionRollupRow[]):
|
||||
|
||||
function getTrendSessionMetrics(
|
||||
db: DatabaseSync,
|
||||
cutoffMs: number | null,
|
||||
cutoffMs: string | null,
|
||||
): TrendSessionMetricRow[] {
|
||||
const whereClause = cutoffMs === null ? '' : 'WHERE s.started_at_ms >= ?';
|
||||
const cutoffValue = cutoffMs === null ? null : toDbTimestamp(cutoffMs);
|
||||
const prepared = db.prepare(`
|
||||
${ACTIVE_SESSION_METRICS_CTE}
|
||||
SELECT
|
||||
@@ -520,14 +530,27 @@ function getTrendSessionMetrics(
|
||||
ORDER BY s.started_at_ms ASC
|
||||
`);
|
||||
|
||||
return (cutoffMs === null ? prepared.all() : prepared.all(cutoffMs)) as TrendSessionMetricRow[];
|
||||
const rows = (cutoffValue === null ? prepared.all() : prepared.all(cutoffValue)) as Array<
|
||||
TrendSessionMetricRow & { startedAtMs: number | string }
|
||||
>;
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
startedAtMs: 0,
|
||||
epochDay: getLocalEpochDay(db, row.startedAtMs),
|
||||
monthKey: getLocalMonthKey(db, row.startedAtMs),
|
||||
dayOfWeek: getLocalDayOfWeek(db, row.startedAtMs),
|
||||
hourOfDay: getLocalHourOfDay(db, row.startedAtMs),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: number | null): TrendChartPoint[] {
|
||||
function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: string | null): TrendChartPoint[] {
|
||||
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
|
||||
const prepared = db.prepare(`
|
||||
SELECT
|
||||
CAST(julianday(first_seen, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS epochDay,
|
||||
CAST(
|
||||
julianday(CAST(first_seen AS REAL), 'unixepoch', 'localtime') - 2440587.5
|
||||
AS INTEGER
|
||||
) AS epochDay,
|
||||
COUNT(*) AS wordCount
|
||||
FROM imm_words
|
||||
WHERE first_seen IS NOT NULL
|
||||
@@ -537,7 +560,7 @@ function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: number | null): TrendCh
|
||||
`);
|
||||
|
||||
const rows = (
|
||||
cutoffMs === null ? prepared.all() : prepared.all(Math.floor(cutoffMs / 1000))
|
||||
cutoffMs === null ? prepared.all() : prepared.all((BigInt(cutoffMs) / 1000n).toString())
|
||||
) as Array<{
|
||||
epochDay: number;
|
||||
wordCount: number;
|
||||
@@ -549,11 +572,14 @@ function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: number | null): TrendCh
|
||||
}));
|
||||
}
|
||||
|
||||
function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: number | null): TrendChartPoint[] {
|
||||
function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: string | null): TrendChartPoint[] {
|
||||
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?';
|
||||
const prepared = db.prepare(`
|
||||
SELECT
|
||||
CAST(strftime('%Y%m', first_seen, 'unixepoch', 'localtime') AS INTEGER) AS monthKey,
|
||||
CAST(
|
||||
strftime('%Y%m', CAST(first_seen AS REAL), 'unixepoch', 'localtime')
|
||||
AS INTEGER
|
||||
) AS monthKey,
|
||||
COUNT(*) AS wordCount
|
||||
FROM imm_words
|
||||
WHERE first_seen IS NOT NULL
|
||||
@@ -563,7 +589,7 @@ function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: number | null): Trend
|
||||
`);
|
||||
|
||||
const rows = (
|
||||
cutoffMs === null ? prepared.all() : prepared.all(Math.floor(cutoffMs / 1000))
|
||||
cutoffMs === null ? prepared.all() : prepared.all((BigInt(cutoffMs) / 1000n).toString())
|
||||
) as Array<{
|
||||
monthKey: number;
|
||||
wordCount: number;
|
||||
@@ -581,8 +607,8 @@ export function getTrendsDashboard(
|
||||
groupBy: TrendGroupBy = 'day',
|
||||
): TrendsDashboardQueryResult {
|
||||
const dayLimit = getTrendDayLimit(range);
|
||||
const monthlyLimit = getTrendMonthlyLimit(range);
|
||||
const cutoffMs = getTrendCutoffMs(range);
|
||||
const monthlyLimit = getTrendMonthlyLimit(db, range);
|
||||
const cutoffMs = getTrendCutoffMs(db, range);
|
||||
const useMonthlyBuckets = groupBy === 'month';
|
||||
const dailyRollups = getDailyRollups(db, dayLimit);
|
||||
const monthlyRollups = getMonthlyRollups(db, monthlyLimit);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createInitialSessionState } from './reducer';
|
||||
import { nowMs } from './time';
|
||||
import { SESSION_STATUS_ACTIVE, SESSION_STATUS_ENDED } from './types';
|
||||
import type { SessionState } from './types';
|
||||
import { toDbMs } from './query-shared';
|
||||
import { toDbMs, toDbTimestamp } from './query-shared';
|
||||
|
||||
export function startSessionRecord(
|
||||
db: DatabaseSync,
|
||||
@@ -25,10 +25,10 @@ export function startSessionRecord(
|
||||
.run(
|
||||
sessionUuid,
|
||||
videoId,
|
||||
toDbMs(startedAtMs),
|
||||
toDbTimestamp(startedAtMs),
|
||||
SESSION_STATUS_ACTIVE,
|
||||
toDbMs(startedAtMs),
|
||||
toDbMs(createdAtMs),
|
||||
toDbTimestamp(startedAtMs),
|
||||
toDbTimestamp(createdAtMs),
|
||||
);
|
||||
const sessionId = Number(result.lastInsertRowid);
|
||||
return {
|
||||
@@ -40,7 +40,7 @@ export function startSessionRecord(
|
||||
export function finalizeSessionRecord(
|
||||
db: DatabaseSync,
|
||||
sessionState: SessionState,
|
||||
endedAtMs = nowMs(),
|
||||
endedAtMs: number | string = nowMs(),
|
||||
): void {
|
||||
db.prepare(
|
||||
`
|
||||
@@ -66,7 +66,7 @@ export function finalizeSessionRecord(
|
||||
WHERE session_id = ?
|
||||
`,
|
||||
).run(
|
||||
toDbMs(endedAtMs),
|
||||
toDbTimestamp(endedAtMs),
|
||||
SESSION_STATUS_ENDED,
|
||||
sessionState.lastMediaMs === null ? null : toDbMs(sessionState.lastMediaMs),
|
||||
sessionState.totalWatchedMs,
|
||||
@@ -82,7 +82,7 @@ export function finalizeSessionRecord(
|
||||
sessionState.seekForwardCount,
|
||||
sessionState.seekBackwardCount,
|
||||
sessionState.mediaBufferEvents,
|
||||
toDbMs(nowMs()),
|
||||
toDbTimestamp(nowMs()),
|
||||
sessionState.sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,10 +143,10 @@ test('ensureSchema creates immersion core tables', () => {
|
||||
const rollupStateRow = db
|
||||
.prepare('SELECT state_value FROM imm_rollup_state WHERE state_key = ?')
|
||||
.get('last_rollup_sample_ms') as {
|
||||
state_value: number;
|
||||
state_value: string;
|
||||
} | null;
|
||||
assert.ok(rollupStateRow);
|
||||
assert.equal(rollupStateRow?.state_value, 0);
|
||||
assert.equal(Number(rollupStateRow?.state_value ?? 0), 0);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
@@ -965,12 +965,12 @@ test('start/finalize session updates ended_at and status', () => {
|
||||
const row = db
|
||||
.prepare('SELECT ended_at_ms, status FROM imm_sessions WHERE session_id = ?')
|
||||
.get(sessionId) as {
|
||||
ended_at_ms: number | null;
|
||||
ended_at_ms: string | null;
|
||||
status: number;
|
||||
} | null;
|
||||
|
||||
assert.ok(row);
|
||||
assert.equal(row?.ended_at_ms, endedAtMs);
|
||||
assert.equal(Number(row?.ended_at_ms ?? 0), endedAtMs);
|
||||
assert.equal(row?.status, SESSION_STATUS_ENDED);
|
||||
} finally {
|
||||
db.close();
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { DatabaseSync } from './sqlite';
|
||||
import { nowMs } from './time';
|
||||
import { SCHEMA_VERSION } from './types';
|
||||
import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types';
|
||||
import { toDbMs } from './query-shared';
|
||||
import { toDbMs, toDbTimestamp } from './query-shared';
|
||||
|
||||
export interface TrackerPreparedStatements {
|
||||
telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
@@ -130,7 +130,7 @@ function deduplicateExistingCoverArtRows(db: DatabaseSync): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const nowMsValue = toDbMs(nowMs());
|
||||
const nowMsValue = toDbTimestamp(nowMs());
|
||||
const upsertBlobStmt = db.prepare(`
|
||||
INSERT INTO imm_cover_art_blobs (blob_hash, cover_blob, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, ?, ?)
|
||||
@@ -275,7 +275,7 @@ function parseLegacyAnimeBackfillCandidate(
|
||||
}
|
||||
|
||||
function ensureLifetimeSummaryTables(db: DatabaseSync): void {
|
||||
const nowMsValue = toDbMs(nowMs());
|
||||
const nowMsValue = toDbTimestamp(nowMs());
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS imm_lifetime_global(
|
||||
@@ -287,9 +287,9 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void {
|
||||
episodes_started INTEGER NOT NULL DEFAULT 0,
|
||||
episodes_completed INTEGER NOT NULL DEFAULT 0,
|
||||
anime_completed INTEGER NOT NULL DEFAULT 0,
|
||||
last_rebuilt_ms INTEGER,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER
|
||||
last_rebuilt_ms TEXT,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -332,10 +332,10 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void {
|
||||
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
|
||||
episodes_started INTEGER NOT NULL DEFAULT 0,
|
||||
episodes_completed INTEGER NOT NULL DEFAULT 0,
|
||||
first_watched_ms INTEGER,
|
||||
last_watched_ms INTEGER,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER,
|
||||
first_watched_ms TEXT,
|
||||
last_watched_ms TEXT,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
@@ -349,10 +349,10 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void {
|
||||
total_lines_seen INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
|
||||
completed INTEGER NOT NULL DEFAULT 0,
|
||||
first_watched_ms INTEGER,
|
||||
last_watched_ms INTEGER,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER,
|
||||
first_watched_ms TEXT,
|
||||
last_watched_ms TEXT,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
@@ -360,9 +360,9 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS imm_lifetime_applied_sessions(
|
||||
session_id INTEGER PRIMARY KEY,
|
||||
applied_at_ms INTEGER NOT NULL,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER,
|
||||
applied_at_ms TEXT NOT NULL,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
@@ -405,13 +405,13 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
|
||||
input.titleEnglish,
|
||||
input.titleNative,
|
||||
input.metadataJson,
|
||||
toDbMs(nowMs()),
|
||||
toDbTimestamp(nowMs()),
|
||||
existing.anime_id,
|
||||
);
|
||||
return existing.anime_id;
|
||||
}
|
||||
|
||||
const nowMsValue = toDbMs(nowMs());
|
||||
const nowMsValue = toDbTimestamp(nowMs());
|
||||
const result = db
|
||||
.prepare(
|
||||
`
|
||||
@@ -471,7 +471,7 @@ export function linkVideoToAnimeRecord(
|
||||
input.parserSource,
|
||||
input.parserConfidence,
|
||||
input.parseMetadataJson,
|
||||
toDbMs(nowMs()),
|
||||
toDbTimestamp(nowMs()),
|
||||
videoId,
|
||||
);
|
||||
}
|
||||
@@ -562,13 +562,13 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS imm_schema_version (
|
||||
schema_version INTEGER PRIMARY KEY,
|
||||
applied_at_ms INTEGER NOT NULL
|
||||
applied_at_ms TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS imm_rollup_state(
|
||||
state_key TEXT PRIMARY KEY,
|
||||
state_value INTEGER NOT NULL
|
||||
state_value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
@@ -597,8 +597,8 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
episodes_total INTEGER,
|
||||
description TEXT,
|
||||
metadata_json TEXT,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
@@ -625,8 +625,8 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
bitrate_kbps INTEGER, audio_codec_id INTEGER,
|
||||
hash_sha256 TEXT, screenshot_path TEXT,
|
||||
metadata_json TEXT,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL
|
||||
);
|
||||
`);
|
||||
@@ -635,7 +635,7 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
session_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_uuid TEXT NOT NULL UNIQUE,
|
||||
video_id INTEGER NOT NULL,
|
||||
started_at_ms INTEGER NOT NULL, ended_at_ms INTEGER,
|
||||
started_at_ms TEXT NOT NULL, ended_at_ms TEXT,
|
||||
status INTEGER NOT NULL,
|
||||
locale_id INTEGER, target_lang_id INTEGER,
|
||||
difficulty_tier INTEGER, subtitle_mode INTEGER,
|
||||
@@ -653,8 +653,8 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
seek_forward_count INTEGER NOT NULL DEFAULT 0,
|
||||
seek_backward_count INTEGER NOT NULL DEFAULT 0,
|
||||
media_buffer_events INTEGER NOT NULL DEFAULT 0,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id)
|
||||
);
|
||||
`);
|
||||
@@ -662,7 +662,7 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
CREATE TABLE IF NOT EXISTS imm_session_telemetry(
|
||||
telemetry_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
sample_ms INTEGER NOT NULL,
|
||||
sample_ms TEXT NOT NULL,
|
||||
total_watched_ms INTEGER NOT NULL DEFAULT 0,
|
||||
active_watched_ms INTEGER NOT NULL DEFAULT 0,
|
||||
lines_seen INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -676,8 +676,8 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
seek_forward_count INTEGER NOT NULL DEFAULT 0,
|
||||
seek_backward_count INTEGER NOT NULL DEFAULT 0,
|
||||
media_buffer_events INTEGER NOT NULL DEFAULT 0,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
@@ -693,8 +693,8 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
tokens_delta INTEGER NOT NULL DEFAULT 0,
|
||||
cards_delta INTEGER NOT NULL DEFAULT 0,
|
||||
payload_json TEXT,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
@@ -710,8 +710,8 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
cards_per_hour REAL,
|
||||
tokens_per_min REAL,
|
||||
lookup_hit_rate REAL,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
PRIMARY KEY (rollup_day, video_id)
|
||||
);
|
||||
`);
|
||||
@@ -724,8 +724,8 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
total_lines_seen INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
|
||||
total_cards INTEGER NOT NULL DEFAULT 0,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
PRIMARY KEY (rollup_month, video_id)
|
||||
);
|
||||
`);
|
||||
@@ -806,9 +806,9 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
title_romaji TEXT,
|
||||
title_english TEXT,
|
||||
episodes_total INTEGER,
|
||||
fetched_at_ms INTEGER NOT NULL,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER,
|
||||
fetched_at_ms TEXT NOT NULL,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
@@ -827,9 +827,9 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
uploader_url TEXT,
|
||||
description TEXT,
|
||||
metadata_json TEXT,
|
||||
fetched_at_ms INTEGER NOT NULL,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER,
|
||||
fetched_at_ms TEXT NOT NULL,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
@@ -837,26 +837,26 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
CREATE TABLE IF NOT EXISTS imm_cover_art_blobs(
|
||||
blob_hash TEXT PRIMARY KEY,
|
||||
cover_blob BLOB NOT NULL,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT
|
||||
);
|
||||
`);
|
||||
|
||||
if (currentVersion?.schema_version === 1) {
|
||||
addColumnIfMissing(db, 'imm_videos', 'CREATED_DATE');
|
||||
addColumnIfMissing(db, 'imm_videos', 'LAST_UPDATE_DATE');
|
||||
addColumnIfMissing(db, 'imm_sessions', 'CREATED_DATE');
|
||||
addColumnIfMissing(db, 'imm_sessions', 'LAST_UPDATE_DATE');
|
||||
addColumnIfMissing(db, 'imm_session_telemetry', 'CREATED_DATE');
|
||||
addColumnIfMissing(db, 'imm_session_telemetry', 'LAST_UPDATE_DATE');
|
||||
addColumnIfMissing(db, 'imm_session_events', 'CREATED_DATE');
|
||||
addColumnIfMissing(db, 'imm_session_events', 'LAST_UPDATE_DATE');
|
||||
addColumnIfMissing(db, 'imm_daily_rollups', 'CREATED_DATE');
|
||||
addColumnIfMissing(db, 'imm_daily_rollups', 'LAST_UPDATE_DATE');
|
||||
addColumnIfMissing(db, 'imm_monthly_rollups', 'CREATED_DATE');
|
||||
addColumnIfMissing(db, 'imm_monthly_rollups', 'LAST_UPDATE_DATE');
|
||||
addColumnIfMissing(db, 'imm_videos', 'CREATED_DATE', 'TEXT');
|
||||
addColumnIfMissing(db, 'imm_videos', 'LAST_UPDATE_DATE', 'TEXT');
|
||||
addColumnIfMissing(db, 'imm_sessions', 'CREATED_DATE', 'TEXT');
|
||||
addColumnIfMissing(db, 'imm_sessions', 'LAST_UPDATE_DATE', 'TEXT');
|
||||
addColumnIfMissing(db, 'imm_session_telemetry', 'CREATED_DATE', 'TEXT');
|
||||
addColumnIfMissing(db, 'imm_session_telemetry', 'LAST_UPDATE_DATE', 'TEXT');
|
||||
addColumnIfMissing(db, 'imm_session_events', 'CREATED_DATE', 'TEXT');
|
||||
addColumnIfMissing(db, 'imm_session_events', 'LAST_UPDATE_DATE', 'TEXT');
|
||||
addColumnIfMissing(db, 'imm_daily_rollups', 'CREATED_DATE', 'TEXT');
|
||||
addColumnIfMissing(db, 'imm_daily_rollups', 'LAST_UPDATE_DATE', 'TEXT');
|
||||
addColumnIfMissing(db, 'imm_monthly_rollups', 'CREATED_DATE', 'TEXT');
|
||||
addColumnIfMissing(db, 'imm_monthly_rollups', 'LAST_UPDATE_DATE', 'TEXT');
|
||||
|
||||
const migratedAtMs = toDbMs(nowMs());
|
||||
const migratedAtMs = toDbTimestamp(nowMs());
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_videos
|
||||
@@ -1243,7 +1243,7 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
|
||||
db.exec(`
|
||||
INSERT INTO imm_schema_version(schema_version, applied_at_ms)
|
||||
VALUES (${SCHEMA_VERSION}, ${toDbMs(nowMs())})
|
||||
VALUES (${SCHEMA_VERSION}, ${toDbTimestamp(nowMs())})
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
}
|
||||
@@ -1401,7 +1401,7 @@ function incrementKanjiAggregate(
|
||||
}
|
||||
|
||||
export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedStatements): void {
|
||||
const currentMs = toDbMs(nowMs());
|
||||
const currentMs = toDbTimestamp(nowMs());
|
||||
if (write.kind === 'telemetry') {
|
||||
if (
|
||||
write.totalWatchedMs === undefined ||
|
||||
@@ -1420,7 +1420,7 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
|
||||
) {
|
||||
throw new Error('Incomplete telemetry write');
|
||||
}
|
||||
const telemetrySampleMs = toDbMs(write.sampleMs ?? Number(currentMs));
|
||||
const telemetrySampleMs = toDbTimestamp(write.sampleMs ?? Number(currentMs));
|
||||
stmts.telemetryInsertStmt.run(
|
||||
write.sessionId,
|
||||
telemetrySampleMs,
|
||||
@@ -1495,7 +1495,7 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
|
||||
|
||||
stmts.eventInsertStmt.run(
|
||||
write.sessionId,
|
||||
toDbMs(write.sampleMs ?? Number(currentMs)),
|
||||
toDbTimestamp(write.sampleMs ?? Number(currentMs)),
|
||||
write.eventType ?? 0,
|
||||
write.lineIndex ?? null,
|
||||
write.segmentStartMs ?? null,
|
||||
@@ -1530,11 +1530,11 @@ export function getOrCreateVideoRecord(
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE video_id = ?
|
||||
`,
|
||||
).run(details.canonicalTitle || 'unknown', toDbMs(nowMs()), existing.video_id);
|
||||
).run(details.canonicalTitle || 'unknown', toDbTimestamp(nowMs()), existing.video_id);
|
||||
return existing.video_id;
|
||||
}
|
||||
|
||||
const currentMs = toDbMs(nowMs());
|
||||
const currentMs = toDbTimestamp(nowMs());
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO imm_videos (
|
||||
video_key, canonical_title, source_type, source_path, source_url,
|
||||
@@ -1604,7 +1604,7 @@ export function updateVideoMetadataRecord(
|
||||
metadata.hashSha256,
|
||||
metadata.screenshotPath,
|
||||
metadata.metadataJson,
|
||||
toDbMs(nowMs()),
|
||||
toDbTimestamp(nowMs()),
|
||||
videoId,
|
||||
);
|
||||
}
|
||||
@@ -1622,7 +1622,7 @@ export function updateVideoTitleRecord(
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE video_id = ?
|
||||
`,
|
||||
).run(canonicalTitle, toDbMs(nowMs()), videoId);
|
||||
).run(canonicalTitle, toDbTimestamp(nowMs()), videoId);
|
||||
}
|
||||
|
||||
export function upsertYoutubeVideoMetadata(
|
||||
@@ -1630,7 +1630,7 @@ export function upsertYoutubeVideoMetadata(
|
||||
videoId: number,
|
||||
metadata: YoutubeVideoMetadata,
|
||||
): void {
|
||||
const currentMs = toDbMs(nowMs());
|
||||
const currentMs = toDbTimestamp(nowMs());
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_youtube_videos (
|
||||
|
||||
@@ -5,3 +5,25 @@ import { nowMs } from './time.js';
|
||||
test('nowMs returns wall-clock epoch milliseconds', () => {
|
||||
assert.ok(nowMs() > 1_600_000_000_000);
|
||||
});
|
||||
|
||||
test('nowMs honors string-backed test clock values', () => {
|
||||
const previousNowMs = globalThis.__subminerTestNowMs;
|
||||
globalThis.__subminerTestNowMs = '123.9';
|
||||
|
||||
try {
|
||||
assert.equal(nowMs(), 123);
|
||||
} finally {
|
||||
globalThis.__subminerTestNowMs = previousNowMs;
|
||||
}
|
||||
});
|
||||
|
||||
test('nowMs truncates negative numeric test clock values', () => {
|
||||
const previousNowMs = globalThis.__subminerTestNowMs;
|
||||
globalThis.__subminerTestNowMs = -1.9;
|
||||
|
||||
try {
|
||||
assert.equal(nowMs(), -1);
|
||||
} finally {
|
||||
globalThis.__subminerTestNowMs = previousNowMs;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
declare global {
|
||||
var __subminerTestNowMs: number | string | undefined;
|
||||
}
|
||||
|
||||
function getMockNowMs(testNowMs: number | string | undefined): number | null {
|
||||
if (typeof testNowMs === 'number' && Number.isFinite(testNowMs)) {
|
||||
return Math.trunc(testNowMs);
|
||||
}
|
||||
if (typeof testNowMs === 'string') {
|
||||
const parsed = Number(testNowMs.trim());
|
||||
if (Number.isFinite(parsed)) {
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function nowMs(): number {
|
||||
const mockedNowMs = getMockNowMs(globalThis.__subminerTestNowMs);
|
||||
if (mockedNowMs !== null) {
|
||||
return mockedNowMs;
|
||||
}
|
||||
|
||||
const perf = globalThis.performance;
|
||||
if (perf && Number.isFinite(perf.timeOrigin)) {
|
||||
return Math.floor(perf.timeOrigin + perf.now());
|
||||
|
||||
@@ -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_PREVIOUS_SUBTITLE_START: '__sub-delay-prev-line',
|
||||
YOUTUBE_PICKER_OPEN: '__youtube-picker-open',
|
||||
PLAYLIST_BROWSER_OPEN: '__playlist-browser-open',
|
||||
},
|
||||
triggerSubsyncFromConfig: () => {
|
||||
calls.push('subsync');
|
||||
@@ -26,6 +27,9 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
openYoutubeTrackPicker: () => {
|
||||
calls.push('youtube-picker');
|
||||
},
|
||||
openPlaylistBrowser: () => {
|
||||
calls.push('playlist-browser');
|
||||
},
|
||||
runtimeOptionsCycle: () => ({ ok: true }),
|
||||
showMpvOsd: (text) => {
|
||||
osd.push(text);
|
||||
@@ -110,6 +114,28 @@ test('handleMpvCommandFromIpc dispatches special youtube picker open command', (
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc dispatches special playlist browser open command', async () => {
|
||||
const { options, calls, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['__playlist-browser-open'], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(calls, ['playlist-browser']);
|
||||
assert.deepEqual(sentCommands, []);
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc surfaces playlist browser open rejections via mpv osd', async () => {
|
||||
const { options, osd } = createOptions({
|
||||
openPlaylistBrowser: async () => {
|
||||
throw new Error('overlay failed');
|
||||
},
|
||||
});
|
||||
|
||||
handleMpvCommandFromIpc(['__playlist-browser-open'], options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(osd, ['Playlist browser failed: overlay failed']);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc does not forward commands while disconnected', () => {
|
||||
const { options, sentCommands, osd } = createOptions({
|
||||
isMpvConnected: () => false,
|
||||
|
||||
@@ -15,10 +15,12 @@ export interface HandleMpvCommandFromIpcOptions {
|
||||
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: string;
|
||||
SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START: string;
|
||||
YOUTUBE_PICKER_OPEN: string;
|
||||
PLAYLIST_BROWSER_OPEN: string;
|
||||
};
|
||||
triggerSubsyncFromConfig: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||
openPlaylistBrowser: () => void | Promise<void>;
|
||||
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
showMpvOsd: (text: string) => void;
|
||||
mpvReplaySubtitle: () => void;
|
||||
@@ -97,6 +99,16 @@ export function handleMpvCommandFromIpc(
|
||||
return;
|
||||
}
|
||||
|
||||
if (first === options.specialCommands.PLAYLIST_BROWSER_OPEN) {
|
||||
Promise.resolve()
|
||||
.then(() => options.openPlaylistBrowser())
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.showMpvOsd(`Playlist browser failed: ${message}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START ||
|
||||
first === options.specialCommands.SHIFT_SUB_DELAY_TO_PREVIOUS_SUBTITLE_START
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
|
||||
import { createIpcDepsRuntime, registerIpcHandlers, type IpcServiceDeps } from './ipc';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import type { SubtitleSidebarSnapshot } from '../../types';
|
||||
import type { PlaylistBrowserSnapshot, SubtitleSidebarSnapshot } from '../../types';
|
||||
|
||||
interface FakeIpcRegistrar {
|
||||
on: Map<string, (event: unknown, ...args: unknown[]) => void>;
|
||||
@@ -148,6 +148,19 @@ function createRegisterIpcDeps(overrides: Partial<IpcServiceDeps> = {}): IpcServ
|
||||
getAnilistQueueStatus: () => ({}),
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||
getPlaylistBrowserSnapshot: async () => ({
|
||||
directoryPath: null,
|
||||
directoryAvailable: false,
|
||||
directoryStatus: '',
|
||||
directoryItems: [],
|
||||
playlistItems: [],
|
||||
playingIndex: null,
|
||||
currentFilePath: null,
|
||||
}),
|
||||
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
|
||||
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||
immersionTracker: null,
|
||||
...overrides,
|
||||
@@ -241,6 +254,19 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
return { ok: true, message: 'done' };
|
||||
},
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'queued' }),
|
||||
getPlaylistBrowserSnapshot: async () => ({
|
||||
directoryPath: '/tmp',
|
||||
directoryAvailable: true,
|
||||
directoryStatus: '/tmp',
|
||||
directoryItems: [],
|
||||
playlistItems: [],
|
||||
playingIndex: 0,
|
||||
currentFilePath: '/tmp/current.mkv',
|
||||
}),
|
||||
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'append' }),
|
||||
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'play' }),
|
||||
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'remove' }),
|
||||
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'move' }),
|
||||
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||
});
|
||||
|
||||
@@ -256,10 +282,83 @@ test('createIpcDepsRuntime wires AniList handlers', async () => {
|
||||
ok: true,
|
||||
message: 'done',
|
||||
});
|
||||
assert.equal((await deps.getPlaylistBrowserSnapshot()).directoryAvailable, true);
|
||||
assert.deepEqual(await deps.appendPlaylistBrowserFile('/tmp/new.mkv'), {
|
||||
ok: true,
|
||||
message: 'append',
|
||||
});
|
||||
assert.deepEqual(await deps.playPlaylistBrowserIndex(2), { ok: true, message: 'play' });
|
||||
assert.deepEqual(await deps.removePlaylistBrowserIndex(2), { ok: true, message: 'remove' });
|
||||
assert.deepEqual(await deps.movePlaylistBrowserIndex(2, -1), { ok: true, message: 'move' });
|
||||
assert.deepEqual(calls, ['clearAnilistToken', 'openAnilistSetup', 'retryAnilistQueueNow']);
|
||||
assert.equal(deps.getPlaybackPaused(), true);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers exposes playlist browser snapshot and mutations', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: Array<[string, unknown[]]> = [];
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
getPlaylistBrowserSnapshot: async () => ({
|
||||
directoryPath: '/tmp/videos',
|
||||
directoryAvailable: true,
|
||||
directoryStatus: '/tmp/videos',
|
||||
directoryItems: [],
|
||||
playlistItems: [],
|
||||
playingIndex: 1,
|
||||
currentFilePath: '/tmp/videos/ep2.mkv',
|
||||
}),
|
||||
appendPlaylistBrowserFile: async (filePath) => {
|
||||
calls.push(['append', [filePath]]);
|
||||
return { ok: true, message: 'append-ok' };
|
||||
},
|
||||
playPlaylistBrowserIndex: async (index) => {
|
||||
calls.push(['play', [index]]);
|
||||
return { ok: true, message: 'play-ok' };
|
||||
},
|
||||
removePlaylistBrowserIndex: async (index) => {
|
||||
calls.push(['remove', [index]]);
|
||||
return { ok: true, message: 'remove-ok' };
|
||||
},
|
||||
movePlaylistBrowserIndex: async (index, direction) => {
|
||||
calls.push(['move', [index, direction]]);
|
||||
return { ok: true, message: 'move-ok' };
|
||||
},
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
const snapshot = (await handlers.handle.get(IPC_CHANNELS.request.getPlaylistBrowserSnapshot)?.(
|
||||
{},
|
||||
)) as PlaylistBrowserSnapshot | undefined;
|
||||
const append = await handlers.handle.get(IPC_CHANNELS.request.appendPlaylistBrowserFile)?.(
|
||||
{},
|
||||
'/tmp/videos/ep3.mkv',
|
||||
);
|
||||
const play = await handlers.handle.get(IPC_CHANNELS.request.playPlaylistBrowserIndex)?.({}, 2);
|
||||
const remove = await handlers.handle.get(IPC_CHANNELS.request.removePlaylistBrowserIndex)?.(
|
||||
{},
|
||||
2,
|
||||
);
|
||||
const move = await handlers.handle.get(IPC_CHANNELS.request.movePlaylistBrowserIndex)?.(
|
||||
{},
|
||||
2,
|
||||
-1,
|
||||
);
|
||||
|
||||
assert.equal(snapshot?.playingIndex, 1);
|
||||
assert.deepEqual(append, { ok: true, message: 'append-ok' });
|
||||
assert.deepEqual(play, { ok: true, message: 'play-ok' });
|
||||
assert.deepEqual(remove, { ok: true, message: 'remove-ok' });
|
||||
assert.deepEqual(move, { ok: true, message: 'move-ok' });
|
||||
assert.deepEqual(calls, [
|
||||
['append', ['/tmp/videos/ep3.mkv']],
|
||||
['play', [2]],
|
||||
['remove', [2]],
|
||||
['move', [2, -1]],
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers rejects malformed runtime-option payloads', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: Array<{ id: string; value: unknown }> = [];
|
||||
@@ -311,6 +410,19 @@ test('registerIpcHandlers rejects malformed runtime-option payloads', async () =
|
||||
getAnilistQueueStatus: () => ({}),
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||
getPlaylistBrowserSnapshot: async () => ({
|
||||
directoryPath: null,
|
||||
directoryAvailable: false,
|
||||
directoryStatus: '',
|
||||
directoryItems: [],
|
||||
playlistItems: [],
|
||||
playingIndex: null,
|
||||
currentFilePath: null,
|
||||
}),
|
||||
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
|
||||
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
registrar,
|
||||
@@ -618,6 +730,19 @@ test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
getAnilistQueueStatus: () => ({}),
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||
getPlaylistBrowserSnapshot: async () => ({
|
||||
directoryPath: null,
|
||||
directoryAvailable: false,
|
||||
directoryStatus: '',
|
||||
directoryItems: [],
|
||||
playlistItems: [],
|
||||
playingIndex: null,
|
||||
currentFilePath: null,
|
||||
}),
|
||||
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
|
||||
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
registrar,
|
||||
@@ -685,6 +810,19 @@ test('registerIpcHandlers awaits saveControllerPreference through request-respon
|
||||
getAnilistQueueStatus: () => ({}),
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||
getPlaylistBrowserSnapshot: async () => ({
|
||||
directoryPath: null,
|
||||
directoryAvailable: false,
|
||||
directoryStatus: '',
|
||||
directoryItems: [],
|
||||
playlistItems: [],
|
||||
playingIndex: null,
|
||||
currentFilePath: null,
|
||||
}),
|
||||
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
|
||||
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
registrar,
|
||||
@@ -755,6 +893,19 @@ test('registerIpcHandlers rejects malformed controller preference payloads', asy
|
||||
getAnilistQueueStatus: () => ({}),
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||
getPlaylistBrowserSnapshot: async () => ({
|
||||
directoryPath: null,
|
||||
directoryAvailable: false,
|
||||
directoryStatus: '',
|
||||
directoryItems: [],
|
||||
playlistItems: [],
|
||||
playingIndex: null,
|
||||
currentFilePath: null,
|
||||
}),
|
||||
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
|
||||
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
registrar,
|
||||
|
||||
@@ -2,6 +2,8 @@ import electron from 'electron';
|
||||
import type { IpcMainEvent } from 'electron';
|
||||
import type {
|
||||
ControllerConfigUpdate,
|
||||
PlaylistBrowserMutationResult,
|
||||
PlaylistBrowserSnapshot,
|
||||
ControllerPreferenceUpdate,
|
||||
ResolvedControllerConfig,
|
||||
RuntimeOptionId,
|
||||
@@ -78,6 +80,14 @@ export interface IpcServiceDeps {
|
||||
getAnilistQueueStatus: () => unknown;
|
||||
retryAnilistQueueNow: () => Promise<{ ok: boolean; message: string }>;
|
||||
appendClipboardVideoToQueue: () => { ok: boolean; message: string };
|
||||
getPlaylistBrowserSnapshot: () => Promise<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?: {
|
||||
recordYomitanLookup: () => void;
|
||||
getSessionSummaries: (limit?: number) => Promise<unknown>;
|
||||
@@ -183,6 +193,14 @@ export interface IpcDepsRuntimeOptions {
|
||||
getAnilistQueueStatus: () => unknown;
|
||||
retryAnilistQueueNow: () => Promise<{ 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'];
|
||||
}
|
||||
|
||||
@@ -246,6 +264,11 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
getAnilistQueueStatus: options.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: options.retryAnilistQueueNow,
|
||||
appendClipboardVideoToQueue: options.appendClipboardVideoToQueue,
|
||||
getPlaylistBrowserSnapshot: options.getPlaylistBrowserSnapshot,
|
||||
appendPlaylistBrowserFile: options.appendPlaylistBrowserFile,
|
||||
playPlaylistBrowserIndex: options.playPlaylistBrowserIndex,
|
||||
removePlaylistBrowserIndex: options.removePlaylistBrowserIndex,
|
||||
movePlaylistBrowserIndex: options.movePlaylistBrowserIndex,
|
||||
get immersionTracker() {
|
||||
return options.getImmersionTracker?.() ?? null;
|
||||
},
|
||||
@@ -510,6 +533,44 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return deps.appendClipboardVideoToQueue();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.getPlaylistBrowserSnapshot, async () => {
|
||||
return await deps.getPlaylistBrowserSnapshot();
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.appendPlaylistBrowserFile, async (_event, filePath: unknown) => {
|
||||
if (typeof filePath !== 'string' || filePath.trim().length === 0) {
|
||||
return { ok: false, message: 'Invalid playlist browser file path.' };
|
||||
}
|
||||
return await deps.appendPlaylistBrowserFile(filePath);
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.playPlaylistBrowserIndex, async (_event, index: unknown) => {
|
||||
if (!Number.isSafeInteger(index) || (index as number) < 0) {
|
||||
return { ok: false, message: 'Invalid playlist browser index.' };
|
||||
}
|
||||
return await deps.playPlaylistBrowserIndex(index as number);
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.removePlaylistBrowserIndex, async (_event, index: unknown) => {
|
||||
if (!Number.isSafeInteger(index) || (index as number) < 0) {
|
||||
return { ok: false, message: 'Invalid playlist browser index.' };
|
||||
}
|
||||
return await deps.removePlaylistBrowserIndex(index as number);
|
||||
});
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.movePlaylistBrowserIndex,
|
||||
async (_event, index: unknown, direction: unknown) => {
|
||||
if (!Number.isSafeInteger(index) || (index as number) < 0) {
|
||||
return { ok: false, message: 'Invalid playlist browser index.' };
|
||||
}
|
||||
if (direction !== 1 && direction !== -1) {
|
||||
return { ok: false, message: 'Invalid playlist browser move direction.' };
|
||||
}
|
||||
return await deps.movePlaylistBrowserIndex(index as number, direction as 1 | -1);
|
||||
},
|
||||
);
|
||||
|
||||
// Stats request handlers
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetOverview, async () => {
|
||||
const tracker = deps.immersionTracker;
|
||||
|
||||
@@ -238,7 +238,7 @@ test('visible overlay stays hidden while a modal window is active', () => {
|
||||
assert.ok(!calls.includes('update-bounds'));
|
||||
});
|
||||
|
||||
test('macOS tracked visible overlay stays visible without passively stealing focus', () => {
|
||||
test('macOS tracked visible overlay stays click-through without passively stealing focus', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
@@ -270,7 +270,7 @@ test('macOS tracked visible overlay stays visible without passively stealing foc
|
||||
isWindowsPlatform: false,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('mouse-ignore:false:plain'));
|
||||
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||
assert.ok(calls.includes('show'));
|
||||
assert.ok(!calls.includes('focus'));
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
|
||||
const showPassiveVisibleOverlay = (): void => {
|
||||
const forceMousePassthrough = args.forceMousePassthrough === true;
|
||||
if (args.isWindowsPlatform || forceMousePassthrough) {
|
||||
if (args.isMacOSPlatform || args.isWindowsPlatform || forceMousePassthrough) {
|
||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
} else {
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
|
||||
23
src/main.ts
23
src/main.ts
@@ -31,6 +31,7 @@ import {
|
||||
screen,
|
||||
} from 'electron';
|
||||
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
|
||||
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
|
||||
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
||||
import { mergeAiConfig } from './ai/config';
|
||||
|
||||
@@ -427,6 +428,7 @@ import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
|
||||
import { createOverlayModalRuntimeService } from './main/overlay-runtime';
|
||||
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
|
||||
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
|
||||
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
|
||||
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
|
||||
import {
|
||||
createFrequencyDictionaryRuntimeService,
|
||||
@@ -1929,6 +1931,23 @@ function openRuntimeOptionsPalette(): void {
|
||||
overlayVisibilityComposer.openRuntimeOptionsPalette();
|
||||
}
|
||||
|
||||
function openPlaylistBrowser(): void {
|
||||
if (!appState.mpvClient?.connected) {
|
||||
showMpvOsd('Playlist browser requires active playback.');
|
||||
return;
|
||||
}
|
||||
const opened = openPlaylistBrowserRuntime({
|
||||
ensureOverlayStartupPrereqs: () => ensureOverlayStartupPrereqs(),
|
||||
ensureOverlayWindowsReadyForVisibilityActions: () =>
|
||||
ensureOverlayWindowsReadyForVisibilityActions(),
|
||||
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) =>
|
||||
sendToActiveOverlayWindow(channel, payload, runtimeOptions),
|
||||
});
|
||||
if (!opened) {
|
||||
showMpvOsd('Playlist browser overlay unavailable.');
|
||||
}
|
||||
}
|
||||
|
||||
function getResolvedConfig() {
|
||||
return configService.getConfig();
|
||||
}
|
||||
@@ -4109,11 +4128,14 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
|
||||
showMpvOsd: (text) => showMpvOsd(text),
|
||||
});
|
||||
|
||||
const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient);
|
||||
|
||||
const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
mpvCommandMainDeps: {
|
||||
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
|
||||
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
|
||||
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
|
||||
openPlaylistBrowser: () => openPlaylistBrowser(),
|
||||
cycleRuntimeOption: (id, direction) => {
|
||||
if (!appState.runtimeOptionsManager) {
|
||||
return { ok: false, error: 'Runtime options manager unavailable' };
|
||||
@@ -4290,6 +4312,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
|
||||
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
|
||||
appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(),
|
||||
...playlistBrowserMainDeps,
|
||||
getImmersionTracker: () => appState.immersionTracker,
|
||||
},
|
||||
ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({
|
||||
|
||||
@@ -93,6 +93,11 @@ export interface MainIpcRuntimeServiceDepsParams {
|
||||
getAnilistQueueStatus: IpcDepsRuntimeOptions['getAnilistQueueStatus'];
|
||||
retryAnilistQueueNow: IpcDepsRuntimeOptions['retryAnilistQueueNow'];
|
||||
appendClipboardVideoToQueue: IpcDepsRuntimeOptions['appendClipboardVideoToQueue'];
|
||||
getPlaylistBrowserSnapshot: IpcDepsRuntimeOptions['getPlaylistBrowserSnapshot'];
|
||||
appendPlaylistBrowserFile: IpcDepsRuntimeOptions['appendPlaylistBrowserFile'];
|
||||
playPlaylistBrowserIndex: IpcDepsRuntimeOptions['playPlaylistBrowserIndex'];
|
||||
removePlaylistBrowserIndex: IpcDepsRuntimeOptions['removePlaylistBrowserIndex'];
|
||||
movePlaylistBrowserIndex: IpcDepsRuntimeOptions['movePlaylistBrowserIndex'];
|
||||
getImmersionTracker?: IpcDepsRuntimeOptions['getImmersionTracker'];
|
||||
}
|
||||
|
||||
@@ -193,6 +198,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
|
||||
triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig'];
|
||||
openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette'];
|
||||
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
|
||||
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
|
||||
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
|
||||
mpvReplaySubtitle: HandleMpvCommandFromIpcOptions['mpvReplaySubtitle'];
|
||||
mpvPlayNextSubtitle: HandleMpvCommandFromIpcOptions['mpvPlayNextSubtitle'];
|
||||
@@ -247,6 +253,11 @@ export function createMainIpcRuntimeServiceDeps(
|
||||
getAnilistQueueStatus: params.getAnilistQueueStatus,
|
||||
retryAnilistQueueNow: params.retryAnilistQueueNow,
|
||||
appendClipboardVideoToQueue: params.appendClipboardVideoToQueue,
|
||||
getPlaylistBrowserSnapshot: params.getPlaylistBrowserSnapshot,
|
||||
appendPlaylistBrowserFile: params.appendPlaylistBrowserFile,
|
||||
playPlaylistBrowserIndex: params.playPlaylistBrowserIndex,
|
||||
removePlaylistBrowserIndex: params.removePlaylistBrowserIndex,
|
||||
movePlaylistBrowserIndex: params.movePlaylistBrowserIndex,
|
||||
getImmersionTracker: params.getImmersionTracker,
|
||||
};
|
||||
}
|
||||
@@ -358,6 +369,7 @@ export function createMpvCommandRuntimeServiceDeps(
|
||||
triggerSubsyncFromConfig: params.triggerSubsyncFromConfig,
|
||||
openRuntimeOptionsPalette: params.openRuntimeOptionsPalette,
|
||||
openYoutubeTrackPicker: params.openYoutubeTrackPicker,
|
||||
openPlaylistBrowser: params.openPlaylistBrowser,
|
||||
runtimeOptionsCycle: params.runtimeOptionsCycle,
|
||||
showMpvOsd: params.showMpvOsd,
|
||||
mpvReplaySubtitle: params.mpvReplaySubtitle,
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface MpvCommandFromIpcRuntimeDeps {
|
||||
triggerSubsyncFromConfig: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||
openPlaylistBrowser: () => void | Promise<void>;
|
||||
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
showMpvOsd: (text: string) => void;
|
||||
replayCurrentSubtitle: () => void;
|
||||
@@ -35,6 +36,7 @@ export function handleMpvCommandFromIpcRuntime(
|
||||
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
|
||||
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
|
||||
openYoutubeTrackPicker: deps.openYoutubeTrackPicker,
|
||||
openPlaylistBrowser: deps.openPlaylistBrowser,
|
||||
runtimeOptionsCycle: deps.cycleRuntimeOption,
|
||||
showMpvOsd: deps.showMpvOsd,
|
||||
mpvReplaySubtitle: deps.replayCurrentSubtitle,
|
||||
|
||||
@@ -125,3 +125,54 @@ test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirel
|
||||
await handler();
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('createMaybeRunAnilistPostWatchUpdateHandler does not live-update after retry already handled current attempt key', async () => {
|
||||
const calls: string[] = [];
|
||||
const attemptedKeys = new Set<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']);
|
||||
});
|
||||
|
||||
@@ -165,6 +165,9 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
||||
deps.setInFlight(true);
|
||||
try {
|
||||
await deps.processNextAnilistRetryUpdate();
|
||||
if (deps.hasAttemptedUpdateKey(attemptKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const accessToken = await deps.refreshAnilistClientSecretState();
|
||||
if (!accessToken) {
|
||||
|
||||
@@ -11,6 +11,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openYoutubeTrackPicker: () => {},
|
||||
openPlaylistBrowser: () => {},
|
||||
cycleRuntimeOption: () => ({ ok: true }),
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
@@ -68,6 +69,20 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
|
||||
getAnilistQueueStatus: () => ({}) as never,
|
||||
retryAnilistQueueNow: async () => ({ ok: true, message: 'ok' }),
|
||||
appendClipboardVideoToQueue: () => ({ ok: true, message: 'ok' }),
|
||||
getPlaylistBrowserSnapshot: async () =>
|
||||
({
|
||||
directoryPath: null,
|
||||
directoryAvailable: false,
|
||||
directoryStatus: '',
|
||||
directoryItems: [],
|
||||
playlistItems: [],
|
||||
playingIndex: null,
|
||||
currentFilePath: null,
|
||||
}) as never,
|
||||
appendPlaylistBrowserFile: async () => ({ ok: true, message: 'ok' }),
|
||||
playPlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
removePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
movePlaylistBrowserIndex: async () => ({ ok: true, message: 'ok' }),
|
||||
onYoutubePickerResolve: async () => ({ ok: true, message: 'ok' }),
|
||||
},
|
||||
ankiJimakuDeps: {
|
||||
|
||||
@@ -14,6 +14,7 @@ test('ipc bridge action main deps builders map callbacks', async () => {
|
||||
triggerSubsyncFromConfig: async () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openYoutubeTrackPicker: () => {},
|
||||
openPlaylistBrowser: () => {},
|
||||
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
|
||||
@@ -11,6 +11,7 @@ test('handle mpv command handler forwards command and built deps', () => {
|
||||
triggerSubsyncFromConfig: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
openYoutubeTrackPicker: () => {},
|
||||
openPlaylistBrowser: () => {},
|
||||
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
||||
showMpvOsd: () => {},
|
||||
replayCurrentSubtitle: () => {},
|
||||
|
||||
@@ -10,6 +10,9 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
openYoutubeTrackPicker: () => {
|
||||
calls.push('youtube-picker');
|
||||
},
|
||||
openPlaylistBrowser: () => {
|
||||
calls.push('playlist-browser');
|
||||
},
|
||||
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
|
||||
showMpvOsd: (text) => calls.push(`osd:${text}`),
|
||||
replayCurrentSubtitle: () => calls.push('replay'),
|
||||
@@ -26,6 +29,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
deps.triggerSubsyncFromConfig();
|
||||
deps.openRuntimeOptionsPalette();
|
||||
void deps.openYoutubeTrackPicker();
|
||||
void deps.openPlaylistBrowser();
|
||||
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
|
||||
deps.showMpvOsd('hello');
|
||||
deps.replayCurrentSubtitle();
|
||||
@@ -39,6 +43,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
|
||||
'subsync',
|
||||
'palette',
|
||||
'youtube-picker',
|
||||
'playlist-browser',
|
||||
'osd:hello',
|
||||
'replay',
|
||||
'next',
|
||||
|
||||
@@ -7,6 +7,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
|
||||
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
|
||||
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
|
||||
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
|
||||
openPlaylistBrowser: () => deps.openPlaylistBrowser(),
|
||||
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
|
||||
showMpvOsd: (text: string) => deps.showMpvOsd(text),
|
||||
replayCurrentSubtitle: () => deps.replayCurrentSubtitle(),
|
||||
|
||||
46
src/main/runtime/playlist-browser-ipc.ts
Normal file
46
src/main/runtime/playlist-browser-ipc.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
28
src/main/runtime/playlist-browser-open.test.ts
Normal file
28
src/main/runtime/playlist-browser-open.test.ts
Normal 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}`]);
|
||||
});
|
||||
23
src/main/runtime/playlist-browser-open.ts
Normal file
23
src/main/runtime/playlist-browser-open.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
487
src/main/runtime/playlist-browser-runtime.test.ts
Normal file
487
src/main/runtime/playlist-browser-runtime.test.ts
Normal 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'],
|
||||
],
|
||||
);
|
||||
});
|
||||
361
src/main/runtime/playlist-browser-runtime.ts
Normal file
361
src/main/runtime/playlist-browser-runtime.ts
Normal 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);
|
||||
}
|
||||
50
src/main/runtime/playlist-browser-sort.test.ts
Normal file
50
src/main/runtime/playlist-browser-sort.test.ts
Normal 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],
|
||||
);
|
||||
});
|
||||
129
src/main/runtime/playlist-browser-sort.ts
Normal file
129
src/main/runtime/playlist-browser-sort.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
@@ -38,6 +38,8 @@ import type {
|
||||
SubsyncManualRunRequest,
|
||||
SubsyncResult,
|
||||
ClipboardAppendResult,
|
||||
PlaylistBrowserMutationResult,
|
||||
PlaylistBrowserSnapshot,
|
||||
KikuFieldGroupingRequestData,
|
||||
KikuFieldGroupingChoice,
|
||||
KikuMergePreviewRequest,
|
||||
@@ -126,6 +128,7 @@ const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload<Youtube
|
||||
IPC_CHANNELS.event.youtubePickerOpen,
|
||||
(payload) => payload as YoutubePickerOpenPayload,
|
||||
);
|
||||
const onOpenPlaylistBrowserEvent = createQueuedIpcListener(IPC_CHANNELS.event.playlistBrowserOpen);
|
||||
const onCancelYoutubeTrackPickerEvent = createQueuedIpcListener(
|
||||
IPC_CHANNELS.event.youtubePickerCancel,
|
||||
);
|
||||
@@ -322,11 +325,25 @@ const electronAPI: ElectronAPI = {
|
||||
onOpenRuntimeOptions: onOpenRuntimeOptionsEvent,
|
||||
onOpenJimaku: onOpenJimakuEvent,
|
||||
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
|
||||
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
|
||||
onCancelYoutubeTrackPicker: onCancelYoutubeTrackPickerEvent,
|
||||
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
|
||||
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
||||
appendClipboardVideoToQueue: (): Promise<ClipboardAppendResult> =>
|
||||
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: (
|
||||
request: YoutubePickerResolveRequest,
|
||||
): Promise<YoutubePickerResolveResult> =>
|
||||
|
||||
@@ -294,6 +294,7 @@ function createKeyboardHandlerHarness() {
|
||||
let controllerSelectOpenCount = 0;
|
||||
let controllerDebugOpenCount = 0;
|
||||
let controllerSelectKeydownCount = 0;
|
||||
let playlistBrowserKeydownCount = 0;
|
||||
|
||||
const createWordNode = (left: number) => ({
|
||||
classList: createClassList(),
|
||||
@@ -333,6 +334,10 @@ function createKeyboardHandlerHarness() {
|
||||
},
|
||||
handleControllerDebugKeydown: () => false,
|
||||
handleYoutubePickerKeydown: () => false,
|
||||
handlePlaylistBrowserKeydown: () => {
|
||||
playlistBrowserKeydownCount += 1;
|
||||
return true;
|
||||
},
|
||||
handleSessionHelpKeydown: () => false,
|
||||
openSessionHelpModal: () => {},
|
||||
appendClipboardVideoToQueue: () => {},
|
||||
@@ -352,6 +357,7 @@ function createKeyboardHandlerHarness() {
|
||||
controllerSelectOpenCount: () => controllerSelectOpenCount,
|
||||
controllerDebugOpenCount: () => controllerDebugOpenCount,
|
||||
controllerSelectKeydownCount: () => controllerSelectKeydownCount,
|
||||
playlistBrowserKeydownCount: () => playlistBrowserKeydownCount,
|
||||
setWordCount: (count: number) => {
|
||||
wordNodes = Array.from({ length: count }, (_, index) => createWordNode(10 + index * 70));
|
||||
},
|
||||
@@ -623,6 +629,49 @@ test('keyboard mode: controller select modal handles arrow keys before yomitan p
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: playlist browser modal handles arrow keys before yomitan popup', async () => {
|
||||
const { ctx, testGlobals, handlers, playlistBrowserKeydownCount } =
|
||||
createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
ctx.state.playlistBrowserModalOpen = true;
|
||||
ctx.state.yomitanPopupVisible = true;
|
||||
testGlobals.setPopupVisible(true);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'ArrowDown', code: 'ArrowDown' });
|
||||
|
||||
assert.equal(playlistBrowserKeydownCount(), 1);
|
||||
assert.equal(
|
||||
testGlobals.commandEvents.some(
|
||||
(event) => event.type === 'forwardKeyDown' && event.code === 'ArrowDown',
|
||||
),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: playlist browser modal handles h before lookup controls', async () => {
|
||||
const { ctx, testGlobals, handlers, playlistBrowserKeydownCount } =
|
||||
createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.handleKeyboardModeToggleRequested();
|
||||
ctx.state.playlistBrowserModalOpen = true;
|
||||
ctx.state.keyboardSelectedWordIndex = 2;
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'h', code: 'KeyH' });
|
||||
|
||||
assert.equal(playlistBrowserKeydownCount(), 1);
|
||||
assert.equal(ctx.state.keyboardSelectedWordIndex, 2);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard mode: configured stats toggle works even while popup is open', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export function createKeyboardHandlers(
|
||||
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
|
||||
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleControllerDebugKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleSessionHelpKeydown: (e: KeyboardEvent) => boolean;
|
||||
@@ -815,6 +816,12 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.state.playlistBrowserModalOpen) {
|
||||
if (options.handlePlaylistBrowserKeydown(e)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (handleKeyboardDrivenModeLookupControls(e)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
|
||||
@@ -320,6 +320,35 @@
|
||||
</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>
|
||||
<script type="module" src="renderer.js"></script>
|
||||
</body>
|
||||
|
||||
144
src/renderer/modals/playlist-browser-renderer.ts
Normal file
144
src/renderer/modals/playlist-browser-renderer.ts
Normal 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;
|
||||
}
|
||||
659
src/renderer/modals/playlist-browser.test.ts
Normal file
659
src/renderer/modals/playlist-browser.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
407
src/renderer/modals/playlist-browser.ts
Normal file
407
src/renderer/modals/playlist-browser.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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.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.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
|
||||
if (first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)) {
|
||||
@@ -164,6 +165,7 @@ function sectionForCommand(command: (string | number)[]): string {
|
||||
|
||||
if (
|
||||
first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN ||
|
||||
first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN ||
|
||||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
|
||||
) {
|
||||
return 'Runtime settings';
|
||||
|
||||
@@ -33,6 +33,7 @@ import { createControllerDebugModal } from './modals/controller-debug.js';
|
||||
import { createControllerSelectModal } from './modals/controller-select.js';
|
||||
import { createJimakuModal } from './modals/jimaku.js';
|
||||
import { createKikuModal } from './modals/kiku.js';
|
||||
import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
|
||||
import { createSessionHelpModal } from './modals/session-help.js';
|
||||
import { createSubtitleSidebarModal } from './modals/subtitle-sidebar.js';
|
||||
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
|
||||
@@ -71,7 +72,8 @@ function isAnySettingsModalOpen(): boolean {
|
||||
ctx.state.kikuModalOpen ||
|
||||
ctx.state.jimakuModalOpen ||
|
||||
ctx.state.youtubePickerModalOpen ||
|
||||
ctx.state.sessionHelpModalOpen
|
||||
ctx.state.sessionHelpModalOpen ||
|
||||
ctx.state.playlistBrowserModalOpen
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,6 +87,7 @@ function isAnyModalOpen(): boolean {
|
||||
ctx.state.subsyncModalOpen ||
|
||||
ctx.state.youtubePickerModalOpen ||
|
||||
ctx.state.sessionHelpModalOpen ||
|
||||
ctx.state.playlistBrowserModalOpen ||
|
||||
ctx.state.subtitleSidebarModalOpen
|
||||
);
|
||||
}
|
||||
@@ -153,12 +156,17 @@ const youtubePickerModal = createYoutubeTrackPickerModal(ctx, {
|
||||
restorePointerInteractionState: mouseHandlers.restorePointerInteractionState,
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const playlistBrowserModal = createPlaylistBrowserModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
||||
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
||||
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
||||
handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
|
||||
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
|
||||
handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown,
|
||||
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
|
||||
handleControllerDebugKeydown: controllerDebugModal.handleControllerDebugKeydown,
|
||||
handleSessionHelpKeydown: sessionHelpModal.handleSessionHelpKeydown,
|
||||
@@ -209,6 +217,7 @@ function getActiveModal(): string | null {
|
||||
if (ctx.state.subtitleSidebarModalOpen) return 'subtitle-sidebar';
|
||||
if (ctx.state.jimakuModalOpen) return 'jimaku';
|
||||
if (ctx.state.youtubePickerModalOpen) return 'youtube-track-picker';
|
||||
if (ctx.state.playlistBrowserModalOpen) return 'playlist-browser';
|
||||
if (ctx.state.kikuModalOpen) return 'kiku';
|
||||
if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options';
|
||||
if (ctx.state.subsyncModalOpen) return 'subsync';
|
||||
@@ -232,6 +241,9 @@ function dismissActiveUiAfterError(): void {
|
||||
if (ctx.state.youtubePickerModalOpen) {
|
||||
youtubePickerModal.closeYoutubePickerModal();
|
||||
}
|
||||
if (ctx.state.playlistBrowserModalOpen) {
|
||||
playlistBrowserModal.closePlaylistBrowserModal();
|
||||
}
|
||||
if (ctx.state.runtimeOptionsModalOpen) {
|
||||
runtimeOptionsModal.closeRuntimeOptionsModal();
|
||||
}
|
||||
@@ -439,6 +451,11 @@ function registerModalOpenHandlers(): void {
|
||||
youtubePickerModal.openYoutubePickerModal(payload);
|
||||
});
|
||||
});
|
||||
window.electronAPI.onOpenPlaylistBrowser(() => {
|
||||
runGuardedAsync('playlist-browser:open', async () => {
|
||||
await playlistBrowserModal.openPlaylistBrowserModal();
|
||||
});
|
||||
});
|
||||
window.electronAPI.onCancelYoutubeTrackPicker(() => {
|
||||
runGuarded('youtube:picker-cancel', () => {
|
||||
youtubePickerModal.closeYoutubePickerModal();
|
||||
@@ -518,6 +535,11 @@ async function init(): Promise<void> {
|
||||
runGuarded('subtitle-position:update', () => {
|
||||
positioning.applyStoredSubtitlePosition(position, 'media-change');
|
||||
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();
|
||||
youtubePickerModal.wireDomEvents();
|
||||
playlistBrowserModal.wireDomEvents();
|
||||
kikuModal.wireDomEvents();
|
||||
runtimeOptionsModal.wireDomEvents();
|
||||
subsyncModal.wireDomEvents();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
PlaylistBrowserSnapshot,
|
||||
ControllerButtonSnapshot,
|
||||
ControllerDeviceInfo,
|
||||
ResolvedControllerConfig,
|
||||
@@ -78,6 +79,12 @@ export type RendererState = {
|
||||
|
||||
sessionHelpModalOpen: boolean;
|
||||
sessionHelpSelectedIndex: number;
|
||||
playlistBrowserModalOpen: boolean;
|
||||
playlistBrowserSnapshot: PlaylistBrowserSnapshot | null;
|
||||
playlistBrowserStatus: string;
|
||||
playlistBrowserActivePane: 'directory' | 'playlist';
|
||||
playlistBrowserSelectedDirectoryIndex: number;
|
||||
playlistBrowserSelectedPlaylistIndex: number;
|
||||
subtitleSidebarCues: SubtitleCue[];
|
||||
subtitleSidebarActiveCueIndex: number;
|
||||
subtitleSidebarToggleKey: string;
|
||||
@@ -175,6 +182,12 @@ export function createRendererState(): RendererState {
|
||||
|
||||
sessionHelpModalOpen: false,
|
||||
sessionHelpSelectedIndex: 0,
|
||||
playlistBrowserModalOpen: false,
|
||||
playlistBrowserSnapshot: null,
|
||||
playlistBrowserStatus: '',
|
||||
playlistBrowserActivePane: 'playlist',
|
||||
playlistBrowserSelectedDirectoryIndex: 0,
|
||||
playlistBrowserSelectedPlaylistIndex: 0,
|
||||
subtitleSidebarCues: [],
|
||||
subtitleSidebarActiveCueIndex: -1,
|
||||
subtitleSidebarToggleKey: 'Backslash',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -96,6 +96,13 @@ export type RendererDom = {
|
||||
sessionHelpStatus: HTMLDivElement;
|
||||
sessionHelpFilter: HTMLInputElement;
|
||||
sessionHelpContent: HTMLDivElement;
|
||||
|
||||
playlistBrowserModal: HTMLDivElement;
|
||||
playlistBrowserTitle: HTMLDivElement;
|
||||
playlistBrowserStatus: HTMLDivElement;
|
||||
playlistBrowserDirectoryList: HTMLUListElement;
|
||||
playlistBrowserPlaylistList: HTMLUListElement;
|
||||
playlistBrowserClose: HTMLButtonElement;
|
||||
};
|
||||
|
||||
function getRequiredElement<T extends HTMLElement>(id: string): T {
|
||||
@@ -211,5 +218,12 @@ export function resolveRendererDom(): RendererDom {
|
||||
sessionHelpStatus: getRequiredElement<HTMLDivElement>('sessionHelpStatus'),
|
||||
sessionHelpFilter: getRequiredElement<HTMLInputElement>('sessionHelpFilter'),
|
||||
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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export const OVERLAY_HOSTED_MODALS = [
|
||||
'subsync',
|
||||
'jimaku',
|
||||
'youtube-track-picker',
|
||||
'playlist-browser',
|
||||
'kiku',
|
||||
'controller-select',
|
||||
'controller-debug',
|
||||
@@ -67,6 +68,11 @@ export const IPC_CHANNELS = {
|
||||
getAnilistQueueStatus: 'anilist:get-queue-status',
|
||||
retryAnilistNow: 'anilist:retry-now',
|
||||
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',
|
||||
jimakuSearchEntries: 'jimaku:search-entries',
|
||||
jimakuListFiles: 'jimaku:list-files',
|
||||
@@ -100,6 +106,7 @@ export const IPC_CHANNELS = {
|
||||
jimakuOpen: 'jimaku:open',
|
||||
youtubePickerOpen: 'youtube:picker-open',
|
||||
youtubePickerCancel: 'youtube:picker-cancel',
|
||||
playlistBrowserOpen: 'playlist-browser:open',
|
||||
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
|
||||
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
||||
configHotReload: 'config:hot-reload',
|
||||
|
||||
@@ -76,6 +76,40 @@ export interface SubsyncResult {
|
||||
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 =
|
||||
| 'none'
|
||||
| 'select'
|
||||
@@ -354,10 +388,19 @@ export interface ElectronAPI {
|
||||
onOpenRuntimeOptions: (callback: () => void) => void;
|
||||
onOpenJimaku: (callback: () => void) => void;
|
||||
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
|
||||
onOpenPlaylistBrowser: (callback: () => void) => void;
|
||||
onCancelYoutubeTrackPicker: (callback: () => void) => void;
|
||||
onKeyboardModeToggleRequested: (callback: () => void) => void;
|
||||
onLookupWindowToggleRequested: (callback: () => void) => void;
|
||||
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: (
|
||||
request: YoutubePickerResolveRequest,
|
||||
) => Promise<YoutubePickerResolveResult>;
|
||||
@@ -367,6 +410,7 @@ export interface ElectronAPI {
|
||||
| 'subsync'
|
||||
| 'jimaku'
|
||||
| 'youtube-track-picker'
|
||||
| 'playlist-browser'
|
||||
| 'kiku'
|
||||
| 'controller-select'
|
||||
| 'controller-debug'
|
||||
@@ -378,6 +422,7 @@ export interface ElectronAPI {
|
||||
| 'subsync'
|
||||
| 'jimaku'
|
||||
| 'youtube-track-picker'
|
||||
| 'playlist-browser'
|
||||
| 'kiku'
|
||||
| 'controller-select'
|
||||
| 'controller-debug'
|
||||
|
||||
Reference in New Issue
Block a user