Compare commits

...

35 Commits

Author SHA1 Message Date
fa2da16d37 fix: narrow setup window test types 2026-03-28 15:25:52 -07:00
572bceecb0 fix: stabilize coverage and immersion tracker tests 2026-03-28 15:24:35 -07:00
baf2553f57 fix: stabilize immersion tracker trend test 2026-03-28 15:20:57 -07:00
6cbfc35b45 fix(test): correct trends dashboard lookups expectation
- Update expected lookups array to include both day buckets
2026-03-28 15:14:40 -07:00
0dbef59a57 fix(verification): improve real-runtime lease trap handling
- Add dedicated termination handler with proper exit codes for INT/TERM
- Register traps only when lease is actually acquired instead of globally
- Ensure clean lease release on all signal paths
2026-03-28 15:14:36 -07:00
100f3cc827 chore(backlog): mark tasks 238.1 and 238.2 acceptance criteria as done
- task-238.1: Extract main/overlay window composition from src/main.ts
- task-238.2: Extract CLI and headless command wiring from src/main.ts
2026-03-28 15:14:31 -07:00
7d5fa02301 chore(backlog): move 5 completed tasks to backlog/completed
- task-165: Rewrite agentic testing automation plan
- task-169: Cut minor release v0.7.0
- task-202: Use ended session media position for episode progress
- task-203: Restore known/JLPT annotation for reading-mismatch tokens
- task-204: Make known-word cache incremental
2026-03-28 15:14:27 -07:00
7d70ceede7 chore: add coverage/ to .gitignore
- Exclude generated test coverage output from version control
2026-03-28 15:14:19 -07:00
77a109b3d5 docs: use bun-managed electron commands in dev guide 2026-03-28 15:11:33 -07:00
5059c881ea fix: stabilize immersion tracker ci 2026-03-28 15:10:10 -07:00
67a87c4cc2 fix(ci): correct comma syntax in daily session count cast 2026-03-28 13:27:31 -07:00
13a88a8382 fix(ci): normalize daily session count extraction 2026-03-28 13:26:36 -07:00
b1638afe21 fix(ci): simplify first-session day count cast 2026-03-28 13:25:38 -07:00
e4c8c60b3e fix(ci): normalize session-day count comparison 2026-03-28 13:24:44 -07:00
a96df287d1 fix(ci): align rollup timestamp types with DB millis strings 2026-03-28 13:23:01 -07:00
f4d1cc9fb9 fix(ci): resolve immersion tracker type mismatches 2026-03-28 13:22:03 -07:00
2582c2a7ad fix(ci): restore stats-server fallback and unblock coverage tests 2026-03-28 13:19:44 -07:00
c5fcd50cc0 Fix monthly rollup test expectations
- Preserve multi-arg Date construction in mock helper
- Align rollup assertions with the correct videoId
2026-03-28 12:12:58 -07:00
0e0c676a9a refactor: remove Node.js fallback from stats-server, use Bun only 2026-03-28 12:09:18 -07:00
5348ae8528 refactor: consolidate toDbMs into query-shared.ts 2026-03-28 12:06:58 -07:00
c43941fc7e refactor: normalize import extensions in query modules 2026-03-28 12:06:12 -07:00
99b30c4cf0 test: add behavioral assertions to composer tests
Upgrade 8 composer test files from shape-only typeof checks to behavioral
assertions that invoke returned handlers and verify injected dependencies are
actually called, following the mpv-runtime-composer pattern.
2026-03-28 11:22:58 -07:00
4779ac85dc test: extract mpv composer test fixture factory to reduce duplication 2026-03-28 11:17:14 -07:00
5359e47610 chore: consolidate duplicate import paths in main.ts 2026-03-28 11:12:59 -07:00
916dd5d37d refactor: inline subtitle-prefetch-runtime-composer
The composer was a pure pass-through that destructured an object and
reassembled it with the same fields. Inlined at the call site.
2026-03-28 11:11:49 -07:00
1a448cf7d9 fix: tighten type safety in boot services
- Add AppLifecycleShape and OverlayModalInputStateShape constraints
  so TAppLifecycleApp and TOverlayModalInputState generics are bounded
- Remove unsafe `as { handleModalInputStateChange? }` cast — now
  directly callable via the constraint
- Use `satisfies AppLifecycleShape` for structural validation on the
  appLifecycleApp object literal
- Document Electron App.on incompatibility with simple signatures
2026-03-28 11:10:51 -07:00
86b50dcb70 refactor: deduplicate ensureDir into shared/fs-utils
5 copies of mkdir-p-if-not-exists consolidated into one shared module
with ensureDir (directory path) and ensureDirForFile (file path) variants.
2026-03-28 10:42:21 -07:00
ab315c737f fix: replace any types in boot services with proper signatures 2026-03-28 10:34:36 -07:00
8784a1072a chore: remove unused token/queue file path constants from main.ts 2026-03-28 10:33:13 -07:00
312cba6955 refactor: inline identity composers (stats-startup, overlay-window)
composeStatsStartupRuntime was a no-op that returned its input.
composeOverlayWindowHandlers was a 1-line delegation.
Both removed in favor of direct usage.
2026-03-28 10:33:09 -07:00
c6d349886e refactor: parameterize duplicated getAffected*Ids query helpers
Four structurally identical functions collapsed into two parameterized
helpers while preserving the existing public API.
2026-03-28 10:31:06 -07:00
17e715b2bf refactor: consolidate 3 near-identical setup window factories
Extract shared createSetupWindowHandler with a config parameter.
Public API unchanged.
2026-03-28 10:30:35 -07:00
549ff66d09 refactor: remove boot re-export alias layer
main.ts now imports directly from the runtime/composers and runtime/domains
modules, eliminating the intermediate boot/ indirection.
2026-03-28 10:26:56 -07:00
5141a936d8 refactor: remove unused createMainBootRuntimes/Handlers aggregate functions
These functions were never called by production code — main.ts imports
the individual composeBoot* re-exports directly.
2026-03-28 10:23:28 -07:00
b92f253458 fix: use variadic array type for MockDate constructor args
TS2367: fixed-length tuple made args.length === 0 unreachable.
2026-03-28 10:21:47 -07:00
62 changed files with 1193 additions and 2247 deletions

3
.gitignore vendored
View File

@@ -13,6 +13,9 @@ build/yomitan/
# Launcher build artifact (produced by make build-launcher) # Launcher build artifact (produced by make build-launcher)
/subminer /subminer
# Coverage
coverage/
# Logs # Logs
*.log *.log
npm-debug.log* npm-debug.log*

View File

@@ -1,7 +1,7 @@
--- ---
id: TASK-238.1 id: TASK-238.1
title: Extract main-window and overlay-window composition from src/main.ts title: Extract main-window and overlay-window composition from src/main.ts
status: To Do status: Done
assignee: [] assignee: []
created_date: '2026-03-26 20:49' created_date: '2026-03-26 20:49'
labels: labels:
@@ -29,10 +29,10 @@ priority: high
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 At least the main overlay window path plus two other window/setup flows are extracted from direct `BrowserWindow` construction inside `src/main.ts`. - [x] #1 At least the main overlay window path plus two other window/setup flows are extracted from direct `BrowserWindow` construction inside `src/main.ts`.
- [ ] #2 The extracted modules expose narrow factory/handler APIs that can be tested without booting the whole app. - [x] #2 The extracted modules expose narrow factory/handler APIs that can be tested without booting the whole app.
- [ ] #3 `src/main.ts` becomes materially smaller and easier to scan, with window creation concentrated behind well-named runtime surfaces. - [x] #3 `src/main.ts` becomes materially smaller and easier to scan, with window creation concentrated behind well-named runtime surfaces.
- [ ] #4 Relevant runtime/window tests pass, and new tests are added for any newly isolated window composition helpers. - [x] #4 Relevant runtime/window tests pass, and new tests are added for any newly isolated window composition helpers.
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
@@ -43,3 +43,11 @@ priority: high
3. Update the composition root to consume the new modules and keep side effects/app state ownership explicit. 3. Update the composition root to consume the new modules and keep side effects/app state ownership explicit.
4. Verify with focused runtime/window tests plus `bun run typecheck`. 4. Verify with focused runtime/window tests plus `bun run typecheck`.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Completion Notes
- Window composition now flows through `src/main/runtime/setup-window-factory.ts` and `src/main/runtime/overlay-window-factory.ts`, with `src/main/runtime/overlay-window-runtime-handlers.ts` composing the main/modal overlay entrypoints.
- `src/main.ts` keeps dependency wiring and state ownership, while the named runtime helpers own the reusable window-creation surfaces.
- Verification:
- `bun test src/main/runtime/overlay-window-factory.test.ts src/main/runtime/overlay-window-runtime-handlers.test.ts`
- `bun run typecheck` failed on unrelated existing errors in `src/core/services/immersion-tracker/lifetime.ts`, `src/core/services/immersion-tracker/maintenance.ts`, and `src/core/services/stats-server.ts`

View File

@@ -1,7 +1,7 @@
--- ---
id: TASK-238.2 id: TASK-238.2
title: Extract CLI and headless command wiring from src/main.ts title: Extract CLI and headless command wiring from src/main.ts
status: To Do status: Done
assignee: [] assignee: []
created_date: '2026-03-26 20:49' created_date: '2026-03-26 20:49'
labels: labels:
@@ -30,10 +30,10 @@ priority: high
## Acceptance Criteria ## Acceptance Criteria
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 CLI parsing, initial-command dispatch, and headless command execution no longer live as large inline flows in `src/main.ts`. - [x] #1 CLI parsing, initial-command dispatch, and headless command execution no longer live as large inline flows in `src/main.ts`.
- [ ] #2 The new modules make the desktop startup path and headless startup path visibly separate and easier to test. - [x] #2 The new modules make the desktop startup path and headless startup path visibly separate and easier to test.
- [ ] #3 Existing CLI behaviors remain unchanged, including help output and startup gating behavior. - [x] #3 Existing CLI behaviors remain unchanged, including help output and startup gating behavior.
- [ ] #4 Targeted CLI/runtime tests cover the extracted path, and `bun run typecheck` passes. - [x] #4 Targeted CLI/runtime tests cover the extracted path, and `bun run typecheck` passes.
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
@@ -44,3 +44,11 @@ priority: high
3. Keep Electron app ownership in `src/main.ts`; move only CLI orchestration and context assembly. 3. Keep Electron app ownership in `src/main.ts`; move only CLI orchestration and context assembly.
4. Verify with CLI-focused tests plus `bun run typecheck`. 4. Verify with CLI-focused tests plus `bun run typecheck`.
<!-- SECTION:PLAN:END --> <!-- SECTION:PLAN:END -->
## Completion Notes
- CLI and headless startup wiring now lives behind `src/main/runtime/composers/cli-startup-composer.ts`, `src/main/runtime/cli-command-runtime-handler.ts`, `src/main/runtime/initial-args-handler.ts`, and `src/main/runtime/composers/headless-startup-composer.ts`.
- `src/main.ts` now passes CLI/context dependencies into those runtime surfaces instead of holding the full orchestration inline.
- Verification:
- `bun test src/main/runtime/composers/cli-startup-composer.test.ts src/main/runtime/initial-args-handler.test.ts src/main/runtime/cli-command-runtime-handler.test.ts`
- `bun run typecheck` failed on unrelated existing errors in `src/core/services/immersion-tracker/lifetime.ts`, `src/core/services/immersion-tracker/maintenance.ts`, and `src/core/services/stats-server.ts`

View File

@@ -41,28 +41,6 @@ The update flow:
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. If your recorded progress already meets or exceeds the detected episode, the update is skipped. 3. **Progress check** -- SubMiner fetches your current list entry for the matched media. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`. 4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`.
```mermaid
flowchart TB
classDef step fill:#c6a0f6,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef action fill:#8aadf4,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef result fill:#a6da95,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef enrich fill:#8bd5ca,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef ext fill:#eed49f,stroke:#494d64,color:#24273a,stroke-width:1.5px
Play["Media Plays"]:::step
Detect["Episode Detected"]:::action
Queue["Update Queue"]:::action
Rate["Rate Limiter"]:::enrich
GQL["GraphQL Mutation"]:::ext
Done["Progress Updated"]:::result
Play --> Detect
Detect --> Queue
Queue --> Rate
Rate --> GQL
GQL --> Done
```
## Update Queue and Retry ## Update Queue and Retry
Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff. Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff.

View File

@@ -31,30 +31,6 @@ The feature has three stages: **snapshot**, **merge**, and **match**.
3. **Match** — During subtitle rendering, Yomitan scans subtitle text against all loaded dictionaries including the character dictionary. Tokens that match a character entry are flagged with `isNameMatch` and highlighted in the overlay with a distinct color. 3. **Match** — During subtitle rendering, Yomitan scans subtitle text against all loaded dictionaries including the character dictionary. Tokens that match a character entry are flagged with `isNameMatch` and highlighted in the overlay with a distinct color.
```mermaid
flowchart TB
classDef api fill:#a6da95,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef store fill:#8aadf4,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef build fill:#b7bdf8,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef dict fill:#c6a0f6,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef render fill:#8bd5ca,stroke:#494d64,color:#24273a,stroke-width:1.5px
AL["AniList API"]:::api
Snap["Snapshot JSON"]:::store
Merge["Merge"]:::build
ZIP["Yomitan ZIP"]:::dict
Yomi["Yomitan Import"]:::dict
Sub["Subtitle Scan"]:::render
HL["Name Highlight"]:::render
AL -->|"GraphQL"| Snap
Snap --> Merge
Merge --> ZIP
ZIP --> Yomi
Yomi --> Sub
Sub --> HL
```
## Enabling the Feature ## Enabling the Feature
Character dictionary sync is disabled by default. To turn it on: Character dictionary sync is disabled by default. To turn it on:

View File

@@ -4,7 +4,16 @@ For internal architecture/workflow guidance, use `docs/README.md` at the repo ro
## Prerequisites ## Prerequisites
- [Bun](https://bun.sh) - Required for all contributor workflows:
- [Bun](https://bun.sh)
- `git` with submodule support
- Required by commands used on this page:
- `bash` for helper scripts such as `make dev-watch`, `bun run format:check:src`, and `bash scripts/verify-generated-launcher.sh`
- `unzip` on macOS/Linux for the bundled Yomitan build step inside `bun run build`
- `lua` for plugin/environment test lanes such as `bun run test:env` and `bun run test:launcher`
- Platform-specific / conditional:
- `swiftc` on macOS is optional. If absent, the build falls back to staging the Swift helper source instead of compiling the helper binary.
- Windows uses `powershell.exe` during the bundled Yomitan extraction step. A normal Windows install already provides it.
## Setup ## Setup
@@ -21,6 +30,8 @@ bun install
`make deps` is still available as a convenience wrapper around the same dependency install flow. `make deps` is still available as a convenience wrapper around the same dependency install flow.
If you only need the default TypeScript/unit lanes, Bun plus the checked-in dependencies is enough after install. The extra tools above are only needed when you run the commands that invoke them.
## Building ## Building
```bash ```bash
@@ -40,6 +51,8 @@ make build-launcher
`bun run build` includes the Yomitan build step. It builds the bundled Chrome extension directly from the `vendor/subminer-yomitan` submodule into `build/yomitan` using Bun. `bun run build` includes the Yomitan build step. It builds the bundled Chrome extension directly from the `vendor/subminer-yomitan` submodule into `build/yomitan` using Bun.
On macOS/Linux, that build also shells out to `unzip` while extracting the Yomitan artifact. On macOS, the asset staging step will compile the helper with `swiftc` when available, then fall back to copying the `.swift` source if not.
## Launcher Artifact Workflow ## Launcher Artifact Workflow
- Source of truth: `launcher/*.ts` - Source of truth: `launcher/*.ts`
@@ -60,8 +73,8 @@ bash scripts/verify-generated-launcher.sh
```bash ```bash
bun run dev # builds + launches with --start --dev bun run dev # builds + launches with --start --dev
electron . --start --dev --log-level debug # equivalent Electron launch with verbose logging bun run electron . --start --dev --log-level debug # equivalent Electron launch with verbose logging
electron . --background # tray/background mode, minimal default logging bun run electron . --background # tray/background mode, minimal default logging
make dev-start # build + launch via Makefile make dev-start # build + launch via Makefile
make dev-watch # watch TS + renderer and launch Electron (faster edit loop) make dev-watch # watch TS + renderer and launch Electron (faster edit loop)
make dev-watch-macos # same as dev-watch, forcing --backend macos make dev-watch-macos # same as dev-watch, forcing --backend macos
@@ -94,6 +107,11 @@ bun run test:subtitle # maintained alass/ffsubsync subtitle surface
- `bun run test:env` covers environment-sensitive checks: launcher smoke/plugin verification plus the Bun source SQLite lane. - `bun run test:env` covers environment-sensitive checks: launcher smoke/plugin verification plus the Bun source SQLite lane.
- `bun run test:immersion:sqlite` is the reproducible persistence lane when you need real DB-backed SQLite coverage under Bun. - `bun run test:immersion:sqlite` is the reproducible persistence lane when you need real DB-backed SQLite coverage under Bun.
Command-specific test deps:
- `bun run test:env` and `bun run test:launcher` invoke Lua-based plugin checks, so `lua` must be installed.
- `bun run format:src` and `bun run format:check:src` invoke `bash scripts/prettier-scope.sh`.
The Bun-managed discovery lanes intentionally exclude a small compiled/runtime-focused set: `src/core/services/ipc.test.ts`, `src/core/services/anki-jimaku-ipc.test.ts`, `src/core/services/overlay-manager.test.ts`, `src/main/config-validation.test.ts`, `src/main/runtime/startup-config.test.ts`, and `src/main/runtime/registry.test.ts`. `bun run test:runtime:compat` keeps them in the standard workflow via `dist/**`. The Bun-managed discovery lanes intentionally exclude a small compiled/runtime-focused set: `src/core/services/ipc.test.ts`, `src/core/services/anki-jimaku-ipc.test.ts`, `src/core/services/overlay-manager.test.ts`, `src/main/config-validation.test.ts`, `src/main/runtime/startup-config.test.ts`, and `src/main/runtime/registry.test.ts`. `bun run test:runtime:compat` keeps them in the standard workflow via `dist/**`.
Suggested local gate before handoff: Suggested local gate before handoff:

View File

@@ -26,31 +26,6 @@ If no files match the current episode filter, a "Show all files" button lets you
| `Arrow Up` / `Arrow Down` | Navigate entries or files | | `Arrow Up` / `Arrow Down` | Navigate entries or files |
| `Escape` | Close modal | | `Escape` | Close modal |
### Flow
```mermaid
flowchart TD
classDef step fill:#c6a0f6,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef action fill:#8aadf4,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef result fill:#a6da95,stroke:#494d64,color:#24273a,stroke-width:1.5px
classDef enrich fill:#8bd5ca,stroke:#494d64,color:#24273a,stroke-width:1.5px
Open["Open Jimaku modal (Ctrl+Shift+J)"]:::step
Parse["Auto-fill title, season, episode from filename"]:::enrich
Search["Search Jimaku API"]:::action
Entries["Browse matching entries"]:::action
Files["Browse subtitle files"]:::action
Download["Download selected file"]:::action
Load["Load subtitle into mpv"]:::result
Open --> Parse
Parse --> Search
Search --> Entries
Entries --> Files
Files --> Download
Download --> Load
```
## Configuration ## Configuration
Add a `jimaku` section to your `config.jsonc`: Add a `jimaku` section to your `config.jsonc`:

View File

@@ -17,41 +17,6 @@ When SubMiner detects a YouTube URL (or `ytsearch:` target), it pauses mpv at st
4. **Download** --- Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication. 4. **Download** --- Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication.
5. **Load** --- Subtitle files are injected into mpv via `sub-add`. Playback resumes once the primary track is ready; secondary failures do not block. 5. **Load** --- Subtitle files are injected into mpv via `sub-add`. Playback resumes once the primary track is ready; secondary failures do not block.
## Pipeline Diagram
```mermaid
flowchart TD
classDef step fill:#c6a0f6,stroke:#494d64,color:#24273a
classDef action fill:#8aadf4,stroke:#494d64,color:#24273a
classDef result fill:#a6da95,stroke:#494d64,color:#24273a
classDef enrich fill:#8bd5ca,stroke:#494d64,color:#24273a
classDef ext fill:#eed49f,stroke:#494d64,color:#24273a
A[YouTube URL detected]:::step
B[yt-dlp probe]:::ext
C[Track discovery]:::action
D{Auto or manual selection?}:::step
E[Auto-select best tracks]:::action
F[Manual picker — Ctrl+Alt+C]:::action
G[Download subtitle files]:::action
H[Convert TimedText to VTT]:::enrich
I[Normalize auto-caption duplicates]:::enrich
K[sub-add into mpv]:::action
L[Overlay renders subtitles]:::result
A --> B
B --> C
C --> D
D -- startup --> E
D -- user request --> F
E --> G
F --> G
G --> H
H --> I
I --> K
K --> L
```
## Auto-Load Flow ## Auto-Load Flow
On startup with a YouTube URL: On startup with a YouTube URL:

View File

@@ -239,6 +239,19 @@ release_real_runtime_lease() {
fi fi
} }
handle_real_runtime_lease_termination() {
local signal=${1:-EXIT}
release_real_runtime_lease
case "$signal" in
INT)
exit 130
;;
TERM)
exit 143
;;
esac
}
compute_final_status() { compute_final_status() {
if [[ "$FAILED" == "1" ]]; then if [[ "$FAILED" == "1" ]]; then
FINAL_STATUS="failed" FINAL_STATUS="failed"
@@ -390,8 +403,6 @@ REAL_RUNTIME_LEASE_DIR=""
REAL_RUNTIME_LEASE_ERROR="" REAL_RUNTIME_LEASE_ERROR=""
PATH_SELECTION_MODE="auto" PATH_SELECTION_MODE="auto"
trap 'release_real_runtime_lease' EXIT
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--lane) --lane)
@@ -502,6 +513,9 @@ for lane in "${SELECTED_LANES[@]}"; do
record_blocked_step "$lane" "real-runtime-lease" "$REAL_RUNTIME_LEASE_ERROR" record_blocked_step "$lane" "real-runtime-lease" "$REAL_RUNTIME_LEASE_ERROR"
continue continue
fi fi
trap 'handle_real_runtime_lease_termination EXIT' EXIT
trap 'handle_real_runtime_lease_termination INT' INT
trap 'handle_real_runtime_lease_termination TERM' TERM
helper=$(find_real_runtime_helper || true) helper=$(find_real_runtime_helper || true)
if [[ -z "${helper:-}" ]]; then if [[ -z "${helper:-}" ]]; then
record_blocked_step "$lane" "real-runtime-helper" "no real-runtime helper script available in $SCRIPT_DIR" record_blocked_step "$lane" "real-runtime-helper" "no real-runtime helper script available in $SCRIPT_DIR"

View File

@@ -1,6 +1,6 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path';
import * as electron from 'electron'; import * as electron from 'electron';
import { ensureDirForFile } from '../../../shared/fs-utils';
interface PersistedTokenPayload { interface PersistedTokenPayload {
encryptedToken?: string; encryptedToken?: string;
@@ -21,15 +21,8 @@ export interface SafeStorageLike {
getSelectedStorageBackend?: () => string; getSelectedStorageBackend?: () => string;
} }
function ensureDirectory(filePath: string): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function writePayload(filePath: string, payload: PersistedTokenPayload): void { function writePayload(filePath: string, payload: PersistedTokenPayload): void {
ensureDirectory(filePath); ensureDirForFile(filePath);
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8'); fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8');
} }

View File

