fix(ci): restore stats-server fallback and unblock coverage tests

This commit is contained in:
2026-03-28 13:19:44 -07:00
parent c5fcd50cc0
commit 2582c2a7ad
12 changed files with 237 additions and 239 deletions

View File

@@ -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`.
<!-- SECTION:PLAN:END -->
## Completion Notes
- Window composition now flows through `src/main/runtime/setup-window-factory.ts` and `src/main/runtime/overlay-window-factory.ts`, with `src/main/runtime/overlay-window-runtime-handlers.ts` composing the main/modal overlay entrypoints.
- `src/main.ts` keeps dependency wiring and state ownership, while the named runtime helpers own the reusable window-creation surfaces.
- Verification:
- `bun test src/main/runtime/overlay-window-factory.test.ts src/main/runtime/overlay-window-runtime-handlers.test.ts`
- `bun run typecheck` failed on unrelated existing errors in `src/core/services/immersion-tracker/lifetime.ts`, `src/core/services/immersion-tracker/maintenance.ts`, and `src/core/services/stats-server.ts`

View File

@@ -1,7 +1,7 @@
---
id: TASK-238.2
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`.
<!-- SECTION:PLAN:END -->
## Completion Notes
- CLI and headless startup wiring now lives behind `src/main/runtime/composers/cli-startup-composer.ts`, `src/main/runtime/cli-command-runtime-handler.ts`, `src/main/runtime/initial-args-handler.ts`, and `src/main/runtime/composers/headless-startup-composer.ts`.
- `src/main.ts` now passes CLI/context dependencies into those runtime surfaces instead of holding the full orchestration inline.
- Verification:
- `bun test src/main/runtime/composers/cli-startup-composer.test.ts src/main/runtime/initial-args-handler.test.ts src/main/runtime/cli-command-runtime-handler.test.ts`
- `bun run typecheck` failed on unrelated existing errors in `src/core/services/immersion-tracker/lifetime.ts`, `src/core/services/immersion-tracker/maintenance.ts`, and `src/core/services/stats-server.ts`

View File

@@ -41,28 +41,6 @@ The update flow:
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
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.

View File

@@ -31,30 +31,6 @@ The feature has three stages: **snapshot**, **merge**, and **match**.
3. **Match** — During subtitle rendering, Yomitan scans subtitle text against all loaded dictionaries including the character dictionary. Tokens that match a character entry are flagged with `isNameMatch` and highlighted in the overlay with a distinct color.
```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:

View File

@@ -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:

View File

@@ -26,31 +26,6 @@ If no files match the current episode filter, a "Show all files" button lets you
| `Arrow Up` / `Arrow Down` | Navigate entries or files |
| `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`:

View File

@@ -17,41 +17,6 @@ When SubMiner detects a YouTube URL (or `ytsearch:` target), it pauses mpv at st
4. **Download** --- Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication.
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:

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -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));
}

View File

@@ -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'`,
);
}

View File

@@ -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();
},
};
}