mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
import { Database } from 'bun:sqlite';
|
|
import { SCHEMA_VERSION } from '../../src/core/services/immersion-tracker/types.js';
|
|
import { IMMERSION_DB_FIXTURE_DDL } from './immersion-db-schema.js';
|
|
|
|
export function createImmersionDbFixture(dbPath: string): void {
|
|
const db = new Database(dbPath, { create: true });
|
|
try {
|
|
db.run('PRAGMA foreign_keys = ON');
|
|
db.run('PRAGMA journal_mode = WAL');
|
|
for (const statement of IMMERSION_DB_FIXTURE_DDL.split(';')) {
|
|
const sql = statement.trim();
|
|
if (sql) db.run(sql);
|
|
}
|
|
db.prepare('INSERT INTO imm_schema_version (schema_version, applied_at_ms) VALUES (?, ?)').run(
|
|
SCHEMA_VERSION,
|
|
String(Date.now()),
|
|
);
|
|
db.prepare(
|
|
`INSERT INTO imm_rollup_state(state_key, state_value) VALUES ('last_rollup_sample_ms', 0)`,
|
|
).run();
|
|
db.prepare(
|
|
`INSERT INTO imm_lifetime_global(global_id, CREATED_DATE, LAST_UPDATE_DATE) VALUES (1, ?, ?)`,
|
|
).run(String(Date.now()), String(Date.now()));
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
let uniqueCounter = 0;
|
|
|
|
export interface FixtureSessionInput {
|
|
uuid: string;
|
|
videoKey: string;
|
|
animeTitleKey?: string | null;
|
|
animeEpisodesTotal?: number | null;
|
|
startedAtMs: number;
|
|
endedAtMs?: number | null;
|
|
activeWatchedMs?: number;
|
|
cardsMined?: number;
|
|
linesSeen?: number;
|
|
tokensSeen?: number;
|
|
watched?: boolean;
|
|
words?: Array<{ headword: string; word: string; reading: string; count: number }>;
|
|
applyLifetime?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Insert a session (with video/anime/subtitle-line/occurrence rows) the way
|
|
* the app would have recorded it, optionally crediting lifetime aggregates
|
|
* the way applySessionLifetimeSummary does for locally-recorded sessions.
|
|
*/
|
|
export function insertFixtureSession(dbPath: string, input: FixtureSessionInput): void {
|
|
const db = new Database(dbPath, { readwrite: true });
|
|
const stamp = String(input.startedAtMs);
|
|
try {
|
|
db.run('PRAGMA foreign_keys = ON');
|
|
let animeId: number | null = null;
|
|
if (input.animeTitleKey) {
|
|
const existing = db
|
|
.query<{
|
|
anime_id: number;
|
|
}>('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?')
|
|
.get(input.animeTitleKey);
|
|
animeId = existing
|
|
? existing.anime_id
|
|
: Number(
|
|
db
|
|
.prepare(
|
|
`INSERT INTO imm_anime (normalized_title_key, canonical_title, episodes_total, CREATED_DATE, LAST_UPDATE_DATE)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
)
|
|
.run(
|
|
input.animeTitleKey,
|
|
input.animeTitleKey,
|
|
input.animeEpisodesTotal ?? null,
|
|
stamp,
|
|
stamp,
|
|
).lastInsertRowid,
|
|
);
|
|
}
|
|
|
|
const existingVideo = db
|
|
.query<{ video_id: number }>('SELECT video_id FROM imm_videos WHERE video_key = ?')
|
|
.get(input.videoKey);
|
|
const videoId = existingVideo
|
|
? existingVideo.video_id
|
|
: Number(
|
|
db
|
|
.prepare(
|
|
`INSERT INTO imm_videos (video_key, anime_id, canonical_title, source_type, source_path, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
|
VALUES (?, ?, ?, 1, ?, ?, 1440000, ?, ?)`,
|
|
)
|
|
.run(
|
|
input.videoKey,
|
|
animeId,
|
|
input.videoKey,
|
|
`/videos/${input.videoKey}.mkv`,
|
|
input.watched ? 1 : 0,
|
|
stamp,
|
|
stamp,
|
|
).lastInsertRowid,
|
|
);
|
|
if (input.watched) {
|
|
db.prepare('UPDATE imm_videos SET watched = 1 WHERE video_id = ?').run(videoId);
|
|
}
|
|
|
|
const endedAtMs =
|
|
input.endedAtMs === undefined ? input.startedAtMs + 1_500_000 : input.endedAtMs;
|
|
const activeWatchedMs = input.activeWatchedMs ?? 1_200_000;
|
|
const cardsMined = input.cardsMined ?? 2;
|
|
const linesSeen = input.linesSeen ?? 100;
|
|
const tokensSeen = input.tokensSeen ?? 800;
|
|
const sessionId = Number(
|
|
db
|
|
.prepare(
|
|
`INSERT INTO imm_sessions (
|
|
session_uuid, video_id, started_at_ms, ended_at_ms, status,
|
|
total_watched_ms, active_watched_ms, lines_seen, tokens_seen, cards_mined,
|
|
CREATED_DATE, LAST_UPDATE_DATE
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
)
|
|
.run(
|
|
input.uuid,
|
|
videoId,
|
|
String(input.startedAtMs),
|
|
endedAtMs === null ? null : String(endedAtMs),
|
|
endedAtMs === null ? 1 : 2,
|
|
activeWatchedMs,
|
|
activeWatchedMs,
|
|
linesSeen,
|
|
tokensSeen,
|
|
cardsMined,
|
|
stamp,
|
|
stamp,
|
|
).lastInsertRowid,
|
|
);
|
|
|
|
db.prepare(
|
|
`INSERT INTO imm_session_telemetry (
|
|
session_id, sample_ms, total_watched_ms, active_watched_ms, lines_seen, tokens_seen,
|
|
cards_mined, CREATED_DATE, LAST_UPDATE_DATE
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
).run(
|
|
sessionId,
|
|
String(endedAtMs ?? input.startedAtMs),
|
|
activeWatchedMs,
|
|
activeWatchedMs,
|
|
linesSeen,
|
|
tokensSeen,
|
|
cardsMined,
|
|
stamp,
|
|
stamp,
|
|
);
|
|
|
|
for (const word of input.words ?? []) {
|
|
const existing = db
|
|
.query<{
|
|
id: number;
|
|
}>('SELECT id FROM imm_words WHERE headword = ? AND word = ? AND reading = ?')
|
|
.get(word.headword, word.word, word.reading);
|
|
const wordId = existing
|
|
? existing.id
|
|
: Number(
|
|
db
|
|
.prepare(
|
|
`INSERT INTO imm_words (headword, word, reading, first_seen, last_seen, frequency)
|
|
VALUES (?, ?, ?, ?, ?, 0)`,
|
|
)
|
|
.run(
|
|
word.headword,
|
|
word.word,
|
|
word.reading,
|
|
Math.floor(input.startedAtMs / 1000),
|
|
Math.floor(input.startedAtMs / 1000),
|
|
).lastInsertRowid,
|
|
);
|
|
uniqueCounter += 1;
|
|
const lineId = Number(
|
|
db
|
|
.prepare(
|
|
`INSERT INTO imm_subtitle_lines (session_id, video_id, anime_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
)
|
|
.run(
|
|
sessionId,
|
|
videoId,
|
|
animeId,
|
|
uniqueCounter,
|
|
`line ${word.word}`,
|
|
input.startedAtMs,
|
|
input.startedAtMs,
|
|
).lastInsertRowid,
|
|
);
|
|
db.prepare(
|
|
'INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count) VALUES (?, ?, ?)',
|
|
).run(lineId, wordId, word.count);
|
|
db.prepare('UPDATE imm_words SET frequency = frequency + ? WHERE id = ?').run(
|
|
word.count,
|
|
wordId,
|
|
);
|
|
}
|
|
|
|
if (input.applyLifetime && endedAtMs !== null) {
|
|
applyFixtureLifetime(db, sessionId, videoId, animeId, {
|
|
endedAtMs,
|
|
startedAtMs: input.startedAtMs,
|
|
activeWatchedMs,
|
|
cardsMined,
|
|
linesSeen,
|
|
tokensSeen,
|
|
watched: Boolean(input.watched),
|
|
episodesTotal: input.animeEpisodesTotal ?? null,
|
|
});
|
|
}
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
function applyFixtureLifetime(
|
|
db: Database,
|
|
sessionId: number,
|
|
videoId: number,
|
|
animeId: number | null,
|
|
data: {
|
|
endedAtMs: number;
|
|
startedAtMs: number;
|
|
activeWatchedMs: number;
|
|
cardsMined: number;
|
|
linesSeen: number;
|
|
tokensSeen: number;
|
|
watched: boolean;
|
|
episodesTotal: number | null;
|
|
},
|
|
): void {
|
|
const stamp = String(data.endedAtMs);
|
|
db.prepare(
|
|
`INSERT INTO imm_lifetime_applied_sessions (session_id, applied_at_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
|
VALUES (?, ?, ?, ?)`,
|
|
).run(sessionId, String(data.endedAtMs), stamp, stamp);
|
|
|
|
const mediaLifetime = db
|
|
.query<{ completed: number }>('SELECT completed FROM imm_lifetime_media WHERE video_id = ?')
|
|
.get(videoId);
|
|
const isFirstSessionForVideo = !mediaLifetime;
|
|
const isFirstCompleted = data.watched && Number(mediaLifetime?.completed ?? 0) <= 0;
|
|
const dayExpr = `CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER)`;
|
|
const otherOnDay = db
|
|
.query(
|
|
`SELECT 1 FROM imm_sessions WHERE session_id != ? AND ${dayExpr} =
|
|
CAST(julianday(CAST(? AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) LIMIT 1`,
|
|
)
|
|
.get(sessionId, String(data.startedAtMs));
|
|
|
|
db.prepare(
|
|
`UPDATE imm_lifetime_global SET
|
|
total_sessions = total_sessions + 1,
|
|
total_active_ms = total_active_ms + ?,
|
|
total_cards = total_cards + ?,
|
|
active_days = active_days + ?,
|
|
episodes_started = episodes_started + ?,
|
|
episodes_completed = episodes_completed + ?
|
|
WHERE global_id = 1`,
|
|
).run(
|
|
data.activeWatchedMs,
|
|
data.cardsMined,
|
|
otherOnDay ? 0 : 1,
|
|
isFirstSessionForVideo ? 1 : 0,
|
|
isFirstCompleted ? 1 : 0,
|
|
);
|
|
|
|
db.prepare(
|
|
`INSERT INTO imm_lifetime_media (video_id, total_sessions, total_active_ms, total_cards, total_lines_seen, total_tokens_seen, completed, first_watched_ms, last_watched_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
|
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(video_id) DO UPDATE SET
|
|
total_sessions = total_sessions + 1,
|
|
total_active_ms = total_active_ms + excluded.total_active_ms,
|
|
total_cards = total_cards + excluded.total_cards,
|
|
total_lines_seen = total_lines_seen + excluded.total_lines_seen,
|
|
total_tokens_seen = total_tokens_seen + excluded.total_tokens_seen,
|
|
completed = MAX(completed, excluded.completed),
|
|
last_watched_ms = excluded.last_watched_ms`,
|
|
).run(
|
|
videoId,
|
|
data.activeWatchedMs,
|
|
data.cardsMined,
|
|
data.linesSeen,
|
|
data.tokensSeen,
|
|
data.watched ? 1 : 0,
|
|
String(data.startedAtMs),
|
|
String(data.endedAtMs),
|
|
stamp,
|
|
stamp,
|
|
);
|
|
|
|
if (animeId !== null) {
|
|
db.prepare(
|
|
`INSERT INTO imm_lifetime_anime (anime_id, total_sessions, total_active_ms, total_cards, total_lines_seen, total_tokens_seen, episodes_started, episodes_completed, first_watched_ms, last_watched_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
|
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(anime_id) DO UPDATE SET
|
|
total_sessions = total_sessions + 1,
|
|
total_active_ms = total_active_ms + excluded.total_active_ms,
|
|
total_cards = total_cards + excluded.total_cards,
|
|
total_lines_seen = total_lines_seen + excluded.total_lines_seen,
|
|
total_tokens_seen = total_tokens_seen + excluded.total_tokens_seen,
|
|
episodes_started = episodes_started + excluded.episodes_started,
|
|
episodes_completed = episodes_completed + excluded.episodes_completed,
|
|
last_watched_ms = excluded.last_watched_ms`,
|
|
).run(
|
|
animeId,
|
|
data.activeWatchedMs,
|
|
data.cardsMined,
|
|
data.linesSeen,
|
|
data.tokensSeen,
|
|
isFirstSessionForVideo ? 1 : 0,
|
|
isFirstCompleted ? 1 : 0,
|
|
String(data.startedAtMs),
|
|
String(data.endedAtMs),
|
|
stamp,
|
|
stamp,
|
|
);
|
|
}
|
|
}
|