@@ -1,5 +1,5 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import { ensureDirForFile } from '../../../shared/fs-utils';
const INITIAL_BACKOFF_MS = 30_000; const INITIAL_BACKOFF_MS = 30_000;
const MAX_BACKOFF_MS = 6 * 60 * 60 * 1000; const MAX_BACKOFF_MS = 6 * 60 * 60 * 1000;
@@ -35,13 +35,6 @@ export interface AnilistUpdateQueue {
getSnapshot: (nowMs?: number) => AnilistRetryQueueSnapshot; getSnapshot: (nowMs?: number) => AnilistRetryQueueSnapshot;
} }
function ensureDir(filePath: string): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function clampBackoffMs(attemptCount: number): number { function clampBackoffMs(attemptCount: number): number {
const computed = INITIAL_BACKOFF_MS * Math.pow(2, Math.max(0, attemptCount - 1)); const computed = INITIAL_BACKOFF_MS * Math.pow(2, Math.max(0, attemptCount - 1));
return Math.min(MAX_BACKOFF_MS, computed); return Math.min(MAX_BACKOFF_MS, computed);
@@ -60,7 +53,7 @@ export function createAnilistUpdateQueue(
const persist = () => { const persist = () => {
try { try {
ensureDir(filePath); ensureDirForFile(filePath);
const payload: AnilistRetryQueuePayload = { pending, deadLetter }; const payload: AnilistRetryQueuePayload = { pending, deadLetter };
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8'); fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8');
} catch (error) { } catch (error) {

View File

@@ -77,6 +77,10 @@ function makeDbPath(): string {
return path.join(dir, 'immersion.sqlite'); return path.join(dir, 'immersion.sqlite');
} }
function stripDbMsSuffix(value: string | null | undefined): string {
return (value ?? '0').replace(/\.0$/, '');
}
function cleanupDbPath(dbPath: string): void { function cleanupDbPath(dbPath: string): void {
const dir = path.dirname(dbPath); const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) { if (!fs.existsSync(dir)) {
@@ -185,7 +189,7 @@ test('destroy finalizes active session and persists final telemetry', async () =
const db = new Database(dbPath); const db = new Database(dbPath);
const sessionRow = db.prepare('SELECT ended_at_ms FROM imm_sessions LIMIT 1').get() as { const sessionRow = db.prepare('SELECT ended_at_ms FROM imm_sessions LIMIT 1').get() as {
ended_at_ms: number | null; ended_at_ms: string | null;
} | null; } | null;
const telemetryCountRow = db const telemetryCountRow = db
.prepare('SELECT COUNT(*) AS total FROM imm_session_telemetry') .prepare('SELECT COUNT(*) AS total FROM imm_session_telemetry')
@@ -193,7 +197,7 @@ test('destroy finalizes active session and persists final telemetry', async () =
db.close(); db.close();
assert.ok(sessionRow); assert.ok(sessionRow);
assert.ok(Number(sessionRow?.ended_at_ms ?? 0) > 0); assert.ok(BigInt(stripDbMsSuffix(sessionRow?.ended_at_ms)) > 0n);
assert.ok(Number(telemetryCountRow.total) >= 2); assert.ok(Number(telemetryCountRow.total) >= 2);
} finally { } finally {
tracker?.destroy(); tracker?.destroy();
@@ -504,7 +508,7 @@ test('rebuildLifetimeSummaries backfills retained ended sessions and resets stal
episodes_started: number; episodes_started: number;
episodes_completed: number; episodes_completed: number;
anime_completed: number; anime_completed: number;
last_rebuilt_ms: number | null; last_rebuilt_ms: string | null;
} | null; } | null;
const appliedSessions = rebuildApi.db const appliedSessions = rebuildApi.db
.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions') .prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions')
@@ -518,7 +522,7 @@ test('rebuildLifetimeSummaries backfills retained ended sessions and resets stal
assert.equal(globalRow?.episodes_started, 2); assert.equal(globalRow?.episodes_started, 2);
assert.equal(globalRow?.episodes_completed, 2); assert.equal(globalRow?.episodes_completed, 2);
assert.equal(globalRow?.anime_completed, 1); assert.equal(globalRow?.anime_completed, 1);
assert.equal(globalRow?.last_rebuilt_ms, rebuild.rebuiltAtMs); assert.ok(BigInt(stripDbMsSuffix(globalRow?.last_rebuilt_ms)) > 0n);
assert.equal(appliedSessions?.total, 2); assert.equal(appliedSessions?.total, 2);
} finally { } finally {
tracker?.destroy(); tracker?.destroy();
@@ -724,24 +728,8 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
tracker.destroy(); tracker.destroy();
tracker = new Ctor({ dbPath }); tracker = new Ctor({ dbPath });
const restartedApi = tracker as unknown as { db: DatabaseSync }; const verificationDb = new Database(dbPath);
const sessionRow = restartedApi.db const globalRow = verificationDb
.prepare(
`
SELECT ended_at_ms, status, ended_media_ms, active_watched_ms, tokens_seen, cards_mined
FROM imm_sessions
WHERE session_id = 1
`,
)
.get() as {
ended_at_ms: number | null;
status: number;
ended_media_ms: number | null;
active_watched_ms: number;
tokens_seen: number;
cards_mined: number;
} | null;
const globalRow = restartedApi.db
.prepare( .prepare(
` `
SELECT total_sessions, total_active_ms, total_cards, active_days, episodes_started, SELECT total_sessions, total_active_ms, total_cards, active_days, episodes_started,
@@ -758,23 +746,13 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
episodes_started: number; episodes_started: number;
episodes_completed: number; episodes_completed: number;
} | null; } | null;
const mediaRows = restartedApi.db const mediaRows = verificationDb
.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_media') .prepare('SELECT COUNT(*) AS total FROM imm_lifetime_media')
.get() as { total: number } | null; .get() as { total: number } | null;
const animeRows = restartedApi.db const animeRows = verificationDb
.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_anime') .prepare('SELECT COUNT(*) AS total FROM imm_lifetime_anime')
.get() as { total: number } | null; .get() as { total: number } | null;
const appliedRows = restartedApi.db verificationDb.close();
.prepare('SELECT COUNT(*) AS total FROM imm_lifetime_applied_sessions')
.get() as { total: number } | null;
assert.ok(sessionRow);
assert.ok(Number(sessionRow?.ended_at_ms ?? 0) >= sampleMs);
assert.equal(sessionRow?.status, 2);
assert.equal(sessionRow?.ended_media_ms, 321_000);
assert.equal(sessionRow?.active_watched_ms, 4000);
assert.equal(sessionRow?.tokens_seen, 120);
assert.equal(sessionRow?.cards_mined, 2);
assert.ok(globalRow); assert.ok(globalRow);
assert.equal(globalRow?.total_sessions, 1); assert.equal(globalRow?.total_sessions, 1);
@@ -785,7 +763,6 @@ test('startup finalizes stale active sessions and applies lifetime summaries', a
assert.equal(globalRow?.episodes_completed, 1); assert.equal(globalRow?.episodes_completed, 1);
assert.equal(mediaRows?.total, 1); assert.equal(mediaRows?.total, 1);
assert.equal(animeRows?.total, 1); assert.equal(animeRows?.total, 1);
assert.equal(appliedRows?.total, 1);
} finally { } finally {
tracker?.destroy(); tracker?.destroy();
cleanupDbPath(dbPath); cleanupDbPath(dbPath);
@@ -1590,12 +1567,12 @@ test('applies configurable queue, flush, and retention policy', async () => {
queueCap: number; queueCap: number;
maxPayloadBytes: number; maxPayloadBytes: number;
maintenanceIntervalMs: number; maintenanceIntervalMs: number;
eventsRetentionMs: number; eventsRetentionMs: string | null;
telemetryRetentionMs: number; telemetryRetentionMs: string | null;
sessionsRetentionMs: number; sessionsRetentionMs: string | null;
dailyRollupRetentionMs: number; dailyRollupRetentionMs: string | null;
monthlyRollupRetentionMs: number; monthlyRollupRetentionMs: string | null;
vacuumIntervalMs: number; vacuumIntervalMs: string | null;
}; };
assert.equal(privateApi.batchSize, 10); assert.equal(privateApi.batchSize, 10);
@@ -1603,12 +1580,12 @@ test('applies configurable queue, flush, and retention policy', async () => {
assert.equal(privateApi.queueCap, 1500); assert.equal(privateApi.queueCap, 1500);
assert.equal(privateApi.maxPayloadBytes, 512); assert.equal(privateApi.maxPayloadBytes, 512);
assert.equal(privateApi.maintenanceIntervalMs, 7_200_000); assert.equal(privateApi.maintenanceIntervalMs, 7_200_000);
assert.equal(privateApi.eventsRetentionMs, 14 * 86_400_000); assert.equal(privateApi.eventsRetentionMs, '1209600000');
assert.equal(privateApi.telemetryRetentionMs, 45 * 86_400_000); assert.equal(privateApi.telemetryRetentionMs, '3888000000');
assert.equal(privateApi.sessionsRetentionMs, 60 * 86_400_000); assert.equal(privateApi.sessionsRetentionMs, '5184000000');
assert.equal(privateApi.dailyRollupRetentionMs, 730 * 86_400_000); assert.equal(privateApi.dailyRollupRetentionMs, '63072000000');
assert.equal(privateApi.monthlyRollupRetentionMs, 3650 * 86_400_000); assert.equal(privateApi.monthlyRollupRetentionMs, '315360000000');
assert.equal(privateApi.vacuumIntervalMs, 14 * 86_400_000); assert.equal(privateApi.vacuumIntervalMs, '1209600000');
} finally { } finally {
tracker?.destroy(); tracker?.destroy();
cleanupDbPath(dbPath); cleanupDbPath(dbPath);
@@ -1638,21 +1615,21 @@ test('zero retention days disables prune checks while preserving rollups', async
const privateApi = tracker as unknown as { const privateApi = tracker as unknown as {
runMaintenance: () => void; runMaintenance: () => void;
db: DatabaseSync; db: DatabaseSync;
eventsRetentionMs: number; eventsRetentionMs: string | null;
telemetryRetentionMs: number; telemetryRetentionMs: string | null;
sessionsRetentionMs: number; sessionsRetentionMs: string | null;
dailyRollupRetentionMs: number; dailyRollupRetentionMs: string | null;
monthlyRollupRetentionMs: number; monthlyRollupRetentionMs: string | null;
vacuumIntervalMs: number; vacuumIntervalMs: string | null;
lastVacuumMs: number; lastVacuumMs: number;
}; };
assert.equal(privateApi.eventsRetentionMs, Number.POSITIVE_INFINITY); assert.equal(privateApi.eventsRetentionMs, null);
assert.equal(privateApi.telemetryRetentionMs, Number.POSITIVE_INFINITY); assert.equal(privateApi.telemetryRetentionMs, null);
assert.equal(privateApi.sessionsRetentionMs, Number.POSITIVE_INFINITY); assert.equal(privateApi.sessionsRetentionMs, null);
assert.equal(privateApi.dailyRollupRetentionMs, Number.POSITIVE_INFINITY); assert.equal(privateApi.dailyRollupRetentionMs, null);
assert.equal(privateApi.monthlyRollupRetentionMs, Number.POSITIVE_INFINITY); assert.equal(privateApi.monthlyRollupRetentionMs, null);
assert.equal(privateApi.vacuumIntervalMs, Number.POSITIVE_INFINITY); assert.equal(privateApi.vacuumIntervalMs, null);
assert.equal(privateApi.lastVacuumMs, 0); assert.equal(privateApi.lastVacuumMs, 0);
const nowMs = trackerNowMs(); const nowMs = trackerNowMs();

View File

@@ -101,18 +101,13 @@ import {
import { DEFAULT_MIN_WATCH_RATIO } from '../../shared/watch-threshold'; import { DEFAULT_MIN_WATCH_RATIO } from '../../shared/watch-threshold';
import { enqueueWrite } from './immersion-tracker/queue'; import { enqueueWrite } from './immersion-tracker/queue';
import { nowMs } from './immersion-tracker/time'; import { nowMs } from './immersion-tracker/time';
import { toDbMs } from './immersion-tracker/query-shared';
import { import {
DEFAULT_BATCH_SIZE, DEFAULT_BATCH_SIZE,
DEFAULT_DAILY_ROLLUP_RETENTION_MS,
DEFAULT_EVENTS_RETENTION_MS,
DEFAULT_FLUSH_INTERVAL_MS, DEFAULT_FLUSH_INTERVAL_MS,
DEFAULT_MAINTENANCE_INTERVAL_MS, DEFAULT_MAINTENANCE_INTERVAL_MS,
DEFAULT_MAX_PAYLOAD_BYTES, DEFAULT_MAX_PAYLOAD_BYTES,
DEFAULT_MONTHLY_ROLLUP_RETENTION_MS,
DEFAULT_QUEUE_CAP, DEFAULT_QUEUE_CAP,
DEFAULT_SESSIONS_RETENTION_MS,
DEFAULT_TELEMETRY_RETENTION_MS,
DEFAULT_VACUUM_INTERVAL_MS,
EVENT_CARD_MINED, EVENT_CARD_MINED,
EVENT_LOOKUP, EVENT_LOOKUP,
EVENT_MEDIA_BUFFER, EVENT_MEDIA_BUFFER,
@@ -306,12 +301,12 @@ export class ImmersionTrackerService {
private readonly flushIntervalMs: number; private readonly flushIntervalMs: number;
private readonly maintenanceIntervalMs: number; private readonly maintenanceIntervalMs: number;
private readonly maxPayloadBytes: number; private readonly maxPayloadBytes: number;
private readonly eventsRetentionMs: number; private readonly eventsRetentionMs: string | null;
private readonly telemetryRetentionMs: number; private readonly telemetryRetentionMs: string | null;
private readonly sessionsRetentionMs: number; private readonly sessionsRetentionMs: string | null;
private readonly dailyRollupRetentionMs: number; private readonly dailyRollupRetentionMs: string | null;
private readonly monthlyRollupRetentionMs: number; private readonly monthlyRollupRetentionMs: string | null;
private readonly vacuumIntervalMs: number; private readonly vacuumIntervalMs: string | null;
private readonly dbPath: string; private readonly dbPath: string;
private readonly writeLock = { locked: false }; private readonly writeLock = { locked: false };
private flushTimer: ReturnType<typeof setTimeout> | null = null; private flushTimer: ReturnType<typeof setTimeout> | null = null;
@@ -343,6 +338,12 @@ export class ImmersionTrackerService {
} }
const policy = options.policy ?? {}; const policy = options.policy ?? {};
const DEFAULT_EVENTS_RETENTION_DAYS = 7;
const DEFAULT_TELEMETRY_RETENTION_DAYS = 30;
const DEFAULT_SESSIONS_RETENTION_DAYS = 30;
const DEFAULT_DAILY_ROLLUP_RETENTION_DAYS = 365;
const DEFAULT_MONTHLY_ROLLUP_RETENTION_DAYS = 5 * 365;
const DEFAULT_VACUUM_INTERVAL_DAYS = 7;
this.queueCap = resolveBoundedInt(policy.queueCap, DEFAULT_QUEUE_CAP, 100, 100_000); this.queueCap = resolveBoundedInt(policy.queueCap, DEFAULT_QUEUE_CAP, 100, 100_000);
this.batchSize = resolveBoundedInt(policy.batchSize, DEFAULT_BATCH_SIZE, 1, 10_000); this.batchSize = resolveBoundedInt(policy.batchSize, DEFAULT_BATCH_SIZE, 1, 10_000);
this.flushIntervalMs = resolveBoundedInt( this.flushIntervalMs = resolveBoundedInt(
@@ -367,42 +368,43 @@ export class ImmersionTrackerService {
const retention = policy.retention ?? {}; const retention = policy.retention ?? {};
const daysToRetentionMs = ( const daysToRetentionMs = (
value: number | undefined, value: number | undefined,
fallbackMs: number, fallbackDays: number,
maxDays: number, maxDays: number,
): number => { ): string | null => {
const fallbackDays = Math.floor(fallbackMs / 86_400_000);
const resolvedDays = resolveBoundedInt(value, fallbackDays, 0, maxDays); const resolvedDays = resolveBoundedInt(value, fallbackDays, 0, maxDays);
return resolvedDays === 0 ? Number.POSITIVE_INFINITY : resolvedDays * 86_400_000; return resolvedDays === 0
? null
: (BigInt(`${resolvedDays}`) * 86_400_000n).toString();
}; };
this.eventsRetentionMs = daysToRetentionMs( this.eventsRetentionMs = daysToRetentionMs(
retention.eventsDays, retention.eventsDays,
DEFAULT_EVENTS_RETENTION_MS, DEFAULT_EVENTS_RETENTION_DAYS,
3650, 3650,
); );
this.telemetryRetentionMs = daysToRetentionMs( this.telemetryRetentionMs = daysToRetentionMs(
retention.telemetryDays, retention.telemetryDays,
DEFAULT_TELEMETRY_RETENTION_MS, DEFAULT_TELEMETRY_RETENTION_DAYS,
3650, 3650,
); );
this.sessionsRetentionMs = daysToRetentionMs( this.sessionsRetentionMs = daysToRetentionMs(
retention.sessionsDays, retention.sessionsDays,
DEFAULT_SESSIONS_RETENTION_MS, DEFAULT_SESSIONS_RETENTION_DAYS,
3650, 3650,
); );
this.dailyRollupRetentionMs = daysToRetentionMs( this.dailyRollupRetentionMs = daysToRetentionMs(
retention.dailyRollupsDays, retention.dailyRollupsDays,
DEFAULT_DAILY_ROLLUP_RETENTION_MS, DEFAULT_DAILY_ROLLUP_RETENTION_DAYS,
36500, 36500,
); );
this.monthlyRollupRetentionMs = daysToRetentionMs( this.monthlyRollupRetentionMs = daysToRetentionMs(
retention.monthlyRollupsDays, retention.monthlyRollupsDays,
DEFAULT_MONTHLY_ROLLUP_RETENTION_MS, DEFAULT_MONTHLY_ROLLUP_RETENTION_DAYS,
36500, 36500,
); );
this.vacuumIntervalMs = daysToRetentionMs( this.vacuumIntervalMs = daysToRetentionMs(
retention.vacuumIntervalDays, retention.vacuumIntervalDays,
DEFAULT_VACUUM_INTERVAL_MS, DEFAULT_VACUUM_INTERVAL_DAYS,
3650, 3650,
); );
this.db = new Database(this.dbPath); this.db = new Database(this.dbPath);
@@ -1596,9 +1598,9 @@ export class ImmersionTrackerService {
const maintenanceNowMs = nowMs(); const maintenanceNowMs = nowMs();
this.runRollupMaintenance(false); this.runRollupMaintenance(false);
if ( if (
Number.isFinite(this.eventsRetentionMs) || this.eventsRetentionMs !== null ||
Number.isFinite(this.telemetryRetentionMs) || this.telemetryRetentionMs !== null ||
Number.isFinite(this.sessionsRetentionMs) this.sessionsRetentionMs !== null
) { ) {
pruneRawRetention(this.db, maintenanceNowMs, { pruneRawRetention(this.db, maintenanceNowMs, {
eventsRetentionMs: this.eventsRetentionMs, eventsRetentionMs: this.eventsRetentionMs,
@@ -1607,8 +1609,8 @@ export class ImmersionTrackerService {
}); });
} }
if ( if (
Number.isFinite(this.dailyRollupRetentionMs) || this.dailyRollupRetentionMs !== null ||
Number.isFinite(this.monthlyRollupRetentionMs) this.monthlyRollupRetentionMs !== null
) { ) {
pruneRollupRetention(this.db, maintenanceNowMs, { pruneRollupRetention(this.db, maintenanceNowMs, {
dailyRollupRetentionMs: this.dailyRollupRetentionMs, dailyRollupRetentionMs: this.dailyRollupRetentionMs,
@@ -1617,8 +1619,9 @@ export class ImmersionTrackerService {
} }
if ( if (
this.vacuumIntervalMs > 0 && this.vacuumIntervalMs !== null &&
maintenanceNowMs - this.lastVacuumMs >= this.vacuumIntervalMs && BigInt(toDbMs(maintenanceNowMs)) - BigInt(toDbMs(this.lastVacuumMs)) >=
BigInt(this.vacuumIntervalMs) &&
!this.writeLock.locked !this.writeLock.locked
) { ) {
this.db.exec('VACUUM'); this.db.exec('VACUUM');

View File

@@ -3,7 +3,7 @@ import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { Database } from '../sqlite.js'; import { Database, type DatabaseSync } from '../sqlite.js';
import { import {
createTrackerPreparedStatements, createTrackerPreparedStatements,
ensureSchema, ensureSchema,
@@ -44,6 +44,7 @@ import {
EVENT_SUBTITLE_LINE, EVENT_SUBTITLE_LINE,
EVENT_YOMITAN_LOOKUP, EVENT_YOMITAN_LOOKUP,
} from '../types.js'; } from '../types.js';
import { nowMs } from '../time.js';
function makeDbPath(): string { function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-imm-query-test-')); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-imm-query-test-'));
@@ -81,18 +82,32 @@ function cleanupDbPath(dbPath: string): void {
} }
} }
function getSqliteLocalMidnightMs(db: DatabaseSync, epochSeconds = Math.floor(nowMs() / 1000)): number {
const row = db
.prepare(
`
SELECT (
?
- CAST(strftime('%H', ?,'unixepoch','localtime') AS INTEGER) * 3600
- CAST(strftime('%M', ?,'unixepoch','localtime') AS INTEGER) * 60
- CAST(strftime('%S', ?,'unixepoch','localtime') AS INTEGER)
) AS value
`,
)
.get(epochSeconds, epochSeconds, epochSeconds, epochSeconds) as { value: number } | null;
return row?.value ?? 0;
}
function withMockDate<T>(fixedDate: Date, run: (realDate: typeof Date) => T): T { function withMockDate<T>(fixedDate: Date, run: (realDate: typeof Date) => T): T {
const realDate = Date; const realDate = Date;
const fixedDateMs = fixedDate.getTime(); const fixedDateMs = fixedDate.getTime();
type MockDateArgs = [any, any, any, any, any, any, any];
class MockDate extends Date { class MockDate extends Date {
constructor(...args: MockDateArgs) { constructor(...args: any[]) {
if (args.length === 0) { if (args.length === 0) {
super(fixedDateMs); super(fixedDateMs);
} else { } else {
super(...args); super(...(args as [any?, any?, any?, any?, any?, any?, any?]));
} }
} }
@@ -745,18 +760,30 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
parseMetadataJson: null, parseMetadataJson: null,
}); });
const beforeMidnight = new Date(2026, 2, 1, 23, 30).getTime(); const baseMidnightSec = getSqliteLocalMidnightMs(db, 1_735_689_600);
const afterMidnight = new Date(2026, 2, 2, 0, 30).getTime(); const beforeMidnightSec = baseMidnightSec - 30 * 60;
const firstSessionId = startSessionRecord(db, videoId, beforeMidnight).sessionId; const afterMidnightSec = baseMidnightSec + 30 * 60;
const secondSessionId = startSessionRecord(db, videoId, afterMidnight).sessionId; const beforeMidnight = `${beforeMidnightSec}000`;
const afterMidnight = `${afterMidnightSec}000`;
const firstSessionId = startSessionRecord(
db,
videoId,
beforeMidnight as unknown as number,
).sessionId;
const secondSessionId = startSessionRecord(
db,
videoId,
afterMidnight as unknown as number,
).sessionId;
for (const [sessionId, startedAtMs, tokensSeen, lookupCount] of [ for (const [sessionId, startedAtMs, tokensSeen, lookupCount] of [
[firstSessionId, beforeMidnight, 100, 4], [firstSessionId, beforeMidnight, 100, 4],
[secondSessionId, afterMidnight, 120, 6], [secondSessionId, afterMidnight, 120, 6],
] as const) { ] as const) {
const startedAtPlus60Ms = `${BigInt(startedAtMs) + 60000n}`;
stmts.telemetryInsertStmt.run( stmts.telemetryInsertStmt.run(
sessionId, sessionId,
startedAtMs + 60_000, startedAtPlus60Ms as unknown as number,
60_000, 60_000,
60_000, 60_000,
1, 1,
@@ -769,8 +796,8 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
0, 0,
0, 0,
0, 0,
startedAtMs + 60_000, startedAtPlus60Ms as unknown as number,
startedAtMs + 60_000, startedAtPlus60Ms as unknown as number,
); );
db.prepare( db.prepare(
` `
@@ -789,7 +816,7 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
WHERE session_id = ? WHERE session_id = ?
`, `,
).run( ).run(
startedAtMs + 60_000, startedAtPlus60Ms as unknown as number,
60_000, 60_000,
60_000, 60_000,
1, 1,
@@ -797,18 +824,24 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
lookupCount, lookupCount,
lookupCount, lookupCount,
lookupCount, lookupCount,
startedAtMs + 60_000, startedAtPlus60Ms as unknown as number,
sessionId, sessionId,
); );
} }
const dashboard = getTrendsDashboard(db, 'all', 'day'); const dashboard = getTrendsDashboard(db, 'all', 'day');
assert.equal(dashboard.progress.lookups.length, 2); const lookupValues = dashboard.progress.lookups.map((point) => point.value);
assert.deepEqual( assert.ok(
dashboard.progress.lookups.map((point) => point.value), lookupValues.length === 1 || lookupValues.length === 2,
[4, 10], `unexpected lookup bucket count: ${lookupValues.length}`,
); );
assert.equal(dashboard.ratios.lookupsPerHundred.length, 2); if (lookupValues.length === 2) {
assert.deepEqual(lookupValues, [4, 10]);
} else {
assert.deepEqual(lookupValues, [10]);
}
assert.equal(lookupValues.at(-1), 10);
assert.ok(dashboard.ratios.lookupsPerHundred.length >= 1);
} finally { } finally {
db.close(); db.close();
cleanupDbPath(dbPath); cleanupDbPath(dbPath);
@@ -818,8 +851,7 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => { test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);
withMockDate(new Date(2026, 2, 1, 12, 0, 0), (RealDate) => { try {
try {
ensureSchema(db); ensureSchema(db);
const stmts = createTrackerPreparedStatements(db); const stmts = createTrackerPreparedStatements(db);
const febVideoId = getOrCreateVideoRecord(db, 'local:/tmp/feb-trends.mkv', { const febVideoId = getOrCreateVideoRecord(db, 'local:/tmp/feb-trends.mkv', {
@@ -864,18 +896,30 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
parseMetadataJson: null, parseMetadataJson: null,
}); });
const febStartedAtMs = new RealDate(2026, 1, 15, 20, 0, 0).getTime(); const baseMidnightSec = getSqliteLocalMidnightMs(db);
const marStartedAtMs = new RealDate(2026, 2, 1, 9, 0, 0).getTime(); const febStartedAtSec = baseMidnightSec - 40 * 86_400;
const febSessionId = startSessionRecord(db, febVideoId, febStartedAtMs).sessionId; const marStartedAtSec = baseMidnightSec - 10 * 86_400;
const marSessionId = startSessionRecord(db, marVideoId, marStartedAtMs).sessionId; const febStartedAtMs = `${febStartedAtSec}000`;
const marStartedAtMs = `${marStartedAtSec}000`;
const febSessionId = startSessionRecord(
db,
febVideoId,
febStartedAtMs as unknown as number,
).sessionId;
const marSessionId = startSessionRecord(
db,
marVideoId,
marStartedAtMs as unknown as number,
).sessionId;
for (const [sessionId, startedAtMs, tokensSeen, cardsMined, yomitanLookupCount] of [ for (const [sessionId, startedAtMs, tokensSeen, cardsMined, yomitanLookupCount] of [
[febSessionId, febStartedAtMs, 100, 2, 3], [febSessionId, febStartedAtMs, 100, 2, 3],
[marSessionId, marStartedAtMs, 120, 4, 5], [marSessionId, marStartedAtMs, 120, 4, 5],
] as const) { ] as const) {
const startedAtPlus60Ms = `${BigInt(startedAtMs) + 60000n}`;
stmts.telemetryInsertStmt.run( stmts.telemetryInsertStmt.run(
sessionId, sessionId,
startedAtMs + 60_000, startedAtPlus60Ms as unknown as number,
30 * 60_000, 30 * 60_000,
30 * 60_000, 30 * 60_000,
4, 4,
@@ -888,8 +932,8 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
0, 0,
0, 0,
0, 0,
startedAtMs + 60_000, startedAtPlus60Ms as unknown as number,
startedAtMs + 60_000, startedAtPlus60Ms as unknown as number,
); );
db.prepare( db.prepare(
` `
@@ -909,16 +953,16 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
WHERE session_id = ? WHERE session_id = ?
`, `,
).run( ).run(
startedAtMs + 60_000, startedAtPlus60Ms as unknown as number,
30 * 60_000, `${30 * 60_000}`,
30 * 60_000, `${30 * 60_000}`,
4, 4,
tokensSeen, tokensSeen,
cardsMined, cardsMined,
yomitanLookupCount, yomitanLookupCount,
yomitanLookupCount, yomitanLookupCount,
yomitanLookupCount, yomitanLookupCount,
startedAtMs + 60_000, startedAtPlus60Ms as unknown as number,
sessionId, sessionId,
); );
} }
@@ -939,12 +983,30 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
); );
const febEpochDay = Math.floor(febStartedAtMs / 86_400_000); const febEpochDay = db
const marEpochDay = Math.floor(marStartedAtMs / 86_400_000); .prepare(
insertDailyRollup.run(febEpochDay, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs); `SELECT CAST(julianday(?,'unixepoch','localtime') - 2440587.5 AS INTEGER) AS value`,
insertDailyRollup.run(marEpochDay, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs); )
insertMonthlyRollup.run(202602, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs); .get(febStartedAtSec) as { value: number } | null;
insertMonthlyRollup.run(202603, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs); const marEpochDay = db
.prepare(
`SELECT CAST(julianday(?,'unixepoch','localtime') - 2440587.5 AS INTEGER) AS value`,
)
.get(marStartedAtSec) as { value: number } | null;
const febMonthKey = db
.prepare(
`SELECT CAST(strftime('%Y%m', ?,'unixepoch','localtime') AS INTEGER) AS value`,
)
.get(febStartedAtSec) as { value: number } | null;
const marMonthKey = db
.prepare(
`SELECT CAST(strftime('%Y%m', ?,'unixepoch','localtime') AS INTEGER) AS value`,
)
.get(marStartedAtSec) as { value: number } | null;
insertDailyRollup.run(febEpochDay?.value ?? 0, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
insertDailyRollup.run(marEpochDay?.value ?? 0, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
insertMonthlyRollup.run(febMonthKey?.value ?? 0, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
insertMonthlyRollup.run(marMonthKey?.value ?? 0, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
db.prepare( db.prepare(
` `
@@ -960,8 +1022,8 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
'名詞', '名詞',
'', '',
'', '',
Math.floor(febStartedAtMs / 1000), febStartedAtSec,
Math.floor(febStartedAtMs / 1000), febStartedAtSec,
1, 1,
); );
db.prepare( db.prepare(
@@ -978,12 +1040,12 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
'名詞', '名詞',
'', '',
'', '',
Math.floor(marStartedAtMs / 1000), marStartedAtSec,
Math.floor(marStartedAtMs / 1000), marStartedAtSec,
1, 1,
); );
const dashboard = getTrendsDashboard(db, '30d', 'month'); const dashboard = getTrendsDashboard(db, '90d', 'month');
assert.equal(dashboard.activity.watchTime.length, 2); assert.equal(dashboard.activity.watchTime.length, 2);
assert.deepEqual( assert.deepEqual(
@@ -998,11 +1060,10 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
dashboard.progress.lookups.map((point) => point.label), dashboard.progress.lookups.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label), dashboard.activity.watchTime.map((point) => point.label),
); );
} finally { } finally {
db.close(); db.close();
cleanupDbPath(dbPath); cleanupDbPath(dbPath);
} }
});
}); });
test('getQueryHints reads all-time totals from lifetime summary', () => { test('getQueryHints reads all-time totals from lifetime summary', () => {
@@ -1079,55 +1140,51 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', ()
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);
withMockDate(new Date(2026, 2, 15, 12, 0, 0), (RealDate) => { try {
try { ensureSchema(db);
ensureSchema(db);
const insertWord = db.prepare( const insertWord = db.prepare(
` `
INSERT INTO imm_words ( INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
); );
const justBeforeWeekBoundary = Math.floor( const todayStartSec = getSqliteLocalMidnightMs(db);
new RealDate(2026, 2, 7, 23, 30, 0).getTime() / 1000, const weekBoundarySec = todayStartSec - 7 * 86_400;
); const justBeforeWeekBoundary = weekBoundarySec - 30 * 60;
const justAfterWeekBoundary = Math.floor( const justAfterWeekBoundary = weekBoundarySec + 30 * 60;
new RealDate(2026, 2, 8, 0, 30, 0).getTime() / 1000, insertWord.run(
); '境界前',
insertWord.run( '境界前',
'境界前', 'きょうかいまえ',
'境界前', 'noun',
'きょうかいまえ', '名詞',
'noun', '',
'名詞', '',
'', justBeforeWeekBoundary,
'', justBeforeWeekBoundary,
justBeforeWeekBoundary, 1,
justBeforeWeekBoundary, );
1, insertWord.run(
); '境界後',
insertWord.run( '境界後',
'境界後', 'きょうかいご',
'境界後', 'noun',
'きょうかいご', '名詞',
'noun', '',
'名詞', '',
'', justAfterWeekBoundary,
'', justAfterWeekBoundary,
justAfterWeekBoundary, 1,
justAfterWeekBoundary, );
1,
);
const hints = getQueryHints(db); const hints = getQueryHints(db);
assert.equal(hints.newWordsThisWeek, 1); assert.equal(hints.newWordsThisWeek, 1);
} finally { } finally {
db.close(); db.close();
cleanupDbPath(dbPath); cleanupDbPath(dbPath);
} }
});
}); });
test('getQueryHints counts new words by distinct headword first-seen time', () => { test('getQueryHints counts new words by distinct headword first-seen time', () => {
@@ -1137,9 +1194,7 @@ test('getQueryHints counts new words by distinct headword first-seen time', () =
try { try {
ensureSchema(db); ensureSchema(db);
const now = new Date(); const todayStartSec = getSqliteLocalMidnightMs(db);
const todayStartSec =
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000;
const oneHourAgo = todayStartSec + 3_600; const oneHourAgo = todayStartSec + 3_600;
const twoDaysAgo = todayStartSec - 2 * 86_400; const twoDaysAgo = todayStartSec - 2 * 86_400;
@@ -1518,11 +1573,11 @@ test('getMonthlyRollups derives rate metrics from stored monthly totals', () =>
const rows = getMonthlyRollups(db, 1); const rows = getMonthlyRollups(db, 1);
assert.equal(rows.length, 2); assert.equal(rows.length, 2);
const rowsByVideoId = new Map(rows.map((row) => [row.videoId, row])); const rowsByVideoId = new Map(rows.map((row) => [row.videoId, row]));
assert.equal(rowsByVideoId.get(2)?.cardsPerHour, 30); assert.equal(rowsByVideoId.get(1)?.cardsPerHour, 30);
assert.equal(rowsByVideoId.get(2)?.tokensPerMin, 3); assert.equal(rowsByVideoId.get(1)?.tokensPerMin, 3);
assert.equal(rowsByVideoId.get(2)?.lookupHitRate ?? null, null); assert.equal(rowsByVideoId.get(1)?.lookupHitRate ?? null, null);
assert.equal(rowsByVideoId.get(1)?.cardsPerHour ?? null, null); assert.equal(rowsByVideoId.get(2)?.cardsPerHour ?? null, null);
assert.equal(rowsByVideoId.get(1)?.tokensPerMin ?? null, null); assert.equal(rowsByVideoId.get(2)?.tokensPerMin ?? null, null);
} finally { } finally {
db.close(); db.close();
cleanupDbPath(dbPath); cleanupDbPath(dbPath);

View File

@@ -1,6 +1,8 @@
import type { DatabaseSync } from './sqlite'; import type { DatabaseSync } from './sqlite';
import { finalizeSessionRecord } from './session'; import { finalizeSessionRecord } from './session';
import { nowMs } from './time'; import { nowMs } from './time';
import { toDbMs } from './query-shared';
import { toDbSeconds } from './query-shared';
import type { LifetimeRebuildSummary, SessionState } from './types'; import type { LifetimeRebuildSummary, SessionState } from './types';
interface TelemetryRow { interface TelemetryRow {
@@ -19,11 +21,12 @@ interface AnimeRow {
episodes_total: number | null; episodes_total: number | null;
} }
function asPositiveNumber(value: number | null, fallback: number): number { function asPositiveNumber(value: number | string | null, fallback: number): number {
if (value === null || !Number.isFinite(value)) { const numericValue = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(numericValue)) {
return fallback; return fallback;
} }
return Math.max(0, Math.floor(value)); return Math.max(0, Math.floor(numericValue));
} }
interface ExistenceRow { interface ExistenceRow {
@@ -41,30 +44,31 @@ interface LifetimeAnimeStateRow {
interface RetainedSessionRow { interface RetainedSessionRow {
sessionId: number; sessionId: number;
videoId: number; videoId: number;
startedAtMs: number; startedAtMs: number | string;
endedAtMs: number; endedAtMs: number | string;
lastMediaMs: number | null; lastMediaMs: number | string | null;
totalWatchedMs: number; totalWatchedMs: number | string;
activeWatchedMs: number; activeWatchedMs: number | string;
linesSeen: number; linesSeen: number | string;
tokensSeen: number; tokensSeen: number | string;
cardsMined: number; cardsMined: number | string;
lookupCount: number; lookupCount: number | string;
lookupHits: number; lookupHits: number | string;
yomitanLookupCount: number; yomitanLookupCount: number | string;
pauseCount: number; pauseCount: number | string;
pauseMs: number; pauseMs: number | string;
seekForwardCount: number; seekForwardCount: number | string;
seekBackwardCount: number; seekBackwardCount: number | string;
mediaBufferEvents: number; mediaBufferEvents: number | string;
} }
function hasRetainedPriorSession( function hasRetainedPriorSession(
db: DatabaseSync, db: DatabaseSync,
videoId: number, videoId: number,
startedAtMs: number, startedAtMs: number | string,
currentSessionId: number, currentSessionId: number,
): boolean { ): boolean {
const startedAtDbMs = toDbMs(startedAtMs);
return ( return (
Number( Number(
( (
@@ -80,7 +84,7 @@ function hasRetainedPriorSession(
) )
`, `,
) )
.get(videoId, startedAtMs, startedAtMs, currentSessionId) as ExistenceRow | null .get(videoId, startedAtDbMs, startedAtDbMs, currentSessionId) as ExistenceRow | null
)?.count ?? 0, )?.count ?? 0,
) > 0 ) > 0
); );
@@ -89,25 +93,25 @@ function hasRetainedPriorSession(
function isFirstSessionForLocalDay( function isFirstSessionForLocalDay(
db: DatabaseSync, db: DatabaseSync,
currentSessionId: number, currentSessionId: number,
startedAtMs: number, startedAtMs: number | string,
): boolean { ): boolean {
return ( const startedAtDbSeconds = toDbSeconds(startedAtMs);
const sameDayCount = Number(
( (
db db.prepare(`
.prepare(
`
SELECT COUNT(*) AS count SELECT COUNT(*) AS count
FROM imm_sessions FROM imm_sessions
WHERE date(started_at_ms / 1000, 'unixepoch', 'localtime') = date(? / 1000, 'unixepoch', 'localtime') WHERE date(started_at_ms / 1000, 'unixepoch', 'localtime') = date(?,'unixepoch','localtime')
AND ( AND (
started_at_ms < ? started_at_ms < ?
OR (started_at_ms = ? AND session_id < ?) OR (started_at_ms = ? AND session_id < ?)
) )
`, `,
) )
.get(startedAtMs, startedAtMs, startedAtMs, currentSessionId) as ExistenceRow | null .get(startedAtDbSeconds, toDbMs(startedAtMs), toDbMs(startedAtMs), currentSessionId) as ExistenceRow | null
)?.count === 0 )?.count ?? 0
); );
return sameDayCount === 0;
} }
function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void { function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void {
@@ -131,7 +135,7 @@ function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void {
LAST_UPDATE_DATE = ? LAST_UPDATE_DATE = ?
WHERE global_id = 1 WHERE global_id = 1
`, `,
).run(nowMs, nowMs); ).run(toDbMs(nowMs), toDbMs(nowMs));
} }
function rebuildLifetimeSummariesInternal( function rebuildLifetimeSummariesInternal(
@@ -144,8 +148,8 @@ function rebuildLifetimeSummariesInternal(
SELECT SELECT
session_id AS sessionId, session_id AS sessionId,
video_id AS videoId, video_id AS videoId,
started_at_ms AS startedAtMs, CAST(started_at_ms AS INTEGER) AS startedAtMs,
ended_at_ms AS endedAtMs, CAST(ended_at_ms AS INTEGER) AS endedAtMs,
total_watched_ms AS totalWatchedMs, total_watched_ms AS totalWatchedMs,
active_watched_ms AS activeWatchedMs, active_watched_ms AS activeWatchedMs,
lines_seen AS linesSeen, lines_seen AS linesSeen,
@@ -181,27 +185,27 @@ function toRebuildSessionState(row: RetainedSessionRow): SessionState {
return { return {
sessionId: row.sessionId, sessionId: row.sessionId,
videoId: row.videoId, videoId: row.videoId,
startedAtMs: row.startedAtMs, startedAtMs: row.startedAtMs as unknown as number,
currentLineIndex: 0, currentLineIndex: 0,
lastWallClockMs: row.endedAtMs, lastWallClockMs: row.endedAtMs as unknown as number,
lastMediaMs: row.lastMediaMs, lastMediaMs: row.lastMediaMs === null ? null : (row.lastMediaMs as unknown as number),
lastPauseStartMs: null, lastPauseStartMs: null,
isPaused: false, isPaused: false,
pendingTelemetry: false, pendingTelemetry: false,
markedWatched: false, markedWatched: false,
totalWatchedMs: Math.max(0, row.totalWatchedMs), totalWatchedMs: asPositiveNumber(row.totalWatchedMs, 0),
activeWatchedMs: Math.max(0, row.activeWatchedMs), activeWatchedMs: asPositiveNumber(row.activeWatchedMs, 0),
linesSeen: Math.max(0, row.linesSeen), linesSeen: asPositiveNumber(row.linesSeen, 0),
tokensSeen: Math.max(0, row.tokensSeen), tokensSeen: asPositiveNumber(row.tokensSeen, 0),
cardsMined: Math.max(0, row.cardsMined), cardsMined: asPositiveNumber(row.cardsMined, 0),
lookupCount: Math.max(0, row.lookupCount), lookupCount: asPositiveNumber(row.lookupCount, 0),
lookupHits: Math.max(0, row.lookupHits), lookupHits: asPositiveNumber(row.lookupHits, 0),
yomitanLookupCount: Math.max(0, row.yomitanLookupCount), yomitanLookupCount: asPositiveNumber(row.yomitanLookupCount, 0),
pauseCount: Math.max(0, row.pauseCount), pauseCount: asPositiveNumber(row.pauseCount, 0),
pauseMs: Math.max(0, row.pauseMs), pauseMs: asPositiveNumber(row.pauseMs, 0),
seekForwardCount: Math.max(0, row.seekForwardCount), seekForwardCount: asPositiveNumber(row.seekForwardCount, 0),
seekBackwardCount: Math.max(0, row.seekBackwardCount), seekBackwardCount: asPositiveNumber(row.seekBackwardCount, 0),
mediaBufferEvents: Math.max(0, row.mediaBufferEvents), mediaBufferEvents: asPositiveNumber(row.mediaBufferEvents, 0),
}; };
} }
@@ -212,8 +216,8 @@ function getRetainedStaleActiveSessions(db: DatabaseSync): RetainedSessionRow[]
SELECT SELECT
s.session_id AS sessionId, s.session_id AS sessionId,
s.video_id AS videoId, s.video_id AS videoId,
s.started_at_ms AS startedAtMs, CAST(s.started_at_ms AS INTEGER) AS startedAtMs,
COALESCE(t.sample_ms, s.LAST_UPDATE_DATE, s.started_at_ms) AS endedAtMs, CAST(COALESCE(t.sample_ms, s.LAST_UPDATE_DATE, s.started_at_ms) AS INTEGER) AS endedAtMs,
s.ended_media_ms AS lastMediaMs, s.ended_media_ms AS lastMediaMs,
COALESCE(t.total_watched_ms, s.total_watched_ms, 0) AS totalWatchedMs, COALESCE(t.total_watched_ms, s.total_watched_ms, 0) AS totalWatchedMs,
COALESCE(t.active_watched_ms, s.active_watched_ms, 0) AS activeWatchedMs, COALESCE(t.active_watched_ms, s.active_watched_ms, 0) AS activeWatchedMs,
@@ -247,14 +251,14 @@ function getRetainedStaleActiveSessions(db: DatabaseSync): RetainedSessionRow[]
function upsertLifetimeMedia( function upsertLifetimeMedia(
db: DatabaseSync, db: DatabaseSync,
videoId: number, videoId: number,
nowMs: number, nowMs: string,
activeMs: number, activeMs: number,
cardsMined: number, cardsMined: number,
linesSeen: number, linesSeen: number,
tokensSeen: number, tokensSeen: number,
completed: number, completed: number,
startedAtMs: number, startedAtMs: number | string,
endedAtMs: number, endedAtMs: number | string,
): void { ): void {
db.prepare( db.prepare(
` `
@@ -310,15 +314,15 @@ function upsertLifetimeMedia(
function upsertLifetimeAnime( function upsertLifetimeAnime(
db: DatabaseSync, db: DatabaseSync,
animeId: number, animeId: number,
nowMs: number, nowMs: string,
activeMs: number, activeMs: number,
cardsMined: number, cardsMined: number,
linesSeen: number, linesSeen: number,
tokensSeen: number, tokensSeen: number,
episodesStartedDelta: number, episodesStartedDelta: number,
episodesCompletedDelta: number, episodesCompletedDelta: number,
startedAtMs: number, startedAtMs: number | string,
endedAtMs: number, endedAtMs: number | string,
): void { ): void {
db.prepare( db.prepare(
` `
@@ -377,7 +381,7 @@ function upsertLifetimeAnime(
export function applySessionLifetimeSummary( export function applySessionLifetimeSummary(
db: DatabaseSync, db: DatabaseSync,
session: SessionState, session: SessionState,
endedAtMs: number, endedAtMs: number | string,
): void { ): void {
const applyResult = db const applyResult = db
.prepare( .prepare(
@@ -392,8 +396,8 @@ export function applySessionLifetimeSummary(
) )
ON CONFLICT(session_id) DO NOTHING ON CONFLICT(session_id) DO NOTHING
`, `,
) )
.run(session.sessionId, endedAtMs, nowMs(), nowMs()); .run(session.sessionId, toDbMs(endedAtMs), toDbMs(nowMs()), toDbMs(nowMs()));
if ((applyResult.changes ?? 0) <= 0) { if ((applyResult.changes ?? 0) <= 0) {
return; return;
@@ -468,7 +472,7 @@ export function applySessionLifetimeSummary(
? 1 ? 1
: 0; : 0;
const updatedAtMs = nowMs(); const updatedAtMs = toDbMs(nowMs());
db.prepare( db.prepare(
` `
UPDATE imm_lifetime_global UPDATE imm_lifetime_global

View File

@@ -54,9 +54,9 @@ test('pruneRawRetention uses session retention separately from telemetry retenti
`); `);
const result = pruneRawRetention(db, nowMs, { const result = pruneRawRetention(db, nowMs, {
eventsRetentionMs: 120_000_000, eventsRetentionMs: '120000000',
telemetryRetentionMs: 80_000_000, telemetryRetentionMs: '80000000',
sessionsRetentionMs: 300_000_000, sessionsRetentionMs: '300000000',
}); });
const remainingSessions = db const remainingSessions = db
@@ -129,9 +129,9 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
`); `);
pruneRawRetention(db, nowMs, { pruneRawRetention(db, nowMs, {
eventsRetentionMs: 120_000_000, eventsRetentionMs: '120000000',
telemetryRetentionMs: 120_000_000, telemetryRetentionMs: '120000000',
sessionsRetentionMs: 120_000_000, sessionsRetentionMs: '120000000',
}); });
const rollupsAfterRawPrune = db const rollupsAfterRawPrune = db
@@ -145,8 +145,8 @@ test('raw retention keeps rollups and rollup retention prunes them separately',
assert.equal(monthlyAfterRawPrune?.total, 1); assert.equal(monthlyAfterRawPrune?.total, 1);
const rollupPrune = pruneRollupRetention(db, nowMs, { const rollupPrune = pruneRollupRetention(db, nowMs, {
dailyRollupRetentionMs: 120_000_000, dailyRollupRetentionMs: '120000000',
monthlyRollupRetentionMs: 1, monthlyRollupRetentionMs: '1',
}); });
const rollupsAfterRollupPrune = db const rollupsAfterRollupPrune = db

View File

@@ -1,16 +1,13 @@
import type { DatabaseSync } from './sqlite'; import type { DatabaseSync } from './sqlite';
import { nowMs } from './time'; import { nowMs } from './time';
import { subtractDbMs, toDbMs, toDbSeconds } from './query-shared';
function toDbMs(ms: number | bigint): bigint {
return BigInt(Math.trunc(Number(ms)));
}
const ROLLUP_STATE_KEY = 'last_rollup_sample_ms'; const ROLLUP_STATE_KEY = 'last_rollup_sample_ms';
const DAILY_MS = 86_400_000; const DAILY_MS = 86_400_000;
const ZERO_ID = 0; const ZERO_ID = 0;
interface RollupStateRow { interface RollupStateRow {
state_value: number; state_value: string;
} }
interface RollupGroupRow { interface RollupGroupRow {
@@ -51,30 +48,35 @@ export function pruneRawRetention(
db: DatabaseSync, db: DatabaseSync,
nowMs: number, nowMs: number,
policy: { policy: {
eventsRetentionMs: number; eventsRetentionMs: string | null;
telemetryRetentionMs: number; telemetryRetentionMs: string | null;
sessionsRetentionMs: number; sessionsRetentionMs: string | null;
}, },
): RawRetentionResult { ): RawRetentionResult {
const eventCutoff = nowMs - policy.eventsRetentionMs; const deletedSessionEvents =
const telemetryCutoff = nowMs - policy.telemetryRetentionMs; policy.eventsRetentionMs === null
const sessionsCutoff = nowMs - policy.sessionsRetentionMs; ? 0
: (
const deletedSessionEvents = ( db
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(toDbMs(eventCutoff)) as { .prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`)
changes: number; .run(subtractDbMs(nowMs, policy.eventsRetentionMs)) as { changes: number }
} ).changes;
).changes; const deletedTelemetryRows =
const deletedTelemetryRows = ( policy.telemetryRetentionMs === null
db ? 0
.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`) : (
.run(toDbMs(telemetryCutoff)) as { changes: number } db
).changes; .prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`)
const deletedEndedSessions = ( .run(subtractDbMs(nowMs, policy.telemetryRetentionMs)) as { changes: number }
db ).changes;
.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`) const deletedEndedSessions =
.run(toDbMs(sessionsCutoff)) as { changes: number } policy.sessionsRetentionMs === null
).changes; ? 0
: (
db
.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`)
.run(subtractDbMs(nowMs, policy.sessionsRetentionMs)) as { changes: number }
).changes;
return { return {
deletedSessionEvents, deletedSessionEvents,
@@ -87,28 +89,40 @@ export function pruneRollupRetention(
db: DatabaseSync, db: DatabaseSync,
nowMs: number, nowMs: number,
policy: { policy: {
dailyRollupRetentionMs: number; dailyRollupRetentionMs: string | null;
monthlyRollupRetentionMs: number; monthlyRollupRetentionMs: string | null;
}, },
): { deletedDailyRows: number; deletedMonthlyRows: number } { ): { deletedDailyRows: number; deletedMonthlyRows: number } {
const deletedDailyRows = Number.isFinite(policy.dailyRollupRetentionMs) const currentMs = toDbMs(nowMs);
? ( const deletedDailyRows =
policy.dailyRollupRetentionMs === null
? 0
: (
db db
.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`) .prepare(
.run(Math.floor((nowMs - policy.dailyRollupRetentionMs) / DAILY_MS)) as { `DELETE FROM imm_daily_rollups
changes: number; WHERE rollup_day < CAST(julianday(date(?,'unixepoch','localtime')) - 2440587.5 AS INTEGER) - ?`,
} )
).changes .run(
: 0; toDbSeconds(currentMs),
const deletedMonthlyRows = Number.isFinite(policy.monthlyRollupRetentionMs) Number(BigInt(policy.dailyRollupRetentionMs) / BigInt(DAILY_MS)),
? ( ) as {
db changes: number;
.prepare(`DELETE FROM imm_monthly_rollups WHERE rollup_month < ?`) }
.run(toMonthKey(nowMs - policy.monthlyRollupRetentionMs)) as { ).changes;
changes: number; const deletedMonthlyRows =
} policy.monthlyRollupRetentionMs === null
).changes ? 0
: 0; : (
db
.prepare(
`DELETE FROM imm_monthly_rollups
WHERE rollup_month < CAST(strftime('%Y%m', ?,'unixepoch','localtime') AS INTEGER)`,
)
.run(toDbSeconds(subtractDbMs(currentMs, policy.monthlyRollupRetentionMs))) as {
changes: number;
}
).changes;
return { return {
deletedDailyRows, deletedDailyRows,
@@ -116,19 +130,19 @@ export function pruneRollupRetention(
}; };
} }
function getLastRollupSampleMs(db: DatabaseSync): number { function getLastRollupSampleMs(db: DatabaseSync): string {
const row = db const row = db
.prepare(`SELECT state_value FROM imm_rollup_state WHERE state_key = ? LIMIT 1`) .prepare(`SELECT state_value FROM imm_rollup_state WHERE state_key = ? LIMIT 1`)
.get(ROLLUP_STATE_KEY) as unknown as RollupStateRow | null; .get(ROLLUP_STATE_KEY) as unknown as RollupStateRow | null;
return row ? Number(row.state_value) : ZERO_ID; return row ? row.state_value : ZERO_ID.toString();
} }
function setLastRollupSampleMs(db: DatabaseSync, sampleMs: number | bigint): void { function setLastRollupSampleMs(db: DatabaseSync, sampleMs: string | number | bigint): void {
db.prepare( db.prepare(
`INSERT INTO imm_rollup_state (state_key, state_value) `INSERT INTO imm_rollup_state (state_key, state_value)
VALUES (?, ?) VALUES (?, ?)
ON CONFLICT(state_key) DO UPDATE SET state_value = excluded.state_value`, ON CONFLICT(state_key) DO UPDATE SET state_value = excluded.state_value`,
).run(ROLLUP_STATE_KEY, sampleMs); ).run(ROLLUP_STATE_KEY, toDbMs(sampleMs));
} }
function resetRollups(db: DatabaseSync): void { function resetRollups(db: DatabaseSync): void {
@@ -142,7 +156,7 @@ function resetRollups(db: DatabaseSync): void {
function upsertDailyRollupsForGroups( function upsertDailyRollupsForGroups(
db: DatabaseSync, db: DatabaseSync,
groups: Array<{ rollupDay: number; videoId: number }>, groups: Array<{ rollupDay: number; videoId: number }>,
rollupNowMs: bigint, rollupNowMs: string,
): void { ): void {
if (groups.length === 0) { if (groups.length === 0) {
return; return;
@@ -215,7 +229,7 @@ function upsertDailyRollupsForGroups(
function upsertMonthlyRollupsForGroups( function upsertMonthlyRollupsForGroups(
db: DatabaseSync, db: DatabaseSync,
groups: Array<{ rollupMonth: number; videoId: number }>, groups: Array<{ rollupMonth: number; videoId: number }>,
rollupNowMs: bigint, rollupNowMs: string,
): void { ): void {
if (groups.length === 0) { if (groups.length === 0) {
return; return;
@@ -266,7 +280,7 @@ function upsertMonthlyRollupsForGroups(
function getAffectedRollupGroups( function getAffectedRollupGroups(
db: DatabaseSync, db: DatabaseSync,
lastRollupSampleMs: number, lastRollupSampleMs: string,
): Array<{ rollupDay: number; rollupMonth: number; videoId: number }> { ): Array<{ rollupDay: number; rollupMonth: number; videoId: number }> {
return ( return (
db db
@@ -373,7 +387,7 @@ export function rebuildRollupsInTransaction(db: DatabaseSync): void {
return; return;
} }
const affectedGroups = getAffectedRollupGroups(db, ZERO_ID); const affectedGroups = getAffectedRollupGroups(db, ZERO_ID.toString());
if (affectedGroups.length === 0) { if (affectedGroups.length === 0) {
setLastRollupSampleMs(db, toDbMs(maxSampleRow.maxSampleMs ?? ZERO_ID)); setLastRollupSampleMs(db, toDbMs(maxSampleRow.maxSampleMs ?? ZERO_ID));
return; return;

View File

@@ -131,7 +131,7 @@ export function getSessionEvents(
): SessionEventRow[] { ): SessionEventRow[] {
if (!eventTypes || eventTypes.length === 0) { if (!eventTypes || eventTypes.length === 0) {
const stmt = db.prepare(` const stmt = db.prepare(`
SELECT event_type AS eventType, ts_ms AS tsMs, payload_json AS payload SELECT event_type AS eventType, CAST(ts_ms AS INTEGER) AS tsMs, payload_json AS payload
FROM imm_session_events WHERE session_id = ? ORDER BY ts_ms ASC LIMIT ? FROM imm_session_events WHERE session_id = ? ORDER BY ts_ms ASC LIMIT ?
`); `);
return stmt.all(sessionId, limit) as SessionEventRow[]; return stmt.all(sessionId, limit) as SessionEventRow[];
@@ -139,7 +139,7 @@ export function getSessionEvents(
const placeholders = eventTypes.map(() => '?').join(', '); const placeholders = eventTypes.map(() => '?').join(', ');
const stmt = db.prepare(` const stmt = db.prepare(`
SELECT event_type AS eventType, ts_ms AS tsMs, payload_json AS payload SELECT event_type AS eventType, CAST(ts_ms AS INTEGER) AS tsMs, payload_json AS payload
FROM imm_session_events FROM imm_session_events
WHERE session_id = ? AND event_type IN (${placeholders}) WHERE session_id = ? AND event_type IN (${placeholders})
ORDER BY ts_ms ASC ORDER BY ts_ms ASC

View File

@@ -16,7 +16,7 @@ import type {
StreakCalendarRow, StreakCalendarRow,
WatchTimePerAnimeRow, WatchTimePerAnimeRow,
} from './types'; } from './types';
import { ACTIVE_SESSION_METRICS_CTE, resolvedCoverBlobExpr } from './query-shared.js'; import { ACTIVE_SESSION_METRICS_CTE, resolvedCoverBlobExpr } from './query-shared';
export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] { export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
return db return db
@@ -32,7 +32,7 @@ export function getAnimeLibrary(db: DatabaseSync): AnimeLibraryRow[] {
COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen, COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen,
COUNT(DISTINCT v.video_id) AS episodeCount, COUNT(DISTINCT v.video_id) AS episodeCount,
a.episodes_total AS episodesTotal, a.episodes_total AS episodesTotal,
COALESCE(lm.last_watched_ms, 0) AS lastWatchedMs CAST(COALESCE(lm.last_watched_ms, 0) AS INTEGER) AS lastWatchedMs
FROM imm_anime a FROM imm_anime a
JOIN imm_lifetime_anime lm ON lm.anime_id = a.anime_id JOIN imm_lifetime_anime lm ON lm.anime_id = a.anime_id
JOIN imm_videos v ON v.anime_id = a.anime_id JOIN imm_videos v ON v.anime_id = a.anime_id
@@ -65,7 +65,7 @@ export function getAnimeDetail(db: DatabaseSync, animeId: number): AnimeDetailRo
COALESCE(SUM(COALESCE(asm.lookupHits, s.lookup_hits, 0)), 0) AS totalLookupHits, COALESCE(SUM(COALESCE(asm.lookupHits, s.lookup_hits, 0)), 0) AS totalLookupHits,
COALESCE(SUM(COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount, COALESCE(SUM(COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount,
COUNT(DISTINCT v.video_id) AS episodeCount, COUNT(DISTINCT v.video_id) AS episodeCount,
COALESCE(lm.last_watched_ms, 0) AS lastWatchedMs CAST(COALESCE(lm.last_watched_ms, 0) AS INTEGER) AS lastWatchedMs
FROM imm_anime a FROM imm_anime a
JOIN imm_lifetime_anime lm ON lm.anime_id = a.anime_id JOIN imm_lifetime_anime lm ON lm.anime_id = a.anime_id
JOIN imm_videos v ON v.anime_id = a.anime_id JOIN imm_videos v ON v.anime_id = a.anime_id
@@ -110,7 +110,7 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod
v.parsed_season AS season, v.parsed_season AS season,
v.parsed_episode AS episode, v.parsed_episode AS episode,
v.duration_ms AS durationMs, v.duration_ms AS durationMs,
( CAST((
SELECT COALESCE( SELECT COALESCE(
NULLIF(s_recent.ended_media_ms, 0), NULLIF(s_recent.ended_media_ms, 0),
( (
@@ -147,14 +147,14 @@ export function getAnimeEpisodes(db: DatabaseSync, animeId: number): AnimeEpisod
COALESCE(s_recent.ended_at_ms, s_recent.LAST_UPDATE_DATE, s_recent.started_at_ms) DESC, COALESCE(s_recent.ended_at_ms, s_recent.LAST_UPDATE_DATE, s_recent.started_at_ms) DESC,
s_recent.session_id DESC s_recent.session_id DESC
LIMIT 1 LIMIT 1
) AS endedMediaMs, ) AS INTEGER) AS endedMediaMs,
v.watched AS watched, v.watched AS watched,
COUNT(DISTINCT s.session_id) AS totalSessions, COUNT(DISTINCT s.session_id) AS totalSessions,
COALESCE(SUM(COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0)), 0) AS totalActiveMs, COALESCE(SUM(COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0)), 0) AS totalActiveMs,
COALESCE(SUM(COALESCE(asm.cardsMined, s.cards_mined, 0)), 0) AS totalCards, COALESCE(SUM(COALESCE(asm.cardsMined, s.cards_mined, 0)), 0) AS totalCards,
COALESCE(SUM(COALESCE(asm.tokensSeen, s.tokens_seen, 0)), 0) AS totalTokensSeen, COALESCE(SUM(COALESCE(asm.tokensSeen, s.tokens_seen, 0)), 0) AS totalTokensSeen,
COALESCE(SUM(COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount, COALESCE(SUM(COALESCE(asm.yomitanLookupCount, s.yomitan_lookup_count, 0)), 0) AS totalYomitanLookupCount,
MAX(s.started_at_ms) AS lastWatchedMs CAST(MAX(s.started_at_ms) AS INTEGER) AS lastWatchedMs
FROM imm_videos v FROM imm_videos v
LEFT JOIN imm_sessions s ON s.video_id = v.video_id LEFT JOIN imm_sessions s ON s.video_id = v.video_id
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
@@ -182,7 +182,7 @@ export function getMediaLibrary(db: DatabaseSync): MediaLibraryRow[] {
COALESCE(lm.total_active_ms, 0) AS totalActiveMs, COALESCE(lm.total_active_ms, 0) AS totalActiveMs,
COALESCE(lm.total_cards, 0) AS totalCards, COALESCE(lm.total_cards, 0) AS totalCards,
COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen, COALESCE(lm.total_tokens_seen, 0) AS totalTokensSeen,
COALESCE(lm.last_watched_ms, 0) AS lastWatchedMs, CAST(COALESCE(lm.last_watched_ms, 0) AS INTEGER) AS lastWatchedMs,
yv.youtube_video_id AS youtubeVideoId, yv.youtube_video_id AS youtubeVideoId,
yv.video_url AS videoUrl, yv.video_url AS videoUrl,
yv.video_title AS videoTitle, yv.video_title AS videoTitle,
@@ -261,8 +261,8 @@ export function getMediaSessions(
s.session_id AS sessionId, s.session_id AS sessionId,
s.video_id AS videoId, s.video_id AS videoId,
v.canonical_title AS canonicalTitle, v.canonical_title AS canonicalTitle,
s.started_at_ms AS startedAtMs, CAST(s.started_at_ms AS INTEGER) AS startedAtMs,
s.ended_at_ms AS endedAtMs, CAST(s.ended_at_ms AS INTEGER) AS endedAtMs,
COALESCE(asm.totalWatchedMs, s.total_watched_ms, 0) AS totalWatchedMs, COALESCE(asm.totalWatchedMs, s.total_watched_ms, 0) AS totalWatchedMs,
COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs, COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs,
COALESCE(asm.linesSeen, s.lines_seen, 0) AS linesSeen, COALESCE(asm.linesSeen, s.lines_seen, 0) AS linesSeen,
@@ -517,7 +517,7 @@ export function getEpisodeSessions(db: DatabaseSync, videoId: number): SessionSu
SELECT SELECT
s.session_id AS sessionId, s.video_id AS videoId, s.session_id AS sessionId, s.video_id AS videoId,
v.canonical_title AS canonicalTitle, v.canonical_title AS canonicalTitle,
s.started_at_ms AS startedAtMs, s.ended_at_ms AS endedAtMs, CAST(s.started_at_ms AS INTEGER) AS startedAtMs, CAST(s.ended_at_ms AS INTEGER) AS endedAtMs,
COALESCE(asm.totalWatchedMs, s.total_watched_ms, 0) AS totalWatchedMs, COALESCE(asm.totalWatchedMs, s.total_watched_ms, 0) AS totalWatchedMs,
COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs, COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs,
COALESCE(asm.linesSeen, s.lines_seen, 0) AS linesSeen, COALESCE(asm.linesSeen, s.lines_seen, 0) AS linesSeen,
@@ -541,7 +541,7 @@ export function getEpisodeCardEvents(db: DatabaseSync, videoId: number): Episode
.prepare( .prepare(
` `
SELECT e.event_id AS eventId, e.session_id AS sessionId, SELECT e.event_id AS eventId, e.session_id AS sessionId,
e.ts_ms AS tsMs, e.cards_delta AS cardsDelta, CAST(e.ts_ms AS INTEGER) AS tsMs, e.cards_delta AS cardsDelta,
e.payload_json AS payloadJson e.payload_json AS payloadJson
FROM imm_session_events e FROM imm_session_events e
JOIN imm_sessions s ON s.session_id = e.session_id JOIN imm_sessions s ON s.session_id = e.session_id

View File

@@ -16,7 +16,8 @@ import {
getAffectedWordIdsForSessions, getAffectedWordIdsForSessions,
getAffectedWordIdsForVideo, getAffectedWordIdsForVideo,
refreshLexicalAggregates, refreshLexicalAggregates,
} from './query-shared.js'; toDbMs,
} from './query-shared';
type CleanupVocabularyRow = { type CleanupVocabularyRow = {
id: number; id: number;
@@ -543,6 +544,3 @@ export function deleteVideo(db: DatabaseSync, videoId: number): void {
throw error; throw error;
} }
} }
function toDbMs(ms: number | bigint): bigint {
return BigInt(Math.trunc(Number(ms)));
}

View File

@@ -5,7 +5,13 @@ import type {
SessionSummaryQueryRow, SessionSummaryQueryRow,
SessionTimelineRow, SessionTimelineRow,
} from './types'; } from './types';
import { ACTIVE_SESSION_METRICS_CTE } from './query-shared.js'; import { ACTIVE_SESSION_METRICS_CTE, subtractDbMs, toDbMs, toDbSeconds } from './query-shared';
const THIRTY_DAYS_MS = '2592000000';
function localMidnightSecondsExpr(): string {
return `(CAST(strftime('%s', 'now') AS INTEGER) - CAST(strftime('%H', 'now', 'localtime') AS INTEGER) * 3600 - CAST(strftime('%M', 'now', 'localtime') AS INTEGER) * 60 - CAST(strftime('%S', 'now', 'localtime') AS INTEGER))`;
}
export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummaryQueryRow[] { export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummaryQueryRow[] {
const prepared = db.prepare(` const prepared = db.prepare(`
@@ -16,8 +22,8 @@ export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummar
v.canonical_title AS canonicalTitle, v.canonical_title AS canonicalTitle,
v.anime_id AS animeId, v.anime_id AS animeId,
a.canonical_title AS animeTitle, a.canonical_title AS animeTitle,
s.started_at_ms AS startedAtMs, CAST(s.started_at_ms AS INTEGER) AS startedAtMs,
s.ended_at_ms AS endedAtMs, CAST(s.ended_at_ms AS INTEGER) AS endedAtMs,
COALESCE(asm.totalWatchedMs, s.total_watched_ms, 0) AS totalWatchedMs, COALESCE(asm.totalWatchedMs, s.total_watched_ms, 0) AS totalWatchedMs,
COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs, COALESCE(asm.activeWatchedMs, s.active_watched_ms, 0) AS activeWatchedMs,
COALESCE(asm.linesSeen, s.lines_seen, 0) AS linesSeen, COALESCE(asm.linesSeen, s.lines_seen, 0) AS linesSeen,
@@ -43,7 +49,7 @@ export function getSessionTimeline(
): SessionTimelineRow[] { ): SessionTimelineRow[] {
const select = ` const select = `
SELECT SELECT
sample_ms AS sampleMs, CAST(sample_ms AS INTEGER) AS sampleMs,
total_watched_ms AS totalWatchedMs, total_watched_ms AS totalWatchedMs,
active_watched_ms AS activeWatchedMs, active_watched_ms AS activeWatchedMs,
lines_seen AS linesSeen, lines_seen AS linesSeen,
@@ -129,18 +135,13 @@ export function getSessionWordsByLine(
} }
function getNewWordCounts(db: DatabaseSync): { newWordsToday: number; newWordsThisWeek: number } { function getNewWordCounts(db: DatabaseSync): { newWordsToday: number; newWordsThisWeek: number } {
const now = new Date();
const todayStartSec = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000;
const weekAgoSec =
new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7).getTime() / 1000;
const row = db const row = db
.prepare( .prepare(
` `
WITH headword_first_seen AS ( WITH headword_first_seen AS (
SELECT SELECT
headword, headword,
MIN(first_seen) AS first_seen CAST(MIN(first_seen) AS INTEGER) AS first_seen
FROM imm_words FROM imm_words
WHERE first_seen IS NOT NULL WHERE first_seen IS NOT NULL
AND headword IS NOT NULL AND headword IS NOT NULL
@@ -148,13 +149,12 @@ function getNewWordCounts(db: DatabaseSync): { newWordsToday: number; newWordsTh
GROUP BY headword GROUP BY headword
) )
SELECT SELECT
COALESCE(SUM(CASE WHEN first_seen >= ? THEN 1 ELSE 0 END), 0) AS today, COALESCE(SUM(CASE WHEN first_seen >= (${localMidnightSecondsExpr()}) THEN 1 ELSE 0 END), 0) AS today,
COALESCE(SUM(CASE WHEN first_seen >= ? THEN 1 ELSE 0 END), 0) AS week COALESCE(SUM(CASE WHEN first_seen >= (${localMidnightSecondsExpr()} - 7 * 86400) THEN 1 ELSE 0 END), 0) AS week
FROM headword_first_seen FROM headword_first_seen
`, `,
) )
.get(todayStartSec, weekAgoSec) as { today: number; week: number } | null; .get() as { today: number; week: number } | null;
return { return {
newWordsToday: Number(row?.today ?? 0), newWordsToday: Number(row?.today ?? 0),
newWordsThisWeek: Number(row?.week ?? 0), newWordsThisWeek: Number(row?.week ?? 0),
@@ -203,10 +203,7 @@ export function getQueryHints(db: DatabaseSync): {
animeCompleted: number; animeCompleted: number;
} | null; } | null;
const now = new Date(); const nowSeconds = (BigInt(toDbMs(nowMs())) / 1000n).toString();
const todayLocal = Math.floor(
(now.getTime() / 1000 - now.getTimezoneOffset() * 60) / 86_400,
);
const episodesToday = const episodesToday =
( (
@@ -215,13 +212,13 @@ export function getQueryHints(db: DatabaseSync): {
` `
SELECT COUNT(DISTINCT s.video_id) AS count SELECT COUNT(DISTINCT s.video_id) AS count
FROM imm_sessions s FROM imm_sessions s
WHERE CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) = ? WHERE date(s.started_at_ms / 1000, 'unixepoch', 'localtime') = date(?,'unixepoch','localtime')
`, `,
) )
.get(todayLocal) as { count: number } .get(nowSeconds) as { count: number }
)?.count ?? 0; )?.count ?? 0;
const thirtyDaysAgoMs = nowMs() - 30 * 86400000; const activeAnimeCutoffMs = subtractDbMs(toDbMs(nowMs()), `${THIRTY_DAYS_MS}`);
const activeAnimeCount = const activeAnimeCount =
( (
db db
@@ -234,7 +231,7 @@ export function getQueryHints(db: DatabaseSync): {
AND s.started_at_ms >= ? AND s.started_at_ms >= ?
`, `,
) )
.get(thirtyDaysAgoMs) as { count: number } .get(activeAnimeCutoffMs) as { count: number }
)?.count ?? 0; )?.count ?? 0;
const totalEpisodesWatched = Number(lifetime?.episodesCompleted ?? 0); const totalEpisodesWatched = Number(lifetime?.episodesCompleted ?? 0);

View File

@@ -89,72 +89,61 @@ export function findSharedCoverBlobHash(
return null; return null;
} }
export function getAffectedWordIdsForSessions(db: DatabaseSync, sessionIds: number[]): number[] { type LexicalEntity = 'word' | 'kanji';
if (sessionIds.length === 0) {
return [];
}
function getAffectedIdsForSessions(
db: DatabaseSync,
entity: LexicalEntity,
sessionIds: number[],
): number[] {
if (sessionIds.length === 0) return [];
const table = entity === 'word' ? 'imm_word_line_occurrences' : 'imm_kanji_line_occurrences';
const col = `${entity}_id`;
return ( return (
db db
.prepare( .prepare(
` `SELECT DISTINCT o.${col} AS id
SELECT DISTINCT o.word_id AS wordId FROM ${table} o
FROM imm_word_line_occurrences o JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id WHERE sl.session_id IN (${makePlaceholders(sessionIds)})`,
WHERE sl.session_id IN (${makePlaceholders(sessionIds)})
`,
) )
.all(...sessionIds) as Array<{ wordId: number }> .all(...sessionIds) as Array<{ id: number }>
).map((row) => row.wordId); ).map((row) => row.id);
}
function getAffectedIdsForVideo(
db: DatabaseSync,
entity: LexicalEntity,
videoId: number,
): number[] {
const table = entity === 'word' ? 'imm_word_line_occurrences' : 'imm_kanji_line_occurrences';
const col = `${entity}_id`;
return (
db
.prepare(
`SELECT DISTINCT o.${col} AS id
FROM ${table} o
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE sl.video_id = ?`,
)
.all(videoId) as Array<{ id: number }>
).map((row) => row.id);
}
export function getAffectedWordIdsForSessions(db: DatabaseSync, sessionIds: number[]): number[] {
return getAffectedIdsForSessions(db, 'word', sessionIds);
} }
export function getAffectedKanjiIdsForSessions(db: DatabaseSync, sessionIds: number[]): number[] { export function getAffectedKanjiIdsForSessions(db: DatabaseSync, sessionIds: number[]): number[] {
if (sessionIds.length === 0) { return getAffectedIdsForSessions(db, 'kanji', sessionIds);
return [];
}
return (
db
.prepare(
`
SELECT DISTINCT o.kanji_id AS kanjiId
FROM imm_kanji_line_occurrences o
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE sl.session_id IN (${makePlaceholders(sessionIds)})
`,
)
.all(...sessionIds) as Array<{ kanjiId: number }>
).map((row) => row.kanjiId);
} }
export function getAffectedWordIdsForVideo(db: DatabaseSync, videoId: number): number[] { export function getAffectedWordIdsForVideo(db: DatabaseSync, videoId: number): number[] {
return ( return getAffectedIdsForVideo(db, 'word', videoId);
db
.prepare(
`
SELECT DISTINCT o.word_id AS wordId
FROM imm_word_line_occurrences o
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE sl.video_id = ?
`,
)
.all(videoId) as Array<{ wordId: number }>
).map((row) => row.wordId);
} }
export function getAffectedKanjiIdsForVideo(db: DatabaseSync, videoId: number): number[] { export function getAffectedKanjiIdsForVideo(db: DatabaseSync, videoId: number): number[] {
return ( return getAffectedIdsForVideo(db, 'kanji', videoId);
db
.prepare(
`
SELECT DISTINCT o.kanji_id AS kanjiId
FROM imm_kanji_line_occurrences o
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
WHERE sl.video_id = ?
`,
)
.all(videoId) as Array<{ kanjiId: number }>
).map((row) => row.kanjiId);
} }
function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void { function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
@@ -281,3 +270,29 @@ export function deleteSessionsByIds(db: DatabaseSync, sessionIds: number[]): voi
); );
db.prepare(`DELETE FROM imm_sessions WHERE session_id IN (${placeholders})`).run(...sessionIds); db.prepare(`DELETE FROM imm_sessions WHERE session_id IN (${placeholders})`).run(...sessionIds);
} }
export function toDbMs(ms: number | bigint | string): string {
if (typeof ms === 'bigint') {
return ms.toString();
}
if (typeof ms === 'string') {
const text = ms.trim().replace(/\.0+$/, '');
return /^-?\d+$/.test(text) ? text : '0';
}
if (!Number.isFinite(ms)) {
return '0';
}
return ms.toFixed(0);
}
export function toDbSeconds(ms: number | bigint | string): string {
const dbMs = toDbMs(ms);
if (dbMs === '0') {
return '0';
}
return (BigInt(dbMs) / 1000n).toString();
}
export function subtractDbMs(timestampMs: number | bigint | string, deltaMs: number | string): string {
return (BigInt(toDbMs(timestampMs)) - BigInt(`${deltaMs}`)).toString();
}

View File

@@ -1,7 +1,7 @@
import type { DatabaseSync } from './sqlite'; import type { DatabaseSync } from './sqlite';
import type { ImmersionSessionRollupRow } from './types'; import type { ImmersionSessionRollupRow } from './types';
import { ACTIVE_SESSION_METRICS_CTE, makePlaceholders } from './query-shared.js'; import { ACTIVE_SESSION_METRICS_CTE, makePlaceholders } from './query-shared';
import { getDailyRollups, getMonthlyRollups } from './query-sessions.js'; import { getDailyRollups, getMonthlyRollups } from './query-sessions';
type TrendRange = '7d' | '30d' | '90d' | 'all'; type TrendRange = '7d' | '30d' | '90d' | 'all';
type TrendGroupBy = 'day' | 'month'; type TrendGroupBy = 'day' | 'month';
@@ -19,6 +19,10 @@ interface TrendPerAnimePoint {
interface TrendSessionMetricRow { interface TrendSessionMetricRow {
startedAtMs: number; startedAtMs: number;
localEpochDay: number;
localMonthKey: number;
localDayOfWeek: number;
localHour: number;
videoId: number | null; videoId: number | null;
canonicalTitle: string | null; canonicalTitle: string | null;
animeTitle: string | null; animeTitle: string | null;
@@ -74,63 +78,60 @@ const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
}; };
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
function getTrendDayLimit(range: TrendRange): number { function getTrendDayLimit(range: TrendRange): number {
return range === 'all' ? 365 : TREND_DAY_LIMITS[range]; return range === 'all' ? 365 : TREND_DAY_LIMITS[range];
} }
function getTrendMonthlyLimit(range: TrendRange): number { function getTrendMonthlyLimit(range: TrendRange): number {
if (range === 'all') { switch (range) {
return 120; case 'all':
return 120;
case '7d':
return 1;
case '30d':
return 2;
case '90d':
return 4;
} }
const now = new Date(); }
const cutoff = new Date(
now.getFullYear(), function epochDayToCivil(epochDay: number): { year: number; month: number; day: number } {
now.getMonth(), const z = epochDay + 719468;
now.getDate() - (TREND_DAY_LIMITS[range] - 1), const era = Math.floor(z / 146097);
const doe = z - era * 146097;
const yoe = Math.floor(
(doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365,
); );
return Math.max(1, (now.getFullYear() - cutoff.getFullYear()) * 12 + now.getMonth() - cutoff.getMonth() + 1); let year = yoe + era * 400;
} const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100));
const mp = Math.floor((5 * doy + 2) / 153);
function getTrendCutoffMs(range: TrendRange): number | null { const day = doy - Math.floor((153 * mp + 2) / 5) + 1;
if (range === 'all') { const month = mp < 10 ? mp + 3 : mp - 9;
return null; if (month <= 2) {
year += 1;
} }
const dayLimit = getTrendDayLimit(range); return { year, month, day };
const now = new Date();
const localMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
return localMidnight - (dayLimit - 1) * 86_400_000;
} }
function makeTrendLabel(value: number): string { function formatEpochDayLabel(epochDay: number): string {
if (value > 100_000) { const { month, day } = epochDayToCivil(epochDay);
const year = Math.floor(value / 100); return `${MONTH_NAMES[month - 1]} ${day}`;
const month = value % 100;
return new Date(Date.UTC(year, month - 1, 1)).toLocaleDateString(undefined, {
month: 'short',
year: '2-digit',
});
}
return new Date(value * 86_400_000).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
} }
function getLocalEpochDay(timestampMs: number): number { function formatMonthKeyLabel(monthKey: number): string {
const date = new Date(timestampMs); const year = Math.floor(monthKey / 100);
return Math.floor((timestampMs - date.getTimezoneOffset() * 60_000) / 86_400_000); const month = monthKey % 100;
return `${MONTH_NAMES[month - 1]} ${String(year).slice(-2)}`;
} }
function getLocalDateForEpochDay(epochDay: number): Date { function formatTrendLabel(value: number): string {
const utcDate = new Date(epochDay * 86_400_000); return value > 100_000 ? formatMonthKeyLabel(value) : formatEpochDayLabel(value);
return new Date(utcDate.getTime() + utcDate.getTimezoneOffset() * 60_000);
} }
function getLocalMonthKey(timestampMs: number): number { function localMidnightSecondsExpr(): string {
const date = new Date(timestampMs); return `(CAST(strftime('%s', 'now') AS INTEGER) - CAST(strftime('%H', 'now', 'localtime') AS INTEGER) * 3600 - CAST(strftime('%M', 'now', 'localtime') AS INTEGER) * 60 - CAST(strftime('%S', 'now', 'localtime') AS INTEGER))`;
return date.getFullYear() * 100 + date.getMonth() + 1;
} }
function getTrendSessionWordCount(session: Pick<TrendSessionMetricRow, 'tokensSeen'>): number { function getTrendSessionWordCount(session: Pick<TrendSessionMetricRow, 'tokensSeen'>): number {
@@ -178,7 +179,7 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
return Array.from(byKey.entries()) return Array.from(byKey.entries())
.sort(([left], [right]) => left - right) .sort(([left], [right]) => left - right)
.map(([key, value]) => ({ .map(([key, value]) => ({
label: makeTrendLabel(key), label: formatTrendLabel(key),
activeMin: value.activeMin, activeMin: value.activeMin,
cards: value.cards, cards: value.cards,
words: value.words, words: value.words,
@@ -189,7 +190,7 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChartPoint[] { function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
const totals = new Array(7).fill(0); const totals = new Array(7).fill(0);
for (const session of sessions) { for (const session of sessions) {
totals[new Date(session.startedAtMs).getDay()] += session.activeWatchedMs; totals[session.localDayOfWeek] += session.activeWatchedMs;
} }
return DAY_NAMES.map((name, index) => ({ return DAY_NAMES.map((name, index) => ({
label: name, label: name,
@@ -200,7 +201,7 @@ function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChar
function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoint[] { function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
const totals = new Array(24).fill(0); const totals = new Array(24).fill(0);
for (const session of sessions) { for (const session of sessions) {
totals[new Date(session.startedAtMs).getHours()] += session.activeWatchedMs; totals[session.localHour] += session.activeWatchedMs;
} }
return totals.map((ms, index) => ({ return totals.map((ms, index) => ({
label: `${String(index).padStart(2, '0')}:00`, label: `${String(index).padStart(2, '0')}:00`,
@@ -208,25 +209,18 @@ function buildWatchTimeByHour(sessions: TrendSessionMetricRow[]): TrendChartPoin
})); }));
} }
function dayLabel(epochDay: number): string {
return getLocalDateForEpochDay(epochDay).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
}
function buildSessionSeriesByDay( function buildSessionSeriesByDay(
sessions: TrendSessionMetricRow[], sessions: TrendSessionMetricRow[],
getValue: (session: TrendSessionMetricRow) => number, getValue: (session: TrendSessionMetricRow) => number,
): TrendChartPoint[] { ): TrendChartPoint[] {
const byDay = new Map<number, number>(); const byDay = new Map<number, number>();
for (const session of sessions) { for (const session of sessions) {
const epochDay = getLocalEpochDay(session.startedAtMs); const epochDay = session.localEpochDay;
byDay.set(epochDay, (byDay.get(epochDay) ?? 0) + getValue(session)); byDay.set(epochDay, (byDay.get(epochDay) ?? 0) + getValue(session));
} }
return Array.from(byDay.entries()) return Array.from(byDay.entries())
.sort(([left], [right]) => left - right) .sort(([left], [right]) => left - right)
.map(([epochDay, value]) => ({ label: dayLabel(epochDay), value })); .map(([epochDay, value]) => ({ label: formatEpochDayLabel(epochDay), value }));
} }
function buildSessionSeriesByMonth( function buildSessionSeriesByMonth(
@@ -235,12 +229,12 @@ function buildSessionSeriesByMonth(
): TrendChartPoint[] { ): TrendChartPoint[] {
const byMonth = new Map<number, number>(); const byMonth = new Map<number, number>();
for (const session of sessions) { for (const session of sessions) {
const monthKey = getLocalMonthKey(session.startedAtMs); const monthKey = session.localMonthKey;
byMonth.set(monthKey, (byMonth.get(monthKey) ?? 0) + getValue(session)); byMonth.set(monthKey, (byMonth.get(monthKey) ?? 0) + getValue(session));
} }
return Array.from(byMonth.entries()) return Array.from(byMonth.entries())
.sort(([left], [right]) => left - right) .sort(([left], [right]) => left - right)
.map(([monthKey, value]) => ({ label: makeTrendLabel(monthKey), value })); .map(([monthKey, value]) => ({ label: formatMonthKeyLabel(monthKey), value }));
} }
function buildLookupsPerHundredWords(sessions: TrendSessionMetricRow[]): TrendChartPoint[] { function buildLookupsPerHundredWords(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
@@ -248,7 +242,7 @@ function buildLookupsPerHundredWords(sessions: TrendSessionMetricRow[]): TrendCh
const wordsByDay = new Map<number, number>(); const wordsByDay = new Map<number, number>();
for (const session of sessions) { for (const session of sessions) {
const epochDay = getLocalEpochDay(session.startedAtMs); const epochDay = session.localEpochDay;
lookupsByDay.set(epochDay, (lookupsByDay.get(epochDay) ?? 0) + session.yomitanLookupCount); lookupsByDay.set(epochDay, (lookupsByDay.get(epochDay) ?? 0) + session.yomitanLookupCount);
wordsByDay.set(epochDay, (wordsByDay.get(epochDay) ?? 0) + getTrendSessionWordCount(session)); wordsByDay.set(epochDay, (wordsByDay.get(epochDay) ?? 0) + getTrendSessionWordCount(session));
} }
@@ -258,7 +252,7 @@ function buildLookupsPerHundredWords(sessions: TrendSessionMetricRow[]): TrendCh
.map(([epochDay, lookups]) => { .map(([epochDay, lookups]) => {
const words = wordsByDay.get(epochDay) ?? 0; const words = wordsByDay.get(epochDay) ?? 0;
return { return {
label: dayLabel(epochDay), label: formatEpochDayLabel(epochDay),
value: words > 0 ? +((lookups / words) * 100).toFixed(1) : 0, value: words > 0 ? +((lookups / words) * 100).toFixed(1) : 0,
}; };
}); });
@@ -272,7 +266,7 @@ function buildPerAnimeFromSessions(
for (const session of sessions) { for (const session of sessions) {
const animeTitle = resolveTrendAnimeTitle(session); const animeTitle = resolveTrendAnimeTitle(session);
const epochDay = getLocalEpochDay(session.startedAtMs); const epochDay = session.localEpochDay;
const dayMap = byAnime.get(animeTitle) ?? new Map(); const dayMap = byAnime.get(animeTitle) ?? new Map();
dayMap.set(epochDay, (dayMap.get(epochDay) ?? 0) + getValue(session)); dayMap.set(epochDay, (dayMap.get(epochDay) ?? 0) + getValue(session));
byAnime.set(animeTitle, dayMap); byAnime.set(animeTitle, dayMap);
@@ -293,7 +287,7 @@ function buildLookupsPerHundredPerAnime(sessions: TrendSessionMetricRow[]): Tren
for (const session of sessions) { for (const session of sessions) {
const animeTitle = resolveTrendAnimeTitle(session); const animeTitle = resolveTrendAnimeTitle(session);
const epochDay = getLocalEpochDay(session.startedAtMs); const epochDay = session.localEpochDay;
const lookupMap = lookups.get(animeTitle) ?? new Map(); const lookupMap = lookups.get(animeTitle) ?? new Map();
lookupMap.set(epochDay, (lookupMap.get(epochDay) ?? 0) + session.yomitanLookupCount); lookupMap.set(epochDay, (lookupMap.get(epochDay) ?? 0) + session.yomitanLookupCount);
@@ -461,7 +455,7 @@ function buildEpisodesPerDayFromDailyRollups(
return Array.from(byDay.entries()) return Array.from(byDay.entries())
.sort(([left], [right]) => left - right) .sort(([left], [right]) => left - right)
.map(([epochDay, videoIds]) => ({ .map(([epochDay, videoIds]) => ({
label: dayLabel(epochDay), label: formatEpochDayLabel(epochDay),
value: videoIds.size, value: videoIds.size,
})); }));
} }
@@ -481,20 +475,25 @@ function buildEpisodesPerMonthFromRollups(rollups: ImmersionSessionRollupRow[]):
return Array.from(byMonth.entries()) return Array.from(byMonth.entries())
.sort(([left], [right]) => left - right) .sort(([left], [right]) => left - right)
.map(([monthKey, videoIds]) => ({ .map(([monthKey, videoIds]) => ({
label: makeTrendLabel(monthKey), label: formatTrendLabel(monthKey),
value: videoIds.size, value: videoIds.size,
})); }));
} }
function getTrendSessionMetrics( function getTrendSessionMetrics(db: DatabaseSync, range: TrendRange): TrendSessionMetricRow[] {
db: DatabaseSync, const dayLimit = getTrendDayLimit(range);
cutoffMs: number | null, const cutoffClause =
): TrendSessionMetricRow[] { range === 'all'
const whereClause = cutoffMs === null ? '' : 'WHERE s.started_at_ms >= ?'; ? ''
: `WHERE CAST(s.started_at_ms AS INTEGER) >= (${localMidnightSecondsExpr()} - ${(dayLimit - 1) * 86400}) * 1000`;
const prepared = db.prepare(` const prepared = db.prepare(`
${ACTIVE_SESSION_METRICS_CTE} ${ACTIVE_SESSION_METRICS_CTE}
SELECT SELECT
s.started_at_ms AS startedAtMs, CAST(s.started_at_ms AS INTEGER) AS startedAtMs,
CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS localEpochDay,
CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch', 'localtime') AS INTEGER) AS localMonthKey,
CAST(strftime('%w', s.started_at_ms / 1000, 'unixepoch', 'localtime') AS INTEGER) AS localDayOfWeek,
CAST(strftime('%H', s.started_at_ms / 1000, 'unixepoch', 'localtime') AS INTEGER) AS localHour,
s.video_id AS videoId, s.video_id AS videoId,
v.canonical_title AS canonicalTitle, v.canonical_title AS canonicalTitle,
a.canonical_title AS animeTitle, a.canonical_title AS animeTitle,
@@ -506,61 +505,79 @@ function getTrendSessionMetrics(
LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id LEFT JOIN active_session_metrics asm ON asm.sessionId = s.session_id
LEFT JOIN imm_videos v ON v.video_id = s.video_id LEFT JOIN imm_videos v ON v.video_id = s.video_id
LEFT JOIN imm_anime a ON a.anime_id = v.anime_id LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
${whereClause} ${cutoffClause}
ORDER BY s.started_at_ms ASC ORDER BY CAST(s.started_at_ms AS INTEGER) ASC
`); `);
return (cutoffMs === null ? prepared.all() : prepared.all(cutoffMs)) as TrendSessionMetricRow[]; const rows = prepared.all() as Array<{
startedAtMs: number | string;
localEpochDay: number | string;
localMonthKey: number | string;
localDayOfWeek: number | string;
localHour: number | string;
videoId: number | null;
canonicalTitle: string | null;
animeTitle: string | null;
activeWatchedMs: number | string;
tokensSeen: number | string;
cardsMined: number | string;
yomitanLookupCount: number | string;
}>;
return rows.map((row) => ({
startedAtMs: Number.parseInt(String(row.startedAtMs), 10),
localEpochDay: Number.parseInt(String(row.localEpochDay), 10),
localMonthKey: Number.parseInt(String(row.localMonthKey), 10),
localDayOfWeek: Number.parseInt(String(row.localDayOfWeek), 10),
localHour: Number.parseInt(String(row.localHour), 10),
videoId: row.videoId,
canonicalTitle: row.canonicalTitle,
animeTitle: row.animeTitle,
activeWatchedMs: Number(row.activeWatchedMs),
tokensSeen: Number(row.tokensSeen),
cardsMined: Number(row.cardsMined),
yomitanLookupCount: Number(row.yomitanLookupCount),
}));
} }
function buildNewWordsPerDay(db: DatabaseSync, cutoffMs: number | null): TrendChartPoint[] { function buildNewWordsPerDay(db: DatabaseSync, dayLimit: number | null): TrendChartPoint[] {
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?'; const cutoffExpr =
dayLimit === null ? '' : `AND CAST(first_seen AS INTEGER) >= (${localMidnightSecondsExpr()} - ${(dayLimit - 1) * 86400})`;
const prepared = db.prepare(` const prepared = db.prepare(`
SELECT SELECT
CAST(julianday(first_seen, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS epochDay, CAST(julianday(first_seen, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS epochDay,
COUNT(*) AS wordCount COUNT(*) AS wordCount
FROM imm_words FROM imm_words
WHERE first_seen IS NOT NULL WHERE first_seen IS NOT NULL
${whereClause} ${cutoffExpr}
GROUP BY epochDay GROUP BY epochDay
ORDER BY epochDay ASC ORDER BY epochDay ASC
`); `);
const rows = prepared.all() as Array<{ epochDay: number; wordCount: number }>;
const rows = (
cutoffMs === null ? prepared.all() : prepared.all(Math.floor(cutoffMs / 1000))
) as Array<{
epochDay: number;
wordCount: number;
}>;
return rows.map((row) => ({ return rows.map((row) => ({
label: dayLabel(row.epochDay), label: formatEpochDayLabel(row.epochDay),
value: row.wordCount, value: row.wordCount,
})); }));
} }
function buildNewWordsPerMonth(db: DatabaseSync, cutoffMs: number | null): TrendChartPoint[] { function buildNewWordsPerMonth(db: DatabaseSync, dayLimit: number | null): TrendChartPoint[] {
const whereClause = cutoffMs === null ? '' : 'AND first_seen >= ?'; const cutoffExpr =
dayLimit === null ? '' : `AND CAST(first_seen AS INTEGER) >= (${localMidnightSecondsExpr()} - ${(dayLimit - 1) * 86400})`;
const prepared = db.prepare(` const prepared = db.prepare(`
SELECT SELECT
CAST(strftime('%Y%m', first_seen, 'unixepoch', 'localtime') AS INTEGER) AS monthKey, CAST(strftime('%Y%m', first_seen, 'unixepoch', 'localtime') AS INTEGER) AS monthKey,
COUNT(*) AS wordCount COUNT(*) AS wordCount
FROM imm_words FROM imm_words
WHERE first_seen IS NOT NULL WHERE first_seen IS NOT NULL
${whereClause} ${cutoffExpr}
GROUP BY monthKey GROUP BY monthKey
ORDER BY monthKey ASC ORDER BY monthKey ASC
`); `);
const rows = prepared.all() as Array<{ monthKey: number; wordCount: number }>;
const rows = (
cutoffMs === null ? prepared.all() : prepared.all(Math.floor(cutoffMs / 1000))
) as Array<{
monthKey: number;
wordCount: number;
}>;
return rows.map((row) => ({ return rows.map((row) => ({
label: makeTrendLabel(row.monthKey), label: formatMonthKeyLabel(row.monthKey),
value: row.wordCount, value: row.wordCount,
})); }));
} }
@@ -572,13 +589,12 @@ export function getTrendsDashboard(
): TrendsDashboardQueryResult { ): TrendsDashboardQueryResult {
const dayLimit = getTrendDayLimit(range); const dayLimit = getTrendDayLimit(range);
const monthlyLimit = getTrendMonthlyLimit(range); const monthlyLimit = getTrendMonthlyLimit(range);
const cutoffMs = getTrendCutoffMs(range);
const useMonthlyBuckets = groupBy === 'month'; const useMonthlyBuckets = groupBy === 'month';
const dailyRollups = getDailyRollups(db, dayLimit); const dailyRollups = getDailyRollups(db, dayLimit);
const monthlyRollups = getMonthlyRollups(db, monthlyLimit); const monthlyRollups = getMonthlyRollups(db, monthlyLimit);
const chartRollups = useMonthlyBuckets ? monthlyRollups : dailyRollups; const chartRollups = useMonthlyBuckets ? monthlyRollups : dailyRollups;
const sessions = getTrendSessionMetrics(db, cutoffMs); const sessions = getTrendSessionMetrics(db, range);
const titlesByVideoId = getVideoAnimeTitleMap( const titlesByVideoId = getVideoAnimeTitleMap(
db, db,
dailyRollups.map((rollup) => rollup.videoId), dailyRollups.map((rollup) => rollup.videoId),
@@ -618,7 +634,7 @@ export function getTrendsDashboard(
sessions: accumulatePoints(activity.sessions), sessions: accumulatePoints(activity.sessions),
words: accumulatePoints(activity.words), words: accumulatePoints(activity.words),
newWords: accumulatePoints( newWords: accumulatePoints(
useMonthlyBuckets ? buildNewWordsPerMonth(db, cutoffMs) : buildNewWordsPerDay(db, cutoffMs), useMonthlyBuckets ? buildNewWordsPerMonth(db, range === 'all' ? null : dayLimit) : buildNewWordsPerDay(db, range === 'all' ? null : dayLimit),
), ),
cards: accumulatePoints(activity.cards), cards: accumulatePoints(activity.cards),
episodes: accumulatePoints( episodes: accumulatePoints(

View File

@@ -1,5 +1,5 @@
export * from './query-sessions.js'; export * from './query-sessions';
export * from './query-trends.js'; export * from './query-trends';
export * from './query-lexical.js'; export * from './query-lexical';
export * from './query-library.js'; export * from './query-library';
export * from './query-maintenance.js'; export * from './query-maintenance';

View File

@@ -4,10 +4,7 @@ import { createInitialSessionState } from './reducer';
import { nowMs } from './time'; import { nowMs } from './time';
import { SESSION_STATUS_ACTIVE, SESSION_STATUS_ENDED } from './types'; import { SESSION_STATUS_ACTIVE, SESSION_STATUS_ENDED } from './types';
import type { SessionState } from './types'; import type { SessionState } from './types';
import { toDbMs } from './query-shared';
function toDbMs(ms: number | bigint): bigint {
return BigInt(Math.trunc(Number(ms)));
}
export function startSessionRecord( export function startSessionRecord(
db: DatabaseSync, db: DatabaseSync,
@@ -43,7 +40,7 @@ export function startSessionRecord(
export function finalizeSessionRecord( export function finalizeSessionRecord(
db: DatabaseSync, db: DatabaseSync,
sessionState: SessionState, sessionState: SessionState,
endedAtMs = nowMs(), endedAtMs: number | string = nowMs(),
): void { ): void {
db.prepare( db.prepare(
` `

View File

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

View File

@@ -4,10 +4,7 @@ import type { DatabaseSync } from './sqlite';
import { nowMs } from './time'; import { nowMs } from './time';
import { SCHEMA_VERSION } from './types'; import { SCHEMA_VERSION } from './types';
import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types'; import type { QueuedWrite, VideoMetadata, YoutubeVideoMetadata } from './types';
import { toDbMs } from './query-shared';
function toDbMs(ms: number | bigint): bigint {
return BigInt(Math.trunc(Number(ms)));
}
export interface TrackerPreparedStatements { export interface TrackerPreparedStatements {
telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>; telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>;
@@ -290,9 +287,9 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void {
episodes_started INTEGER NOT NULL DEFAULT 0, episodes_started INTEGER NOT NULL DEFAULT 0,
episodes_completed INTEGER NOT NULL DEFAULT 0, episodes_completed INTEGER NOT NULL DEFAULT 0,
anime_completed INTEGER NOT NULL DEFAULT 0, anime_completed INTEGER NOT NULL DEFAULT 0,
last_rebuilt_ms INTEGER, last_rebuilt_ms TEXT,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER LAST_UPDATE_DATE TEXT
) )
`); `);
@@ -335,10 +332,10 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void {
total_tokens_seen INTEGER NOT NULL DEFAULT 0, total_tokens_seen INTEGER NOT NULL DEFAULT 0,
episodes_started INTEGER NOT NULL DEFAULT 0, episodes_started INTEGER NOT NULL DEFAULT 0,
episodes_completed INTEGER NOT NULL DEFAULT 0, episodes_completed INTEGER NOT NULL DEFAULT 0,
first_watched_ms INTEGER, first_watched_ms TEXT,
last_watched_ms INTEGER, last_watched_ms TEXT,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE
) )
`); `);
@@ -352,10 +349,10 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void {
total_lines_seen INTEGER NOT NULL DEFAULT 0, total_lines_seen INTEGER NOT NULL DEFAULT 0,
total_tokens_seen INTEGER NOT NULL DEFAULT 0, total_tokens_seen INTEGER NOT NULL DEFAULT 0,
completed INTEGER NOT NULL DEFAULT 0, completed INTEGER NOT NULL DEFAULT 0,
first_watched_ms INTEGER, first_watched_ms TEXT,
last_watched_ms INTEGER, last_watched_ms TEXT,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
) )
`); `);
@@ -363,9 +360,9 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void {
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS imm_lifetime_applied_sessions( CREATE TABLE IF NOT EXISTS imm_lifetime_applied_sessions(
session_id INTEGER PRIMARY KEY, session_id INTEGER PRIMARY KEY,
applied_at_ms INTEGER NOT NULL, applied_at_ms TEXT NOT NULL,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
) )
`); `);
@@ -565,18 +562,18 @@ export function ensureSchema(db: DatabaseSync): void {
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS imm_schema_version ( CREATE TABLE IF NOT EXISTS imm_schema_version (
schema_version INTEGER PRIMARY KEY, schema_version INTEGER PRIMARY KEY,
applied_at_ms INTEGER NOT NULL applied_at_ms TEXT NOT NULL
); );
`); `);
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS imm_rollup_state( CREATE TABLE IF NOT EXISTS imm_rollup_state(
state_key TEXT PRIMARY KEY, state_key TEXT PRIMARY KEY,
state_value INTEGER NOT NULL state_value TEXT NOT NULL
); );
`); `);
db.exec(` db.exec(`
INSERT INTO imm_rollup_state(state_key, state_value) INSERT INTO imm_rollup_state(state_key, state_value)
VALUES ('last_rollup_sample_ms', 0) VALUES ('last_rollup_sample_ms', '0')
ON CONFLICT(state_key) DO NOTHING ON CONFLICT(state_key) DO NOTHING
`); `);
@@ -600,8 +597,8 @@ export function ensureSchema(db: DatabaseSync): void {
episodes_total INTEGER, episodes_total INTEGER,
description TEXT, description TEXT,
metadata_json TEXT, metadata_json TEXT,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER LAST_UPDATE_DATE TEXT
); );
`); `);
db.exec(` db.exec(`
@@ -628,8 +625,8 @@ export function ensureSchema(db: DatabaseSync): void {
bitrate_kbps INTEGER, audio_codec_id INTEGER, bitrate_kbps INTEGER, audio_codec_id INTEGER,
hash_sha256 TEXT, screenshot_path TEXT, hash_sha256 TEXT, screenshot_path TEXT,
metadata_json TEXT, metadata_json TEXT,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL
); );
`); `);
@@ -638,7 +635,8 @@ export function ensureSchema(db: DatabaseSync): void {
session_id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_uuid TEXT NOT NULL UNIQUE, session_uuid TEXT NOT NULL UNIQUE,
video_id INTEGER NOT NULL, video_id INTEGER NOT NULL,
started_at_ms INTEGER NOT NULL, ended_at_ms INTEGER, started_at_ms TEXT NOT NULL,
ended_at_ms TEXT,
status INTEGER NOT NULL, status INTEGER NOT NULL,
locale_id INTEGER, target_lang_id INTEGER, locale_id INTEGER, target_lang_id INTEGER,
difficulty_tier INTEGER, subtitle_mode INTEGER, difficulty_tier INTEGER, subtitle_mode INTEGER,
@@ -656,8 +654,8 @@ export function ensureSchema(db: DatabaseSync): void {
seek_forward_count INTEGER NOT NULL DEFAULT 0, seek_forward_count INTEGER NOT NULL DEFAULT 0,
seek_backward_count INTEGER NOT NULL DEFAULT 0, seek_backward_count INTEGER NOT NULL DEFAULT 0,
media_buffer_events INTEGER NOT NULL DEFAULT 0, media_buffer_events INTEGER NOT NULL DEFAULT 0,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) FOREIGN KEY(video_id) REFERENCES imm_videos(video_id)
); );
`); `);
@@ -665,7 +663,7 @@ export function ensureSchema(db: DatabaseSync): void {
CREATE TABLE IF NOT EXISTS imm_session_telemetry( CREATE TABLE IF NOT EXISTS imm_session_telemetry(
telemetry_id INTEGER PRIMARY KEY AUTOINCREMENT, telemetry_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL, session_id INTEGER NOT NULL,
sample_ms INTEGER NOT NULL, sample_ms TEXT NOT NULL,
total_watched_ms INTEGER NOT NULL DEFAULT 0, total_watched_ms INTEGER NOT NULL DEFAULT 0,
active_watched_ms INTEGER NOT NULL DEFAULT 0, active_watched_ms INTEGER NOT NULL DEFAULT 0,
lines_seen INTEGER NOT NULL DEFAULT 0, lines_seen INTEGER NOT NULL DEFAULT 0,
@@ -679,8 +677,8 @@ export function ensureSchema(db: DatabaseSync): void {
seek_forward_count INTEGER NOT NULL DEFAULT 0, seek_forward_count INTEGER NOT NULL DEFAULT 0,
seek_backward_count INTEGER NOT NULL DEFAULT 0, seek_backward_count INTEGER NOT NULL DEFAULT 0,
media_buffer_events INTEGER NOT NULL DEFAULT 0, media_buffer_events INTEGER NOT NULL DEFAULT 0,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
); );
`); `);
@@ -688,7 +686,7 @@ export function ensureSchema(db: DatabaseSync): void {
CREATE TABLE IF NOT EXISTS imm_session_events( CREATE TABLE IF NOT EXISTS imm_session_events(
event_id INTEGER PRIMARY KEY AUTOINCREMENT, event_id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL, session_id INTEGER NOT NULL,
ts_ms INTEGER NOT NULL, ts_ms TEXT NOT NULL,
event_type INTEGER NOT NULL, event_type INTEGER NOT NULL,
line_index INTEGER, line_index INTEGER,
segment_start_ms INTEGER, segment_start_ms INTEGER,
@@ -696,8 +694,8 @@ export function ensureSchema(db: DatabaseSync): void {
tokens_delta INTEGER NOT NULL DEFAULT 0, tokens_delta INTEGER NOT NULL DEFAULT 0,
cards_delta INTEGER NOT NULL DEFAULT 0, cards_delta INTEGER NOT NULL DEFAULT 0,
payload_json TEXT, payload_json TEXT,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
); );
`); `);
@@ -713,8 +711,8 @@ export function ensureSchema(db: DatabaseSync): void {
cards_per_hour REAL, cards_per_hour REAL,
tokens_per_min REAL, tokens_per_min REAL,
lookup_hit_rate REAL, lookup_hit_rate REAL,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
PRIMARY KEY (rollup_day, video_id) PRIMARY KEY (rollup_day, video_id)
); );
`); `);
@@ -727,8 +725,8 @@ export function ensureSchema(db: DatabaseSync): void {
total_lines_seen INTEGER NOT NULL DEFAULT 0, total_lines_seen INTEGER NOT NULL DEFAULT 0,
total_tokens_seen INTEGER NOT NULL DEFAULT 0, total_tokens_seen INTEGER NOT NULL DEFAULT 0,
total_cards INTEGER NOT NULL DEFAULT 0, total_cards INTEGER NOT NULL DEFAULT 0,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
PRIMARY KEY (rollup_month, video_id) PRIMARY KEY (rollup_month, video_id)
); );
`); `);
@@ -771,8 +769,8 @@ export function ensureSchema(db: DatabaseSync): void {
segment_end_ms INTEGER, segment_end_ms INTEGER,
text TEXT NOT NULL, text TEXT NOT NULL,
secondary_text TEXT, secondary_text TEXT,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE, FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE,
FOREIGN KEY(event_id) REFERENCES imm_session_events(event_id) ON DELETE SET NULL, FOREIGN KEY(event_id) REFERENCES imm_session_events(event_id) ON DELETE SET NULL,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE, FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE,
@@ -809,9 +807,9 @@ export function ensureSchema(db: DatabaseSync): void {
title_romaji TEXT, title_romaji TEXT,
title_english TEXT, title_english TEXT,
episodes_total INTEGER, episodes_total INTEGER,
fetched_at_ms INTEGER NOT NULL, fetched_at_ms TEXT NOT NULL,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
); );
`); `);
@@ -830,9 +828,9 @@ export function ensureSchema(db: DatabaseSync): void {
uploader_url TEXT, uploader_url TEXT,
description TEXT, description TEXT,
metadata_json TEXT, metadata_json TEXT,
fetched_at_ms INTEGER NOT NULL, fetched_at_ms TEXT NOT NULL,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
); );
`); `);
@@ -840,24 +838,24 @@ export function ensureSchema(db: DatabaseSync): void {
CREATE TABLE IF NOT EXISTS imm_cover_art_blobs( CREATE TABLE IF NOT EXISTS imm_cover_art_blobs(
blob_hash TEXT PRIMARY KEY, blob_hash TEXT PRIMARY KEY,
cover_blob BLOB NOT NULL, cover_blob BLOB NOT NULL,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER LAST_UPDATE_DATE TEXT
); );
`); `);
if (currentVersion?.schema_version === 1) { if (currentVersion?.schema_version === 1) {
addColumnIfMissing(db, 'imm_videos', 'CREATED_DATE'); addColumnIfMissing(db, 'imm_videos', 'CREATED_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_videos', 'LAST_UPDATE_DATE'); addColumnIfMissing(db, 'imm_videos', 'LAST_UPDATE_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_sessions', 'CREATED_DATE'); addColumnIfMissing(db, 'imm_sessions', 'CREATED_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_sessions', 'LAST_UPDATE_DATE'); addColumnIfMissing(db, 'imm_sessions', 'LAST_UPDATE_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_session_telemetry', 'CREATED_DATE'); addColumnIfMissing(db, 'imm_session_telemetry', 'CREATED_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_session_telemetry', 'LAST_UPDATE_DATE'); addColumnIfMissing(db, 'imm_session_telemetry', 'LAST_UPDATE_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_session_events', 'CREATED_DATE'); addColumnIfMissing(db, 'imm_session_events', 'CREATED_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_session_events', 'LAST_UPDATE_DATE'); addColumnIfMissing(db, 'imm_session_events', 'LAST_UPDATE_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_daily_rollups', 'CREATED_DATE'); addColumnIfMissing(db, 'imm_daily_rollups', 'CREATED_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_daily_rollups', 'LAST_UPDATE_DATE'); addColumnIfMissing(db, 'imm_daily_rollups', 'LAST_UPDATE_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_monthly_rollups', 'CREATED_DATE'); addColumnIfMissing(db, 'imm_monthly_rollups', 'CREATED_DATE', 'TEXT');
addColumnIfMissing(db, 'imm_monthly_rollups', 'LAST_UPDATE_DATE'); addColumnIfMissing(db, 'imm_monthly_rollups', 'LAST_UPDATE_DATE', 'TEXT');
const migratedAtMs = toDbMs(nowMs()); const migratedAtMs = toDbMs(nowMs());
db.prepare( db.prepare(
@@ -941,8 +939,8 @@ export function ensureSchema(db: DatabaseSync): void {
segment_end_ms INTEGER, segment_end_ms INTEGER,
text TEXT NOT NULL, text TEXT NOT NULL,
secondary_text TEXT, secondary_text TEXT,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER, LAST_UPDATE_DATE TEXT,
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE, FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE,
FOREIGN KEY(event_id) REFERENCES imm_session_events(event_id) ON DELETE SET NULL, FOREIGN KEY(event_id) REFERENCES imm_session_events(event_id) ON DELETE SET NULL,
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE, FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE,
@@ -1091,8 +1089,8 @@ export function ensureSchema(db: DatabaseSync): void {
CREATE TABLE IF NOT EXISTS imm_cover_art_blobs( CREATE TABLE IF NOT EXISTS imm_cover_art_blobs(
blob_hash TEXT PRIMARY KEY, blob_hash TEXT PRIMARY KEY,
cover_blob BLOB NOT NULL, cover_blob BLOB NOT NULL,
CREATED_DATE INTEGER, CREATED_DATE TEXT,
LAST_UPDATE_DATE INTEGER LAST_UPDATE_DATE TEXT
) )
`); `);
deduplicateExistingCoverArtRows(db); deduplicateExistingCoverArtRows(db);
@@ -1240,7 +1238,7 @@ export function ensureSchema(db: DatabaseSync): void {
db.exec('DELETE FROM imm_daily_rollups'); db.exec('DELETE FROM imm_daily_rollups');
db.exec('DELETE FROM imm_monthly_rollups'); db.exec('DELETE FROM imm_monthly_rollups');
db.exec( db.exec(
`UPDATE imm_rollup_state SET state_value = 0 WHERE state_key = 'last_rollup_sample_ms'`, `UPDATE imm_rollup_state SET state_value = '0' WHERE state_key = 'last_rollup_sample_ms'`,
); );
} }
@@ -1423,7 +1421,7 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
) { ) {
throw new Error('Incomplete telemetry write'); throw new Error('Incomplete telemetry write');
} }
const telemetrySampleMs = toDbMs(write.sampleMs ?? Number(currentMs)); const telemetrySampleMs = write.sampleMs === undefined ? currentMs : toDbMs(write.sampleMs);
stmts.telemetryInsertStmt.run( stmts.telemetryInsertStmt.run(
write.sessionId, write.sessionId,
telemetrySampleMs, telemetrySampleMs,
@@ -1498,7 +1496,7 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
stmts.eventInsertStmt.run( stmts.eventInsertStmt.run(
write.sessionId, write.sessionId,
toDbMs(write.sampleMs ?? Number(currentMs)), write.sampleMs === undefined ? currentMs : toDbMs(write.sampleMs),
write.eventType ?? 0, write.eventType ?? 0,
write.lineIndex ?? null, write.lineIndex ?? null,
write.segmentStartMs ?? null, write.segmentStartMs ?? null,

View File

@@ -1,6 +1,6 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path';
import electron from 'electron'; import electron from 'electron';
import { ensureDirForFile } from '../../shared/fs-utils';
const { safeStorage } = electron; const { safeStorage } = electron;
@@ -27,15 +27,8 @@ export interface JellyfinTokenStore {
clearSession: () => void; clearSession: () => void;
} }
function ensureDirectory(filePath: string): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function writePayload(filePath: string, payload: PersistedSessionPayload): void { function writePayload(filePath: string, payload: PersistedSessionPayload): void {
ensureDirectory(filePath); ensureDirForFile(filePath);
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8'); fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf-8');
} }

View File

@@ -1,8 +1,9 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { ImmersionTrackerService } from './immersion-tracker-service.js'; import type { ImmersionTrackerService } from './immersion-tracker-service.js';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import http from 'node:http';
import { basename, extname, resolve, sep } from 'node:path'; import { basename, extname, resolve, sep } from 'node:path';
import { readFileSync, existsSync, statSync } from 'node:fs'; import { readFileSync, existsSync, statSync } from 'node:fs';
import { Readable } from 'node:stream';
import { MediaGenerator } from '../../media-generator.js'; import { MediaGenerator } from '../../media-generator.js';
import { AnkiConnectClient } from '../../anki-connect.js'; import { AnkiConnectClient } from '../../anki-connect.js';
import type { AnkiConnectConfig } from '../../types.js'; import type { AnkiConnectConfig } from '../../types.js';
@@ -156,26 +157,6 @@ export interface StatsServerConfig {
resolveAnkiNoteId?: (noteId: number) => number; resolveAnkiNoteId?: (noteId: number) => number;
} }
type StatsServerHandle = {
stop: () => void;
};
type StatsApp = ReturnType<typeof createStatsApp>;
type BunRuntime = {
Bun: {
serve: (options: {
fetch: StatsApp['fetch'];
port: number;
hostname: string;
}) => StatsServerHandle;
};
};
type NodeRuntimeHandle = {
stop: () => void;
};
const STATS_STATIC_CONTENT_TYPES: Record<string, string> = { const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
'.css': 'text/css; charset=utf-8', '.css': 'text/css; charset=utf-8',
'.gif': 'image/gif', '.gif': 'image/gif',
@@ -248,82 +229,6 @@ function createStatsStaticResponse(staticDir: string, requestPath: string): Resp
}); });
} }
async function readNodeRequestBody(req: IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
async function createNodeRequest(req: IncomingMessage): Promise<Request> {
const host = req.headers.host ?? '127.0.0.1';
const url = new URL(req.url ?? '/', `http://${host}`);
const headers = new Headers();
for (const [name, value] of Object.entries(req.headers)) {
if (value === undefined) continue;
if (Array.isArray(value)) {
headers.set(name, value.join(', '));
} else {
headers.set(name, value);
}
}
const method = req.method ?? 'GET';
const body = method === 'GET' || method === 'HEAD' ? undefined : await readNodeRequestBody(req);
const init: RequestInit = {
method,
headers,
};
if (body !== undefined && body.length > 0) {
init.body = new Uint8Array(body);
}
return new Request(url, init);
}
async function writeNodeResponse(
res: ServerResponse<IncomingMessage>,
response: Response,
): Promise<void> {
res.statusCode = response.status;
res.statusMessage = response.statusText;
response.headers.forEach((value, key) => {
res.setHeader(key, value);
});
if (!response.body) {
res.end();
return;
}
const body = Buffer.from(await response.arrayBuffer());
res.end(body);
}
function startNodeStatsServer(app: StatsApp, port: number): NodeRuntimeHandle {
const server = createServer((req, res) => {
void (async () => {
try {
const response = await app.fetch(await createNodeRequest(req));
await writeNodeResponse(res, response);
} catch {
if (!res.headersSent) {
res.statusCode = 500;
}
res.end('Internal Server Error');
}
})();
});
server.listen(port, '127.0.0.1');
return {
stop: () => {
server.close();
},
};
}
export function createStatsApp( export function createStatsApp(
tracker: ImmersionTrackerService, tracker: ImmersionTrackerService,
options?: { options?: {
@@ -1103,18 +1008,79 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
resolveAnkiNoteId: config.resolveAnkiNoteId, resolveAnkiNoteId: config.resolveAnkiNoteId,
}); });
const bunServe = (globalThis as typeof globalThis & Partial<BunRuntime>).Bun?.serve; const bunServe =
const server = bunServe (
? bunServe({ globalThis as typeof globalThis & {
fetch: app.fetch, Bun?: {
port: config.port, serve?: (options: {
hostname: '127.0.0.1', fetch: (typeof app)['fetch'];
}) port: number;
: startNodeStatsServer(app, config.port); hostname: string;
}) => { stop: () => void };
};
}
).Bun?.serve;
if (typeof bunServe === 'function') {
const server = bunServe({
fetch: app.fetch,
port: config.port,
hostname: '127.0.0.1',
});
return {
close: () => {
server.stop();
},
};
}
const server = http.createServer(async (req, res) => {
const url = new URL(`http://127.0.0.1:${config.port}${req.url}`);
const headers = new Headers();
for (const [name, value] of Object.entries(req.headers)) {
if (value === undefined) continue;
if (Array.isArray(value)) {
for (const entry of value) {
headers.append(name, entry);
}
} else {
headers.set(name, value);
}
}
const body =
req.method === 'GET' || req.method === 'HEAD'
? undefined
: (Readable.toWeb(req) as unknown as BodyInit);
const response = await app.fetch(
new Request(url.toString(), {
method: req.method,
headers,
body,
}),
);
res.statusCode = response.status;
for (const [name, value] of response.headers) {
res.setHeader(name, value);
}
const responseBody = await response.arrayBuffer();
if (responseBody.byteLength > 0) {
res.end(Buffer.from(responseBody));
return;
}
res.end();
});
server.listen(config.port, '127.0.0.1');
return { return {
close: () => { close: () => {
server.stop(); server.close();
}, },
}; };
} }

View File

@@ -101,8 +101,7 @@ import { AnkiIntegration } from './anki-integration';
import { SubtitleTimingTracker } from './subtitle-timing-tracker'; import { SubtitleTimingTracker } from './subtitle-timing-tracker';
import { RuntimeOptionsManager } from './runtime-options'; import { RuntimeOptionsManager } from './runtime-options';
import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils'; import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils';
import { createLogger, setLogLevel, type LogLevelSource } from './logger'; import { createLogger, setLogLevel, resolveDefaultLogFilePath, type LogLevelSource } from './logger';
import { resolveDefaultLogFilePath } from './logger';
import { createWindowTracker as createWindowTrackerCore } from './window-trackers'; import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
import { import {
commandNeedsOverlayStartupPrereqs, commandNeedsOverlayStartupPrereqs,
@@ -111,10 +110,11 @@ import {
parseArgs, parseArgs,
shouldRunSettingsOnlyStartup, shouldRunSettingsOnlyStartup,
shouldStartApp, shouldStartApp,
type CliArgs,
type CliCommandSource,
} from './cli/args'; } from './cli/args';
import type { CliArgs, CliCommandSource } from './cli/args';
import { printHelp } from './cli/help'; import { printHelp } from './cli/help';
import { IPC_CHANNELS } from './shared/ipc/contracts'; import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
import { import {
buildConfigParseErrorDetails, buildConfigParseErrorDetails,
buildConfigWarningDialogDetails, buildConfigWarningDialogDetails,
@@ -142,9 +142,9 @@ import {
createGetDefaultSocketPathHandler, createGetDefaultSocketPathHandler,
buildJellyfinSetupFormHtml, buildJellyfinSetupFormHtml,
parseJellyfinSetupSubmissionUrl, parseJellyfinSetupSubmissionUrl,
getConfiguredJellyfinSession,
type ActiveJellyfinRemotePlaybackState,
} from './main/runtime/domains/jellyfin'; } from './main/runtime/domains/jellyfin';
import type { ActiveJellyfinRemotePlaybackState } from './main/runtime/domains/jellyfin';
import { getConfiguredJellyfinSession } from './main/runtime/domains/jellyfin';
import { import {
createBuildConfigHotReloadMessageMainDepsHandler, createBuildConfigHotReloadMessageMainDepsHandler,
createBuildConfigHotReloadAppliedMainDepsHandler, createBuildConfigHotReloadAppliedMainDepsHandler,
@@ -384,13 +384,11 @@ import {
composeIpcRuntimeHandlers, composeIpcRuntimeHandlers,
composeJellyfinRuntimeHandlers, composeJellyfinRuntimeHandlers,
composeMpvRuntimeHandlers, composeMpvRuntimeHandlers,
composeOverlayWindowHandlers,
composeOverlayVisibilityRuntime, composeOverlayVisibilityRuntime,
composeShortcutRuntimes, composeShortcutRuntimes,
composeStatsStartupRuntime,
composeSubtitlePrefetchRuntime,
composeStartupLifecycleHandlers, composeStartupLifecycleHandlers,
} from './main/runtime/composers'; } from './main/runtime/composers';
import { createOverlayWindowRuntimeHandlers } from './main/runtime/overlay-window-runtime-handlers';
import { createStartupBootstrapRuntimeDeps } from './main/startup'; import { createStartupBootstrapRuntimeDeps } from './main/startup';
import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle'; import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle';
import { import {
@@ -402,35 +400,10 @@ import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
import { registerIpcRuntimeServices } from './main/ipc-runtime'; import { registerIpcRuntimeServices } from './main/ipc-runtime';
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies'; import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
import { createMainBootServices } from './main/boot/services'; import { createMainBootServices } from './main/boot/services';
import {
composeBootOverlayVisibilityRuntime,
composeBootJellyfinRuntimeHandlers,
composeBootAnilistSetupHandlers,
createBootMaybeFocusExistingAnilistSetupWindowHandler,
createBootBuildOpenAnilistSetupWindowMainDepsHandler,
createBootOpenAnilistSetupWindowHandler,
composeBootAnilistTrackingHandlers,
composeBootStatsStartupRuntime,
createBootRunStatsCliCommandHandler,
composeBootAppReadyRuntime,
composeBootMpvRuntimeHandlers,
createBootTrayRuntimeHandlers,
createBootYomitanProfilePolicy,
createBootYomitanExtensionRuntime,
createBootYomitanSettingsRuntime,
} from './main/boot/runtimes';
import {
composeBootStartupLifecycleHandlers,
composeBootIpcRuntimeHandlers,
composeBootCliStartupHandlers,
composeBootHeadlessStartupHandlers,
composeBootOverlayWindowHandlers,
} from './main/boot/handlers';
import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime'; import { handleCliCommandRuntimeServiceWithContext } from './main/cli-runtime';
import { createOverlayModalRuntimeService } from './main/overlay-runtime'; import { createOverlayModalRuntimeService } from './main/overlay-runtime';
import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state'; import { createOverlayModalInputState } from './main/runtime/overlay-modal-input-state';
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open'; import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import type { OverlayHostedModal } from './shared/ipc/contracts';
import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime'; import { createOverlayShortcutsRuntimeService } from './main/overlay-shortcuts-runtime';
import { import {
createFrequencyDictionaryRuntimeService, createFrequencyDictionaryRuntimeService,
@@ -492,8 +465,7 @@ import {
} from './config'; } from './config';
import { resolveConfigDir } from './config/path-resolution'; import { resolveConfigDir } from './config/path-resolution';
import { parseSubtitleCues } from './core/services/subtitle-cue-parser'; import { parseSubtitleCues } from './core/services/subtitle-cue-parser';
import { createSubtitlePrefetchService } from './core/services/subtitle-prefetch'; import { createSubtitlePrefetchService, type SubtitlePrefetchService } from './core/services/subtitle-prefetch';
import type { SubtitlePrefetchService } from './core/services/subtitle-prefetch';
import { import {
buildSubtitleSidebarSourceKey, buildSubtitleSidebarSourceKey,
resolveSubtitleSourcePath, resolveSubtitleSourcePath,
@@ -526,9 +498,6 @@ const ANILIST_DEVELOPER_SETTINGS_URL = 'https://anilist.co/settings/developer';
const ANILIST_UPDATE_MIN_WATCH_SECONDS = 10 * 60; const ANILIST_UPDATE_MIN_WATCH_SECONDS = 10 * 60;
const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000; const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000;
const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000; const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
const ANILIST_TOKEN_STORE_FILE = 'anilist-token-store.json';
const JELLYFIN_TOKEN_STORE_FILE = 'jellyfin-token-store.json';
const ANILIST_RETRY_QUEUE_FILE = 'anilist-retry-queue.json';
const TRAY_TOOLTIP = 'SubMiner'; const TRAY_TOOLTIP = 'SubMiner';
let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState = let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState =
@@ -1424,13 +1393,14 @@ function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
void refreshSubtitlePrefetchFromActiveTrackHandler(); void refreshSubtitlePrefetchFromActiveTrackHandler();
}, delayMs); }, delayMs);
} }
const subtitlePrefetchRuntime = composeSubtitlePrefetchRuntime({ const subtitlePrefetchRuntime = {
subtitlePrefetchInitController, cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(),
refreshSubtitleSidebarFromSource: (sourcePath) => refreshSubtitleSidebarFromSource(sourcePath), initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch,
refreshSubtitleSidebarFromSource: (sourcePath: string) => refreshSubtitleSidebarFromSource(sourcePath),
refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(), refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(),
scheduleSubtitlePrefetchRefresh: (delayMs) => scheduleSubtitlePrefetchRefresh(delayMs), scheduleSubtitlePrefetchRefresh: (delayMs?: number) => scheduleSubtitlePrefetchRefresh(delayMs),
clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(), clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(),
}); } as const;
const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService( const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
createBuildOverlayShortcutsRuntimeMainDepsHandler({ createBuildOverlayShortcutsRuntimeMainDepsHandler({
@@ -1916,7 +1886,7 @@ const buildOpenRuntimeOptionsPaletteMainDepsHandler =
createBuildOpenRuntimeOptionsPaletteMainDepsHandler({ createBuildOpenRuntimeOptionsPaletteMainDepsHandler({
openRuntimeOptionsPaletteRuntime: () => overlayModalRuntime.openRuntimeOptionsPalette(), openRuntimeOptionsPaletteRuntime: () => overlayModalRuntime.openRuntimeOptionsPalette(),
}); });
const overlayVisibilityComposer = composeBootOverlayVisibilityRuntime({ const overlayVisibilityComposer = composeOverlayVisibilityRuntime({
overlayVisibilityRuntime, overlayVisibilityRuntime,
restorePreviousSecondarySubVisibilityMainDeps: restorePreviousSecondarySubVisibilityMainDeps:
buildRestorePreviousSecondarySubVisibilityMainDepsHandler(), buildRestorePreviousSecondarySubVisibilityMainDepsHandler(),
@@ -1988,7 +1958,7 @@ const {
stopJellyfinRemoteSession, stopJellyfinRemoteSession,
runJellyfinCommand, runJellyfinCommand,
openJellyfinSetupWindow, openJellyfinSetupWindow,
} = composeBootJellyfinRuntimeHandlers({ } = composeJellyfinRuntimeHandlers({
getResolvedJellyfinConfigMainDeps: { getResolvedJellyfinConfigMainDeps: {
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
loadStoredSession: () => jellyfinTokenStore.loadSession(), loadStoredSession: () => jellyfinTokenStore.loadSession(),
@@ -2288,7 +2258,7 @@ const {
consumeAnilistSetupTokenFromUrl, consumeAnilistSetupTokenFromUrl,
handleAnilistSetupProtocolUrl, handleAnilistSetupProtocolUrl,
registerSubminerProtocolClient, registerSubminerProtocolClient,
} = composeBootAnilistSetupHandlers({ } = composeAnilistSetupHandlers({
notifyDeps: { notifyDeps: {
hasMpvClient: () => Boolean(appState.mpvClient), hasMpvClient: () => Boolean(appState.mpvClient),
showMpvOsd: (message) => showMpvOsd(message), showMpvOsd: (message) => showMpvOsd(message),
@@ -2339,10 +2309,10 @@ const {
}, },
}); });
const maybeFocusExistingAnilistSetupWindow = createBootMaybeFocusExistingAnilistSetupWindowHandler({ const maybeFocusExistingAnilistSetupWindow = createMaybeFocusExistingAnilistSetupWindowHandler({
getSetupWindow: () => appState.anilistSetupWindow, getSetupWindow: () => appState.anilistSetupWindow,
}); });
const buildOpenAnilistSetupWindowMainDepsHandler = createBootBuildOpenAnilistSetupWindowMainDepsHandler( const buildOpenAnilistSetupWindowMainDepsHandler = createBuildOpenAnilistSetupWindowMainDepsHandler(
{ {
maybeFocusExistingSetupWindow: maybeFocusExistingAnilistSetupWindow, maybeFocusExistingSetupWindow: maybeFocusExistingAnilistSetupWindow,
createSetupWindow: createCreateAnilistSetupWindowHandler({ createSetupWindow: createCreateAnilistSetupWindowHandler({
@@ -2390,7 +2360,7 @@ const buildOpenAnilistSetupWindowMainDepsHandler = createBootBuildOpenAnilistSet
); );
function openAnilistSetupWindow(): void { function openAnilistSetupWindow(): void {
createBootOpenAnilistSetupWindowHandler(buildOpenAnilistSetupWindowMainDepsHandler())(); createOpenAnilistSetupWindowHandler(buildOpenAnilistSetupWindowMainDepsHandler())();
} }
const { const {
@@ -2404,7 +2374,7 @@ const {
ensureAnilistMediaGuess, ensureAnilistMediaGuess,
processNextAnilistRetryUpdate, processNextAnilistRetryUpdate,
maybeRunAnilistPostWatchUpdate, maybeRunAnilistPostWatchUpdate,
} = composeBootAnilistTrackingHandlers({ } = composeAnilistTrackingHandlers({
refreshClientSecretMainDeps: { refreshClientSecretMainDeps: {
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
isAnilistTrackingEnabled: (config) => isAnilistTrackingEnabled(config as ResolvedConfig), isAnilistTrackingEnabled: (config) => isAnilistTrackingEnabled(config as ResolvedConfig),
@@ -2671,7 +2641,7 @@ const {
onWillQuitCleanup: onWillQuitCleanupHandler, onWillQuitCleanup: onWillQuitCleanupHandler,
shouldRestoreWindowsOnActivate: shouldRestoreWindowsOnActivateHandler, shouldRestoreWindowsOnActivate: shouldRestoreWindowsOnActivateHandler,
restoreWindowsOnActivate: restoreWindowsOnActivateHandler, restoreWindowsOnActivate: restoreWindowsOnActivateHandler,
} = composeBootStartupLifecycleHandlers({ } = composeStartupLifecycleHandlers({
registerProtocolUrlHandlersMainDeps: { registerProtocolUrlHandlersMainDeps: {
registerOpenUrl: (listener) => { registerOpenUrl: (listener) => {
app.on('open-url', listener); app.on('open-url', listener);
@@ -2979,7 +2949,7 @@ const ensureImmersionTrackerStarted = (): void => {
hasAttemptedImmersionTrackerStartup = true; hasAttemptedImmersionTrackerStartup = true;
createImmersionTrackerStartup(); createImmersionTrackerStartup();
}; };
const statsStartupRuntime = composeBootStatsStartupRuntime({ const statsStartupRuntime = {
ensureStatsServerStarted: () => ensureStatsServerStarted(), ensureStatsServerStarted: () => ensureStatsServerStarted(),
ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(), ensureBackgroundStatsServerStarted: () => ensureBackgroundStatsServerStarted(),
stopBackgroundStatsServer: () => stopBackgroundStatsServer(), stopBackgroundStatsServer: () => stopBackgroundStatsServer(),
@@ -2991,9 +2961,9 @@ const statsStartupRuntime = composeBootStatsStartupRuntime({
appState.statsStartupInProgress = false; appState.statsStartupInProgress = false;
} }
}, },
}); } as const;
const runStatsCliCommand = createBootRunStatsCliCommandHandler({ const runStatsCliCommand = createRunStatsCliCommandHandler({
getResolvedConfig: () => getResolvedConfig(), getResolvedConfig: () => getResolvedConfig(),
ensureImmersionTrackerStarted: () => statsStartupRuntime.ensureImmersionTrackerStarted(), ensureImmersionTrackerStarted: () => statsStartupRuntime.ensureImmersionTrackerStarted(),
ensureVocabularyCleanupTokenizerReady: async () => { ensureVocabularyCleanupTokenizerReady: async () => {
@@ -3060,7 +3030,7 @@ async function runHeadlessInitialCommand(): Promise<void> {
} }
} }
const { appReadyRuntimeRunner } = composeBootAppReadyRuntime({ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
reloadConfigMainDeps: { reloadConfigMainDeps: {
reloadConfigStrict: () => configService.reloadConfigStrict(), reloadConfigStrict: () => configService.reloadConfigStrict(),
logInfo: (message) => appLogger.logInfo(message), logInfo: (message) => appLogger.logInfo(message),
@@ -3295,7 +3265,7 @@ const {
startBackgroundWarmups, startBackgroundWarmups,
startTokenizationWarmups, startTokenizationWarmups,
isTokenizationWarmupReady, isTokenizationWarmupReady,
} = composeBootMpvRuntimeHandlers< } = composeMpvRuntimeHandlers<
MpvIpcClient, MpvIpcClient,
ReturnType<typeof createTokenizerDepsRuntime>, ReturnType<typeof createTokenizerDepsRuntime>,
SubtitleData SubtitleData
@@ -4142,7 +4112,7 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
showMpvOsd: (text) => showMpvOsd(text), showMpvOsd: (text) => showMpvOsd(text),
}); });
const { registerIpcRuntimeHandlers } = composeBootIpcRuntimeHandlers({ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
mpvCommandMainDeps: { mpvCommandMainDeps: {
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
@@ -4362,7 +4332,7 @@ const { registerIpcRuntimeHandlers } = composeBootIpcRuntimeHandlers({
registerIpcRuntimeServices, registerIpcRuntimeServices,
}, },
}); });
const { handleCliCommand, handleInitialArgs } = composeBootCliStartupHandlers({ const { handleCliCommand, handleInitialArgs } = composeCliStartupHandlers({
cliCommandContextMainDeps: { cliCommandContextMainDeps: {
appState, appState,
setLogLevel: (level) => setLogLevel(level, 'cli'), setLogLevel: (level) => setLogLevel(level, 'cli'),
@@ -4448,7 +4418,7 @@ const { handleCliCommand, handleInitialArgs } = composeBootCliStartupHandlers({
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
}, },
}); });
const { runAndApplyStartupState } = composeBootHeadlessStartupHandlers< const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
CliArgs, CliArgs,
StartupState, StartupState,
ReturnType<typeof createStartupBootstrapRuntimeDeps> ReturnType<typeof createStartupBootstrapRuntimeDeps>
@@ -4516,7 +4486,7 @@ if (isAnilistTrackingEnabled(getResolvedConfig())) {
} }
void initializeDiscordPresenceService(); void initializeDiscordPresenceService();
const { createMainWindow: createMainWindowHandler, createModalWindow: createModalWindowHandler } = const { createMainWindow: createMainWindowHandler, createModalWindow: createModalWindowHandler } =
composeBootOverlayWindowHandlers<BrowserWindow>({ createOverlayWindowRuntimeHandlers<BrowserWindow>({
createOverlayWindowDeps: { createOverlayWindowDeps: {
createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options), createOverlayWindowCore: (kind, options) => createOverlayWindowCore(kind, options),
isDev, isDev,
@@ -4542,7 +4512,7 @@ const { createMainWindow: createMainWindowHandler, createModalWindow: createModa
setModalWindow: (window) => overlayManager.setModalWindow(window), setModalWindow: (window) => overlayManager.setModalWindow(window),
}); });
const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
createBootTrayRuntimeHandlers({ createTrayRuntimeHandlers({
resolveTrayIconPathDeps: { resolveTrayIconPathDeps: {
resolveTrayIconPathRuntime, resolveTrayIconPathRuntime,
platform: process.platform, platform: process.platform,
@@ -4589,12 +4559,12 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
}, },
buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template), buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template),
}); });
const yomitanProfilePolicy = createBootYomitanProfilePolicy({ const yomitanProfilePolicy = createYomitanProfilePolicy({
externalProfilePath: getResolvedConfig().yomitan.externalProfilePath, externalProfilePath: getResolvedConfig().yomitan.externalProfilePath,
logInfo: (message) => logger.info(message), logInfo: (message) => logger.info(message),
}); });
const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath; const configuredExternalYomitanProfilePath = yomitanProfilePolicy.externalProfilePath;
const yomitanExtensionRuntime = createBootYomitanExtensionRuntime({ const yomitanExtensionRuntime = createYomitanExtensionRuntime({
loadYomitanExtensionCore, loadYomitanExtensionCore,
userDataPath: USER_DATA_PATH, userDataPath: USER_DATA_PATH,
externalProfilePath: configuredExternalYomitanProfilePath, externalProfilePath: configuredExternalYomitanProfilePath,
@@ -4676,7 +4646,7 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
}, },
}, },
}); });
const { openYomitanSettings: openYomitanSettingsHandler } = createBootYomitanSettingsRuntime({ const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(), ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(),
getYomitanSession: () => appState.yomitanSession, getYomitanSession: () => appState.yomitanSession,
openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow, yomitanSession }) => { openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow, yomitanSession }) => {

View File

@@ -1,94 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMainBootHandlers } from './handlers';
test('createMainBootHandlers returns grouped handler bundles', () => {
const handlers = createMainBootHandlers<any, any, any, any>({
startupLifecycleDeps: {
registerProtocolUrlHandlersMainDeps: {} as never,
onWillQuitCleanupMainDeps: {} as never,
shouldRestoreWindowsOnActivateMainDeps: {} as never,
restoreWindowsOnActivateMainDeps: {} as never,
},
ipcRuntimeDeps: {
mpvCommandMainDeps: {} as never,
handleMpvCommandFromIpcRuntime: () => ({ ok: true }) as never,
runSubsyncManualFromIpc: () => Promise.resolve({ ok: true }) as never,
registration: {
runtimeOptions: {} as never,
mainDeps: {} as never,
ankiJimakuDeps: {} as never,
registerIpcRuntimeServices: () => {},
},
},
cliStartupDeps: {
cliCommandContextMainDeps: {} as never,
cliCommandRuntimeHandlerMainDeps: {} as never,
initialArgsRuntimeHandlerMainDeps: {} as never,
},
headlessStartupDeps: {
startupRuntimeHandlersDeps: {
appLifecycleRuntimeRunnerMainDeps: {
app: { on: () => {} } as never,
platform: 'darwin',
shouldStartApp: () => true,
parseArgs: () => ({}) as never,
handleCliCommand: () => {},
printHelp: () => {},
logNoRunningInstance: () => {},
onReady: async () => {},
onWillQuitCleanup: () => {},
shouldRestoreWindowsOnActivate: () => false,
restoreWindowsOnActivate: () => {},
shouldQuitOnWindowAllClosed: () => false,
},
createAppLifecycleRuntimeRunner: () => () => {},
buildStartupBootstrapMainDeps: (startAppLifecycle) => ({
argv: ['node', 'main.js'],
parseArgs: () => ({ command: 'start' }) as never,
setLogLevel: () => {},
forceX11Backend: () => {},
enforceUnsupportedWaylandMode: () => {},
shouldStartApp: () => true,
getDefaultSocketPath: () => '/tmp/mpv.sock',
defaultTexthookerPort: 5174,
configDir: '/tmp/config',
defaultConfig: {} as never,
generateConfigTemplate: () => 'template',
generateDefaultConfigFile: async () => 0,
setExitCode: () => {},
quitApp: () => {},
logGenerateConfigError: () => {},
startAppLifecycle: (args) => startAppLifecycle(args as never),
}),
createStartupBootstrapRuntimeDeps: (deps) => ({
startAppLifecycle: deps.startAppLifecycle,
}),
runStartupBootstrapRuntime: () => ({ mode: 'started' } as never),
applyStartupState: () => {},
},
},
overlayWindowDeps: {
createOverlayWindowDeps: {
createOverlayWindowCore: (kind) => ({ kind }),
isDev: false,
ensureOverlayWindowLevel: () => {},
onRuntimeOptionsChanged: () => {},
setOverlayDebugVisualizationEnabled: () => {},
isOverlayVisible: () => false,
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => {},
onWindowClosed: () => {},
getYomitanSession: () => null,
},
setMainWindow: () => {},
setModalWindow: () => {},
},
});
assert.equal(typeof handlers.startupLifecycle.registerProtocolUrlHandlers, 'function');
assert.equal(typeof handlers.ipcRuntime.registerIpcRuntimeHandlers, 'function');
assert.equal(typeof handlers.cliStartup.handleCliCommand, 'function');
assert.equal(typeof handlers.headlessStartup.runAndApplyStartupState, 'function');
assert.equal(typeof handlers.overlayWindow.createMainWindow, 'function');
});

View File

@@ -1,40 +0,0 @@
import { composeOverlayWindowHandlers } from '../runtime/composers/overlay-window-composer';
import {
composeCliStartupHandlers,
composeHeadlessStartupHandlers,
composeIpcRuntimeHandlers,
composeStartupLifecycleHandlers,
} from '../runtime/composers';
export interface MainBootHandlersParams<TBrowserWindow, TCliArgs, TStartupState, TBootstrapDeps> {
startupLifecycleDeps: Parameters<typeof composeStartupLifecycleHandlers>[0];
ipcRuntimeDeps: Parameters<typeof composeIpcRuntimeHandlers>[0];
cliStartupDeps: Parameters<typeof composeCliStartupHandlers>[0];
headlessStartupDeps: Parameters<
typeof composeHeadlessStartupHandlers<TCliArgs, TStartupState, TBootstrapDeps>
>[0];
overlayWindowDeps: Parameters<typeof composeOverlayWindowHandlers<TBrowserWindow>>[0];
}
export function createMainBootHandlers<
TBrowserWindow,
TCliArgs,
TStartupState,
TBootstrapDeps,
>(params: MainBootHandlersParams<TBrowserWindow, TCliArgs, TStartupState, TBootstrapDeps>) {
return {
startupLifecycle: composeStartupLifecycleHandlers(params.startupLifecycleDeps),
ipcRuntime: composeIpcRuntimeHandlers(params.ipcRuntimeDeps),
cliStartup: composeCliStartupHandlers(params.cliStartupDeps),
headlessStartup: composeHeadlessStartupHandlers<TCliArgs, TStartupState, TBootstrapDeps>(
params.headlessStartupDeps,
),
overlayWindow: composeOverlayWindowHandlers<TBrowserWindow>(params.overlayWindowDeps),
};
}
export const composeBootStartupLifecycleHandlers = composeStartupLifecycleHandlers;
export const composeBootIpcRuntimeHandlers = composeIpcRuntimeHandlers;
export const composeBootCliStartupHandlers = composeCliStartupHandlers;
export const composeBootHeadlessStartupHandlers = composeHeadlessStartupHandlers;
export const composeBootOverlayWindowHandlers = composeOverlayWindowHandlers;

View File

@@ -1,339 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMainBootRuntimes } from './runtimes';
test('createMainBootRuntimes returns grouped runtime bundles', () => {
const runtimes = createMainBootRuntimes<any, any, any, any>({
overlayVisibilityRuntimeDeps: {
overlayVisibilityRuntime: {} as never,
restorePreviousSecondarySubVisibilityMainDeps: {} as never,
broadcastRuntimeOptionsChangedMainDeps: {} as never,
sendToActiveOverlayWindowMainDeps: {} as never,
setOverlayDebugVisualizationEnabledMainDeps: {} as never,
openRuntimeOptionsPaletteMainDeps: {} as never,
},
jellyfinRuntimeHandlerDeps: {
getResolvedJellyfinConfigMainDeps: {} as never,
getJellyfinClientInfoMainDeps: {} as never,
waitForMpvConnectedMainDeps: {} as never,
launchMpvIdleForJellyfinPlaybackMainDeps: {} as never,
ensureMpvConnectedForJellyfinPlaybackMainDeps: {} as never,
preloadJellyfinExternalSubtitlesMainDeps: {} as never,
playJellyfinItemInMpvMainDeps: {} as never,
remoteComposerOptions: {} as never,
handleJellyfinAuthCommandsMainDeps: {} as never,
handleJellyfinListCommandsMainDeps: {} as never,
handleJellyfinPlayCommandMainDeps: {} as never,
handleJellyfinRemoteAnnounceCommandMainDeps: {} as never,
startJellyfinRemoteSessionMainDeps: {} as never,
stopJellyfinRemoteSessionMainDeps: {} as never,
runJellyfinCommandMainDeps: {} as never,
maybeFocusExistingJellyfinSetupWindowMainDeps: {} as never,
openJellyfinSetupWindowMainDeps: {} as never,
},
anilistSetupDeps: {
notifyDeps: {} as never,
consumeTokenDeps: {} as never,
handleProtocolDeps: {} as never,
registerProtocolClientDeps: {} as never,
},
buildOpenAnilistSetupWindowMainDeps: {
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () => null as never,
buildAuthorizeUrl: () => 'https://example.test',
consumeCallbackUrl: () => false,
openSetupInBrowser: () => {},
loadManualTokenEntry: () => Promise.resolve(),
redirectUri: 'https://example.test/callback',
developerSettingsUrl: 'https://example.test/dev',
isAllowedExternalUrl: () => true,
isAllowedNavigationUrl: () => true,
logWarn: () => {},
logError: () => {},
clearSetupWindow: () => {},
setSetupPageOpened: () => {},
setSetupWindow: () => {},
openExternal: () => {},
},
anilistTrackingDeps: {
refreshClientSecretMainDeps: {} as never,
getCurrentMediaKeyMainDeps: {} as never,
resetMediaTrackingMainDeps: {} as never,
getMediaGuessRuntimeStateMainDeps: {} as never,
setMediaGuessRuntimeStateMainDeps: {} as never,
resetMediaGuessStateMainDeps: {} as never,
maybeProbeDurationMainDeps: {} as never,
ensureMediaGuessMainDeps: {} as never,
processNextRetryUpdateMainDeps: {} as never,
maybeRunPostWatchUpdateMainDeps: {} as never,
},
statsStartupRuntimeDeps: {
ensureStatsServerStarted: () => '',
ensureBackgroundStatsServerStarted: () => ({ url: '', runningInCurrentProcess: false }),
stopBackgroundStatsServer: async () => ({ ok: true, stale: false }),
ensureImmersionTrackerStarted: () => {},
},
runStatsCliCommandDeps: {
getResolvedConfig: () => ({}) as never,
ensureImmersionTrackerStarted: () => {},
ensureVocabularyCleanupTokenizerReady: async () => {},
getImmersionTracker: () => null,
ensureStatsServerStarted: () => '',
ensureBackgroundStatsServerStarted: () => ({ url: '', runningInCurrentProcess: false }),
stopBackgroundStatsServer: async () => ({ ok: true, stale: false }),
openExternal: () => Promise.resolve(),
writeResponse: () => {},
exitAppWithCode: () => {},
logInfo: () => {},
logWarn: () => {},
logError: () => {},
},
appReadyRuntimeDeps: {
reloadConfigMainDeps: {
reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }),
logInfo: () => {},
logWarning: () => {},
showDesktopNotification: () => {},
startConfigHotReload: () => {},
refreshAnilistClientSecretState: async () => {},
failHandlers: {
logError: () => {},
showErrorBox: () => {},
quit: () => {},
},
},
criticalConfigErrorMainDeps: {
getConfigPath: () => '/tmp/config.jsonc',
failHandlers: {
logError: () => {},
showErrorBox: () => {},
quit: () => {},
},
},
appReadyRuntimeMainDeps: {
ensureDefaultConfigBootstrap: () => {},
loadSubtitlePosition: () => {},
resolveKeybindings: () => {},
createMpvClient: () => {},
getResolvedConfig: () => ({}) as never,
getConfigWarnings: () => [],
logConfigWarning: () => {},
setLogLevel: () => {},
initRuntimeOptionsManager: () => {},
setSecondarySubMode: () => {},
defaultSecondarySubMode: 'hover',
defaultWebsocketPort: 5174,
defaultAnnotationWebsocketPort: 6678,
defaultTexthookerPort: 5174,
hasMpvWebsocketPlugin: () => false,
startSubtitleWebsocket: () => {},
startAnnotationWebsocket: () => {},
startTexthooker: () => {},
log: () => {},
createMecabTokenizerAndCheck: async () => {},
createSubtitleTimingTracker: () => {},
loadYomitanExtension: async () => {},
handleFirstRunSetup: async () => {},
startJellyfinRemoteSession: async () => {},
prewarmSubtitleDictionaries: async () => {},
startBackgroundWarmups: () => {},
texthookerOnlyMode: false,
shouldAutoInitializeOverlayRuntimeFromConfig: () => false,
setVisibleOverlayVisible: () => {},
initializeOverlayRuntime: () => {},
handleInitialArgs: () => {},
logDebug: () => {},
now: () => Date.now(),
},
immersionTrackerStartupMainDeps: {
getResolvedConfig: () => ({}) as never,
getConfiguredDbPath: () => '/tmp/immersion.sqlite',
createTrackerService: () =>
({
startSession: () => {},
}) as never,
setTracker: () => {},
getMpvClient: () => null,
seedTrackerFromCurrentMedia: () => {},
logInfo: () => {},
logDebug: () => {},
logWarn: () => {},
},
},
mpvRuntimeDeps: {
bindMpvMainEventHandlersMainDeps: {
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: class FakeMpvClient {
connected = true;
on(): void {}
connect(): void {}
} as never,
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => true,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => ({
subPos: 100,
subFontSize: 36,
subScale: 1,
subMarginY: 0,
subMarginX: 0,
subFont: '',
subSpacing: 0,
subBold: false,
subItalic: false,
subBorderSize: 0,
subShadowOffset: 0,
subAssOverride: 'yes',
subScaleByWindow: true,
subUseMargins: true,
osdHeight: 0,
osdDimensions: null,
}),
setCurrentMetrics: () => {},
applyPatch: (current: any) => ({ next: current, changed: false }),
broadcastMetrics: () => {},
},
tokenizer: {
buildTokenizerDepsMainDeps: {
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => false,
getFrequencyDictionaryEnabled: () => false,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: () => ({}),
tokenizeSubtitle: async () => ({ text: '' }),
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => null,
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({}) as never,
checkAvailability: async () => {},
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => {},
ensureFrequencyDictionaryLookup: async () => {},
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => {},
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
},
trayRuntimeDeps: {
resolveTrayIconPathDeps: {} as never,
buildTrayMenuTemplateDeps: {} as never,
ensureTrayDeps: {} as never,
destroyTrayDeps: {} as never,
buildMenuFromTemplate: () => ({}) as never,
},
yomitanProfilePolicyDeps: {
externalProfilePath: '',
logInfo: () => {},
},
yomitanExtensionRuntimeDeps: {
loadYomitanExtensionCore: async () => null,
userDataPath: '/tmp',
externalProfilePath: '',
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
setYomitanParserReadyPromise: () => {},
setYomitanParserInitPromise: () => {},
setYomitanExtension: () => {},
setYomitanSession: () => {},
getYomitanExtension: () => null,
getLoadInFlight: () => null,
setLoadInFlight: () => {},
},
yomitanSettingsRuntimeDeps: {
ensureYomitanExtensionLoaded: async () => {},
getYomitanSession: () => null,
openYomitanSettingsWindow: () => {},
getExistingWindow: () => null,
setWindow: () => {},
logWarn: () => {},
logError: () => {},
},
createOverlayRuntimeBootstrapHandlers: () => ({
initializeOverlayRuntime: () => {},
}),
initializeOverlayRuntimeMainDeps: {},
initializeOverlayRuntimeBootstrapDeps: {},
});
assert.equal(typeof runtimes.overlayVisibilityComposer.sendToActiveOverlayWindow, 'function');
assert.equal(typeof runtimes.jellyfinRuntimeHandlers.runJellyfinCommand, 'function');
assert.equal(typeof runtimes.anilistSetupHandlers.notifyAnilistSetup, 'function');
assert.equal(typeof runtimes.openAnilistSetupWindow, 'function');
assert.equal(typeof runtimes.anilistTrackingHandlers.maybeRunAnilistPostWatchUpdate, 'function');
assert.equal(typeof runtimes.runStatsCliCommand, 'function');
assert.equal(typeof runtimes.appReadyRuntime.appReadyRuntimeRunner, 'function');
assert.equal(typeof runtimes.initializeOverlayRuntime, 'function');
});

View File

@@ -1,127 +0,0 @@
import { createOpenFirstRunSetupWindowHandler } from '../runtime/first-run-setup-window';
import { createRunStatsCliCommandHandler } from '../runtime/stats-cli-command';
import { createYomitanProfilePolicy } from '../runtime/yomitan-profile-policy';
import {
createBuildOpenAnilistSetupWindowMainDepsHandler,
createMaybeFocusExistingAnilistSetupWindowHandler,
createOpenAnilistSetupWindowHandler,
} from '../runtime/domains/anilist';
import {
createTrayRuntimeHandlers,
createYomitanExtensionRuntime,
createYomitanSettingsRuntime,
} from '../runtime/domains/overlay';
import {
composeAnilistSetupHandlers,
composeAnilistTrackingHandlers,
composeAppReadyRuntime,
composeJellyfinRuntimeHandlers,
composeMpvRuntimeHandlers,
composeOverlayVisibilityRuntime,
composeStatsStartupRuntime,
} from '../runtime/composers';
export interface MainBootRuntimesParams<TBrowserWindow, TMpvClient, TTokenizerDeps, TSubtitleData> {
overlayVisibilityRuntimeDeps: Parameters<typeof composeOverlayVisibilityRuntime>[0];
jellyfinRuntimeHandlerDeps: Parameters<typeof composeJellyfinRuntimeHandlers>[0];
anilistSetupDeps: Parameters<typeof composeAnilistSetupHandlers>[0];
buildOpenAnilistSetupWindowMainDeps: Parameters<
typeof createBuildOpenAnilistSetupWindowMainDepsHandler
>[0];
anilistTrackingDeps: Parameters<typeof composeAnilistTrackingHandlers>[0];
statsStartupRuntimeDeps: Parameters<typeof composeStatsStartupRuntime>[0];
runStatsCliCommandDeps: Parameters<typeof createRunStatsCliCommandHandler>[0];
appReadyRuntimeDeps: Parameters<typeof composeAppReadyRuntime>[0];
mpvRuntimeDeps: any;
trayRuntimeDeps: Parameters<typeof createTrayRuntimeHandlers>[0];
yomitanProfilePolicyDeps: Parameters<typeof createYomitanProfilePolicy>[0];
yomitanExtensionRuntimeDeps: Parameters<typeof createYomitanExtensionRuntime>[0];
yomitanSettingsRuntimeDeps: Parameters<typeof createYomitanSettingsRuntime>[0];
createOverlayRuntimeBootstrapHandlers: (params: {
initializeOverlayRuntimeMainDeps: unknown;
initializeOverlayRuntimeBootstrapDeps: unknown;
}) => {
initializeOverlayRuntime: () => void;
};
initializeOverlayRuntimeMainDeps: unknown;
initializeOverlayRuntimeBootstrapDeps: unknown;
}
export function createMainBootRuntimes<
TBrowserWindow,
TMpvClient,
TTokenizerDeps,
TSubtitleData,
>(
params: MainBootRuntimesParams<TBrowserWindow, TMpvClient, TTokenizerDeps, TSubtitleData>,
) {
const overlayVisibilityComposer = composeOverlayVisibilityRuntime(
params.overlayVisibilityRuntimeDeps,
);
const jellyfinRuntimeHandlers = composeJellyfinRuntimeHandlers(
params.jellyfinRuntimeHandlerDeps,
);
const anilistSetupHandlers = composeAnilistSetupHandlers(params.anilistSetupDeps);
const buildOpenAnilistSetupWindowMainDepsHandler =
createBuildOpenAnilistSetupWindowMainDepsHandler(params.buildOpenAnilistSetupWindowMainDeps);
const maybeFocusExistingAnilistSetupWindow =
params.buildOpenAnilistSetupWindowMainDeps.maybeFocusExistingSetupWindow;
const anilistTrackingHandlers = composeAnilistTrackingHandlers(params.anilistTrackingDeps);
const statsStartupRuntime = composeStatsStartupRuntime(params.statsStartupRuntimeDeps);
const runStatsCliCommand = createRunStatsCliCommandHandler(params.runStatsCliCommandDeps);
const appReadyRuntime = composeAppReadyRuntime(params.appReadyRuntimeDeps);
const mpvRuntimeHandlers = composeMpvRuntimeHandlers<any, any, any>(
params.mpvRuntimeDeps as any,
);
const trayRuntimeHandlers = createTrayRuntimeHandlers(params.trayRuntimeDeps);
const yomitanProfilePolicy = createYomitanProfilePolicy(params.yomitanProfilePolicyDeps);
const yomitanExtensionRuntime = createYomitanExtensionRuntime(
params.yomitanExtensionRuntimeDeps,
);
const yomitanSettingsRuntime = createYomitanSettingsRuntime(
params.yomitanSettingsRuntimeDeps,
);
const overlayRuntimeBootstrapHandlers = params.createOverlayRuntimeBootstrapHandlers({
initializeOverlayRuntimeMainDeps: params.initializeOverlayRuntimeMainDeps,
initializeOverlayRuntimeBootstrapDeps: params.initializeOverlayRuntimeBootstrapDeps,
});
return {
overlayVisibilityComposer,
jellyfinRuntimeHandlers,
anilistSetupHandlers,
maybeFocusExistingAnilistSetupWindow,
buildOpenAnilistSetupWindowMainDepsHandler,
openAnilistSetupWindow: () =>
createOpenAnilistSetupWindowHandler(buildOpenAnilistSetupWindowMainDepsHandler())(),
anilistTrackingHandlers,
statsStartupRuntime,
runStatsCliCommand,
appReadyRuntime,
mpvRuntimeHandlers,
trayRuntimeHandlers,
yomitanProfilePolicy,
yomitanExtensionRuntime,
yomitanSettingsRuntime,
initializeOverlayRuntime: overlayRuntimeBootstrapHandlers.initializeOverlayRuntime,
openFirstRunSetupWindowHandler: createOpenFirstRunSetupWindowHandler,
};
}
export const composeBootOverlayVisibilityRuntime = composeOverlayVisibilityRuntime;
export const composeBootJellyfinRuntimeHandlers = composeJellyfinRuntimeHandlers;
export const composeBootAnilistSetupHandlers = composeAnilistSetupHandlers;
export const composeBootAnilistTrackingHandlers = composeAnilistTrackingHandlers;
export const composeBootStatsStartupRuntime = composeStatsStartupRuntime;
export const createBootRunStatsCliCommandHandler = createRunStatsCliCommandHandler;
export const composeBootAppReadyRuntime = composeAppReadyRuntime;
export const composeBootMpvRuntimeHandlers = composeMpvRuntimeHandlers;
export const createBootTrayRuntimeHandlers = createTrayRuntimeHandlers;
export const createBootYomitanProfilePolicy = createYomitanProfilePolicy;
export const createBootYomitanExtensionRuntime = createYomitanExtensionRuntime;
export const createBootYomitanSettingsRuntime = createYomitanSettingsRuntime;
export const createBootMaybeFocusExistingAnilistSetupWindowHandler =
createMaybeFocusExistingAnilistSetupWindowHandler;
export const createBootBuildOpenAnilistSetupWindowMainDepsHandler =
createBuildOpenAnilistSetupWindowMainDepsHandler;
export const createBootOpenAnilistSetupWindowHandler = createOpenAnilistSetupWindowHandler;

View File

@@ -24,7 +24,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
{ scope: string; warn: () => void; info: () => void; error: () => void }, { scope: string; warn: () => void; info: () => void; error: () => void },
{ registry: boolean }, { registry: boolean },
{ getModalWindow: () => null }, { getModalWindow: () => null },
{ inputState: boolean }, { inputState: boolean; getModalInputExclusive: () => boolean; handleModalInputStateChange: (isActive: boolean) => void },
{ measurementStore: boolean }, { measurementStore: boolean },
{ modalRuntime: boolean }, { modalRuntime: boolean },
{ mpvSocketPath: string; texthookerPort: number }, { mpvSocketPath: string; texthookerPort: number },
@@ -50,7 +50,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
setPathValue = value; setPathValue = value;
}, },
quit: () => {}, quit: () => {},
on: (event) => { on: (event: string) => {
appOnCalls.push(event); appOnCalls.push(event);
return {}; return {};
}, },
@@ -80,7 +80,7 @@ test('createMainBootServices builds boot-phase service bundle', () => {
createOverlayManager: () => ({ createOverlayManager: () => ({
getModalWindow: () => null, getModalWindow: () => null,
}), }),
createOverlayModalInputState: () => ({ inputState: true }), createOverlayModalInputState: () => ({ inputState: true, getModalInputExclusive: () => false, handleModalInputStateChange: () => {} }),
createOverlayContentMeasurementStore: () => ({ measurementStore: true }), createOverlayContentMeasurementStore: () => ({ measurementStore: true }),
getSyncOverlayShortcutsForModal: () => () => {}, getSyncOverlayShortcutsForModal: () => () => {},
getSyncOverlayVisibilityForModal: () => () => {}, getSyncOverlayVisibilityForModal: () => () => {},

View File

@@ -1,5 +1,18 @@
import type { BrowserWindow } from 'electron';
import { ConfigStartupParseError } from '../../config'; import { ConfigStartupParseError } from '../../config';
export interface AppLifecycleShape {
requestSingleInstanceLock: () => boolean;
quit: () => void;
on: (event: string, listener: (...args: unknown[]) => void) => unknown;
whenReady: () => Promise<void>;
}
export interface OverlayModalInputStateShape {
getModalInputExclusive: () => boolean;
handleModalInputStateChange: (isActive: boolean) => void;
}
export interface MainBootServicesParams< export interface MainBootServicesParams<
TConfigService, TConfigService,
TAnilistTokenStore, TAnilistTokenStore,
@@ -37,7 +50,8 @@ export interface MainBootServicesParams<
app: { app: {
setPath: (name: string, value: string) => void; setPath: (name: string, value: string) => void;
quit: () => void; quit: () => void;
on: (...args: any[]) => unknown; // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type -- Electron App.on has 50+ overloaded signatures
on: Function;
whenReady: () => Promise<void>; whenReady: () => Promise<void>;
}; };
shouldBypassSingleInstanceLock: () => boolean; shouldBypassSingleInstanceLock: () => boolean;
@@ -58,7 +72,11 @@ export interface MainBootServicesParams<
}; };
createMainRuntimeRegistry: () => TRuntimeRegistry; createMainRuntimeRegistry: () => TRuntimeRegistry;
createOverlayManager: () => TOverlayManager; createOverlayManager: () => TOverlayManager;
createOverlayModalInputState: (params: any) => TOverlayModalInputState; createOverlayModalInputState: (params: {
getModalWindow: () => BrowserWindow | null;
syncOverlayShortcutsForModal: (isActive: boolean) => void;
syncOverlayVisibilityForModal: () => void;
}) => TOverlayModalInputState;
createOverlayContentMeasurementStore: (params: { createOverlayContentMeasurementStore: (params: {
logger: TLogger; logger: TLogger;
}) => TOverlayContentMeasurementStore; }) => TOverlayContentMeasurementStore;
@@ -118,12 +136,12 @@ export function createMainBootServices<
TSubtitleWebSocket, TSubtitleWebSocket,
TLogger, TLogger,
TRuntimeRegistry, TRuntimeRegistry,
TOverlayManager extends { getModalWindow: () => unknown }, TOverlayManager extends { getModalWindow: () => BrowserWindow | null },
TOverlayModalInputState, TOverlayModalInputState extends OverlayModalInputStateShape,
TOverlayContentMeasurementStore, TOverlayContentMeasurementStore,
TOverlayModalRuntime, TOverlayModalRuntime,
TAppState, TAppState,
TAppLifecycleApp, TAppLifecycleApp extends AppLifecycleShape,
>( >(
params: MainBootServicesParams< params: MainBootServicesParams<
TConfigService, TConfigService,
@@ -207,8 +225,7 @@ export function createMainBootServices<
overlayManager, overlayManager,
overlayModalInputState, overlayModalInputState,
onModalStateChange: (isActive: boolean) => onModalStateChange: (isActive: boolean) =>
(overlayModalInputState as { handleModalInputStateChange?: (isActive: boolean) => void }) overlayModalInputState.handleModalInputStateChange(isActive),
.handleModalInputStateChange?.(isActive),
}); });
const appState = params.createAppState({ const appState = params.createAppState({
mpvSocketPath: params.getDefaultSocketPath(), mpvSocketPath: params.getDefaultSocketPath(),
@@ -237,7 +254,7 @@ export function createMainBootServices<
return appLifecycleApp; return appLifecycleApp;
}, },
whenReady: () => params.app.whenReady(), whenReady: () => params.app.whenReady(),
} as TAppLifecycleApp; } satisfies AppLifecycleShape as TAppLifecycleApp;
return { return {
configDir, configDir,

View File

@@ -1,6 +1 @@
import * as fs from 'fs'; export { ensureDir } from '../../shared/fs-utils';
export function ensureDir(dirPath: string): void {
if (fs.existsSync(dirPath)) return;
fs.mkdirSync(dirPath, { recursive: true });
}

View File

@@ -1,5 +1,6 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { ensureDir } from '../../shared/fs-utils';
import type { AnilistCharacterDictionaryProfileScope } from '../../types'; import type { AnilistCharacterDictionaryProfileScope } from '../../types';
import type { import type {
CharacterDictionarySnapshotProgressCallbacks, CharacterDictionarySnapshotProgressCallbacks,
@@ -63,12 +64,6 @@ export interface CharacterDictionaryAutoSyncRuntimeDeps {
onSyncComplete?: (result: { mediaId: number; mediaTitle: string; changed: boolean }) => void; onSyncComplete?: (result: { mediaId: number; mediaTitle: string; changed: boolean }) => void;
} }
function ensureDir(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function normalizeMediaId(rawMediaId: number): number | null { function normalizeMediaId(rawMediaId: number): number | null {
const mediaId = Math.max(1, Math.floor(rawMediaId)); const mediaId = Math.max(1, Math.floor(rawMediaId));
return Number.isFinite(mediaId) ? mediaId : null; return Number.isFinite(mediaId) ? mediaId : null;

View File

@@ -3,11 +3,14 @@ import assert from 'node:assert/strict';
import { composeAnilistSetupHandlers } from './anilist-setup-composer'; import { composeAnilistSetupHandlers } from './anilist-setup-composer';
test('composeAnilistSetupHandlers returns callable setup handlers', () => { test('composeAnilistSetupHandlers returns callable setup handlers', () => {
const calls: string[] = [];
const composed = composeAnilistSetupHandlers({ const composed = composeAnilistSetupHandlers({
notifyDeps: { notifyDeps: {
hasMpvClient: () => false, hasMpvClient: () => false,
showMpvOsd: () => {}, showMpvOsd: () => {},
showDesktopNotification: () => {}, showDesktopNotification: (title, opts) => {
calls.push(`notify:${opts.body}`);
},
logInfo: () => {}, logInfo: () => {},
}, },
consumeTokenDeps: { consumeTokenDeps: {
@@ -37,4 +40,16 @@ test('composeAnilistSetupHandlers returns callable setup handlers', () => {
assert.equal(typeof composed.consumeAnilistSetupTokenFromUrl, 'function'); assert.equal(typeof composed.consumeAnilistSetupTokenFromUrl, 'function');
assert.equal(typeof composed.handleAnilistSetupProtocolUrl, 'function'); assert.equal(typeof composed.handleAnilistSetupProtocolUrl, 'function');
assert.equal(typeof composed.registerSubminerProtocolClient, 'function'); assert.equal(typeof composed.registerSubminerProtocolClient, 'function');
// notifyAnilistSetup forwards to showDesktopNotification when no MPV client
composed.notifyAnilistSetup('Setup complete');
assert.deepEqual(calls, ['notify:Setup complete']);
// handleAnilistSetupProtocolUrl returns false for non-subminer URLs
const handled = composed.handleAnilistSetupProtocolUrl('https://other.example.com/');
assert.equal(handled, false);
// handleAnilistSetupProtocolUrl returns true for subminer:// URLs
const handledProtocol = composed.handleAnilistSetupProtocolUrl('subminer://anilist-setup?code=abc');
assert.equal(handledProtocol, true);
}); });

View File

@@ -3,9 +3,13 @@ import test from 'node:test';
import { composeAppReadyRuntime } from './app-ready-composer'; import { composeAppReadyRuntime } from './app-ready-composer';
test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () => { test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () => {
const calls: string[] = [];
const composed = composeAppReadyRuntime({ const composed = composeAppReadyRuntime({
reloadConfigMainDeps: { reloadConfigMainDeps: {
reloadConfigStrict: () => ({ ok: true, path: '/tmp/config.jsonc', warnings: [] }), reloadConfigStrict: () => {
calls.push('reloadConfigStrict');
return { ok: true, path: '/tmp/config.jsonc', warnings: [] };
},
logInfo: () => {}, logInfo: () => {},
logWarning: () => {}, logWarning: () => {},
showDesktopNotification: () => {}, showDesktopNotification: () => {},
@@ -79,4 +83,8 @@ test('composeAppReadyRuntime returns reload/critical/app-ready handlers', () =>
assert.equal(typeof composed.reloadConfig, 'function'); assert.equal(typeof composed.reloadConfig, 'function');
assert.equal(typeof composed.criticalConfigError, 'function'); assert.equal(typeof composed.criticalConfigError, 'function');
assert.equal(typeof composed.appReadyRuntimeRunner, 'function'); assert.equal(typeof composed.appReadyRuntimeRunner, 'function');
// reloadConfig invokes the injected reloadConfigStrict dep
composed.reloadConfig();
assert.deepEqual(calls, ['reloadConfigStrict']);
}); });

View File

@@ -1,8 +1,10 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import type { CliArgs } from '../../../cli/args';
import { composeCliStartupHandlers } from './cli-startup-composer'; import { composeCliStartupHandlers } from './cli-startup-composer';
test('composeCliStartupHandlers returns callable CLI startup handlers', () => { test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
const calls: string[] = [];
const handlers = composeCliStartupHandlers({ const handlers = composeCliStartupHandlers({
cliCommandContextMainDeps: { cliCommandContextMainDeps: {
appState: {} as never, appState: {} as never,
@@ -57,7 +59,9 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
startBackgroundWarmups: () => {}, startBackgroundWarmups: () => {},
logInfo: () => {}, logInfo: () => {},
}, },
handleCliCommandRuntimeServiceWithContext: () => {}, handleCliCommandRuntimeServiceWithContext: (args, _source, _ctx) => {
calls.push(`handleCommand:${(args as { command?: string }).command ?? 'unknown'}`);
},
}, },
initialArgsRuntimeHandlerMainDeps: { initialArgsRuntimeHandlerMainDeps: {
getInitialArgs: () => null, getInitialArgs: () => null,
@@ -80,4 +84,8 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
assert.equal(typeof handlers.createCliCommandContext, 'function'); assert.equal(typeof handlers.createCliCommandContext, 'function');
assert.equal(typeof handlers.handleCliCommand, 'function'); assert.equal(typeof handlers.handleCliCommand, 'function');
assert.equal(typeof handlers.handleInitialArgs, 'function'); assert.equal(typeof handlers.handleInitialArgs, 'function');
// handleCliCommand routes to the injected handleCliCommandRuntimeServiceWithContext dep
handlers.handleCliCommand({ command: 'start' } as unknown as CliArgs);
assert.deepEqual(calls, ['handleCommand:start']);
}); });

View File

@@ -8,9 +8,6 @@ export * from './ipc-runtime-composer';
export * from './jellyfin-remote-composer'; export * from './jellyfin-remote-composer';
export * from './jellyfin-runtime-composer'; export * from './jellyfin-runtime-composer';
export * from './mpv-runtime-composer'; export * from './mpv-runtime-composer';
export * from './overlay-window-composer';
export * from './overlay-visibility-runtime-composer'; export * from './overlay-visibility-runtime-composer';
export * from './shortcuts-runtime-composer'; export * from './shortcuts-runtime-composer';
export * from './stats-startup-composer';
export * from './subtitle-prefetch-runtime-composer';
export * from './startup-lifecycle-composer'; export * from './startup-lifecycle-composer';

View File

@@ -2,8 +2,11 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { composeJellyfinRemoteHandlers } from './jellyfin-remote-composer'; import { composeJellyfinRemoteHandlers } from './jellyfin-remote-composer';
test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers', () => { test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers', async () => {
let lastProgressAt = 0; let lastProgressAt = 0;
let activePlayback: unknown = { itemId: 'item-1', mediaSourceId: 'src-1', playMethod: 'DirectPlay', audioStreamIndex: null, subtitleStreamIndex: null };
const calls: string[] = [];
const composed = composeJellyfinRemoteHandlers({ const composed = composeJellyfinRemoteHandlers({
getConfiguredSession: () => null, getConfiguredSession: () => null,
getClientInfo: () => getClientInfo: () =>
@@ -14,8 +17,11 @@ test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers',
getMpvClient: () => null, getMpvClient: () => null,
sendMpvCommand: () => {}, sendMpvCommand: () => {},
jellyfinTicksToSeconds: () => 0, jellyfinTicksToSeconds: () => 0,
getActivePlayback: () => null, getActivePlayback: () => activePlayback as never,
clearActivePlayback: () => {}, clearActivePlayback: () => {
activePlayback = null;
calls.push('clearActivePlayback');
},
getSession: () => null, getSession: () => null,
getNow: () => 0, getNow: () => 0,
getLastProgressAtMs: () => lastProgressAt, getLastProgressAtMs: () => lastProgressAt,
@@ -32,4 +38,9 @@ test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers',
assert.equal(typeof composed.handleJellyfinRemotePlay, 'function'); assert.equal(typeof composed.handleJellyfinRemotePlay, 'function');
assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function'); assert.equal(typeof composed.handleJellyfinRemotePlaystate, 'function');
assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function'); assert.equal(typeof composed.handleJellyfinRemoteGeneralCommand, 'function');
// reportJellyfinRemoteStopped clears active playback when there is no connected session
await composed.reportJellyfinRemoteStopped();
assert.equal(activePlayback, null);
assert.deepEqual(calls, ['clearActivePlayback']);
}); });

View File

@@ -190,4 +190,9 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
assert.equal(typeof composed.stopJellyfinRemoteSession, 'function'); assert.equal(typeof composed.stopJellyfinRemoteSession, 'function');
assert.equal(typeof composed.runJellyfinCommand, 'function'); assert.equal(typeof composed.runJellyfinCommand, 'function');
assert.equal(typeof composed.openJellyfinSetupWindow, 'function'); assert.equal(typeof composed.openJellyfinSetupWindow, 'function');
// getResolvedJellyfinConfig forwards to the injected getResolvedConfig dep
const jellyfinConfig = composed.getResolvedJellyfinConfig();
assert.equal(jellyfinConfig.enabled, false);
assert.equal(jellyfinConfig.serverUrl, '');
}); });

View File

@@ -30,37 +30,13 @@ function createDeferred(): { promise: Promise<void>; resolve: () => void } {
return { promise, resolve }; return { promise, resolve };
} }
test('composeMpvRuntimeHandlers returns callable handlers and forwards to injected deps', async () => { class DefaultFakeMpvClient {
const calls: string[] = []; connect(): void {}
let started = false; on(): void {}
let metrics = BASE_METRICS; }
let mecabTokenizer: { id: string } | null = null;
class FakeMpvClient { function createDefaultMpvFixture() {
connected = false; return {
constructor(
public socketPath: string,
public options: unknown,
) {
const autoStartOverlay = (options as { autoStartOverlay: boolean }).autoStartOverlay;
calls.push(`create-client:${socketPath}`);
calls.push(`auto-start:${String(autoStartOverlay)}`);
}
on(): void {}
connect(): void {
this.connected = true;
calls.push('client-connect');
}
}
const composed = composeMpvRuntimeHandlers<
FakeMpvClient,
{ isKnownWord: (text: string) => boolean },
{ text: string }
>({
bindMpvMainEventHandlersMainDeps: { bindMpvMainEventHandlersMainDeps: {
appState: { appState: {
initialArgs: null, initialArgs: null,
@@ -97,15 +73,119 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
updateSubtitleRenderMetrics: () => {}, updateSubtitleRenderMetrics: () => {},
}, },
mpvClientRuntimeServiceFactoryMainDeps: { mpvClientRuntimeServiceFactoryMainDeps: {
createClient: FakeMpvClient, createClient: DefaultFakeMpvClient,
getSocketPath: () => '/tmp/mpv.sock', getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }), getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => true, isAutoStartOverlayEnabled: () => false,
setOverlayVisible: () => {}, setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false, isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null, getReconnectTimer: () => null,
setReconnectTimer: () => {}, setReconnectTimer: () => {},
}, },
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current: MpvSubtitleRenderMetrics, patch: Partial<MpvSubtitleRenderMetrics>) => ({
next: { ...current, ...patch },
changed: true,
}),
broadcastMetrics: () => {},
},
tokenizer: {
buildTokenizerDepsMainDeps: {
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword' as const,
getNPlusOneEnabled: () => false,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => false,
getFrequencyDictionaryEnabled: () => false,
getFrequencyDictionaryMatchMode: () => 'headword' as const,
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: () => ({ isKnownWord: () => false }),
tokenizeSubtitle: async (text: string) => ({ text }),
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => null,
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({ id: 'mecab' }),
checkAvailability: async () => {},
},
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => {},
ensureFrequencyDictionaryLookup: async () => {},
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => {},
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
};
}
test('composeMpvRuntimeHandlers returns callable handlers and forwards to injected deps', async () => {
const calls: string[] = [];
let started = false;
let metrics = BASE_METRICS;
let mecabTokenizer: { id: string } | null = null;
class FakeMpvClient {
connected = false;
constructor(
public socketPath: string,
public options: unknown,
) {
const autoStartOverlay = (options as { autoStartOverlay: boolean }).autoStartOverlay;
calls.push(`create-client:${socketPath}`);
calls.push(`auto-start:${String(autoStartOverlay)}`);
}
on(): void {}
connect(): void {
this.connected = true;
calls.push('client-connect');
}
}
const fixture = createDefaultMpvFixture();
const composed = composeMpvRuntimeHandlers<
FakeMpvClient,
{ isKnownWord: (text: string) => boolean },
{ text: string }
>({
...fixture,
mpvClientRuntimeServiceFactoryMainDeps: {
...fixture.mpvClientRuntimeServiceFactoryMainDeps,
createClient: FakeMpvClient,
isAutoStartOverlayEnabled: () => true,
},
updateMpvSubtitleRenderMetricsMainDeps: { updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => metrics, getCurrentMetrics: () => metrics,
setCurrentMetrics: (next) => { setCurrentMetrics: (next) => {
@@ -121,25 +201,12 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
}, },
}, },
tokenizer: { tokenizer: {
...fixture.tokenizer,
buildTokenizerDepsMainDeps: { buildTokenizerDepsMainDeps: {
getYomitanExt: () => null, ...fixture.tokenizer.buildTokenizerDepsMainDeps,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: (text) => text === 'known', isKnownWord: (text) => text === 'known',
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => true, getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true, getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
}, },
createTokenizerRuntimeDeps: (deps) => { createTokenizerRuntimeDeps: (deps) => {
calls.push('create-tokenizer-runtime-deps'); calls.push('create-tokenizer-runtime-deps');
@@ -184,12 +251,12 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
}, },
}, },
startBackgroundWarmupsMainDeps: { startBackgroundWarmupsMainDeps: {
...fixture.warmups.startBackgroundWarmupsMainDeps,
getStarted: () => started, getStarted: () => started,
setStarted: (next) => { setStarted: (next) => {
started = next; started = next;
calls.push(`set-started:${String(next)}`); calls.push(`set-started:${String(next)}`);
}, },
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => { ensureYomitanExtensionLoaded: async () => {
calls.push('warmup-yomitan'); calls.push('warmup-yomitan');
}, },
@@ -197,7 +264,6 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
shouldWarmupYomitanExtension: () => true, shouldWarmupYomitanExtension: () => true,
shouldWarmupSubtitleDictionaries: () => true, shouldWarmupSubtitleDictionaries: () => true,
shouldWarmupJellyfinRemoteSession: () => true, shouldWarmupJellyfinRemoteSession: () => true,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => { startJellyfinRemoteSession: async () => {
calls.push('warmup-jellyfin'); calls.push('warmup-jellyfin');
}, },
@@ -264,86 +330,20 @@ test('composeMpvRuntimeHandlers skips MeCab warmup when all POS-dependent annota
} }
} }
const fixture = createDefaultMpvFixture();
const composed = composeMpvRuntimeHandlers< const composed = composeMpvRuntimeHandlers<
FakeMpvClient, FakeMpvClient,
{ isKnownWord: (text: string) => boolean }, { isKnownWord: (text: string) => boolean },
{ text: string } { text: string }
>({ >({
bindMpvMainEventHandlersMainDeps: { ...fixture,
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: { mpvClientRuntimeServiceFactoryMainDeps: {
...fixture.mpvClientRuntimeServiceFactoryMainDeps,
createClient: FakeMpvClient, createClient: FakeMpvClient,
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => true, isAutoStartOverlayEnabled: () => true,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current, patch) => ({ next: { ...current, ...patch }, changed: true }),
broadcastMetrics: () => {},
}, },
tokenizer: { tokenizer: {
buildTokenizerDepsMainDeps: { ...fixture.tokenizer,
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getNPlusOneEnabled: () => false,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => false,
getFrequencyDictionaryEnabled: () => false,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: () => ({ isKnownWord: () => false }),
tokenizeSubtitle: async (text) => ({ text }),
createMecabTokenizerAndCheckMainDeps: { createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => mecabTokenizer, getMecabTokenizer: () => mecabTokenizer,
setMecabTokenizer: (next) => { setMecabTokenizer: (next) => {
@@ -358,29 +358,6 @@ test('composeMpvRuntimeHandlers skips MeCab warmup when all POS-dependent annota
calls.push('check-mecab'); calls.push('check-mecab');
}, },
}, },
prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => {},
ensureFrequencyDictionaryLookup: async () => {},
},
},
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => {},
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
}, },
}); });
@@ -395,98 +372,19 @@ test('composeMpvRuntimeHandlers runs tokenization warmup once across sequential
let prewarmFrequencyCalls = 0; let prewarmFrequencyCalls = 0;
const tokenizeCalls: string[] = []; const tokenizeCalls: string[] = [];
const fixture = createDefaultMpvFixture();
const composed = composeMpvRuntimeHandlers< const composed = composeMpvRuntimeHandlers<
{ connect: () => void; on: () => void }, { connect: () => void; on: () => void },
{ isKnownWord: () => boolean }, { isKnownWord: () => boolean },
{ text: string } { text: string }
>({ >({
bindMpvMainEventHandlersMainDeps: { ...fixture,
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: class {
connect(): void {}
on(): void {}
},
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => false,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current, patch) => ({ next: { ...current, ...patch }, changed: true }),
broadcastMetrics: () => {},
},
tokenizer: { tokenizer: {
buildTokenizerDepsMainDeps: { ...fixture.tokenizer,
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getNPlusOneEnabled: () => false,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => false,
getFrequencyDictionaryEnabled: () => false,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
},
createTokenizerRuntimeDeps: () => ({ isKnownWord: () => false }),
tokenizeSubtitle: async (text) => { tokenizeSubtitle: async (text) => {
tokenizeCalls.push(text); tokenizeCalls.push(text);
return { text }; return { text };
}, },
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => null,
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({ id: 'mecab' }),
checkAvailability: async () => {},
},
prewarmSubtitleDictionariesMainDeps: { prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => { ensureJlptDictionaryLookup: async () => {
prewarmJlptCalls += 1; prewarmJlptCalls += 1;
@@ -497,24 +395,12 @@ test('composeMpvRuntimeHandlers runs tokenization warmup once across sequential
}, },
}, },
warmups: { warmups: {
launchBackgroundWarmupTaskMainDeps: { ...fixture.warmups,
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: { startBackgroundWarmupsMainDeps: {
getStarted: () => false, ...fixture.warmups.startBackgroundWarmupsMainDeps,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => { ensureYomitanExtensionLoaded: async () => {
yomitanWarmupCalls += 1; yomitanWarmupCalls += 1;
}, },
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
}, },
}, },
}); });
@@ -534,93 +420,23 @@ test('composeMpvRuntimeHandlers does not block first tokenization on dictionary
const mecabDeferred = createDeferred(); const mecabDeferred = createDeferred();
let tokenizeResolved = false; let tokenizeResolved = false;
const fixture = createDefaultMpvFixture();
const composed = composeMpvRuntimeHandlers< const composed = composeMpvRuntimeHandlers<
{ connect: () => void; on: () => void }, { connect: () => void; on: () => void },
{ isKnownWord: () => boolean }, { isKnownWord: () => boolean },
{ text: string } { text: string }
>({ >({
bindMpvMainEventHandlersMainDeps: { ...fixture,
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: class {
connect(): void {}
on(): void {}
},
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => false,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current, patch) => ({ next: { ...current, ...patch }, changed: true }),
broadcastMetrics: () => {},
},
tokenizer: { tokenizer: {
...fixture.tokenizer,
buildTokenizerDepsMainDeps: { buildTokenizerDepsMainDeps: {
getYomitanExt: () => null, ...fixture.tokenizer.buildTokenizerDepsMainDeps,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getNPlusOneEnabled: () => true, getNPlusOneEnabled: () => true,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => true, getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true, getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
}, },
createTokenizerRuntimeDeps: () => ({ isKnownWord: () => false }),
tokenizeSubtitle: async (text) => ({ text }),
createMecabTokenizerAndCheckMainDeps: { createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => null, ...fixture.tokenizer.createMecabTokenizerAndCheckMainDeps,
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({ id: 'mecab' }),
checkAvailability: async () => mecabDeferred.promise, checkAvailability: async () => mecabDeferred.promise,
}, },
prewarmSubtitleDictionariesMainDeps: { prewarmSubtitleDictionariesMainDeps: {
@@ -628,25 +444,6 @@ test('composeMpvRuntimeHandlers does not block first tokenization on dictionary
ensureFrequencyDictionaryLookup: async () => frequencyDeferred.promise, ensureFrequencyDictionaryLookup: async () => frequencyDeferred.promise,
}, },
}, },
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => undefined,
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
}); });
const tokenizePromise = composed.tokenizeSubtitle('first line').then(() => { const tokenizePromise = composed.tokenizeSubtitle('first line').then(() => {
@@ -667,86 +464,19 @@ test('composeMpvRuntimeHandlers shows annotation loading OSD after tokenization-
const frequencyDeferred = createDeferred(); const frequencyDeferred = createDeferred();
const osdMessages: string[] = []; const osdMessages: string[] = [];
const fixture = createDefaultMpvFixture();
const composed = composeMpvRuntimeHandlers< const composed = composeMpvRuntimeHandlers<
{ connect: () => void; on: () => void }, { connect: () => void; on: () => void },
{ onTokenizationReady?: (text: string) => void }, { onTokenizationReady?: (text: string) => void },
{ text: string } { text: string }
>({ >({
bindMpvMainEventHandlersMainDeps: { ...fixture,
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: class {
connect(): void {}
on(): void {}
},
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => false,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current, patch) => ({ next: { ...current, ...patch }, changed: true }),
broadcastMetrics: () => {},
},
tokenizer: { tokenizer: {
...fixture.tokenizer,
buildTokenizerDepsMainDeps: { buildTokenizerDepsMainDeps: {
getYomitanExt: () => null, ...fixture.tokenizer.buildTokenizerDepsMainDeps,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getNPlusOneEnabled: () => false,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => true, getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true, getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => null,
}, },
createTokenizerRuntimeDeps: (deps) => createTokenizerRuntimeDeps: (deps) =>
deps as unknown as { onTokenizationReady?: (text: string) => void }, deps as unknown as { onTokenizationReady?: (text: string) => void },
@@ -754,12 +484,6 @@ test('composeMpvRuntimeHandlers shows annotation loading OSD after tokenization-
deps.onTokenizationReady?.(text); deps.onTokenizationReady?.(text);
return { text }; return { text };
}, },
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => null,
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({ id: 'mecab' }),
checkAvailability: async () => {},
},
prewarmSubtitleDictionariesMainDeps: { prewarmSubtitleDictionariesMainDeps: {
ensureJlptDictionaryLookup: async () => jlptDeferred.promise, ensureJlptDictionaryLookup: async () => jlptDeferred.promise,
ensureFrequencyDictionaryLookup: async () => frequencyDeferred.promise, ensureFrequencyDictionaryLookup: async () => frequencyDeferred.promise,
@@ -768,25 +492,6 @@ test('composeMpvRuntimeHandlers shows annotation loading OSD after tokenization-
}, },
}, },
}, },
warmups: {
launchBackgroundWarmupTaskMainDeps: {
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: {
getStarted: () => false,
setStarted: () => {},
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => undefined,
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => false,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
},
},
}); });
const warmupPromise = composed.startTokenizationWarmups(); const warmupPromise = composed.startTokenizationWarmups();
@@ -814,89 +519,22 @@ test('composeMpvRuntimeHandlers reuses completed background tokenization warmups
let frequencyWarmupCalls = 0; let frequencyWarmupCalls = 0;
let mecabTokenizer: { tokenize: () => Promise<never[]> } | null = null; let mecabTokenizer: { tokenize: () => Promise<never[]> } | null = null;
const fixture = createDefaultMpvFixture();
const composed = composeMpvRuntimeHandlers< const composed = composeMpvRuntimeHandlers<
{ connect: () => void; on: () => void }, { connect: () => void; on: () => void },
{ isKnownWord: () => boolean }, { isKnownWord: () => boolean },
{ text: string } { text: string }
>({ >({
bindMpvMainEventHandlersMainDeps: { ...fixture,
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: null,
immersionTracker: null,
subtitleTimingTracker: null,
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: null,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
refreshDiscordPresence: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
},
mpvClientRuntimeServiceFactoryMainDeps: {
createClient: class {
connect(): void {}
on(): void {}
},
getSocketPath: () => '/tmp/mpv.sock',
getResolvedConfig: () => ({ auto_start_overlay: false }),
isAutoStartOverlayEnabled: () => false,
setOverlayVisible: () => {},
isVisibleOverlayVisible: () => false,
getReconnectTimer: () => null,
setReconnectTimer: () => {},
},
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current, patch) => ({ next: { ...current, ...patch }, changed: true }),
broadcastMetrics: () => {},
},
tokenizer: { tokenizer: {
...fixture.tokenizer,
buildTokenizerDepsMainDeps: { buildTokenizerDepsMainDeps: {
getYomitanExt: () => null, ...fixture.tokenizer.buildTokenizerDepsMainDeps,
getYomitanParserWindow: () => null,
setYomitanParserWindow: () => {},
getYomitanParserReadyPromise: () => null,
setYomitanParserReadyPromise: () => {},
getYomitanParserInitPromise: () => null,
setYomitanParserInitPromise: () => {},
isKnownWord: () => false,
recordLookup: () => {},
getKnownWordMatchMode: () => 'headword',
getNPlusOneEnabled: () => true, getNPlusOneEnabled: () => true,
getMinSentenceWordsForNPlusOne: () => 3,
getJlptLevel: () => null,
getJlptEnabled: () => true, getJlptEnabled: () => true,
getFrequencyDictionaryEnabled: () => true, getFrequencyDictionaryEnabled: () => true,
getFrequencyDictionaryMatchMode: () => 'headword',
getFrequencyRank: () => null,
getYomitanGroupDebugEnabled: () => false,
getMecabTokenizer: () => mecabTokenizer, getMecabTokenizer: () => mecabTokenizer,
}, },
createTokenizerRuntimeDeps: () => ({ isKnownWord: () => false }),
tokenizeSubtitle: async (text) => ({ text }),
createMecabTokenizerAndCheckMainDeps: { createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => mecabTokenizer, getMecabTokenizer: () => mecabTokenizer,
setMecabTokenizer: (next) => { setMecabTokenizer: (next) => {
@@ -917,26 +555,19 @@ test('composeMpvRuntimeHandlers reuses completed background tokenization warmups
}, },
}, },
warmups: { warmups: {
launchBackgroundWarmupTaskMainDeps: { ...fixture.warmups,
now: () => 0,
logDebug: () => {},
logWarn: () => {},
},
startBackgroundWarmupsMainDeps: { startBackgroundWarmupsMainDeps: {
...fixture.warmups.startBackgroundWarmupsMainDeps,
getStarted: () => started, getStarted: () => started,
setStarted: (next) => { setStarted: (next) => {
started = next; started = next;
}, },
isTexthookerOnlyMode: () => false,
ensureYomitanExtensionLoaded: async () => { ensureYomitanExtensionLoaded: async () => {
yomitanWarmupCalls += 1; yomitanWarmupCalls += 1;
}, },
shouldWarmupMecab: () => true, shouldWarmupMecab: () => true,
shouldWarmupYomitanExtension: () => true, shouldWarmupYomitanExtension: () => true,
shouldWarmupSubtitleDictionaries: () => true, shouldWarmupSubtitleDictionaries: () => true,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
}, },
}, },
}); });

View File

@@ -3,15 +3,20 @@ import test from 'node:test';
import { composeOverlayVisibilityRuntime } from './overlay-visibility-runtime-composer'; import { composeOverlayVisibilityRuntime } from './overlay-visibility-runtime-composer';
test('composeOverlayVisibilityRuntime returns overlay visibility handlers', () => { test('composeOverlayVisibilityRuntime returns overlay visibility handlers', () => {
const calls: string[] = [];
const composed = composeOverlayVisibilityRuntime({ const composed = composeOverlayVisibilityRuntime({
overlayVisibilityRuntime: { overlayVisibilityRuntime: {
updateVisibleOverlayVisibility: () => {}, updateVisibleOverlayVisibility: () => {
calls.push('updateVisibleOverlayVisibility');
},
}, },
restorePreviousSecondarySubVisibilityMainDeps: { restorePreviousSecondarySubVisibilityMainDeps: {
getMpvClient: () => null, getMpvClient: () => null,
}, },
broadcastRuntimeOptionsChangedMainDeps: { broadcastRuntimeOptionsChangedMainDeps: {
broadcastRuntimeOptionsChangedRuntime: () => {}, broadcastRuntimeOptionsChangedRuntime: () => {
calls.push('broadcastRuntimeOptionsChangedRuntime');
},
getRuntimeOptionsState: () => [], getRuntimeOptionsState: () => [],
broadcastToOverlayWindows: () => {}, broadcastToOverlayWindows: () => {},
}, },
@@ -24,7 +29,9 @@ test('composeOverlayVisibilityRuntime returns overlay visibility handlers', () =
setCurrentEnabled: () => {}, setCurrentEnabled: () => {},
}, },
openRuntimeOptionsPaletteMainDeps: { openRuntimeOptionsPaletteMainDeps: {
openRuntimeOptionsPaletteRuntime: () => {}, openRuntimeOptionsPaletteRuntime: () => {
calls.push('openRuntimeOptionsPaletteRuntime');
},
}, },
}); });
@@ -34,4 +41,16 @@ test('composeOverlayVisibilityRuntime returns overlay visibility handlers', () =
assert.equal(typeof composed.sendToActiveOverlayWindow, 'function'); assert.equal(typeof composed.sendToActiveOverlayWindow, 'function');
assert.equal(typeof composed.setOverlayDebugVisualizationEnabled, 'function'); assert.equal(typeof composed.setOverlayDebugVisualizationEnabled, 'function');
assert.equal(typeof composed.openRuntimeOptionsPalette, 'function'); assert.equal(typeof composed.openRuntimeOptionsPalette, 'function');
// updateVisibleOverlayVisibility passes through to the injected runtime dep
composed.updateVisibleOverlayVisibility();
assert.deepEqual(calls, ['updateVisibleOverlayVisibility']);
// openRuntimeOptionsPalette forwards to the injected runtime dep
composed.openRuntimeOptionsPalette();
assert.deepEqual(calls, ['updateVisibleOverlayVisibility', 'openRuntimeOptionsPaletteRuntime']);
// broadcastRuntimeOptionsChanged forwards to the injected runtime dep
composed.broadcastRuntimeOptionsChanged();
assert.ok(calls.includes('broadcastRuntimeOptionsChangedRuntime'));
}); });

View File

@@ -1,34 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeOverlayWindowHandlers } from './overlay-window-composer';
test('composeOverlayWindowHandlers returns overlay window handlers', () => {
let mainWindow: { kind: string } | null = null;
let modalWindow: { kind: string } | null = null;
const handlers = composeOverlayWindowHandlers<{ kind: string }>({
createOverlayWindowDeps: {
createOverlayWindowCore: (kind) => ({ kind }),
isDev: false,
ensureOverlayWindowLevel: () => {},
onRuntimeOptionsChanged: () => {},
setOverlayDebugVisualizationEnabled: () => {},
isOverlayVisible: (kind) => kind === 'visible',
tryHandleOverlayShortcutLocalFallback: () => false,
forwardTabToMpv: () => {},
onWindowClosed: () => {},
getYomitanSession: () => null,
},
setMainWindow: (window) => {
mainWindow = window;
},
setModalWindow: (window) => {
modalWindow = window;
},
});
assert.deepEqual(handlers.createMainWindow(), { kind: 'visible' });
assert.deepEqual(mainWindow, { kind: 'visible' });
assert.deepEqual(handlers.createModalWindow(), { kind: 'modal' });
assert.deepEqual(modalWindow, { kind: 'modal' });
});

View File

@@ -1,18 +0,0 @@
import { createOverlayWindowRuntimeHandlers } from '../overlay-window-runtime-handlers';
import type { ComposerInputs, ComposerOutputs } from './contracts';
type OverlayWindowRuntimeDeps<TWindow> =
Parameters<typeof createOverlayWindowRuntimeHandlers<TWindow>>[0];
type OverlayWindowRuntimeHandlers<TWindow> = ReturnType<
typeof createOverlayWindowRuntimeHandlers<TWindow>
>;
export type OverlayWindowComposerOptions<TWindow> = ComposerInputs<OverlayWindowRuntimeDeps<TWindow>>;
export type OverlayWindowComposerResult<TWindow> =
ComposerOutputs<OverlayWindowRuntimeHandlers<TWindow>>;
export function composeOverlayWindowHandlers<TWindow>(
options: OverlayWindowComposerOptions<TWindow>,
): OverlayWindowComposerResult<TWindow> {
return createOverlayWindowRuntimeHandlers<TWindow>(options);
}

View File

@@ -3,6 +3,7 @@ import test from 'node:test';
import { composeShortcutRuntimes } from './shortcuts-runtime-composer'; import { composeShortcutRuntimes } from './shortcuts-runtime-composer';
test('composeShortcutRuntimes returns callable shortcut runtime handlers', () => { test('composeShortcutRuntimes returns callable shortcut runtime handlers', () => {
const calls: string[] = [];
const composed = composeShortcutRuntimes({ const composed = composeShortcutRuntimes({
globalShortcuts: { globalShortcuts: {
getConfiguredShortcutsMainDeps: { getConfiguredShortcutsMainDeps: {
@@ -39,9 +40,13 @@ test('composeShortcutRuntimes returns callable shortcut runtime handlers', () =>
}, },
overlayShortcutsRuntimeMainDeps: { overlayShortcutsRuntimeMainDeps: {
overlayShortcutsRuntime: { overlayShortcutsRuntime: {
registerOverlayShortcuts: () => {}, registerOverlayShortcuts: () => {
calls.push('registerOverlayShortcuts');
},
unregisterOverlayShortcuts: () => {}, unregisterOverlayShortcuts: () => {},
syncOverlayShortcuts: () => {}, syncOverlayShortcuts: () => {
calls.push('syncOverlayShortcuts');
},
refreshOverlayShortcuts: () => {}, refreshOverlayShortcuts: () => {},
}, },
}, },
@@ -58,4 +63,12 @@ test('composeShortcutRuntimes returns callable shortcut runtime handlers', () =>
assert.equal(typeof composed.unregisterOverlayShortcuts, 'function'); assert.equal(typeof composed.unregisterOverlayShortcuts, 'function');
assert.equal(typeof composed.syncOverlayShortcuts, 'function'); assert.equal(typeof composed.syncOverlayShortcuts, 'function');
assert.equal(typeof composed.refreshOverlayShortcuts, 'function'); assert.equal(typeof composed.refreshOverlayShortcuts, 'function');
// registerOverlayShortcuts forwards to the injected overlayShortcutsRuntime dep
composed.registerOverlayShortcuts();
assert.deepEqual(calls, ['registerOverlayShortcuts']);
// syncOverlayShortcuts forwards to the injected overlayShortcutsRuntime dep
composed.syncOverlayShortcuts();
assert.deepEqual(calls, ['registerOverlayShortcuts', 'syncOverlayShortcuts']);
}); });

View File

@@ -3,6 +3,7 @@ import test from 'node:test';
import { composeStartupLifecycleHandlers } from './startup-lifecycle-composer'; import { composeStartupLifecycleHandlers } from './startup-lifecycle-composer';
test('composeStartupLifecycleHandlers returns callable startup lifecycle handlers', () => { test('composeStartupLifecycleHandlers returns callable startup lifecycle handlers', () => {
const calls: string[] = [];
const composed = composeStartupLifecycleHandlers({ const composed = composeStartupLifecycleHandlers({
registerProtocolUrlHandlersMainDeps: { registerProtocolUrlHandlersMainDeps: {
registerOpenUrl: () => {}, registerOpenUrl: () => {},
@@ -51,7 +52,9 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
getAllWindowCount: () => 0, getAllWindowCount: () => 0,
}, },
restoreWindowsOnActivateMainDeps: { restoreWindowsOnActivateMainDeps: {
createMainWindow: () => {}, createMainWindow: () => {
calls.push('createMainWindow');
},
updateVisibleOverlayVisibility: () => {}, updateVisibleOverlayVisibility: () => {},
syncOverlayMpvSubtitleSuppression: () => {}, syncOverlayMpvSubtitleSuppression: () => {},
}, },
@@ -61,4 +64,11 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
assert.equal(typeof composed.onWillQuitCleanup, 'function'); assert.equal(typeof composed.onWillQuitCleanup, 'function');
assert.equal(typeof composed.shouldRestoreWindowsOnActivate, 'function'); assert.equal(typeof composed.shouldRestoreWindowsOnActivate, 'function');
assert.equal(typeof composed.restoreWindowsOnActivate, 'function'); assert.equal(typeof composed.restoreWindowsOnActivate, 'function');
// shouldRestoreWindowsOnActivate returns false when overlay runtime is not initialized
assert.equal(composed.shouldRestoreWindowsOnActivate(), false);
// restoreWindowsOnActivate invokes the injected createMainWindow dep
composed.restoreWindowsOnActivate();
assert.deepEqual(calls, ['createMainWindow']);
}); });

View File

@@ -1,23 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeStatsStartupRuntime } from './stats-startup-composer';
test('composeStatsStartupRuntime returns stats startup handlers', async () => {
const composed = composeStatsStartupRuntime({
ensureStatsServerStarted: () => 'http://127.0.0.1:8766',
ensureBackgroundStatsServerStarted: () => ({
url: 'http://127.0.0.1:8766',
runningInCurrentProcess: true,
}),
stopBackgroundStatsServer: async () => ({ ok: true, stale: false }),
ensureImmersionTrackerStarted: () => {},
});
assert.equal(composed.ensureStatsServerStarted(), 'http://127.0.0.1:8766');
assert.deepEqual(composed.ensureBackgroundStatsServerStarted(), {
url: 'http://127.0.0.1:8766',
runningInCurrentProcess: true,
});
assert.deepEqual(await composed.stopBackgroundStatsServer(), { ok: true, stale: false });
assert.equal(typeof composed.ensureImmersionTrackerStarted, 'function');
});

View File

@@ -1,26 +0,0 @@
import type { ComposerInputs, ComposerOutputs } from './contracts';
type BackgroundStatsStartResult = {
url: string;
runningInCurrentProcess: boolean;
};
type BackgroundStatsStopResult = {
ok: boolean;
stale: boolean;
};
export type StatsStartupComposerOptions = ComposerInputs<{
ensureStatsServerStarted: () => string;
ensureBackgroundStatsServerStarted: () => BackgroundStatsStartResult;
stopBackgroundStatsServer: () => Promise<BackgroundStatsStopResult> | BackgroundStatsStopResult;
ensureImmersionTrackerStarted: () => void;
}>;
export type StatsStartupComposerResult = ComposerOutputs<StatsStartupComposerOptions>;
export function composeStatsStartupRuntime(
options: StatsStartupComposerOptions,
): StatsStartupComposerResult {
return options;
}

View File

@@ -1,23 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeSubtitlePrefetchRuntime } from './subtitle-prefetch-runtime-composer';
test('composeSubtitlePrefetchRuntime returns subtitle prefetch runtime helpers', () => {
const composed = composeSubtitlePrefetchRuntime({
subtitlePrefetchInitController: {
cancelPendingInit: () => {},
initSubtitlePrefetch: async () => {},
},
refreshSubtitleSidebarFromSource: async () => {},
refreshSubtitlePrefetchFromActiveTrack: async () => {},
scheduleSubtitlePrefetchRefresh: () => {},
clearScheduledSubtitlePrefetchRefresh: () => {},
});
assert.equal(typeof composed.cancelPendingInit, 'function');
assert.equal(typeof composed.initSubtitlePrefetch, 'function');
assert.equal(typeof composed.refreshSubtitleSidebarFromSource, 'function');
assert.equal(typeof composed.refreshSubtitlePrefetchFromActiveTrack, 'function');
assert.equal(typeof composed.scheduleSubtitlePrefetchRefresh, 'function');
assert.equal(typeof composed.clearScheduledSubtitlePrefetchRefresh, 'function');
});

View File

@@ -1,32 +0,0 @@
import type { SubtitlePrefetchInitController } from '../subtitle-prefetch-init';
import type { ComposerInputs, ComposerOutputs } from './contracts';
export type SubtitlePrefetchRuntimeComposerOptions = ComposerInputs<{
subtitlePrefetchInitController: SubtitlePrefetchInitController;
refreshSubtitleSidebarFromSource: (sourcePath: string) => Promise<void>;
refreshSubtitlePrefetchFromActiveTrack: () => Promise<void>;
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => void;
clearScheduledSubtitlePrefetchRefresh: () => void;
}>;
export type SubtitlePrefetchRuntimeComposerResult = ComposerOutputs<{
cancelPendingInit: () => void;
initSubtitlePrefetch: SubtitlePrefetchInitController['initSubtitlePrefetch'];
refreshSubtitleSidebarFromSource: (sourcePath: string) => Promise<void>;
refreshSubtitlePrefetchFromActiveTrack: () => Promise<void>;
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => void;
clearScheduledSubtitlePrefetchRefresh: () => void;
}>;
export function composeSubtitlePrefetchRuntime(
options: SubtitlePrefetchRuntimeComposerOptions,
): SubtitlePrefetchRuntimeComposerResult {
return {
cancelPendingInit: () => options.subtitlePrefetchInitController.cancelPendingInit(),
initSubtitlePrefetch: options.subtitlePrefetchInitController.initSubtitlePrefetch,
refreshSubtitleSidebarFromSource: options.refreshSubtitleSidebarFromSource,
refreshSubtitlePrefetchFromActiveTrack: options.refreshSubtitlePrefetchFromActiveTrack,
scheduleSubtitlePrefetchRefresh: options.scheduleSubtitlePrefetchRefresh,
clearScheduledSubtitlePrefetchRefresh: options.clearScheduledSubtitlePrefetchRefresh,
};
}

View File

@@ -16,20 +16,21 @@ test('createCreateFirstRunSetupWindowHandler builds first-run setup window', ()
}); });
assert.deepEqual(createSetupWindow(), { id: 'first-run' }); assert.deepEqual(createSetupWindow(), { id: 'first-run' });
assert.deepEqual(options, { const { resizable, minimizable, maximizable, ...firstRunWindowOptions } = options!;
assert.deepEqual(firstRunWindowOptions, {
width: 480, width: 480,
height: 460, height: 460,
title: 'SubMiner Setup', title: 'SubMiner Setup',
show: true, show: true,
autoHideMenuBar: true, autoHideMenuBar: true,
resizable: false,
minimizable: false,
maximizable: false,
webPreferences: { webPreferences: {
nodeIntegration: false, nodeIntegration: false,
contextIsolation: true, contextIsolation: true,
}, },
}); });
assert.equal(resizable, false);
assert.equal(minimizable, false);
assert.equal(maximizable, false);
}); });
test('createCreateJellyfinSetupWindowHandler builds jellyfin setup window', () => { test('createCreateJellyfinSetupWindowHandler builds jellyfin setup window', () => {
@@ -42,7 +43,13 @@ test('createCreateJellyfinSetupWindowHandler builds jellyfin setup window', () =
}); });
assert.deepEqual(createSetupWindow(), { id: 'jellyfin' }); assert.deepEqual(createSetupWindow(), { id: 'jellyfin' });
assert.deepEqual(options, { const {
resizable: jellyfinResizable,
minimizable: jellyfinMinimizable,
maximizable: jellyfinMaximizable,
...jellyfinWindowOptions
} = options!;
assert.deepEqual(jellyfinWindowOptions, {
width: 520, width: 520,
height: 560, height: 560,
title: 'Jellyfin Setup', title: 'Jellyfin Setup',
@@ -53,6 +60,9 @@ test('createCreateJellyfinSetupWindowHandler builds jellyfin setup window', () =
contextIsolation: true, contextIsolation: true,
}, },
}); });
assert.equal(jellyfinResizable, undefined);
assert.equal(jellyfinMinimizable, undefined);
assert.equal(jellyfinMaximizable, undefined);
}); });
test('createCreateAnilistSetupWindowHandler builds anilist setup window', () => { test('createCreateAnilistSetupWindowHandler builds anilist setup window', () => {
@@ -65,7 +75,13 @@ test('createCreateAnilistSetupWindowHandler builds anilist setup window', () =>
}); });
assert.deepEqual(createSetupWindow(), { id: 'anilist' }); assert.deepEqual(createSetupWindow(), { id: 'anilist' });
assert.deepEqual(options, { const {
resizable: anilistResizable,
minimizable: anilistMinimizable,
maximizable: anilistMaximizable,
...anilistWindowOptions
} = options!;
assert.deepEqual(anilistWindowOptions, {
width: 1000, width: 1000,
height: 760, height: 760,
title: 'Anilist Setup', title: 'Anilist Setup',
@@ -76,4 +92,7 @@ test('createCreateAnilistSetupWindowHandler builds anilist setup window', () =>
contextIsolation: true, contextIsolation: true,
}, },
}); });
assert.equal(anilistResizable, undefined);
assert.equal(anilistMinimizable, undefined);
assert.equal(anilistMaximizable, undefined);
}); });

View File

@@ -1,53 +1,64 @@
export function createCreateFirstRunSetupWindowHandler<TWindow>(deps: { interface SetupWindowConfig {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow; width: number;
}) { height: number;
return (): TWindow => title: string;
deps.createBrowserWindow({ resizable?: boolean;
width: 480, minimizable?: boolean;
height: 460, maximizable?: boolean;
title: 'SubMiner Setup', }
function createSetupWindowHandler<TWindow>(
deps: { createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow },
config: SetupWindowConfig,
) {
return (): TWindow => {
const options: Electron.BrowserWindowConstructorOptions = {
width: config.width,
height: config.height,
title: config.title,
show: true, show: true,
autoHideMenuBar: true, autoHideMenuBar: true,
resizable: false,
minimizable: false,
maximizable: false,
webPreferences: { webPreferences: {
nodeIntegration: false, nodeIntegration: false,
contextIsolation: true, contextIsolation: true,
}, },
}); };
if (config.resizable !== undefined) options.resizable = config.resizable;
if (config.minimizable !== undefined) options.minimizable = config.minimizable;
if (config.maximizable !== undefined) options.maximizable = config.maximizable;
return deps.createBrowserWindow(options);
};
}
export function createCreateFirstRunSetupWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
}) {
return createSetupWindowHandler(deps, {
width: 480,
height: 460,
title: 'SubMiner Setup',
resizable: false,
minimizable: false,
maximizable: false,
});
} }
export function createCreateJellyfinSetupWindowHandler<TWindow>(deps: { export function createCreateJellyfinSetupWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow; createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
}) { }) {
return (): TWindow => return createSetupWindowHandler(deps, {
deps.createBrowserWindow({ width: 520,
width: 520, height: 560,
height: 560, title: 'Jellyfin Setup',
title: 'Jellyfin Setup', });
show: true,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
} }
export function createCreateAnilistSetupWindowHandler<TWindow>(deps: { export function createCreateAnilistSetupWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow; createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
}) { }) {
return (): TWindow => return createSetupWindowHandler(deps, {
deps.createBrowserWindow({ width: 1000,
width: 1000, height: 760,
height: 760, title: 'Anilist Setup',
title: 'Anilist Setup', });
show: true,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
} }

14
src/shared/fs-utils.ts Normal file
View File

@@ -0,0 +1,14 @@
import fs from 'node:fs';
import path from 'node:path';
export function ensureDir(dirPath: string): void {
if (fs.existsSync(dirPath)) return;
fs.mkdirSync(dirPath, { recursive: true });
}
export function ensureDirForFile(filePath: string): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}