Compare commits

..

88 Commits

Author SHA1 Message Date
24667ad6c9 fix(review): address latest CodeRabbit comments 2026-03-19 23:49:55 -07:00
42028d0a4d fix(subtitle): unify annotation token filtering 2026-03-19 23:48:38 -07:00
4a01cebca6 feat(stats): rename all token display text to words
Replace every user-facing "token(s)" label, tooltip, and message in the
stats UI with "words" so the terminology is consistent and friendlier
(e.g. "Words Seen", "word occurrences", "3.4 / 100 words", "Words Today").

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:48:37 -07:00
3995c396f8 fix(review): address latest CodeRabbit comments 2026-03-19 23:13:43 -07:00
544cd8aaa0 fix(stats): address review follow-ups 2026-03-19 22:55:46 -07:00
1932d2e25e fix(stats): format stats navigation helper 2026-03-19 22:21:57 -07:00
2258ededbd Show anime progress from latest session position
- include anime ID in media detail data
- use latest session position for episode progress
- update stats UI and lookup tests
2026-03-19 21:57:04 -07:00
64a88020c9 feat(stats): add 'View Anime' navigation button in MediaDetailView
- Added onNavigateToAnime prop to MediaDetailView
- Show 'View Anime →' button in the top-right when viewing media from
  non-anime origins (overview/sessions)
- Extract animeId from available sessions to enable navigation
- Button is hidden when already viewing from anime origin

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-19 21:43:30 -07:00
0ea1746123 feat(stats): add media-detail navigation from Sessions rows; fix(tokenizer): exclude そうだ auxiliary-stem from annotations
- Added hover-revealed ↗ button on SessionRow that navigates to the
  anime media-detail view for the session's videoId
- Added `sessions` origin type to MediaDetailOrigin and
  openSessionsMediaDetail() / closeMediaDetail() handling so the
  back button returns correctly to the Sessions tab ("Back to Sessions")
- Wired onNavigateToMediaDetail down through SessionsTab → SessionRow
- Excluded tokens with MeCab POS3 = 助動詞語幹 (e.g. そうだ grammar tails)
  from subtitle annotation metadata so frequency, JLPT, and N+1 styling
  no longer apply to grammar-tail tokens
- Added annotation-stage unit test and end-to-end tokenizeSubtitle test
  for the そうだ exclusion path
- Updated docs-site changelog, immersion-tracking, and
  subtitle-annotations pages to reflect both changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 21:42:53 -07:00
