mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 04:19:27 -07:00
Compare commits
7 Commits
ba9bae63e4
...
b7e0026d48
| Author | SHA1 | Date | |
|---|---|---|---|
|
b7e0026d48
|
|||
|
207151dba3
|
|||
|
c6e6aeebbe
|
|||
|
e9fc6bf8ec
|
|||
|
2e43d95396
|
|||
|
3e7615b3bd
|
|||
|
1f48ff000c
|
@@ -66,6 +66,10 @@ Local stats dashboard — watch time, anime library, vocabulary growth, mining t
|
|||||||
### Integrations
|
### Integrations
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td><b>YouTube</b></td>
|
||||||
|
<td>App-owned subtitle picker with downloaded/native track selection and local fallback generation</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>AniList</b></td>
|
<td><b>AniList</b></td>
|
||||||
<td>Automatic episode tracking and progress sync</td>
|
<td>Automatic episode tracking and progress sync</td>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
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
|
- 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-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- dictionary
|
- dictionary
|
||||||
- startup
|
- startup
|
||||||
@@ -18,7 +18,7 @@ references:
|
|||||||
- >-
|
- >-
|
||||||
/home/sudacode/projects/japanese/SubMiner/src/main/runtime/current-media-tokenization-gate.ts
|
/home/sudacode/projects/japanese/SubMiner/src/main/runtime/current-media-tokenization-gate.ts
|
||||||
priority: high
|
priority: high
|
||||||
ordinal: 38500
|
ordinal: 144500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ status: Done
|
|||||||
assignee:
|
assignee:
|
||||||
- '@codex'
|
- '@codex'
|
||||||
created_date: '2026-03-19 17:46'
|
created_date: '2026-03-19 17:46'
|
||||||
updated_date: '2026-03-19 17:54'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- stats
|
- stats
|
||||||
- immersion-tracking
|
- immersion-tracking
|
||||||
@@ -19,6 +19,7 @@ references:
|
|||||||
- src/core/services/stats-server.ts
|
- src/core/services/stats-server.ts
|
||||||
parent_task_id: TASK-177
|
parent_task_id: TASK-177
|
||||||
priority: medium
|
priority: medium
|
||||||
|
ordinal: 132500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ status: Done
|
|||||||
assignee:
|
assignee:
|
||||||
- '@codex'
|
- '@codex'
|
||||||
created_date: '2026-03-19 19:38'
|
created_date: '2026-03-19 19:38'
|
||||||
updated_date: '2026-03-19 19:40'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- stats
|
- stats
|
||||||
- immersion-tracking
|
- immersion-tracking
|
||||||
@@ -17,6 +17,7 @@ references:
|
|||||||
- stats/src/lib/dashboard-data.ts
|
- stats/src/lib/dashboard-data.ts
|
||||||
parent_task_id: TASK-177
|
parent_task_id: TASK-177
|
||||||
priority: medium
|
priority: medium
|
||||||
|
ordinal: 130500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ status: Done
|
|||||||
assignee:
|
assignee:
|
||||||
- '@codex'
|
- '@codex'
|
||||||
created_date: '2026-03-19 20:15'
|
created_date: '2026-03-19 20:15'
|
||||||
updated_date: '2026-03-19 20:17'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- launcher
|
- launcher
|
||||||
- stats
|
- stats
|
||||||
@@ -19,6 +19,7 @@ references:
|
|||||||
- src/main/runtime/stats-cli-command.test.ts
|
- src/main/runtime/stats-cli-command.test.ts
|
||||||
parent_task_id: TASK-177
|
parent_task_id: TASK-177
|
||||||
priority: medium
|
priority: medium
|
||||||
|
ordinal: 129500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ status: Done
|
|||||||
assignee:
|
assignee:
|
||||||
- codex
|
- codex
|
||||||
created_date: '2026-03-19 20:31'
|
created_date: '2026-03-19 20:31'
|
||||||
updated_date: '2026-03-19 20:52'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- bug
|
- bug
|
||||||
- stats
|
- stats
|
||||||
@@ -17,6 +17,7 @@ references:
|
|||||||
- >-
|
- >-
|
||||||
/Users/sudacode/projects/japanese/SubMiner/stats/src/lib/session-detail.test.tsx
|
/Users/sudacode/projects/japanese/SubMiner/stats/src/lib/session-detail.test.tsx
|
||||||
parent_task_id: TASK-182
|
parent_task_id: TASK-182
|
||||||
|
ordinal: 128500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ status: Done
|
|||||||
assignee:
|
assignee:
|
||||||
- codex
|
- codex
|
||||||
created_date: '2026-03-18 00:29'
|
created_date: '2026-03-18 00:29'
|
||||||
updated_date: '2026-03-18 00:55'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- stats
|
- stats
|
||||||
- performance
|
- performance
|
||||||
@@ -22,6 +22,7 @@ references:
|
|||||||
- stats/src/types/stats.ts
|
- stats/src/types/stats.ts
|
||||||
- stats/src/lib/dashboard-data.ts
|
- stats/src/lib/dashboard-data.ts
|
||||||
priority: medium
|
priority: medium
|
||||||
|
ordinal: 138500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ status: Done
|
|||||||
assignee:
|
assignee:
|
||||||
- codex
|
- codex
|
||||||
created_date: '2026-03-17 23:15'
|
created_date: '2026-03-17 23:15'
|
||||||
updated_date: '2026-03-17 23:18'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- pr-review
|
- pr-review
|
||||||
- stats
|
- stats
|
||||||
@@ -16,6 +16,7 @@ references:
|
|||||||
- src/core/services/immersion-tracker-service.ts
|
- src/core/services/immersion-tracker-service.ts
|
||||||
- src/core/services/immersion-tracker-service.test.ts
|
- src/core/services/immersion-tracker-service.test.ts
|
||||||
priority: medium
|
priority: medium
|
||||||
|
ordinal: 139500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
---
|
|
||||||
id: TASK-192
|
|
||||||
title: 'Assess remaining PR #19 review batch'
|
|
||||||
status: Done
|
|
||||||
assignee:
|
|
||||||
- codex
|
|
||||||
created_date: '2026-03-17 23:24'
|
|
||||||
updated_date: '2026-03-17 23:42'
|
|
||||||
labels:
|
|
||||||
- pr-review
|
|
||||||
- stats
|
|
||||||
- docs
|
|
||||||
milestone: m-1
|
|
||||||
dependencies: []
|
|
||||||
references:
|
|
||||||
- docs/superpowers/plans/2026-03-12-immersion-stats-page.md
|
|
||||||
- src/core/services/immersion-tracker/__tests__/query.test.ts
|
|
||||||
- src/core/services/ipc.ts
|
|
||||||
- src/core/services/stats-server.ts
|
|
||||||
- src/main.ts
|
|
||||||
- src/renderer/handlers/keyboard.ts
|
|
||||||
- stats/src
|
|
||||||
priority: medium
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
Validate the remaining PR #19 automated review findings against the current branch, implement only the technically correct fixes, and document which comments are stale, already addressed, or not warranted.
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
- [x] #1 Each remaining review comment is classified as actionable, already fixed, stale, or not warranted
|
|
||||||
- [x] #2 Confirmed bugs or correctness issues are fixed with focused regression coverage where it fits
|
|
||||||
- [x] #3 Final notes record which comments were intentionally not applied and why
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
||||||
## Implementation Plan
|
|
||||||
|
|
||||||
<!-- SECTION:PLAN:BEGIN -->
|
|
||||||
1. Inspect the referenced files in batches and compare each comment against current branch behavior.
|
|
||||||
2. Separate correctness/security regressions from stylistic nitpicks and already-fixed items.
|
|
||||||
3. Add tests first for confirmed behavior bugs where practical, apply the smallest safe fixes, and rerun targeted verification.
|
|
||||||
<!-- SECTION:PLAN:END -->
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
<!-- SECTION:NOTES:BEGIN -->
|
|
||||||
Swept the pasted PR #19 review batch against the current branch.
|
|
||||||
|
|
||||||
Classification:
|
|
||||||
- Already fixed on current branch: `src/core/services/immersion-tracker/__tests__/query.test.ts` cleanup rethrow, `src/core/services/ipc.ts` limit validation, `src/core/services/stats-server.ts` max-limit parsing and CORS removal, `src/main.ts` quit-path TDZ issue, `src/renderer/handlers/keyboard.ts` stats-toggle shortcut ordering/config usage, `stats/src/components/vocabulary/WordList.tsx`, `stats/src/hooks/useSessions.ts`, `stats/src/hooks/useTrends.ts` stale-error reset, `src/core/services/__tests__/stats-server.test.ts` kanji endpoint/readability notes, `src/core/services/stats-window.ts`, `stats/src/App.tsx`, `stats/src/components/layout/TabBar.tsx`, `stats/src/components/overview/QuickStats.tsx`, `stats/src/components/overview/WatchTimeChart.tsx`, `stats/src/components/sessions/SessionDetail.tsx`, `stats/src/components/sessions/SessionRow.tsx`, `stats/src/components/trends/DateRangeSelector.tsx`, `stats/src/components/vocabulary/KanjiBreakdown.tsx`, `stats/src/components/vocabulary/VocabularyTab.tsx`, `stats/src/hooks/useVocabulary.ts`, `stats/src/lib/api-client.ts`, `stats/src/types/stats.ts`.
|
|
||||||
- Stale / obsolete against current architecture: `docs/superpowers/plans/2026-03-12-immersion-stats-page.md` path does not exist on this branch; `stats/src/components/trends/TrendsTab.tsx` / monthly-range comments describe older client-side aggregation code that is no longer present because trends now come from `getTrendsDashboard`.
|
|
||||||
- Not warranted as written: `stats/src/lib/formatters.ts` no longer emits negative `Xd ago`; current code short-circuits future timestamps to `just now`, so the reported bug condition is gone even though the suggested wording differs.
|
|
||||||
- Actionable and fixed now: `src/core/services/ipc.ts` no-tracker `statsGetOverview` fallback omitted required hint fields (`totalLookupCount`, `totalLookupHits`, `newWordsToday`, `newWordsThisWeek`). Added the missing fields in the fallback object and updated IPC tests to assert the full shape.
|
|
||||||
|
|
||||||
Verification:
|
|
||||||
- `bun test src/core/services/ipc.test.ts`
|
|
||||||
- `bun test src/core/services/ipc.test.ts --test-name-pattern "empty stats overview shape without a tracker|validates and clamps stats request limits"`
|
|
||||||
- `bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh src/core/services/ipc.ts src/core/services/ipc.test.ts`
|
|
||||||
|
|
||||||
Repo verifier note:
|
|
||||||
- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core src/core/services/ipc.ts src/core/services/ipc.test.ts`
|
|
||||||
- That verifier run captured a temporary `bun run typecheck` failure in `src/anki-integration.test.ts` and `src/core/services/__tests__/stats-server.test.ts`, but a fresh rerun after the follow-up validation no longer reproduces those diagnostics.
|
|
||||||
- Fresh verification: `bun run typecheck` passes locally.
|
|
||||||
- artifact dir from the earlier failed verifier snapshot: `.tmp/skill-verification/subminer-verify-20260317-234027-i6QJ3n`
|
|
||||||
<!-- SECTION:NOTES:END -->
|
|
||||||
|
|
||||||
## Final Summary
|
|
||||||
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
|
||||||
The larger pasted PR #19 review batch was not mostly new work on the current branch. After verifying each item against the live code, almost all were already fixed or stale. One additional item was still actionable: the no-tracker fallback returned by `statsGetOverview` in `src/core/services/ipc.ts` omitted required hint fields, which made the fallback shape inconsistent with the normal overview payload. That fallback is now fixed and covered by IPC tests.
|
|
||||||
|
|
||||||
Count-wise: the earlier open CodeRabbit service comments contributed 2 actionable fixes, and this larger pasted batch contributed 1 additional actionable fix on top of those.
|
|
||||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
|
||||||
@@ -5,7 +5,7 @@ status: Done
|
|||||||
assignee:
|
assignee:
|
||||||
- codex
|
- codex
|
||||||
created_date: '2026-03-20 00:12'
|
created_date: '2026-03-20 00:12'
|
||||||
updated_date: '2026-03-20 00:14'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- stats
|
- stats
|
||||||
- immersion-tracker
|
- immersion-tracker
|
||||||
@@ -17,6 +17,7 @@ references:
|
|||||||
- src/core/services/immersion-tracker/query.ts
|
- src/core/services/immersion-tracker/query.ts
|
||||||
- src/core/services/immersion-tracker-service.test.ts
|
- src/core/services/immersion-tracker-service.test.ts
|
||||||
priority: medium
|
priority: medium
|
||||||
|
ordinal: 127500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ title: App-owned YouTube subtitle picker flow
|
|||||||
status: Done
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-03-18 07:52'
|
created_date: '2026-03-18 07:52'
|
||||||
|
updated_date: '2026-03-23 03:22'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
references:
|
references:
|
||||||
@@ -13,6 +14,7 @@ references:
|
|||||||
documentation:
|
documentation:
|
||||||
- /home/sudacode/projects/japanese/SubMiner/youtube.md
|
- /home/sudacode/projects/japanese/SubMiner/youtube.md
|
||||||
priority: medium
|
priority: medium
|
||||||
|
ordinal: 137500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
@@ -4,13 +4,16 @@ title: Fix subtitle prefetch cache-key mismatch and active-cue window
|
|||||||
status: Done
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-03-18 16:05'
|
created_date: '2026-03-18 16:05'
|
||||||
|
updated_date: '2026-03-23 03:22'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
references:
|
references:
|
||||||
- /home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-processing-controller.ts
|
- >-
|
||||||
- /home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-prefetch.ts
|
/home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-processing-controller.ts
|
||||||
documentation: []
|
- >-
|
||||||
|
/home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-prefetch.ts
|
||||||
priority: high
|
priority: high
|
||||||
|
ordinal: 136500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -4,15 +4,19 @@ title: Eliminate per-line plain subtitle flash on prefetch cache hit
|
|||||||
status: Done
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-03-18 16:28'
|
created_date: '2026-03-18 16:28'
|
||||||
|
updated_date: '2026-03-23 03:22'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies:
|
dependencies:
|
||||||
- TASK-196
|
- TASK-196
|
||||||
references:
|
references:
|
||||||
- /home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-processing-controller.ts
|
- >-
|
||||||
- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/mpv-main-event-actions.ts
|
/home/sudacode/projects/japanese/SubMiner/src/core/services/subtitle-processing-controller.ts
|
||||||
- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/mpv-main-event-main-deps.ts
|
- >-
|
||||||
documentation: []
|
/home/sudacode/projects/japanese/SubMiner/src/main/runtime/mpv-main-event-actions.ts
|
||||||
|
- >-
|
||||||
|
/home/sudacode/projects/japanese/SubMiner/src/main/runtime/mpv-main-event-main-deps.ts
|
||||||
priority: high
|
priority: high
|
||||||
|
ordinal: 135500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ title: Forward launcher log level into mpv plugin script opts
|
|||||||
status: Done
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-03-18 21:16'
|
created_date: '2026-03-18 21:16'
|
||||||
|
updated_date: '2026-03-23 03:22'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies:
|
dependencies:
|
||||||
- TASK-198
|
- TASK-198
|
||||||
@@ -12,8 +13,8 @@ references:
|
|||||||
- /home/sudacode/projects/japanese/SubMiner/launcher/mpv.ts
|
- /home/sudacode/projects/japanese/SubMiner/launcher/mpv.ts
|
||||||
- /home/sudacode/projects/japanese/SubMiner/launcher/main.test.ts
|
- /home/sudacode/projects/japanese/SubMiner/launcher/main.test.ts
|
||||||
- /home/sudacode/projects/japanese/SubMiner/launcher/aniskip-metadata.test.ts
|
- /home/sudacode/projects/japanese/SubMiner/launcher/aniskip-metadata.test.ts
|
||||||
documentation: []
|
|
||||||
priority: medium
|
priority: medium
|
||||||
|
ordinal: 134500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ status: Done
|
|||||||
assignee:
|
assignee:
|
||||||
- '@codex'
|
- '@codex'
|
||||||
created_date: '2026-03-19 07:18'
|
created_date: '2026-03-19 07:18'
|
||||||
updated_date: '2026-03-19 07:28'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- pr-review
|
- pr-review
|
||||||
- anki-integration
|
- anki-integration
|
||||||
@@ -19,6 +19,7 @@ references:
|
|||||||
- src/anki-integration/runtime.ts
|
- src/anki-integration/runtime.ts
|
||||||
- src/anki-integration/known-word-cache.ts
|
- src/anki-integration/known-word-cache.ts
|
||||||
priority: medium
|
priority: medium
|
||||||
|
ordinal: 133500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ status: Done
|
|||||||
assignee:
|
assignee:
|
||||||
- '@codex'
|
- '@codex'
|
||||||
created_date: '2026-03-19 18:47'
|
created_date: '2026-03-19 18:47'
|
||||||
updated_date: '2026-03-19 19:01'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- bug
|
- bug
|
||||||
- macos
|
- macos
|
||||||
@@ -20,6 +20,7 @@ references:
|
|||||||
- >-
|
- >-
|
||||||
/Users/sudacode/projects/japanese/SubMiner/src/core/services/overlay-visibility.test.ts
|
/Users/sudacode/projects/japanese/SubMiner/src/core/services/overlay-visibility.test.ts
|
||||||
priority: high
|
priority: high
|
||||||
|
ordinal: 131500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ status: Done
|
|||||||
assignee:
|
assignee:
|
||||||
- '@Codex'
|
- '@Codex'
|
||||||
created_date: '2026-03-20 02:52'
|
created_date: '2026-03-20 02:52'
|
||||||
updated_date: '2026-03-20 03:02'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- anki
|
- anki
|
||||||
- cache
|
- cache
|
||||||
@@ -17,6 +17,7 @@ references:
|
|||||||
- docs/plans/2026-03-19-known-word-cache-incremental-sync-design.md
|
- docs/plans/2026-03-19-known-word-cache-incremental-sync-design.md
|
||||||
parent_task_id: TASK-204
|
parent_task_id: TASK-204
|
||||||
priority: high
|
priority: high
|
||||||
|
ordinal: 124500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ status: Done
|
|||||||
assignee:
|
assignee:
|
||||||
- codex
|
- codex
|
||||||
created_date: '2026-03-20 02:41'
|
created_date: '2026-03-20 02:41'
|
||||||
updated_date: '2026-03-20 02:46'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels: []
|
labels: []
|
||||||
milestone: m-1
|
milestone: m-1
|
||||||
dependencies: []
|
dependencies: []
|
||||||
@@ -14,6 +14,7 @@ references:
|
|||||||
- stats/src/hooks/useSessions.ts
|
- stats/src/hooks/useSessions.ts
|
||||||
- stats/src/hooks/useTrends.ts
|
- stats/src/hooks/useTrends.ts
|
||||||
priority: medium
|
priority: medium
|
||||||
|
ordinal: 126500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ status: Done
|
|||||||
assignee:
|
assignee:
|
||||||
- '@codex'
|
- '@codex'
|
||||||
created_date: '2026-03-20 02:51'
|
created_date: '2026-03-20 02:51'
|
||||||
updated_date: '2026-03-20 02:59'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- pr-review
|
- pr-review
|
||||||
- launcher
|
- launcher
|
||||||
@@ -22,6 +22,7 @@ references:
|
|||||||
- src/anki-integration.ts
|
- src/anki-integration.ts
|
||||||
- src/anki-integration/known-word-cache.ts
|
- src/anki-integration/known-word-cache.ts
|
||||||
priority: medium
|
priority: medium
|
||||||
|
ordinal: 125500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ status: Done
|
|||||||
assignee:
|
assignee:
|
||||||
- '@codex'
|
- '@codex'
|
||||||
created_date: '2026-03-20 03:03'
|
created_date: '2026-03-20 03:03'
|
||||||
updated_date: '2026-03-20 03:04'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- pr-review
|
- pr-review
|
||||||
- anki-integration
|
- anki-integration
|
||||||
@@ -15,6 +15,7 @@ dependencies: []
|
|||||||
references:
|
references:
|
||||||
- src/anki-integration/anki-connect-proxy.test.ts
|
- src/anki-integration/anki-connect-proxy.test.ts
|
||||||
priority: medium
|
priority: medium
|
||||||
|
ordinal: 123500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ status: Done
|
|||||||
assignee:
|
assignee:
|
||||||
- '@codex'
|
- '@codex'
|
||||||
created_date: '2026-03-20 03:37'
|
created_date: '2026-03-20 03:37'
|
||||||
updated_date: '2026-03-20 03:47'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- pr-review
|
- pr-review
|
||||||
- launcher
|
- launcher
|
||||||
@@ -17,6 +17,7 @@ references:
|
|||||||
- launcher/mpv.ts
|
- launcher/mpv.ts
|
||||||
- src/anki-integration.ts
|
- src/anki-integration.ts
|
||||||
priority: medium
|
priority: medium
|
||||||
|
ordinal: 122500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ status: Done
|
|||||||
assignee:
|
assignee:
|
||||||
- codex
|
- codex
|
||||||
created_date: '2026-03-20 04:06'
|
created_date: '2026-03-20 04:06'
|
||||||
updated_date: '2026-03-20 04:33'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- bug
|
- bug
|
||||||
- tokenizer
|
- tokenizer
|
||||||
@@ -18,6 +18,7 @@ references:
|
|||||||
- >-
|
- >-
|
||||||
/Users/sudacode/projects/japanese/SubMiner/src/core/services/tokenizer.test.ts
|
/Users/sudacode/projects/japanese/SubMiner/src/core/services/tokenizer.test.ts
|
||||||
priority: high
|
priority: high
|
||||||
|
ordinal: 120500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ status: Done
|
|||||||
assignee:
|
assignee:
|
||||||
- '@Codex'
|
- '@Codex'
|
||||||
created_date: '2026-03-20 04:09'
|
created_date: '2026-03-20 04:09'
|
||||||
updated_date: '2026-03-20 04:25'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- stats
|
- stats
|
||||||
- bug
|
- bug
|
||||||
@@ -17,6 +17,7 @@ references:
|
|||||||
- src/core/services/immersion-tracker/query.ts
|
- src/core/services/immersion-tracker/query.ts
|
||||||
- src/core/services/immersion-tracker/session.ts
|
- src/core/services/immersion-tracker/session.ts
|
||||||
- src/core/services/immersion-tracker-service.ts
|
- src/core/services/immersion-tracker-service.ts
|
||||||
|
ordinal: 121500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
---
|
---
|
||||||
id: TASK-211
|
id: TASK-211
|
||||||
title: Recover anime episode progress from subtitle timing when checkpoints are missing
|
title: >-
|
||||||
|
Recover anime episode progress from subtitle timing when checkpoints are
|
||||||
|
missing
|
||||||
status: Done
|
status: Done
|
||||||
assignee:
|
assignee:
|
||||||
- '@Codex'
|
- '@Codex'
|
||||||
created_date: '2026-03-20 10:15'
|
created_date: '2026-03-20 10:15'
|
||||||
updated_date: '2026-03-20 10:22'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- stats
|
- stats
|
||||||
- bug
|
- bug
|
||||||
@@ -14,20 +16,26 @@ dependencies: []
|
|||||||
references:
|
references:
|
||||||
- src/core/services/immersion-tracker/query.ts
|
- src/core/services/immersion-tracker/query.ts
|
||||||
- src/core/services/immersion-tracker/__tests__/query.test.ts
|
- src/core/services/immersion-tracker/__tests__/query.test.ts
|
||||||
|
ordinal: 119500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
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.
|
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.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
- [x] `getAnimeEpisodes` returns the latest known session position even when `ended_media_ms` is null but subtitle/event timing exists.
|
- [x] #1 `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] #2 Existing ended-session metrics and aggregation totals do not regress.
|
||||||
- [x] Regression coverage locks the fallback behavior.
|
- [x] #3 Regression coverage locks the fallback behavior.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
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.
|
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.
|
Verification: `bun test src/core/services/immersion-tracker/__tests__/query.test.ts` passed. `bun run typecheck` passed.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: TASK-212
|
id: TASK-212
|
||||||
title: Fix mac texthooker helper startup blocking mpv launch
|
title: Fix mac texthooker helper startup blocking mpv launch
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-03-20 08:27'
|
created_date: '2026-03-20 08:27'
|
||||||
updated_date: '2026-03-20 08:45'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- bug
|
- bug
|
||||||
- macos
|
- macos
|
||||||
@@ -15,6 +15,7 @@ references:
|
|||||||
- /Users/sudacode/projects/japanese/SubMiner/src/main.ts
|
- /Users/sudacode/projects/japanese/SubMiner/src/main.ts
|
||||||
- /Users/sudacode/projects/japanese/SubMiner/plugin/subminer/process.lua
|
- /Users/sudacode/projects/japanese/SubMiner/plugin/subminer/process.lua
|
||||||
priority: high
|
priority: high
|
||||||
|
ordinal: 140500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: TASK-213
|
id: TASK-213
|
||||||
title: Show character dictionary progress during paused startup waits
|
title: Show character dictionary progress during paused startup waits
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-03-20 08:59'
|
created_date: '2026-03-20 08:59'
|
||||||
updated_date: '2026-03-20 09:22'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- bug
|
- bug
|
||||||
- ux
|
- ux
|
||||||
@@ -18,6 +18,7 @@ references:
|
|||||||
/Users/sudacode/projects/japanese/SubMiner/src/main/runtime/character-dictionary-auto-sync-notifications.ts
|
/Users/sudacode/projects/japanese/SubMiner/src/main/runtime/character-dictionary-auto-sync-notifications.ts
|
||||||
- /Users/sudacode/projects/japanese/SubMiner/src/main.ts
|
- /Users/sudacode/projects/japanese/SubMiner/src/main.ts
|
||||||
priority: medium
|
priority: medium
|
||||||
|
ordinal: 141500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: TASK-214
|
id: TASK-214
|
||||||
title: Jump subtitle sidebar directly to resume position on first resolved cue
|
title: Jump subtitle sidebar directly to resume position on first resolved cue
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-03-21 11:15'
|
created_date: '2026-03-21 11:15'
|
||||||
updated_date: '2026-03-21 11:15'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- bug
|
- bug
|
||||||
- ux
|
- ux
|
||||||
@@ -12,9 +12,12 @@ labels:
|
|||||||
- subtitles
|
- subtitles
|
||||||
dependencies: []
|
dependencies: []
|
||||||
references:
|
references:
|
||||||
- /Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.ts
|
- >-
|
||||||
- /Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.test.ts
|
/Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.ts
|
||||||
|
- >-
|
||||||
|
/Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.test.ts
|
||||||
priority: medium
|
priority: medium
|
||||||
|
ordinal: 142500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: TASK-215
|
id: TASK-215
|
||||||
title: Add startup auto-open option for subtitle sidebar
|
title: Add startup auto-open option for subtitle sidebar
|
||||||
status: In Progress
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-03-21 11:35'
|
created_date: '2026-03-21 11:35'
|
||||||
updated_date: '2026-03-21 11:35'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- feature
|
- feature
|
||||||
- ux
|
- ux
|
||||||
@@ -13,11 +13,15 @@ labels:
|
|||||||
dependencies: []
|
dependencies: []
|
||||||
references:
|
references:
|
||||||
- /Users/sudacode/projects/japanese/SubMiner/src/types.ts
|
- /Users/sudacode/projects/japanese/SubMiner/src/types.ts
|
||||||
- /Users/sudacode/projects/japanese/SubMiner/src/config/definitions/defaults-subtitle.ts
|
- >-
|
||||||
- /Users/sudacode/projects/japanese/SubMiner/src/config/resolve/subtitle-domains.ts
|
/Users/sudacode/projects/japanese/SubMiner/src/config/definitions/defaults-subtitle.ts
|
||||||
- /Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.ts
|
- >-
|
||||||
|
/Users/sudacode/projects/japanese/SubMiner/src/config/resolve/subtitle-domains.ts
|
||||||
|
- >-
|
||||||
|
/Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.ts
|
||||||
- /Users/sudacode/projects/japanese/SubMiner/src/renderer/renderer.ts
|
- /Users/sudacode/projects/japanese/SubMiner/src/renderer/renderer.ts
|
||||||
priority: medium
|
priority: medium
|
||||||
|
ordinal: 143500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ status: Done
|
|||||||
assignee:
|
assignee:
|
||||||
- codex
|
- codex
|
||||||
created_date: '2026-03-21 23:16'
|
created_date: '2026-03-21 23:16'
|
||||||
updated_date: '2026-03-21 23:28'
|
updated_date: '2026-03-23 03:22'
|
||||||
labels:
|
labels:
|
||||||
- bug
|
- bug
|
||||||
- overlay
|
- overlay
|
||||||
@@ -18,6 +18,7 @@ references:
|
|||||||
documentation:
|
documentation:
|
||||||
- docs/workflow/verification.md
|
- docs/workflow/verification.md
|
||||||
priority: high
|
priority: high
|
||||||
|
ordinal: 118500
|
||||||
---
|
---
|
||||||
|
|
||||||
## Description
|
## Description
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
id: TASK-218
|
||||||
|
title: 'Delete zero-session media from stats library and trends'
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- codex
|
||||||
|
created_date: '2026-03-22 16:20'
|
||||||
|
updated_date: '2026-03-22 21:10'
|
||||||
|
labels:
|
||||||
|
- stats
|
||||||
|
- immersion-tracker
|
||||||
|
priority: medium
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker/query.ts
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker/lifetime.ts
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker/maintenance.ts
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker/__tests__/query.test.ts
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Deleting the last retained session for a video still left stale lifetime media rows and trend rollups behind, so the stats dashboard could continue showing ghost entries in Library and Trends after all sessions were gone.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Deleting the final session for a video removes that media from Library queries and detail reads
|
||||||
|
- [x] #2 Deleting the final session for a video removes stale daily/monthly trend rollups for that media
|
||||||
|
- [x] #3 Regression coverage proves zero-session media disappears from affected stats surfaces after deletion
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Add a failing regression around deleting the only retained session for a video while preexisting lifetime and rollup rows exist.
|
||||||
|
2. Patch the deletion path to rebuild lifetime and rollup state from retained sessions inside the same transaction.
|
||||||
|
3. Run focused immersion-tracker tests plus the repo-native verifier core lane and record results.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Added a query regression that seeds a finished session plus stale lifetime media/anime rows and daily/monthly rollups, deletes that only session, and asserts Library, Anime detail, and Trends all drop the media immediately.
|
||||||
|
|
||||||
|
Refactored lifetime rebuild logic so it can run inside an existing delete transaction, then reused that helper from `deleteSession`, `deleteSessions`, and `deleteVideo`.
|
||||||
|
|
||||||
|
Added a rollup rebuild helper that clears existing daily/monthly rollups and reconstructs them from retained telemetry inside the current transaction so deleted sessions cannot leave ghost trend points behind.
|
||||||
|
|
||||||
|
Verification passed:
|
||||||
|
- `bun test src/core/services/immersion-tracker/__tests__/query.test.ts`
|
||||||
|
- `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/lifetime.ts src/core/services/immersion-tracker/maintenance.ts src/core/services/immersion-tracker/__tests__/query.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/lifetime.ts src/core/services/immersion-tracker/maintenance.ts src/core/services/immersion-tracker/__tests__/query.test.ts`
|
||||||
|
|
||||||
|
Verifier artifact dir: `.tmp/skill-verification/subminer-verify-20260322-210718-n6sGL8`
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Delete paths now rebuild lifetime summaries and trend rollups after removing sessions, so when the last session for a video disappears the stats database also drops that media from Library, related detail reads, and chart data. Added a regression proving a video with only stale lifetime/rollup rows vanishes after its final session is deleted, and verified the change with focused immersion-tracker tests plus the SubMiner core verification lane.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
id: TASK-219
|
||||||
|
title: 'Restore streamed video progress in anime episodes'
|
||||||
|
status: In Progress
|
||||||
|
assignee:
|
||||||
|
- codex
|
||||||
|
created_date: '2026-03-22 21:25'
|
||||||
|
updated_date: '2026-03-22 21:25'
|
||||||
|
labels:
|
||||||
|
- stats
|
||||||
|
- immersion-tracker
|
||||||
|
- youtube
|
||||||
|
priority: medium
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker/query.ts
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker-service.ts
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker/__tests__/query.test.ts
|
||||||
|
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker-service.test.ts
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Episode progress for streamed media can stay at `0%` because some remote sessions persist `ended_media_ms = 0` even when subtitle timing and watch activity clearly advanced, and the anime episode query currently treats `0` as a valid progress checkpoint.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [ ] #1 Anime episode progress ignores zero-valued session checkpoints and falls back to subtitle/event timing
|
||||||
|
- [ ] #2 New streamed sessions persist meaningful progress even when playback-position updates are missing or sparse
|
||||||
|
- [ ] #3 Regression tests cover the zero-checkpoint remote-session case
|
||||||
|
<!-- AC:END -->
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
id: TASK-220
|
||||||
|
title: Restore YouTube overlay mpv keybindings after picker routing
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- codex
|
||||||
|
created_date: '2026-03-22 00:00'
|
||||||
|
updated_date: '2026-03-22 23:49'
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
- overlay
|
||||||
|
- youtube
|
||||||
|
- keyboard
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- src/renderer/handlers/keyboard.ts
|
||||||
|
- src/renderer/modals/youtube-track-picker.ts
|
||||||
|
- src/renderer/handlers/keyboard.test.ts
|
||||||
|
- src/renderer/modals/youtube-track-picker.test.ts
|
||||||
|
documentation:
|
||||||
|
- docs/workflow/verification.md
|
||||||
|
priority: high
|
||||||
|
ordinal: 118800
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Regression: after adding the YouTube subtitle picker modal path, visible-overlay keydown handling can stop before reaching the shared mpv keybinding dispatch path. Result: default overlay mpv bindings like `Space` pause/play and `q` quit stop working while the overlay owns focus during YouTube playback.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Unhandled keys while the YouTube track picker state is active still fall through to the shared overlay mpv keybinding dispatcher.
|
||||||
|
- [x] #2 The YouTube picker continues to consume `Enter` and `Escape` for its own actions.
|
||||||
|
- [x] #3 Renderer regression tests cover both the picker modal key contract and the shared keyboard dispatch fallback.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Add a failing renderer keyboard regression test covering YouTube picker state plus shared mpv keybinding fallback.
|
||||||
|
2. Update the global keyboard handler to return early only when the YouTube picker actually handles the key event.
|
||||||
|
3. Update the picker modal handler to return false for unhandled keys while preserving `Enter`/`Escape`.
|
||||||
|
4. Run the cheap renderer verification lane and record results.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Fixed the regression by making the global renderer keyboard handler stop early for the YouTube picker only when the picker actually consumes the key. The picker modal now returns `false` for unrelated keys, so shared overlay mpv bindings like `Space` and `KeyQ` still dispatch while the visible overlay has focus.
|
||||||
|
|
||||||
|
Added regression coverage in the keyboard handler suite for mpv keybinding fallback during YouTube picker state, plus a picker-modal contract test that keeps `Escape` handled but leaves unrelated keys unclaimed.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
- `bun test src/renderer/handlers/keyboard.test.ts src/renderer/modals/youtube-track-picker.test.ts`
|
||||||
|
- `bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh src/renderer/handlers/keyboard.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/youtube-track-picker.ts src/renderer/modals/youtube-track-picker.test.ts`
|
||||||
|
- `bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane core src/renderer/handlers/keyboard.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/youtube-track-picker.ts src/renderer/modals/youtube-track-picker.test.ts`
|
||||||
|
- verifier artifact: `.tmp/skill-verification/subminer-verify-20260322-234831-b2m6nJ`
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Restored YouTube-session overlay mpv keybindings by removing an unconditional early return added to the renderer keyboard path for the YouTube subtitle picker modal. Unhandled keys now fall through to the shared mpv keybinding dispatcher, while handled picker keys (`Enter`, `Escape`) still stay local to the picker. Added renderer regression tests for both the keyboard fallback path and the picker modal key-consumption contract.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
type: fixed
|
type: changed
|
||||||
area: immersion
|
area: launcher
|
||||||
|
|
||||||
- Hardened immersion tracker storage/session/query paths with the updated YouTube metadata flow.
|
- Added an app-owned YouTube subtitle flow that pauses mpv, lets the overlay picker choose tracks, and injects downloaded subtitle files before playback resumes.
|
||||||
- Added metadata probe support for YouTube subtitle retrieval edge cases.
|
- Added absPlayer-style YouTube timedtext parsing/conversion so downloaded subtitle tracks load as parsed cues for the sidebar, tokenization, and mining flows.
|
||||||
|
- Added yt-dlp metadata probing so YouTube playback and immersion tracking keep canonical video and channel metadata.
|
||||||
|
- Hardened the YouTube picker against duplicate submissions and tightened YouTube URL detection so follow-up runtime flows only treat real YouTube hosts as YouTube playback.
|
||||||
|
|||||||
5
changes/2026-03-23-youtube-cookie-override.md
Normal file
5
changes/2026-03-23-youtube-cookie-override.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
type: changed
|
||||||
|
area: launcher
|
||||||
|
|
||||||
|
- Stopped forcing `--ytdl-raw-options=` before user-provided MPV options during YouTube playback so existing YouTube cookie integrations in user configs are no longer clobbered.
|
||||||
|
- Reordered launcher argument application so user `--args` are appended after SubMiner’s internal YouTube defaults, preserving explicit runtime overrides for `--ytdl-raw-options-*`.
|
||||||
@@ -1276,6 +1276,14 @@ Enable or disable local immersion analytics stored in SQLite for mined subtitles
|
|||||||
| `retention.monthlyRollupsDays` | integer (`0`-`36500`) | Monthly rollup retention window. Default `0` (keep all). |
|
| `retention.monthlyRollupsDays` | integer (`0`-`36500`) | Monthly rollup retention window. Default `0` (keep all). |
|
||||||
| `retention.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). |
|
| `retention.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). |
|
||||||
|
|
||||||
|
You can also disable immersion tracking for a single session using:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SUBMINER_DISABLE_IMMERSION_TRACKING=1 subminer
|
||||||
|
```
|
||||||
|
|
||||||
|
When this is set, SubMiner skips immersion-tracker startup and does not initialize or read the immersion SQLite database for that session.
|
||||||
|
|
||||||
Default behavior keeps raw events, telemetry, sessions, and rollups forever while still maintaining lifetime summary tables and daily/monthly rollups for faster reads. If you later want bounded retention, switch `retentionMode` or set explicit `retention.*` values.
|
Default behavior keeps raw events, telemetry, sessions, and rollups forever while still maintaining lifetime summary tables and daily/monthly rollups for faster reads. If you later want bounded retention, switch `retentionMode` or set explicit `retention.*` values.
|
||||||
|
|
||||||
When `dbPath` is blank or omitted, SubMiner writes telemetry and session summaries to the default app-data location:
|
When `dbPath` is blank or omitted, SubMiner writes telemetry and session summaries to the default app-data location:
|
||||||
|
|||||||
@@ -228,12 +228,13 @@ If you also use Yomitan in a browser, configure that browser profile separately;
|
|||||||
### YouTube Playback
|
### YouTube Playback
|
||||||
|
|
||||||
`subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets.
|
`subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets.
|
||||||
For YouTube playback, SubMiner now generates or downloads subtitle tracks before mpv starts, then launches mpv with the resolved subtitle files attached.
|
For YouTube playback, SubMiner now resolves subtitle tracks before mpv starts playback: it pauses at startup, opens an overlay subtitle picker, resolves the selected tracks, then resumes with the downloaded subtitle files attached.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- Install `yt-dlp` so mpv can resolve YouTube streams and subtitle tracks reliably.
|
- Install `yt-dlp` so mpv can resolve YouTube streams and subtitle tracks reliably.
|
||||||
- For YouTube URLs, `subminer` now generates any missing subtitles before mpv launch.
|
- For YouTube URLs, the overlay picker lets you choose the primary and optional secondary subtitle tracks before playback resumes.
|
||||||
|
- For YouTube URLs, `subminer` generates only the missing tracks after probing YouTube's native/manual subtitle inventory.
|
||||||
- It probes manual/native YouTube subtitle tracks first, then falls back to local `whisper.cpp` only for missing tracks.
|
- It probes manual/native YouTube subtitle tracks first, then falls back to local `whisper.cpp` only for missing tracks.
|
||||||
- Primary subtitle target languages come from `youtubeSubgen.primarySubLanguages` (defaults to `["ja","jpn"]`).
|
- Primary subtitle target languages come from `youtubeSubgen.primarySubLanguages` (defaults to `["ja","jpn"]`).
|
||||||
- Secondary target languages come from `secondarySub.secondarySubLanguages` (defaults to English if unset).
|
- Secondary target languages come from `secondarySub.secondarySubLanguages` (defaults to English if unset).
|
||||||
|
|||||||
@@ -31,11 +31,12 @@ function checkDependencies(args: Args): void {
|
|||||||
|
|
||||||
if (!commandExists('mpv')) missing.push('mpv');
|
if (!commandExists('mpv')) missing.push('mpv');
|
||||||
|
|
||||||
if (args.targetKind === 'url' && isYoutubeTarget(args.target) && !commandExists('yt-dlp')) {
|
const isYoutubeUrl = args.targetKind === 'url' && isYoutubeTarget(args.target);
|
||||||
|
if (args.targetKind === 'url' && !isYoutubeUrl && !commandExists('yt-dlp')) {
|
||||||
missing.push('yt-dlp');
|
missing.push('yt-dlp');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.targetKind === 'url' && isYoutubeTarget(args.target) && !commandExists('ffmpeg')) {
|
if (args.targetKind === 'url' && !isYoutubeUrl && !commandExists('ffmpeg')) {
|
||||||
missing.push('ffmpeg');
|
missing.push('ffmpeg');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -557,13 +557,9 @@ export async function startMpv(
|
|||||||
const mpvArgs: string[] = [];
|
const mpvArgs: string[] = [];
|
||||||
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
|
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
|
||||||
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
|
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
|
||||||
if (args.mpvArgs) {
|
|
||||||
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetKind === 'url' && isYoutubeTarget(target)) {
|
if (targetKind === 'url' && isYoutubeTarget(target)) {
|
||||||
log('info', args.logLevel, 'Applying URL playback options');
|
log('info', args.logLevel, 'Applying URL playback options');
|
||||||
mpvArgs.push('--ytdl=yes', '--ytdl-raw-options=');
|
mpvArgs.push('--ytdl=yes');
|
||||||
|
|
||||||
if (isYoutubeTarget(target)) {
|
if (isYoutubeTarget(target)) {
|
||||||
const subtitleLangs = uniqueNormalizedLangCodes([
|
const subtitleLangs = uniqueNormalizedLangCodes([
|
||||||
@@ -588,6 +584,9 @@ export async function startMpv(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (args.mpvArgs) {
|
||||||
|
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
|
||||||
|
}
|
||||||
|
|
||||||
if (preloadedSubtitles?.primaryPath) {
|
if (preloadedSubtitles?.primaryPath) {
|
||||||
mpvArgs.push(`--sub-file=${preloadedSubtitles.primaryPath}`);
|
mpvArgs.push(`--sub-file=${preloadedSubtitles.primaryPath}`);
|
||||||
|
|||||||
@@ -1284,6 +1284,40 @@ test('flushTelemetry checkpoints latest playback position on the active session
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('recordSubtitleLine advances session checkpoint progress when playback position is unavailable', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor({ dbPath });
|
||||||
|
|
||||||
|
tracker.handleMediaChange('https://stream.example.com/subtitle-progress.m3u8', 'Subtitle Progress');
|
||||||
|
tracker.recordSubtitleLine('line one', 170, 185, [], null);
|
||||||
|
|
||||||
|
const privateApi = tracker as unknown as {
|
||||||
|
db: DatabaseSync;
|
||||||
|
sessionState: { sessionId: number } | null;
|
||||||
|
flushTelemetry: (force?: boolean) => void;
|
||||||
|
flushNow: () => void;
|
||||||
|
};
|
||||||
|
const sessionId = privateApi.sessionState?.sessionId;
|
||||||
|
assert.ok(sessionId);
|
||||||
|
|
||||||
|
privateApi.flushTelemetry(true);
|
||||||
|
privateApi.flushNow();
|
||||||
|
|
||||||
|
const row = privateApi.db
|
||||||
|
.prepare('SELECT ended_media_ms FROM imm_sessions WHERE session_id = ?')
|
||||||
|
.get(sessionId) as { ended_media_ms: number | null } | null;
|
||||||
|
|
||||||
|
assert.equal(row?.ended_media_ms, 185_000);
|
||||||
|
} finally {
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('deleteSession ignores the currently active session and keeps new writes flushable', async () => {
|
test('deleteSession ignores the currently active session and keeps new writes flushable', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
@@ -2412,6 +2446,23 @@ printf '%s\n' '${ytDlpOutput}'
|
|||||||
`,
|
`,
|
||||||
)
|
)
|
||||||
.get() as { canonicalTitle: string } | null;
|
.get() as { canonicalTitle: string } | null;
|
||||||
|
const animeRow = privateApi.db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
a.canonical_title AS canonicalTitle,
|
||||||
|
v.parsed_title AS parsedTitle,
|
||||||
|
v.parser_source AS parserSource
|
||||||
|
FROM imm_videos v
|
||||||
|
JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||||
|
WHERE v.video_id = 1
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.get() as {
|
||||||
|
canonicalTitle: string;
|
||||||
|
parsedTitle: string | null;
|
||||||
|
parserSource: string | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
assert.ok(row);
|
assert.ok(row);
|
||||||
assert.ok(videoRow);
|
assert.ok(videoRow);
|
||||||
@@ -2427,6 +2478,9 @@ printf '%s\n' '${ytDlpOutput}'
|
|||||||
assert.equal(row.uploaderUrl, 'https://www.youtube.com/@creator');
|
assert.equal(row.uploaderUrl, 'https://www.youtube.com/@creator');
|
||||||
assert.equal(row.description, 'Video description');
|
assert.equal(row.description, 'Video description');
|
||||||
assert.equal(videoRow.canonicalTitle, 'Video Name');
|
assert.equal(videoRow.canonicalTitle, 'Video Name');
|
||||||
|
assert.equal(animeRow?.canonicalTitle, 'Creator Name');
|
||||||
|
assert.equal(animeRow?.parsedTitle, 'Creator Name');
|
||||||
|
assert.equal(animeRow?.parserSource, 'youtube');
|
||||||
} finally {
|
} finally {
|
||||||
process.env.PATH = originalPath;
|
process.env.PATH = originalPath;
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
@@ -2438,6 +2492,419 @@ printf '%s\n' '${ytDlpOutput}'
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getMediaLibrary lazily backfills missing youtube metadata for existing rows', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
const originalPath = process.env.PATH;
|
||||||
|
let fakeBinDir: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
fakeBinDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yt-dlp-bin-'));
|
||||||
|
const ytDlpOutput =
|
||||||
|
'{"id":"backfill123","title":"Backfilled Video Title","webpage_url":"https://www.youtube.com/watch?v=backfill123","thumbnail":"https://i.ytimg.com/vi/backfill123/hqdefault.jpg","channel_id":"UCbackfill123","channel":"Backfill Creator","channel_url":"https://www.youtube.com/channel/UCbackfill123","uploader_id":"@backfill","uploader_url":"https://www.youtube.com/@backfill","description":"Backfilled description","thumbnails":[{"url":"https://i.ytimg.com/vi/backfill123/hqdefault.jpg"},{"url":"https://yt3.googleusercontent.com/backfill-avatar=s88"}]}';
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const outputPath = path.join(fakeBinDir, 'output.json');
|
||||||
|
fs.writeFileSync(outputPath, ytDlpOutput, 'utf8');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(fakeBinDir, 'yt-dlp.cmd'),
|
||||||
|
'@echo off\r\ntype "%~dp0output.json"\r\n',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const scriptPath = path.join(fakeBinDir, 'yt-dlp');
|
||||||
|
fs.writeFileSync(
|
||||||
|
scriptPath,
|
||||||
|
`#!/bin/sh
|
||||||
|
printf '%s\n' '${ytDlpOutput}'
|
||||||
|
`,
|
||||||
|
{ mode: 0o755 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
process.env.PATH = `${fakeBinDir}${path.delimiter}${originalPath ?? ''}`;
|
||||||
|
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor({ dbPath });
|
||||||
|
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||||
|
const nowMs = Date.now();
|
||||||
|
|
||||||
|
privateApi.db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO imm_videos (
|
||||||
|
video_key,
|
||||||
|
canonical_title,
|
||||||
|
source_type,
|
||||||
|
source_path,
|
||||||
|
source_url,
|
||||||
|
duration_ms,
|
||||||
|
file_size_bytes,
|
||||||
|
codec_id,
|
||||||
|
container_id,
|
||||||
|
width_px,
|
||||||
|
height_px,
|
||||||
|
fps_x100,
|
||||||
|
bitrate_kbps,
|
||||||
|
audio_codec_id,
|
||||||
|
hash_sha256,
|
||||||
|
screenshot_path,
|
||||||
|
metadata_json,
|
||||||
|
CREATED_DATE,
|
||||||
|
LAST_UPDATE_DATE
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
'remote:https://www.youtube.com/watch?v=backfill123',
|
||||||
|
'watch?v=backfill123',
|
||||||
|
2,
|
||||||
|
null,
|
||||||
|
'https://www.youtube.com/watch?v=backfill123',
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
nowMs,
|
||||||
|
nowMs,
|
||||||
|
);
|
||||||
|
privateApi.db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO imm_lifetime_media (
|
||||||
|
video_id,
|
||||||
|
total_sessions,
|
||||||
|
total_active_ms,
|
||||||
|
total_cards,
|
||||||
|
total_lines_seen,
|
||||||
|
total_tokens_seen,
|
||||||
|
completed,
|
||||||
|
first_watched_ms,
|
||||||
|
last_watched_ms,
|
||||||
|
CREATED_DATE,
|
||||||
|
LAST_UPDATE_DATE
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.run(1, 1, 5_000, 0, 0, 50, 0, nowMs, nowMs, nowMs, nowMs);
|
||||||
|
|
||||||
|
const before = await tracker.getMediaLibrary();
|
||||||
|
assert.equal(before[0]?.channelName ?? null, null);
|
||||||
|
|
||||||
|
await waitForCondition(() => {
|
||||||
|
const row = privateApi.db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
video_title AS videoTitle,
|
||||||
|
channel_name AS channelName,
|
||||||
|
channel_thumbnail_url AS channelThumbnailUrl
|
||||||
|
FROM imm_youtube_videos
|
||||||
|
WHERE video_id = 1
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.get() as {
|
||||||
|
videoTitle: string | null;
|
||||||
|
channelName: string | null;
|
||||||
|
channelThumbnailUrl: string | null;
|
||||||
|
} | null;
|
||||||
|
return (
|
||||||
|
row?.videoTitle === 'Backfilled Video Title' &&
|
||||||
|
row.channelName === 'Backfill Creator' &&
|
||||||
|
row.channelThumbnailUrl === 'https://yt3.googleusercontent.com/backfill-avatar=s88'
|
||||||
|
);
|
||||||
|
}, 5_000);
|
||||||
|
|
||||||
|
const after = await tracker.getMediaLibrary();
|
||||||
|
assert.equal(after[0]?.videoTitle, 'Backfilled Video Title');
|
||||||
|
assert.equal(after[0]?.channelName, 'Backfill Creator');
|
||||||
|
assert.equal(
|
||||||
|
after[0]?.channelThumbnailUrl,
|
||||||
|
'https://yt3.googleusercontent.com/backfill-avatar=s88',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
process.env.PATH = originalPath;
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
if (fakeBinDir) {
|
||||||
|
fs.rmSync(fakeBinDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAnimeLibrary lazily relinks youtube rows to channel groupings', async () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const Ctor = await loadTrackerCtor();
|
||||||
|
tracker = new Ctor({ dbPath });
|
||||||
|
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||||
|
const nowMs = Date.now();
|
||||||
|
|
||||||
|
privateApi.db.exec(`
|
||||||
|
INSERT INTO imm_anime (
|
||||||
|
anime_id,
|
||||||
|
normalized_title_key,
|
||||||
|
canonical_title,
|
||||||
|
CREATED_DATE,
|
||||||
|
LAST_UPDATE_DATE
|
||||||
|
) VALUES
|
||||||
|
(1, 'watch v first', 'watch?v first', ${nowMs}, ${nowMs}),
|
||||||
|
(2, 'watch v second', 'watch?v second', ${nowMs}, ${nowMs});
|
||||||
|
|
||||||
|
INSERT INTO imm_videos (
|
||||||
|
video_id,
|
||||||
|
anime_id,
|
||||||
|
video_key,
|
||||||
|
canonical_title,
|
||||||
|
parsed_title,
|
||||||
|
parser_source,
|
||||||
|
source_type,
|
||||||
|
source_path,
|
||||||
|
source_url,
|
||||||
|
duration_ms,
|
||||||
|
file_size_bytes,
|
||||||
|
codec_id,
|
||||||
|
container_id,
|
||||||
|
width_px,
|
||||||
|
height_px,
|
||||||
|
fps_x100,
|
||||||
|
bitrate_kbps,
|
||||||
|
audio_codec_id,
|
||||||
|
hash_sha256,
|
||||||
|
screenshot_path,
|
||||||
|
metadata_json,
|
||||||
|
CREATED_DATE,
|
||||||
|
LAST_UPDATE_DATE
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
'remote:https://www.youtube.com/watch?v=first',
|
||||||
|
'watch?v first',
|
||||||
|
'watch?v first',
|
||||||
|
'fallback',
|
||||||
|
2,
|
||||||
|
NULL,
|
||||||
|
'https://www.youtube.com/watch?v=first',
|
||||||
|
0,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
${nowMs},
|
||||||
|
${nowMs}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
'remote:https://www.youtube.com/watch?v=second',
|
||||||
|
'watch?v second',
|
||||||
|
'watch?v second',
|
||||||
|
'fallback',
|
||||||
|
2,
|
||||||
|
NULL,
|
||||||
|
'https://www.youtube.com/watch?v=second',
|
||||||
|
0,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
${nowMs},
|
||||||
|
${nowMs}
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO imm_youtube_videos (
|
||||||
|
video_id,
|
||||||
|
youtube_video_id,
|
||||||
|
video_url,
|
||||||
|
video_title,
|
||||||
|
video_thumbnail_url,
|
||||||
|
channel_id,
|
||||||
|
channel_name,
|
||||||
|
channel_url,
|
||||||
|
channel_thumbnail_url,
|
||||||
|
uploader_id,
|
||||||
|
uploader_url,
|
||||||
|
description,
|
||||||
|
metadata_json,
|
||||||
|
fetched_at_ms,
|
||||||
|
CREATED_DATE,
|
||||||
|
LAST_UPDATE_DATE
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
1,
|
||||||
|
'first',
|
||||||
|
'https://www.youtube.com/watch?v=first',
|
||||||
|
'First Video',
|
||||||
|
'https://i.ytimg.com/vi/first/hqdefault.jpg',
|
||||||
|
'UCchannel1',
|
||||||
|
'Shared Channel',
|
||||||
|
'https://www.youtube.com/channel/UCchannel1',
|
||||||
|
'https://yt3.googleusercontent.com/shared=s88',
|
||||||
|
'@shared',
|
||||||
|
'https://www.youtube.com/@shared',
|
||||||
|
NULL,
|
||||||
|
'{}',
|
||||||
|
${nowMs},
|
||||||
|
${nowMs},
|
||||||
|
${nowMs}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
2,
|
||||||
|
'second',
|
||||||
|
'https://www.youtube.com/watch?v=second',
|
||||||
|
'Second Video',
|
||||||
|
'https://i.ytimg.com/vi/second/hqdefault.jpg',
|
||||||
|
'UCchannel1',
|
||||||
|
'Shared Channel',
|
||||||
|
'https://www.youtube.com/channel/UCchannel1',
|
||||||
|
'https://yt3.googleusercontent.com/shared=s88',
|
||||||
|
'@shared',
|
||||||
|
'https://www.youtube.com/@shared',
|
||||||
|
NULL,
|
||||||
|
'{}',
|
||||||
|
${nowMs},
|
||||||
|
${nowMs},
|
||||||
|
${nowMs}
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO imm_sessions (
|
||||||
|
session_id,
|
||||||
|
session_uuid,
|
||||||
|
video_id,
|
||||||
|
started_at_ms,
|
||||||
|
ended_at_ms,
|
||||||
|
status,
|
||||||
|
total_watched_ms,
|
||||||
|
active_watched_ms,
|
||||||
|
lines_seen,
|
||||||
|
tokens_seen,
|
||||||
|
cards_mined,
|
||||||
|
lookup_count,
|
||||||
|
lookup_hits,
|
||||||
|
yomitan_lookup_count,
|
||||||
|
CREATED_DATE,
|
||||||
|
LAST_UPDATE_DATE
|
||||||
|
) VALUES
|
||||||
|
(
|
||||||
|
1,
|
||||||
|
'session-youtube-1',
|
||||||
|
1,
|
||||||
|
${nowMs - 70000},
|
||||||
|
${nowMs - 10000},
|
||||||
|
2,
|
||||||
|
65000,
|
||||||
|
60000,
|
||||||
|
0,
|
||||||
|
100,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
${nowMs},
|
||||||
|
${nowMs}
|
||||||
|
),
|
||||||
|
(
|
||||||
|
2,
|
||||||
|
'session-youtube-2',
|
||||||
|
2,
|
||||||
|
${nowMs - 50000},
|
||||||
|
${nowMs - 5000},
|
||||||
|
2,
|
||||||
|
35000,
|
||||||
|
30000,
|
||||||
|
0,
|
||||||
|
50,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
${nowMs},
|
||||||
|
${nowMs}
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO imm_lifetime_anime (
|
||||||
|
anime_id,
|
||||||
|
total_sessions,
|
||||||
|
total_active_ms,
|
||||||
|
total_cards,
|
||||||
|
total_lines_seen,
|
||||||
|
total_tokens_seen,
|
||||||
|
episodes_started,
|
||||||
|
episodes_completed,
|
||||||
|
first_watched_ms,
|
||||||
|
last_watched_ms,
|
||||||
|
CREATED_DATE,
|
||||||
|
LAST_UPDATE_DATE
|
||||||
|
) VALUES
|
||||||
|
(1, 1, 60000, 0, 0, 100, 1, 0, ${nowMs}, ${nowMs}, ${nowMs}, ${nowMs}),
|
||||||
|
(2, 1, 30000, 0, 0, 50, 1, 0, ${nowMs}, ${nowMs}, ${nowMs}, ${nowMs});
|
||||||
|
|
||||||
|
INSERT INTO imm_lifetime_media (
|
||||||
|
video_id,
|
||||||
|
total_sessions,
|
||||||
|
total_active_ms,
|
||||||
|
total_cards,
|
||||||
|
total_lines_seen,
|
||||||
|
total_tokens_seen,
|
||||||
|
completed,
|
||||||
|
first_watched_ms,
|
||||||
|
last_watched_ms,
|
||||||
|
CREATED_DATE,
|
||||||
|
LAST_UPDATE_DATE
|
||||||
|
) VALUES
|
||||||
|
(1, 1, 60000, 0, 0, 100, 0, ${nowMs}, ${nowMs}, ${nowMs}, ${nowMs}),
|
||||||
|
(2, 1, 30000, 0, 0, 50, 0, ${nowMs}, ${nowMs}, ${nowMs}, ${nowMs});
|
||||||
|
`);
|
||||||
|
|
||||||
|
const rows = await tracker.getAnimeLibrary();
|
||||||
|
const sharedRows = rows.filter((row) => row.canonicalTitle === 'Shared Channel');
|
||||||
|
|
||||||
|
assert.equal(sharedRows.length, 1);
|
||||||
|
assert.equal(sharedRows[0]?.episodeCount, 2);
|
||||||
|
|
||||||
|
const relinked = privateApi.db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT a.canonical_title AS canonicalTitle, COUNT(*) AS total
|
||||||
|
FROM imm_videos v
|
||||||
|
JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||||
|
GROUP BY a.anime_id, a.canonical_title
|
||||||
|
ORDER BY total DESC, a.anime_id ASC
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all() as Array<{ canonicalTitle: string; total: number }>;
|
||||||
|
|
||||||
|
assert.equal(relinked[0]?.canonicalTitle, 'Shared Channel');
|
||||||
|
assert.equal(relinked[0]?.total, 2);
|
||||||
|
} finally {
|
||||||
|
tracker?.destroy();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('reassignAnimeAnilist clears description when description is explicitly null', async () => {
|
test('reassignAnimeAnilist clears description when description is explicitly null', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
let tracker: ImmersionTrackerService | null = null;
|
let tracker: ImmersionTrackerService | null = null;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
getOrCreateAnimeRecord,
|
getOrCreateAnimeRecord,
|
||||||
getOrCreateVideoRecord,
|
getOrCreateVideoRecord,
|
||||||
linkVideoToAnimeRecord,
|
linkVideoToAnimeRecord,
|
||||||
|
linkYoutubeVideoToAnimeRecord,
|
||||||
type TrackerPreparedStatements,
|
type TrackerPreparedStatements,
|
||||||
updateVideoMetadataRecord,
|
updateVideoMetadataRecord,
|
||||||
updateVideoTitleRecord,
|
updateVideoTitleRecord,
|
||||||
@@ -161,6 +162,7 @@ const YOUTUBE_COVER_RETRY_MS = 5 * 60 * 1000;
|
|||||||
const YOUTUBE_SCREENSHOT_MAX_SECONDS = 120;
|
const YOUTUBE_SCREENSHOT_MAX_SECONDS = 120;
|
||||||
const YOUTUBE_OEMBED_ENDPOINT = 'https://www.youtube.com/oembed';
|
const YOUTUBE_OEMBED_ENDPOINT = 'https://www.youtube.com/oembed';
|
||||||
const YOUTUBE_ID_PATTERN = /^[A-Za-z0-9_-]{6,}$/;
|
const YOUTUBE_ID_PATTERN = /^[A-Za-z0-9_-]{6,}$/;
|
||||||
|
const YOUTUBE_METADATA_REFRESH_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
function isValidYouTubeVideoId(value: string | null): boolean {
|
function isValidYouTubeVideoId(value: string | null): boolean {
|
||||||
return Boolean(value && YOUTUBE_ID_PATTERN.test(value));
|
return Boolean(value && YOUTUBE_ID_PATTERN.test(value));
|
||||||
@@ -535,11 +537,15 @@ export class ImmersionTrackerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getMediaLibrary(): Promise<MediaLibraryRow[]> {
|
async getMediaLibrary(): Promise<MediaLibraryRow[]> {
|
||||||
return getMediaLibrary(this.db);
|
const rows = getMediaLibrary(this.db);
|
||||||
|
this.backfillYoutubeMetadataForLibrary();
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMediaDetail(videoId: number): Promise<MediaDetailRow | null> {
|
async getMediaDetail(videoId: number): Promise<MediaDetailRow | null> {
|
||||||
return getMediaDetail(this.db, videoId);
|
const detail = getMediaDetail(this.db, videoId);
|
||||||
|
this.backfillYoutubeMetadataForVideo(videoId);
|
||||||
|
return detail;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMediaSessions(videoId: number, limit = 100): Promise<SessionSummaryQueryRow[]> {
|
async getMediaSessions(videoId: number, limit = 100): Promise<SessionSummaryQueryRow[]> {
|
||||||
@@ -555,10 +561,12 @@ export class ImmersionTrackerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getAnimeLibrary(): Promise<AnimeLibraryRow[]> {
|
async getAnimeLibrary(): Promise<AnimeLibraryRow[]> {
|
||||||
|
this.relinkYoutubeAnimeLibrary();
|
||||||
return getAnimeLibrary(this.db);
|
return getAnimeLibrary(this.db);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAnimeDetail(animeId: number): Promise<AnimeDetailRow | null> {
|
async getAnimeDetail(animeId: number): Promise<AnimeDetailRow | null> {
|
||||||
|
this.relinkYoutubeAnimeLibrary();
|
||||||
return getAnimeDetail(this.db, animeId);
|
return getAnimeDetail(this.db, animeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -909,6 +917,7 @@ export class ImmersionTrackerService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
upsertYoutubeVideoMetadata(this.db, videoId, metadata);
|
upsertYoutubeVideoMetadata(this.db, videoId, metadata);
|
||||||
|
linkYoutubeVideoToAnimeRecord(this.db, videoId, metadata);
|
||||||
if (metadata.videoTitle?.trim()) {
|
if (metadata.videoTitle?.trim()) {
|
||||||
updateVideoTitleRecord(this.db, videoId, metadata.videoTitle.trim());
|
updateVideoTitleRecord(this.db, videoId, metadata.videoTitle.trim());
|
||||||
}
|
}
|
||||||
@@ -927,6 +936,174 @@ export class ImmersionTrackerService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private backfillYoutubeMetadataForLibrary(): void {
|
||||||
|
const candidate = this.db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
v.video_id AS videoId,
|
||||||
|
v.source_url AS sourceUrl
|
||||||
|
FROM imm_videos v
|
||||||
|
JOIN imm_lifetime_media lm ON lm.video_id = v.video_id
|
||||||
|
LEFT JOIN imm_youtube_videos yv ON yv.video_id = v.video_id
|
||||||
|
WHERE
|
||||||
|
v.source_type = ?
|
||||||
|
AND v.source_url IS NOT NULL
|
||||||
|
AND (
|
||||||
|
LOWER(v.source_url) LIKE 'https://www.youtube.com/%'
|
||||||
|
OR LOWER(v.source_url) LIKE 'https://youtube.com/%'
|
||||||
|
OR LOWER(v.source_url) LIKE 'https://m.youtube.com/%'
|
||||||
|
OR LOWER(v.source_url) LIKE 'https://youtu.be/%'
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
yv.video_id IS NULL
|
||||||
|
OR yv.video_title IS NULL
|
||||||
|
OR yv.channel_name IS NULL
|
||||||
|
OR yv.channel_thumbnail_url IS NULL
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
yv.fetched_at_ms IS NULL
|
||||||
|
OR yv.fetched_at_ms <= ?
|
||||||
|
)
|
||||||
|
ORDER BY lm.last_watched_ms DESC, v.video_id DESC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
SOURCE_TYPE_REMOTE,
|
||||||
|
Date.now() - YOUTUBE_METADATA_REFRESH_MS,
|
||||||
|
) as { videoId: number; sourceUrl: string | null } | null;
|
||||||
|
if (!candidate?.sourceUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.captureYoutubeMetadataAsync(candidate.videoId, candidate.sourceUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private backfillYoutubeMetadataForVideo(videoId: number): void {
|
||||||
|
const candidate = this.db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
v.source_url AS sourceUrl
|
||||||
|
FROM imm_videos v
|
||||||
|
LEFT JOIN imm_youtube_videos yv ON yv.video_id = v.video_id
|
||||||
|
WHERE
|
||||||
|
v.video_id = ?
|
||||||
|
AND v.source_type = ?
|
||||||
|
AND v.source_url IS NOT NULL
|
||||||
|
AND (
|
||||||
|
LOWER(v.source_url) LIKE 'https://www.youtube.com/%'
|
||||||
|
OR LOWER(v.source_url) LIKE 'https://youtube.com/%'
|
||||||
|
OR LOWER(v.source_url) LIKE 'https://m.youtube.com/%'
|
||||||
|
OR LOWER(v.source_url) LIKE 'https://youtu.be/%'
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
yv.video_id IS NULL
|
||||||
|
OR yv.video_title IS NULL
|
||||||
|
OR yv.channel_name IS NULL
|
||||||
|
OR yv.channel_thumbnail_url IS NULL
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
yv.fetched_at_ms IS NULL
|
||||||
|
OR yv.fetched_at_ms <= ?
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
videoId,
|
||||||
|
SOURCE_TYPE_REMOTE,
|
||||||
|
Date.now() - YOUTUBE_METADATA_REFRESH_MS,
|
||||||
|
) as { sourceUrl: string | null } | null;
|
||||||
|
if (!candidate?.sourceUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.captureYoutubeMetadataAsync(videoId, candidate.sourceUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private relinkYoutubeAnimeLibrary(): void {
|
||||||
|
const candidates = this.db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
v.video_id AS videoId,
|
||||||
|
yv.youtube_video_id AS youtubeVideoId,
|
||||||
|
yv.video_url AS videoUrl,
|
||||||
|
yv.video_title AS videoTitle,
|
||||||
|
yv.video_thumbnail_url AS videoThumbnailUrl,
|
||||||
|
yv.channel_id AS channelId,
|
||||||
|
yv.channel_name AS channelName,
|
||||||
|
yv.channel_url AS channelUrl,
|
||||||
|
yv.channel_thumbnail_url AS channelThumbnailUrl,
|
||||||
|
yv.uploader_id AS uploaderId,
|
||||||
|
yv.uploader_url AS uploaderUrl,
|
||||||
|
yv.description AS description,
|
||||||
|
yv.metadata_json AS metadataJson
|
||||||
|
FROM imm_videos v
|
||||||
|
JOIN imm_youtube_videos yv ON yv.video_id = v.video_id
|
||||||
|
LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||||
|
LEFT JOIN imm_lifetime_media lm ON lm.video_id = v.video_id
|
||||||
|
WHERE
|
||||||
|
v.source_type = ?
|
||||||
|
AND v.source_url IS NOT NULL
|
||||||
|
AND (
|
||||||
|
LOWER(v.source_url) LIKE 'https://www.youtube.com/%'
|
||||||
|
OR LOWER(v.source_url) LIKE 'https://youtube.com/%'
|
||||||
|
OR LOWER(v.source_url) LIKE 'https://m.youtube.com/%'
|
||||||
|
OR LOWER(v.source_url) LIKE 'https://youtu.be/%'
|
||||||
|
)
|
||||||
|
AND yv.channel_name IS NOT NULL
|
||||||
|
AND (
|
||||||
|
v.anime_id IS NULL
|
||||||
|
OR a.metadata_json IS NULL
|
||||||
|
OR a.metadata_json NOT LIKE '%"source":"youtube-channel"%'
|
||||||
|
OR a.canonical_title IS NULL
|
||||||
|
OR TRIM(a.canonical_title) != TRIM(yv.channel_name)
|
||||||
|
)
|
||||||
|
ORDER BY lm.last_watched_ms DESC, v.video_id DESC
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all(SOURCE_TYPE_REMOTE) as Array<{
|
||||||
|
videoId: number;
|
||||||
|
youtubeVideoId: string | null;
|
||||||
|
videoUrl: string | null;
|
||||||
|
videoTitle: string | null;
|
||||||
|
videoThumbnailUrl: string | null;
|
||||||
|
channelId: string | null;
|
||||||
|
channelName: string | null;
|
||||||
|
channelUrl: string | null;
|
||||||
|
channelThumbnailUrl: string | null;
|
||||||
|
uploaderId: string | null;
|
||||||
|
uploaderUrl: string | null;
|
||||||
|
description: string | null;
|
||||||
|
metadataJson: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!candidate.youtubeVideoId || !candidate.videoUrl) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
linkYoutubeVideoToAnimeRecord(this.db, candidate.videoId, {
|
||||||
|
youtubeVideoId: candidate.youtubeVideoId,
|
||||||
|
videoUrl: candidate.videoUrl,
|
||||||
|
videoTitle: candidate.videoTitle,
|
||||||
|
videoThumbnailUrl: candidate.videoThumbnailUrl,
|
||||||
|
channelId: candidate.channelId,
|
||||||
|
channelName: candidate.channelName,
|
||||||
|
channelUrl: candidate.channelUrl,
|
||||||
|
channelThumbnailUrl: candidate.channelThumbnailUrl,
|
||||||
|
uploaderId: candidate.uploaderId,
|
||||||
|
uploaderUrl: candidate.uploaderUrl,
|
||||||
|
description: candidate.description,
|
||||||
|
metadataJson: candidate.metadataJson,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
rebuildLifetimeSummaryTables(this.db);
|
||||||
|
}
|
||||||
|
|
||||||
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
|
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
|
||||||
const normalizedPath = normalizeMediaPath(mediaPath);
|
const normalizedPath = normalizeMediaPath(mediaPath);
|
||||||
const normalizedTitle = normalizeText(mediaTitle);
|
const normalizedTitle = normalizeText(mediaTitle);
|
||||||
@@ -971,14 +1148,14 @@ export class ImmersionTrackerService {
|
|||||||
`Starting immersion session for path=${normalizedPath} videoId=${sessionInfo.videoId}`,
|
`Starting immersion session for path=${normalizedPath} videoId=${sessionInfo.videoId}`,
|
||||||
);
|
);
|
||||||
this.startSession(sessionInfo.videoId, sessionInfo.startedAtMs);
|
this.startSession(sessionInfo.videoId, sessionInfo.startedAtMs);
|
||||||
if (sourceType === SOURCE_TYPE_REMOTE) {
|
const youtubeVideoId =
|
||||||
const youtubeVideoId = extractYouTubeVideoId(normalizedPath);
|
sourceType === SOURCE_TYPE_REMOTE ? extractYouTubeVideoId(normalizedPath) : null;
|
||||||
if (youtubeVideoId) {
|
if (youtubeVideoId) {
|
||||||
void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId);
|
void this.ensureYouTubeCoverArt(sessionInfo.videoId, normalizedPath, youtubeVideoId);
|
||||||
this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath);
|
this.captureYoutubeMetadataAsync(sessionInfo.videoId, normalizedPath);
|
||||||
}
|
} else {
|
||||||
|
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
|
||||||
}
|
}
|
||||||
this.captureAnimeMetadataAsync(sessionInfo.videoId, normalizedPath, normalizedTitle || null);
|
|
||||||
this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath);
|
this.captureVideoMetadataAsync(sessionInfo.videoId, sourceType, normalizedPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1006,6 +1183,7 @@ export class ImmersionTrackerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startMs = secToMs(startSec);
|
const startMs = secToMs(startSec);
|
||||||
|
const endMs = secToMs(endSec);
|
||||||
const subtitleKey = `${startMs}:${cleaned}`;
|
const subtitleKey = `${startMs}:${cleaned}`;
|
||||||
if (this.recordedSubtitleKeys.has(subtitleKey)) {
|
if (this.recordedSubtitleKeys.has(subtitleKey)) {
|
||||||
return;
|
return;
|
||||||
@@ -1019,6 +1197,9 @@ export class ImmersionTrackerService {
|
|||||||
this.sessionState.currentLineIndex += 1;
|
this.sessionState.currentLineIndex += 1;
|
||||||
this.sessionState.linesSeen += 1;
|
this.sessionState.linesSeen += 1;
|
||||||
this.sessionState.tokensSeen += tokenCount;
|
this.sessionState.tokensSeen += tokenCount;
|
||||||
|
if (this.sessionState.lastMediaMs === null || endMs > this.sessionState.lastMediaMs) {
|
||||||
|
this.sessionState.lastMediaMs = endMs;
|
||||||
|
}
|
||||||
this.sessionState.pendingTelemetry = true;
|
this.sessionState.pendingTelemetry = true;
|
||||||
|
|
||||||
const wordOccurrences = new Map<string, CountedWordOccurrence>();
|
const wordOccurrences = new Map<string, CountedWordOccurrence>();
|
||||||
@@ -1068,8 +1249,8 @@ export class ImmersionTrackerService {
|
|||||||
sessionId: this.sessionState.sessionId,
|
sessionId: this.sessionState.sessionId,
|
||||||
videoId: this.sessionState.videoId,
|
videoId: this.sessionState.videoId,
|
||||||
lineIndex: this.sessionState.currentLineIndex,
|
lineIndex: this.sessionState.currentLineIndex,
|
||||||
segmentStartMs: secToMs(startSec),
|
segmentStartMs: startMs,
|
||||||
segmentEndMs: secToMs(endSec),
|
segmentEndMs: endMs,
|
||||||
text: cleaned,
|
text: cleaned,
|
||||||
secondaryText: secondaryText ?? null,
|
secondaryText: secondaryText ?? null,
|
||||||
wordOccurrences: Array.from(wordOccurrences.values()),
|
wordOccurrences: Array.from(wordOccurrences.values()),
|
||||||
|
|||||||
@@ -280,6 +280,78 @@ test('getAnimeEpisodes falls back to the latest subtitle segment end when sessio
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getAnimeEpisodes ignores zero-valued session checkpoints and falls back to subtitle progress', () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
const stmts = createTrackerPreparedStatements(db);
|
||||||
|
const videoId = getOrCreateVideoRecord(db, 'remote:https://www.youtube.com/watch?v=zero123', {
|
||||||
|
canonicalTitle: 'Zero Checkpoint Stream',
|
||||||
|
sourcePath: null,
|
||||||
|
sourceUrl: 'https://www.youtube.com/watch?v=zero123',
|
||||||
|
sourceType: SOURCE_TYPE_REMOTE,
|
||||||
|
});
|
||||||
|
const animeId = getOrCreateAnimeRecord(db, {
|
||||||
|
parsedTitle: 'Zero Checkpoint Anime',
|
||||||
|
canonicalTitle: 'Zero Checkpoint Anime',
|
||||||
|
anilistId: null,
|
||||||
|
titleRomaji: null,
|
||||||
|
titleEnglish: null,
|
||||||
|
titleNative: null,
|
||||||
|
metadataJson: null,
|
||||||
|
});
|
||||||
|
linkVideoToAnimeRecord(db, videoId, {
|
||||||
|
animeId,
|
||||||
|
parsedBasename: 'watch?v=zero123',
|
||||||
|
parsedTitle: 'Zero Checkpoint Anime',
|
||||||
|
parsedSeason: 1,
|
||||||
|
parsedEpisode: 1,
|
||||||
|
parserSource: 'fallback',
|
||||||
|
parserConfidence: 1,
|
||||||
|
parseMetadataJson: '{"episode":1}',
|
||||||
|
});
|
||||||
|
db.prepare('UPDATE imm_videos SET duration_ms = ? WHERE video_id = ?').run(600_000, videoId);
|
||||||
|
|
||||||
|
const startedAtMs = 1_200_000;
|
||||||
|
const sessionId = startSessionRecord(db, videoId, startedAtMs).sessionId;
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
UPDATE imm_sessions
|
||||||
|
SET
|
||||||
|
ended_at_ms = ?,
|
||||||
|
status = 2,
|
||||||
|
ended_media_ms = 0,
|
||||||
|
active_watched_ms = ?,
|
||||||
|
LAST_UPDATE_DATE = ?
|
||||||
|
WHERE session_id = ?
|
||||||
|
`,
|
||||||
|
).run(startedAtMs + 30_000, 180_000, startedAtMs + 30_000, sessionId);
|
||||||
|
stmts.eventInsertStmt.run(
|
||||||
|
sessionId,
|
||||||
|
startedAtMs + 29_000,
|
||||||
|
EVENT_SUBTITLE_LINE,
|
||||||
|
1,
|
||||||
|
170_000,
|
||||||
|
185_000,
|
||||||
|
4,
|
||||||
|
0,
|
||||||
|
'{"line":"stream progress"}',
|
||||||
|
startedAtMs + 29_000,
|
||||||
|
startedAtMs + 29_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [episode] = getAnimeEpisodes(db, animeId);
|
||||||
|
assert.ok(episode);
|
||||||
|
assert.equal(episode?.endedMediaMs, 185_000);
|
||||||
|
assert.equal(episode?.durationMs, 600_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);
|
||||||
@@ -2774,3 +2846,200 @@ test('deleteSession rebuilds word and kanji aggregates from retained subtitle li
|
|||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('deleteSession removes zero-session media from library and trends', () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
|
||||||
|
const animeId = getOrCreateAnimeRecord(db, {
|
||||||
|
parsedTitle: 'Delete Me Anime',
|
||||||
|
canonicalTitle: 'Delete Me Anime',
|
||||||
|
anilistId: 404_404,
|
||||||
|
titleRomaji: 'Delete Me Anime',
|
||||||
|
titleEnglish: 'Delete Me Anime',
|
||||||
|
titleNative: 'Delete Me Anime',
|
||||||
|
metadataJson: null,
|
||||||
|
});
|
||||||
|
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/delete-last-session.mkv', {
|
||||||
|
canonicalTitle: 'Delete Last Session',
|
||||||
|
sourcePath: '/tmp/delete-last-session.mkv',
|
||||||
|
sourceUrl: null,
|
||||||
|
sourceType: SOURCE_TYPE_LOCAL,
|
||||||
|
});
|
||||||
|
linkVideoToAnimeRecord(db, videoId, {
|
||||||
|
animeId,
|
||||||
|
parsedBasename: 'Delete Last Session',
|
||||||
|
parsedTitle: 'Delete Me Anime',
|
||||||
|
parsedSeason: 1,
|
||||||
|
parsedEpisode: 1,
|
||||||
|
parserSource: 'fallback',
|
||||||
|
parserConfidence: 1,
|
||||||
|
parseMetadataJson: '{"episode":1}',
|
||||||
|
});
|
||||||
|
|
||||||
|
const startedAtMs = 9_000_000;
|
||||||
|
const endedAtMs = startedAtMs + 120_000;
|
||||||
|
const rollupDay = Math.floor(startedAtMs / 86_400_000);
|
||||||
|
const rollupMonth = 197001;
|
||||||
|
const { sessionId } = startSessionRecord(db, videoId, startedAtMs);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
UPDATE imm_sessions
|
||||||
|
SET
|
||||||
|
ended_at_ms = ?,
|
||||||
|
ended_media_ms = ?,
|
||||||
|
total_watched_ms = ?,
|
||||||
|
active_watched_ms = ?,
|
||||||
|
lines_seen = ?,
|
||||||
|
tokens_seen = ?,
|
||||||
|
cards_mined = ?,
|
||||||
|
LAST_UPDATE_DATE = ?
|
||||||
|
WHERE session_id = ?
|
||||||
|
`,
|
||||||
|
).run(endedAtMs, 120000, 120000, 120000, 12, 120, 3, endedAtMs, sessionId);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO imm_lifetime_applied_sessions (
|
||||||
|
session_id,
|
||||||
|
applied_at_ms,
|
||||||
|
CREATED_DATE,
|
||||||
|
LAST_UPDATE_DATE
|
||||||
|
) VALUES (?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
).run(sessionId, endedAtMs, endedAtMs, endedAtMs);
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO imm_lifetime_media (
|
||||||
|
video_id,
|
||||||
|
total_sessions,
|
||||||
|
total_active_ms,
|
||||||
|
total_cards,
|
||||||
|
total_lines_seen,
|
||||||
|
total_tokens_seen,
|
||||||
|
completed,
|
||||||
|
first_watched_ms,
|
||||||
|
last_watched_ms,
|
||||||
|
CREATED_DATE,
|
||||||
|
LAST_UPDATE_DATE
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
).run(videoId, 1, 120_000, 3, 12, 120, 0, startedAtMs, endedAtMs, endedAtMs, endedAtMs);
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO imm_lifetime_anime (
|
||||||
|
anime_id,
|
||||||
|
total_sessions,
|
||||||
|
total_active_ms,
|
||||||
|
total_cards,
|
||||||
|
total_lines_seen,
|
||||||
|
total_tokens_seen,
|
||||||
|
episodes_started,
|
||||||
|
episodes_completed,
|
||||||
|
first_watched_ms,
|
||||||
|
last_watched_ms,
|
||||||
|
CREATED_DATE,
|
||||||
|
LAST_UPDATE_DATE
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
).run(animeId, 1, 120000, 3, 12, 120, 1, 0, startedAtMs, endedAtMs, endedAtMs, endedAtMs);
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
UPDATE imm_lifetime_global
|
||||||
|
SET
|
||||||
|
total_sessions = 1,
|
||||||
|
total_active_ms = 120000,
|
||||||
|
total_cards = 3,
|
||||||
|
active_days = 1,
|
||||||
|
episodes_started = 1,
|
||||||
|
episodes_completed = 0,
|
||||||
|
anime_completed = 0,
|
||||||
|
last_rebuilt_ms = ?,
|
||||||
|
LAST_UPDATE_DATE = ?
|
||||||
|
WHERE global_id = 1
|
||||||
|
`,
|
||||||
|
).run(endedAtMs, endedAtMs);
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO imm_daily_rollups (
|
||||||
|
rollup_day,
|
||||||
|
video_id,
|
||||||
|
total_sessions,
|
||||||
|
total_active_min,
|
||||||
|
total_lines_seen,
|
||||||
|
total_tokens_seen,
|
||||||
|
total_cards,
|
||||||
|
cards_per_hour,
|
||||||
|
tokens_per_min,
|
||||||
|
lookup_hit_rate,
|
||||||
|
CREATED_DATE,
|
||||||
|
LAST_UPDATE_DATE
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
).run(rollupDay, videoId, 1, 2, 12, 120, 3, 90, 60, null, endedAtMs, endedAtMs);
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO imm_monthly_rollups (
|
||||||
|
rollup_month,
|
||||||
|
video_id,
|
||||||
|
total_sessions,
|
||||||
|
total_active_min,
|
||||||
|
total_lines_seen,
|
||||||
|
total_tokens_seen,
|
||||||
|
total_cards,
|
||||||
|
CREATED_DATE,
|
||||||
|
LAST_UPDATE_DATE
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
).run(rollupMonth, videoId, 1, 2, 12, 120, 3, endedAtMs, endedAtMs);
|
||||||
|
|
||||||
|
deleteSession(db, sessionId);
|
||||||
|
|
||||||
|
assert.deepEqual(getMediaLibrary(db), []);
|
||||||
|
assert.equal(getMediaDetail(db, videoId) ?? null, null);
|
||||||
|
assert.deepEqual(getAnimeLibrary(db), []);
|
||||||
|
assert.equal(getAnimeDetail(db, animeId) ?? null, null);
|
||||||
|
|
||||||
|
const trends = getTrendsDashboard(db, 'all', 'day');
|
||||||
|
assert.deepEqual(trends.activity.watchTime, []);
|
||||||
|
assert.deepEqual(trends.activity.sessions, []);
|
||||||
|
|
||||||
|
const dailyRollups = getDailyRollups(db, 30);
|
||||||
|
const monthlyRollups = getMonthlyRollups(db, 30);
|
||||||
|
assert.deepEqual(dailyRollups, []);
|
||||||
|
assert.deepEqual(monthlyRollups, []);
|
||||||
|
|
||||||
|
const lifetimeMediaCount = Number(
|
||||||
|
(
|
||||||
|
db.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_media WHERE video_id = ?').get(
|
||||||
|
videoId,
|
||||||
|
) as { total: number }
|
||||||
|
).total,
|
||||||
|
);
|
||||||
|
const lifetimeAnimeCount = Number(
|
||||||
|
(
|
||||||
|
db.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_anime WHERE anime_id = ?').get(
|
||||||
|
animeId,
|
||||||
|
) as { total: number }
|
||||||
|
).total,
|
||||||
|
);
|
||||||
|
const appliedSessionCount = Number(
|
||||||
|
(
|
||||||
|
db
|
||||||
|
.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions WHERE session_id = ?')
|
||||||
|
.get(sessionId) as { total: number }
|
||||||
|
).total,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(lifetimeMediaCount, 0);
|
||||||
|
assert.equal(lifetimeAnimeCount, 0);
|
||||||
|
assert.equal(appliedSessionCount, 0);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -134,6 +134,49 @@ function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void {
|
|||||||
).run(nowMs, nowMs);
|
).run(nowMs, nowMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rebuildLifetimeSummariesInternal(
|
||||||
|
db: DatabaseSync,
|
||||||
|
rebuiltAtMs: number,
|
||||||
|
): LifetimeRebuildSummary {
|
||||||
|
const sessions = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
session_id AS sessionId,
|
||||||
|
video_id AS videoId,
|
||||||
|
started_at_ms AS startedAtMs,
|
||||||
|
ended_at_ms AS endedAtMs,
|
||||||
|
total_watched_ms AS totalWatchedMs,
|
||||||
|
active_watched_ms AS activeWatchedMs,
|
||||||
|
lines_seen AS linesSeen,
|
||||||
|
tokens_seen AS tokensSeen,
|
||||||
|
cards_mined AS cardsMined,
|
||||||
|
lookup_count AS lookupCount,
|
||||||
|
lookup_hits AS lookupHits,
|
||||||
|
yomitan_lookup_count AS yomitanLookupCount,
|
||||||
|
pause_count AS pauseCount,
|
||||||
|
pause_ms AS pauseMs,
|
||||||
|
seek_forward_count AS seekForwardCount,
|
||||||
|
seek_backward_count AS seekBackwardCount,
|
||||||
|
media_buffer_events AS mediaBufferEvents
|
||||||
|
FROM imm_sessions
|
||||||
|
WHERE ended_at_ms IS NOT NULL
|
||||||
|
ORDER BY started_at_ms ASC, session_id ASC
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.all() as RetainedSessionRow[];
|
||||||
|
|
||||||
|
resetLifetimeSummaries(db, rebuiltAtMs);
|
||||||
|
for (const session of sessions) {
|
||||||
|
applySessionLifetimeSummary(db, toRebuildSessionState(session), session.endedAtMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
appliedSessions: sessions.length,
|
||||||
|
rebuiltAtMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function toRebuildSessionState(row: RetainedSessionRow): SessionState {
|
function toRebuildSessionState(row: RetainedSessionRow): SessionState {
|
||||||
return {
|
return {
|
||||||
sessionId: row.sessionId,
|
sessionId: row.sessionId,
|
||||||
@@ -482,50 +525,22 @@ export function applySessionLifetimeSummary(
|
|||||||
|
|
||||||
export function rebuildLifetimeSummaries(db: DatabaseSync): LifetimeRebuildSummary {
|
export function rebuildLifetimeSummaries(db: DatabaseSync): LifetimeRebuildSummary {
|
||||||
const rebuiltAtMs = Date.now();
|
const rebuiltAtMs = Date.now();
|
||||||
const sessions = db
|
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
session_id AS sessionId,
|
|
||||||
video_id AS videoId,
|
|
||||||
started_at_ms AS startedAtMs,
|
|
||||||
ended_at_ms AS endedAtMs,
|
|
||||||
total_watched_ms AS totalWatchedMs,
|
|
||||||
active_watched_ms AS activeWatchedMs,
|
|
||||||
lines_seen AS linesSeen,
|
|
||||||
tokens_seen AS tokensSeen,
|
|
||||||
cards_mined AS cardsMined,
|
|
||||||
lookup_count AS lookupCount,
|
|
||||||
lookup_hits AS lookupHits,
|
|
||||||
yomitan_lookup_count AS yomitanLookupCount,
|
|
||||||
pause_count AS pauseCount,
|
|
||||||
pause_ms AS pauseMs,
|
|
||||||
seek_forward_count AS seekForwardCount,
|
|
||||||
seek_backward_count AS seekBackwardCount,
|
|
||||||
media_buffer_events AS mediaBufferEvents
|
|
||||||
FROM imm_sessions
|
|
||||||
WHERE ended_at_ms IS NOT NULL
|
|
||||||
ORDER BY started_at_ms ASC, session_id ASC
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.all() as RetainedSessionRow[];
|
|
||||||
|
|
||||||
db.exec('BEGIN');
|
db.exec('BEGIN');
|
||||||
try {
|
try {
|
||||||
resetLifetimeSummaries(db, rebuiltAtMs);
|
const summary = rebuildLifetimeSummariesInTransaction(db, rebuiltAtMs);
|
||||||
for (const session of sessions) {
|
|
||||||
applySessionLifetimeSummary(db, toRebuildSessionState(session), session.endedAtMs);
|
|
||||||
}
|
|
||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
|
return summary;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
db.exec('ROLLBACK');
|
db.exec('ROLLBACK');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
export function rebuildLifetimeSummariesInTransaction(
|
||||||
appliedSessions: sessions.length,
|
db: DatabaseSync,
|
||||||
rebuiltAtMs,
|
rebuiltAtMs = Date.now(),
|
||||||
};
|
): LifetimeRebuildSummary {
|
||||||
|
return rebuildLifetimeSummariesInternal(db, rebuiltAtMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reconcileStaleActiveSessions(db: DatabaseSync): number {
|
export function reconcileStaleActiveSessions(db: DatabaseSync): number {
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ function setLastRollupSampleMs(db: DatabaseSync, sampleMs: number): void {
|
|||||||
).run(ROLLUP_STATE_KEY, sampleMs);
|
).run(ROLLUP_STATE_KEY, sampleMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetRollups(db: DatabaseSync): void {
|
||||||
|
db.exec(`
|
||||||
|
DELETE FROM imm_daily_rollups;
|
||||||
|
DELETE FROM imm_monthly_rollups;
|
||||||
|
`);
|
||||||
|
setLastRollupSampleMs(db, ZERO_ID);
|
||||||
|
}
|
||||||
|
|
||||||
function upsertDailyRollupsForGroups(
|
function upsertDailyRollupsForGroups(
|
||||||
db: DatabaseSync,
|
db: DatabaseSync,
|
||||||
groups: Array<{ rollupDay: number; videoId: number }>,
|
groups: Array<{ rollupDay: number; videoId: number }>,
|
||||||
@@ -281,8 +289,20 @@ function dedupeGroups<T extends { rollupDay?: number; rollupMonth?: number; vide
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function runRollupMaintenance(db: DatabaseSync, forceRebuild = false): void {
|
export function runRollupMaintenance(db: DatabaseSync, forceRebuild = false): void {
|
||||||
|
if (forceRebuild) {
|
||||||
|
db.exec('BEGIN IMMEDIATE');
|
||||||
|
try {
|
||||||
|
rebuildRollupsInTransaction(db);
|
||||||
|
db.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const rollupNowMs = Date.now();
|
const rollupNowMs = Date.now();
|
||||||
const lastRollupSampleMs = forceRebuild ? ZERO_ID : getLastRollupSampleMs(db);
|
const lastRollupSampleMs = getLastRollupSampleMs(db);
|
||||||
|
|
||||||
const maxSampleRow = db
|
const maxSampleRow = db
|
||||||
.prepare('SELECT MAX(sample_ms) AS maxSampleMs FROM imm_session_telemetry')
|
.prepare('SELECT MAX(sample_ms) AS maxSampleMs FROM imm_session_telemetry')
|
||||||
@@ -324,6 +344,41 @@ export function runRollupMaintenance(db: DatabaseSync, forceRebuild = false): vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function rebuildRollupsInTransaction(db: DatabaseSync): void {
|
||||||
|
const rollupNowMs = Date.now();
|
||||||
|
const maxSampleRow = db
|
||||||
|
.prepare('SELECT MAX(sample_ms) AS maxSampleMs FROM imm_session_telemetry')
|
||||||
|
.get() as unknown as RollupTelemetryResult | null;
|
||||||
|
|
||||||
|
resetRollups(db);
|
||||||
|
if (!maxSampleRow?.maxSampleMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const affectedGroups = getAffectedRollupGroups(db, ZERO_ID);
|
||||||
|
if (affectedGroups.length === 0) {
|
||||||
|
setLastRollupSampleMs(db, Number(maxSampleRow.maxSampleMs));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyGroups = dedupeGroups(
|
||||||
|
affectedGroups.map((group) => ({
|
||||||
|
rollupDay: group.rollupDay,
|
||||||
|
videoId: group.videoId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const monthlyGroups = dedupeGroups(
|
||||||
|
affectedGroups.map((group) => ({
|
||||||
|
rollupMonth: group.rollupMonth,
|
||||||
|
videoId: group.videoId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs);
|
||||||
|
upsertMonthlyRollupsForGroups(db, monthlyGroups, rollupNowMs);
|
||||||
|
setLastRollupSampleMs(db, Number(maxSampleRow.maxSampleMs));
|
||||||
|
}
|
||||||
|
|
||||||
export function runOptimizeMaintenance(db: DatabaseSync): void {
|
export function runOptimizeMaintenance(db: DatabaseSync): void {
|
||||||
db.exec('PRAGMA optimize');
|
db.exec('PRAGMA optimize');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import type {
|
|||||||
VocabularyStatsRow,
|
VocabularyStatsRow,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage';
|
import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage';
|
||||||
|
import { rebuildLifetimeSummariesInTransaction } from './lifetime';
|
||||||
|
import { rebuildRollupsInTransaction } from './maintenance';
|
||||||
import { PartOfSpeech, type MergedToken } from '../../../types';
|
import { PartOfSpeech, type MergedToken } from '../../../types';
|
||||||
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
|
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
|
||||||
import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech';
|
import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech';
|
||||||
@@ -1746,7 +1748,7 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod
|
|||||||
v.duration_ms AS durationMs,
|
v.duration_ms AS durationMs,
|
||||||
(
|
(
|
||||||
SELECT COALESCE(
|
SELECT COALESCE(
|
||||||
s_recent.ended_media_ms,
|
NULLIF(s_recent.ended_media_ms, 0),
|
||||||
(
|
(
|
||||||
SELECT MAX(line.segment_end_ms)
|
SELECT MAX(line.segment_end_ms)
|
||||||
FROM imm_subtitle_lines line
|
FROM imm_subtitle_lines line
|
||||||
@@ -2467,6 +2469,8 @@ export function deleteSession(db: DatabaseSync, sessionId: number): void {
|
|||||||
try {
|
try {
|
||||||
deleteSessionsByIds(db, sessionIds);
|
deleteSessionsByIds(db, sessionIds);
|
||||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||||
|
rebuildLifetimeSummariesInTransaction(db);
|
||||||
|
rebuildRollupsInTransaction(db);
|
||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
db.exec('ROLLBACK');
|
db.exec('ROLLBACK');
|
||||||
@@ -2483,6 +2487,8 @@ export function deleteSessions(db: DatabaseSync, sessionIds: number[]): void {
|
|||||||
try {
|
try {
|
||||||
deleteSessionsByIds(db, sessionIds);
|
deleteSessionsByIds(db, sessionIds);
|
||||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||||
|
rebuildLifetimeSummariesInTransaction(db);
|
||||||
|
rebuildRollupsInTransaction(db);
|
||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
db.exec('ROLLBACK');
|
db.exec('ROLLBACK');
|
||||||
@@ -2519,6 +2525,8 @@ export function deleteVideo(db: DatabaseSync, videoId: number): void {
|
|||||||
cleanupUnusedCoverArtBlobHash(db, artRow?.coverBlobHash ?? null);
|
cleanupUnusedCoverArtBlobHash(db, artRow?.coverBlobHash ?? null);
|
||||||
db.prepare('DELETE FROM imm_videos WHERE video_id = ?').run(videoId);
|
db.prepare('DELETE FROM imm_videos WHERE video_id = ?').run(videoId);
|
||||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||||
|
rebuildLifetimeSummariesInTransaction(db);
|
||||||
|
rebuildRollupsInTransaction(db);
|
||||||
db.exec('COMMIT');
|
db.exec('COMMIT');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
db.exec('ROLLBACK');
|
db.exec('ROLLBACK');
|
||||||
|
|||||||
@@ -15,8 +15,14 @@ import {
|
|||||||
getOrCreateAnimeRecord,
|
getOrCreateAnimeRecord,
|
||||||
getOrCreateVideoRecord,
|
getOrCreateVideoRecord,
|
||||||
linkVideoToAnimeRecord,
|
linkVideoToAnimeRecord,
|
||||||
|
linkYoutubeVideoToAnimeRecord,
|
||||||
} from './storage';
|
} from './storage';
|
||||||
import { EVENT_SUBTITLE_LINE, SESSION_STATUS_ENDED, SOURCE_TYPE_LOCAL } from './types';
|
import {
|
||||||
|
EVENT_SUBTITLE_LINE,
|
||||||
|
SESSION_STATUS_ENDED,
|
||||||
|
SOURCE_TYPE_LOCAL,
|
||||||
|
SOURCE_TYPE_REMOTE,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
function makeDbPath(): string {
|
function makeDbPath(): string {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-imm-storage-session-'));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-imm-storage-session-'));
|
||||||
@@ -817,6 +823,123 @@ test('anime rows are reused by normalized parsed title and upgraded with AniList
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('youtube videos can be regrouped under a shared channel anime identity', () => {
|
||||||
|
const dbPath = makeDbPath();
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureSchema(db);
|
||||||
|
|
||||||
|
const firstVideoId = getOrCreateVideoRecord(
|
||||||
|
db,
|
||||||
|
'remote:https://www.youtube.com/watch?v=video-1',
|
||||||
|
{
|
||||||
|
canonicalTitle: 'watch?v video-1',
|
||||||
|
sourcePath: null,
|
||||||
|
sourceUrl: 'https://www.youtube.com/watch?v=video-1',
|
||||||
|
sourceType: SOURCE_TYPE_REMOTE,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const secondVideoId = getOrCreateVideoRecord(
|
||||||
|
db,
|
||||||
|
'remote:https://www.youtube.com/watch?v=video-2',
|
||||||
|
{
|
||||||
|
canonicalTitle: 'watch?v video-2',
|
||||||
|
sourcePath: null,
|
||||||
|
sourceUrl: 'https://www.youtube.com/watch?v=video-2',
|
||||||
|
sourceType: SOURCE_TYPE_REMOTE,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstAnimeId = getOrCreateAnimeRecord(db, {
|
||||||
|
parsedTitle: 'watch?v video-1',
|
||||||
|
canonicalTitle: 'watch?v video-1',
|
||||||
|
anilistId: null,
|
||||||
|
titleRomaji: null,
|
||||||
|
titleEnglish: null,
|
||||||
|
titleNative: null,
|
||||||
|
metadataJson: null,
|
||||||
|
});
|
||||||
|
linkVideoToAnimeRecord(db, firstVideoId, {
|
||||||
|
animeId: firstAnimeId,
|
||||||
|
parsedBasename: null,
|
||||||
|
parsedTitle: 'watch?v video-1',
|
||||||
|
parsedSeason: null,
|
||||||
|
parsedEpisode: null,
|
||||||
|
parserSource: 'fallback',
|
||||||
|
parserConfidence: 0.2,
|
||||||
|
parseMetadataJson: '{"source":"fallback"}',
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondAnimeId = getOrCreateAnimeRecord(db, {
|
||||||
|
parsedTitle: 'watch?v video-2',
|
||||||
|
canonicalTitle: 'watch?v video-2',
|
||||||
|
anilistId: null,
|
||||||
|
titleRomaji: null,
|
||||||
|
titleEnglish: null,
|
||||||
|
titleNative: null,
|
||||||
|
metadataJson: null,
|
||||||
|
});
|
||||||
|
linkVideoToAnimeRecord(db, secondVideoId, {
|
||||||
|
animeId: secondAnimeId,
|
||||||
|
parsedBasename: null,
|
||||||
|
parsedTitle: 'watch?v video-2',
|
||||||
|
parsedSeason: null,
|
||||||
|
parsedEpisode: null,
|
||||||
|
parserSource: 'fallback',
|
||||||
|
parserConfidence: 0.2,
|
||||||
|
parseMetadataJson: '{"source":"fallback"}',
|
||||||
|
});
|
||||||
|
|
||||||
|
linkYoutubeVideoToAnimeRecord(db, firstVideoId, {
|
||||||
|
youtubeVideoId: 'video-1',
|
||||||
|
videoUrl: 'https://www.youtube.com/watch?v=video-1',
|
||||||
|
videoTitle: 'Video One',
|
||||||
|
videoThumbnailUrl: 'https://i.ytimg.com/vi/video-1/hqdefault.jpg',
|
||||||
|
channelId: 'UC123',
|
||||||
|
channelName: 'Channel Name',
|
||||||
|
channelUrl: 'https://www.youtube.com/channel/UC123',
|
||||||
|
channelThumbnailUrl: 'https://yt3.googleusercontent.com/channel-123=s176-c-k-c0x00ffffff-no-rj',
|
||||||
|
uploaderId: '@channelname',
|
||||||
|
uploaderUrl: 'https://www.youtube.com/@channelname',
|
||||||
|
description: null,
|
||||||
|
metadataJson: '{"id":"video-1"}',
|
||||||
|
});
|
||||||
|
linkYoutubeVideoToAnimeRecord(db, secondVideoId, {
|
||||||
|
youtubeVideoId: 'video-2',
|
||||||
|
videoUrl: 'https://www.youtube.com/watch?v=video-2',
|
||||||
|
videoTitle: 'Video Two',
|
||||||
|
videoThumbnailUrl: 'https://i.ytimg.com/vi/video-2/hqdefault.jpg',
|
||||||
|
channelId: 'UC123',
|
||||||
|
channelName: 'Channel Name',
|
||||||
|
channelUrl: 'https://www.youtube.com/channel/UC123',
|
||||||
|
channelThumbnailUrl: 'https://yt3.googleusercontent.com/channel-123=s176-c-k-c0x00ffffff-no-rj',
|
||||||
|
uploaderId: '@channelname',
|
||||||
|
uploaderUrl: 'https://www.youtube.com/@channelname',
|
||||||
|
description: null,
|
||||||
|
metadataJson: '{"id":"video-2"}',
|
||||||
|
});
|
||||||
|
|
||||||
|
const animeRows = db.prepare('SELECT anime_id, canonical_title FROM imm_anime').all() as Array<{
|
||||||
|
anime_id: number;
|
||||||
|
canonical_title: string;
|
||||||
|
}>;
|
||||||
|
const videoRows = db
|
||||||
|
.prepare('SELECT video_id, anime_id, parsed_title FROM imm_videos ORDER BY video_id ASC')
|
||||||
|
.all() as Array<{ video_id: number; anime_id: number | null; parsed_title: string | null }>;
|
||||||
|
|
||||||
|
const channelAnimeRows = animeRows.filter((row) => row.canonical_title === 'Channel Name');
|
||||||
|
assert.equal(channelAnimeRows.length, 1);
|
||||||
|
assert.equal(videoRows[0]?.anime_id, channelAnimeRows[0]?.anime_id);
|
||||||
|
assert.equal(videoRows[1]?.anime_id, channelAnimeRows[0]?.anime_id);
|
||||||
|
assert.equal(videoRows[0]?.parsed_title, 'Channel Name');
|
||||||
|
assert.equal(videoRows[1]?.parsed_title, 'Channel Name');
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
cleanupDbPath(dbPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('start/finalize session updates ended_at and status', () => {
|
test('start/finalize session updates ended_at and status', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = new Database(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|||||||
@@ -39,6 +39,41 @@ export interface VideoAnimeLinkInput {
|
|||||||
parseMetadataJson: string | null;
|
parseMetadataJson: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildYoutubeChannelAnimeIdentity(metadata: YoutubeVideoMetadata): {
|
||||||
|
parsedTitle: string;
|
||||||
|
canonicalTitle: string;
|
||||||
|
metadataJson: string;
|
||||||
|
} | null {
|
||||||
|
const channelId = metadata.channelId?.trim() || null;
|
||||||
|
const channelUrl = metadata.channelUrl?.trim() || null;
|
||||||
|
const channelName = metadata.channelName?.trim() || null;
|
||||||
|
const uploaderId = metadata.uploaderId?.trim() || null;
|
||||||
|
const videoTitle = metadata.videoTitle?.trim() || null;
|
||||||
|
|
||||||
|
const parsedTitle = channelId
|
||||||
|
? `youtube-channel:${channelId}`
|
||||||
|
: channelUrl
|
||||||
|
? `youtube-channel-url:${channelUrl}`
|
||||||
|
: channelName
|
||||||
|
? `youtube-channel-name:${channelName}`
|
||||||
|
: null;
|
||||||
|
if (!parsedTitle) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
parsedTitle,
|
||||||
|
canonicalTitle: channelName || uploaderId || videoTitle || parsedTitle,
|
||||||
|
metadataJson: JSON.stringify({
|
||||||
|
source: 'youtube-channel',
|
||||||
|
channelId,
|
||||||
|
channelUrl,
|
||||||
|
channelName,
|
||||||
|
uploaderId,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const COVER_BLOB_REFERENCE_PREFIX = '__subminer_cover_blob_ref__:';
|
const COVER_BLOB_REFERENCE_PREFIX = '__subminer_cover_blob_ref__:';
|
||||||
const WAL_JOURNAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024;
|
const WAL_JOURNAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -439,6 +474,38 @@ export function linkVideoToAnimeRecord(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function linkYoutubeVideoToAnimeRecord(
|
||||||
|
db: DatabaseSync,
|
||||||
|
videoId: number,
|
||||||
|
metadata: YoutubeVideoMetadata,
|
||||||
|
): number | null {
|
||||||
|
const identity = buildYoutubeChannelAnimeIdentity(metadata);
|
||||||
|
if (!identity) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const animeId = getOrCreateAnimeRecord(db, {
|
||||||
|
parsedTitle: identity.parsedTitle,
|
||||||
|
canonicalTitle: identity.canonicalTitle,
|
||||||
|
anilistId: null,
|
||||||
|
titleRomaji: null,
|
||||||
|
titleEnglish: null,
|
||||||
|
titleNative: null,
|
||||||
|
metadataJson: identity.metadataJson,
|
||||||
|
});
|
||||||
|
linkVideoToAnimeRecord(db, videoId, {
|
||||||
|
animeId,
|
||||||
|
parsedBasename: null,
|
||||||
|
parsedTitle: identity.canonicalTitle,
|
||||||
|
parsedSeason: null,
|
||||||
|
parsedEpisode: null,
|
||||||
|
parserSource: 'youtube',
|
||||||
|
parserConfidence: 1,
|
||||||
|
parseMetadataJson: identity.metadataJson,
|
||||||
|
});
|
||||||
|
return animeId;
|
||||||
|
}
|
||||||
|
|
||||||
function migrateLegacyAnimeMetadata(db: DatabaseSync): void {
|
function migrateLegacyAnimeMetadata(db: DatabaseSync): void {
|
||||||
addColumnIfMissing(db, 'imm_videos', 'anime_id', 'INTEGER REFERENCES imm_anime(anime_id)');
|
addColumnIfMissing(db, 'imm_videos', 'anime_id', 'INTEGER REFERENCES imm_anime(anime_id)');
|
||||||
addColumnIfMissing(db, 'imm_videos', 'parsed_basename', 'TEXT');
|
addColumnIfMissing(db, 'imm_videos', 'parsed_basename', 'TEXT');
|
||||||
|
|||||||
18
src/main.ts
18
src/main.ts
@@ -412,6 +412,7 @@ import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/charac
|
|||||||
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
||||||
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
||||||
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
|
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
|
||||||
|
import { isYoutubePlaybackActive } from './main/runtime/youtube-playback';
|
||||||
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
|
import { createYomitanProfilePolicy } from './main/runtime/yomitan-profile-policy';
|
||||||
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
|
import { formatSkippedYomitanWriteAction } from './main/runtime/yomitan-read-only-log';
|
||||||
import {
|
import {
|
||||||
@@ -1231,6 +1232,13 @@ const startupOsdSequencer = createStartupOsdSequencer({
|
|||||||
showOsd: (message) => showMpvOsd(message),
|
showOsd: (message) => showMpvOsd(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function isYoutubePlaybackActiveNow(): boolean {
|
||||||
|
return isYoutubePlaybackActive(
|
||||||
|
appState.currentMediaPath,
|
||||||
|
appState.mpvClient?.currentVideoPath ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function maybeSignalPluginAutoplayReady(
|
function maybeSignalPluginAutoplayReady(
|
||||||
payload: SubtitleData,
|
payload: SubtitleData,
|
||||||
options?: { forceWhilePaused?: boolean },
|
options?: { forceWhilePaused?: boolean },
|
||||||
@@ -1741,7 +1749,10 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
|||||||
getConfig: () => {
|
getConfig: () => {
|
||||||
const config = getResolvedConfig().anilist.characterDictionary;
|
const config = getResolvedConfig().anilist.characterDictionary;
|
||||||
return {
|
return {
|
||||||
enabled: config.enabled && yomitanProfilePolicy.isCharacterDictionaryEnabled(),
|
enabled:
|
||||||
|
config.enabled &&
|
||||||
|
yomitanProfilePolicy.isCharacterDictionaryEnabled() &&
|
||||||
|
!isYoutubePlaybackActiveNow(),
|
||||||
maxLoaded: config.maxLoaded,
|
maxLoaded: config.maxLoaded,
|
||||||
profileScope: config.profileScope,
|
profileScope: config.profileScope,
|
||||||
};
|
};
|
||||||
@@ -3518,7 +3529,7 @@ const {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
scheduleCharacterDictionarySync: () => {
|
scheduleCharacterDictionarySync: () => {
|
||||||
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled()) {
|
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
characterDictionaryAutoSyncRuntime.scheduleSync();
|
characterDictionaryAutoSyncRuntime.scheduleSync();
|
||||||
@@ -3613,7 +3624,8 @@ const {
|
|||||||
),
|
),
|
||||||
getCharacterDictionaryEnabled: () =>
|
getCharacterDictionaryEnabled: () =>
|
||||||
getResolvedConfig().anilist.characterDictionary.enabled &&
|
getResolvedConfig().anilist.characterDictionary.enabled &&
|
||||||
yomitanProfilePolicy.isCharacterDictionaryEnabled(),
|
yomitanProfilePolicy.isCharacterDictionaryEnabled() &&
|
||||||
|
!isYoutubePlaybackActiveNow(),
|
||||||
getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
|
getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
|
||||||
getFrequencyDictionaryEnabled: () =>
|
getFrequencyDictionaryEnabled: () =>
|
||||||
getRuntimeBooleanOption(
|
getRuntimeBooleanOption(
|
||||||
|
|||||||
@@ -68,3 +68,32 @@ test('ensureAnilistMediaGuess memoizes in-flight guess promise', async () => {
|
|||||||
});
|
});
|
||||||
assert.equal(state.mediaGuessPromise, null);
|
assert.equal(state.mediaGuessPromise, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ensureAnilistMediaGuess skips youtube playback urls', async () => {
|
||||||
|
let state: AnilistMediaGuessRuntimeState = {
|
||||||
|
mediaKey: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
mediaDurationSec: null,
|
||||||
|
mediaGuess: null,
|
||||||
|
mediaGuessPromise: null,
|
||||||
|
lastDurationProbeAtMs: 0,
|
||||||
|
};
|
||||||
|
let calls = 0;
|
||||||
|
const ensureGuess = createEnsureAnilistMediaGuessHandler({
|
||||||
|
getState: () => state,
|
||||||
|
setState: (next) => {
|
||||||
|
state = next;
|
||||||
|
},
|
||||||
|
resolveMediaPathForJimaku: (value) => value,
|
||||||
|
getCurrentMediaPath: () => 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
getCurrentMediaTitle: () => 'Video',
|
||||||
|
guessAnilistMediaInfo: async () => {
|
||||||
|
calls += 1;
|
||||||
|
return { title: 'Show', season: null, episode: 1, source: 'guessit' };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const guess = await ensureGuess('https://www.youtube.com/watch?v=abc123');
|
||||||
|
assert.equal(guess, null);
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
assert.equal(state.mediaGuess, null);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
|
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
|
||||||
|
import { isYoutubeMediaPath } from './youtube-playback';
|
||||||
|
|
||||||
export type AnilistMediaGuessRuntimeState = {
|
export type AnilistMediaGuessRuntimeState = {
|
||||||
mediaKey: string | null;
|
mediaKey: string | null;
|
||||||
@@ -26,6 +27,9 @@ export function createMaybeProbeAnilistDurationHandler(deps: {
|
|||||||
if (state.mediaKey !== mediaKey) {
|
if (state.mediaKey !== mediaKey) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (isYoutubeMediaPath(mediaKey)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (typeof state.mediaDurationSec === 'number' && state.mediaDurationSec > 0) {
|
if (typeof state.mediaDurationSec === 'number' && state.mediaDurationSec > 0) {
|
||||||
return state.mediaDurationSec;
|
return state.mediaDurationSec;
|
||||||
}
|
}
|
||||||
@@ -73,6 +77,9 @@ export function createEnsureAnilistMediaGuessHandler(deps: {
|
|||||||
if (state.mediaKey !== mediaKey) {
|
if (state.mediaKey !== mediaKey) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (isYoutubeMediaPath(mediaKey)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (state.mediaGuess) {
|
if (state.mediaGuess) {
|
||||||
return state.mediaGuess;
|
return state.mediaGuess;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ test('get current anilist media key trims and normalizes empty path', () => {
|
|||||||
assert.equal(getEmptyKey(), null);
|
assert.equal(getEmptyKey(), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('get current anilist media key skips youtube playback urls', () => {
|
||||||
|
const getYoutubeKey = createGetCurrentAnilistMediaKeyHandler({
|
||||||
|
getCurrentMediaPath: () => ' https://www.youtube.com/watch?v=abc123 ',
|
||||||
|
});
|
||||||
|
const getShortYoutubeKey = createGetCurrentAnilistMediaKeyHandler({
|
||||||
|
getCurrentMediaPath: () => 'https://youtu.be/abc123',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(getYoutubeKey(), null);
|
||||||
|
assert.equal(getShortYoutubeKey(), null);
|
||||||
|
});
|
||||||
|
|
||||||
test('reset anilist media tracking clears duration/guess/probe state', () => {
|
test('reset anilist media tracking clears duration/guess/probe state', () => {
|
||||||
let mediaKey: string | null = 'old';
|
let mediaKey: string | null = 'old';
|
||||||
let mediaDurationSec: number | null = 123;
|
let mediaDurationSec: number | null = 123;
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import type { AnilistMediaGuessRuntimeState } from './anilist-media-guess';
|
import type { AnilistMediaGuessRuntimeState } from './anilist-media-guess';
|
||||||
|
import { isYoutubeMediaPath } from './youtube-playback';
|
||||||
|
|
||||||
export function createGetCurrentAnilistMediaKeyHandler(deps: {
|
export function createGetCurrentAnilistMediaKeyHandler(deps: {
|
||||||
getCurrentMediaPath: () => string | null;
|
getCurrentMediaPath: () => string | null;
|
||||||
}) {
|
}) {
|
||||||
return (): string | null => {
|
return (): string | null => {
|
||||||
const mediaPath = deps.getCurrentMediaPath()?.trim();
|
const mediaPath = deps.getCurrentMediaPath()?.trim();
|
||||||
return mediaPath && mediaPath.length > 0 ? mediaPath : null;
|
if (!mediaPath || mediaPath.length === 0 || isYoutubeMediaPath(mediaPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return mediaPath;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,3 +76,52 @@ test('createMaybeRunAnilistPostWatchUpdateHandler queues when token missing', as
|
|||||||
assert.ok(calls.includes('inflight:true'));
|
assert.ok(calls.includes('inflight:true'));
|
||||||
assert.ok(calls.includes('inflight:false'));
|
assert.ok(calls.includes('inflight:false'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createMaybeRunAnilistPostWatchUpdateHandler skips youtube playback entirely', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
||||||
|
getInFlight: () => false,
|
||||||
|
setInFlight: (value) => calls.push(`inflight:${value}`),
|
||||||
|
getResolvedConfig: () => ({}),
|
||||||
|
isAnilistTrackingEnabled: () => true,
|
||||||
|
getCurrentMediaKey: () => 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
hasMpvClient: () => true,
|
||||||
|
getTrackedMediaKey: () => 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
resetTrackedMedia: () => calls.push('reset'),
|
||||||
|
getWatchedSeconds: () => 1000,
|
||||||
|
maybeProbeAnilistDuration: async () => {
|
||||||
|
calls.push('probe');
|
||||||
|
return 1000;
|
||||||
|
},
|
||||||
|
ensureAnilistMediaGuess: async () => {
|
||||||
|
calls.push('guess');
|
||||||
|
return { title: 'Show', season: null, episode: 1 };
|
||||||
|
},
|
||||||
|
hasAttemptedUpdateKey: () => false,
|
||||||
|
processNextAnilistRetryUpdate: async () => {
|
||||||
|
calls.push('process-retry');
|
||||||
|
return { ok: true, message: 'noop' };
|
||||||
|
},
|
||||||
|
refreshAnilistClientSecretState: async () => {
|
||||||
|
calls.push('refresh-token');
|
||||||
|
return 'token';
|
||||||
|
},
|
||||||
|
enqueueRetry: () => calls.push('enqueue'),
|
||||||
|
markRetryFailure: () => calls.push('mark-failure'),
|
||||||
|
markRetrySuccess: () => calls.push('mark-success'),
|
||||||
|
refreshRetryQueueState: () => calls.push('refresh'),
|
||||||
|
updateAnilistPostWatchProgress: async () => {
|
||||||
|
calls.push('update');
|
||||||
|
return { status: 'updated', message: 'ok' };
|
||||||
|
},
|
||||||
|
rememberAttemptedUpdateKey: () => calls.push('remember'),
|
||||||
|
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||||
|
logInfo: (message) => calls.push(`info:${message}`),
|
||||||
|
logWarn: (message) => calls.push(`warn:${message}`),
|
||||||
|
minWatchSeconds: 600,
|
||||||
|
minWatchRatio: 0.85,
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler();
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isYoutubeMediaPath } from './youtube-playback';
|
||||||
|
|
||||||
type AnilistGuess = {
|
type AnilistGuess = {
|
||||||
title: string;
|
title: string;
|
||||||
episode: number | null;
|
episode: number | null;
|
||||||
@@ -130,6 +132,9 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
|||||||
if (!mediaKey || !deps.hasMpvClient()) {
|
if (!mediaKey || !deps.hasMpvClient()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isYoutubeMediaPath(mediaKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (deps.getTrackedMediaKey() !== mediaKey) {
|
if (deps.getTrackedMediaKey() !== mediaKey) {
|
||||||
deps.resetTrackedMedia(mediaKey);
|
deps.resetTrackedMedia(mediaKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,57 @@ test('createImmersionTrackerStartupHandler skips when disabled', () => {
|
|||||||
assert.equal(tracker, 'unchanged');
|
assert.equal(tracker, 'unchanged');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createImmersionTrackerStartupHandler skips when env disables session tracking', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const originalEnv = process.env.SUBMINER_DISABLE_IMMERSION_TRACKING;
|
||||||
|
process.env.SUBMINER_DISABLE_IMMERSION_TRACKING = '1';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let tracker: unknown = 'unchanged';
|
||||||
|
const handler = createImmersionTrackerStartupHandler({
|
||||||
|
getResolvedConfig: () => {
|
||||||
|
calls.push('getResolvedConfig');
|
||||||
|
return makeConfig();
|
||||||
|
},
|
||||||
|
getConfiguredDbPath: () => {
|
||||||
|
calls.push('getConfiguredDbPath');
|
||||||
|
return '/tmp/subminer.db';
|
||||||
|
},
|
||||||
|
createTrackerService: () => {
|
||||||
|
calls.push('createTrackerService');
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
setTracker: (nextTracker) => {
|
||||||
|
tracker = nextTracker;
|
||||||
|
},
|
||||||
|
getMpvClient: () => null,
|
||||||
|
seedTrackerFromCurrentMedia: () => calls.push('seedTracker'),
|
||||||
|
logInfo: (message) => calls.push(`info:${message}`),
|
||||||
|
logDebug: (message) => calls.push(`debug:${message}`),
|
||||||
|
logWarn: (message) => calls.push(`warn:${message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
handler();
|
||||||
|
|
||||||
|
assert.equal(calls.includes('getResolvedConfig'), false);
|
||||||
|
assert.equal(calls.includes('getConfiguredDbPath'), false);
|
||||||
|
assert.equal(calls.includes('createTrackerService'), false);
|
||||||
|
assert.equal(calls.includes('seedTracker'), false);
|
||||||
|
assert.equal(tracker, 'unchanged');
|
||||||
|
assert.ok(
|
||||||
|
calls.includes(
|
||||||
|
'info:Immersion tracking disabled for this session by SUBMINER_DISABLE_IMMERSION_TRACKING=1.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (originalEnv === undefined) {
|
||||||
|
delete process.env.SUBMINER_DISABLE_IMMERSION_TRACKING;
|
||||||
|
} else {
|
||||||
|
process.env.SUBMINER_DISABLE_IMMERSION_TRACKING = originalEnv;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('createImmersionTrackerStartupHandler creates tracker and auto-connects mpv', () => {
|
test('createImmersionTrackerStartupHandler creates tracker and auto-connects mpv', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const trackerInstance = { kind: 'tracker' };
|
const trackerInstance = { kind: 'tracker' };
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type ImmersionTrackingConfig = {
|
|||||||
|
|
||||||
type ImmersionTrackerPolicy = Omit<ImmersionTrackingPolicy, 'enabled'>;
|
type ImmersionTrackerPolicy = Omit<ImmersionTrackingPolicy, 'enabled'>;
|
||||||
|
|
||||||
|
const DISABLE_IMMERSION_TRACKING_SESSION_ENV = 'SUBMINER_DISABLE_IMMERSION_TRACKING';
|
||||||
|
|
||||||
type ImmersionTrackerServiceParams = {
|
type ImmersionTrackerServiceParams = {
|
||||||
dbPath: string;
|
dbPath: string;
|
||||||
policy: ImmersionTrackerPolicy;
|
policy: ImmersionTrackerPolicy;
|
||||||
@@ -49,7 +51,16 @@ export type ImmersionTrackerStartupDeps = {
|
|||||||
export function createImmersionTrackerStartupHandler(
|
export function createImmersionTrackerStartupHandler(
|
||||||
deps: ImmersionTrackerStartupDeps,
|
deps: ImmersionTrackerStartupDeps,
|
||||||
): () => void {
|
): () => void {
|
||||||
|
const isSessionTrackingDisabled = process.env[DISABLE_IMMERSION_TRACKING_SESSION_ENV] === '1';
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (isSessionTrackingDisabled) {
|
||||||
|
deps.logInfo(
|
||||||
|
`Immersion tracking disabled for this session by ${DISABLE_IMMERSION_TRACKING_SESSION_ENV}=1.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const config = deps.getResolvedConfig();
|
const config = deps.getResolvedConfig();
|
||||||
if (config.immersionTracking?.enabled === false) {
|
if (config.immersionTracking?.enabled === false) {
|
||||||
deps.logInfo('Immersion tracking disabled in config');
|
deps.logInfo('Immersion tracking disabled in config');
|
||||||
|
|||||||
24
src/main/runtime/youtube-playback.test.ts
Normal file
24
src/main/runtime/youtube-playback.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { isYoutubeMediaPath, isYoutubePlaybackActive } from './youtube-playback';
|
||||||
|
|
||||||
|
test('isYoutubeMediaPath detects youtube watch and short urls', () => {
|
||||||
|
assert.equal(isYoutubeMediaPath('https://www.youtube.com/watch?v=abc123'), true);
|
||||||
|
assert.equal(isYoutubeMediaPath('https://m.youtube.com/watch?v=abc123'), true);
|
||||||
|
assert.equal(isYoutubeMediaPath('https://youtu.be/abc123'), true);
|
||||||
|
assert.equal(isYoutubeMediaPath('https://www.youtube-nocookie.com/embed/abc123'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isYoutubeMediaPath ignores local files and non-youtube urls', () => {
|
||||||
|
assert.equal(isYoutubeMediaPath('/tmp/video.mkv'), false);
|
||||||
|
assert.equal(isYoutubeMediaPath('https://example.com/watch?v=abc123'), false);
|
||||||
|
assert.equal(isYoutubeMediaPath('https://notyoutube.com/watch?v=abc123'), false);
|
||||||
|
assert.equal(isYoutubeMediaPath(' '), false);
|
||||||
|
assert.equal(isYoutubeMediaPath(null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isYoutubePlaybackActive checks both current media and mpv video paths', () => {
|
||||||
|
assert.equal(isYoutubePlaybackActive('/tmp/video.mkv', 'https://youtu.be/abc123'), true);
|
||||||
|
assert.equal(isYoutubePlaybackActive('https://www.youtube.com/watch?v=abc123', null), true);
|
||||||
|
assert.equal(isYoutubePlaybackActive('/tmp/video.mkv', '/tmp/video.mkv'), false);
|
||||||
|
});
|
||||||
39
src/main/runtime/youtube-playback.ts
Normal file
39
src/main/runtime/youtube-playback.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
function trimToNull(value: string | null | undefined): string | null {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesYoutubeHost(hostname: string, expectedHost: string): boolean {
|
||||||
|
return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isYoutubeMediaPath(mediaPath: string | null | undefined): boolean {
|
||||||
|
const normalized = trimToNull(mediaPath);
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(normalized);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = parsed.hostname.toLowerCase();
|
||||||
|
return (
|
||||||
|
matchesYoutubeHost(host, 'youtu.be') ||
|
||||||
|
matchesYoutubeHost(host, 'youtube.com') ||
|
||||||
|
matchesYoutubeHost(host, 'youtube-nocookie.com')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isYoutubePlaybackActive(
|
||||||
|
currentMediaPath: string | null | undefined,
|
||||||
|
currentVideoPath: string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return isYoutubeMediaPath(currentMediaPath) || isYoutubeMediaPath(currentVideoPath);
|
||||||
|
}
|
||||||
@@ -619,6 +619,33 @@ test('keyboard mode: configured stats toggle works even while popup is open', as
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('youtube picker: unhandled keys still dispatch mpv keybindings', async () => {
|
||||||
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handlers.setupMpvInputForwarding();
|
||||||
|
handlers.updateKeybindings([
|
||||||
|
{
|
||||||
|
key: 'Space',
|
||||||
|
command: ['cycle', 'pause'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'KeyQ',
|
||||||
|
command: ['quit'],
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
|
||||||
|
ctx.state.youtubePickerModalOpen = true;
|
||||||
|
|
||||||
|
testGlobals.dispatchKeydown({ key: ' ', code: 'Space' });
|
||||||
|
testGlobals.dispatchKeydown({ key: 'q', code: 'KeyQ' });
|
||||||
|
|
||||||
|
assert.deepEqual(testGlobals.mpvCommands.slice(-2), [['cycle', 'pause'], ['quit']]);
|
||||||
|
} finally {
|
||||||
|
testGlobals.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('keyboard mode: h moves left when popup is closed', async () => {
|
test('keyboard mode: h moves left when popup is closed', async () => {
|
||||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -843,8 +843,9 @@ export function createKeyboardHandlers(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ctx.state.youtubePickerModalOpen) {
|
if (ctx.state.youtubePickerModalOpen) {
|
||||||
options.handleYoutubePickerKeydown(e);
|
if (options.handleYoutubePickerKeydown(e)) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (ctx.state.controllerSelectModalOpen) {
|
if (ctx.state.controllerSelectModalOpen) {
|
||||||
options.handleControllerSelectKeydown(e);
|
options.handleControllerSelectKeydown(e);
|
||||||
|
|||||||
@@ -348,3 +348,211 @@ test('youtube track picker surfaces rejected resolve calls as modal status', asy
|
|||||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('youtube track picker ignores duplicate resolve submissions while request is in flight', async () => {
|
||||||
|
const resolveCalls: Array<{
|
||||||
|
sessionId: string;
|
||||||
|
action: string;
|
||||||
|
primaryTrackId: string | null;
|
||||||
|
secondaryTrackId: string | null;
|
||||||
|
}> = [];
|
||||||
|
const originalWindow = globalThis.window;
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
let releaseResolve: (() => void) | null = null;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => createFakeElement(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
dispatchEvent: () => true,
|
||||||
|
focus: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
notifyOverlayModalOpened: () => {},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
youtubePickerResolve: async (payload: {
|
||||||
|
sessionId: string;
|
||||||
|
action: string;
|
||||||
|
primaryTrackId: string | null;
|
||||||
|
secondaryTrackId: string | null;
|
||||||
|
}) => {
|
||||||
|
resolveCalls.push(payload);
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
releaseResolve = resolve;
|
||||||
|
});
|
||||||
|
return { ok: true, message: '' };
|
||||||
|
},
|
||||||
|
setIgnoreMouseEvents: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
const dom = {
|
||||||
|
overlay: {
|
||||||
|
classList: createClassList(),
|
||||||
|
focus: () => {},
|
||||||
|
},
|
||||||
|
youtubePickerModal: createFakeElement(),
|
||||||
|
youtubePickerTitle: createFakeElement(),
|
||||||
|
youtubePickerPrimarySelect: createFakeElement(),
|
||||||
|
youtubePickerSecondarySelect: createFakeElement(),
|
||||||
|
youtubePickerTracks: createFakeElement(),
|
||||||
|
youtubePickerStatus: createFakeElement(),
|
||||||
|
youtubePickerContinueButton: createFakeElement(),
|
||||||
|
youtubePickerCloseButton: createFakeElement(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = createYoutubeTrackPickerModal(
|
||||||
|
{
|
||||||
|
state,
|
||||||
|
dom,
|
||||||
|
platform: {
|
||||||
|
shouldToggleMouseIgnore: false,
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
modalStateReader: { isAnyModalOpen: () => true },
|
||||||
|
restorePointerInteractionState: () => {},
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
modal.openYoutubePickerModal({
|
||||||
|
sessionId: 'yt-1',
|
||||||
|
url: 'https://example.com',
|
||||||
|
mode: 'download',
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
id: 'auto:ja-orig',
|
||||||
|
language: 'ja',
|
||||||
|
sourceLanguage: 'ja-orig',
|
||||||
|
kind: 'auto',
|
||||||
|
label: 'Japanese (auto)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultPrimaryTrackId: 'auto:ja-orig',
|
||||||
|
defaultSecondaryTrackId: null,
|
||||||
|
hasTracks: true,
|
||||||
|
});
|
||||||
|
modal.wireDomEvents();
|
||||||
|
|
||||||
|
const listeners = dom.youtubePickerContinueButton.listeners.get('click') ?? [];
|
||||||
|
const first = listeners[0]?.();
|
||||||
|
const second = listeners[0]?.();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.equal(resolveCalls.length, 1);
|
||||||
|
assert.equal(dom.youtubePickerPrimarySelect.disabled, true);
|
||||||
|
assert.equal(dom.youtubePickerSecondarySelect.disabled, true);
|
||||||
|
assert.equal(dom.youtubePickerContinueButton.disabled, true);
|
||||||
|
assert.equal(dom.youtubePickerCloseButton.disabled, true);
|
||||||
|
|
||||||
|
assert.ok(releaseResolve);
|
||||||
|
const release = releaseResolve as () => void;
|
||||||
|
release();
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
|
||||||
|
assert.equal(dom.youtubePickerPrimarySelect.disabled, false);
|
||||||
|
assert.equal(dom.youtubePickerSecondarySelect.disabled, false);
|
||||||
|
assert.equal(dom.youtubePickerContinueButton.disabled, false);
|
||||||
|
assert.equal(dom.youtubePickerCloseButton.disabled, false);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('youtube track picker only consumes handled keys', async () => {
|
||||||
|
const originalWindow = globalThis.window;
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => createFakeElement(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
dispatchEvent: () => true,
|
||||||
|
focus: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
notifyOverlayModalOpened: () => {},
|
||||||
|
notifyOverlayModalClosed: () => {},
|
||||||
|
youtubePickerResolve: async () => ({ ok: true, message: '' }),
|
||||||
|
setIgnoreMouseEvents: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
const dom = {
|
||||||
|
overlay: {
|
||||||
|
classList: createClassList(),
|
||||||
|
focus: () => {},
|
||||||
|
},
|
||||||
|
youtubePickerModal: createFakeElement(),
|
||||||
|
youtubePickerTitle: createFakeElement(),
|
||||||
|
youtubePickerPrimarySelect: createFakeElement(),
|
||||||
|
youtubePickerSecondarySelect: createFakeElement(),
|
||||||
|
youtubePickerTracks: createFakeElement(),
|
||||||
|
youtubePickerStatus: createFakeElement(),
|
||||||
|
youtubePickerContinueButton: createFakeElement(),
|
||||||
|
youtubePickerCloseButton: createFakeElement(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = createYoutubeTrackPickerModal(
|
||||||
|
{
|
||||||
|
state,
|
||||||
|
dom,
|
||||||
|
platform: {
|
||||||
|
shouldToggleMouseIgnore: false,
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
modalStateReader: { isAnyModalOpen: () => true },
|
||||||
|
restorePointerInteractionState: () => {},
|
||||||
|
syncSettingsModalSubtitleSuppression: () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
modal.openYoutubePickerModal({
|
||||||
|
sessionId: 'yt-1',
|
||||||
|
url: 'https://example.com',
|
||||||
|
mode: 'download',
|
||||||
|
tracks: [],
|
||||||
|
defaultPrimaryTrackId: null,
|
||||||
|
defaultSecondaryTrackId: null,
|
||||||
|
hasTracks: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
modal.handleYoutubePickerKeydown({
|
||||||
|
key: ' ',
|
||||||
|
preventDefault: () => {},
|
||||||
|
} as KeyboardEvent),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
modal.handleYoutubePickerKeydown({
|
||||||
|
key: 'Escape',
|
||||||
|
preventDefault: () => {},
|
||||||
|
} as KeyboardEvent),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export function createYoutubeTrackPickerModal(
|
|||||||
syncSettingsModalSubtitleSuppression: () => void;
|
syncSettingsModalSubtitleSuppression: () => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
|
let resolveSelectionInFlight = false;
|
||||||
|
|
||||||
function setStatus(message: string, isError = false): void {
|
function setStatus(message: string, isError = false): void {
|
||||||
ctx.state.youtubePickerStatus = message;
|
ctx.state.youtubePickerStatus = message;
|
||||||
ctx.dom.youtubePickerStatus.textContent = message;
|
ctx.dom.youtubePickerStatus.textContent = message;
|
||||||
@@ -34,7 +36,12 @@ export function createYoutubeTrackPickerModal(
|
|||||||
const payload = ctx.state.youtubePickerPayload;
|
const payload = ctx.state.youtubePickerPayload;
|
||||||
if (!payload || payload.tracks.length === 0) {
|
if (!payload || payload.tracks.length === 0) {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.innerHTML = '<span>No subtitle tracks found</span><span class="youtube-picker-track-meta">Continue without subtitles</span>';
|
const left = document.createElement('span');
|
||||||
|
left.textContent = 'No subtitle tracks found';
|
||||||
|
const right = document.createElement('span');
|
||||||
|
right.className = 'youtube-picker-track-meta';
|
||||||
|
right.textContent = 'Continue without subtitles';
|
||||||
|
li.append(left, right);
|
||||||
ctx.dom.youtubePickerTracks.appendChild(li);
|
ctx.dom.youtubePickerTracks.appendChild(li);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -51,6 +58,13 @@ export function createYoutubeTrackPickerModal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setResolveControlsDisabled(disabled: boolean): void {
|
||||||
|
ctx.dom.youtubePickerPrimarySelect.disabled = disabled;
|
||||||
|
ctx.dom.youtubePickerSecondarySelect.disabled = disabled;
|
||||||
|
ctx.dom.youtubePickerContinueButton.disabled = disabled;
|
||||||
|
ctx.dom.youtubePickerCloseButton.disabled = disabled;
|
||||||
|
}
|
||||||
|
|
||||||
function syncSecondaryOptions(): void {
|
function syncSecondaryOptions(): void {
|
||||||
const payload = ctx.state.youtubePickerPayload;
|
const payload = ctx.state.youtubePickerPayload;
|
||||||
const primaryTrackId = ctx.dom.youtubePickerPrimarySelect.value || null;
|
const primaryTrackId = ctx.dom.youtubePickerPrimarySelect.value || null;
|
||||||
@@ -107,6 +121,9 @@ export function createYoutubeTrackPickerModal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resolveSelection(action: 'use-selected' | 'continue-without-subtitles'): Promise<void> {
|
async function resolveSelection(action: 'use-selected' | 'continue-without-subtitles'): Promise<void> {
|
||||||
|
if (resolveSelectionInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const payload = ctx.state.youtubePickerPayload;
|
const payload = ctx.state.youtubePickerPayload;
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
if (action === 'use-selected' && payload.hasTracks && !ctx.dom.youtubePickerPrimarySelect.value) {
|
if (action === 'use-selected' && payload.hasTracks && !ctx.dom.youtubePickerPrimarySelect.value) {
|
||||||
@@ -114,9 +131,10 @@ export function createYoutubeTrackPickerModal(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let response;
|
resolveSelectionInFlight = true;
|
||||||
|
setResolveControlsDisabled(true);
|
||||||
try {
|
try {
|
||||||
response =
|
const response =
|
||||||
action === 'use-selected'
|
action === 'use-selected'
|
||||||
? await window.electronAPI.youtubePickerResolve({
|
? await window.electronAPI.youtubePickerResolve({
|
||||||
sessionId: payload.sessionId,
|
sessionId: payload.sessionId,
|
||||||
@@ -130,15 +148,17 @@ export function createYoutubeTrackPickerModal(
|
|||||||
primaryTrackId: null,
|
primaryTrackId: null,
|
||||||
secondaryTrackId: null,
|
secondaryTrackId: null,
|
||||||
});
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
setStatus(response.message, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeYoutubePickerModal();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(error instanceof Error ? error.message : String(error), true);
|
setStatus(error instanceof Error ? error.message : String(error), true);
|
||||||
return;
|
} finally {
|
||||||
|
resolveSelectionInFlight = false;
|
||||||
|
setResolveControlsDisabled(false);
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
|
||||||
setStatus(response.message, true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
closeYoutubePickerModal();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openYoutubePickerModal(payload: YoutubePickerOpenPayload): void {
|
function openYoutubePickerModal(payload: YoutubePickerOpenPayload): void {
|
||||||
@@ -209,7 +229,7 @@ export function createYoutubeTrackPickerModal(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireDomEvents(): void {
|
function wireDomEvents(): void {
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ interface MediaCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MediaCard({ item, onClick }: MediaCardProps) {
|
export function MediaCard({ item, onClick }: MediaCardProps) {
|
||||||
|
const primaryTitle = item.videoTitle?.trim() || item.canonicalTitle;
|
||||||
|
const secondaryTitle =
|
||||||
|
item.videoTitle?.trim() && item.videoTitle !== item.canonicalTitle ? item.canonicalTitle : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -20,9 +24,9 @@ export function MediaCard({ item, onClick }: MediaCardProps) {
|
|||||||
className="w-full aspect-[3/4] rounded-t-lg"
|
className="w-full aspect-[3/4] rounded-t-lg"
|
||||||
/>
|
/>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<div className="text-sm font-medium text-ctp-text truncate">{item.canonicalTitle}</div>
|
<div className="text-sm font-medium text-ctp-text truncate">{primaryTitle}</div>
|
||||||
{item.videoTitle && item.videoTitle !== item.canonicalTitle ? (
|
{secondaryTitle ? (
|
||||||
<div className="text-xs text-ctp-subtext1 truncate mt-1">{item.videoTitle}</div>
|
<div className="text-xs text-ctp-subtext1 truncate mt-1">{secondaryTitle}</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="text-xs text-ctp-overlay2 mt-1">
|
<div className="text-xs text-ctp-overlay2 mt-1">
|
||||||
{formatDuration(item.totalActiveMs)} · {formatNumber(item.totalCards)} cards
|
{formatDuration(item.totalActiveMs)} · {formatNumber(item.totalCards)} cards
|
||||||
|
|||||||
43
stats/src/components/library/MediaDetailView.test.tsx
Normal file
43
stats/src/components/library/MediaDetailView.test.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { getRelatedCollectionLabel } from './MediaDetailView';
|
||||||
|
|
||||||
|
test('getRelatedCollectionLabel returns View Channel for youtube-backed media', () => {
|
||||||
|
assert.equal(
|
||||||
|
getRelatedCollectionLabel({
|
||||||
|
videoId: 1,
|
||||||
|
animeId: 1,
|
||||||
|
canonicalTitle: 'Video',
|
||||||
|
totalSessions: 1,
|
||||||
|
totalActiveMs: 1,
|
||||||
|
totalCards: 0,
|
||||||
|
totalTokensSeen: 0,
|
||||||
|
totalLinesSeen: 0,
|
||||||
|
totalLookupCount: 0,
|
||||||
|
totalLookupHits: 0,
|
||||||
|
totalYomitanLookupCount: 0,
|
||||||
|
channelName: 'Creator',
|
||||||
|
}),
|
||||||
|
'View Channel',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getRelatedCollectionLabel returns View Anime for non-youtube media', () => {
|
||||||
|
assert.equal(
|
||||||
|
getRelatedCollectionLabel({
|
||||||
|
videoId: 2,
|
||||||
|
animeId: 1,
|
||||||
|
canonicalTitle: 'Episode 5',
|
||||||
|
totalSessions: 1,
|
||||||
|
totalActiveMs: 1,
|
||||||
|
totalCards: 0,
|
||||||
|
totalTokensSeen: 0,
|
||||||
|
totalLinesSeen: 0,
|
||||||
|
totalLookupCount: 0,
|
||||||
|
totalLookupHits: 0,
|
||||||
|
totalYomitanLookupCount: 0,
|
||||||
|
channelName: null,
|
||||||
|
}),
|
||||||
|
'View Anime',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -5,7 +5,14 @@ import { confirmSessionDelete } from '../../lib/delete-confirm';
|
|||||||
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
import { getSessionDisplayWordCount } from '../../lib/session-word-count';
|
||||||
import { MediaHeader } from './MediaHeader';
|
import { MediaHeader } from './MediaHeader';
|
||||||
import { MediaSessionList } from './MediaSessionList';
|
import { MediaSessionList } from './MediaSessionList';
|
||||||
import type { SessionSummary } from '../../types/stats';
|
import type { MediaDetailData, SessionSummary } from '../../types/stats';
|
||||||
|
|
||||||
|
export function getRelatedCollectionLabel(detail: MediaDetailData['detail']): string {
|
||||||
|
if (detail?.channelName?.trim()) {
|
||||||
|
return 'View Channel';
|
||||||
|
}
|
||||||
|
return 'View Anime';
|
||||||
|
}
|
||||||
|
|
||||||
interface MediaDetailViewProps {
|
interface MediaDetailViewProps {
|
||||||
videoId: number;
|
videoId: number;
|
||||||
@@ -53,6 +60,7 @@ export function MediaDetailView({
|
|||||||
totalLookupHits: sessions.reduce((sum, session) => sum + session.lookupHits, 0),
|
totalLookupHits: sessions.reduce((sum, session) => sum + session.lookupHits, 0),
|
||||||
totalYomitanLookupCount: sessions.reduce((sum, session) => sum + session.yomitanLookupCount, 0),
|
totalYomitanLookupCount: sessions.reduce((sum, session) => sum + session.yomitanLookupCount, 0),
|
||||||
};
|
};
|
||||||
|
const relatedCollectionLabel = getRelatedCollectionLabel(detail);
|
||||||
|
|
||||||
const handleDeleteSession = async (session: SessionSummary) => {
|
const handleDeleteSession = async (session: SessionSummary) => {
|
||||||
if (!confirmSessionDelete()) return;
|
if (!confirmSessionDelete()) return;
|
||||||
@@ -87,7 +95,7 @@ export function MediaDetailView({
|
|||||||
onClick={() => onNavigateToAnime(animeId)}
|
onClick={() => onNavigateToAnime(animeId)}
|
||||||
className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors"
|
className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors"
|
||||||
>
|
>
|
||||||
View Anime →
|
{relatedCollectionLabel} →
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
57
stats/src/hooks/useMediaLibrary.test.ts
Normal file
57
stats/src/hooks/useMediaLibrary.test.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import type { MediaLibraryItem } from '../types/stats';
|
||||||
|
import { shouldRefreshMediaLibraryRows } from './useMediaLibrary';
|
||||||
|
|
||||||
|
const baseItem: MediaLibraryItem = {
|
||||||
|
videoId: 1,
|
||||||
|
canonicalTitle: 'watch?v=abc123',
|
||||||
|
totalSessions: 1,
|
||||||
|
totalActiveMs: 60_000,
|
||||||
|
totalCards: 0,
|
||||||
|
totalTokensSeen: 10,
|
||||||
|
lastWatchedMs: 1_000,
|
||||||
|
hasCoverArt: 0,
|
||||||
|
youtubeVideoId: 'abc123',
|
||||||
|
videoUrl: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
videoTitle: null,
|
||||||
|
videoThumbnailUrl: 'https://i.ytimg.com/vi/abc123/hqdefault.jpg',
|
||||||
|
channelId: null,
|
||||||
|
channelName: null,
|
||||||
|
channelUrl: null,
|
||||||
|
channelThumbnailUrl: null,
|
||||||
|
uploaderId: null,
|
||||||
|
uploaderUrl: null,
|
||||||
|
description: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('shouldRefreshMediaLibraryRows requests a follow-up fetch for incomplete youtube metadata', () => {
|
||||||
|
assert.equal(shouldRefreshMediaLibraryRows([baseItem]), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRefreshMediaLibraryRows skips follow-up fetch when youtube metadata is complete', () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldRefreshMediaLibraryRows([
|
||||||
|
{
|
||||||
|
...baseItem,
|
||||||
|
videoTitle: 'Video Name',
|
||||||
|
channelName: 'Creator Name',
|
||||||
|
channelThumbnailUrl: 'https://yt3.googleusercontent.com/channel-avatar=s88',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldRefreshMediaLibraryRows ignores non-youtube rows', () => {
|
||||||
|
assert.equal(
|
||||||
|
shouldRefreshMediaLibraryRows([
|
||||||
|
{
|
||||||
|
...baseItem,
|
||||||
|
youtubeVideoId: null,
|
||||||
|
videoUrl: null,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -2,6 +2,18 @@ import { useState, useEffect } from 'react';
|
|||||||
import { getStatsClient } from './useStatsApi';
|
import { getStatsClient } from './useStatsApi';
|
||||||
import type { MediaLibraryItem } from '../types/stats';
|
import type { MediaLibraryItem } from '../types/stats';
|
||||||
|
|
||||||
|
const MEDIA_LIBRARY_REFRESH_DELAY_MS = 1_500;
|
||||||
|
const MEDIA_LIBRARY_MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
export function shouldRefreshMediaLibraryRows(rows: MediaLibraryItem[]): boolean {
|
||||||
|
return rows.some((row) => {
|
||||||
|
if (!row.youtubeVideoId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !row.videoTitle?.trim() || !row.channelName?.trim() || !row.channelThumbnailUrl?.trim();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useMediaLibrary() {
|
export function useMediaLibrary() {
|
||||||
const [media, setMedia] = useState<MediaLibraryItem[]>([]);
|
const [media, setMedia] = useState<MediaLibraryItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -9,24 +21,43 @@ export function useMediaLibrary() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
let retryCount = 0;
|
||||||
setError(null);
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
getStatsClient()
|
|
||||||
.getMediaLibrary()
|
const load = (isInitial = false) => {
|
||||||
.then((rows) => {
|
if (isInitial) {
|
||||||
if (cancelled) return;
|
setLoading(true);
|
||||||
setMedia(rows);
|
setError(null);
|
||||||
})
|
}
|
||||||
.catch((err: Error) => {
|
getStatsClient()
|
||||||
if (cancelled) return;
|
.getMediaLibrary()
|
||||||
setError(err.message);
|
.then((rows) => {
|
||||||
})
|
if (cancelled) return;
|
||||||
.finally(() => {
|
setMedia(rows);
|
||||||
if (cancelled) return;
|
if (shouldRefreshMediaLibraryRows(rows) && retryCount < MEDIA_LIBRARY_MAX_RETRIES) {
|
||||||
setLoading(false);
|
retryCount += 1;
|
||||||
});
|
retryTimer = setTimeout(() => {
|
||||||
|
retryTimer = null;
|
||||||
|
load(false);
|
||||||
|
}, MEDIA_LIBRARY_REFRESH_DELAY_MS);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setError(err.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled || !isInitial) return;
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
load(true);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
if (retryTimer) {
|
||||||
|
clearTimeout(retryTimer);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,30 @@ test('groupMediaLibraryItems groups youtube videos by channel and leaves local m
|
|||||||
assert.equal(groups[1]?.items.length, 1);
|
assert.equal(groups[1]?.items.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('groupMediaLibraryItems falls back to channel metadata when youtube channel id is missing', () => {
|
||||||
|
const first = {
|
||||||
|
...youtubeEpisodeA,
|
||||||
|
videoId: 20,
|
||||||
|
youtubeVideoId: 'yt-20',
|
||||||
|
videoUrl: 'https://www.youtube.com/watch?v=yt-20',
|
||||||
|
channelId: null,
|
||||||
|
};
|
||||||
|
const second = {
|
||||||
|
...youtubeEpisodeB,
|
||||||
|
videoId: 21,
|
||||||
|
youtubeVideoId: 'yt-21',
|
||||||
|
videoUrl: 'https://www.youtube.com/watch?v=yt-21',
|
||||||
|
channelId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const groups = groupMediaLibraryItems([first, second]);
|
||||||
|
|
||||||
|
assert.equal(groups.length, 1);
|
||||||
|
assert.equal(groups[0]?.title, 'Creator Name');
|
||||||
|
assert.equal(groups[0]?.items.length, 2);
|
||||||
|
assert.equal(groups[0]?.channelUrl, 'https://www.youtube.com/channel/UC123');
|
||||||
|
});
|
||||||
|
|
||||||
test('resolveMediaArtworkUrl prefers youtube thumbnails for video and channel images', () => {
|
test('resolveMediaArtworkUrl prefers youtube thumbnails for video and channel images', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
resolveMediaArtworkUrl(youtubeEpisodeA, 'video'),
|
resolveMediaArtworkUrl(youtubeEpisodeA, 'video'),
|
||||||
@@ -147,3 +171,10 @@ test('MediaCard uses the proxied cover endpoint instead of metadata artwork urls
|
|||||||
assert.match(markup, /src="http:\/\/127\.0\.0\.1:6969\/api\/stats\/media\/1\/cover"/);
|
assert.match(markup, /src="http:\/\/127\.0\.0\.1:6969\/api\/stats\/media\/1\/cover"/);
|
||||||
assert.doesNotMatch(markup, /https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg/);
|
assert.doesNotMatch(markup, /https:\/\/i\.ytimg\.com\/vi\/yt-1\/hqdefault\.jpg/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('MediaCard prefers youtube video title over canonical fallback url slug', () => {
|
||||||
|
const markup = renderToStaticMarkup(<MediaCard item={youtubeEpisodeA} onClick={() => {}} />);
|
||||||
|
|
||||||
|
assert.match(markup, />Video 1</);
|
||||||
|
assert.match(markup, />Episode 1</);
|
||||||
|
});
|
||||||
|
|||||||
@@ -45,9 +45,16 @@ export function groupMediaLibraryItems(items: MediaLibraryItem[]): MediaLibraryG
|
|||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const channelId = item.channelId?.trim() || null;
|
const channelId = item.channelId?.trim() || null;
|
||||||
const channelName = item.channelName?.trim() || null;
|
const channelName = item.channelName?.trim() || null;
|
||||||
|
const channelUrl = item.channelUrl?.trim() || null;
|
||||||
const uploaderId = item.uploaderId?.trim() || null;
|
const uploaderId = item.uploaderId?.trim() || null;
|
||||||
const videoTitle = item.videoTitle?.trim() || null;
|
const videoTitle = item.videoTitle?.trim() || null;
|
||||||
const key = channelId || `video:${item.videoId}`;
|
const key = channelId
|
||||||
|
? `youtube:channel:${channelId}`
|
||||||
|
: channelUrl
|
||||||
|
? `youtube:channel-url:${channelUrl}`
|
||||||
|
: channelName
|
||||||
|
? `youtube:channel-name:${channelName.toLowerCase()}`
|
||||||
|
: `video:${item.videoId}`;
|
||||||
const title = channelName || uploaderId || videoTitle || item.canonicalTitle;
|
const title = channelName || uploaderId || videoTitle || item.canonicalTitle;
|
||||||
const subtitle = channelId
|
const subtitle = channelId
|
||||||
? channelId
|
? channelId
|
||||||
|
|||||||
Reference in New Issue
Block a user