mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-09 16:19:25 -07:00
Compare commits
35 Commits
efcacded66
...
fa2da16d37
| Author | SHA1 | Date | |
|---|---|---|---|
|
fa2da16d37
|
|||
|
572bceecb0
|
|||
|
baf2553f57
|
|||
|
6cbfc35b45
|
|||
|
0dbef59a57
|
|||
|
100f3cc827
|
|||
|
7d5fa02301
|
|||
|
7d70ceede7
|
|||
|
77a109b3d5
|
|||
|
5059c881ea
|
|||
|
67a87c4cc2
|
|||
|
13a88a8382
|
|||
|
b1638afe21
|
|||
|
e4c8c60b3e
|
|||
|
a96df287d1
|
|||
|
f4d1cc9fb9
|
|||
|
2582c2a7ad
|
|||
|
c5fcd50cc0
|
|||
|
0e0c676a9a
|
|||
|
5348ae8528
|
|||
|
c43941fc7e
|
|||
|
99b30c4cf0
|
|||
|
4779ac85dc
|
|||
|
5359e47610
|
|||
|
916dd5d37d
|
|||
|
1a448cf7d9
|
|||
|
86b50dcb70
|
|||
|
ab315c737f
|
|||
|
8784a1072a
|
|||
|
312cba6955
|
|||
|
c6d349886e
|
|||
|
17e715b2bf
|
|||
|
549ff66d09
|
|||
|
5141a936d8
|
|||
|
b92f253458
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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*
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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`:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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(
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
100
src/main.ts
100
src/main.ts
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
@@ -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: () => () => {},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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, '');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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' });
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
14
src/shared/fs-utils.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user