59fa3b427d fix: exclude auxiliary grammar tails from subtitle annotations 2026-03-19 21:40:20 -07:00
ff95934f07 fix(launcher): address newest PR review feedback 2026-03-19 21:32:51 -07:00
c27ef90046 test(anki): cover non-blocking proxy enrichment 2026-03-19 21:32:32 -07:00
34ba602405 fix(stats): persist anime episode progress checkpoints 2026-03-19 21:31:47 -07:00
ecb4b07f43 docs: remove release cut note from changelog 2026-03-19 20:07:11 -07:00
1227706ac9 fix: address latest PR review feedback 2026-03-19 20:06:52 -07:00
9ad3ccfa38 fix(stats): address Claude review follow-ups 2026-03-19 19:55:05 -07:00
20f53c0b70 Switch known-word cache to incremental sync and doctor refresh
- Load persisted known-word cache on startup; reconcile adds/deletes/edits on timed sync
- Add `knownWords.addMinedWordsImmediately` (default `true`) for immediate mined-word updates
- Route full rebuild to explicit `subminer doctor --refresh-known-words` and expand tests/docs
2026-03-19 19:29:58 -07:00
72d78ba1ca chore: prepare release v0.7.0 2026-03-19 18:04:02 -07:00
43a0d11446 fix(subtitle): restore known and JLPT token annotations 2026-03-19 18:03:20 -07:00
1b5f0c6999 Normalize formatting in tracking snapshot and session detail test
- Collapse multiline JSX and import statements to single-line style
- No behavior changes; formatting-only cleanup
2026-03-19 17:04:36 -07:00
886c6ef1d7 cleanup 2026-03-19 15:47:05 -07:00
f2d6c70019 Fix stats command flow and tracking metrics regressions
- Route default `subminer stats` through attached `--stats`; keep daemon path for `--background`/`--stop`
- Update overview metrics: lookup rate uses lifetime Yomitan lookups per 100 tokens; new words dedupe by headword
- Suppress repeated macOS `Overlay loading...` OSD during fullscreen tracker flaps and improve session-detail chart scaling
- Add/adjust launcher, tracker query, stats server, IPC, overlay, and stats UI regression tests; add changelog fragments
2026-03-19 15:46:52 -07:00
274b0619ac fix(anki): address latest PR 19 review follow-ups 2026-03-19 08:47:31 -07:00
a954f62f55 Decouple stats daemon and preserve final mine OSD status
- Run `subminer stats -b` as a dedicated daemon process, independent from the overlay app
- Stop Anki progress spinner before showing final `✓`/`x` mine result so it is not overwritten
- Keep grammar/noise subtitle tokens hoverable while stripping annotation metadata
2026-03-18 23:49:27 -07:00
4d96ebf5c0 fix: reduce prefetched subtitle annotation delay 2026-03-18 23:47:33 -07:00
7a0d7a488b docs: redesign README for cleaner layout and scannability
- Condense features into bold-label paragraphs instead of H3 subsections
- Collapse install instructions into <details> sections
- Remove redundant screenshots (annotations-key, stats-vocabulary)
- Add AUR version badge
- Merge first-launch and setup steps into a single paragraph
- Add horizontal rule dividers between major sections
2026-03-18 23:35:17 -07:00
f916b65d7f fix: sync texthooker-ui annotation overrides 2026-03-18 19:32:51 -07:00
36627bf87d fix(anki): avoid unnecessary known-word cache restarts 2026-03-18 19:29:47 -07:00
ad1f66a842 feat: sync animated anki images to sentence audio 2026-03-18 19:21:12 -07:00
f4cce31d4a fix: align texthooker and stats formatting with CI expectations 2026-03-18 19:01:29 -07:00
ec56970646 fix(ci): install stats deps in release builds 2026-03-18 02:37:58 -07:00
48f10dbb03 chore(backlog): maintain task backlog and add changelog fragments
- Move completed tasks (85, 117, 118, 155) to backlog/completed/
- Delete superseded task files (166 verification, 172 drilldown)
- Add stats dashboard milestone m-1
- Add new tasks (190, 194)
- Update task metadata across remaining backlog items
- Add changelog fragments for stats, mpv args, and subtitle filtering
2026-03-18 02:25:07 -07:00
1cb129b0b7 chore: update README, gitignore, and add CLAUDE.md
- Refresh README with feature overview and screenshot embeds
- Add .superpowers/ and clean up duplicate gitignore entries
- Add CLAUDE.md project instructions
- Remove stale release/release-notes.md
2026-03-18 02:24:57 -07:00
af1505fbe6 docs: update config examples, docs site, and add screenshots
- Update config examples with word field, retention, and stats options
- Add immersion tracking documentation for retention presets
- Add Anki integration and configuration docs updates
- Add stats dashboard screenshots
2026-03-18 02:24:46 -07:00
97126caf4e feat(stats): add note ID resolution and session event handling improvements
- Add note ID resolution through merge redirects in stats API
- Build Anki note previews using configured field names
- Add session event helpers for merged note dedup and stable request keys
- Refactor SessionDetail to prevent redundant note info requests
- Add session event popover and API client tests
2026-03-18 02:24:38 -07:00
a0015dc75c feat: add configurable Anki word field with note ID merge tracking
- Extract word field config into reusable anki-field-config module
- Add ankiConnect.fields.word config option (default: "Expression")
- Replace hardcoded "Expression" field references across Anki integration
- Add note ID redirect tracking for merged/moved cards
- Support legacy ankiConnect.wordField migration path
2026-03-18 02:24:26 -07:00
61e1621137 perf: split stats app bundles by route 2026-03-18 00:05:51 -07:00
a5b1c0509d fix(stats): align session event popovers with chart plot area 2026-03-17 23:56:58 -07:00
e694963426 fix(stats): address PR 19 review follow-ups 2026-03-17 23:56:42 -07:00
a69254f976 feat(stats): show seek length in session event tooltip 2026-03-17 23:38:45 -07:00
a1348cf8e4 chore(backlog): add m-1 milestone for remaining stats fixes 2026-03-17 23:38:33 -07:00
f9b582582b fix(stats): load full session timelines by default 2026-03-17 22:37:34 -07:00
8f39416ff5 fix(stats): use yomitan tokens for subtitle counts 2026-03-17 22:33:08 -07:00
ecb41a490b feat(launcher): add mpv args passthrough 2026-03-17 21:51:52 -07:00
b061b265c2 chore(vendor): bump subminer-yomitan 2026-03-17 20:12:42 -07:00
f2b3af17d7 docs: update docs, add backlog tasks and change notes
Update configuration, immersion tracking, and mining workflow docs.
Add backlog tasks for upcoming work items and change notes for recent
features and fixes.
2026-03-17 20:12:42 -07:00
5698121996 chore: minor fixes and cleanup across services and renderer 2026-03-17 20:12:41 -07:00
f8e2ae4887 feat: overhaul stats dashboard with navigation, trends, and anime views
Add navigation state machine for tab/detail routing, anime overview
stats with Yomitan lookup rates, session word count accuracy fixes,
vocabulary tab hook order fix, simplified trends data fetching from
backend-aggregated endpoints, and improved session detail charts.
2026-03-17 20:12:41 -07:00
08a5401a7d feat: add background stats server daemon lifecycle
Implement `subminer stats -b` to start a background stats daemon and
`subminer stats -s` to stop it, with PID-based process lifecycle
management, single-instance lock bypass for daemon mode, and automatic
reuse of running daemon instances.
2026-03-17 20:12:41 -07:00
55ee12e87f feat: refactor immersion tracker queries and session word tracking
Add comprehensive query helpers for session deletion with word aggregate
refresh, known-words-per-session timeline, anime-level word summaries,
and trends dashboard aggregation. Track yomitanLookupCount in session
metrics and support bulk session operations.
2026-03-17 20:12:41 -07:00
a5a6426fe1 feat: add mark-as-watched keybinding and Yomitan lookup tracking
Add configurable keybinding to mark the current video as watched with
IPC plumbing between renderer and main process. Add event listener
infrastructure for tracking Yomitan dictionary lookups per session.
2026-03-17 20:12:41 -07:00
75f2c212c7 refactor: centralize watch threshold constant
Extract DEFAULT_MIN_WATCH_RATIO (0.85) into src/shared/watch-threshold.ts
to replace the hardcoded ANILIST_UPDATE_MIN_WATCH_RATIO in main.ts.
2026-03-17 20:05:08 -07:00
3dd337f518 update backlog tasks 2026-03-17 20:05:08 -07:00
94ec28b48c refactor: isolate character dictionary completion handling 2026-03-17 20:05:08 -07:00
078792e0b2 docs: refresh immersion and stats documentation 2026-03-17 20:05:08 -07:00
390ae1b2f2 feat: optimize stats dashboard data and components 2026-03-17 20:05:08 -07:00
11710f20db feat: stabilize startup sync and overlay/runtime paths 2026-03-17 20:05:08 -07:00
de574c04bd Add isolated typecheck test for get_frequency script
- add `scripts/get_frequency.test.ts` to verify `scripts/get_frequency.ts` typechecks alone
- remove duplicate `yomitanSession` property from runtime state/interface
2026-03-17 20:05:08 -07:00
a9e33618e7 chore: apply remaining workspace formatting and updates 2026-03-17 20:05:08 -07:00
77c35c770d chore: add stats lint/check wiring for CI 2026-03-17 20:05:08 -07:00
64e9821e7a chore(backlog): sync task metadata and archives 2026-03-17 20:05:08 -07:00
5c529802c6 fix(stats): restore cross-anime words table 2026-03-17 20:05:08 -07:00
8123a145c0 fix(plugin): add lowercase linux binary fallbacks 2026-03-17 20:05:07 -07:00
659118c20c docs: document stats page mining, word exclusions, and vocabulary UX improvements 2026-03-17 20:05:07 -07:00
929159bba5 test(renderer): verify excluded interjections remain visible as non-interactive text 2026-03-17 20:05:07 -07:00
a317019bb9 feat(tokenizer): exclude interjections and sound effects from subtitle annotations
- Filter out 感動詞 (interjection) POS1 tokens from annotation payloads
- Exclude common interjection terms (ああ, ええ, はあ, etc.)
- Exclude reduplicated kana SFX with optional trailing と
- shouldExcludeTokenFromSubtitleAnnotations checks both POS1 and term patterns
- filterSubtitleAnnotationTokens applied after annotation stage
2026-03-17 20:05:07 -07:00
5767667d51 feat(stats): add mine card from stats page with Yomitan bridge
- POST /api/stats/mine-card endpoint with mode=word|sentence|audio
- mode=word: creates full Yomitan card (definition/reading/pitch) via hidden search page bridge
- mode=sentence/audio: creates card directly with Lapis/Kiku flags
- Audio + image generated in parallel from source video via ffmpeg
- Respects all AnkiConnect config (AVIF, static, field mappings, metadata pattern)
- addYomitanNoteViaSearch calls window.__subminerAddNote exposed by Yomitan fork
- Syncs AnkiConnect URL to Yomitan before each word mine
2026-03-17 20:05:07 -07:00
a1f30fd482 feat(tracking): store secondary subtitle text and source path in occurrence data
- Add secondary_text column to imm_subtitle_lines with migration
- Pass currentSecondarySubText through recordSubtitleLine flow
- Include secondaryText and sourcePath in word/kanji occurrence queries
- Update all type interfaces (backend + frontend)
2026-03-17 20:05:07 -07:00
5a30446809 feat(stats): add click handler to bar charts for word detail navigation 2026-03-17 20:05:07 -07:00
6634255f43 feat(stats): fix truncated readings and improve word detail UX
- fullReading() reconstructs full word reading from headword + partial stored reading
- FrequencyRankTable always shows reading for every row
- Word highlighted in example sentences with underline style
- Bar chart clicks open word detail panel
2026-03-17 20:05:07 -07:00
a3ed8dcf3d feat(stats): add word exclusion list for vocabulary tab
- useExcludedWords hook manages excluded words in localStorage
- ExclusionManager modal for viewing/removing excluded words
- Exclusion button on WordDetailPanel header
- Filtered words affect stat cards, charts, frequency table, and word list
2026-03-17 20:05:07 -07:00
92c1557e46 refactor: split known words config from n-plus-one 2026-03-17 20:05:07 -07:00
04682a02cc feat: improve stats dashboard and annotation settings 2026-03-17 20:05:07 -07:00
650e95cdc3 Feature/renderer performance (#24) 2026-03-17 20:05:07 -07:00
46fbea902a Harden stats APIs and fix Electron Yomitan debug runtime
- Validate stats session IDs/limits and add AnkiConnect request timeouts
- Stabilize stats window/runtime lifecycle and tighten window security defaults
- Fix Electron CLI debug startup by unsetting `ELECTRON_RUN_AS_NODE` and wiring Yomitan session state
- Expand regression coverage for tracker queries/events ordering and session aggregates
- Update docs for stats dashboard usage and Yomitan lookup troubleshooting
2026-03-17 20:05:07 -07:00
93811ebfde fix(launcher): default stats cleanup to vocab mode 2026-03-17 20:05:07 -07:00
74544d79a7 docs: update stats dashboard docs, config, and releasing checklist
- Update dashboard tab descriptions to include Anime tab and richer session timelines
- Add autoOpenBrowser config option to stats section
- Add subminer stats cleanup command to changelog fragment
- Expand releasing checklist with doc verification, changelog lint, and build gates
2026-03-17 20:05:07 -07:00
536f0a1315 feat(stats): redesign session timeline and clean up vocabulary tab
- Replace cumulative line chart with activity-focused area chart showing per-interval new words
- Add total words as a blue line on a secondary right Y-axis
- Add pause shaded regions, seek markers, and card mined markers with numeric x-axis for reliable rendering
- Add clickable header logo with proper aspect ratio
- Remove unused "Hide particles & single kana" checkbox from vocabulary tab
2026-03-17 20:05:07 -07:00
ff2d9141bc feat(stats): add episodes completed and anime completed to tracking snapshot
- Query watched videos count and anime with all episodes watched
- Display in overview tracking snapshot
- Remove efficiency section from trends
2026-03-17 20:05:07 -07:00
249a7cada8 chore: remove implementation plan documents 2026-03-17 20:05:07 -07:00
9530445a95 feat: add AniList rate limiter and remaining backlog tasks 2026-03-17 20:05:07 -07:00
2d87dae6cc docs: update documentation site for stats dashboard and immersion tracking 2026-03-17 20:05:07 -07:00
0f44107beb feat(stats): build anime-centric stats dashboard frontend
5-tab React dashboard with Catppuccin Mocha theme:
- Overview: hero stats, streak calendar, watch time chart, recent sessions
- Anime: grid with cover art, episode list with completion %, detail view
- Trends: 15 charts across Activity, Efficiency, Anime, and Patterns
- Vocabulary: POS-filtered word/kanji lists with detail panels
- Sessions: expandable session history with event timeline

Features:
- Cross-tab navigation (anime <-> vocabulary)
- Global word detail panel overlay
- Expandable episode detail with Anki card links (Expression field)
- Per-anime multi-line trend charts
- Watch time by day-of-week and hour-of-day
- Collapsible sections with accessibility (aria-expanded)
- Card size selector for anime grid
- Cover art caching via AniList
- HTTP API client with file:// protocol fallback for Electron overlay
2026-03-17 20:05:07 -07:00
950263bd66 feat(stats): add launcher stats command and build integration
- Launcher stats subcommand with cleanup mode
- Stats frontend build integrated into Makefile
- CI workflow updated for stats package
- Config example updated with stats section
- mpv plugin menu entry for stats toggle
2026-03-17 20:05:07 -07:00
26fb5b4162 feat(stats): wire stats server, overlay, and CLI into main process
- Stats server auto-start on immersion tracker init
- Stats overlay toggle via keybinding and IPC
- Stats CLI command (subminer stats) with cleanup mode
- mpv plugin menu integration for stats toggle
- CLI args for --stats, --stats-cleanup, --stats-response-path
2026-03-17 20:04:40 -07:00
ffe5c6e1c6 feat(stats): add stats server, API endpoints, config, and Anki integration
- Hono HTTP server with 20+ REST endpoints for stats data
- Stats overlay BrowserWindow with toggle keybinding
- IPC channel definitions and preload bridge
- Stats config section (toggleKey, serverPort, autoStartServer, autoOpenBrowser)
- Config resolver for stats section
- AnkiConnect proxy endpoints (guiBrowse, notesInfo)
- Note ID passthrough in card mining callback chain
- Stats CLI command with autoOpenBrowser respect
2026-03-17 20:04:40 -07:00
fe8bb167c4 feat(immersion): add anime metadata, occurrence tracking, and schema upgrades
- Add imm_anime table with AniList integration
- Add imm_subtitle_lines, imm_word_line_occurrences, imm_kanji_line_occurrences
- Add POS fields (part_of_speech, pos1, pos2, pos3) to imm_words
- Add anime metadata parsing with guessit fallback
- Add video duration tracking and watched status
- Add episode, streak, trend, and word/kanji detail queries
- Deduplicate subtitle line recording within sessions
- Pass Anki note IDs through card mining callback chain
2026-03-17 20:01:23 -07:00
cc5d270b8e docs: add stats dashboard design docs, plans, and knowledge base
- Stats dashboard redesign design and implementation plans
- Episode detail and Anki card link design
- Internal knowledge base restructure
- Backlog tasks for testing, verification, and occurrence tracking
2026-03-17 20:01:23 -07:00
43 changed files with 268 additions and 1178 deletions

View File

@@ -1,11 +1,10 @@
--- ---
id: TASK-143 id: TASK-143
title: Keep character dictionary auto-sync non-blocking during startup title: Keep character dictionary auto-sync non-blocking during startup
status: In Progress status: Done
assignee: assignee: []
- codex
created_date: '2026-03-09 01:45' created_date: '2026-03-09 01:45'
updated_date: '2026-03-20 09:22' updated_date: '2026-03-18 05:28'
labels: labels:
- dictionary - dictionary
- startup - startup
@@ -34,20 +33,8 @@ Keep character dictionary auto-sync running in parallel during startup without d
- [x] #3 Regression coverage verifies auto-sync builds before the gate and only mutates Yomitan after the gate resolves. - [x] #3 Regression coverage verifies auto-sync builds before the gate and only mutates Yomitan after the gate resolves.
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a regression test for startup autoplay release surviving delayed mpv readiness or late subtitle refresh after dictionary sync.
2. Harden the autoplay-ready release path so paused startup keeps retrying until mpv is actually released or media changes, without resuming user-paused playback later.
3. Keep the existing character-dictionary revisit fixes and paused-startup OSD fixes aligned with the autoplay change, then run targeted runtime tests and typecheck.
<!-- SECTION:PLAN:END -->
## Implementation Notes ## Implementation Notes
<!-- SECTION:NOTES:BEGIN --> <!-- SECTION:NOTES:BEGIN -->
Added a small current-media tokenization gate in main runtime. Media changes reset the gate, the first tokenization-ready event marks it ready, and auto-sync now waits on that gate only before Yomitan dictionary inspection/import/settings updates. Snapshot generation and merged ZIP build still run immediately in parallel. Added a small current-media tokenization gate in main runtime. Media changes reset the gate, the first tokenization-ready event marks it ready, and auto-sync now waits on that gate only before Yomitan dictionary inspection/import/settings updates. Snapshot generation and merged ZIP build still run immediately in parallel.
2026-03-20: User reports startup remains paused after annotations/tokenization are visible and only resumes after character-dictionary generation/import finishes. Investigating autoplay-ready release regression vs dictionary sync completion refresh.
2026-03-20: Added startup autoplay retry-budget helper so paused startup retries cover the full plugin gate window instead of only ~2.8s. Verification: bun test src/main/runtime/startup-autoplay-release-policy.test.ts src/main/runtime/character-dictionary-auto-sync.test.ts src/main/runtime/startup-osd-sequencer.test.ts src/main/runtime/character-dictionary-auto-sync-completion.test.ts; bun run typecheck; bun run test:fast; bun run test:env; bun run build; bun run test:smoke:dist; runtime-compat verifier passed at .tmp/skill-verification/subminer-verify-20260320-022106-nM28Nk. Pending real installed-app/mpv validation.
<!-- SECTION:NOTES:END --> <!-- SECTION:NOTES:END -->

View File

@@ -1,67 +0,0 @@
---
id: TASK-192
title: Fix stale anime cover art after AniList reassignment
status: Done
assignee:
- codex
created_date: '2026-03-20 00:12'
updated_date: '2026-03-20 00:14'
labels:
- stats
- immersion-tracker
- anilist
milestone: m-1
dependencies: []
references:
- src/core/services/immersion-tracker-service.ts
- src/core/services/immersion-tracker/query.ts
- src/core/services/immersion-tracker-service.test.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Fix the stats anime-detail cover image path so reassigning an anime to a different AniList entry replaces the stored cover art bytes instead of keeping the previous image blob under updated metadata.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Reassigning an anime to a different AniList entry stores the new cover art bytes for that anime's videos
- [x] #2 Shared blob deduplication still works when multiple videos in the anime use the same new cover image
- [x] #3 Focused regression coverage proves stale cover blobs are replaced on reassignment
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a failing regression test that reassigns an anime twice with different downloaded cover bytes and asserts the resolved cover updates.
2. Update cover-art upsert logic so new blob bytes generate a new shared hash instead of reusing an existing hash for the row.
3. Run the focused immersion tracker service test file and record the result.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
2026-03-20: Created during live debugging of a user-reported stale anime profile picture after changing the AniList entry from the stats UI.
2026-03-20: Root cause was in `upsertCoverArt(...)`. When a row already had `cover_blob_hash`, a later AniList reassignment with a freshly downloaded cover reused the existing hash instead of hashing the new bytes, so the blob store kept serving the old image while metadata changed.
2026-03-20: Added a regression in `src/core/services/immersion-tracker-service.test.ts` that reassigns the same anime twice with different fetched image bytes and asserts the resolved anime cover changes to the second blob while both videos still deduplicate to one shared hash.
2026-03-20: Fixed `src/core/services/immersion-tracker/query.ts` so incoming cover blob bytes compute a fresh hash before falling back to an existing row hash. Existing hashes are now reused only when no new bytes were fetched.
2026-03-20: Verification commands run:
- `bun test src/core/services/immersion-tracker-service.test.ts`
- `bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh src/core/services/immersion-tracker/query.ts src/core/services/immersion-tracker-service.test.ts`
- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core src/core/services/immersion-tracker/query.ts src/core/services/immersion-tracker-service.test.ts`
2026-03-20: Verification results:
- focused service test: passed
- verifier lane selection: `core`
- verifier result: passed (`bun run typecheck`, `bun run test:fast`)
- verifier artifacts: `.tmp/skill-verification/subminer-verify-20260320-001433-IZLFqs/`
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed stale anime cover art after AniList reassignment by correcting cover-blob hash replacement in the immersion tracker storage layer. Reassignments now store the new fetched image bytes instead of reusing the previous blob hash from the row, while still deduplicating the updated image across videos in the same anime.
Added focused regression coverage that reproduces the exact failure mode: same anime reassigned twice with different cover downloads, with the second image expected to replace the first. Verified with the touched service test file plus the SubMiner `core` verification lane.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,33 +0,0 @@
---
id: TASK-211
title: Recover anime episode progress from subtitle timing when checkpoints are missing
status: Done
assignee:
- '@Codex'
created_date: '2026-03-20 10:15'
updated_date: '2026-03-20 10:22'
labels:
- stats
- bug
milestone: m-1
dependencies: []
references:
- src/core/services/immersion-tracker/query.ts
- src/core/services/immersion-tracker/__tests__/query.test.ts
---
## Description
Anime episode progress can still show `0%` for older sessions that have watch-time and subtitle timing but no persisted `ended_media_ms` checkpoint. Recover progress from the latest retained subtitle/event segment end so already-recorded sessions render a useful progress percentage.
## Acceptance Criteria
- [x] `getAnimeEpisodes` returns the latest known session position even when `ended_media_ms` is null but subtitle/event timing exists.
- [x] Existing ended-session metrics and aggregation totals do not regress.
- [x] Regression coverage locks the fallback behavior.
## Implementation Notes
Added a query-side fallback for anime episode progress: when the newest session for a video has no persisted `ended_media_ms`, `getAnimeEpisodes` now uses the latest retained subtitle-line or session-event `segment_end_ms` from that same session. This recovers useful progress for already-recorded sessions that have timing data but predate or missed checkpoint persistence.
Verification: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts` passed. `bun run typecheck` passed.

View File

@@ -1,43 +0,0 @@
---
id: TASK-212
title: Fix mac texthooker helper startup blocking mpv launch
status: In Progress
assignee: []
created_date: '2026-03-20 08:27'
updated_date: '2026-03-20 08:45'
labels:
- bug
- macos
- startup
dependencies: []
references:
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/startup.ts
- /Users/sudacode/projects/japanese/SubMiner/src/main.ts
- /Users/sudacode/projects/japanese/SubMiner/plugin/subminer/process.lua
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
`subminer` mpv auto-start on mac can stall before the video is usable because the helper process launched with `--texthooker` still runs heavy app-ready startup. Recent logs show the helper loading the Yomitan Chromium extension, emitting `Permission 'contextMenus' is unknown` warnings, then hitting Chromium runtime errors before SubMiner signals readiness back to the mpv plugin. The texthooker helper should take the minimal startup path needed to serve texthooker traffic without loading overlay/window-only startup work that can crash or delay readiness.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Launching SubMiner with `--texthooker` avoids heavy app-ready startup work that is not required for texthooker helper mode.
- [x] #2 A regression test covers texthooker helper startup so it fails if Yomitan extension loading is reintroduced on that path.
- [x] #3 The change preserves existing startup behavior for non-texthooker app launches.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Follow-up: user confirmed the root issue is the plugin auto-start ordering. Adjust mpv plugin sequencing so `--start` launches before any separate `--texthooker` helper, then verify plugin regressions still pass.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed the mac mpv startup hang caused by the `--texthooker` helper taking the full app-ready path. `runAppReadyRuntime` now fast-paths texthooker-only mode through minimal startup (`reloadConfig` plus CLI handling) so it no longer loads Yomitan or first-run setup work before serving texthooker traffic. Added regression coverage in `src/core/services/app-ready.test.ts`, then verified with `bun test src/core/services/app-ready.test.ts src/core/services/startup.test.ts`, `bun test src/cli/args.test.ts src/main/early-single-instance.test.ts src/main/runtime/stats-cli-command.test.ts`, and `bun run typecheck`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -1,42 +0,0 @@
---
id: TASK-213
title: Show character dictionary progress during paused startup waits
status: In Progress
assignee: []
created_date: '2026-03-20 08:59'
updated_date: '2026-03-20 09:22'
labels:
- bug
- ux
- dictionary
- startup
dependencies: []
references:
- >-
/Users/sudacode/projects/japanese/SubMiner/src/main/runtime/startup-osd-sequencer.ts
- >-
/Users/sudacode/projects/japanese/SubMiner/src/main/runtime/character-dictionary-auto-sync-notifications.ts
- /Users/sudacode/projects/japanese/SubMiner/src/main.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
During startup on mpv auto-start, character dictionary regeneration/update can be active while playback remains paused. The current startup OSD sequencer buffers dictionary progress behind annotation-loading OSD, which leaves the user with no visible dictionary-specific progress while the pause is active. Adjust the startup OSD sequencing so dictionary progress can surface once tokenization is ready during the paused startup window, without regressing later ready/failure handling.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 When tokenization is ready during startup, later character dictionary progress updates are shown on OSD even if annotation-loading state is still active.
- [ ] #2 Startup OSD completion/failure behavior for character dictionary sync remains coherent after the new progress ordering.
- [ ] #3 Regression coverage exercises the paused startup sequencing for dictionary progress.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
2026-03-20: Confirmed issue is broader than OSD-only. Paused-startup OSD fixes remain relevant, but current user report also points at a regression in non-blocking startup playback release (tracked in TASK-143).
2026-03-20: OSD sequencing fix remains in local patch alongside TASK-143 regression fix. Covered by startup-osd-sequencer tests; pending installed-app/mpv validation before task finalization.
<!-- SECTION:NOTES:END -->

View File

@@ -1,4 +0,0 @@
type: changed
area: docs
- Refreshed the vendored Texthooker docs/index.html bundle to match the latest local build artifacts.

View File

@@ -1,4 +0,0 @@
type: fixed
area: stats
- Anime episode progress now falls back to the latest retained subtitle/event timing when a session is missing a persisted playback-position checkpoint, so older watch sessions no longer get stuck at `0%` progress.

View File

@@ -0,0 +1,46 @@
<!-- read_when: changing known-word cache lifecycle, stats cache semantics, or Anki sync behavior -->
# Incremental Known-Word Cache Sync
## Goal
Stop rebuilding the entire known-word cache on startup or routine refreshes. Keep the cache correct through incremental reconciliation on the configured sync cadence, with an immediate append path for freshly mined cards.
## Scope
- Persist per-note extracted known-word snapshots beside the existing global `words` list.
- Replace startup refresh with load-only behavior.
- Make timed refresh diff current Anki note IDs against cached note IDs, then apply add/remove/edit deltas.
- Add `ankiConnect.knownWords.addMinedWordsImmediately`, default `true`.
- Keep full rebuild out of normal lifecycle; reserve it for explicit doctor tooling.
## Data Model
Persist versioned cache state with:
- `words`: deduplicated global known-word set for stats/UI consumers
- `notes`: record of `noteId -> extractedWords[]`
- `refreshedAtMs`
- `scope`
The in-memory manager derives the global set from the per-note snapshots during sync updates so deletes and edits can remove stale words safely.
## Sync Behavior
- Startup: load persisted state only
- Interval tick or explicit refresh command: run incremental sync
- Incremental sync:
- query tracked note IDs for configured deck scope
- remove note snapshots for note IDs that disappeared
- fetch `notesInfo` for note IDs that are new or need field reconciliation
- compare extracted words per note and update the global set
## Immediate Mining Path
When SubMiner already has fresh `noteInfo` after mining or updating a note, append/update that note snapshot immediately if `addMinedWordsImmediately` is enabled.
## Verification
- focused cache manager tests for add/delete/edit reconciliation
- focused integration/config tests for startup behavior and new config flag
- config verification lane because defaults/schema/example change

View File

@@ -48,64 +48,6 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
}; };
} }
type StatsTestArgOverrides = {
stats?: boolean;
statsBackground?: boolean;
statsCleanup?: boolean;
statsCleanupVocab?: boolean;
statsCleanupLifetime?: boolean;
statsStop?: boolean;
logLevel?: LauncherCommandContext['args']['logLevel'];
};
function createStatsTestHarness(overrides: StatsTestArgOverrides = {}) {
const context = createContext();
const forwarded: string[][] = [];
const removedPaths: string[] = [];
const createTempDir = (_prefix: string) => {
const created = `/tmp/subminer-stats-test`;
return created;
};
const joinPath = (...parts: string[]) => parts.join('/');
const removeDir = (targetPath: string) => {
removedPaths.push(targetPath);
};
const runAppCommandAttachedStub = async (
_appPath: string,
appArgs: string[],
_logLevel: LauncherCommandContext['args']['logLevel'],
_label: string,
) => {
forwarded.push(appArgs);
return 0;
};
const waitForStatsResponseStub = async () => ({ ok: true, url: 'http://127.0.0.1:5175' });
context.args = {
...context.args,
stats: true,
...overrides,
};
return {
context,
forwarded,
removedPaths,
createTempDir,
joinPath,
removeDir,
runAppCommandAttachedStub,
waitForStatsResponseStub,
commandDeps: {
createTempDir,
joinPath,
runAppCommandAttached: runAppCommandAttachedStub,
waitForStatsResponse: waitForStatsResponseStub,
removeDir,
},
};
}
test('config command writes newline-terminated path via process adapter', () => { test('config command writes newline-terminated path via process adapter', () => {
const writes: string[] = []; const writes: string[] = [];
const context = createContext(); const context = createContext();
@@ -215,11 +157,24 @@ test('dictionary command throws if app handoff unexpectedly returns', () => {
}); });
test('stats command launches attached app command with response path', async () => { test('stats command launches attached app command with response path', async () => {
const harness = createStatsTestHarness({ stats: true, logLevel: 'debug' }); const context = createContext();
const handled = await runStatsCommand(harness.context, harness.commandDeps); context.args.stats = true;
context.args.logLevel = 'debug';
const forwarded: string[][] = [];
const handled = await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async (_appPath, appArgs) => {
forwarded.push(appArgs);
return 0;
},
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
removeDir: () => {},
});
assert.equal(handled, true); assert.equal(handled, true);
assert.deepEqual(harness.forwarded, [ assert.deepEqual(forwarded, [
[ [
'--stats', '--stats',
'--stats-response-path', '--stats-response-path',
@@ -228,34 +183,50 @@ test('stats command launches attached app command with response path', async ()
'debug', 'debug',
], ],
]); ]);
assert.equal(harness.removedPaths.length, 1);
}); });
test('stats background command launches attached daemon control command with response path', async () => { test('stats background command launches attached daemon control command with response path', async () => {
const harness = createStatsTestHarness({ stats: true, statsBackground: true }); const context = createContext();
const handled = await runStatsCommand(harness.context, harness.commandDeps); context.args.stats = true;
(context.args as typeof context.args & { statsBackground?: boolean }).statsBackground = true;
const forwarded: string[][] = [];
const handled = await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async (_appPath, appArgs) => {
forwarded.push(appArgs);
return 0;
},
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
removeDir: () => {},
} as Parameters<typeof runStatsCommand>[1]);
assert.equal(handled, true); assert.equal(handled, true);
assert.deepEqual(harness.forwarded, [ assert.deepEqual(forwarded, [
[ [
'--stats-daemon-start', '--stats-daemon-start',
'--stats-response-path', '--stats-response-path',
'/tmp/subminer-stats-test/response.json', '/tmp/subminer-stats-test/response.json',
], ],
]); ]);
assert.equal(harness.removedPaths.length, 1);
}); });
test('stats command waits for attached app exit after startup response', async () => { test('stats command waits for attached app exit after startup response', async () => {
const harness = createStatsTestHarness({ stats: true }); const context = createContext();
context.args.stats = true;
const forwarded: string[][] = [];
const started = new Promise<number>((resolve) => setTimeout(() => resolve(0), 20)); const started = new Promise<number>((resolve) => setTimeout(() => resolve(0), 20));
const statsCommand = runStatsCommand(harness.context, { const statsCommand = runStatsCommand(context, {
...harness.commandDeps, createTempDir: () => '/tmp/subminer-stats-test',
runAppCommandAttached: async (...args) => { joinPath: (...parts) => parts.join('/'),
await harness.runAppCommandAttachedStub(...args); runAppCommandAttached: async (_appPath, appArgs) => {
forwarded.push(appArgs);
return started; return started;
}, },
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
removeDir: () => {},
}); });
const result = await Promise.race([ const result = await Promise.race([
statsCommand.then(() => 'resolved'), statsCommand.then(() => 'resolved'),
@@ -266,46 +237,53 @@ test('stats command waits for attached app exit after startup response', async (
const final = await statsCommand; const final = await statsCommand;
assert.equal(final, true); assert.equal(final, true);
assert.deepEqual(harness.forwarded, [ assert.deepEqual(forwarded, [
[ [
'--stats', '--stats',
'--stats-response-path', '--stats-response-path',
'/tmp/subminer-stats-test/response.json', '/tmp/subminer-stats-test/response.json',
], ],
]); ]);
assert.equal(harness.removedPaths.length, 1);
}); });
test('stats command throws when attached app exits non-zero after startup response', async () => { test('stats command throws when attached app exits non-zero after startup response', async () => {
const harness = createStatsTestHarness({ stats: true }); const context = createContext();
context.args.stats = true;
await assert.rejects(async () => { await assert.rejects(async () => {
await runStatsCommand(harness.context, { await runStatsCommand(context, {
...harness.commandDeps, createTempDir: () => '/tmp/subminer-stats-test',
runAppCommandAttached: async (...args) => { joinPath: (...parts) => parts.join('/'),
await harness.runAppCommandAttachedStub(...args); runAppCommandAttached: async () => {
await new Promise((resolve) => setTimeout(resolve, 10)); await new Promise((resolve) => setTimeout(resolve, 10));
return 3; return 3;
}, },
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
removeDir: () => {},
}); });
}, /Stats app exited with status 3\./); }, /Stats app exited with status 3\./);
assert.equal(harness.removedPaths.length, 1);
}); });
test('stats cleanup command forwards cleanup vocab flags to the app', async () => { test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
const harness = createStatsTestHarness({ const context = createContext();
stats: true, context.args.stats = true;
statsCleanup: true, context.args.statsCleanup = true;
statsCleanupVocab: true, context.args.statsCleanupVocab = true;
}); const forwarded: string[][] = [];
const handled = await runStatsCommand(harness.context, {
...harness.commandDeps, const handled = await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async (_appPath, appArgs) => {
forwarded.push(appArgs);
return 0;
},
waitForStatsResponse: async () => ({ ok: true }), waitForStatsResponse: async () => ({ ok: true }),
removeDir: () => {},
}); });
assert.equal(handled, true); assert.equal(handled, true);
assert.deepEqual(harness.forwarded, [ assert.deepEqual(forwarded, [
[ [
'--stats', '--stats',
'--stats-response-path', '--stats-response-path',
@@ -314,62 +292,76 @@ test('stats cleanup command forwards cleanup vocab flags to the app', async () =
'--stats-cleanup-vocab', '--stats-cleanup-vocab',
], ],
]); ]);
assert.equal(harness.removedPaths.length, 1);
}); });
test('stats stop command forwards stop flag to the app', async () => { test('stats stop command forwards stop flag to the app', async () => {
const harness = createStatsTestHarness({ stats: true, statsStop: true }); const context = createContext();
context.args.stats = true;
(context.args as typeof context.args & { statsStop?: boolean }).statsStop = true;
const forwarded: string[][] = [];
const handled = await runStatsCommand(harness.context, { const handled = await runStatsCommand(context, {
...harness.commandDeps, createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async (_appPath, appArgs) => {
forwarded.push(appArgs);
return 0;
},
waitForStatsResponse: async () => ({ ok: true }), waitForStatsResponse: async () => ({ ok: true }),
removeDir: () => {},
}); });
assert.equal(handled, true); assert.equal(handled, true);
assert.deepEqual(harness.forwarded, [ assert.deepEqual(forwarded, [
[ [
'--stats-daemon-stop', '--stats-daemon-stop',
'--stats-response-path', '--stats-response-path',
'/tmp/subminer-stats-test/response.json', '/tmp/subminer-stats-test/response.json',
], ],
]); ]);
assert.equal(harness.removedPaths.length, 1);
}); });
test('stats stop command exits on process exit without waiting for startup response', async () => { test('stats stop command exits on process exit without waiting for startup response', async () => {
const harness = createStatsTestHarness({ stats: true, statsStop: true }); const context = createContext();
context.args.stats = true;
(context.args as typeof context.args & { statsStop?: boolean }).statsStop = true;
let waitedForResponse = false; let waitedForResponse = false;
const handled = await runStatsCommand(harness.context, { const handled = await runStatsCommand(context, {
...harness.commandDeps, createTempDir: () => '/tmp/subminer-stats-test',
runAppCommandAttached: async (...args) => { joinPath: (...parts) => parts.join('/'),
await harness.runAppCommandAttachedStub(...args); runAppCommandAttached: async () => 0,
return 0;
},
waitForStatsResponse: async () => { waitForStatsResponse: async () => {
waitedForResponse = true; waitedForResponse = true;
return { ok: true }; return { ok: true };
}, },
removeDir: () => {},
}); });
assert.equal(handled, true); assert.equal(handled, true);
assert.equal(waitedForResponse, false); assert.equal(waitedForResponse, false);
assert.equal(harness.removedPaths.length, 1);
}); });
test('stats cleanup command forwards lifetime rebuild flag to the app', async () => { test('stats cleanup command forwards lifetime rebuild flag to the app', async () => {
const harness = createStatsTestHarness({ const context = createContext();
stats: true, context.args.stats = true;
statsCleanup: true, context.args.statsCleanup = true;
statsCleanupLifetime: true, context.args.statsCleanupLifetime = true;
}); const forwarded: string[][] = [];
const handled = await runStatsCommand(harness.context, {
...harness.commandDeps, const handled = await runStatsCommand(context, {
createTempDir: () => '/tmp/subminer-stats-test',
joinPath: (...parts) => parts.join('/'),
runAppCommandAttached: async (_appPath, appArgs) => {
forwarded.push(appArgs);
return 0;
},
waitForStatsResponse: async () => ({ ok: true }), waitForStatsResponse: async () => ({ ok: true }),
removeDir: () => {},
}); });
assert.equal(handled, true); assert.equal(handled, true);
assert.deepEqual(harness.forwarded, [ assert.deepEqual(forwarded, [
[ [
'--stats', '--stats',
'--stats-response-path', '--stats-response-path',
@@ -378,64 +370,56 @@ test('stats cleanup command forwards lifetime rebuild flag to the app', async ()
'--stats-cleanup-lifetime', '--stats-cleanup-lifetime',
], ],
]); ]);
assert.equal(harness.removedPaths.length, 1);
}); });
test('stats command throws when stats response reports an error', async () => { test('stats command throws when stats response reports an error', async () => {
const harness = createStatsTestHarness({ stats: true }); const context = createContext();
context.args.stats = true;
await assert.rejects(async () => { await assert.rejects(async () => {
await runStatsCommand(harness.context, { await runStatsCommand(context, {
...harness.commandDeps, createTempDir: () => '/tmp/subminer-stats-test',
runAppCommandAttached: async (...args) => { joinPath: (...parts) => parts.join('/'),
await harness.runAppCommandAttachedStub(...args); runAppCommandAttached: async () => 0,
return 0;
},
waitForStatsResponse: async () => ({ waitForStatsResponse: async () => ({
ok: false, ok: false,
error: 'Immersion tracking is disabled in config.', error: 'Immersion tracking is disabled in config.',
}), }),
removeDir: () => {},
}); });
}, /Immersion tracking is disabled in config\./); }, /Immersion tracking is disabled in config\./);
assert.equal(harness.removedPaths.length, 1);
}); });
test('stats cleanup command fails if attached app exits before startup response', async () => { test('stats cleanup command fails if attached app exits before startup response', async () => {
const harness = createStatsTestHarness({ const context = createContext();
stats: true, context.args.stats = true;
statsCleanup: true, context.args.statsCleanup = true;
statsCleanupVocab: true, context.args.statsCleanupVocab = true;
});
await assert.rejects(async () => { await assert.rejects(async () => {
await runStatsCommand(harness.context, { await runStatsCommand(context, {
...harness.commandDeps, createTempDir: () => '/tmp/subminer-stats-test',
runAppCommandAttached: async (...args) => { joinPath: (...parts) => parts.join('/'),
await harness.runAppCommandAttachedStub(...args); runAppCommandAttached: async () => 2,
return 2;
},
waitForStatsResponse: async () => { waitForStatsResponse: async () => {
await new Promise((resolve) => setTimeout(resolve, 25)); await new Promise((resolve) => setTimeout(resolve, 25));
return { ok: true, url: 'http://127.0.0.1:5175' }; return { ok: true, url: 'http://127.0.0.1:5175' };
}, },
removeDir: () => {},
}); });
}, /Stats app exited before startup response \(status 2\)\./); }, /Stats app exited before startup response \(status 2\)\./);
assert.equal(harness.removedPaths.length, 1);
}); });
test('stats command aborts pending response wait when app exits before startup response', async () => { test('stats command aborts pending response wait when app exits before startup response', async () => {
const harness = createStatsTestHarness({ stats: true }); const context = createContext();
context.args.stats = true;
let aborted = false; let aborted = false;
await assert.rejects(async () => { await assert.rejects(async () => {
await runStatsCommand(harness.context, { await runStatsCommand(context, {
...harness.commandDeps, createTempDir: () => '/tmp/subminer-stats-test',
runAppCommandAttached: async (...args) => { joinPath: (...parts) => parts.join('/'),
await harness.runAppCommandAttachedStub(...args); runAppCommandAttached: async () => 2,
return 2;
},
waitForStatsResponse: async (_responsePath, signal) => waitForStatsResponse: async (_responsePath, signal) =>
await new Promise((resolve) => { await new Promise((resolve) => {
signal?.addEventListener( signal?.addEventListener(
@@ -447,24 +431,25 @@ test('stats command aborts pending response wait when app exits before startup r
{ once: true }, { once: true },
); );
}), }),
removeDir: () => {},
}); });
}, /Stats app exited before startup response \(status 2\)\./); }, /Stats app exited before startup response \(status 2\)\./);
assert.equal(aborted, true); assert.equal(aborted, true);
assert.equal(harness.removedPaths.length, 1);
}); });
test('stats command aborts pending response wait when attached app fails to spawn', async () => { test('stats command aborts pending response wait when attached app fails to spawn', async () => {
const harness = createStatsTestHarness({ stats: true }); const context = createContext();
context.args.stats = true;
const spawnError = new Error('spawn failed'); const spawnError = new Error('spawn failed');
let aborted = false; let aborted = false;
await assert.rejects( await assert.rejects(
async () => { async () => {
await runStatsCommand(harness.context, { await runStatsCommand(context, {
...harness.commandDeps, createTempDir: () => '/tmp/subminer-stats-test',
runAppCommandAttached: async (...args) => { joinPath: (...parts) => parts.join('/'),
await harness.runAppCommandAttachedStub(...args); runAppCommandAttached: async () => {
throw spawnError; throw spawnError;
}, },
waitForStatsResponse: async (_responsePath, signal) => waitForStatsResponse: async (_responsePath, signal) =>
@@ -478,30 +463,27 @@ test('stats command aborts pending response wait when attached app fails to spaw
{ once: true }, { once: true },
); );
}), }),
removeDir: () => {},
}); });
}, },
(error: unknown) => error === spawnError, (error: unknown) => error === spawnError,
); );
assert.equal(aborted, true); assert.equal(aborted, true);
assert.equal(harness.removedPaths.length, 1);
}); });
test('stats cleanup command aborts pending response wait when app exits before startup response', async () => { test('stats cleanup command aborts pending response wait when app exits before startup response', async () => {
const harness = createStatsTestHarness({ const context = createContext();
stats: true, context.args.stats = true;
statsCleanup: true, context.args.statsCleanup = true;
statsCleanupVocab: true, context.args.statsCleanupVocab = true;
});
let aborted = false; let aborted = false;
await assert.rejects(async () => { await assert.rejects(async () => {
await runStatsCommand(harness.context, { await runStatsCommand(context, {
...harness.commandDeps, createTempDir: () => '/tmp/subminer-stats-test',
runAppCommandAttached: async (...args) => { joinPath: (...parts) => parts.join('/'),
await harness.runAppCommandAttachedStub(...args); runAppCommandAttached: async () => 2,
return 2;
},
waitForStatsResponse: async (_responsePath, signal) => waitForStatsResponse: async (_responsePath, signal) =>
await new Promise((resolve) => { await new Promise((resolve) => {
signal?.addEventListener( signal?.addEventListener(
@@ -513,9 +495,9 @@ test('stats cleanup command aborts pending response wait when app exits before s
{ once: true }, { once: true },
); );
}), }),
removeDir: () => {},
}); });
}, /Stats app exited before startup response \(status 2\)\./); }, /Stats app exited before startup response \(status 2\)\./);
assert.equal(aborted, true); assert.equal(aborted, true);
assert.equal(harness.removedPaths.length, 1);
}); });

View File

@@ -372,9 +372,12 @@ function M.create(ctx)
end) end)
end end
launch_overlay_with_retry(1)
if texthooker_enabled then if texthooker_enabled then
ensure_texthooker_running(function() end) ensure_texthooker_running(function()
launch_overlay_with_retry(1)
end)
else
launch_overlay_with_retry(1)
end end
end end
@@ -478,6 +481,7 @@ function M.create(ctx)
state.texthooker_running = false state.texthooker_running = false
disarm_auto_play_ready_gate() disarm_auto_play_ready_gate()
ensure_texthooker_running(function()
local start_args = build_command_args("start") local start_args = build_command_args("start")
subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " ")) subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " "))
@@ -501,10 +505,7 @@ function M.create(ctx)
show_osd("Restarted successfully") show_osd("Restarted successfully")
end end
end) end)
end)
if opts.texthooker_enabled then
ensure_texthooker_running(function() end)
end
end) end)
end end

