From 2582c2a7ad8fe2875b3b2ef2aed5af023b5149a8 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 28 Mar 2026 13:19:44 -0700 Subject: [PATCH] fix(ci): restore stats-server fallback and unblock coverage tests --- ...lay-window-composition-from-src-main.ts.md | 10 +- ...eadless-command-wiring-from-src-main.ts.md | 10 +- docs-site/anilist-integration.md | 22 ---- docs-site/character-dictionary.md | 24 ---- docs-site/development.md | 20 ++- docs-site/jimaku-integration.md | 25 ---- docs-site/youtube-integration.md | 35 ----- .../services/immersion-tracker/lifetime.ts | 103 ++++++++------- .../services/immersion-tracker/maintenance.ts | 4 +- .../immersion-tracker/query-shared.ts | 17 ++- .../services/immersion-tracker/storage.ts | 123 +++++++++--------- src/core/services/stats-server.ts | 83 +++++++++--- 12 files changed, 237 insertions(+), 239 deletions(-) diff --git a/backlog/tasks/task-238.1 - Extract-main-window-and-overlay-window-composition-from-src-main.ts.md b/backlog/tasks/task-238.1 - Extract-main-window-and-overlay-window-composition-from-src-main.ts.md index 2a38570e..8686953c 100644 --- a/backlog/tasks/task-238.1 - Extract-main-window-and-overlay-window-composition-from-src-main.ts.md +++ b/backlog/tasks/task-238.1 - Extract-main-window-and-overlay-window-composition-from-src-main.ts.md @@ -1,7 +1,7 @@ --- id: TASK-238.1 title: Extract main-window and overlay-window composition from src/main.ts -status: To Do +status: Done assignee: [] created_date: '2026-03-26 20:49' labels: @@ -43,3 +43,11 @@ priority: high 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`. + +## 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` diff --git a/backlog/tasks/task-238.2 - Extract-CLI-and-headless-command-wiring-from-src-main.ts.md b/backlog/tasks/task-238.2 - Extract-CLI-and-headless-command-wiring-from-src-main.ts.md index d536d688..84e39f68 100644 --- a/backlog/tasks/task-238.2 - Extract-CLI-and-headless-command-wiring-from-src-main.ts.md +++ b/backlog/tasks/task-238.2 - Extract-CLI-and-headless-command-wiring-from-src-main.ts.md @@ -1,7 +1,7 @@ --- id: TASK-238.2 title: Extract CLI and headless command wiring from src/main.ts -status: To Do +status: Done assignee: [] created_date: '2026-03-26 20:49' labels: @@ -44,3 +44,11 @@ priority: high 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`. + +## 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` diff --git a/docs-site/anilist-integration.md b/docs-site/anilist-integration.md index a9cdd5a6..bec91aff 100644 --- a/docs-site/anilist-integration.md +++ b/docs-site/anilist-integration.md @@ -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. 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 Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff. diff --git a/docs-site/character-dictionary.md b/docs-site/character-dictionary.md index 01fe8ec5..d058ec4c 100644 --- a/docs-site/character-dictionary.md +++ b/docs-site/character-dictionary.md @@ -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. -```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 Character dictionary sync is disabled by default. To turn it on: diff --git a/docs-site/development.md b/docs-site/development.md index 5607dd36..222e837b 100644 --- a/docs-site/development.md +++ b/docs-site/development.md @@ -4,7 +4,16 @@ For internal architecture/workflow guidance, use `docs/README.md` at the repo ro ## 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 @@ -21,6 +30,8 @@ bun install `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 ```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. +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 - Source of truth: `launcher/*.ts` @@ -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: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/**`. Suggested local gate before handoff: diff --git a/docs-site/jimaku-integration.md b/docs-site/jimaku-integration.md index 4d74bd61..ba608210 100644 --- a/docs-site/jimaku-integration.md +++ b/docs-site/jimaku-integration.md @@ -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 | | `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 Add a `jimaku` section to your `config.jsonc`: diff --git a/docs-site/youtube-integration.md b/docs-site/youtube-integration.md index 81e58f09..2fad3324 100644 --- a/docs-site/youtube-integration.md +++ b/docs-site/youtube-integration.md @@ -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. 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 On startup with a YouTube URL: diff --git a/src/core/services/immersion-tracker/lifetime.ts b/src/core/services/immersion-tracker/lifetime.ts index 82c5c8ad..004fca52 100644 --- a/src/core/services/immersion-tracker/lifetime.ts +++ b/src/core/services/immersion-tracker/lifetime.ts @@ -1,6 +1,7 @@ import type { DatabaseSync } from './sqlite'; import { finalizeSessionRecord } from './session'; import { nowMs } from './time'; +import { toDbMs } from './query-shared'; import type { LifetimeRebuildSummary, SessionState } from './types'; interface TelemetryRow { @@ -19,11 +20,12 @@ interface AnimeRow { episodes_total: number | null; } -function asPositiveNumber(value: number | null, fallback: number): number { - if (value === null || !Number.isFinite(value)) { +function asPositiveNumber(value: number | string | null, fallback: number): number { + const numericValue = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(numericValue)) { return fallback; } - return Math.max(0, Math.floor(value)); + return Math.max(0, Math.floor(numericValue)); } interface ExistenceRow { @@ -41,22 +43,22 @@ interface LifetimeAnimeStateRow { interface RetainedSessionRow { sessionId: number; videoId: number; - startedAtMs: number; - endedAtMs: number; - lastMediaMs: number | null; - totalWatchedMs: number; - activeWatchedMs: number; - linesSeen: number; - tokensSeen: number; - cardsMined: number; - lookupCount: number; - lookupHits: number; - yomitanLookupCount: number; - pauseCount: number; - pauseMs: number; - seekForwardCount: number; - seekBackwardCount: number; - mediaBufferEvents: number; + startedAtMs: number | string; + endedAtMs: number | string; + lastMediaMs: number | string | null; + totalWatchedMs: number | string; + activeWatchedMs: number | string; + linesSeen: number | string; + tokensSeen: number | string; + cardsMined: number | string; + lookupCount: number | string; + lookupHits: number | string; + yomitanLookupCount: number | string; + pauseCount: number | string; + pauseMs: number | string; + seekForwardCount: number | string; + seekBackwardCount: number | string; + mediaBufferEvents: number | string; } function hasRetainedPriorSession( @@ -110,7 +112,7 @@ function isFirstSessionForLocalDay( ); } -function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void { +function resetLifetimeSummaries(db: DatabaseSync, nowMs: string): void { db.exec(` DELETE FROM imm_lifetime_anime; DELETE FROM imm_lifetime_media; @@ -136,7 +138,7 @@ function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void { function rebuildLifetimeSummariesInternal( db: DatabaseSync, - rebuiltAtMs: number, + rebuiltAtMs: string, ): LifetimeRebuildSummary { const sessions = db .prepare( @@ -178,30 +180,33 @@ function rebuildLifetimeSummariesInternal( } function toRebuildSessionState(row: RetainedSessionRow): SessionState { + const startedAtMs = Number(row.startedAtMs); + const endedAtMs = Number(row.endedAtMs); + const lastMediaMs = row.lastMediaMs === null ? null : Number(row.lastMediaMs); return { sessionId: row.sessionId, videoId: row.videoId, - startedAtMs: row.startedAtMs, + startedAtMs, currentLineIndex: 0, - lastWallClockMs: row.endedAtMs, - lastMediaMs: row.lastMediaMs, + lastWallClockMs: endedAtMs, + lastMediaMs, lastPauseStartMs: null, isPaused: false, pendingTelemetry: false, markedWatched: false, - totalWatchedMs: Math.max(0, row.totalWatchedMs), - activeWatchedMs: Math.max(0, row.activeWatchedMs), - linesSeen: Math.max(0, row.linesSeen), - tokensSeen: Math.max(0, row.tokensSeen), - cardsMined: Math.max(0, row.cardsMined), - lookupCount: Math.max(0, row.lookupCount), - lookupHits: Math.max(0, row.lookupHits), - yomitanLookupCount: Math.max(0, row.yomitanLookupCount), - pauseCount: Math.max(0, row.pauseCount), - pauseMs: Math.max(0, row.pauseMs), - seekForwardCount: Math.max(0, row.seekForwardCount), - seekBackwardCount: Math.max(0, row.seekBackwardCount), - mediaBufferEvents: Math.max(0, row.mediaBufferEvents), + totalWatchedMs: asPositiveNumber(row.totalWatchedMs, 0), + activeWatchedMs: asPositiveNumber(row.activeWatchedMs, 0), + linesSeen: asPositiveNumber(row.linesSeen, 0), + tokensSeen: asPositiveNumber(row.tokensSeen, 0), + cardsMined: asPositiveNumber(row.cardsMined, 0), + lookupCount: asPositiveNumber(row.lookupCount, 0), + lookupHits: asPositiveNumber(row.lookupHits, 0), + yomitanLookupCount: asPositiveNumber(row.yomitanLookupCount, 0), + pauseCount: asPositiveNumber(row.pauseCount, 0), + pauseMs: asPositiveNumber(row.pauseMs, 0), + seekForwardCount: asPositiveNumber(row.seekForwardCount, 0), + seekBackwardCount: asPositiveNumber(row.seekBackwardCount, 0), + mediaBufferEvents: asPositiveNumber(row.mediaBufferEvents, 0), }; } @@ -247,14 +252,14 @@ function getRetainedStaleActiveSessions(db: DatabaseSync): RetainedSessionRow[] function upsertLifetimeMedia( db: DatabaseSync, videoId: number, - nowMs: number, + nowMs: string, activeMs: number, cardsMined: number, linesSeen: number, tokensSeen: number, completed: number, - startedAtMs: number, - endedAtMs: number, + startedAtMs: number | string, + endedAtMs: number | string, ): void { db.prepare( ` @@ -310,15 +315,15 @@ function upsertLifetimeMedia( function upsertLifetimeAnime( db: DatabaseSync, animeId: number, - nowMs: number, + nowMs: string, activeMs: number, cardsMined: number, linesSeen: number, tokensSeen: number, episodesStartedDelta: number, episodesCompletedDelta: number, - startedAtMs: number, - endedAtMs: number, + startedAtMs: number | string, + endedAtMs: number | string, ): void { db.prepare( ` @@ -377,7 +382,7 @@ function upsertLifetimeAnime( export function applySessionLifetimeSummary( db: DatabaseSync, session: SessionState, - endedAtMs: number, + endedAtMs: number | string, ): void { const applyResult = db .prepare( @@ -392,8 +397,8 @@ export function applySessionLifetimeSummary( ) 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) { return; @@ -468,7 +473,7 @@ export function applySessionLifetimeSummary( ? 1 : 0; - const updatedAtMs = nowMs(); + const updatedAtMs = toDbMs(nowMs()); db.prepare( ` UPDATE imm_lifetime_global @@ -524,7 +529,7 @@ export function applySessionLifetimeSummary( } export function rebuildLifetimeSummaries(db: DatabaseSync): LifetimeRebuildSummary { - const rebuiltAtMs = nowMs(); + const rebuiltAtMs = toDbMs(nowMs()); db.exec('BEGIN'); try { const summary = rebuildLifetimeSummariesInTransaction(db, rebuiltAtMs); @@ -538,7 +543,7 @@ export function rebuildLifetimeSummaries(db: DatabaseSync): LifetimeRebuildSumma export function rebuildLifetimeSummariesInTransaction( db: DatabaseSync, - rebuiltAtMs = nowMs(), + rebuiltAtMs = toDbMs(nowMs()), ): LifetimeRebuildSummary { return rebuildLifetimeSummariesInternal(db, rebuiltAtMs); } diff --git a/src/core/services/immersion-tracker/maintenance.ts b/src/core/services/immersion-tracker/maintenance.ts index 3c4034d0..ca3ec504 100644 --- a/src/core/services/immersion-tracker/maintenance.ts +++ b/src/core/services/immersion-tracker/maintenance.ts @@ -7,7 +7,7 @@ const DAILY_MS = 86_400_000; const ZERO_ID = 0; interface RollupStateRow { - state_value: number; + state_value: string; } interface RollupGroupRow { @@ -125,7 +125,7 @@ function setLastRollupSampleMs(db: DatabaseSync, sampleMs: number | bigint): voi `INSERT INTO imm_rollup_state (state_key, state_value) VALUES (?, ?) 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 { diff --git a/src/core/services/immersion-tracker/query-shared.ts b/src/core/services/immersion-tracker/query-shared.ts index ffd59a56..35016791 100644 --- a/src/core/services/immersion-tracker/query-shared.ts +++ b/src/core/services/immersion-tracker/query-shared.ts @@ -271,6 +271,19 @@ export function deleteSessionsByIds(db: DatabaseSync, sessionIds: number[]): voi db.prepare(`DELETE FROM imm_sessions WHERE session_id IN (${placeholders})`).run(...sessionIds); } -export function toDbMs(ms: number | bigint): bigint { - return BigInt(Math.trunc(Number(ms))); +export function toDbMs(ms: number | bigint | string): string { + if (typeof ms === 'bigint') { + return ms.toString(); + } + if (typeof ms === 'string') { + const parsedMs = Number(ms); + if (!Number.isFinite(parsedMs)) { + return '0'; + } + return String(Math.trunc(parsedMs)); + } + if (!Number.isFinite(ms)) { + return '0'; + } + return String(Math.trunc(ms)); } diff --git a/src/core/services/immersion-tracker/storage.ts b/src/core/services/immersion-tracker/storage.ts index ce8833cc..347567bb 100644 --- a/src/core/services/immersion-tracker/storage.ts +++ b/src/core/services/immersion-tracker/storage.ts @@ -287,9 +287,9 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void { episodes_started INTEGER NOT NULL DEFAULT 0, episodes_completed INTEGER NOT NULL DEFAULT 0, anime_completed INTEGER NOT NULL DEFAULT 0, - last_rebuilt_ms INTEGER, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER + last_rebuilt_ms TEXT, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT ) `); @@ -332,10 +332,10 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void { total_tokens_seen INTEGER NOT NULL DEFAULT 0, episodes_started INTEGER NOT NULL DEFAULT 0, episodes_completed INTEGER NOT NULL DEFAULT 0, - first_watched_ms INTEGER, - last_watched_ms INTEGER, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + first_watched_ms TEXT, + last_watched_ms TEXT, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE ) `); @@ -349,10 +349,10 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void { total_lines_seen INTEGER NOT NULL DEFAULT 0, total_tokens_seen INTEGER NOT NULL DEFAULT 0, completed INTEGER NOT NULL DEFAULT 0, - first_watched_ms INTEGER, - last_watched_ms INTEGER, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + first_watched_ms TEXT, + last_watched_ms TEXT, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE ) `); @@ -360,9 +360,9 @@ function ensureLifetimeSummaryTables(db: DatabaseSync): void { db.exec(` CREATE TABLE IF NOT EXISTS imm_lifetime_applied_sessions( session_id INTEGER PRIMARY KEY, - applied_at_ms INTEGER NOT NULL, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + applied_at_ms TEXT NOT NULL, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE ) `); @@ -562,18 +562,18 @@ export function ensureSchema(db: DatabaseSync): void { db.exec(` CREATE TABLE IF NOT EXISTS imm_schema_version ( schema_version INTEGER PRIMARY KEY, - applied_at_ms INTEGER NOT NULL + applied_at_ms TEXT NOT NULL ); `); db.exec(` CREATE TABLE IF NOT EXISTS imm_rollup_state( state_key TEXT PRIMARY KEY, - state_value INTEGER NOT NULL + state_value TEXT NOT NULL ); `); db.exec(` 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 `); @@ -597,8 +597,8 @@ export function ensureSchema(db: DatabaseSync): void { episodes_total INTEGER, description TEXT, metadata_json TEXT, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT ); `); db.exec(` @@ -625,8 +625,8 @@ export function ensureSchema(db: DatabaseSync): void { bitrate_kbps INTEGER, audio_codec_id INTEGER, hash_sha256 TEXT, screenshot_path TEXT, metadata_json TEXT, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL ); `); @@ -635,7 +635,8 @@ export function ensureSchema(db: DatabaseSync): void { session_id INTEGER PRIMARY KEY AUTOINCREMENT, session_uuid TEXT NOT NULL UNIQUE, video_id INTEGER NOT NULL, - started_at_ms INTEGER NOT NULL, ended_at_ms INTEGER, + started_at_ms TEXT NOT NULL, + ended_at_ms TEXT, status INTEGER NOT NULL, locale_id INTEGER, target_lang_id INTEGER, difficulty_tier INTEGER, subtitle_mode INTEGER, @@ -653,8 +654,8 @@ export function ensureSchema(db: DatabaseSync): void { seek_forward_count INTEGER NOT NULL DEFAULT 0, seek_backward_count INTEGER NOT NULL DEFAULT 0, media_buffer_events INTEGER NOT NULL DEFAULT 0, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ); `); @@ -662,7 +663,7 @@ export function ensureSchema(db: DatabaseSync): void { CREATE TABLE IF NOT EXISTS imm_session_telemetry( telemetry_id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL, - sample_ms INTEGER NOT NULL, + sample_ms TEXT NOT NULL, total_watched_ms INTEGER NOT NULL DEFAULT 0, active_watched_ms INTEGER NOT NULL DEFAULT 0, lines_seen INTEGER NOT NULL DEFAULT 0, @@ -676,8 +677,8 @@ export function ensureSchema(db: DatabaseSync): void { seek_forward_count INTEGER NOT NULL DEFAULT 0, seek_backward_count INTEGER NOT NULL DEFAULT 0, media_buffer_events INTEGER NOT NULL DEFAULT 0, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE ); `); @@ -685,7 +686,7 @@ export function ensureSchema(db: DatabaseSync): void { CREATE TABLE IF NOT EXISTS imm_session_events( event_id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL, - ts_ms INTEGER NOT NULL, + ts_ms TEXT NOT NULL, event_type INTEGER NOT NULL, line_index INTEGER, segment_start_ms INTEGER, @@ -693,8 +694,8 @@ export function ensureSchema(db: DatabaseSync): void { tokens_delta INTEGER NOT NULL DEFAULT 0, cards_delta INTEGER NOT NULL DEFAULT 0, payload_json TEXT, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE ); `); @@ -710,8 +711,8 @@ export function ensureSchema(db: DatabaseSync): void { cards_per_hour REAL, tokens_per_min REAL, lookup_hit_rate REAL, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, PRIMARY KEY (rollup_day, video_id) ); `); @@ -724,8 +725,8 @@ export function ensureSchema(db: DatabaseSync): void { total_lines_seen INTEGER NOT NULL DEFAULT 0, total_tokens_seen INTEGER NOT NULL DEFAULT 0, total_cards INTEGER NOT NULL DEFAULT 0, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, PRIMARY KEY (rollup_month, video_id) ); `); @@ -768,8 +769,8 @@ export function ensureSchema(db: DatabaseSync): void { segment_end_ms INTEGER, text TEXT NOT NULL, secondary_text TEXT, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE, 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, @@ -806,9 +807,9 @@ export function ensureSchema(db: DatabaseSync): void { title_romaji TEXT, title_english TEXT, episodes_total INTEGER, - fetched_at_ms INTEGER NOT NULL, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + fetched_at_ms TEXT NOT NULL, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE ); `); @@ -827,9 +828,9 @@ export function ensureSchema(db: DatabaseSync): void { uploader_url TEXT, description TEXT, metadata_json TEXT, - fetched_at_ms INTEGER NOT NULL, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + fetched_at_ms TEXT NOT NULL, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE ); `); @@ -837,24 +838,24 @@ export function ensureSchema(db: DatabaseSync): void { CREATE TABLE IF NOT EXISTS imm_cover_art_blobs( blob_hash TEXT PRIMARY KEY, cover_blob BLOB NOT NULL, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT ); `); if (currentVersion?.schema_version === 1) { - addColumnIfMissing(db, 'imm_videos', 'CREATED_DATE'); - addColumnIfMissing(db, 'imm_videos', 'LAST_UPDATE_DATE'); - addColumnIfMissing(db, 'imm_sessions', 'CREATED_DATE'); - addColumnIfMissing(db, 'imm_sessions', 'LAST_UPDATE_DATE'); - addColumnIfMissing(db, 'imm_session_telemetry', 'CREATED_DATE'); - addColumnIfMissing(db, 'imm_session_telemetry', 'LAST_UPDATE_DATE'); - addColumnIfMissing(db, 'imm_session_events', 'CREATED_DATE'); - addColumnIfMissing(db, 'imm_session_events', 'LAST_UPDATE_DATE'); - addColumnIfMissing(db, 'imm_daily_rollups', 'CREATED_DATE'); - addColumnIfMissing(db, 'imm_daily_rollups', 'LAST_UPDATE_DATE'); - addColumnIfMissing(db, 'imm_monthly_rollups', 'CREATED_DATE'); - addColumnIfMissing(db, 'imm_monthly_rollups', 'LAST_UPDATE_DATE'); + addColumnIfMissing(db, 'imm_videos', 'CREATED_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_videos', 'LAST_UPDATE_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_sessions', 'CREATED_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_sessions', 'LAST_UPDATE_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_session_telemetry', 'CREATED_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_session_telemetry', 'LAST_UPDATE_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_session_events', 'CREATED_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_session_events', 'LAST_UPDATE_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_daily_rollups', 'CREATED_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_daily_rollups', 'LAST_UPDATE_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_monthly_rollups', 'CREATED_DATE', 'TEXT'); + addColumnIfMissing(db, 'imm_monthly_rollups', 'LAST_UPDATE_DATE', 'TEXT'); const migratedAtMs = toDbMs(nowMs()); db.prepare( @@ -938,8 +939,8 @@ export function ensureSchema(db: DatabaseSync): void { segment_end_ms INTEGER, text TEXT NOT NULL, secondary_text TEXT, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER, + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT, FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE, 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, @@ -1088,8 +1089,8 @@ export function ensureSchema(db: DatabaseSync): void { CREATE TABLE IF NOT EXISTS imm_cover_art_blobs( blob_hash TEXT PRIMARY KEY, cover_blob BLOB NOT NULL, - CREATED_DATE INTEGER, - LAST_UPDATE_DATE INTEGER + CREATED_DATE TEXT, + LAST_UPDATE_DATE TEXT ) `); deduplicateExistingCoverArtRows(db); @@ -1237,7 +1238,7 @@ export function ensureSchema(db: DatabaseSync): void { db.exec('DELETE FROM imm_daily_rollups'); db.exec('DELETE FROM imm_monthly_rollups'); 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'`, ); } diff --git a/src/core/services/stats-server.ts b/src/core/services/stats-server.ts index 328d6d3e..f54265c8 100644 --- a/src/core/services/stats-server.ts +++ b/src/core/services/stats-server.ts @@ -1,7 +1,9 @@ import { Hono } from 'hono'; import type { ImmersionTrackerService } from './immersion-tracker-service.js'; +import http from 'node:http'; import { basename, extname, resolve, sep } from 'node:path'; import { readFileSync, existsSync, statSync } from 'node:fs'; +import { Readable } from 'node:stream'; import { MediaGenerator } from '../../media-generator.js'; import { AnkiConnectClient } from '../../anki-connect.js'; import type { AnkiConnectConfig } from '../../types.js'; @@ -1006,27 +1008,76 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void resolveAnkiNoteId: config.resolveAnkiNoteId, }); - const bunServe = ( - globalThis as typeof globalThis & { - Bun: { - serve: (options: { - fetch: (typeof app)['fetch']; - port: number; - hostname: string; - }) => { stop: () => void }; - }; - } - ).Bun.serve; + const bunServe = + ( + globalThis as typeof globalThis & { + Bun?: { + serve?: (options: { + fetch: (typeof app)['fetch']; + port: number; + hostname: string; + }) => { stop: () => void }; + }; + } + ).Bun?.serve; - const server = bunServe({ - fetch: app.fetch, - port: config.port, - hostname: '127.0.0.1', + 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); + + 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 { close: () => { - server.stop(); + server.close(); }, }; }