Compare commits

..

16 Commits

Author SHA1 Message Date
06708c9882 Harden playlist browser test cleanup and keydown fixture
- Wrap injected global cleanup assertions in `try/finally`
- Return the post-append mutation snapshot before Ctrl+ArrowDown coverage
2026-03-30 23:24:50 -07:00
ff760eaa32 fix: address CodeRabbit PR feedback 2026-03-30 22:47:27 -07:00
13680af3f6 Preserve playlist browser selection across refreshes
- keep modal selection stable when playlist snapshots mutate
- align test clock and timestamp fixtures with db helpers
- add regression coverage for selection and time parsing
2026-03-30 22:16:21 -07:00
e16250dedc Use shared local-day helpers in immersion tracker tests
- derive midnight and week-boundary timestamps from query-shared utilities
- keep split-module coverage aligned with db-local time handling
2026-03-30 19:59:45 -07:00
6118c46192 Fix immersion tracker SQLite timestamp truncation
- Bind epoch ms values as text to avoid libsql numeric truncation
- Update retention, lifetime, and query tests for string timestamps
- Add backlog ticket for the SQLite timestamp bug
2026-03-30 19:52:18 -07:00
c8e42b3973 refactor: split playlist browser wiring 2026-03-30 18:43:41 -07:00
90772f994c fix: restore macos overlay passthrough 2026-03-30 18:10:43 -07:00
c471bdf554 update backlog 2026-03-30 18:00:03 -07:00
f901433eea fix: address playlist browser coderabbit feedback 2026-03-30 17:50:39 -07:00
6ae3888b53 feat: add playlist browser overlay modal
- Add overlay modal for browsing sibling video files and live mpv queue
- Add IPC commands for playlist operations (add, remove, move, play)
- Add playlist-browser-runtime and playlist-browser-sort modules
- Add keyboard handler and preload bindings for playlist browser
- Add default Ctrl+Alt+P keybinding to open the modal
- Add HTML structure, renderer wiring, and state for the modal
- Add changelog fragment and docs updates
2026-03-30 01:50:38 -07:00
6e041bc68e style: fix modal background colors to use Catppuccin Macchiato values
- Playlist browser outer container: Base (#24273a) → Mantle (#1e2030)
  gradient instead of near-black values
- Playlist browser panes: Surface0 (#363a4f) → Base gradient for visible
  layering against the outer background
- Playlist browser list: Mantle (#1e2030) instead of near-black (#0c0e18)
2026-03-30 01:46:53 -07:00
8db89c2239 style: apply Catppuccin Macchiato palette to all modals
- Add full Catppuccin Macchiato CSS variables to :root (--ctp-*)
- Replace all generic rgba(255,255,255,...) and ad-hoc accent colors
  in modal components with shared --ctp-* variables
- Affected modals: jimaku, kiku, subsync, runtime options, controller
  select/config/debug, session help, YouTube picker, playlist browser,
  subtitle sidebar
- Shared button classes (.kiku-confirm-button, .kiku-cancel-button,
  .btn-learn, .btn-secondary) also updated
2026-03-30 01:40:25 -07:00
f9a4039ad2 chore: add backlog task records 2026-03-30 00:30:18 -07:00
8e5c21b443 fix: restore integrated texthooker startup 2026-03-30 00:25:30 -07:00
55b350c3a2 Fix AniList token persistence and AVIF timing
- Defer AniList setup prompts until app-ready and reuse stored tokens
- Add AVIF lead-in padding so motion stays aligned with sentence audio
2026-03-29 22:07:15 -07:00
54324df3be fix(release): make AUR publish best-effort 2026-03-29 16:51:22 -07:00
96 changed files with 5257 additions and 721 deletions

View File

@@ -409,33 +409,64 @@ jobs:
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Validate AUR SSH secret
- name: Check AUR publish prerequisites
id: aur_prereqs
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: |
set -euo pipefail
if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then
echo "Missing required secret: AUR_SSH_PRIVATE_KEY"
exit 1
echo "::warning::Missing AUR_SSH_PRIVATE_KEY; skipping automated AUR publish."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Configure SSH for AUR
id: aur_ssh
if: steps.aur_prereqs.outputs.skip != 'true'
env:
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
run: |
set -euo pipefail
install -dm700 ~/.ssh
printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur
chmod 600 ~/.ssh/aur
ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
if install -dm700 ~/.ssh \
&& printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur \
&& chmod 600 ~/.ssh/aur \
&& ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts \
&& chmod 644 ~/.ssh/known_hosts; then
echo "skip=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::warning::Unable to configure SSH for AUR; skipping automated AUR publish."
echo "skip=true" >> "$GITHUB_OUTPUT"
- name: Clone AUR repo
id: aur_clone
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true'
env:
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
run: git clone ssh://aur@aur.archlinux.org/subminer-bin.git aur-subminer-bin
run: |
set -euo pipefail
attempts=3
for attempt in $(seq 1 "$attempts"); do
if git clone ssh://aur@aur.archlinux.org/subminer-bin.git aur-subminer-bin; then
echo "skip=false" >> "$GITHUB_OUTPUT"
exit 0
fi
rm -rf aur-subminer-bin
if [ "$attempt" -lt "$attempts" ]; then
sleep $((attempt * 15))
fi
done
echo "::warning::Unable to clone subminer-bin from AUR after ${attempts} attempts; skipping automated AUR publish."
echo "skip=true" >> "$GITHUB_OUTPUT"
- name: Download release assets for AUR
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
@@ -449,6 +480,7 @@ jobs:
--pattern "subminer-assets.tar.gz"
- name: Update AUR packaging metadata
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
run: |
set -euo pipefail
version_no_v="${{ steps.version.outputs.VERSION }}"
@@ -463,6 +495,7 @@ jobs:
--assets ".tmp/aur-release-assets/subminer-assets.tar.gz"
- name: Commit and push AUR update
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
working-directory: aur-subminer-bin
env:
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
@@ -476,4 +509,16 @@ jobs:
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add PKGBUILD .SRCINFO
git commit -m "Update to ${{ steps.version.outputs.VERSION }}"
git push origin HEAD:master
attempts=3
for attempt in $(seq 1 "$attempts"); do
if git push origin HEAD:master; then
exit 0
fi
if [ "$attempt" -lt "$attempts" ]; then
sleep $((attempt * 15))
fi
done
echo "::warning::Unable to push the AUR update after ${attempts} attempts; GitHub release is published, but subminer-bin needs manual follow-up."

View File

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

View File

@@ -0,0 +1,35 @@
---
id: TASK-252
title: Harden AUR publish release step against transient SSH failures
status: Done
assignee: []
created_date: '2026-03-29 23:46'
updated_date: '2026-03-29 23:49'
labels:
- release
- ci
- aur
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Make tagged releases resilient when the automated AUR update hits transient SSH disconnects from GitHub-hosted runners. The GitHub Release should still complete successfully, while AUR publish should retry a few times and downgrade persistent AUR failures to warnings instead of failing the entire release workflow.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Tagged release workflow retries the AUR clone/push path with bounded backoff when AUR SSH disconnects transiently.
- [x] #2 Persistent AUR publish failure does not fail the overall tagged release workflow or block GitHub Release publication.
- [x] #3 Release documentation notes that AUR publish is best-effort and may need manual follow-up when retries are exhausted.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Updated .github/workflows/release.yml so AUR secret/configure/clone/push failures downgrade to warnings, clone/push retry three times with linear backoff, and the GitHub Release path remains green.
Documented AUR publish as best-effort in docs/RELEASING.md and added changes/253-aur-release-best-effort.md for PR changelog compliance.
<!-- SECTION:NOTES:END -->

View File

@@ -0,0 +1,68 @@
---
id: TASK-253
title: Fix animated AVIF lead-in alignment with sentence audio
status: Done
assignee:
- codex
created_date: '2026-03-30 01:59'
updated_date: '2026-03-30 02:03'
labels: []
dependencies: []
references:
- >-
/Users/sudacode/projects/japanese/SubMiner/src/anki-integration/animated-image-sync.ts
- /Users/sudacode/projects/japanese/SubMiner/src/anki-integration.ts
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/stats-server.ts
- /Users/sudacode/projects/japanese/SubMiner/src/media-generator.ts
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Animated AVIF cards currently freeze only for the existing word-audio duration. Because generated sentence audio starts with configured audio padding before the spoken subtitle begins, animation motion can begin early instead of lining up with the spoken sentence. Update the shared lead-in calculation so animated motion begins when sentence speech begins after the chosen word audio finishes.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Animated AVIF lead-in calculation includes both the chosen word-audio duration and the generated sentence-audio start offset so motion begins with spoken sentence audio
- [x] #2 Shared animated-image sync behavior is applied consistently across the Anki note update, card creation, and stats server media-generation paths
- [x] #3 Regression tests cover the corrected lead-in timing calculation and fail before the fix
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
Approved plan:
1. Add a failing unit test proving animated-image lead-in must include sentence-audio start offset in addition to chosen word-audio duration.
2. Update shared animated-image lead-in resolution to add the configured sentence-audio offset used by generated sentence audio.
3. Thread the shared calculation through note update, card creation, and stats-server generation paths without duplicating timing logic.
4. Run targeted tests first, then the relevant fast verification lane for touched files.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
User approved implementation on 2026-03-29 local time. Root cause: lead-in omitted sentence-audio padding offset, so AVIF motion began before spoken sentence audio.
Implemented shared animated-image lead-in fix in src/anki-integration/animated-image-sync.ts by adding the same sentence-audio start offset used by generated audio (`audioPadding`) after summing the chosen word-audio durations.
Added regression coverage in src/anki-integration/animated-image-sync.test.ts for explicit `audioPadding` lead-in alignment and kept the zero-padding case covered.
Verification passed: `bun test src/anki-integration/animated-image-sync.test.ts src/anki-integration/note-update-workflow.test.ts src/media-generator.test.ts`, `bun run typecheck`, `bun run test:fast`, `bun run test:env`, `bun run build`, `bun run test:smoke:dist`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed animated AVIF lead-in alignment so motion starts when the spoken sentence starts, not at the padded beginning of the generated sentence-audio clip. The shared resolver in `src/anki-integration/animated-image-sync.ts` now adds the configured/default `audioPadding` offset after summing the selected word-audio durations, which keeps note update, card creation, and stats-server generation paths aligned through the same logic.
Added regression coverage in `src/anki-integration/animated-image-sync.test.ts` for both zero-padding and explicit padding cases to prove the lead-in math matches sentence-audio timing.
Verification:
- `bun test src/anki-integration/animated-image-sync.test.ts src/anki-integration/note-update-workflow.test.ts src/media-generator.test.ts`
- `bun run typecheck`
- `bun run test:fast`
- `bun run test:env`
- `bun run build`
- `bun run test:smoke:dist`
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,59 @@
---
id: TASK-254
title: Fix AniList token persistence when safe storage is unavailable
status: Done
assignee:
- codex
created_date: '2026-03-30 02:10'
updated_date: '2026-03-30 02:20'
labels:
- bug
- anilist
dependencies: []
references:
- >-
/Users/sudacode/projects/japanese/SubMiner/src/core/services/anilist/anilist-token-store.ts
- /Users/sudacode/projects/japanese/SubMiner/src/main/runtime/anilist-setup.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/main/runtime/anilist-token-refresh.ts
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
AniList login currently appears to succeed during setup, but some environments cannot persist the token because Electron safeStorage is unavailable or unusable. On the next app start, AniList tracking cannot load the token and re-prompts the user to set up AniList again. Align AniList token persistence with the intended login UX so a token the user already saved is reused on later sessions.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Saved encrypted AniList token is reused on app-ready startup without reopening setup.
- [x] #2 AniList startup no longer attempts to open the setup BrowserWindow before Electron is ready.
- [x] #3 AniList auth/runtime tests cover stored-token reuse and the missing-token startup path that previously triggered pre-ready setup attempts.
- [x] #4 AniList token storage remains encrypted-only; no plaintext fallback is introduced.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add regression tests for AniList startup auth refresh so a stored encrypted token is reused without opening setup, and for the missing-token path so setup opening is deferred safely until the app can actually show a window.
2. Update AniList startup/auth runtime to separate token resolution from setup-window prompting, and gate prompting on app readiness instead of attempting BrowserWindow creation during early startup.
3. Preserve encrypted-only storage semantics in anilist-token-store; do not add plaintext fallback. If stored-token load fails, keep logging/diagnostics intact.
4. Run targeted AniList runtime/token tests, then summarize root cause and verification results.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Investigated AniList auth persistence flow. Current setup path treats callback token as saved even when anilist-token-store refuses persistence because safeStorage is unavailable. Jellyfin token store already uses plaintext fallback in this environment class, which is a likely model for the AniList fix.
Confirmed from local logs that safeStorage was explicitly unavailable on 2026-03-23 due macOS Keychain lookup failure with NSOSStatusErrorDomain Code=-128 userCanceledErr. Current environment also has an encrypted AniList token file at /Users/sudacode/.config/SubMiner/anilist-token-store.json updated 2026-03-29 18:49, so safeStorage did work recently for save. Repeated AniList setup prompts on 2026-03-29/30 correlate more strongly with startup auth flow deciding no token is available and opening setup immediately; logs show repeated 'Loaded AniList manual token entry page' and several 'Failed to refresh AniList client secret state during startup' errors with 'Cannot create BrowserWindow before app is ready'. No recent log lines indicate safeStorage.isEncryptionAvailable() false after 2026-03-23.
Implemented encrypted-only startup fix by adding an allowSetupPrompt control to AniList token refresh and disabling setup-window prompting for the early pre-ready startup refresh in main.ts. App-ready reloadConfig still performs the normal prompt-capable refresh after Electron is ready. Added regression tests for stored-token reuse and prompt suppression when startup explicitly disables prompting.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Root cause was a redundant early AniList auth refresh during startup. The app refreshed AniList auth once before Electron was ready and again during app-ready config reload. When the early refresh could not resolve a token, it tried to open the AniList setup window immediately, which produced the observed 'Cannot create BrowserWindow before app is ready' failures and repeated setup prompts. The fix keeps token storage encrypted-only, teaches AniList auth refresh to optionally suppress setup-window prompting, and uses that suppression for the early startup refresh. App-ready startup still performs the normal prompt-capable refresh once Electron is ready, so saved encrypted tokens are reused without reopening setup and missing-token setup only happens at a safe point. Verified with targeted AniList auth tests, typecheck, test:fast, test:env, build, and test:smoke:dist.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,87 @@
---
id: TASK-255
title: Add overlay playlist browser modal for sibling video files and mpv queue
status: In Progress
assignee:
- '@codex'
created_date: '2026-03-30 05:46'
updated_date: '2026-03-31 05:59'
labels:
- feature
- overlay
- mpv
- launcher
dependencies: []
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add an in-session overlay modal that opens from a keybinding during active playback and lets the user browse video files from the current file's parent directory alongside the active mpv playlist. The modal should sort local files in best-effort episode order, highlight the current item, and allow keyboard/mouse interaction to add files into the mpv queue, remove queued items, and reorder queued items without leaving playback.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 An overlay modal can be opened during active playback from a dedicated keybinding and closed without disrupting existing modal behavior.
- [ ] #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.
- [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 -->

View File

@@ -0,0 +1,56 @@
---
id: TASK-256
title: Fix texthooker page live websocket connect/send regression
status: Done
assignee:
- codex
created_date: '2026-03-30 06:04'
updated_date: '2026-03-30 06:12'
labels:
- bug
- texthooker
- websocket
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Investigate why the bundled texthooker page loads at the local HTTP endpoint but does not reliably connect to the configured websocket feed or receive/display live subtitle lines. Identify the regression in the SubMiner startup/bootstrap or vendored texthooker client path, restore live line delivery, and cover the fix with focused regression tests and any required docs updates.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Bundled texthooker connects to the intended websocket endpoint on launch using the configured/default SubMiner startup path.
- [x] #2 Incoming subtitle or annotation websocket messages are accepted by the bundled texthooker and rendered as live lines.
- [x] #3 Regression coverage fails before the fix and passes after the fix for the identified breakage.
- [x] #4 Relevant docs/config notes are updated if user-facing behavior or troubleshooting guidance changes.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a focused CLI regression test covering `--texthooker` startup when the runtime has a resolved websocket URL, proving the handler currently starts texthooker without that URL.
2. Extend CLI texthooker dependencies/runtime wiring so the handler can retrieve the resolved texthooker websocket URL from current config/runtime state.
3. Update the CLI texthooker flow to pass the resolved websocket URL into texthooker startup instead of starting the HTTP server with only a port.
4. Run focused tests for CLI command handling and texthooker bootstrap behavior; update task notes/final summary with the verified root cause and fix.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Root cause: the CLI `--texthooker` path started the HTTP texthooker server with only the port, so the served page never received `bannou-texthooker-websocketUrl` and fell back to the vendored default `ws://localhost:6677`. In environments where the regular websocket was skipped or the annotation websocket should have been used, the page stayed on `Connecting...` and never received lines.
Fix: added a shared `resolveTexthookerWebsocketUrl(...)` helper for websocket selection, reused it in both app-ready startup and CLI texthooker context wiring, and threaded the resolved websocket URL through `handleCliCommand` into `Texthooker.start(...)`.
Verification: `bun run typecheck`; focused Bun tests for texthooker bootstrap, startup, CLI command handling, and CLI context wiring; browser-level repro against a throwaway source-backed texthooker server confirmed the page bootstraps `ws://127.0.0.1:6678`, connects successfully, and renders live sample lines (`テスト一`, `テスト二`).
Docs: no user-facing behavior change beyond restoring the intended existing behavior, so no docs update was required.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Restored texthooker live line delivery for the CLI/startup path that launched the page without a resolved websocket URL. Shared websocket URL resolution between app-ready startup and CLI texthooker context, forwarded that URL into `Texthooker.start(...)`, added regression coverage for the CLI path, and verified both by focused tests and a browser-level throwaway server that connected on `ws://127.0.0.1:6678` and rendered live sample lines.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,57 @@
---
id: TASK-257
title: Fix texthooker-only mode startup to initialize websocket pipeline
status: Done
assignee:
- codex
created_date: '2026-03-30 06:15'
updated_date: '2026-03-30 06:17'
labels:
- bug
- texthooker
- websocket
- startup
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Investigate and fix `--texthooker` / `subminer texthooker` startup so it launches the texthooker page without the overlay window but still initializes the runtime pieces required for live subtitle delivery. Today texthooker-only mode serves the page yet skips mpv client and websocket startup, leaving the page pointed at `ws://127.0.0.1:6678` with no listener behind it.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 `--texthooker` mode starts the texthooker page without opening the overlay window and still initializes the websocket path needed for live subtitle delivery.
- [x] #2 Texthooker-only startup creates the mpv/websocket runtime needed for the configured annotation or subtitle websocket feed.
- [x] #3 Regression coverage fails before the fix and passes after the fix for texthooker-only startup.
- [x] #4 Docs/help text remain accurate for texthooker-only behavior; update docs only if wording needs correction.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Replace the existing texthooker-only startup regression test so it asserts websocket/mpv startup still happens while overlay window initialization stays skipped.
2. Remove or narrow the early texthooker-only short-circuit in app-ready startup so runtime config, mpv client, subtitle websocket, and annotation websocket still initialize.
3. Run focused tests plus a local process check proving `--texthooker` now opens the websocket listener expected by the served page.
4. Update task notes/final summary with the live-process root cause (`--texthooker` serving HTML on 5174 with no 6678 listener).
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Live-process repro on the user's machine: `ps` showed the active process as `/tmp/.mount_SubMin.../SubMiner --texthooker --port 5174`. `lsof` showed 5174 listening but no listener on 6678/6677, while `curl http://127.0.0.1:5174/` confirmed the served page was correctly bootstrapped to `ws://127.0.0.1:6678`. That proved the remaining failure was startup mode, not page injection.
Root cause: `runAppReadyRuntime(...)` had an early `texthookerOnlyMode` return that reloaded config and handled initial args, but skipped `createMpvClient()`, subtitle websocket startup, annotation websocket startup, subtitle timing tracker creation, and the later texthooker-only branch that only skips the overlay window.
Fix: removed the early texthooker-only short-circuit so texthooker-only mode now runs the normal startup pipeline, then falls through to the existing `Texthooker-only mode enabled; skipping overlay window.` branch.
Verification: `bun run typecheck`; focused Bun tests for app-ready startup, startup bootstrap, CLI texthooker startup, and CLI context wiring. Existing local live-binary repro still reflects the old mounted AppImage until rebuilt/restarted. Current-binary workaround is to launch normal startup / `--start --texthooker` instead of plain `--texthooker`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed the second texthooker regression: plain `--texthooker` mode was serving the page but skipping mpv/websocket initialization, so the page pointed at `ws://127.0.0.1:6678` with no listener. Removed the early texthooker-only startup return, kept the later overlay-skip behavior, updated the startup regression test to require websocket/mpv initialization in texthooker-only mode, and re-verified with typecheck plus focused test coverage.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,57 @@
---
id: TASK-258
title: Stop plugin auto-start from spawning separate texthooker helper
status: Done
assignee:
- codex
created_date: '2026-03-30 06:25'
updated_date: '2026-03-30 06:26'
labels:
- bug
- texthooker
- launcher
- plugin
- startup
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Change the mpv/plugin auto-start path so normal SubMiner startup owns texthooker and websocket startup inside the main `--start` app instance. Keep standalone `subminer texthooker` / plain `--texthooker` available for explicit external use, but stop the plugin from spawning a second helper subprocess during regular auto-start.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Plugin auto-start includes texthooker on the main `--start` command when texthooker is enabled.
- [x] #2 Plugin auto-start no longer spawns a separate standalone `--texthooker` helper subprocess during normal startup.
- [x] #3 Regression coverage fails before the fix and passes after the fix for the plugin auto-start path.
- [x] #4 Standalone external `subminer texthooker` / plain `--texthooker` entrypoints remain available for explicit helper use.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Flip the mpv/plugin start-gate regression so enabled texthooker is folded into the main `--start` command and standalone helper subprocesses are rejected.
2. Update plugin process command construction so `start` includes `--texthooker` when enabled and the separate helper-launch path becomes a no-op for normal auto-start.
3. Run plugin Lua regressions, adjacent launcher tests, and typecheck to verify behavior and preserve explicit standalone `--texthooker` entrypoints.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Design approved by user: normal in-app startup should own texthooker/websocket; `texthookerOnlyMode` should stay explicit external-only.
Root cause path: mpv/plugin auto-start in `plugin/subminer/process.lua` launched `binary_path --start ...` and then separately spawned `binary_path --texthooker --port ...`. That created the standalone helper process observed live (`SubMiner --texthooker --port 5174`) instead of relying on the normal app instance.
Fix: `build_command_args('start', overrides)` now appends `--texthooker` when texthooker is enabled, and the old helper-launch path is reduced to a no-op so normal auto-start remains single-process.
Verification: `lua scripts/test-plugin-start-gate.lua`, `lua scripts/test-plugin-process-start-retries.lua`, `bun test launcher/mpv.test.ts launcher/commands/playback-command.test.ts launcher/config/args-normalizer.test.ts`, and `bun run typecheck`. Standalone launcher/app entrypoints for explicit `subminer texthooker` / plain `--texthooker` were left untouched.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Stopped the mpv/plugin auto-start path from spawning a second standalone texthooker helper. Texthooker now rides on the main `--start` app instance for normal startup, with Lua regressions updated to require `--texthooker` on the main start command and reject separate helper subprocesses. Explicit standalone `subminer texthooker` / plain `--texthooker` entrypoints remain available.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,33 @@
---
id: TASK-259
title: Fix integrated --start --texthooker startup skipping texthooker server
status: Done
assignee: []
created_date: '2026-03-30 06:48'
updated_date: '2026-03-30 06:56'
labels:
- bug
- texthooker
- startup
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Integrated overlay startup with `--start --texthooker` currently takes the minimal-startup path because startup mode flags treat any `args.texthooker` as texthooker-only. That skips app-ready texthooker service startup, so no server binds on port 5174 during normal SubMiner playback launches.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 `--start --texthooker` uses full app-ready startup instead of minimal texthooker-only startup
- [x] #2 Integrated playback launch starts the texthooker server on the configured/default port
- [x] #3 Regression tests cover the startup-mode classification and integrated startup behavior
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Narrowed texthooker-only startup classification so integrated `--start --texthooker` no longer takes the minimal-startup path. Added CLI arg regression coverage, rebuilt the AppImage, installed it to `~/.local/bin/SubMiner.AppImage` with a timestamped backup, restarted against `/tmp/subminer-socket`, and verified listeners on 5174/6677/6678 plus browser connection state `Connected with ws://127.0.0.1:6678`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,67 @@
---
id: TASK-260
title: >-
Fix macOS overlay subtitle sidebar passthrough without requiring a subtitle
hover cycle
status: Done
assignee:
- '@codex'
created_date: '2026-03-31 00:58'
updated_date: '2026-03-31 01:01'
labels:
- bug
- macos
- overlay
- subtitle-sidebar
- passthrough
dependencies: []
references:
- >-
/Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/renderer/overlay-mouse-ignore.ts
- /Users/sudacode/projects/japanese/SubMiner/src/renderer/handlers/mouse.ts
- /Users/sudacode/projects/japanese/SubMiner/src/main/overlay-runtime.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/core/services/overlay-visibility.ts
documentation:
- docs/workflow/verification.md
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
On macOS, opening the overlay-layout subtitle sidebar should allow click-through outside the sidebar immediately. Users should not need to first hover subtitle content before passthrough/click-through starts working, including when no subtitle line is currently visible.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 With the overlay-layout subtitle sidebar open on macOS, areas outside the sidebar pass clicks through immediately after open without requiring a prior subtitle hover.
- [x] #2 When no subtitle line is currently visible, opening the subtitle sidebar still leaves non-sidebar overlay regions click-through on macOS.
- [x] #3 Regression coverage exercises the first-open/idle passthrough path so overlay interactivity does not depend on a later hover cycle.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add/adjust focused overlay visibility regressions for the tracked macOS visible overlay so the default idle state stays click-through instead of forcing mouse interaction.
2. Update main-process visible overlay visibility sync to keep the tracked macOS overlay passive by default and let renderer hover/sidebar state opt into interaction.
3. Run focused verification for overlay visibility and any dependent runtime tests, then update task notes/criteria/final summary with the confirmed outcome.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Investigation points to a main-process override on macOS: renderer sidebar open path already requests mouse passthrough outside the panel, but visible-overlay visibility sync still hard-sets the tracked overlay window interactive on macOS (`mouse-ignore:false`). Window-tracker focus/visibility resync can therefore undo renderer passthrough until a later hover cycle re-applies it.
Added a failing regression in `src/core/services/overlay-visibility.test.ts` showing the tracked macOS visible overlay was still forced interactive by main-process visibility sync (`mouse-ignore:false`) instead of staying forwarded click-through.
Updated `src/core/services/overlay-visibility.ts` so tracked macOS visible overlays now default to `setIgnoreMouseEvents(true, { forward: true })`, matching the renderer-side passthrough model and preventing window-tracker/focus resync from undoing idle sidebar clickthrough.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed the macOS subtitle-sidebar passthrough regression by changing tracked visible-overlay startup/visibility sync to stay click-through by default in the main process. Previously `updateVisibleOverlayVisibility` forced the macOS overlay window interactive, which could override renderer sidebar passthrough until a later hover cycle repaired it. Added a regression in `src/core/services/overlay-visibility.test.ts` and verified with `bun test src/core/services/overlay-visibility.test.ts`, `bun test src/renderer/modals/subtitle-sidebar.test.ts src/renderer/handlers/mouse.test.ts`, and `bun run typecheck`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,29 @@
---
id: TASK-261
title: Fix immersion tracker SQLite timestamp truncation
status: In Progress
assignee: []
created_date: '2026-03-31 01:45'
labels:
- immersion-tracker
- sqlite
- bug
dependencies: []
references:
- src/core/services/immersion-tracker
priority: medium
ordinal: 1200
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Current-epoch millisecond values are being truncated by the libsql driver when bound as numeric parameters, which corrupts session, telemetry, lifetime, and rollup timestamps.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Current-epoch millisecond timestamps persist correctly in session, telemetry, lifetime, and rollup tables
- [ ] #2 Startup backfill and destroy/finalize flows keep retained sessions and lifetime summaries consistent
- [ ] #3 Regression tests cover the destroyed-session, startup backfill, and distinct-day/distinct-video lifetime semantics
<!-- AC:END -->

View File

@@ -0,0 +1,5 @@
type: internal
area: release
- Retried AUR clone and push operations in the tagged release workflow.
- Kept GitHub Releases green when AUR publish flakes and needs manual follow-up.

View File

@@ -0,0 +1,5 @@
type: fixed
area: main
- Keep integrated `--start --texthooker` launches on the full app-ready startup path so the texthooker page and websocket servers start together during normal playback startup.
- Stop the mpv/plugin auto-start flow from spawning a separate standalone texthooker helper during normal `subminer <video>` launches.

View File

@@ -0,0 +1,5 @@
type: added
area: overlay
- Added a playlist browser overlay modal for browsing sibling video files and the live mpv queue during playback.
- Added the default `Ctrl+Alt+P` keybinding to open the playlist browser and manage queue order without leaving playback.

View File

@@ -0,0 +1,5 @@
type: fixed
area: overlay
- Keep tracked macOS visible overlays click-through by default so subtitle sidebar passthrough works immediately without requiring a subtitle hover cycle first.
- Add regression coverage for the macOS visible-overlay passthrough default.

View File

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

View File

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

View File

@@ -295,6 +295,8 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
`Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config.
:::
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

View File

@@ -34,4 +34,5 @@ Notes:
- Do not tag while `changes/*.md` fragments still exist.
- If you need to repair a published release body (for example, a prior versions section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.

View File

@@ -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
@@ -191,6 +202,11 @@ function M.create(ctx)
else
table.insert(args, "--hide-visible-overlay")
end
local texthooker_enabled = resolve_texthooker_enabled(overrides.texthooker_enabled)
if texthooker_enabled then
table.insert(args, "--texthooker")
end
end
return args
@@ -242,50 +258,10 @@ function M.create(ctx)
return overrides
end
local function build_texthooker_args()
local args = { state.binary_path, "--texthooker", "--port", tostring(opts.texthooker_port) }
local log_level = normalize_log_level(opts.log_level)
if log_level ~= "info" then
table.insert(args, "--log-level")
table.insert(args, log_level)
end
return args
end
local function ensure_texthooker_running(callback)
if not opts.texthooker_enabled then
if callback then
callback()
return
end
if state.texthooker_running then
callback()
return
end
local args = build_texthooker_args()
subminer_log("info", "texthooker", "Starting texthooker process: " .. table.concat(args, " "))
state.texthooker_running = true
mp.command_native_async({
name = "subprocess",
args = args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
}, function(success, result, error)
if not success or (result and result.status ~= 0) then
state.texthooker_running = false
subminer_log(
"warn",
"texthooker",
"Texthooker process exited unexpectedly: " .. (error or (result and result.stderr) or "unknown error")
)
end
end)
-- Start overlay immediately; overlay start path retries on readiness failures.
callback()
end
local function start_overlay(overrides)
@@ -328,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
@@ -530,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)

View File

@@ -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 = "",
@@ -664,8 +689,8 @@ do
fire_event(recorded, "file-loaded")
local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "auto-start should issue --start command")
local texthooker_call = find_texthooker_call(recorded.async_calls)
assert_true(texthooker_call ~= nil, "auto-start should issue texthooker helper command when enabled")
assert_true(call_has_arg(start_call, "--texthooker"), "auto-start should include --texthooker on the main --start command when enabled")
assert_true(find_control_call(recorded.async_calls, "--texthooker") == nil, "auto-start should not issue a separate texthooker helper command")
assert_true(
call_has_arg(start_call, "--show-visible-overlay"),
"auto-start with visible overlay enabled should include --show-visible-overlay on --start"
@@ -678,10 +703,6 @@ do
find_control_call(recorded.async_calls, "--show-visible-overlay") ~= nil,
"auto-start with visible overlay enabled should issue a separate --show-visible-overlay command"
)
assert_true(
find_call_index(recorded.async_calls, start_call) < find_call_index(recorded.async_calls, texthooker_call),
"auto-start should launch --start before separate --texthooker helper startup"
)
assert_true(
not has_property_set(recorded.property_sets, "pause", true),
"auto-start visible overlay should not force pause without explicit pause-until-ready option"

View File

@@ -19,6 +19,7 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
media: {
imageType: 'avif',
syncAnimatedImageToWordAudio: true,
audioPadding: 0,
},
},
noteInfo: {
@@ -49,6 +50,46 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
assert.equal(leadInSeconds, 1.25);
});
test('resolveAnimatedImageLeadInSeconds adds sentence audio padding to word audio duration', async () => {
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
config: {
fields: {
audio: 'ExpressionAudio',
},
media: {
imageType: 'avif',
syncAnimatedImageToWordAudio: true,
audioPadding: 0.5,
},
},
noteInfo: {
noteId: 42,
fields: {
ExpressionAudio: {
value: '[sound:word.mp3][sound:alt.ogg]',
},
},
},
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => {
for (const preferredName of preferredNames) {
if (!preferredName) continue;
const resolved = Object.keys(noteInfo.fields).find(
(fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(),
);
if (resolved) return resolved;
}
return null;
},
retrieveMediaFileBase64: async (filename) =>
filename === 'word.mp3' ? 'd29yZA==' : filename === 'alt.ogg' ? 'YWx0' : '',
probeAudioDurationSeconds: async (_buffer, filename) =>
filename === 'word.mp3' ? 0.41 : filename === 'alt.ogg' ? 0.84 : null,
logWarn: () => undefined,
});
assert.equal(leadInSeconds, 1.75);
});
test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled', async () => {
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
config: {

View File

@@ -39,6 +39,14 @@ function shouldSyncAnimatedImageToWordAudio(config: Pick<AnkiConnectConfig, 'med
return config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false;
}
function resolveSentenceAudioStartOffsetSeconds(config: Pick<AnkiConnectConfig, 'media'>): number {
const configuredPadding = config.media?.audioPadding;
if (typeof configuredPadding === 'number' && Number.isFinite(configuredPadding)) {
return configuredPadding;
}
return DEFAULT_ANKI_CONNECT_CONFIG.media.audioPadding;
}
export async function probeAudioDurationSeconds(
buffer: Buffer,
filename: string,
@@ -127,5 +135,5 @@ export async function resolveAnimatedImageLeadInSeconds<TNoteInfo extends NoteIn
totalLeadInSeconds += durationSeconds;
}
return totalLeadInSeconds;
return totalLeadInSeconds + resolveSentenceAudioStartOffsetSeconds(config);
}

View File

@@ -5,6 +5,7 @@ import {
commandNeedsOverlayRuntime,
hasExplicitCommand,
isHeadlessInitialCommand,
isStandaloneTexthookerCommand,
parseArgs,
shouldRunSettingsOnlyStartup,
shouldStartApp,
@@ -79,6 +80,14 @@ test('youtube playback does not use generic overlay-runtime bootstrap classifica
assert.equal(commandNeedsOverlayStartupPrereqs(args), true);
});
test('standalone texthooker classification excludes integrated start flow', () => {
const standalone = parseArgs(['--texthooker']);
const integrated = parseArgs(['--start', '--texthooker']);
assert.equal(isStandaloneTexthookerCommand(standalone), true);
assert.equal(isStandaloneTexthookerCommand(integrated), false);
});
test('parseArgs handles jellyfin item listing controls', () => {
const args = parseArgs([
'--jellyfin-items',

View File

@@ -397,6 +397,54 @@ export function isHeadlessInitialCommand(args: CliArgs): boolean {
return args.refreshKnownWords;
}
export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
return (
args.texthooker &&
!args.background &&
!args.start &&
!Boolean(args.youtubePlay) &&
!args.launchMpv &&
!args.stop &&
!args.toggle &&
!args.toggleVisibleOverlay &&
!args.settings &&
!args.setup &&
!args.show &&
!args.hide &&
!args.showVisibleOverlay &&
!args.hideVisibleOverlay &&
!args.copySubtitle &&
!args.copySubtitleMultiple &&
!args.mineSentence &&
!args.mineSentenceMultiple &&
!args.updateLastCardFromClipboard &&
!args.refreshKnownWords &&
!args.toggleSecondarySub &&
!args.triggerFieldGrouping &&
!args.triggerSubsync &&
!args.markAudioCard &&
!args.openRuntimeOptions &&
!args.anilistStatus &&
!args.anilistLogout &&
!args.anilistSetup &&
!args.anilistRetryQueue &&
!args.dictionary &&
!args.stats &&
!args.jellyfin &&
!args.jellyfinLogin &&
!args.jellyfinLogout &&
!args.jellyfinLibraries &&
!args.jellyfinItems &&
!args.jellyfinSubtitles &&
!args.jellyfinPlay &&
!args.jellyfinRemoteAnnounce &&
!args.jellyfinPreviewAuth &&
!args.help &&
!args.autoStartOverlay &&
!args.generateConfig
);
}
export function shouldStartApp(args: CliArgs): boolean {
if (args.stop && !args.start) return false;
if (

View File

@@ -80,6 +80,7 @@ test('default keybindings include primary and secondary subtitle track cycling o
assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']);
assert.deepEqual(keybindingMap.get('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', () => {

View File

@@ -47,6 +47,7 @@ export const SPECIAL_COMMANDS = {
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
SHIFT_SUB_DELAY_TO_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'] },

View File

@@ -176,7 +176,7 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs'));
});
test('runAppReadyRuntime uses minimal startup for texthooker-only mode', async () => {
test('runAppReadyRuntime keeps websocket startup in texthooker-only mode but skips overlay window', async () => {
const { deps, calls } = makeDeps({
texthookerOnlyMode: true,
reloadConfig: () => calls.push('reloadConfig'),
@@ -185,7 +185,16 @@ test('runAppReadyRuntime uses minimal startup for texthooker-only mode', async (
await runAppReadyRuntime(deps);
assert.deepEqual(calls, ['ensureDefaultConfigBootstrap', 'reloadConfig', 'handleInitialArgs']);
assert.ok(calls.includes('reloadConfig'));
assert.ok(calls.includes('createMpvClient'));
assert.ok(calls.includes('startAnnotationWebsocket:6678'));
assert.ok(calls.includes('startTexthooker:5174:ws://127.0.0.1:6678'));
assert.ok(calls.includes('createSubtitleTimingTracker'));
assert.ok(calls.includes('handleFirstRunSetup'));
assert.ok(calls.includes('handleInitialArgs'));
assert.ok(calls.includes('log:Texthooker-only mode enabled; skipping overlay window.'));
assert.equal(calls.includes('initializeOverlayRuntime'), false);
assert.equal(calls.includes('setVisibleOverlayVisible:true'), false);
});
test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => {

View File

@@ -62,6 +62,7 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
let mpvSocketPath = '/tmp/subminer.sock';
let texthookerPort = 5174;
const osd: string[] = [];
let texthookerWebsocketUrl: string | undefined;
const deps: CliCommandServiceDeps = {
getMpvSocketPath: () => mpvSocketPath,
@@ -82,9 +83,10 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
calls.push(`setTexthookerPort:${port}`);
},
getTexthookerPort: () => texthookerPort,
getTexthookerWebsocketUrl: () => texthookerWebsocketUrl,
shouldOpenTexthookerBrowser: () => true,
ensureTexthookerRunning: (port) => {
calls.push(`ensureTexthookerRunning:${port}`);
ensureTexthookerRunning: (port, websocketUrl) => {
calls.push(`ensureTexthookerRunning:${port}:${websocketUrl ?? ''}`);
},
openTexthookerInBrowser: (url) => {
calls.push(`openTexthookerInBrowser:${url}`);
@@ -354,10 +356,20 @@ test('handleCliCommand runs texthooker flow with browser open', () => {
handleCliCommand(args, 'initial', deps);
assert.ok(calls.includes('ensureTexthookerRunning:5174'));
assert.ok(calls.includes('ensureTexthookerRunning:5174:'));
assert.ok(calls.includes('openTexthookerInBrowser:http://127.0.0.1:5174'));
});
test('handleCliCommand forwards resolved websocket url to texthooker startup', () => {
const { deps, calls } = createDeps({
getTexthookerWebsocketUrl: () => 'ws://127.0.0.1:6678',
});
handleCliCommand(makeArgs({ texthooker: true }), 'initial', deps);
assert.ok(calls.includes('ensureTexthookerRunning:5174:ws://127.0.0.1:6678'));
});
test('handleCliCommand reports async mine errors to OSD', async () => {
const { deps, calls, osd } = createDeps({
mineSentenceCard: async () => {

View File

@@ -10,8 +10,9 @@ export interface CliCommandServiceDeps {
isTexthookerRunning: () => boolean;
setTexthookerPort: (port: number) => void;
getTexthookerPort: () => number;
getTexthookerWebsocketUrl: () => string | undefined;
shouldOpenTexthookerBrowser: () => boolean;
ensureTexthookerRunning: (port: number) => void;
ensureTexthookerRunning: (port: number, websocketUrl?: string) => void;
openTexthookerInBrowser: (url: string) => void;
stopApp: () => void;
isOverlayRuntimeInitialized: () => boolean;
@@ -84,7 +85,7 @@ interface MpvClientLike {
interface TexthookerServiceLike {
isRunning: () => boolean;
start: (port: number) => void;
start: (port: number, websocketUrl?: string) => void;
}
interface MpvCliRuntime {
@@ -98,6 +99,7 @@ interface TexthookerCliRuntime {
service: TexthookerServiceLike;
getPort: () => number;
setPort: (port: number) => void;
getWebsocketUrl: () => string | undefined;
shouldOpenBrowser: () => boolean;
openInBrowser: (url: string) => void;
}
@@ -194,10 +196,11 @@ export function createCliCommandDepsRuntime(
isTexthookerRunning: () => options.texthooker.service.isRunning(),
setTexthookerPort: options.texthooker.setPort,
getTexthookerPort: options.texthooker.getPort,
getTexthookerWebsocketUrl: options.texthooker.getWebsocketUrl,
shouldOpenTexthookerBrowser: options.texthooker.shouldOpenBrowser,
ensureTexthookerRunning: (port) => {
ensureTexthookerRunning: (port, websocketUrl) => {
if (!options.texthooker.service.isRunning()) {
options.texthooker.service.start(port);
options.texthooker.service.start(port, websocketUrl);
}
},
openTexthookerInBrowser: options.texthooker.openInBrowser,
@@ -473,7 +476,7 @@ export function handleCliCommand(
);
} else if (args.texthooker) {
const texthookerPort = deps.getTexthookerPort();
deps.ensureTexthookerRunning(texthookerPort);
deps.ensureTexthookerRunning(texthookerPort, deps.getTexthookerWebsocketUrl());
if (deps.shouldOpenTexthookerBrowser()) {
deps.openTexthookerInBrowser(`http://127.0.0.1:${texthookerPort}`);
}

View File

@@ -5,6 +5,7 @@ import os from 'node:os';
import path from 'node:path';
import { toMonthKey } from './immersion-tracker/maintenance';
import { enqueueWrite } from './immersion-tracker/queue';
import { toDbTimestamp } from './immersion-tracker/query-shared';
import { Database, type DatabaseSync } from './immersion-tracker/sqlite';
import { nowMs as trackerNowMs } from './immersion-tracker/time';
import {
@@ -185,7 +186,7 @@ test('destroy finalizes active session and persists final telemetry', async () =
const db = new Database(dbPath);
const sessionRow = db.prepare('SELECT ended_at_ms FROM imm_sessions LIMIT 1').get() as {
ended_at_ms: number | null;
ended_at_ms: string | number | null;
} | null;
const telemetryCountRow = db
.prepare('SELECT COUNT(*) AS total FROM imm_session_telemetry')
@@ -193,7 +194,7 @@ test('destroy finalizes active session and persists final telemetry', async () =
db.close();
assert.ok(sessionRow);
assert.ok(Number(sessionRow?.ended_at_ms ?? 0) > 0);
assert.notEqual(sessionRow?.ended_at_ms, null);
assert.ok(Number(telemetryCountRow.total) >= 2);
} finally {
tracker?.destroy();
@@ -504,7 +505,7 @@ test('rebuildLifetimeSummaries backfills retained ended sessions and resets stal
episodes_started: number;
episodes_completed: number;
anime_completed: number;
last_rebuilt_ms: number | null;
last_rebuilt_ms: string | number | null;
} | null;
const appliedSessions = rebuildApi.db
.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions')
@@ -518,7 +519,7 @@ test('rebuildLifetimeSummaries backfills retained ended sessions and resets stal
assert.equal(globalRow?.episodes_started, 2);
assert.equal(globalRow?.episodes_completed, 2);
assert.equal(globalRow?.anime_completed, 1);
assert.equal(globalRow?.last_rebuilt_ms, rebuild.rebuiltAtMs);
assert.equal(globalRow?.last_rebuilt_ms, toDbTimestamp(rebuild.rebuiltAtMs));
assert.equal(appliedSessions?.total, 2);
} finally {
tracker?.destroy();
@@ -629,97 +630,89 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
const startedAtMs = trackerNowMs() - 10_000;
const sampleMs = startedAtMs + 5_000;
db.exec(`
INSERT INTO imm_anime (
anime_id,
canonical_title,
normalized_title_key,
episodes_total,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES (
1,
'KonoSuba',
'konosuba',
10,
${startedAtMs},
${startedAtMs}
);
db.prepare(
`
INSERT INTO imm_anime (
anime_id,
canonical_title,
normalized_title_key,
episodes_total,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?)
`,
).run(1, 'KonoSuba', 'konosuba', 10, toDbTimestamp(startedAtMs), toDbTimestamp(startedAtMs));
INSERT INTO imm_videos (
video_id,
video_key,
canonical_title,
anime_id,
watched,
source_type,
duration_ms,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES (
1,
'local:/tmp/konosuba-s02e05.mkv',
'KonoSuba S02E05',
1,
1,
1,
0,
${startedAtMs},
${startedAtMs}
);
db.prepare(
`
INSERT INTO imm_videos (
video_id,
video_key,
canonical_title,
anime_id,
watched,
source_type,
duration_ms,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
1,
'local:/tmp/konosuba-s02e05.mkv',
'KonoSuba S02E05',
1,
1,
1,
0,
toDbTimestamp(startedAtMs),
toDbTimestamp(startedAtMs),
);
INSERT INTO imm_sessions (
session_id,
session_uuid,
video_id,
started_at_ms,
status,
ended_media_ms,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES (
1,
'11111111-1111-1111-1111-111111111111',
1,
${startedAtMs},
1,
321000,
${startedAtMs},
${sampleMs}
);
db.prepare(
`
INSERT INTO imm_sessions (
session_id,
session_uuid,
video_id,
started_at_ms,
status,
ended_media_ms,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
1,
'11111111-1111-1111-1111-111111111111',
1,
toDbTimestamp(startedAtMs),
1,
321000,
toDbTimestamp(startedAtMs),
toDbTimestamp(sampleMs),
);
INSERT INTO imm_session_telemetry (
session_id,
sample_ms,
total_watched_ms,
active_watched_ms,
lines_seen,
tokens_seen,
cards_mined,
lookup_count,
lookup_hits,
pause_count,
pause_ms,
seek_forward_count,
seek_backward_count,
media_buffer_events
) VALUES (
1,
${sampleMs},
5000,
4000,
12,
120,
2,
5,
3,
1,
250,
1,
0,
0
);
`);
db.prepare(
`
INSERT INTO imm_session_telemetry (
session_id,
sample_ms,
total_watched_ms,
active_watched_ms,
lines_seen,
tokens_seen,
cards_mined,
lookup_count,
lookup_hits,
pause_count,
pause_ms,
seek_forward_count,
seek_backward_count,
media_buffer_events
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(1, toDbTimestamp(sampleMs), 5000, 4000, 12, 120, 2, 5, 3, 1, 250, 1, 0, 0);
tracker.destroy();
tracker = new Ctor({ dbPath });
@@ -734,7 +727,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
`,
)
.get() as {
ended_at_ms: number | null;
ended_at_ms: string | number | null;
status: number;
ended_media_ms: number | null;
active_watched_ms: number;
@@ -769,7 +762,7 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
.get() as { total: number } | null;
assert.ok(sessionRow);
assert.ok(Number(sessionRow?.ended_at_ms ?? 0) >= sampleMs);
assert.equal(sessionRow?.ended_at_ms, toDbTimestamp(sampleMs));
assert.equal(sessionRow?.status, 2);
assert.equal(sessionRow?.ended_media_ms, 321_000);
assert.equal(sessionRow?.active_watched_ms, 4000);

View File

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

View File

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

View File

@@ -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,38 +1183,70 @@ test('getQueryHints counts new words by distinct headword first-seen time', () =
try {
ensureSchema(db);
withMockNowMs('1773601200000', () => {
const todayStartSec = 1_773_558_000;
const oneHourAgo = todayStartSec + 3_600;
const twoDaysAgo = todayStartSec - 2 * 86_400;
const now = new Date();
const todayStartSec =
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000;
const oneHourAgo = todayStartSec + 3_600;
const twoDaysAgo = todayStartSec - 2 * 86_400;
db.prepare(
`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
'知る',
'知った',
'しった',
'verb',
'動詞',
'',
'',
String(oneHourAgo),
String(oneHourAgo),
1,
);
db.prepare(
`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
'知る',
'知っている',
'しっている',
'verb',
'動詞',
'',
'',
String(oneHourAgo),
String(oneHourAgo),
1,
);
db.prepare(
`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
'猫',
'猫',
'ねこ',
'noun',
'名詞',
'',
'',
String(twoDaysAgo),
String(twoDaysAgo),
1,
);
db.prepare(
`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run('知る', '知った', 'しった', 'verb', '動詞', '', '', oneHourAgo, oneHourAgo, 1);
db.prepare(
`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run('知る', '知っている', 'しっている', 'verb', '動詞', '', '', oneHourAgo, oneHourAgo, 1);
db.prepare(
`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run('猫', '猫', 'ねこ', 'noun', '名詞', '', '', twoDaysAgo, twoDaysAgo, 1);
const hints = getQueryHints(db);
assert.equal(hints.newWordsToday, 1);
assert.equal(hints.newWordsThisWeek, 2);
const hints = getQueryHints(db);
assert.equal(hints.newWordsToday, 1);
assert.equal(hints.newWordsThisWeek, 2);
});
} finally {
db.close();
cleanupDbPath(dbPath);
@@ -2020,7 +2101,7 @@ test('getSessionWordsByLine joins word occurrences through imm_words.id', () =>
try {
ensureSchema(db);
const stmts = createTrackerPreparedStatements(db);
const startedAtMs = Date.UTC(2025, 0, 1, 12, 0, 0);
const startedAtMs = 1_735_732_800_000;
const videoId = getOrCreateVideoRecord(db, '/tmp/session-words-by-line.mkv', {
canonicalTitle: 'Episode',
sourcePath: '/tmp/session-words-by-line.mkv',

View File

@@ -1,6 +1,7 @@
import type { DatabaseSync } from './sqlite';
import { finalizeSessionRecord } from './session';
import { nowMs } from './time';
import { toDbTimestamp } from './query-shared';
import type { LifetimeRebuildSummary, SessionState } from './types';
interface TelemetryRow {
@@ -41,8 +42,8 @@ interface LifetimeAnimeStateRow {
interface RetainedSessionRow {
sessionId: number;
videoId: number;
startedAtMs: number;
endedAtMs: number;
startedAtMs: number | string;
endedAtMs: number | string;
lastMediaMs: number | null;
totalWatchedMs: number;
activeWatchedMs: number;
@@ -65,25 +66,29 @@ function hasRetainedPriorSession(
startedAtMs: number,
currentSessionId: number,
): boolean {
return (
Number(
(
db
.prepare(
`
SELECT COUNT(*) AS count
FROM imm_sessions
WHERE video_id = ?
AND (
started_at_ms < ?
OR (started_at_ms = ? AND session_id < ?)
)
`,
const row = db
.prepare(
`
SELECT 1 AS found
FROM imm_sessions
WHERE video_id = ?
AND (
CAST(started_at_ms AS REAL) < CAST(? AS REAL)
OR (
CAST(started_at_ms AS REAL) = CAST(? AS REAL)
AND session_id < ?
)
.get(videoId, startedAtMs, startedAtMs, currentSessionId) as ExistenceRow | null
)?.count ?? 0,
) > 0
);
)
LIMIT 1
`,
)
.get(
videoId,
toDbTimestamp(startedAtMs),
toDbTimestamp(startedAtMs),
currentSessionId,
) as { found: number } | null;
return Boolean(row);
}
function isFirstSessionForLocalDay(
@@ -91,23 +96,37 @@ function isFirstSessionForLocalDay(
currentSessionId: number,
startedAtMs: number,
): boolean {
return (
(
db
.prepare(
`
SELECT COUNT(*) AS count
const row = db
.prepare(
`
SELECT 1 AS found
FROM imm_sessions
WHERE date(started_at_ms / 1000, 'unixepoch', 'localtime') = date(? / 1000, 'unixepoch', 'localtime')
WHERE session_id != ?
AND CAST(
julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5
AS INTEGER
) = CAST(
julianday(CAST(? AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5
AS INTEGER
)
AND (
started_at_ms < ?
OR (started_at_ms = ? AND session_id < ?)
CAST(started_at_ms AS REAL) < CAST(? AS REAL)
OR (
CAST(started_at_ms AS REAL) = CAST(? AS REAL)
AND session_id < ?
)
)
`,
)
.get(startedAtMs, startedAtMs, startedAtMs, currentSessionId) as ExistenceRow | null
)?.count === 0
);
LIMIT 1
`,
)
.get(
currentSessionId,
toDbTimestamp(startedAtMs),
toDbTimestamp(startedAtMs),
toDbTimestamp(startedAtMs),
currentSessionId,
) as { found: number } | null;
return !row;
}
function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void {
@@ -131,14 +150,14 @@ function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void {
LAST_UPDATE_DATE = ?
WHERE global_id = 1
`,
).run(nowMs, nowMs);
).run(toDbTimestamp(nowMs), toDbTimestamp(nowMs));
}
function rebuildLifetimeSummariesInternal(
db: DatabaseSync,
rebuiltAtMs: number,
): LifetimeRebuildSummary {
const sessions = db
const rows = db
.prepare(
`
SELECT
@@ -146,6 +165,7 @@ function rebuildLifetimeSummariesInternal(
video_id AS videoId,
started_at_ms AS startedAtMs,
ended_at_ms AS endedAtMs,
ended_media_ms AS lastMediaMs,
total_watched_ms AS totalWatchedMs,
active_watched_ms AS activeWatchedMs,
lines_seen AS linesSeen,
@@ -164,7 +184,19 @@ function rebuildLifetimeSummariesInternal(
ORDER BY started_at_ms ASC, session_id ASC
`,
)
.all() as RetainedSessionRow[];
.all() as Array<
Omit<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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
FROM imm_words
WHERE first_seen IS NOT NULL
AND headword IS NOT NULL
AND headword != ''
GROUP BY headword
)
SELECT
COALESCE(SUM(CASE WHEN first_seen >= ? THEN 1 ELSE 0 END), 0) AS today,
COALESCE(SUM(CASE WHEN first_seen >= ? THEN 1 ELSE 0 END), 0) AS week
FROM headword_first_seen
headword,
first_seen AS firstSeen
FROM imm_words
WHERE first_seen IS NOT NULL
AND headword IS NOT NULL
AND headword != ''
`,
)
.get(todayStartSec, weekAgoSec) as { today: number; week: number } | null;
.all() as Array<{ headword: string; firstSeen: number | string }>;
const firstSeenByHeadword = new Map<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

View File

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

View File

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

View File

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

View File

@@ -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();

View File

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

View File

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

View File

@@ -1,4 +1,26 @@
declare global {
var __subminerTestNowMs: number | string | undefined;
}
function getMockNowMs(testNowMs: number | string | undefined): number | null {
if (typeof testNowMs === 'number' && Number.isFinite(testNowMs)) {
return Math.trunc(testNowMs);
}
if (typeof testNowMs === 'string') {
const parsed = Number(testNowMs.trim());
if (Number.isFinite(parsed)) {
return Math.trunc(parsed);
}
}
return null;
}
export function nowMs(): number {
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());

View File

@@ -16,6 +16,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
SHIFT_SUB_DELAY_TO_NEXT_SUBTITLE_START: '__sub-delay-next-line',
SHIFT_SUB_DELAY_TO_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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -98,6 +98,13 @@ interface AppReadyConfigLike {
};
}
type TexthookerWebsocketConfigLike = Pick<AppReadyConfigLike, 'annotationWebsocket' | 'websocket'>;
type TexthookerWebsocketDefaults = {
defaultWebsocketPort: number;
defaultAnnotationWebsocketPort: number;
};
export interface AppReadyRuntimeDeps {
ensureDefaultConfigBootstrap: () => void;
loadSubtitlePosition: () => void;
@@ -169,6 +176,29 @@ function getStartupCriticalConfigErrors(config: AppReadyConfigLike): string[] {
return errors;
}
export function resolveTexthookerWebsocketUrl(
config: TexthookerWebsocketConfigLike,
defaults: TexthookerWebsocketDefaults,
hasMpvWebsocketPlugin: boolean,
): string | undefined {
const wsConfig = config.websocket || {};
const wsEnabled = wsConfig.enabled ?? 'auto';
const wsPort = wsConfig.port || defaults.defaultWebsocketPort;
const annotationWsConfig = config.annotationWebsocket || {};
const annotationWsEnabled = annotationWsConfig.enabled !== false;
const annotationWsPort = annotationWsConfig.port || defaults.defaultAnnotationWebsocketPort;
if (annotationWsEnabled) {
return `ws://127.0.0.1:${annotationWsPort}`;
}
if (wsEnabled === true || (wsEnabled === 'auto' && !hasMpvWebsocketPlugin)) {
return `ws://127.0.0.1:${wsPort}`;
}
return undefined;
}
export function shouldAutoInitializeOverlayRuntimeFromConfig(config: RuntimeConfigLike): boolean {
return config.auto_start_overlay === true;
}
@@ -201,12 +231,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
return;
}
if (deps.texthookerOnlyMode) {
deps.reloadConfig();
deps.handleInitialArgs();
return;
}
if (deps.shouldUseMinimalStartup?.()) {
deps.reloadConfig();
deps.handleInitialArgs();
@@ -262,7 +286,14 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
const annotationWsEnabled = annotationWsConfig.enabled !== false;
const annotationWsPort = annotationWsConfig.port || deps.defaultAnnotationWebsocketPort;
const texthookerPort = deps.defaultTexthookerPort;
let texthookerWebsocketUrl: string | undefined;
const texthookerWebsocketUrl = resolveTexthookerWebsocketUrl(
config,
{
defaultWebsocketPort: deps.defaultWebsocketPort,
defaultAnnotationWebsocketPort: deps.defaultAnnotationWebsocketPort,
},
deps.hasMpvWebsocketPlugin(),
);
if (wsEnabled === true || (wsEnabled === 'auto' && !deps.hasMpvWebsocketPlugin())) {
deps.startSubtitleWebsocket(wsPort);
@@ -272,9 +303,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
if (annotationWsEnabled) {
deps.startAnnotationWebsocket(annotationWsPort);
texthookerWebsocketUrl = `ws://127.0.0.1:${annotationWsPort}`;
} else if (wsEnabled === true || (wsEnabled === 'auto' && !deps.hasMpvWebsocketPlugin())) {
texthookerWebsocketUrl = `ws://127.0.0.1:${wsPort}`;
}
if (config.texthooker?.launchAtStartup !== false) {

View File

@@ -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';
@@ -75,7 +76,7 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
} {
return {
shouldUseMinimalStartup: Boolean(
initialArgs?.texthooker ||
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
(initialArgs?.stats &&
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
),
@@ -128,6 +129,7 @@ import {
commandNeedsOverlayStartupPrereqs,
commandNeedsOverlayRuntime,
isHeadlessInitialCommand,
isStandaloneTexthookerCommand,
parseArgs,
shouldRunSettingsOnlyStartup,
shouldStartApp,
@@ -426,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,
@@ -1928,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();
}
@@ -2591,6 +2611,7 @@ const {
function refreshAnilistClientSecretStateIfEnabled(options?: {
force?: boolean;
allowSetupPrompt?: boolean;
}): Promise<string | null> {
if (!isAnilistTrackingEnabled(getResolvedConfig())) {
return Promise.resolve(null);
@@ -4107,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' };
@@ -4288,6 +4312,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getAnilistQueueStatus: () => anilistStateRuntime.getQueueStatusSnapshot(),
retryAnilistQueueNow: () => processNextAnilistRetryUpdate(),
appendClipboardVideoToQueue: () => appendClipboardVideoToQueue(),
...playlistBrowserMainDeps,
getImmersionTracker: () => appState.immersionTracker,
},
ankiJimakuDeps: createAnkiJimakuIpcRuntimeServiceDeps({
@@ -4333,6 +4358,9 @@ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
setLogLevel: (level) => setLogLevel(level, 'cli'),
texthookerService,
getResolvedConfig: () => getResolvedConfig(),
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
defaultAnnotationWebsocketPort: DEFAULT_CONFIG.annotationWebsocket.port,
hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(),
openExternal: (url: string) => shell.openExternal(url),
logBrowserOpenError: (url: string, error: unknown) =>
logger.error(`Failed to open browser for texthooker URL: ${url}`, error),
@@ -4480,7 +4508,10 @@ const shouldUseMinimalStartup = startupModeFlags.shouldUseMinimalStartup;
const shouldSkipHeavyStartup = startupModeFlags.shouldSkipHeavyStartup;
if (!appState.initialArgs || (!shouldUseMinimalStartup && !shouldSkipHeavyStartup)) {
if (isAnilistTrackingEnabled(getResolvedConfig())) {
void refreshAnilistClientSecretStateIfEnabled({ force: true }).catch((error) => {
void refreshAnilistClientSecretStateIfEnabled({
force: true,
allowSetupPrompt: false,
}).catch((error) => {
logger.error('Failed to refresh AniList client secret state during startup', error);
});
anilistStateRuntime.refreshRetryQueueState();

View File

@@ -13,6 +13,7 @@ export interface CliCommandRuntimeServiceContext {
showOsd: CliCommandRuntimeServiceDepsParams['mpv']['showOsd'];
getTexthookerPort: () => number;
setTexthookerPort: (port: number) => void;
getTexthookerWebsocketUrl: () => string | undefined;
shouldOpenBrowser: () => boolean;
openInBrowser: (url: string) => void;
isOverlayInitialized: () => boolean;
@@ -71,6 +72,7 @@ function createCliCommandDepsFromContext(
service: context.texthookerService,
getPort: context.getTexthookerPort,
setPort: context.setTexthookerPort,
getWebsocketUrl: context.getTexthookerWebsocketUrl,
shouldOpenBrowser: context.shouldOpenBrowser,
openInBrowser: context.openInBrowser,
},

View File

@@ -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'];
}
@@ -132,6 +137,7 @@ export interface CliCommandRuntimeServiceDepsParams {
service: CliCommandDepsRuntimeOptions['texthooker']['service'];
getPort: CliCommandDepsRuntimeOptions['texthooker']['getPort'];
setPort: CliCommandDepsRuntimeOptions['texthooker']['setPort'];
getWebsocketUrl: CliCommandDepsRuntimeOptions['texthooker']['getWebsocketUrl'];
shouldOpenBrowser: CliCommandDepsRuntimeOptions['texthooker']['shouldOpenBrowser'];
openInBrowser: CliCommandDepsRuntimeOptions['texthooker']['openInBrowser'];
};
@@ -192,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'];
@@ -246,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,
};
}
@@ -293,6 +305,7 @@ export function createCliCommandRuntimeServiceDeps(
service: params.texthooker.service,
getPort: params.texthooker.getPort,
setPort: params.texthooker.setPort,
getWebsocketUrl: params.texthooker.getWebsocketUrl,
shouldOpenBrowser: params.texthooker.shouldOpenBrowser,
openInBrowser: params.texthooker.openInBrowser,
},
@@ -356,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,

View File

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

View File

@@ -82,7 +82,42 @@ test('refresh handler prefers cached token when not forced', async () => {
assert.equal(loadCalls, 0);
});
test('refresh handler falls back to stored token then opens setup when missing', async () => {
test('refresh handler falls back to stored token without opening setup', async () => {
let cached: string | null = null;
let opened = false;
let openCalls = 0;
const states: Array<{ status: string; source: string }> = [];
const refresh = createRefreshAnilistClientSecretStateHandler({
getResolvedConfig: () => ({ anilist: { accessToken: '' } }),
isAnilistTrackingEnabled: () => true,
getCachedAccessToken: () => cached,
setCachedAccessToken: (token) => {
cached = token;
},
saveStoredToken: () => {},
loadStoredToken: () => ' stored-token ',
setClientSecretState: (state) => {
states.push({ status: state.status, source: state.source });
},
getAnilistSetupPageOpened: () => opened,
setAnilistSetupPageOpened: (next) => {
opened = next;
},
openAnilistSetupWindow: () => {
openCalls += 1;
},
now: () => 400,
});
const token = await refresh({ force: true });
assert.equal(token, 'stored-token');
assert.equal(cached, 'stored-token');
assert.equal(opened, false);
assert.equal(openCalls, 0);
assert.deepEqual(states, [{ status: 'resolved', source: 'stored' }]);
});
test('refresh handler opens setup when missing token and prompting allowed', async () => {
let cached: string | null = null;
let opened = false;
let openCalls = 0;
@@ -111,3 +146,44 @@ test('refresh handler falls back to stored token then opens setup when missing',
assert.equal(cached, null);
assert.equal(openCalls, 1);
});
test('refresh handler skips setup open when missing token and prompting disabled', async () => {
let cached: string | null = null;
let opened = false;
let openCalls = 0;
const states: Array<{ status: string; source: string; message: string | null }> = [];
const refresh = createRefreshAnilistClientSecretStateHandler({
getResolvedConfig: () => ({ anilist: { accessToken: '' } }),
isAnilistTrackingEnabled: () => true,
getCachedAccessToken: () => cached,
setCachedAccessToken: (token) => {
cached = token;
},
saveStoredToken: () => {},
loadStoredToken: () => '',
setClientSecretState: (state) => {
states.push({ status: state.status, source: state.source, message: state.message });
},
getAnilistSetupPageOpened: () => opened,
setAnilistSetupPageOpened: (next) => {
opened = next;
},
openAnilistSetupWindow: () => {
openCalls += 1;
},
now: () => 500,
});
const token = await refresh({ force: true, allowSetupPrompt: false });
assert.equal(token, null);
assert.equal(cached, null);
assert.equal(opened, false);
assert.equal(openCalls, 0);
assert.deepEqual(states, [
{
status: 'error',
source: 'none',
message: 'cannot authenticate without anilist.accessToken',
},
]);
});

View File

@@ -27,7 +27,10 @@ export function createRefreshAnilistClientSecretStateHandler<
openAnilistSetupWindow: () => void;
now: () => number;
}) {
return async (options?: { force?: boolean }): Promise<string | null> => {
return async (options?: {
force?: boolean;
allowSetupPrompt?: boolean;
}): Promise<string | null> => {
const resolved = deps.getResolvedConfig();
const now = deps.now();
if (!deps.isAnilistTrackingEnabled(resolved)) {
@@ -87,7 +90,11 @@ export function createRefreshAnilistClientSecretStateHandler<
resolvedAt: null,
errorAt: now,
});
if (deps.isAnilistTrackingEnabled(resolved) && !deps.getAnilistSetupPageOpened()) {
if (
options?.allowSetupPrompt !== false &&
deps.isAnilistTrackingEnabled(resolved) &&
!deps.getAnilistSetupPageOpened()
) {
deps.openAnilistSetupWindow();
}
return null;

View File

@@ -12,6 +12,7 @@ test('build cli command context deps maps handlers and values', () => {
texthookerService: { start: () => null, status: () => ({ running: false }) } as never,
getTexthookerPort: () => 5174,
setTexthookerPort: (port) => calls.push(`port:${port}`),
getTexthookerWebsocketUrl: () => 'ws://127.0.0.1:6678',
shouldOpenBrowser: () => true,
openExternal: async (url) => calls.push(`open:${url}`),
logBrowserOpenError: (url) => calls.push(`open-error:${url}`),
@@ -82,6 +83,7 @@ test('build cli command context deps maps handlers and values', () => {
const deps = buildDeps();
assert.equal(deps.getSocketPath(), '/tmp/mpv.sock');
assert.equal(deps.getTexthookerPort(), 5174);
assert.equal(deps.getTexthookerWebsocketUrl(), 'ws://127.0.0.1:6678');
assert.equal(deps.shouldOpenBrowser(), true);
assert.equal(deps.isOverlayInitialized(), true);
assert.equal(deps.hasMainWindow(), true);

View File

@@ -10,6 +10,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
texthookerService: CliCommandContextFactoryDeps['texthookerService'];
getTexthookerPort: () => number;
setTexthookerPort: (port: number) => void;
getTexthookerWebsocketUrl: () => string | undefined;
shouldOpenBrowser: () => boolean;
openExternal: (url: string) => Promise<unknown>;
logBrowserOpenError: (url: string, error: unknown) => void;
@@ -58,6 +59,7 @@ export function createBuildCliCommandContextDepsHandler(deps: {
texthookerService: deps.texthookerService,
getTexthookerPort: deps.getTexthookerPort,
setTexthookerPort: deps.setTexthookerPort,
getTexthookerWebsocketUrl: deps.getTexthookerWebsocketUrl,
shouldOpenBrowser: deps.shouldOpenBrowser,
openExternal: deps.openExternal,
logBrowserOpenError: deps.logBrowserOpenError,

View File

@@ -14,7 +14,13 @@ test('cli command context factory composes main deps and context handlers', () =
const createContext = createCliCommandContextFactory({
appState,
texthookerService: { isRunning: () => false, start: () => null },
getResolvedConfig: () => ({ texthooker: { openBrowser: true } }),
getResolvedConfig: () => ({
texthooker: { openBrowser: true },
annotationWebsocket: { enabled: true, port: 6678 },
}),
defaultWebsocketPort: 6677,
defaultAnnotationWebsocketPort: 6678,
hasMpvWebsocketPlugin: () => false,
openExternal: async () => {},
logBrowserOpenError: () => {},
showMpvOsd: (text) => calls.push(`osd:${text}`),

View File

@@ -14,7 +14,13 @@ test('cli command context main deps builder maps state and callbacks', async ()
const build = createBuildCliCommandContextMainDepsHandler({
appState,
texthookerService: { isRunning: () => false, start: () => null },
getResolvedConfig: () => ({ texthooker: { openBrowser: true } }),
getResolvedConfig: () => ({
texthooker: { openBrowser: true },
annotationWebsocket: { enabled: true, port: 6678 },
}),
defaultWebsocketPort: 6677,
defaultAnnotationWebsocketPort: 6678,
hasMpvWebsocketPlugin: () => false,
openExternal: async (url) => {
calls.push(`open:${url}`);
},
@@ -110,6 +116,7 @@ test('cli command context main deps builder maps state and callbacks', async ()
assert.equal(deps.getTexthookerPort(), 5174);
deps.setTexthookerPort(5175);
assert.equal(appState.texthookerPort, 5175);
assert.equal(deps.getTexthookerWebsocketUrl(), 'ws://127.0.0.1:6678');
assert.equal(deps.shouldOpenBrowser(), true);
deps.showOsd('hello');
deps.initializeOverlay();

View File

@@ -1,4 +1,5 @@
import type { CliArgs } from '../../cli/args';
import { resolveTexthookerWebsocketUrl } from '../../core/services/startup';
import type { CliCommandContextFactoryDeps } from './cli-command-context';
type CliCommandContextMainState = {
@@ -12,7 +13,14 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
appState: CliCommandContextMainState;
setLogLevel?: (level: NonNullable<CliArgs['logLevel']>) => void;
texthookerService: CliCommandContextFactoryDeps['texthookerService'];
getResolvedConfig: () => { texthooker?: { openBrowser?: boolean } };
getResolvedConfig: () => {
texthooker?: { openBrowser?: boolean };
websocket?: { enabled?: boolean | 'auto'; port?: number };
annotationWebsocket?: { enabled?: boolean; port?: number };
};
defaultWebsocketPort: number;
defaultAnnotationWebsocketPort: number;
hasMpvWebsocketPlugin: () => boolean;
openExternal: (url: string) => Promise<unknown>;
logBrowserOpenError: (url: string, error: unknown) => void;
showMpvOsd: (text: string) => void;
@@ -68,6 +76,15 @@ export function createBuildCliCommandContextMainDepsHandler(deps: {
setTexthookerPort: (port: number) => {
deps.appState.texthookerPort = port;
},
getTexthookerWebsocketUrl: () =>
resolveTexthookerWebsocketUrl(
deps.getResolvedConfig(),
{
defaultWebsocketPort: deps.defaultWebsocketPort,
defaultAnnotationWebsocketPort: deps.defaultAnnotationWebsocketPort,
},
deps.hasMpvWebsocketPlugin(),
),
shouldOpenBrowser: () => deps.getResolvedConfig().texthooker?.openBrowser !== false,
openExternal: (url: string) => deps.openExternal(url),
logBrowserOpenError: (url: string, error: unknown) => deps.logBrowserOpenError(url, error),

View File

@@ -18,6 +18,7 @@ function createDeps() {
texthookerService: {} as never,
getTexthookerPort: () => 6677,
setTexthookerPort: () => {},
getTexthookerWebsocketUrl: () => 'ws://127.0.0.1:6678',
shouldOpenBrowser: () => true,
openExternal: async () => {},
logBrowserOpenError: (url: string) => browserErrors.push(url),

View File

@@ -15,6 +15,7 @@ export type CliCommandContextFactoryDeps = {
texthookerService: CliCommandRuntimeServiceContextHandlers['texthookerService'];
getTexthookerPort: () => number;
setTexthookerPort: (port: number) => void;
getTexthookerWebsocketUrl: () => string | undefined;
shouldOpenBrowser: () => boolean;
openExternal: (url: string) => Promise<unknown>;
logBrowserOpenError: (url: string, error: unknown) => void;
@@ -67,6 +68,7 @@ export function createCliCommandContext(
texthookerService: deps.texthookerService,
getTexthookerPort: deps.getTexthookerPort,
setTexthookerPort: deps.setTexthookerPort,
getTexthookerWebsocketUrl: deps.getTexthookerWebsocketUrl,
shouldOpenBrowser: deps.shouldOpenBrowser,
openInBrowser: (url: string) => {
void deps.openExternal(url).catch((error) => {

View File

@@ -11,6 +11,9 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
setLogLevel: () => {},
texthookerService: {} as never,
getResolvedConfig: () => ({}) as never,
defaultWebsocketPort: 6677,
defaultAnnotationWebsocketPort: 6678,
hasMpvWebsocketPlugin: () => false,
openExternal: async () => {},
logBrowserOpenError: () => {},
showMpvOsd: () => {},

View File

@@ -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: {

View File

@@ -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: () => {},

View File

@@ -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: () => {},

View File

@@ -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',

View File

@@ -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(),

View File

@@ -0,0 +1,46 @@
import type { RegisterIpcRuntimeServicesParams } from '../ipc-runtime';
import {
appendPlaylistBrowserFileRuntime,
getPlaylistBrowserSnapshotRuntime,
movePlaylistBrowserIndexRuntime,
playPlaylistBrowserIndexRuntime,
removePlaylistBrowserIndexRuntime,
type PlaylistBrowserRuntimeDeps,
} from './playlist-browser-runtime';
type PlaylistBrowserMainDeps = Pick<
RegisterIpcRuntimeServicesParams['mainDeps'],
| 'getPlaylistBrowserSnapshot'
| 'appendPlaylistBrowserFile'
| 'playPlaylistBrowserIndex'
| 'removePlaylistBrowserIndex'
| 'movePlaylistBrowserIndex'
>;
export type PlaylistBrowserIpcRuntime = {
playlistBrowserRuntimeDeps: PlaylistBrowserRuntimeDeps;
playlistBrowserMainDeps: PlaylistBrowserMainDeps;
};
export function createPlaylistBrowserIpcRuntime(
getMpvClient: PlaylistBrowserRuntimeDeps['getMpvClient'],
): PlaylistBrowserIpcRuntime {
const playlistBrowserRuntimeDeps: PlaylistBrowserRuntimeDeps = {
getMpvClient,
};
return {
playlistBrowserRuntimeDeps,
playlistBrowserMainDeps: {
getPlaylistBrowserSnapshot: () => getPlaylistBrowserSnapshotRuntime(playlistBrowserRuntimeDeps),
appendPlaylistBrowserFile: (filePath: string) =>
appendPlaylistBrowserFileRuntime(playlistBrowserRuntimeDeps, filePath),
playPlaylistBrowserIndex: (index: number) =>
playPlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index),
removePlaylistBrowserIndex: (index: number) =>
removePlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index),
movePlaylistBrowserIndex: (index: number, direction: 1 | -1) =>
movePlaylistBrowserIndexRuntime(playlistBrowserRuntimeDeps, index, direction),
},
};
}

View File

@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { openPlaylistBrowser } from './playlist-browser-open';
test('playlist browser open bootstraps overlay runtime before dispatching the modal event', () => {
const calls: string[] = [];
const opened = openPlaylistBrowser({
ensureOverlayStartupPrereqs: () => {
calls.push('prereqs');
},
ensureOverlayWindowsReadyForVisibilityActions: () => {
calls.push('windows');
},
sendToActiveOverlayWindow: (channel, payload, runtimeOptions) => {
calls.push(`send:${channel}`);
assert.equal(payload, undefined);
assert.deepEqual(runtimeOptions, {
restoreOnModalClose: 'playlist-browser',
});
return true;
},
});
assert.equal(opened, true);
assert.deepEqual(calls, ['prereqs', 'windows', `send:${IPC_CHANNELS.event.playlistBrowserOpen}`]);
});

View File

@@ -0,0 +1,23 @@
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
const PLAYLIST_BROWSER_MODAL: OverlayHostedModal = 'playlist-browser';
export function openPlaylistBrowser(deps: {
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
}): boolean {
deps.ensureOverlayStartupPrereqs();
deps.ensureOverlayWindowsReadyForVisibilityActions();
return deps.sendToActiveOverlayWindow(IPC_CHANNELS.event.playlistBrowserOpen, undefined, {
restoreOnModalClose: PLAYLIST_BROWSER_MODAL,
});
}

View File

@@ -0,0 +1,487 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test, { type TestContext } from 'node:test';
import type { PlaylistBrowserQueueItem } from '../../types';
import {
appendPlaylistBrowserFileRuntime,
getPlaylistBrowserSnapshotRuntime,
movePlaylistBrowserIndexRuntime,
playPlaylistBrowserIndexRuntime,
removePlaylistBrowserIndexRuntime,
} from './playlist-browser-runtime';
type FakePlaylistEntry = {
current?: boolean;
playing?: boolean;
filename: string;
title?: string;
id?: number;
};
function createTempVideoDir(t: TestContext): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-playlist-browser-'));
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
return dir;
}
function createFakeMpvClient(options: {
currentVideoPath: string;
playlist: FakePlaylistEntry[];
connected?: boolean;
}) {
let playlist = options.playlist.map((item, index) => ({
id: item.id ?? index + 1,
current: item.current ?? false,
playing: item.playing ?? item.current ?? false,
filename: item.filename,
title: item.title ?? null,
}));
const commands: Array<(string | number)[]> = [];
const syncFlags = (): void => {
let playingIndex = playlist.findIndex((item) => item.current || item.playing);
if (playingIndex < 0 && playlist.length > 0) {
playingIndex = 0;
}
playlist = playlist.map((item, index) => ({
...item,
current: index === playingIndex,
playing: index === playingIndex,
}));
};
syncFlags();
return {
connected: options.connected ?? true,
currentVideoPath: options.currentVideoPath,
async requestProperty(name: string): Promise<unknown> {
if (name === 'playlist') {
return playlist;
}
if (name === 'playlist-playing-pos') {
return playlist.findIndex((item) => item.current || item.playing);
}
if (name === 'path') {
return this.currentVideoPath;
}
throw new Error(`Unexpected property: ${name}`);
},
send(payload: { command: unknown[] }): boolean {
const command = payload.command as (string | number)[];
commands.push(command);
const [action, first, second] = command;
if (action === 'loadfile' && typeof first === 'string' && second === 'append') {
playlist.push({
id: playlist.length + 1,
filename: first,
title: null,
current: false,
playing: false,
});
syncFlags();
return true;
}
if (action === 'playlist-play-index' && typeof first === 'number' && playlist[first]) {
playlist = playlist.map((item, index) => ({
...item,
current: index === first,
playing: index === first,
}));
this.currentVideoPath = playlist[first]!.filename;
return true;
}
if (action === 'playlist-remove' && typeof first === 'number' && playlist[first]) {
const removingCurrent = playlist[first]!.current || playlist[first]!.playing;
playlist.splice(first, 1);
if (removingCurrent) {
syncFlags();
this.currentVideoPath =
playlist.find((item) => item.current || item.playing)?.filename ?? this.currentVideoPath;
}
return true;
}
if (
action === 'playlist-move' &&
typeof first === 'number' &&
typeof second === 'number' &&
playlist[first]
) {
const [moved] = playlist.splice(first, 1);
playlist.splice(second, 0, moved!);
syncFlags();
return true;
}
return true;
},
getCommands(): Array<(string | number)[]> {
return commands;
},
};
}
test('getPlaylistBrowserSnapshotRuntime lists sibling videos in best-effort episode order', async (t) => {
const dir = createTempVideoDir(t);
const episode2 = path.join(dir, 'Show - S01E02.mkv');
const episode1 = path.join(dir, 'Show - S01E01.mkv');
const special = path.join(dir, 'Show - Special.mp4');
const ignored = path.join(dir, 'notes.txt');
fs.writeFileSync(episode2, '');
fs.writeFileSync(episode1, '');
fs.writeFileSync(special, '');
fs.writeFileSync(ignored, '');
const mpvClient = createFakeMpvClient({
currentVideoPath: episode2,
playlist: [
{ filename: episode1, current: false, playing: false, title: 'Episode 1' },
{ filename: episode2, current: true, playing: true, title: 'Episode 2' },
],
});
const snapshot = await getPlaylistBrowserSnapshotRuntime({
getMpvClient: () => mpvClient,
});
assert.equal(snapshot.directoryAvailable, true);
assert.equal(snapshot.directoryPath, dir);
assert.equal(snapshot.currentFilePath, episode2);
assert.equal(snapshot.playingIndex, 1);
assert.deepEqual(
snapshot.directoryItems.map((item) => [item.basename, item.isCurrentFile]),
[
['Show - S01E01.mkv', false],
['Show - S01E02.mkv', true],
['Show - Special.mp4', false],
],
);
assert.deepEqual(
snapshot.playlistItems.map((item) => ({
index: item.index,
displayLabel: item.displayLabel,
current: item.current,
})),
[
{ index: 0, displayLabel: 'Episode 1', current: false },
{ index: 1, displayLabel: 'Episode 2', current: true },
],
);
});
test('getPlaylistBrowserSnapshotRuntime clamps stale playing index to the playlist bounds', async (t) => {
const dir = createTempVideoDir(t);
const episode1 = path.join(dir, 'Show - S01E01.mkv');
const episode2 = path.join(dir, 'Show - S01E02.mkv');
fs.writeFileSync(episode1, '');
fs.writeFileSync(episode2, '');
const mpvClient = createFakeMpvClient({
currentVideoPath: episode1,
playlist: [
{ filename: episode1, current: true, playing: true, title: 'Episode 1' },
{ filename: episode2, title: 'Episode 2' },
],
});
const requestProperty = mpvClient.requestProperty.bind(mpvClient);
mpvClient.requestProperty = async (name: string): Promise<unknown> => {
if (name === 'playlist-playing-pos') {
return 99;
}
return requestProperty(name);
};
const snapshot = await getPlaylistBrowserSnapshotRuntime({
getMpvClient: () => mpvClient,
});
assert.equal(snapshot.playingIndex, 1);
});
test('getPlaylistBrowserSnapshotRuntime degrades directory pane for remote media', async () => {
const mpvClient = createFakeMpvClient({
currentVideoPath: 'https://example.com/video.m3u8',
playlist: [{ filename: 'https://example.com/video.m3u8', current: true }],
});
const snapshot = await getPlaylistBrowserSnapshotRuntime({
getMpvClient: () => mpvClient,
});
assert.equal(snapshot.directoryAvailable, false);
assert.equal(snapshot.directoryItems.length, 0);
assert.match(snapshot.directoryStatus, /local filesystem/i);
assert.equal(snapshot.playlistItems.length, 1);
});
test('playlist-browser mutation runtimes mutate queue and return refreshed snapshots', async (t) => {
const dir = createTempVideoDir(t);
const episode1 = path.join(dir, 'Show - S01E01.mkv');
const episode2 = path.join(dir, 'Show - S01E02.mkv');
const episode3 = path.join(dir, 'Show - S01E03.mkv');
fs.writeFileSync(episode1, '');
fs.writeFileSync(episode2, '');
fs.writeFileSync(episode3, '');
const mpvClient = createFakeMpvClient({
currentVideoPath: episode1,
playlist: [
{ filename: episode1, current: true, title: 'Episode 1' },
{ filename: episode2, title: 'Episode 2' },
],
});
const scheduled: Array<{ callback: () => void; delayMs: number }> = [];
const deps = {
getMpvClient: () => mpvClient,
schedule: (callback: () => void, delayMs: number) => {
scheduled.push({ callback, delayMs });
},
};
const appendResult = await appendPlaylistBrowserFileRuntime(deps, episode3);
assert.equal(appendResult.ok, true);
assert.deepEqual(mpvClient.getCommands().at(-1), ['loadfile', episode3, 'append']);
assert.deepEqual(
appendResult.snapshot?.playlistItems.map((item) => item.path),
[episode1, episode2, episode3],
);
const moveResult = await movePlaylistBrowserIndexRuntime(deps, 2, -1);
assert.equal(moveResult.ok, true);
assert.deepEqual(mpvClient.getCommands().at(-1), ['playlist-move', 2, 1]);
assert.deepEqual(
moveResult.snapshot?.playlistItems.map((item) => item.path),
[episode1, episode3, episode2],
);
const playResult = await playPlaylistBrowserIndexRuntime(deps, 1);
assert.equal(playResult.ok, true);
assert.deepEqual(mpvClient.getCommands().slice(-2), [
['set_property', 'sub-auto', 'fuzzy'],
['playlist-play-index', 1],
]);
assert.deepEqual(scheduled.map((entry) => entry.delayMs), [400]);
scheduled[0]?.callback();
assert.deepEqual(mpvClient.getCommands().slice(-2), [
['set_property', 'sid', 'auto'],
['set_property', 'secondary-sid', 'auto'],
]);
assert.equal(playResult.snapshot?.playingIndex, 1);
const removeResult = await removePlaylistBrowserIndexRuntime(deps, 2);
assert.equal(removeResult.ok, true);
assert.deepEqual(mpvClient.getCommands().at(-1), ['playlist-remove', 2]);
assert.deepEqual(
removeResult.snapshot?.playlistItems.map((item) => item.path),
[episode1, episode3],
);
});
test('playlist-browser mutation runtimes report MPV send rejection', async (t) => {
const dir = createTempVideoDir(t);
const episode1 = path.join(dir, 'Show - S01E01.mkv');
const episode2 = path.join(dir, 'Show - S01E02.mkv');
const episode3 = path.join(dir, 'Show - S01E03.mkv');
fs.writeFileSync(episode1, '');
fs.writeFileSync(episode2, '');
fs.writeFileSync(episode3, '');
const mpvClient = createFakeMpvClient({
currentVideoPath: episode1,
playlist: [
{ filename: episode1, current: true, title: 'Episode 1' },
{ filename: episode2, title: 'Episode 2' },
{ filename: episode3, title: 'Episode 3' },
],
});
const scheduled: Array<{ callback: () => void; delayMs: number }> = [];
mpvClient.send = () => false;
const deps = {
getMpvClient: () => mpvClient,
schedule: (callback: () => void, delayMs: number) => {
scheduled.push({ callback, delayMs });
},
};
const appendResult = await appendPlaylistBrowserFileRuntime(deps, episode3);
assert.equal(appendResult.ok, false);
assert.equal(appendResult.snapshot, undefined);
const playResult = await playPlaylistBrowserIndexRuntime(deps, 1);
assert.equal(playResult.ok, false);
assert.equal(playResult.snapshot, undefined);
assert.deepEqual(scheduled, []);
const removeResult = await removePlaylistBrowserIndexRuntime(deps, 1);
assert.equal(removeResult.ok, false);
assert.equal(removeResult.snapshot, undefined);
const moveResult = await movePlaylistBrowserIndexRuntime(deps, 1, 1);
assert.equal(moveResult.ok, false);
assert.equal(moveResult.snapshot, undefined);
});
test('appendPlaylistBrowserFileRuntime returns an error result when statSync throws', async (t) => {
const dir = createTempVideoDir(t);
const episode1 = path.join(dir, 'Show - S01E01.mkv');
fs.writeFileSync(episode1, '');
const mutableFs = fs as typeof fs & { statSync: typeof fs.statSync };
const originalStatSync = mutableFs.statSync;
mutableFs.statSync = ((targetPath: fs.PathLike) => {
if (path.resolve(String(targetPath)) === episode1) {
throw new Error('EACCES');
}
return originalStatSync(targetPath);
}) as typeof fs.statSync;
try {
const result = await appendPlaylistBrowserFileRuntime(
{
getMpvClient: () =>
createFakeMpvClient({
currentVideoPath: episode1,
playlist: [{ filename: episode1, current: true }],
}),
},
episode1,
);
assert.deepEqual(result, {
ok: false,
message: 'Playlist browser file is not readable.',
});
} finally {
mutableFs.statSync = originalStatSync;
}
});
test('movePlaylistBrowserIndexRuntime rejects top and bottom boundary moves', async (t) => {
const dir = createTempVideoDir(t);
const episode1 = path.join(dir, 'Show - S01E01.mkv');
const episode2 = path.join(dir, 'Show - S01E02.mkv');
fs.writeFileSync(episode1, '');
fs.writeFileSync(episode2, '');
const mpvClient = createFakeMpvClient({
currentVideoPath: episode1,
playlist: [
{ filename: episode1, current: true },
{ filename: episode2 },
],
});
const deps = {
getMpvClient: () => mpvClient,
};
const moveUp = await movePlaylistBrowserIndexRuntime(deps, 0, -1);
assert.deepEqual(moveUp, {
ok: false,
message: 'Playlist item is already at the top.',
});
const moveDown = await movePlaylistBrowserIndexRuntime(deps, 1, 1);
assert.deepEqual(moveDown, {
ok: false,
message: 'Playlist item is already at the bottom.',
});
});
test('getPlaylistBrowserSnapshotRuntime normalizes playlist labels from title then filename', async (t) => {
const dir = createTempVideoDir(t);
const episode1 = path.join(dir, 'Show - S01E01.mkv');
fs.writeFileSync(episode1, '');
const mpvClient = createFakeMpvClient({
currentVideoPath: episode1,
playlist: [{ filename: episode1, current: true, title: '' }],
});
const snapshot = await getPlaylistBrowserSnapshotRuntime({
getMpvClient: () => mpvClient,
});
const item = snapshot.playlistItems[0] as PlaylistBrowserQueueItem;
assert.equal(item.displayLabel, 'Show - S01E01.mkv');
assert.equal(item.path, episode1);
});
test('playPlaylistBrowserIndexRuntime skips local subtitle reset for remote playlist entries', async () => {
const scheduled: Array<{ callback: () => void; delayMs: number }> = [];
const mpvClient = createFakeMpvClient({
currentVideoPath: 'https://example.com/video-1.m3u8',
playlist: [
{ filename: 'https://example.com/video-1.m3u8', current: true, title: 'Episode 1' },
{ filename: 'https://example.com/video-2.m3u8', title: 'Episode 2' },
],
});
const result = await playPlaylistBrowserIndexRuntime(
{
getMpvClient: () => mpvClient,
schedule: (callback, delayMs) => {
scheduled.push({ callback, delayMs });
},
},
1,
);
assert.equal(result.ok, true);
assert.deepEqual(mpvClient.getCommands().slice(-1), [['playlist-play-index', 1]]);
assert.equal(scheduled.length, 0);
});
test('playPlaylistBrowserIndexRuntime ignores superseded local subtitle rearm callbacks', async (t) => {
const dir = createTempVideoDir(t);
const episode1 = path.join(dir, 'Show - S01E01.mkv');
const episode2 = path.join(dir, 'Show - S01E02.mkv');
const episode3 = path.join(dir, 'Show - S01E03.mkv');
fs.writeFileSync(episode1, '');
fs.writeFileSync(episode2, '');
fs.writeFileSync(episode3, '');
const scheduled: Array<() => void> = [];
const mpvClient = createFakeMpvClient({
currentVideoPath: episode1,
playlist: [
{ filename: episode1, current: true, title: 'Episode 1' },
{ filename: episode2, title: 'Episode 2' },
{ filename: episode3, title: 'Episode 3' },
],
});
const deps = {
getMpvClient: () => mpvClient,
schedule: (callback: () => void) => {
scheduled.push(callback);
},
};
const firstPlay = await playPlaylistBrowserIndexRuntime(deps, 1);
const secondPlay = await playPlaylistBrowserIndexRuntime(deps, 2);
assert.equal(firstPlay.ok, true);
assert.equal(secondPlay.ok, true);
assert.equal(scheduled.length, 2);
scheduled[0]?.();
scheduled[1]?.();
assert.deepEqual(
mpvClient.getCommands().slice(-6),
[
['set_property', 'sub-auto', 'fuzzy'],
['playlist-play-index', 1],
['set_property', 'sub-auto', 'fuzzy'],
['playlist-play-index', 2],
['set_property', 'sid', 'auto'],
['set_property', 'secondary-sid', 'auto'],
],
);
});

View File

@@ -0,0 +1,361 @@
import fs from 'node:fs';
import path from 'node:path';
import type {
PlaylistBrowserDirectoryItem,
PlaylistBrowserMutationResult,
PlaylistBrowserQueueItem,
PlaylistBrowserSnapshot,
} from '../../types';
import { isRemoteMediaPath } from '../../jimaku/utils';
import { hasVideoExtension } from '../../shared/video-extensions';
import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort';
type PlaylistLike = {
filename?: unknown;
title?: unknown;
id?: unknown;
current?: unknown;
playing?: unknown;
};
type MpvPlaylistBrowserClientLike = {
connected: boolean;
currentVideoPath?: string | null;
requestProperty?: (name: string) => Promise<unknown>;
send: (payload: { command: unknown[]; request_id?: number }) => boolean;
};
export type PlaylistBrowserRuntimeDeps = {
getMpvClient: () => MpvPlaylistBrowserClientLike | null;
schedule?: (callback: () => void, delayMs: number) => void;
};
const pendingLocalSubtitleSelectionRearms = new WeakMap<MpvPlaylistBrowserClientLike, number>();
function trimToNull(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
async function readProperty(
client: MpvPlaylistBrowserClientLike | null,
name: string,
): Promise<unknown> {
if (!client?.requestProperty) return null;
try {
return await client.requestProperty(name);
} catch {
return null;
}
}
async function resolveCurrentFilePath(
client: MpvPlaylistBrowserClientLike | null,
): Promise<string | null> {
const currentVideoPath = trimToNull(client?.currentVideoPath);
if (currentVideoPath) return currentVideoPath;
return trimToNull(await readProperty(client, 'path'));
}
function resolveDirectorySnapshot(
currentFilePath: string | null,
): Pick<PlaylistBrowserSnapshot, 'directoryAvailable' | 'directoryItems' | 'directoryPath' | 'directoryStatus'> {
if (!currentFilePath) {
return {
directoryAvailable: false,
directoryItems: [],
directoryPath: null,
directoryStatus: 'Current media path is unavailable.',
};
}
if (isRemoteMediaPath(currentFilePath)) {
return {
directoryAvailable: false,
directoryItems: [],
directoryPath: null,
directoryStatus: 'Directory browser requires a local filesystem video.',
};
}
const resolvedPath = path.resolve(currentFilePath);
const directoryPath = path.dirname(resolvedPath);
try {
const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
const videoPaths = entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter((name) => hasVideoExtension(path.extname(name)))
.map((name) => path.join(directoryPath, name));
const directoryItems: PlaylistBrowserDirectoryItem[] = sortPlaylistBrowserDirectoryItems(
videoPaths,
).map((item) => ({
...item,
isCurrentFile: item.path === resolvedPath,
}));
return {
directoryAvailable: true,
directoryItems,
directoryPath,
directoryStatus: directoryPath,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
directoryAvailable: false,
directoryItems: [],
directoryPath,
directoryStatus: `Could not read parent directory: ${message}`,
};
}
}
function normalizePlaylistItems(raw: unknown): PlaylistBrowserQueueItem[] {
if (!Array.isArray(raw)) return [];
return raw.map((entry, index) => {
const item = (entry ?? {}) as PlaylistLike;
const filename = trimToNull(item.filename) ?? '';
const title = trimToNull(item.title);
const normalizedPath =
filename && !isRemoteMediaPath(filename) ? path.resolve(filename) : trimToNull(filename);
return {
index,
id: typeof item.id === 'number' ? item.id : null,
filename,
title,
displayLabel:
title ?? (path.basename(filename || '') || filename || `Playlist item ${index + 1}`),
current: item.current === true,
playing: item.playing === true,
path: normalizedPath,
};
});
}
function ensureConnectedClient(
deps: PlaylistBrowserRuntimeDeps,
): MpvPlaylistBrowserClientLike | { ok: false; message: string } {
const client = deps.getMpvClient();
if (!client?.connected) {
return {
ok: false,
message: 'MPV is not connected.',
};
}
return client;
}
function buildRejectedCommandResult(): PlaylistBrowserMutationResult {
return {
ok: false,
message: 'Could not send command to MPV.',
};
}
async function getPlaylistItemsFromClient(
client: MpvPlaylistBrowserClientLike | null,
): Promise<PlaylistBrowserQueueItem[]> {
return normalizePlaylistItems(await readProperty(client, 'playlist'));
}
function resolvePlayingIndex(
playlistItems: PlaylistBrowserQueueItem[],
playingPosValue: unknown,
): number | null {
if (playlistItems.length === 0) {
return null;
}
if (typeof playingPosValue === 'number' && Number.isInteger(playingPosValue)) {
return Math.min(Math.max(playingPosValue, 0), playlistItems.length - 1);
}
const playingIndex = playlistItems.findIndex((item) => item.current || item.playing);
return playingIndex >= 0 ? playingIndex : null;
}
export async function getPlaylistBrowserSnapshotRuntime(
deps: PlaylistBrowserRuntimeDeps,
): Promise<PlaylistBrowserSnapshot> {
const client = deps.getMpvClient();
const currentFilePath = await resolveCurrentFilePath(client);
const [playlistItems, playingPosValue] = await Promise.all([
getPlaylistItemsFromClient(client),
readProperty(client, 'playlist-playing-pos'),
]);
return {
...resolveDirectorySnapshot(currentFilePath),
playlistItems,
playingIndex: resolvePlayingIndex(playlistItems, playingPosValue),
currentFilePath,
};
}
async function validatePlaylistIndex(
deps: PlaylistBrowserRuntimeDeps,
index: number,
): Promise<
| { ok: false; message: string }
| { ok: true; client: MpvPlaylistBrowserClientLike; playlistItems: PlaylistBrowserQueueItem[] }
> {
const client = ensureConnectedClient(deps);
if ('ok' in client) {
return client;
}
const playlistItems = await getPlaylistItemsFromClient(client);
if (!Number.isInteger(index) || index < 0 || index >= playlistItems.length) {
return {
ok: false,
message: 'Playlist item not found.',
};
}
return {
ok: true,
client,
playlistItems,
};
}
async function buildMutationResult(
message: string,
deps: PlaylistBrowserRuntimeDeps,
): Promise<PlaylistBrowserMutationResult> {
return {
ok: true,
message,
snapshot: await getPlaylistBrowserSnapshotRuntime(deps),
};
}
function rearmLocalSubtitleSelection(client: MpvPlaylistBrowserClientLike): void {
client.send({ command: ['set_property', 'sid', 'auto'] });
client.send({ command: ['set_property', 'secondary-sid', 'auto'] });
}
function prepareLocalSubtitleAutoload(client: MpvPlaylistBrowserClientLike): void {
client.send({ command: ['set_property', 'sub-auto', 'fuzzy'] });
}
function isLocalPlaylistItem(
item: PlaylistBrowserQueueItem | null | undefined,
): item is PlaylistBrowserQueueItem & { path: string } {
return Boolean(item?.path && !isRemoteMediaPath(item.path));
}
function scheduleLocalSubtitleSelectionRearm(
deps: PlaylistBrowserRuntimeDeps,
client: MpvPlaylistBrowserClientLike,
expectedPath: string,
): void {
const nextToken = (pendingLocalSubtitleSelectionRearms.get(client) ?? 0) + 1;
pendingLocalSubtitleSelectionRearms.set(client, nextToken);
(deps.schedule ?? setTimeout)(() => {
if (pendingLocalSubtitleSelectionRearms.get(client) !== nextToken) return;
pendingLocalSubtitleSelectionRearms.delete(client);
const currentPath = trimToNull(client.currentVideoPath);
if (currentPath && path.resolve(currentPath) !== expectedPath) {
return;
}
rearmLocalSubtitleSelection(client);
}, 400);
}
export async function appendPlaylistBrowserFileRuntime(
deps: PlaylistBrowserRuntimeDeps,
filePath: string,
): Promise<PlaylistBrowserMutationResult> {
const client = ensureConnectedClient(deps);
if ('ok' in client) {
return client;
}
const resolvedPath = path.resolve(filePath);
let stats: fs.Stats;
try {
stats = fs.statSync(resolvedPath);
} catch {
return {
ok: false,
message: 'Playlist browser file is not readable.',
};
}
if (!stats.isFile()) {
return {
ok: false,
message: 'Playlist browser file is not readable.',
};
}
if (!client.send({ command: ['loadfile', resolvedPath, 'append'] })) {
return buildRejectedCommandResult();
}
return buildMutationResult(`Queued ${path.basename(resolvedPath)}`, deps);
}
export async function playPlaylistBrowserIndexRuntime(
deps: PlaylistBrowserRuntimeDeps,
index: number,
): Promise<PlaylistBrowserMutationResult> {
const result = await validatePlaylistIndex(deps, index);
if (!result.ok) {
return result;
}
const targetItem = result.playlistItems[index] ?? null;
if (isLocalPlaylistItem(targetItem)) {
prepareLocalSubtitleAutoload(result.client);
}
if (!result.client.send({ command: ['playlist-play-index', index] })) {
return buildRejectedCommandResult();
}
if (isLocalPlaylistItem(targetItem)) {
scheduleLocalSubtitleSelectionRearm(deps, result.client, path.resolve(targetItem.path));
}
return buildMutationResult(`Playing playlist item ${index + 1}`, deps);
}
export async function removePlaylistBrowserIndexRuntime(
deps: PlaylistBrowserRuntimeDeps,
index: number,
): Promise<PlaylistBrowserMutationResult> {
const result = await validatePlaylistIndex(deps, index);
if (!result.ok) {
return result;
}
if (!result.client.send({ command: ['playlist-remove', index] })) {
return buildRejectedCommandResult();
}
return buildMutationResult(`Removed playlist item ${index + 1}`, deps);
}
export async function movePlaylistBrowserIndexRuntime(
deps: PlaylistBrowserRuntimeDeps,
index: number,
direction: -1 | 1,
): Promise<PlaylistBrowserMutationResult> {
const result = await validatePlaylistIndex(deps, index);
if (!result.ok) {
return result;
}
const targetIndex = index + direction;
if (targetIndex < 0) {
return {
ok: false,
message: 'Playlist item is already at the top.',
};
}
if (targetIndex >= result.playlistItems.length) {
return {
ok: false,
message: 'Playlist item is already at the bottom.',
};
}
if (!result.client.send({ command: ['playlist-move', index, targetIndex] })) {
return buildRejectedCommandResult();
}
return buildMutationResult(`Moved playlist item ${index + 1}`, deps);
}

View File

@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import test from 'node:test';
import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort';
test('sortPlaylistBrowserDirectoryItems prefers parsed season and episode order', () => {
const root = '/library/show';
const items = sortPlaylistBrowserDirectoryItems([
path.join(root, 'Show - S01E10.mkv'),
path.join(root, 'Show - S01E02.mkv'),
path.join(root, 'Show - S01E01.mkv'),
path.join(root, 'Show - Episode 7.mkv'),
path.join(root, 'Show - 01x03.mkv'),
]);
assert.deepEqual(
items.map((item) => item.basename),
[
'Show - S01E01.mkv',
'Show - S01E02.mkv',
'Show - 01x03.mkv',
'Show - Episode 7.mkv',
'Show - S01E10.mkv',
],
);
assert.deepEqual(
items.map((item) => item.episodeLabel),
['S1E1', 'S1E2', 'S1E3', 'E7', 'S1E10'],
);
});
test('sortPlaylistBrowserDirectoryItems falls back to deterministic natural ordering', () => {
const root = '/library/show';
const items = sortPlaylistBrowserDirectoryItems([
path.join(root, 'Show Part 10.mkv'),
path.join(root, 'Show Part 2.mkv'),
path.join(root, 'Show Part 1.mkv'),
path.join(root, 'Show Special.mkv'),
]);
assert.deepEqual(
items.map((item) => item.basename),
['Show Part 1.mkv', 'Show Part 2.mkv', 'Show Part 10.mkv', 'Show Special.mkv'],
);
assert.deepEqual(
items.map((item) => item.episodeLabel),
[null, null, null, null],
);
});

View File

@@ -0,0 +1,129 @@
import path from 'node:path';
type ParsedEpisodeKey = {
season: number | null;
episode: number;
};
type SortToken = string | number;
export type PlaylistBrowserSortedDirectoryItem = {
path: string;
basename: string;
episodeLabel: string | null;
};
const COLLATOR = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
function parseEpisodeKey(basename: string): ParsedEpisodeKey | null {
const name = basename.replace(/\.[^.]+$/, '');
const seasonEpisode = name.match(/(?:^|[^a-z0-9])s(\d{1,2})\s*e(\d{1,3})(?:$|[^a-z0-9])/i);
if (seasonEpisode) {
return {
season: Number(seasonEpisode[1]),
episode: Number(seasonEpisode[2]),
};
}
const seasonByX = name.match(/(?:^|[^a-z0-9])(\d{1,2})x(\d{1,3})(?:$|[^a-z0-9])/i);
if (seasonByX) {
return {
season: Number(seasonByX[1]),
episode: Number(seasonByX[2]),
};
}
const namedEpisode = name.match(
/(?:^|[^a-z0-9])(?:ep|episode|第)\s*(\d{1,3})(?:\s*(?:話|episode|ep))?(?:$|[^a-z0-9])/i,
);
if (namedEpisode) {
return {
season: null,
episode: Number(namedEpisode[1]),
};
}
return null;
}
function buildEpisodeLabel(parsed: ParsedEpisodeKey | null): string | null {
if (!parsed) return null;
if (parsed.season !== null) {
return `S${parsed.season}E${parsed.episode}`;
}
return `E${parsed.episode}`;
}
function tokenizeNaturalSort(basename: string): SortToken[] {
return basename
.toLowerCase()
.split(/(\d+)/)
.filter((token) => token.length > 0)
.map((token) => (/^\d+$/.test(token) ? Number(token) : token));
}
function compareNaturalTokens(left: SortToken[], right: SortToken[]): number {
const maxLength = Math.max(left.length, right.length);
for (let index = 0; index < maxLength; index += 1) {
const a = left[index];
const b = right[index];
if (a === undefined) return -1;
if (b === undefined) return 1;
if (typeof a === 'number' && typeof b === 'number') {
if (a !== b) return a - b;
continue;
}
const comparison = COLLATOR.compare(String(a), String(b));
if (comparison !== 0) return comparison;
}
return 0;
}
export function sortPlaylistBrowserDirectoryItems(
paths: string[],
): PlaylistBrowserSortedDirectoryItem[] {
return paths
.map((pathValue) => {
const basename = path.basename(pathValue);
const parsed = parseEpisodeKey(basename);
return {
path: pathValue,
basename,
parsed,
episodeLabel: buildEpisodeLabel(parsed),
naturalTokens: tokenizeNaturalSort(basename),
};
})
.sort((left, right) => {
if (left.parsed && right.parsed) {
if (
left.parsed.season !== null &&
right.parsed.season !== null &&
left.parsed.season !== right.parsed.season
) {
return left.parsed.season - right.parsed.season;
}
if (left.parsed.episode !== right.parsed.episode) {
return left.parsed.episode - right.parsed.episode;
}
} else if (left.parsed && !right.parsed) {
return -1;
} else if (!left.parsed && right.parsed) {
return 1;
}
const naturalComparison = compareNaturalTokens(left.naturalTokens, right.naturalTokens);
if (naturalComparison !== 0) {
return naturalComparison;
}
return COLLATOR.compare(left.basename, right.basename);
})
.map(({ path: itemPath, basename, episodeLabel }) => ({
path: itemPath,
basename,
episodeLabel,
}));
}

View File

@@ -38,6 +38,8 @@ import type {
SubsyncManualRunRequest,
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> =>

View File

@@ -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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -130,6 +130,7 @@ function describeCommand(command: (string | number)[]): string {
}
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
if (first === SPECIAL_COMMANDS.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';

View File

@@ -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();

View File

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

View File

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

View File

@@ -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',

View File

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