View File

@@ -344,27 +344,6 @@ local function count_start_calls(async_calls)
return count return count
end end
local function find_texthooker_call(async_calls)
for _, call in ipairs(async_calls) do
local args = call.args or {}
for i = 1, #args do
if args[i] == "--texthooker" then
return call
end
end
end
return nil
end
local function find_call_index(async_calls, target_call)
for index, call in ipairs(async_calls) do
if call == target_call then
return index
end
end
return nil
end
local function find_control_call(async_calls, flag) local function find_control_call(async_calls, flag)
for _, call in ipairs(async_calls) do for _, call in ipairs(async_calls) do
local args = call.args or {} local args = call.args or {}
@@ -664,8 +643,6 @@ do
fire_event(recorded, "file-loaded") fire_event(recorded, "file-loaded")
local start_call = find_start_call(recorded.async_calls) local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "auto-start should issue --start command") 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( assert_true(
call_has_arg(start_call, "--show-visible-overlay"), call_has_arg(start_call, "--show-visible-overlay"),
"auto-start with visible overlay enabled should include --show-visible-overlay on --start" "auto-start with visible overlay enabled should include --show-visible-overlay on --start"
@@ -678,10 +655,6 @@ do
find_control_call(recorded.async_calls, "--show-visible-overlay") ~= nil, find_control_call(recorded.async_calls, "--show-visible-overlay") ~= nil,
"auto-start with visible overlay enabled should issue a separate --show-visible-overlay command" "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( assert_true(
not has_property_set(recorded.property_sets, "pause", true), not has_property_set(recorded.property_sets, "pause", true),
"auto-start visible overlay should not force pause without explicit pause-until-ready option" "auto-start visible overlay should not force pause without explicit pause-until-ready option"

View File

@@ -176,22 +176,6 @@ test('runAppReadyRuntime skips heavy startup when shouldSkipHeavyStartup returns
assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs')); assert.ok(calls.indexOf('handleFirstRunSetup') < calls.indexOf('handleInitialArgs'));
}); });
test('runAppReadyRuntime uses minimal startup for texthooker-only mode', async () => {
const { deps, calls } = makeDeps({
texthookerOnlyMode: true,
reloadConfig: () => calls.push('reloadConfig'),
handleInitialArgs: () => calls.push('handleInitialArgs'),
});
await runAppReadyRuntime(deps);
assert.deepEqual(calls, [
'ensureDefaultConfigBootstrap',
'reloadConfig',
'handleInitialArgs',
]);
});
test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => { test('runAppReadyRuntime skips Jellyfin remote startup when dependency is not wired', async () => {
const { deps, calls } = makeDeps({ const { deps, calls } = makeDeps({
startJellyfinRemoteSession: undefined, startJellyfinRemoteSession: undefined,

View File

@@ -2128,129 +2128,6 @@ test('reassignAnimeAnilist deduplicates cover blobs and getCoverArt remains comp
} }
}); });
test('reassignAnimeAnilist replaces stale cover blobs when the AniList cover changes', async () => {
const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null;
const originalFetch = globalThis.fetch;
const initialCoverBlob = Buffer.from([1, 2, 3, 4]);
const replacementCoverBlob = Buffer.from([9, 8, 7, 6]);
let fetchCallCount = 0;
try {
globalThis.fetch = async () => {
fetchCallCount += 1;
const blob = fetchCallCount === 1 ? initialCoverBlob : replacementCoverBlob;
return new Response(new Uint8Array(blob), {
status: 200,
headers: { 'Content-Type': 'image/jpeg' },
});
};
const Ctor = await loadTrackerCtor();
tracker = new Ctor({ dbPath });
const privateApi = tracker as unknown as { db: DatabaseSync };
privateApi.db.exec(`
INSERT INTO imm_anime (
anime_id,
normalized_title_key,
canonical_title,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES (
1,
'little witch academia',
'Little Witch Academia',
1000,
1000
);
INSERT INTO imm_videos (
video_id,
video_key,
canonical_title,
source_type,
duration_ms,
anime_id,
CREATED_DATE,
LAST_UPDATE_DATE
) VALUES
(
1,
'local:/tmp/lwa-1.mkv',
'Little Witch Academia S01E01',
1,
0,
1,
1000,
1000
),
(
2,
'local:/tmp/lwa-2.mkv',
'Little Witch Academia S01E02',
1,
0,
1,
1000,
1000
);
`);
await tracker.reassignAnimeAnilist(1, {
anilistId: 33489,
titleRomaji: 'Little Witch Academia',
coverUrl: 'https://example.com/lwa-old.jpg',
});
await tracker.reassignAnimeAnilist(1, {
anilistId: 100526,
titleRomaji: 'Otome Game Sekai wa Mob ni Kibishii Sekai desu',
coverUrl: 'https://example.com/mobseka-new.jpg',
});
const mediaRows = privateApi.db
.prepare(
`
SELECT
video_id AS videoId,
anilist_id AS anilistId,
cover_url AS coverUrl,
cover_blob_hash AS coverBlobHash
FROM imm_media_art
ORDER BY video_id ASC
`,
)
.all() as Array<{
videoId: number;
anilistId: number | null;
coverUrl: string | null;
coverBlobHash: string | null;
}>;
const blobRows = privateApi.db
.prepare('SELECT blob_hash AS blobHash, cover_blob AS coverBlob FROM imm_cover_art_blobs')
.all() as Array<{ blobHash: string; coverBlob: Buffer }>;
const resolvedCover = await tracker.getAnimeCoverArt(1);
assert.equal(fetchCallCount, 2);
assert.equal(mediaRows.length, 2);
assert.equal(mediaRows[0]?.anilistId, 100526);
assert.equal(mediaRows[0]?.coverUrl, 'https://example.com/mobseka-new.jpg');
assert.equal(mediaRows[0]?.coverBlobHash, mediaRows[1]?.coverBlobHash);
assert.equal(blobRows.length, 1);
assert.deepEqual(
new Uint8Array(blobRows[0]?.coverBlob ?? Buffer.alloc(0)),
new Uint8Array(replacementCoverBlob),
);
assert.deepEqual(
new Uint8Array(resolvedCover?.coverBlob ?? Buffer.alloc(0)),
new Uint8Array(replacementCoverBlob),
);
} finally {
globalThis.fetch = originalFetch;
tracker?.destroy();
cleanupDbPath(dbPath);
}
});
test('reassignAnimeAnilist preserves existing description when description is omitted', async () => { test('reassignAnimeAnilist preserves existing description when description is omitted', async () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
let tracker: ImmersionTrackerService | null = null; let tracker: ImmersionTrackerService | null = null;

View File

@@ -207,78 +207,6 @@ test('getAnimeEpisodes prefers the latest session media position when the latest
} }
}); });
test('getAnimeEpisodes falls back to the latest subtitle segment end when session progress checkpoints are missing', () => {
const dbPath = makeDbPath();
const db = new Database(dbPath);
try {
ensureSchema(db);
const stmts = createTrackerPreparedStatements(db);
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/subtitle-progress-fallback.mkv', {
canonicalTitle: 'Subtitle Progress Fallback',
sourcePath: '/tmp/subtitle-progress-fallback.mkv',
sourceUrl: null,
sourceType: SOURCE_TYPE_LOCAL,
});
const animeId = getOrCreateAnimeRecord(db, {
parsedTitle: 'Subtitle Progress Fallback Anime',
canonicalTitle: 'Subtitle Progress Fallback Anime',
anilistId: null,
titleRomaji: null,
titleEnglish: null,
titleNative: null,
metadataJson: null,
});
linkVideoToAnimeRecord(db, videoId, {
animeId,
parsedBasename: 'subtitle-progress-fallback.mkv',
parsedTitle: 'Subtitle Progress Fallback Anime',
parsedSeason: 1,
parsedEpisode: 1,
parserSource: 'fallback',
parserConfidence: 1,
parseMetadataJson: '{"episode":1}',
});
db.prepare('UPDATE imm_videos SET duration_ms = ? WHERE video_id = ?').run(24_000, videoId);
const startedAtMs = 1_100_000;
const sessionId = startSessionRecord(db, videoId, startedAtMs).sessionId;
db.prepare(
`
UPDATE imm_sessions
SET
ended_at_ms = ?,
status = 2,
active_watched_ms = ?,
LAST_UPDATE_DATE = ?
WHERE session_id = ?
`,
).run(startedAtMs + 10_000, 10_000, startedAtMs + 10_000, sessionId);
stmts.eventInsertStmt.run(
sessionId,
startedAtMs + 9_000,
EVENT_SUBTITLE_LINE,
1,
18_000,
21_000,
5,
0,
'{"line":"progress fallback"}',
startedAtMs + 9_000,
startedAtMs + 9_000,
);
const [episode] = getAnimeEpisodes(db, animeId);
assert.ok(episode);
assert.equal(episode?.endedMediaMs, 21_000);
assert.equal(episode?.totalSessions, 1);
assert.equal(episode?.totalActiveMs, 10_000);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('getSessionTimeline returns the full session when no limit is provided', () => { test('getSessionTimeline returns the full session when no limit is provided', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);

View File

@@ -1745,38 +1745,10 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod
v.parsed_episode AS episode, v.parsed_episode AS episode,
v.duration_ms AS durationMs, v.duration_ms AS durationMs,
( (
SELECT COALESCE( SELECT s_recent.ended_media_ms
s_recent.ended_media_ms,
(
SELECT MAX(line.segment_end_ms)
FROM imm_subtitle_lines line
WHERE line.session_id = s_recent.session_id
AND line.segment_end_ms IS NOT NULL
),
(
SELECT MAX(event.segment_end_ms)
FROM imm_session_events event
WHERE event.session_id = s_recent.session_id
AND event.segment_end_ms IS NOT NULL
)
)
FROM imm_sessions s_recent FROM imm_sessions s_recent
WHERE s_recent.video_id = v.video_id WHERE s_recent.video_id = v.video_id
AND ( AND s_recent.ended_media_ms IS NOT NULL
s_recent.ended_media_ms IS NOT NULL
OR EXISTS (
SELECT 1
FROM imm_subtitle_lines line
WHERE line.session_id = s_recent.session_id
AND line.segment_end_ms IS NOT NULL
)
OR EXISTS (
SELECT 1
FROM imm_session_events event
WHERE event.session_id = s_recent.session_id
AND event.segment_end_ms IS NOT NULL
)
)
ORDER BY ORDER BY
COALESCE(s_recent.ended_at_ms, s_recent.LAST_UPDATE_DATE, s_recent.started_at_ms) DESC, COALESCE(s_recent.ended_at_ms, s_recent.LAST_UPDATE_DATE, s_recent.started_at_ms) DESC,
s_recent.session_id DESC s_recent.session_id DESC
@@ -2317,13 +2289,10 @@ export function upsertCoverArt(
const sharedCoverBlobHash = findSharedCoverBlobHash(db, videoId, art.anilistId, art.coverUrl); const sharedCoverBlobHash = findSharedCoverBlobHash(db, videoId, art.anilistId, art.coverUrl);
const nowMs = Date.now(); const nowMs = Date.now();
const coverBlob = normalizeCoverBlobBytes(art.coverBlob); const coverBlob = normalizeCoverBlobBytes(art.coverBlob);
let coverBlobHash = sharedCoverBlobHash ?? null; let coverBlobHash = sharedCoverBlobHash ?? existing?.coverBlobHash ?? null;
if (!coverBlobHash && coverBlob && coverBlob.length > 0) { if (!coverBlobHash && coverBlob && coverBlob.length > 0) {
coverBlobHash = createHash('sha256').update(coverBlob).digest('hex'); coverBlobHash = createHash('sha256').update(coverBlob).digest('hex');
} }
if (!coverBlobHash && (!coverBlob || coverBlob.length === 0)) {
coverBlobHash = existing?.coverBlobHash ?? null;
}
if (coverBlobHash && coverBlob && coverBlob.length > 0 && !sharedCoverBlobHash) { if (coverBlobHash && coverBlob && coverBlob.length > 0 && !sharedCoverBlobHash) {
db.prepare( db.prepare(

View File

@@ -200,12 +200,6 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
return; return;
} }
if (deps.texthookerOnlyMode) {
deps.reloadConfig();
deps.handleInitialArgs();
return;
}
if (deps.shouldUseMinimalStartup?.()) { if (deps.shouldUseMinimalStartup?.()) {
deps.reloadConfig(); deps.reloadConfig();
deps.handleInitialArgs(); deps.handleInitialArgs();

View File

@@ -3741,98 +3741,6 @@ test('tokenizeSubtitle clears all annotations for kana-only demonstrative helper
); );
}); });
test('tokenizeSubtitle clears all annotations for explanatory pondering endings', async () => {
const result = await tokenizeSubtitle(
'俺どうかしちゃったのかな',
makeDepsFromYomitanTokens(
[
{ surface: '俺', reading: 'おれ', headword: '俺' },
{ surface: 'どうかしちゃった', reading: 'どうかしちゃった', headword: 'どうかしちゃう' },
{ surface: 'のかな', reading: 'のかな', headword: 'の' },
],
{
getFrequencyDictionaryEnabled: () => true,
getFrequencyRank: (text) => (text === '俺' ? 19 : text === 'どうかしちゃう' ? 3200 : 77),
getJlptLevel: (text) =>
text === '俺' ? 'N5' : text === 'どうかしちゃう' ? 'N3' : text === 'の' ? 'N5' : null,
isKnownWord: (text) => text === '俺' || text === 'の',
getMinSentenceWordsForNPlusOne: () => 1,
tokenizeWithMecab: async () => [
{
headword: '俺',
surface: '俺',
reading: 'オレ',
startPos: 0,
endPos: 1,
partOfSpeech: PartOfSpeech.noun,
pos1: '名詞',
pos2: '代名詞',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: 'どうかしちゃう',
surface: 'どうかしちゃった',
reading: 'ドウカシチャッタ',
startPos: 1,
endPos: 8,
partOfSpeech: PartOfSpeech.verb,
pos1: '動詞',
pos2: '自立',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
{
headword: 'の',
surface: 'のかな',
reading: 'ノカナ',
startPos: 8,
endPos: 11,
partOfSpeech: PartOfSpeech.other,
pos1: '名詞|助動詞',
pos2: '非自立',
isMerged: false,
isKnown: false,
isNPlusOneTarget: false,
},
],
},
),
);
assert.deepEqual(
result.tokens?.map((token) => ({
surface: token.surface,
headword: token.headword,
isKnown: token.isKnown,
isNPlusOneTarget: token.isNPlusOneTarget,
frequencyRank: token.frequencyRank,
jlptLevel: token.jlptLevel,
})),
[
{ surface: '俺', headword: '俺', isKnown: true, isNPlusOneTarget: false, frequencyRank: 19, jlptLevel: 'N5' },
{
surface: 'どうかしちゃった',
headword: 'どうかしちゃう',
isKnown: false,
isNPlusOneTarget: true,
frequencyRank: 3200,
jlptLevel: 'N3',
},
{
surface: 'のかな',
headword: 'の',
isKnown: false,
isNPlusOneTarget: false,
frequencyRank: undefined,
jlptLevel: undefined,
},
],
);
});
test('tokenizeSubtitle keeps frequency for content-led merged token with trailing colloquial suffixes', async () => { test('tokenizeSubtitle keeps frequency for content-led merged token with trailing colloquial suffixes', async () => {
const result = await tokenizeSubtitle( const result = await tokenizeSubtitle(
'張り切ってんじゃ', '張り切ってんじゃ',

View File

@@ -234,18 +234,6 @@ test('shouldExcludeTokenFromSubtitleAnnotations excludes explanatory ending vari
} }
}); });
test('shouldExcludeTokenFromSubtitleAnnotations excludes explanatory pondering endings', () => {
const token = makeToken({
surface: 'のかな',
headword: 'の',
reading: 'ノカナ',
pos1: '名詞|助動詞',
pos2: '非自立',
});
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(token), true);
});
test('shouldExcludeTokenFromSubtitleAnnotations excludes auxiliary-stem そうだ grammar tails', () => { test('shouldExcludeTokenFromSubtitleAnnotations excludes auxiliary-stem そうだ grammar tails', () => {
const token = makeToken({ const token = makeToken({
surface: 'そうだ', surface: 'そうだ',

View File

@@ -45,7 +45,6 @@ const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_TRAILING_PARTICLES = [
'かな', 'かな',
'かね', 'かね',
] as const; ] as const;
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_THOUGHT_SUFFIXES = ['か', 'かな', 'かね'] as const;
const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDINGS = new Set( const SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDINGS = new Set(
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_PREFIXES.flatMap((prefix) => SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_PREFIXES.flatMap((prefix) =>
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_CORES.flatMap((core) => SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_CORES.flatMap((core) =>
@@ -259,16 +258,6 @@ function isExcludedByTerm(token: MergedToken): boolean {
continue; continue;
} }
if (
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_PREFIXES.some((prefix) =>
SUBTITLE_ANNOTATION_EXCLUDED_EXPLANATORY_ENDING_THOUGHT_SUFFIXES.some(
(suffix) => normalized === `${prefix}${suffix}`,
),
)
) {
return true;
}
if ( if (
SUBTITLE_ANNOTATION_EXCLUDED_TERMS.has(trimmed) || SUBTITLE_ANNOTATION_EXCLUDED_TERMS.has(trimmed) ||
SUBTITLE_ANNOTATION_EXCLUDED_TERMS.has(normalized) || SUBTITLE_ANNOTATION_EXCLUDED_TERMS.has(normalized) ||

View File

@@ -315,7 +315,6 @@ import {
createFirstRunSetupService, createFirstRunSetupService,
shouldAutoOpenFirstRunSetup, shouldAutoOpenFirstRunSetup,
} from './main/runtime/first-run-setup-service'; } from './main/runtime/first-run-setup-service';
import { resolveAutoplayReadyMaxReleaseAttempts } from './main/runtime/startup-autoplay-release-policy';
import { import {
buildFirstRunSetupHtml, buildFirstRunSetupHtml,
createMaybeFocusExistingFirstRunSetupWindowHandler, createMaybeFocusExistingFirstRunSetupWindowHandler,
@@ -1097,11 +1096,8 @@ function maybeSignalPluginAutoplayReady(
// Fallback: repeatedly try to release pause for a short window in case startup // Fallback: repeatedly try to release pause for a short window in case startup
// gate arming and tokenization-ready signal arrive out of order. // gate arming and tokenization-ready signal arrive out of order.
const maxReleaseAttempts = options?.forceWhilePaused === true ? 14 : 3;
const releaseRetryDelayMs = 200; const releaseRetryDelayMs = 200;
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
forceWhilePaused: options?.forceWhilePaused === true,
retryDelayMs: releaseRetryDelayMs,
});
const attemptRelease = (attempt: number): void => { const attemptRelease = (attempt: number): void => {
void (async () => { void (async () => {
if ( if (
@@ -3041,11 +3037,10 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)), Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
shouldUseMinimalStartup: () => shouldUseMinimalStartup: () =>
Boolean( Boolean(
appState.initialArgs?.texthooker || appState.initialArgs?.stats &&
(appState.initialArgs?.stats &&
(appState.initialArgs?.statsCleanup || (appState.initialArgs?.statsCleanup ||
appState.initialArgs?.statsBackground || appState.initialArgs?.statsBackground ||
appState.initialArgs?.statsStop)), appState.initialArgs?.statsStop),
), ),
shouldSkipHeavyStartup: () => shouldSkipHeavyStartup: () =>
Boolean( Boolean(
@@ -3135,39 +3130,6 @@ void initializeDiscordPresenceService();
const handleCliCommand = createCliCommandRuntimeHandler({ const handleCliCommand = createCliCommandRuntimeHandler({
handleTexthookerOnlyModeTransitionMainDeps: { handleTexthookerOnlyModeTransitionMainDeps: {
isTexthookerOnlyMode: () => appState.texthookerOnlyMode, isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
ensureOverlayStartupPrereqs: () => {
if (appState.subtitlePosition === null) {
loadSubtitlePosition();
}
if (appState.keybindings.length === 0) {
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
}
if (!appState.mpvClient) {
appState.mpvClient = createMpvClientRuntimeService();
}
if (!appState.runtimeOptionsManager) {
appState.runtimeOptionsManager = new RuntimeOptionsManager(
() => configService.getConfig().ankiConnect,
{
applyAnkiPatch: (patch) => {
if (appState.ankiIntegration) {
appState.ankiIntegration.applyRuntimeConfigPatch(patch);
}
},
getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle,
onOptionsChanged: () => {
subtitleProcessingController.invalidateTokenizationCache();
subtitlePrefetchService?.onSeek(lastObservedTimePos);
broadcastRuntimeOptionsChanged();
refreshOverlayShortcuts();
},
},
);
}
if (!appState.subtitleTimingTracker) {
appState.subtitleTimingTracker = new SubtitleTimingTracker();
}
},
setTexthookerOnlyMode: (enabled) => { setTexthookerOnlyMode: (enabled) => {
appState.texthookerOnlyMode = enabled; appState.texthookerOnlyMode = enabled;
}, },

View File

@@ -150,59 +150,6 @@ test('auto sync skips rebuild/import on unchanged revisit when merged dictionary
assert.deepEqual(imports, ['/tmp/merged.zip']); assert.deepEqual(imports, ['/tmp/merged.zip']);
}); });
test('auto sync does not emit updating progress for unchanged revisit when merged dictionary is current', async () => {
const userDataPath = makeTempDir();
let importedRevision: string | null = null;
let currentRun: string[] = [];
const phaseHistory: string[][] = [];
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath,
getConfig: () => ({
enabled: true,
maxLoaded: 3,
profileScope: 'all',
}),
getOrCreateCurrentSnapshot: async () => ({
mediaId: 7,
mediaTitle: 'Frieren',
entryCount: 100,
fromCache: true,
updatedAt: 1000,
}),
buildMergedDictionary: async () => ({
zipPath: '/tmp/merged.zip',
revision: 'rev-7',
dictionaryTitle: 'SubMiner Character Dictionary',
entryCount: 100,
}),
getYomitanDictionaryInfo: async () =>
importedRevision
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
: [],
importYomitanDictionary: async () => {
importedRevision = 'rev-7';
return true;
},
deleteYomitanDictionary: async () => true,
upsertYomitanDictionarySettings: async () => false,
now: () => 1000,
onSyncStatus: (event) => {
currentRun.push(event.phase);
},
});
currentRun = [];
await runtime.runSyncNow();
phaseHistory.push([...currentRun]);
currentRun = [];
await runtime.runSyncNow();
phaseHistory.push([...currentRun]);
assert.deepEqual(phaseHistory[0], ['building', 'importing', 'ready']);
assert.deepEqual(phaseHistory[1], ['ready']);
});
test('auto sync updates MRU order without rebuilding merged dictionary when membership is unchanged', async () => { test('auto sync updates MRU order without rebuilding merged dictionary when membership is unchanged', async () => {
const userDataPath = makeTempDir(); const userDataPath = makeTempDir();
const sequence = [1, 2, 1]; const sequence = [1, 2, 1];
@@ -270,63 +217,6 @@ test('auto sync updates MRU order without rebuilding merged dictionary when memb
assert.deepEqual(state.activeMediaIds, ['1 - Title 1', '2 - Title 2']); assert.deepEqual(state.activeMediaIds, ['1 - Title 1', '2 - Title 2']);
}); });
test('auto sync reimports existing merged zip without rebuilding on unchanged revisit', async () => {
const userDataPath = makeTempDir();
const dictionariesDir = path.join(userDataPath, 'character-dictionaries');
fs.mkdirSync(dictionariesDir, { recursive: true });
fs.writeFileSync(path.join(dictionariesDir, 'merged.zip'), 'cached-zip', 'utf8');
const mergedBuilds: number[][] = [];
const imports: string[] = [];
let importedRevision: string | null = null;
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
userDataPath,
getConfig: () => ({
enabled: true,
maxLoaded: 3,
profileScope: 'all',
}),
getOrCreateCurrentSnapshot: async () => ({
mediaId: 7,
mediaTitle: 'Frieren',
entryCount: 100,
fromCache: true,
updatedAt: 1000,
}),
buildMergedDictionary: async (mediaIds) => {
mergedBuilds.push([...mediaIds]);
return {
zipPath: '/tmp/merged.zip',
revision: 'rev-7',
dictionaryTitle: 'SubMiner Character Dictionary',
entryCount: 100,
};
},
getYomitanDictionaryInfo: async () =>
importedRevision
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
: [],
importYomitanDictionary: async (zipPath) => {
imports.push(zipPath);
importedRevision = 'rev-7';
return true;
},
deleteYomitanDictionary: async () => true,
upsertYomitanDictionarySettings: async () => true,
now: () => 1000,
});
await runtime.runSyncNow();
importedRevision = null;
await runtime.runSyncNow();
assert.deepEqual(mergedBuilds, [[7]]);
assert.deepEqual(imports, [
'/tmp/merged.zip',
path.join(userDataPath, 'character-dictionaries', 'merged.zip'),
]);
});
test('auto sync evicts least recently used media from merged set', async () => { test('auto sync evicts least recently used media from merged set', async () => {
const userDataPath = makeTempDir(); const userDataPath = makeTempDir();
const sequence = [1, 2, 3, 4]; const sequence = [1, 2, 3, 4];
@@ -647,6 +537,12 @@ test('auto sync emits progress events for start import and completion', async ()
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai', mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
message: 'Generating character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...', message: 'Generating character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
}, },
{
phase: 'syncing',
mediaId: 101291,
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
message: 'Updating character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
},
{ {
phase: 'building', phase: 'building',
mediaId: 101291, mediaId: 101291,

View File

@@ -275,6 +275,12 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
}); });
currentMediaId = snapshot.mediaId; currentMediaId = snapshot.mediaId;
currentMediaTitle = snapshot.mediaTitle; currentMediaTitle = snapshot.mediaTitle;
deps.onSyncStatus?.({
phase: 'syncing',
mediaId: snapshot.mediaId,
mediaTitle: snapshot.mediaTitle,
message: buildSyncingMessage(snapshot.mediaTitle),
});
const state = readAutoSyncState(statePath); const state = readAutoSyncState(statePath);
const nextActiveMediaIds = [ const nextActiveMediaIds = [
{ {
@@ -354,18 +360,8 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
); );
} }
if (merged === null) { if (merged === null) {
const existingMergedZipPath = path.join(dictionariesDir, 'merged.zip');
if (fs.existsSync(existingMergedZipPath)) {
merged = {
zipPath: existingMergedZipPath,
revision,
dictionaryTitle,
entryCount: snapshot.entryCount,
};
} else {
merged = await deps.buildMergedDictionary(nextActiveMediaIdValues); merged = await deps.buildMergedDictionary(nextActiveMediaIdValues);
} }
}
deps.logInfo?.(`[dictionary:auto-sync] importing merged dictionary: ${merged.zipPath}`); deps.logInfo?.(`[dictionary:auto-sync] importing merged dictionary: ${merged.zipPath}`);
const imported = await withOperationTimeout( const imported = await withOperationTimeout(
`importYomitanDictionary(${path.basename(merged.zipPath)})`, `importYomitanDictionary(${path.basename(merged.zipPath)})`,

View File

@@ -8,7 +8,6 @@ test('cli prechecks main deps builder maps transition handlers', () => {
isTexthookerOnlyMode: () => true, isTexthookerOnlyMode: () => true,
setTexthookerOnlyMode: (enabled) => calls.push(`set:${enabled}`), setTexthookerOnlyMode: (enabled) => calls.push(`set:${enabled}`),
commandNeedsOverlayRuntime: () => true, commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
startBackgroundWarmups: () => calls.push('warmups'), startBackgroundWarmups: () => calls.push('warmups'),
logInfo: (message) => calls.push(`info:${message}`), logInfo: (message) => calls.push(`info:${message}`),
})(); })();
@@ -16,8 +15,7 @@ test('cli prechecks main deps builder maps transition handlers', () => {
assert.equal(deps.isTexthookerOnlyMode(), true); assert.equal(deps.isTexthookerOnlyMode(), true);
assert.equal(deps.commandNeedsOverlayRuntime({} as never), true); assert.equal(deps.commandNeedsOverlayRuntime({} as never), true);
deps.setTexthookerOnlyMode(false); deps.setTexthookerOnlyMode(false);
deps.ensureOverlayStartupPrereqs();
deps.startBackgroundWarmups(); deps.startBackgroundWarmups();
deps.logInfo('x'); deps.logInfo('x');
assert.deepEqual(calls, ['set:false', 'prereqs', 'warmups', 'info:x']); assert.deepEqual(calls, ['set:false', 'warmups', 'info:x']);
}); });

View File

@@ -4,7 +4,6 @@ export function createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler(dep
isTexthookerOnlyMode: () => boolean; isTexthookerOnlyMode: () => boolean;
setTexthookerOnlyMode: (enabled: boolean) => void; setTexthookerOnlyMode: (enabled: boolean) => void;
commandNeedsOverlayRuntime: (args: CliArgs) => boolean; commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
ensureOverlayStartupPrereqs: () => void;
startBackgroundWarmups: () => void; startBackgroundWarmups: () => void;
logInfo: (message: string) => void; logInfo: (message: string) => void;
}) { }) {
@@ -12,7 +11,6 @@ export function createBuildHandleTexthookerOnlyModeTransitionMainDepsHandler(dep
isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(), isTexthookerOnlyMode: () => deps.isTexthookerOnlyMode(),
setTexthookerOnlyMode: (enabled: boolean) => deps.setTexthookerOnlyMode(enabled), setTexthookerOnlyMode: (enabled: boolean) => deps.setTexthookerOnlyMode(enabled),
commandNeedsOverlayRuntime: (args: CliArgs) => deps.commandNeedsOverlayRuntime(args), commandNeedsOverlayRuntime: (args: CliArgs) => deps.commandNeedsOverlayRuntime(args),
ensureOverlayStartupPrereqs: () => deps.ensureOverlayStartupPrereqs(),
startBackgroundWarmups: () => deps.startBackgroundWarmups(), startBackgroundWarmups: () => deps.startBackgroundWarmups(),
logInfo: (message: string) => deps.logInfo(message), logInfo: (message: string) => deps.logInfo(message),
}); });

View File

@@ -8,7 +8,6 @@ test('texthooker precheck no-ops when mode is disabled', () => {
isTexthookerOnlyMode: () => false, isTexthookerOnlyMode: () => false,
setTexthookerOnlyMode: () => {}, setTexthookerOnlyMode: () => {},
commandNeedsOverlayRuntime: () => true, commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => {},
startBackgroundWarmups: () => { startBackgroundWarmups: () => {
warmups += 1; warmups += 1;
}, },
@@ -23,16 +22,12 @@ test('texthooker precheck disables mode and warms up on start command', () => {
let mode = true; let mode = true;
let warmups = 0; let warmups = 0;
let logs = 0; let logs = 0;
let prereqs = 0;
const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({ const handlePrecheck = createHandleTexthookerOnlyModeTransitionHandler({
isTexthookerOnlyMode: () => mode, isTexthookerOnlyMode: () => mode,
setTexthookerOnlyMode: (enabled) => { setTexthookerOnlyMode: (enabled) => {
mode = enabled; mode = enabled;
}, },
commandNeedsOverlayRuntime: () => false, commandNeedsOverlayRuntime: () => false,
ensureOverlayStartupPrereqs: () => {
prereqs += 1;
},
startBackgroundWarmups: () => { startBackgroundWarmups: () => {
warmups += 1; warmups += 1;
}, },
@@ -43,7 +38,6 @@ test('texthooker precheck disables mode and warms up on start command', () => {
handlePrecheck({ start: true, texthooker: false } as never); handlePrecheck({ start: true, texthooker: false } as never);
assert.equal(mode, false); assert.equal(mode, false);
assert.equal(prereqs, 1);
assert.equal(warmups, 1); assert.equal(warmups, 1);
assert.equal(logs, 1); assert.equal(logs, 1);
}); });
@@ -56,7 +50,6 @@ test('texthooker precheck no-ops for texthooker command', () => {
mode = enabled; mode = enabled;
}, },
commandNeedsOverlayRuntime: () => true, commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => {},
startBackgroundWarmups: () => {}, startBackgroundWarmups: () => {},
logInfo: () => {}, logInfo: () => {},
}); });

View File

@@ -4,7 +4,6 @@ export function createHandleTexthookerOnlyModeTransitionHandler(deps: {
isTexthookerOnlyMode: () => boolean; isTexthookerOnlyMode: () => boolean;
setTexthookerOnlyMode: (enabled: boolean) => void; setTexthookerOnlyMode: (enabled: boolean) => void;
commandNeedsOverlayRuntime: (args: CliArgs) => boolean; commandNeedsOverlayRuntime: (args: CliArgs) => boolean;
ensureOverlayStartupPrereqs: () => void;
startBackgroundWarmups: () => void; startBackgroundWarmups: () => void;
logInfo: (message: string) => void; logInfo: (message: string) => void;
}) { }) {
@@ -14,7 +13,6 @@ export function createHandleTexthookerOnlyModeTransitionHandler(deps: {
!args.texthooker && !args.texthooker &&
(args.start || deps.commandNeedsOverlayRuntime(args)) (args.start || deps.commandNeedsOverlayRuntime(args))
) { ) {
deps.ensureOverlayStartupPrereqs();
deps.setTexthookerOnlyMode(false); deps.setTexthookerOnlyMode(false);
deps.logInfo('Disabling texthooker-only mode after overlay/start command.'); deps.logInfo('Disabling texthooker-only mode after overlay/start command.');
deps.startBackgroundWarmups(); deps.startBackgroundWarmups();

View File

@@ -9,7 +9,6 @@ test('cli command runtime handler applies precheck and forwards command with con
isTexthookerOnlyMode: () => true, isTexthookerOnlyMode: () => true,
setTexthookerOnlyMode: () => calls.push('set-mode'), setTexthookerOnlyMode: () => calls.push('set-mode'),
commandNeedsOverlayRuntime: () => true, commandNeedsOverlayRuntime: () => true,
ensureOverlayStartupPrereqs: () => calls.push('prereqs'),
startBackgroundWarmups: () => calls.push('warmups'), startBackgroundWarmups: () => calls.push('warmups'),
logInfo: (message) => calls.push(`log:${message}`), logInfo: (message) => calls.push(`log:${message}`),
}, },
@@ -25,7 +24,6 @@ test('cli command runtime handler applies precheck and forwards command with con
handler({ start: true } as never); handler({ start: true } as never);
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'prereqs',
'set-mode', 'set-mode',
'log:Disabling texthooker-only mode after overlay/start command.', 'log:Disabling texthooker-only mode after overlay/start command.',
'warmups', 'warmups',

View File

@@ -87,7 +87,6 @@ test('media path change handler reports stop for empty path and probes media key
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync'), syncImmersionMediaState: () => calls.push('sync'),
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'), scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`), signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
refreshDiscordPresence: () => calls.push('presence'), refreshDiscordPresence: () => calls.push('presence'),
@@ -95,7 +94,6 @@ test('media path change handler reports stop for empty path and probes media key
handler({ path: '' }); handler({ path: '' });
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'flush-playback',
'path:', 'path:',
'stopped', 'stopped',
'restore-mpv-sub', 'restore-mpv-sub',
@@ -118,7 +116,6 @@ test('media path change handler signals autoplay-ready fast path for warm non-em
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync'), syncImmersionMediaState: () => calls.push('sync'),
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'), scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`), signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
refreshDiscordPresence: () => calls.push('presence'), refreshDiscordPresence: () => calls.push('presence'),
@@ -136,35 +133,6 @@ test('media path change handler signals autoplay-ready fast path for warm non-em
]); ]);
}); });
test('media path change handler ignores playback flush for non-empty path', () => {
const calls: string[] = [];
const handler = createHandleMpvMediaPathChangeHandler({
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
reportJellyfinRemoteStopped: () => calls.push('stopped'),
restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'),
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: (mediaKey) => calls.push(`reset:${String(mediaKey)}`),
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync'),
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
refreshDiscordPresence: () => calls.push('presence'),
});
handler({ path: '/tmp/video.mkv' });
assert.ok(!calls.includes('flush-playback'));
assert.deepEqual(calls, [
'path:/tmp/video.mkv',
'reset:null',
'sync',
'dict-sync',
'autoplay:/tmp/video.mkv',
'presence',
]);
});
test('media title change handler clears guess state without re-scheduling character dictionary sync', () => { test('media title change handler clears guess state without re-scheduling character dictionary sync', () => {
const calls: string[] = []; const calls: string[] = [];
const deps: Parameters<typeof createHandleMpvMediaTitleChangeHandler>[0] & { const deps: Parameters<typeof createHandleMpvMediaTitleChangeHandler>[0] & {

View File

@@ -53,14 +53,10 @@ export function createHandleMpvMediaPathChangeHandler(deps: {
syncImmersionMediaState: () => void; syncImmersionMediaState: () => void;
scheduleCharacterDictionarySync?: () => void; scheduleCharacterDictionarySync?: () => void;
signalAutoplayReadyIfWarm?: (path: string) => void; signalAutoplayReadyIfWarm?: (path: string) => void;
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
refreshDiscordPresence: () => void; refreshDiscordPresence: () => void;
}) { }) {
return ({ path }: { path: string | null }): void => { return ({ path }: { path: string | null }): void => {
const normalizedPath = typeof path === 'string' ? path : ''; const normalizedPath = typeof path === 'string' ? path : '';
if (!normalizedPath) {
deps.flushPlaybackPositionOnMediaPathClear?.(normalizedPath);
}
deps.updateCurrentMediaPath(normalizedPath); deps.updateCurrentMediaPath(normalizedPath);
if (!normalizedPath) { if (!normalizedPath) {
deps.reportJellyfinRemoteStopped(); deps.reportJellyfinRemoteStopped();

View File

@@ -44,7 +44,6 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`), maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`), ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync-immersion'), syncImmersionMediaState: () => calls.push('sync-immersion'),
flushPlaybackPositionOnMediaPathClear: () => calls.push('flush-playback'),
updateCurrentMediaTitle: (title) => calls.push(`media-title:${title}`), updateCurrentMediaTitle: (title) => calls.push(`media-title:${title}`),
resetAnilistMediaGuessState: () => calls.push('reset-guess-state'), resetAnilistMediaGuessState: () => calls.push('reset-guess-state'),
@@ -87,6 +86,4 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
assert.ok(calls.includes('progress:normal')); assert.ok(calls.includes('progress:normal'));
assert.ok(calls.includes('progress:force')); assert.ok(calls.includes('progress:force'));
assert.ok(calls.includes('presence-refresh')); assert.ok(calls.includes('presence-refresh'));
assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('flush-playback'));
}); });

View File

@@ -56,7 +56,6 @@ export function createBindMpvMainEventHandlersHandler(deps: {
ensureAnilistMediaGuess: (mediaKey: string) => void; ensureAnilistMediaGuess: (mediaKey: string) => void;
syncImmersionMediaState: () => void; syncImmersionMediaState: () => void;
signalAutoplayReadyIfWarm?: (path: string) => void; signalAutoplayReadyIfWarm?: (path: string) => void;
flushPlaybackPositionOnMediaPathClear?: (mediaPath: string) => void;
updateCurrentMediaTitle: (title: string) => void; updateCurrentMediaTitle: (title: string) => void;
resetAnilistMediaGuessState: () => void; resetAnilistMediaGuessState: () => void;
@@ -115,8 +114,6 @@ export function createBindMpvMainEventHandlersHandler(deps: {
maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey), maybeProbeAnilistDuration: (mediaKey) => deps.maybeProbeAnilistDuration(mediaKey),
ensureAnilistMediaGuess: (mediaKey) => deps.ensureAnilistMediaGuess(mediaKey), ensureAnilistMediaGuess: (mediaKey) => deps.ensureAnilistMediaGuess(mediaKey),
syncImmersionMediaState: () => deps.syncImmersionMediaState(), syncImmersionMediaState: () => deps.syncImmersionMediaState(),
flushPlaybackPositionOnMediaPathClear: (mediaPath) =>
deps.flushPlaybackPositionOnMediaPathClear?.(mediaPath),
signalAutoplayReadyIfWarm: (path) => deps.signalAutoplayReadyIfWarm?.(path), signalAutoplayReadyIfWarm: (path) => deps.signalAutoplayReadyIfWarm?.(path),
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(), scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(), refreshDiscordPresence: () => deps.refreshDiscordPresence(),

View File

@@ -7,11 +7,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
const appState = { const appState = {
initialArgs: { jellyfinPlay: true }, initialArgs: { jellyfinPlay: true },
overlayRuntimeInitialized: true, overlayRuntimeInitialized: true,
mpvClient: { mpvClient: { connected: true },
connected: true,
currentTimePos: 12.25,
requestProperty: async () => 18.75,
},
immersionTracker: { immersionTracker: {
recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`), recordSubtitleLine: (text: string) => calls.push(`immersion-sub:${text}`),
handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`), handleMediaTitleUpdate: (title: string) => calls.push(`immersion-title:${title}`),
@@ -96,8 +92,6 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.recordPauseState(true); deps.recordPauseState(true);
deps.updateSubtitleRenderMetrics({}); deps.updateSubtitleRenderMetrics({});
deps.setPreviousSecondarySubVisibility(true); deps.setPreviousSecondarySubVisibility(true);
deps.flushPlaybackPositionOnMediaPathClear?.('');
await Promise.resolve();
assert.equal(appState.currentSubText, 'sub'); assert.equal(appState.currentSubText, 'sub');
assert.equal(appState.currentSubAssText, 'ass'); assert.equal(appState.currentSubAssText, 'ass');
@@ -112,6 +106,4 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.ok(calls.includes('metrics')); assert.ok(calls.includes('metrics'));
assert.ok(calls.includes('presence-refresh')); assert.ok(calls.includes('presence-refresh'));
assert.ok(calls.includes('restore-mpv-sub')); assert.ok(calls.includes('restore-mpv-sub'));
assert.ok(calls.includes('immersion-time:12.25'));
assert.ok(calls.includes('immersion-time:18.75'));
}); });

View File

@@ -4,14 +4,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
appState: { appState: {
initialArgs?: { jellyfinPlay?: unknown } | null; initialArgs?: { jellyfinPlay?: unknown } | null;
overlayRuntimeInitialized: boolean; overlayRuntimeInitialized: boolean;
mpvClient: mpvClient: { connected?: boolean; currentSecondarySubText?: string } | null;
| {
connected?: boolean;
currentSecondarySubText?: string;
currentTimePos?: number;
requestProperty?: (name: string) => Promise<unknown>;
}
| null;
immersionTracker: { immersionTracker: {
recordSubtitleLine?: ( recordSubtitleLine?: (
text: string, text: string,
@@ -28,7 +21,6 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
subtitleTimingTracker: { subtitleTimingTracker: {
recordSubtitle?: (text: string, start: number, end: number) => void; recordSubtitle?: (text: string, start: number, end: number) => void;
} | null; } | null;
currentMediaPath?: string | null;
currentSubText: string; currentSubText: string;
currentSubAssText: string; currentSubAssText: string;
currentSubtitleData?: SubtitleData | null; currentSubtitleData?: SubtitleData | null;
@@ -66,15 +58,6 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
ensureImmersionTrackerInitialized: () => void; ensureImmersionTrackerInitialized: () => void;
tokenizeSubtitleForImmersion?: (text: string) => Promise<SubtitleData | null>; tokenizeSubtitleForImmersion?: (text: string) => Promise<SubtitleData | null>;
}) { }) {
const writePlaybackPositionFromMpv = (timeSec: unknown): void => {
const normalizedTimeSec = Number(timeSec);
if (!Number.isFinite(normalizedTimeSec)) {
return;
}
deps.ensureImmersionTrackerInitialized();
deps.appState.immersionTracker?.recordPlaybackPosition?.(normalizedTimeSec);
};
return () => ({ return () => ({
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(), syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
@@ -178,25 +161,6 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
deps.ensureImmersionTrackerInitialized(); deps.ensureImmersionTrackerInitialized();
deps.appState.immersionTracker?.recordPauseState?.(paused); deps.appState.immersionTracker?.recordPauseState?.(paused);
}, },
flushPlaybackPositionOnMediaPathClear: (mediaPath: string) => {
const mpvClient = deps.appState.mpvClient;
const currentKnownTime = Number(mpvClient?.currentTimePos);
writePlaybackPositionFromMpv(currentKnownTime);
if (!mpvClient?.requestProperty) {
return;
}
void mpvClient.requestProperty('time-pos').then((timePos) => {
const currentPath = (deps.appState.currentMediaPath ?? '').trim();
if (currentPath.length > 0 && currentPath !== mediaPath) {
return;
}
const resolvedTime = Number(timePos);
if (Number.isFinite(currentKnownTime) && Number.isFinite(resolvedTime) && currentKnownTime === resolvedTime) {
return;
}
writePlaybackPositionFromMpv(resolvedTime);
});
},
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => updateSubtitleRenderMetrics: (patch: Record<string, unknown>) =>
deps.updateSubtitleRenderMetrics(patch), deps.updateSubtitleRenderMetrics(patch),
setPreviousSecondarySubVisibility: (visible: boolean) => { setPreviousSecondarySubVisibility: (visible: boolean) => {

View File

@@ -1,32 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS,
resolveAutoplayReadyMaxReleaseAttempts,
STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS,
} from './startup-autoplay-release-policy';
test('autoplay release keeps the short retry budget for normal playback signals', () => {
assert.equal(resolveAutoplayReadyMaxReleaseAttempts(), 3);
assert.equal(resolveAutoplayReadyMaxReleaseAttempts({ forceWhilePaused: false }), 3);
});
test('autoplay release uses the full startup timeout window while paused', () => {
assert.equal(
resolveAutoplayReadyMaxReleaseAttempts({ forceWhilePaused: true }),
Math.ceil(
STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS / DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS,
),
);
});
test('autoplay release rounds up custom paused retry budgets to cover the timeout window', () => {
assert.equal(
resolveAutoplayReadyMaxReleaseAttempts({
forceWhilePaused: true,
retryDelayMs: 300,
startupTimeoutMs: 1_000,
}),
4,
);
});

View File

@@ -1,28 +0,0 @@
const DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS = 200;
const STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS = 15_000;
export function resolveAutoplayReadyMaxReleaseAttempts(options?: {
forceWhilePaused?: boolean;
retryDelayMs?: number;
startupTimeoutMs?: number;
}): number {
if (options?.forceWhilePaused !== true) {
return 3;
}
const retryDelayMs = Math.max(
1,
Math.floor(options.retryDelayMs ?? DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS),
);
const startupTimeoutMs = Math.max(
retryDelayMs,
Math.floor(options.startupTimeoutMs ?? STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS),
);
return Math.max(3, Math.ceil(startupTimeoutMs / retryDelayMs));
}
export {
DEFAULT_AUTOPLAY_RELEASE_RETRY_DELAY_MS,
STARTUP_AUTOPLAY_RELEASE_TIMEOUT_MS,
};

View File

@@ -62,10 +62,7 @@ test('startup OSD buffers checking behind annotations and replaces it with later
makeDictionaryEvent('generating', 'Generating character dictionary for Frieren...'), makeDictionaryEvent('generating', 'Generating character dictionary for Frieren...'),
); );
assert.deepEqual(osdMessages, [ assert.deepEqual(osdMessages, ['Loading subtitle annotations |']);
'Loading subtitle annotations |',
'Generating character dictionary for Frieren...',
]);
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded'); sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
@@ -157,30 +154,3 @@ test('startup OSD reset keeps tokenization ready after first warmup', () => {
assert.deepEqual(osdMessages, ['Updating character dictionary for Frieren...']); assert.deepEqual(osdMessages, ['Updating character dictionary for Frieren...']);
}); });
test('startup OSD shows later dictionary progress immediately once tokenization is ready', () => {
const osdMessages: string[] = [];
const sequencer = createStartupOsdSequencer({
showOsd: (message) => {
osdMessages.push(message);
},
});
sequencer.showAnnotationLoading('Loading subtitle annotations |');
sequencer.markTokenizationReady();
sequencer.notifyCharacterDictionaryStatus(
makeDictionaryEvent('generating', 'Generating character dictionary for Frieren...'),
);
assert.deepEqual(osdMessages, [
'Loading subtitle annotations |',
'Generating character dictionary for Frieren...',
]);
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
assert.deepEqual(osdMessages, [
'Loading subtitle annotations |',
'Generating character dictionary for Frieren...',
]);
});

View File

@@ -25,9 +25,6 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
return false; return false;
} }
if (pendingDictionaryProgress) { if (pendingDictionaryProgress) {
if (dictionaryProgressShown) {
return true;
}
deps.showOsd(pendingDictionaryProgress.message); deps.showOsd(pendingDictionaryProgress.message);
dictionaryProgressShown = true; dictionaryProgressShown = true;
return true; return true;
@@ -87,9 +84,6 @@ export function createStartupOsdSequencer(deps: { showOsd: (message: string) =>
if (canShowDictionaryStatus()) { if (canShowDictionaryStatus()) {
deps.showOsd(event.message); deps.showOsd(event.message);
dictionaryProgressShown = true; dictionaryProgressShown = true;
} else if (tokenizationReady) {
deps.showOsd(event.message);
dictionaryProgressShown = true;
} }
return; return;
} }

View File

@@ -18,8 +18,8 @@ const summary: OverviewSummary = {
activeDays: 12, activeDays: 12,
totalSessions: 15, totalSessions: 15,
lookupRate: { lookupRate: {
shortValue: '2.3 / 100 words', shortValue: '2.3 / 100 tokens',
longValue: '2.3 lookups per 100 words', longValue: '2.3 lookups per 100 tokens',
}, },
todayTokens: 0, todayTokens: 0,
newWordsToday: 0, newWordsToday: 0,
@@ -33,8 +33,8 @@ test('TrackingSnapshot renders Yomitan lookup rate copy on the homepage card', (
); );
assert.match(markup, /Lookup Rate/); assert.match(markup, /Lookup Rate/);
assert.match(markup, /2\.3 \/ 100 words/); assert.match(markup, /2\.3 \/ 100 tokens/);
assert.match(markup, /Lifetime Yomitan lookups normalized by total words seen/); assert.match(markup, /Lifetime Yomitan lookups normalized by total tokens seen/);
}); });
test('TrackingSnapshot labels new words as unique headwords', () => { test('TrackingSnapshot labels new words as unique headwords', () => {

View File

@@ -183,7 +183,7 @@ export function TrendsTab() {
color={cardsMinedColor} color={cardsMinedColor}
type="bar" type="bar"
/> />
<TrendChart title="Words Seen" data={data.activity.words} color="#8bd5ca" type="bar" /> <TrendChart title="Tokens Seen" data={data.activity.words} color="#8bd5ca" type="bar" />
<TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" /> <TrendChart title="Sessions" data={data.activity.sessions} color="#b7bdf8" type="bar" />
<SectionHeader>Period Trends</SectionHeader> <SectionHeader>Period Trends</SectionHeader>
@@ -194,7 +194,7 @@ export function TrendsTab() {
type="line" type="line"
/> />
<TrendChart title="Sessions" data={data.progress.sessions} color="#b7bdf8" type="line" /> <TrendChart title="Sessions" data={data.progress.sessions} color="#b7bdf8" type="line" />
<TrendChart title="Words Seen" data={data.progress.words} color="#8bd5ca" type="line" /> <TrendChart title="Tokens Seen" data={data.progress.words} color="#8bd5ca" type="line" />
<TrendChart <TrendChart
title="New Words Seen" title="New Words Seen"
data={data.progress.newWords} data={data.progress.newWords}
@@ -215,7 +215,7 @@ export function TrendsTab() {
/> />
<TrendChart title="Lookups" data={data.progress.lookups} color="#f5bde6" type="line" /> <TrendChart title="Lookups" data={data.progress.lookups} color="#f5bde6" type="line" />
<TrendChart <TrendChart
title="Lookups / 100 Words" title="Lookups / 100 Tokens"
data={data.ratios.lookupsPerHundred} data={data.ratios.lookupsPerHundred}
color="#f5a97f" color="#f5a97f"
type="line" type="line"
@@ -246,7 +246,7 @@ export function TrendsTab() {
data={filteredCardsPerAnime} data={filteredCardsPerAnime}
colorPalette={cardsMinedStackedColors} colorPalette={cardsMinedStackedColors}
/> />
<StackedTrendChart title="Words Seen per Anime" data={filteredWordsPerAnime} /> <StackedTrendChart title="Tokens Seen per Anime" data={filteredWordsPerAnime} />
<StackedTrendChart title="Lookups per Anime" data={filteredLookupsPerAnime} /> <StackedTrendChart title="Lookups per Anime" data={filteredLookupsPerAnime} />
<StackedTrendChart <StackedTrendChart
title="Lookups/100w per Anime" title="Lookups/100w per Anime"
@@ -261,7 +261,7 @@ export function TrendsTab() {
data={filteredCardsProgress} data={filteredCardsProgress}
colorPalette={cardsMinedStackedColors} colorPalette={cardsMinedStackedColors}
/> />
<StackedTrendChart title="Words Seen Progress" data={filteredWordsProgress} /> <StackedTrendChart title="Tokens Seen Progress" data={filteredWordsProgress} />
<SectionHeader>Patterns</SectionHeader> <SectionHeader>Patterns</SectionHeader>
<TrendChart <TrendChart

View File

@@ -85,8 +85,8 @@ test('buildOverviewSummary aggregates tracked totals and recent windows', () =>
assert.equal(summary.activeDays, 2); assert.equal(summary.activeDays, 2);
assert.equal(summary.totalSessions, 15); assert.equal(summary.totalSessions, 15);
assert.deepEqual(summary.lookupRate, { assert.deepEqual(summary.lookupRate, {
shortValue: '2.3 / 100 words', shortValue: '2.3 / 100 tokens',
longValue: '2.3 lookups per 100 words', longValue: '2.3 lookups per 100 tokens',
}); });
}); });

View File

@@ -35,6 +35,6 @@ test('MediaSessionList renders expandable session rows with delete affordance',
assert.match(markup, /Session History/); assert.match(markup, /Session History/);
assert.match(markup, /aria-expanded="true"/); assert.match(markup, /aria-expanded="true"/);
assert.match(markup, /Delete session Episode 7/); assert.match(markup, /Delete session Episode 7/);
assert.match(markup, /words/); assert.match(markup, /tokens/);
assert.match(markup, /No word data for this session/); assert.match(markup, /No token data for this session/);
}); });

View File

@@ -30,7 +30,7 @@ test('SessionDetail omits the misleading new words metric', () => {
/>, />,
); );
assert.match(markup, /No word data/); assert.match(markup, /No token data/);
assert.doesNotMatch(markup, /New words/); assert.doesNotMatch(markup, /New words/);
}); });

View File

@@ -8,10 +8,10 @@ import { SessionRow } from '../components/sessions/SessionRow';
import { EventType, type SessionEvent } from '../types/stats'; import { EventType, type SessionEvent } from '../types/stats';
import { buildLookupRateDisplay, getYomitanLookupEvents } from './yomitan-lookup'; import { buildLookupRateDisplay, getYomitanLookupEvents } from './yomitan-lookup';
test('buildLookupRateDisplay formats lookups per 100 words in short and long forms', () => { test('buildLookupRateDisplay formats lookups per 100 tokens in short and long forms', () => {
assert.deepEqual(buildLookupRateDisplay(23, 1000), { assert.deepEqual(buildLookupRateDisplay(23, 1000), {
shortValue: '2.3 / 100 words', shortValue: '2.3 / 100 tokens',
longValue: '2.3 lookups per 100 words', longValue: '2.3 lookups per 100 tokens',
}); });
assert.equal(buildLookupRateDisplay(0, 0), null); assert.equal(buildLookupRateDisplay(0, 0), null);
}); });
@@ -49,11 +49,11 @@ test('MediaHeader renders Yomitan lookup count and lookup rate copy', () => {
); );
assert.match(markup, /23/); assert.match(markup, /23/);
assert.match(markup, /2\.3 \/ 100 words/); assert.match(markup, /2\.3 \/ 100 tokens/);
assert.match(markup, /2\.3 lookups per 100 words/); assert.match(markup, /2\.3 lookups per 100 tokens/);
}); });
test('MediaHeader distinguishes word occurrences from known unique words', () => { test('MediaHeader distinguishes token occurrences from known unique words', () => {
const markup = renderToStaticMarkup( const markup = renderToStaticMarkup(
<MediaHeader <MediaHeader
detail={{ detail={{
@@ -76,7 +76,7 @@ test('MediaHeader distinguishes word occurrences from known unique words', () =>
/>, />,
); );
assert.match(markup, /word occurrences/); assert.match(markup, /token occurrences/);
assert.match(markup, /known unique words \(50%\)/); assert.match(markup, /known unique words \(50%\)/);
assert.match(markup, /17 \/ 34/); assert.match(markup, /17 \/ 34/);
}); });
@@ -105,7 +105,7 @@ test('EpisodeList renders per-episode Yomitan lookup rate', () => {
); );
assert.match(markup, /Lookup Rate/); assert.match(markup, /Lookup Rate/);
assert.match(markup, /2\.0 \/ 100 words/); assert.match(markup, /2\.0 \/ 100 tokens/);
assert.match(markup, /6%/); assert.match(markup, /6%/);
assert.doesNotMatch(markup, /90%/); assert.doesNotMatch(markup, /90%/);
}); });
@@ -139,11 +139,11 @@ test('AnimeOverviewStats renders aggregate Yomitan lookup metrics', () => {
assert.match(markup, /Lookups/); assert.match(markup, /Lookups/);
assert.match(markup, /16/); assert.match(markup, /16/);
assert.match(markup, /2\.0 \/ 100 words/); assert.match(markup, /2\.0 \/ 100 tokens/);
assert.match(markup, /Yomitan lookups per 100 words seen/); assert.match(markup, /Yomitan lookups per 100 tokens seen/);
}); });
test('SessionRow prefers word-based count when available', () => { test('SessionRow prefers token-based word count when available', () => {
const markup = renderToStaticMarkup( const markup = renderToStaticMarkup(
<SessionRow <SessionRow
session={{ session={{