mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-02 18:22:42 -08:00
feat(core): add Electron runtime, services, and app composition
This commit is contained in:
90
src/core/services/immersion-tracker/maintenance.ts
Normal file
90
src/core/services/immersion-tracker/maintenance.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
export function toMonthKey(timestampMs: number): number {
|
||||
const monthDate = new Date(timestampMs);
|
||||
return monthDate.getUTCFullYear() * 100 + monthDate.getUTCMonth() + 1;
|
||||
}
|
||||
|
||||
export function pruneRetention(
|
||||
db: DatabaseSync,
|
||||
nowMs: number,
|
||||
policy: {
|
||||
eventsRetentionMs: number;
|
||||
telemetryRetentionMs: number;
|
||||
dailyRollupRetentionMs: number;
|
||||
monthlyRollupRetentionMs: number;
|
||||
},
|
||||
): void {
|
||||
const eventCutoff = nowMs - policy.eventsRetentionMs;
|
||||
const telemetryCutoff = nowMs - policy.telemetryRetentionMs;
|
||||
const dailyCutoff = nowMs - policy.dailyRollupRetentionMs;
|
||||
const monthlyCutoff = nowMs - policy.monthlyRollupRetentionMs;
|
||||
const dayCutoff = Math.floor(dailyCutoff / 86_400_000);
|
||||
const monthCutoff = toMonthKey(monthlyCutoff);
|
||||
|
||||
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(eventCutoff);
|
||||
db.prepare(`DELETE FROM imm_session_telemetry WHERE sample_ms < ?`).run(telemetryCutoff);
|
||||
db.prepare(`DELETE FROM imm_daily_rollups WHERE rollup_day < ?`).run(dayCutoff);
|
||||
db.prepare(`DELETE FROM imm_monthly_rollups WHERE rollup_month < ?`).run(monthCutoff);
|
||||
db.prepare(`DELETE FROM imm_sessions WHERE ended_at_ms IS NOT NULL AND ended_at_ms < ?`).run(
|
||||
telemetryCutoff,
|
||||
);
|
||||
}
|
||||
|
||||
export function runRollupMaintenance(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
INSERT OR REPLACE INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_words_seen, total_tokens_seen, total_cards, cards_per_hour,
|
||||
words_per_min, lookup_hit_rate
|
||||
)
|
||||
SELECT
|
||||
CAST(s.started_at_ms / 86400000 AS INTEGER) AS rollup_day,
|
||||
s.video_id AS video_id,
|
||||
COUNT(DISTINCT s.session_id) AS total_sessions,
|
||||
COALESCE(SUM(t.active_watched_ms), 0) / 60000.0 AS total_active_min,
|
||||
COALESCE(SUM(t.lines_seen), 0) AS total_lines_seen,
|
||||
COALESCE(SUM(t.words_seen), 0) AS total_words_seen,
|
||||
COALESCE(SUM(t.tokens_seen), 0) AS total_tokens_seen,
|
||||
COALESCE(SUM(t.cards_mined), 0) AS total_cards,
|
||||
CASE
|
||||
WHEN COALESCE(SUM(t.active_watched_ms), 0) > 0
|
||||
THEN (COALESCE(SUM(t.cards_mined), 0) * 60.0) / (COALESCE(SUM(t.active_watched_ms), 0) / 60000.0)
|
||||
ELSE NULL
|
||||
END AS cards_per_hour,
|
||||
CASE
|
||||
WHEN COALESCE(SUM(t.active_watched_ms), 0) > 0
|
||||
THEN COALESCE(SUM(t.words_seen), 0) / (COALESCE(SUM(t.active_watched_ms), 0) / 60000.0)
|
||||
ELSE NULL
|
||||
END AS words_per_min,
|
||||
CASE
|
||||
WHEN COALESCE(SUM(t.lookup_count), 0) > 0
|
||||
THEN CAST(COALESCE(SUM(t.lookup_hits), 0) AS REAL) / CAST(SUM(t.lookup_count) AS REAL)
|
||||
ELSE NULL
|
||||
END AS lookup_hit_rate
|
||||
FROM imm_sessions s
|
||||
JOIN imm_session_telemetry t
|
||||
ON t.session_id = s.session_id
|
||||
GROUP BY rollup_day, s.video_id
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
INSERT OR REPLACE INTO imm_monthly_rollups (
|
||||
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_words_seen, total_tokens_seen, total_cards
|
||||
)
|
||||
SELECT
|
||||
CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch') AS INTEGER) AS rollup_month,
|
||||
s.video_id AS video_id,
|
||||
COUNT(DISTINCT s.session_id) AS total_sessions,
|
||||
COALESCE(SUM(t.active_watched_ms), 0) / 60000.0 AS total_active_min,
|
||||
COALESCE(SUM(t.lines_seen), 0) AS total_lines_seen,
|
||||
COALESCE(SUM(t.words_seen), 0) AS total_words_seen,
|
||||
COALESCE(SUM(t.tokens_seen), 0) AS total_tokens_seen,
|
||||
COALESCE(SUM(t.cards_mined), 0) AS total_cards
|
||||
FROM imm_sessions s
|
||||
JOIN imm_session_telemetry t
|
||||
ON t.session_id = s.session_id
|
||||
GROUP BY rollup_month, s.video_id
|
||||
`);
|
||||
}
|
||||
148
src/core/services/immersion-tracker/metadata.test.ts
Normal file
148
src/core/services/immersion-tracker/metadata.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import test from 'node:test';
|
||||
import type { spawn as spawnFn } from 'node:child_process';
|
||||
import { SOURCE_TYPE_LOCAL } from './types';
|
||||
import { getLocalVideoMetadata, runFfprobe } from './metadata';
|
||||
|
||||
type Spawn = typeof spawnFn;
|
||||
|
||||
function createSpawnStub(options: {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
emitError?: boolean;
|
||||
}): Spawn {
|
||||
return (() => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
};
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (options.emitError) {
|
||||
child.emit('error', new Error('ffprobe failed'));
|
||||
return;
|
||||
}
|
||||
if (options.stderr) {
|
||||
child.stderr.emit('data', Buffer.from(options.stderr));
|
||||
}
|
||||
if (options.stdout !== undefined) {
|
||||
child.stdout.emit('data', Buffer.from(options.stdout));
|
||||
}
|
||||
child.emit('close', 0);
|
||||
});
|
||||
|
||||
return child as unknown as ReturnType<Spawn>;
|
||||
}) as Spawn;
|
||||
}
|
||||
|
||||
test('runFfprobe parses valid JSON from stream and format sections', async () => {
|
||||
const metadata = await runFfprobe('/tmp/video.mp4', {
|
||||
spawn: createSpawnStub({
|
||||
stdout: JSON.stringify({
|
||||
format: { duration: '12.34', bit_rate: '3456000' },
|
||||
streams: [
|
||||
{
|
||||
codec_type: 'video',
|
||||
codec_tag_string: 'avc1',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
avg_frame_rate: '24000/1001',
|
||||
},
|
||||
{
|
||||
codec_type: 'audio',
|
||||
codec_tag_string: 'mp4a',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(metadata.durationMs, 12340);
|
||||
assert.equal(metadata.bitrateKbps, 3456);
|
||||
assert.equal(metadata.widthPx, 1920);
|
||||
assert.equal(metadata.heightPx, 1080);
|
||||
assert.equal(metadata.fpsX100, 2398);
|
||||
assert.equal(metadata.containerId, 0);
|
||||
assert.ok(Number(metadata.codecId) > 0);
|
||||
assert.ok(Number(metadata.audioCodecId) > 0);
|
||||
});
|
||||
|
||||
test('runFfprobe returns empty metadata for invalid JSON and process errors', async () => {
|
||||
const invalidJsonMetadata = await runFfprobe('/tmp/broken.mp4', {
|
||||
spawn: createSpawnStub({ stdout: '{invalid' }),
|
||||
});
|
||||
assert.deepEqual(invalidJsonMetadata, {
|
||||
durationMs: null,
|
||||
codecId: null,
|
||||
containerId: null,
|
||||
widthPx: null,
|
||||
heightPx: null,
|
||||
fpsX100: null,
|
||||
bitrateKbps: null,
|
||||
audioCodecId: null,
|
||||
});
|
||||
|
||||
const errorMetadata = await runFfprobe('/tmp/error.mp4', {
|
||||
spawn: createSpawnStub({ emitError: true }),
|
||||
});
|
||||
assert.deepEqual(errorMetadata, {
|
||||
durationMs: null,
|
||||
codecId: null,
|
||||
containerId: null,
|
||||
widthPx: null,
|
||||
heightPx: null,
|
||||
fpsX100: null,
|
||||
bitrateKbps: null,
|
||||
audioCodecId: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('getLocalVideoMetadata derives title and falls back to null hash on read errors', async () => {
|
||||
const successMetadata = await getLocalVideoMetadata('/tmp/Episode 01.mkv', {
|
||||
spawn: createSpawnStub({ stdout: JSON.stringify({ format: { duration: '0' }, streams: [] }) }),
|
||||
fs: {
|
||||
createReadStream: () => {
|
||||
const stream = new EventEmitter();
|
||||
queueMicrotask(() => {
|
||||
stream.emit('data', Buffer.from('hello world'));
|
||||
stream.emit('end');
|
||||
});
|
||||
return stream as unknown as ReturnType<typeof import('node:fs').createReadStream>;
|
||||
},
|
||||
promises: {
|
||||
stat: (async () => ({ size: 1234 }) as unknown) as typeof import('node:fs').promises.stat,
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
assert.equal(successMetadata.sourceType, SOURCE_TYPE_LOCAL);
|
||||
assert.equal(successMetadata.canonicalTitle, 'Episode 01');
|
||||
assert.equal(successMetadata.fileSizeBytes, 1234);
|
||||
assert.equal(
|
||||
successMetadata.hashSha256,
|
||||
createHash('sha256').update('hello world').digest('hex'),
|
||||
);
|
||||
|
||||
const hashFallbackMetadata = await getLocalVideoMetadata('/tmp/Episode 02.mkv', {
|
||||
spawn: createSpawnStub({ stdout: JSON.stringify({ format: {}, streams: [] }) }),
|
||||
fs: {
|
||||
createReadStream: () => {
|
||||
const stream = new EventEmitter();
|
||||
queueMicrotask(() => {
|
||||
stream.emit('error', new Error('read failed'));
|
||||
});
|
||||
return stream as unknown as ReturnType<typeof import('node:fs').createReadStream>;
|
||||
},
|
||||
promises: {
|
||||
stat: (async () => ({ size: 5678 }) as unknown) as typeof import('node:fs').promises.stat,
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
assert.equal(hashFallbackMetadata.canonicalTitle, 'Episode 02');
|
||||
assert.equal(hashFallbackMetadata.hashSha256, null);
|
||||
});
|
||||
153
src/core/services/immersion-tracker/metadata.ts
Normal file
153
src/core/services/immersion-tracker/metadata.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { spawn as nodeSpawn } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import {
|
||||
deriveCanonicalTitle,
|
||||
emptyMetadata,
|
||||
hashToCode,
|
||||
parseFps,
|
||||
toNullableInt,
|
||||
} from './reducer';
|
||||
import { SOURCE_TYPE_LOCAL, type ProbeMetadata, type VideoMetadata } from './types';
|
||||
|
||||
type SpawnFn = typeof nodeSpawn;
|
||||
|
||||
interface FsDeps {
|
||||
createReadStream: typeof fs.createReadStream;
|
||||
promises: {
|
||||
stat: typeof fs.promises.stat;
|
||||
};
|
||||
}
|
||||
|
||||
interface MetadataDeps {
|
||||
spawn?: SpawnFn;
|
||||
fs?: FsDeps;
|
||||
}
|
||||
|
||||
export async function computeSha256(
|
||||
mediaPath: string,
|
||||
deps: MetadataDeps = {},
|
||||
): Promise<string | null> {
|
||||
const fileSystem = deps.fs ?? fs;
|
||||
return new Promise((resolve) => {
|
||||
const file = fileSystem.createReadStream(mediaPath);
|
||||
const digest = crypto.createHash('sha256');
|
||||
file.on('data', (chunk) => digest.update(chunk));
|
||||
file.on('end', () => resolve(digest.digest('hex')));
|
||||
file.on('error', () => resolve(null));
|
||||
});
|
||||
}
|
||||
|
||||
export function runFfprobe(mediaPath: string, deps: MetadataDeps = {}): Promise<ProbeMetadata> {
|
||||
const spawn = deps.spawn ?? nodeSpawn;
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn('ffprobe', [
|
||||
'-v',
|
||||
'error',
|
||||
'-print_format',
|
||||
'json',
|
||||
'-show_entries',
|
||||
'stream=codec_type,codec_tag_string,width,height,avg_frame_rate,bit_rate',
|
||||
'-show_entries',
|
||||
'format=duration,bit_rate',
|
||||
mediaPath,
|
||||
]);
|
||||
|
||||
let output = '';
|
||||
let errorOutput = '';
|
||||
child.stdout.on('data', (chunk) => {
|
||||
output += chunk.toString('utf-8');
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
errorOutput += chunk.toString('utf-8');
|
||||
});
|
||||
child.on('error', () => resolve(emptyMetadata()));
|
||||
child.on('close', () => {
|
||||
if (errorOutput && output.length === 0) {
|
||||
resolve(emptyMetadata());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(output) as {
|
||||
format?: { duration?: string; bit_rate?: string };
|
||||
streams?: Array<{
|
||||
codec_type?: string;
|
||||
codec_tag_string?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
avg_frame_rate?: string;
|
||||
bit_rate?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const durationText = parsed.format?.duration;
|
||||
const bitrateText = parsed.format?.bit_rate;
|
||||
const durationMs = Number(durationText) ? Math.round(Number(durationText) * 1000) : null;
|
||||
const bitrateKbps = Number(bitrateText) ? Math.round(Number(bitrateText) / 1000) : null;
|
||||
|
||||
let codecId: number | null = null;
|
||||
let containerId: number | null = null;
|
||||
let widthPx: number | null = null;
|
||||
let heightPx: number | null = null;
|
||||
let fpsX100: number | null = null;
|
||||
let audioCodecId: number | null = null;
|
||||
|
||||
for (const stream of parsed.streams ?? []) {
|
||||
if (stream.codec_type === 'video') {
|
||||
widthPx = toNullableInt(stream.width);
|
||||
heightPx = toNullableInt(stream.height);
|
||||
fpsX100 = parseFps(stream.avg_frame_rate);
|
||||
codecId = hashToCode(stream.codec_tag_string);
|
||||
containerId = 0;
|
||||
}
|
||||
if (stream.codec_type === 'audio') {
|
||||
audioCodecId = hashToCode(stream.codec_tag_string);
|
||||
if (audioCodecId && audioCodecId > 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolve({
|
||||
durationMs,
|
||||
codecId,
|
||||
containerId,
|
||||
widthPx,
|
||||
heightPx,
|
||||
fpsX100,
|
||||
bitrateKbps,
|
||||
audioCodecId,
|
||||
});
|
||||
} catch {
|
||||
resolve(emptyMetadata());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLocalVideoMetadata(
|
||||
mediaPath: string,
|
||||
deps: MetadataDeps = {},
|
||||
): Promise<VideoMetadata> {
|
||||
const fileSystem = deps.fs ?? fs;
|
||||
const hash = await computeSha256(mediaPath, deps);
|
||||
const info = await runFfprobe(mediaPath, deps);
|
||||
const stat = await fileSystem.promises.stat(mediaPath);
|
||||
return {
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
canonicalTitle: deriveCanonicalTitle(mediaPath),
|
||||
durationMs: info.durationMs || 0,
|
||||
fileSizeBytes: Number.isFinite(stat.size) ? stat.size : null,
|
||||
codecId: info.codecId ?? null,
|
||||
containerId: info.containerId ?? null,
|
||||
widthPx: info.widthPx ?? null,
|
||||
heightPx: info.heightPx ?? null,
|
||||
fpsX100: info.fpsX100 ?? null,
|
||||
bitrateKbps: info.bitrateKbps ?? null,
|
||||
audioCodecId: info.audioCodecId ?? null,
|
||||
hashSha256: hash,
|
||||
screenshotPath: null,
|
||||
metadataJson: null,
|
||||
};
|
||||
}
|
||||
104
src/core/services/immersion-tracker/query.ts
Normal file
104
src/core/services/immersion-tracker/query.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type {
|
||||
ImmersionSessionRollupRow,
|
||||
SessionSummaryQueryRow,
|
||||
SessionTimelineRow,
|
||||
} from './types';
|
||||
|
||||
export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummaryQueryRow[] {
|
||||
const prepared = db.prepare(`
|
||||
SELECT
|
||||
s.video_id AS videoId,
|
||||
s.started_at_ms AS startedAtMs,
|
||||
s.ended_at_ms AS endedAtMs,
|
||||
COALESCE(SUM(t.total_watched_ms), 0) AS totalWatchedMs,
|
||||
COALESCE(SUM(t.active_watched_ms), 0) AS activeWatchedMs,
|
||||
COALESCE(SUM(t.lines_seen), 0) AS linesSeen,
|
||||
COALESCE(SUM(t.words_seen), 0) AS wordsSeen,
|
||||
COALESCE(SUM(t.tokens_seen), 0) AS tokensSeen,
|
||||
COALESCE(SUM(t.cards_mined), 0) AS cardsMined,
|
||||
COALESCE(SUM(t.lookup_count), 0) AS lookupCount,
|
||||
COALESCE(SUM(t.lookup_hits), 0) AS lookupHits
|
||||
FROM imm_sessions s
|
||||
LEFT JOIN imm_session_telemetry t ON t.session_id = s.session_id
|
||||
GROUP BY s.session_id
|
||||
ORDER BY s.started_at_ms DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return prepared.all(limit) as unknown as SessionSummaryQueryRow[];
|
||||
}
|
||||
|
||||
export function getSessionTimeline(
|
||||
db: DatabaseSync,
|
||||
sessionId: number,
|
||||
limit = 200,
|
||||
): SessionTimelineRow[] {
|
||||
const prepared = db.prepare(`
|
||||
SELECT
|
||||
sample_ms AS sampleMs,
|
||||
total_watched_ms AS totalWatchedMs,
|
||||
active_watched_ms AS activeWatchedMs,
|
||||
lines_seen AS linesSeen,
|
||||
words_seen AS wordsSeen,
|
||||
tokens_seen AS tokensSeen,
|
||||
cards_mined AS cardsMined
|
||||
FROM imm_session_telemetry
|
||||
WHERE session_id = ?
|
||||
ORDER BY sample_ms DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return prepared.all(sessionId, limit) as unknown as SessionTimelineRow[];
|
||||
}
|
||||
|
||||
export function getQueryHints(db: DatabaseSync): {
|
||||
totalSessions: number;
|
||||
activeSessions: number;
|
||||
} {
|
||||
const sessions = db.prepare('SELECT COUNT(*) AS total FROM imm_sessions');
|
||||
const active = db.prepare('SELECT COUNT(*) AS total FROM imm_sessions WHERE ended_at_ms IS NULL');
|
||||
const totalSessions = Number(sessions.get()?.total ?? 0);
|
||||
const activeSessions = Number(active.get()?.total ?? 0);
|
||||
return { totalSessions, activeSessions };
|
||||
}
|
||||
|
||||
export function getDailyRollups(db: DatabaseSync, limit = 60): ImmersionSessionRollupRow[] {
|
||||
const prepared = db.prepare(`
|
||||
SELECT
|
||||
rollup_day AS rollupDayOrMonth,
|
||||
video_id AS videoId,
|
||||
total_sessions AS totalSessions,
|
||||
total_active_min AS totalActiveMin,
|
||||
total_lines_seen AS totalLinesSeen,
|
||||
total_words_seen AS totalWordsSeen,
|
||||
total_tokens_seen AS totalTokensSeen,
|
||||
total_cards AS totalCards,
|
||||
cards_per_hour AS cardsPerHour,
|
||||
words_per_min AS wordsPerMin,
|
||||
lookup_hit_rate AS lookupHitRate
|
||||
FROM imm_daily_rollups
|
||||
ORDER BY rollup_day DESC, video_id DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return prepared.all(limit) as unknown as ImmersionSessionRollupRow[];
|
||||
}
|
||||
|
||||
export function getMonthlyRollups(db: DatabaseSync, limit = 24): ImmersionSessionRollupRow[] {
|
||||
const prepared = db.prepare(`
|
||||
SELECT
|
||||
rollup_month AS rollupDayOrMonth,
|
||||
video_id AS videoId,
|
||||
total_sessions AS totalSessions,
|
||||
total_active_min AS totalActiveMin,
|
||||
total_lines_seen AS totalLinesSeen,
|
||||
total_words_seen AS totalWordsSeen,
|
||||
total_tokens_seen AS totalTokensSeen,
|
||||
total_cards AS totalCards,
|
||||
0 AS cardsPerHour,
|
||||
0 AS wordsPerMin,
|
||||
0 AS lookupHitRate
|
||||
FROM imm_monthly_rollups
|
||||
ORDER BY rollup_month DESC, video_id DESC
|
||||
LIMIT ?
|
||||
`);
|
||||
return prepared.all(limit) as unknown as ImmersionSessionRollupRow[];
|
||||
}
|
||||
19
src/core/services/immersion-tracker/queue.ts
Normal file
19
src/core/services/immersion-tracker/queue.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { QueuedWrite } from './types';
|
||||
|
||||
export function enqueueWrite(
|
||||
queue: QueuedWrite[],
|
||||
write: QueuedWrite,
|
||||
queueCap: number,
|
||||
): {
|
||||
dropped: number;
|
||||
queueLength: number;
|
||||
} {
|
||||
let dropped = 0;
|
||||
if (queue.length >= queueCap) {
|
||||
const overflow = queue.length - queueCap + 1;
|
||||
queue.splice(0, overflow);
|
||||
dropped = overflow;
|
||||
}
|
||||
queue.push(write);
|
||||
return { dropped, queueLength: queue.length };
|
||||
}
|
||||
144
src/core/services/immersion-tracker/reducer.ts
Normal file
144
src/core/services/immersion-tracker/reducer.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import path from 'node:path';
|
||||
import type { ProbeMetadata, SessionState } from './types';
|
||||
import { SOURCE_TYPE_REMOTE } from './types';
|
||||
|
||||
export function createInitialSessionState(
|
||||
sessionId: number,
|
||||
videoId: number,
|
||||
startedAtMs: number,
|
||||
): SessionState {
|
||||
return {
|
||||
sessionId,
|
||||
videoId,
|
||||
startedAtMs,
|
||||
currentLineIndex: 0,
|
||||
totalWatchedMs: 0,
|
||||
activeWatchedMs: 0,
|
||||
linesSeen: 0,
|
||||
wordsSeen: 0,
|
||||
tokensSeen: 0,
|
||||
cardsMined: 0,
|
||||
lookupCount: 0,
|
||||
lookupHits: 0,
|
||||
pauseCount: 0,
|
||||
pauseMs: 0,
|
||||
seekForwardCount: 0,
|
||||
seekBackwardCount: 0,
|
||||
mediaBufferEvents: 0,
|
||||
lastWallClockMs: 0,
|
||||
lastMediaMs: null,
|
||||
lastPauseStartMs: null,
|
||||
isPaused: false,
|
||||
pendingTelemetry: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveBoundedInt(
|
||||
value: number | undefined,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max: number,
|
||||
): number {
|
||||
if (!Number.isFinite(value)) return fallback;
|
||||
const candidate = Math.floor(value as number);
|
||||
if (candidate < min || candidate > max) return fallback;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function sanitizePayload(payload: Record<string, unknown>, maxPayloadBytes: number): string {
|
||||
const json = JSON.stringify(payload);
|
||||
return json.length <= maxPayloadBytes ? json : JSON.stringify({ truncated: true });
|
||||
}
|
||||
|
||||
export function calculateTextMetrics(value: string): {
|
||||
words: number;
|
||||
tokens: number;
|
||||
} {
|
||||
const words = value.split(/\s+/).filter(Boolean).length;
|
||||
const cjkCount = value.match(/[\u3040-\u30ff\u4e00-\u9fff]/g)?.length ?? 0;
|
||||
const tokens = Math.max(words, cjkCount);
|
||||
return { words, tokens };
|
||||
}
|
||||
|
||||
export function secToMs(seconds: number): number {
|
||||
const coerced = Number(seconds);
|
||||
if (!Number.isFinite(coerced)) return 0;
|
||||
return Math.round(coerced * 1000);
|
||||
}
|
||||
|
||||
export function normalizeMediaPath(mediaPath: string | null): string {
|
||||
if (!mediaPath || !mediaPath.trim()) return '';
|
||||
return mediaPath.trim();
|
||||
}
|
||||
|
||||
export function normalizeText(value: string | null | undefined): string {
|
||||
if (!value) return '';
|
||||
return value.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function buildVideoKey(mediaPath: string, sourceType: number): string {
|
||||
if (sourceType === SOURCE_TYPE_REMOTE) {
|
||||
return `remote:${mediaPath}`;
|
||||
}
|
||||
return `local:${mediaPath}`;
|
||||
}
|
||||
|
||||
export function isRemoteSource(mediaPath: string): boolean {
|
||||
return /^[a-z][a-z0-9+.-]*:\/\//i.test(mediaPath);
|
||||
}
|
||||
|
||||
export function deriveCanonicalTitle(mediaPath: string): string {
|
||||
if (isRemoteSource(mediaPath)) {
|
||||
try {
|
||||
const parsed = new URL(mediaPath);
|
||||
const parts = parsed.pathname.split('/').filter(Boolean);
|
||||
if (parts.length > 0) {
|
||||
const leaf = decodeURIComponent(parts[parts.length - 1]!);
|
||||
return normalizeText(leaf.replace(/\.[^/.]+$/, ''));
|
||||
}
|
||||
return normalizeText(parsed.hostname) || 'unknown';
|
||||
} catch {
|
||||
return normalizeText(mediaPath);
|
||||
}
|
||||
}
|
||||
|
||||
const filename = path.basename(mediaPath);
|
||||
return normalizeText(filename.replace(/\.[^/.]+$/, ''));
|
||||
}
|
||||
|
||||
export function parseFps(value?: string): number | null {
|
||||
if (!value || typeof value !== 'string') return null;
|
||||
const [num, den] = value.split('/');
|
||||
const n = Number(num);
|
||||
const d = Number(den);
|
||||
if (!Number.isFinite(n) || !Number.isFinite(d) || d === 0) return null;
|
||||
const fps = n / d;
|
||||
return Number.isFinite(fps) ? Math.round(fps * 100) : null;
|
||||
}
|
||||
|
||||
export function hashToCode(input?: string): number | null {
|
||||
if (!input) return null;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash = (hash * 31 + input.charCodeAt(i)) & 0x7fffffff;
|
||||
}
|
||||
return hash || null;
|
||||
}
|
||||
|
||||
export function emptyMetadata(): ProbeMetadata {
|
||||
return {
|
||||
durationMs: null,
|
||||
codecId: null,
|
||||
containerId: null,
|
||||
widthPx: null,
|
||||
heightPx: null,
|
||||
fpsX100: null,
|
||||
bitrateKbps: null,
|
||||
audioCodecId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function toNullableInt(value: number | null | undefined): number | null {
|
||||
if (value === null || value === undefined || !Number.isFinite(value)) return null;
|
||||
return value;
|
||||
}
|
||||
37
src/core/services/immersion-tracker/session.ts
Normal file
37
src/core/services/immersion-tracker/session.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import { createInitialSessionState } from './reducer';
|
||||
import { SESSION_STATUS_ACTIVE, SESSION_STATUS_ENDED } from './types';
|
||||
import type { SessionState } from './types';
|
||||
|
||||
export function startSessionRecord(
|
||||
db: DatabaseSync,
|
||||
videoId: number,
|
||||
startedAtMs = Date.now(),
|
||||
): { sessionId: number; state: SessionState } {
|
||||
const sessionUuid = crypto.randomUUID();
|
||||
const result = db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO imm_sessions (
|
||||
session_uuid, video_id, started_at_ms, status, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
)
|
||||
.run(sessionUuid, videoId, startedAtMs, SESSION_STATUS_ACTIVE, startedAtMs, startedAtMs);
|
||||
const sessionId = Number(result.lastInsertRowid);
|
||||
return {
|
||||
sessionId,
|
||||
state: createInitialSessionState(sessionId, videoId, startedAtMs),
|
||||
};
|
||||
}
|
||||
|
||||
export function finalizeSessionRecord(
|
||||
db: DatabaseSync,
|
||||
sessionState: SessionState,
|
||||
endedAtMs = Date.now(),
|
||||
): void {
|
||||
db.prepare(
|
||||
'UPDATE imm_sessions SET ended_at_ms = ?, status = ?, updated_at_ms = ? WHERE session_id = ?',
|
||||
).run(endedAtMs, SESSION_STATUS_ENDED, Date.now(), sessionState.sessionId);
|
||||
}
|
||||
162
src/core/services/immersion-tracker/storage-session.test.ts
Normal file
162
src/core/services/immersion-tracker/storage-session.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import type { DatabaseSync as NodeDatabaseSync } from 'node:sqlite';
|
||||
import { finalizeSessionRecord, startSessionRecord } from './session';
|
||||
import {
|
||||
createTrackerPreparedStatements,
|
||||
ensureSchema,
|
||||
executeQueuedWrite,
|
||||
getOrCreateVideoRecord,
|
||||
} from './storage';
|
||||
import { EVENT_SUBTITLE_LINE, SESSION_STATUS_ENDED, SOURCE_TYPE_LOCAL } from './types';
|
||||
|
||||
type DatabaseSyncCtor = typeof NodeDatabaseSync;
|
||||
const DatabaseSync: DatabaseSyncCtor | null = (() => {
|
||||
try {
|
||||
return (require('node:sqlite') as { DatabaseSync?: DatabaseSyncCtor }).DatabaseSync ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const testIfSqlite = DatabaseSync ? test : test.skip;
|
||||
|
||||
function makeDbPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-imm-storage-session-'));
|
||||
return path.join(dir, 'immersion.sqlite');
|
||||
}
|
||||
|
||||
function cleanupDbPath(dbPath: string): void {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (fs.existsSync(dir)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
testIfSqlite('ensureSchema creates immersion core tables', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new DatabaseSync!(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'imm_%' ORDER BY name`,
|
||||
)
|
||||
.all() as Array<{ name: string }>;
|
||||
const tableNames = new Set(rows.map((row) => row.name));
|
||||
|
||||
assert.ok(tableNames.has('imm_videos'));
|
||||
assert.ok(tableNames.has('imm_sessions'));
|
||||
assert.ok(tableNames.has('imm_session_telemetry'));
|
||||
assert.ok(tableNames.has('imm_session_events'));
|
||||
assert.ok(tableNames.has('imm_daily_rollups'));
|
||||
assert.ok(tableNames.has('imm_monthly_rollups'));
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
testIfSqlite('start/finalize session updates ended_at and status', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new DatabaseSync!(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/slice-a.mkv', {
|
||||
canonicalTitle: 'Slice A Episode',
|
||||
sourcePath: '/tmp/slice-a.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const startedAtMs = 1_234_567_000;
|
||||
const endedAtMs = startedAtMs + 8_500;
|
||||
const { sessionId, state } = startSessionRecord(db, videoId, startedAtMs);
|
||||
|
||||
finalizeSessionRecord(db, state, endedAtMs);
|
||||
|
||||
const row = db
|
||||
.prepare('SELECT ended_at_ms, status FROM imm_sessions WHERE session_id = ?')
|
||||
.get(sessionId) as {
|
||||
ended_at_ms: number | null;
|
||||
status: number;
|
||||
} | null;
|
||||
|
||||
assert.ok(row);
|
||||
assert.equal(row?.ended_at_ms, endedAtMs);
|
||||
assert.equal(row?.status, SESSION_STATUS_ENDED);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
testIfSqlite('executeQueuedWrite inserts event and telemetry rows', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new DatabaseSync!(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const stmts = createTrackerPreparedStatements(db);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/slice-a-events.mkv', {
|
||||
canonicalTitle: 'Slice A Events',
|
||||
sourcePath: '/tmp/slice-a-events.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const { sessionId } = startSessionRecord(db, videoId, 5_000);
|
||||
|
||||
executeQueuedWrite(
|
||||
{
|
||||
kind: 'telemetry',
|
||||
sessionId,
|
||||
sampleMs: 6_000,
|
||||
totalWatchedMs: 1_000,
|
||||
activeWatchedMs: 900,
|
||||
linesSeen: 3,
|
||||
wordsSeen: 6,
|
||||
tokensSeen: 6,
|
||||
cardsMined: 1,
|
||||
lookupCount: 2,
|
||||
lookupHits: 1,
|
||||
pauseCount: 1,
|
||||
pauseMs: 50,
|
||||
seekForwardCount: 0,
|
||||
seekBackwardCount: 0,
|
||||
mediaBufferEvents: 0,
|
||||
},
|
||||
stmts,
|
||||
);
|
||||
executeQueuedWrite(
|
||||
{
|
||||
kind: 'event',
|
||||
sessionId,
|
||||
sampleMs: 6_100,
|
||||
eventType: EVENT_SUBTITLE_LINE,
|
||||
lineIndex: 1,
|
||||
segmentStartMs: 0,
|
||||
segmentEndMs: 800,
|
||||
wordsDelta: 2,
|
||||
cardsDelta: 0,
|
||||
payloadJson: '{"event":"subtitle-line"}',
|
||||
},
|
||||
stmts,
|
||||
);
|
||||
|
||||
const telemetryCount = db
|
||||
.prepare('SELECT COUNT(*) AS total FROM imm_session_telemetry WHERE session_id = ?')
|
||||
.get(sessionId) as { total: number };
|
||||
const eventCount = db
|
||||
.prepare('SELECT COUNT(*) AS total FROM imm_session_events WHERE session_id = ?')
|
||||
.get(sessionId) as { total: number };
|
||||
|
||||
assert.equal(telemetryCount.total, 1);
|
||||
assert.equal(eventCount.total, 1);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
328
src/core/services/immersion-tracker/storage.ts
Normal file
328
src/core/services/immersion-tracker/storage.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import { SCHEMA_VERSION } from './types';
|
||||
import type { QueuedWrite, VideoMetadata } from './types';
|
||||
|
||||
export interface TrackerPreparedStatements {
|
||||
telemetryInsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
eventInsertStmt: ReturnType<DatabaseSync['prepare']>;
|
||||
}
|
||||
|
||||
export function applyPragmas(db: DatabaseSync): void {
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA synchronous = NORMAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 2500');
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
`);
|
||||
|
||||
const currentVersion = db
|
||||
.prepare('SELECT schema_version FROM imm_schema_version ORDER BY schema_version DESC LIMIT 1')
|
||||
.get() as { schema_version: number } | null;
|
||||
if (currentVersion?.schema_version === SCHEMA_VERSION) {
|
||||
return;
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS imm_videos(
|
||||
video_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
video_key TEXT NOT NULL UNIQUE,
|
||||
canonical_title TEXT NOT NULL,
|
||||
source_type INTEGER NOT NULL,
|
||||
source_path TEXT,
|
||||
source_url TEXT,
|
||||
duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
|
||||
file_size_bytes INTEGER CHECK(file_size_bytes>=0),
|
||||
codec_id INTEGER, container_id INTEGER,
|
||||
width_px INTEGER, height_px INTEGER, fps_x100 INTEGER,
|
||||
bitrate_kbps INTEGER, audio_codec_id INTEGER,
|
||||
hash_sha256 TEXT, screenshot_path TEXT,
|
||||
metadata_json TEXT,
|
||||
created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS imm_sessions(
|
||||
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,
|
||||
status INTEGER NOT NULL,
|
||||
locale_id INTEGER, target_lang_id INTEGER,
|
||||
difficulty_tier INTEGER, subtitle_mode INTEGER,
|
||||
created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL,
|
||||
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id)
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS imm_session_telemetry(
|
||||
telemetry_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
sample_ms INTEGER 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,
|
||||
words_seen INTEGER NOT NULL DEFAULT 0,
|
||||
tokens_seen INTEGER NOT NULL DEFAULT 0,
|
||||
cards_mined INTEGER NOT NULL DEFAULT 0,
|
||||
lookup_count INTEGER NOT NULL DEFAULT 0,
|
||||
lookup_hits INTEGER NOT NULL DEFAULT 0,
|
||||
pause_count INTEGER NOT NULL DEFAULT 0,
|
||||
pause_ms INTEGER NOT NULL DEFAULT 0,
|
||||
seek_forward_count INTEGER NOT NULL DEFAULT 0,
|
||||
seek_backward_count INTEGER NOT NULL DEFAULT 0,
|
||||
media_buffer_events INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS imm_session_events(
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
ts_ms INTEGER NOT NULL,
|
||||
event_type INTEGER NOT NULL,
|
||||
line_index INTEGER,
|
||||
segment_start_ms INTEGER,
|
||||
segment_end_ms INTEGER,
|
||||
words_delta INTEGER NOT NULL DEFAULT 0,
|
||||
cards_delta INTEGER NOT NULL DEFAULT 0,
|
||||
payload_json TEXT,
|
||||
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS imm_daily_rollups(
|
||||
rollup_day INTEGER NOT NULL,
|
||||
video_id INTEGER,
|
||||
total_sessions INTEGER NOT NULL DEFAULT 0,
|
||||
total_active_min REAL NOT NULL DEFAULT 0,
|
||||
total_lines_seen INTEGER NOT NULL DEFAULT 0,
|
||||
total_words_seen INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
|
||||
total_cards INTEGER NOT NULL DEFAULT 0,
|
||||
cards_per_hour REAL,
|
||||
words_per_min REAL,
|
||||
lookup_hit_rate REAL,
|
||||
PRIMARY KEY (rollup_day, video_id)
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS imm_monthly_rollups(
|
||||
rollup_month INTEGER NOT NULL,
|
||||
video_id INTEGER,
|
||||
total_sessions INTEGER NOT NULL DEFAULT 0,
|
||||
total_active_min REAL NOT NULL DEFAULT 0,
|
||||
total_lines_seen INTEGER NOT NULL DEFAULT 0,
|
||||
total_words_seen INTEGER NOT NULL DEFAULT 0,
|
||||
total_tokens_seen INTEGER NOT NULL DEFAULT 0,
|
||||
total_cards INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (rollup_month, video_id)
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_video_started
|
||||
ON imm_sessions(video_id, started_at_ms DESC)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_status_started
|
||||
ON imm_sessions(status, started_at_ms DESC)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_telemetry_session_sample
|
||||
ON imm_session_telemetry(session_id, sample_ms DESC)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_events_session_ts
|
||||
ON imm_session_events(session_id, ts_ms DESC)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_events_type_ts
|
||||
ON imm_session_events(event_type, ts_ms DESC)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_rollups_day_video
|
||||
ON imm_daily_rollups(rollup_day, video_id)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_rollups_month_video
|
||||
ON imm_monthly_rollups(rollup_month, video_id)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
INSERT INTO imm_schema_version(schema_version, applied_at_ms)
|
||||
VALUES (${SCHEMA_VERSION}, ${Date.now()})
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
}
|
||||
|
||||
export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPreparedStatements {
|
||||
return {
|
||||
telemetryInsertStmt: db.prepare(`
|
||||
INSERT INTO imm_session_telemetry (
|
||||
session_id, sample_ms, total_watched_ms, active_watched_ms,
|
||||
lines_seen, words_seen, tokens_seen, cards_mined, lookup_count,
|
||||
lookup_hits, pause_count, pause_ms, seek_forward_count,
|
||||
seek_backward_count, media_buffer_events
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
`),
|
||||
eventInsertStmt: db.prepare(`
|
||||
INSERT INTO imm_session_events (
|
||||
session_id, ts_ms, event_type, line_index, segment_start_ms, segment_end_ms,
|
||||
words_delta, cards_delta, payload_json
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
`),
|
||||
};
|
||||
}
|
||||
|
||||
export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedStatements): void {
|
||||
if (write.kind === 'telemetry') {
|
||||
stmts.telemetryInsertStmt.run(
|
||||
write.sessionId,
|
||||
write.sampleMs!,
|
||||
write.totalWatchedMs!,
|
||||
write.activeWatchedMs!,
|
||||
write.linesSeen!,
|
||||
write.wordsSeen!,
|
||||
write.tokensSeen!,
|
||||
write.cardsMined!,
|
||||
write.lookupCount!,
|
||||
write.lookupHits!,
|
||||
write.pauseCount!,
|
||||
write.pauseMs!,
|
||||
write.seekForwardCount!,
|
||||
write.seekBackwardCount!,
|
||||
write.mediaBufferEvents!,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
stmts.eventInsertStmt.run(
|
||||
write.sessionId,
|
||||
write.sampleMs!,
|
||||
write.eventType!,
|
||||
write.lineIndex ?? null,
|
||||
write.segmentStartMs ?? null,
|
||||
write.segmentEndMs ?? null,
|
||||
write.wordsDelta ?? 0,
|
||||
write.cardsDelta ?? 0,
|
||||
write.payloadJson ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
export function getOrCreateVideoRecord(
|
||||
db: DatabaseSync,
|
||||
videoKey: string,
|
||||
details: {
|
||||
canonicalTitle: string;
|
||||
sourcePath: string | null;
|
||||
sourceUrl: string | null;
|
||||
sourceType: number;
|
||||
},
|
||||
): number {
|
||||
const existing = db
|
||||
.prepare('SELECT video_id FROM imm_videos WHERE video_key = ?')
|
||||
.get(videoKey) as { video_id: number } | null;
|
||||
if (existing?.video_id) {
|
||||
db.prepare(
|
||||
'UPDATE imm_videos SET canonical_title = ?, updated_at_ms = ? WHERE video_id = ?',
|
||||
).run(details.canonicalTitle || 'unknown', Date.now(), existing.video_id);
|
||||
return existing.video_id;
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO imm_videos (
|
||||
video_key, canonical_title, source_type, source_path, source_url,
|
||||
duration_ms, file_size_bytes, codec_id, container_id, width_px, height_px,
|
||||
fps_x100, bitrate_kbps, audio_codec_id, hash_sha256, screenshot_path,
|
||||
metadata_json, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const result = insert.run(
|
||||
videoKey,
|
||||
details.canonicalTitle || 'unknown',
|
||||
details.sourceType,
|
||||
details.sourcePath,
|
||||
details.sourceUrl,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
nowMs,
|
||||
nowMs,
|
||||
);
|
||||
return Number(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
export function updateVideoMetadataRecord(
|
||||
db: DatabaseSync,
|
||||
videoId: number,
|
||||
metadata: VideoMetadata,
|
||||
): void {
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_videos
|
||||
SET
|
||||
duration_ms = ?,
|
||||
file_size_bytes = ?,
|
||||
codec_id = ?,
|
||||
container_id = ?,
|
||||
width_px = ?,
|
||||
height_px = ?,
|
||||
fps_x100 = ?,
|
||||
bitrate_kbps = ?,
|
||||
audio_codec_id = ?,
|
||||
hash_sha256 = ?,
|
||||
screenshot_path = ?,
|
||||
metadata_json = ?,
|
||||
updated_at_ms = ?
|
||||
WHERE video_id = ?
|
||||
`,
|
||||
).run(
|
||||
metadata.durationMs,
|
||||
metadata.fileSizeBytes,
|
||||
metadata.codecId,
|
||||
metadata.containerId,
|
||||
metadata.widthPx,
|
||||
metadata.heightPx,
|
||||
metadata.fpsX100,
|
||||
metadata.bitrateKbps,
|
||||
metadata.audioCodecId,
|
||||
metadata.hashSha256,
|
||||
metadata.screenshotPath,
|
||||
metadata.metadataJson,
|
||||
Date.now(),
|
||||
videoId,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateVideoTitleRecord(
|
||||
db: DatabaseSync,
|
||||
videoId: number,
|
||||
canonicalTitle: string,
|
||||
): void {
|
||||
db.prepare('UPDATE imm_videos SET canonical_title = ?, updated_at_ms = ? WHERE video_id = ?').run(
|
||||
canonicalTitle,
|
||||
Date.now(),
|
||||
videoId,
|
||||
);
|
||||
}
|
||||
167
src/core/services/immersion-tracker/types.ts
Normal file
167
src/core/services/immersion-tracker/types.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
export const SCHEMA_VERSION = 1;
|
||||
export const DEFAULT_QUEUE_CAP = 1_000;
|
||||
export const DEFAULT_BATCH_SIZE = 25;
|
||||
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
|
||||
export const DEFAULT_MAINTENANCE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
export const DEFAULT_EVENTS_RETENTION_MS = ONE_WEEK_MS;
|
||||
export const DEFAULT_VACUUM_INTERVAL_MS = ONE_WEEK_MS;
|
||||
export const DEFAULT_TELEMETRY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
export const DEFAULT_DAILY_ROLLUP_RETENTION_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
export const DEFAULT_MONTHLY_ROLLUP_RETENTION_MS = 5 * 365 * 24 * 60 * 60 * 1000;
|
||||
export const DEFAULT_MAX_PAYLOAD_BYTES = 256;
|
||||
|
||||
export const SOURCE_TYPE_LOCAL = 1;
|
||||
export const SOURCE_TYPE_REMOTE = 2;
|
||||
|
||||
export const SESSION_STATUS_ACTIVE = 1;
|
||||
export const SESSION_STATUS_ENDED = 2;
|
||||
|
||||
export const EVENT_SUBTITLE_LINE = 1;
|
||||
export const EVENT_MEDIA_BUFFER = 2;
|
||||
export const EVENT_LOOKUP = 3;
|
||||
export const EVENT_CARD_MINED = 4;
|
||||
export const EVENT_SEEK_FORWARD = 5;
|
||||
export const EVENT_SEEK_BACKWARD = 6;
|
||||
export const EVENT_PAUSE_START = 7;
|
||||
export const EVENT_PAUSE_END = 8;
|
||||
|
||||
export interface ImmersionTrackerOptions {
|
||||
dbPath: string;
|
||||
policy?: ImmersionTrackerPolicy;
|
||||
}
|
||||
|
||||
export interface ImmersionTrackerPolicy {
|
||||
queueCap?: number;
|
||||
batchSize?: number;
|
||||
flushIntervalMs?: number;
|
||||
maintenanceIntervalMs?: number;
|
||||
payloadCapBytes?: number;
|
||||
retention?: {
|
||||
eventsDays?: number;
|
||||
telemetryDays?: number;
|
||||
dailyRollupsDays?: number;
|
||||
monthlyRollupsDays?: number;
|
||||
vacuumIntervalDays?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TelemetryAccumulator {
|
||||
totalWatchedMs: number;
|
||||
activeWatchedMs: number;
|
||||
linesSeen: number;
|
||||
wordsSeen: number;
|
||||
tokensSeen: number;
|
||||
cardsMined: number;
|
||||
lookupCount: number;
|
||||
lookupHits: number;
|
||||
pauseCount: number;
|
||||
pauseMs: number;
|
||||
seekForwardCount: number;
|
||||
seekBackwardCount: number;
|
||||
mediaBufferEvents: number;
|
||||
}
|
||||
|
||||
export interface SessionState extends TelemetryAccumulator {
|
||||
sessionId: number;
|
||||
videoId: number;
|
||||
startedAtMs: number;
|
||||
currentLineIndex: number;
|
||||
lastWallClockMs: number;
|
||||
lastMediaMs: number | null;
|
||||
lastPauseStartMs: number | null;
|
||||
isPaused: boolean;
|
||||
pendingTelemetry: boolean;
|
||||
}
|
||||
|
||||
export interface QueuedWrite {
|
||||
kind: 'telemetry' | 'event';
|
||||
sessionId: number;
|
||||
sampleMs?: number;
|
||||
totalWatchedMs?: number;
|
||||
activeWatchedMs?: number;
|
||||
linesSeen?: number;
|
||||
wordsSeen?: number;
|
||||
tokensSeen?: number;
|
||||
cardsMined?: number;
|
||||
lookupCount?: number;
|
||||
lookupHits?: number;
|
||||
pauseCount?: number;
|
||||
pauseMs?: number;
|
||||
seekForwardCount?: number;
|
||||
seekBackwardCount?: number;
|
||||
mediaBufferEvents?: number;
|
||||
eventType?: number;
|
||||
lineIndex?: number | null;
|
||||
segmentStartMs?: number | null;
|
||||
segmentEndMs?: number | null;
|
||||
wordsDelta?: number;
|
||||
cardsDelta?: number;
|
||||
payloadJson?: string | null;
|
||||
}
|
||||
|
||||
export interface VideoMetadata {
|
||||
sourceType: number;
|
||||
canonicalTitle: string;
|
||||
durationMs: number;
|
||||
fileSizeBytes: number | null;
|
||||
codecId: number | null;
|
||||
containerId: number | null;
|
||||
widthPx: number | null;
|
||||
heightPx: number | null;
|
||||
fpsX100: number | null;
|
||||
bitrateKbps: number | null;
|
||||
audioCodecId: number | null;
|
||||
hashSha256: string | null;
|
||||
screenshotPath: string | null;
|
||||
metadataJson: string | null;
|
||||
}
|
||||
|
||||
export interface SessionSummaryQueryRow {
|
||||
videoId: number | null;
|
||||
startedAtMs: number;
|
||||
endedAtMs: number | null;
|
||||
totalWatchedMs: number;
|
||||
activeWatchedMs: number;
|
||||
linesSeen: number;
|
||||
wordsSeen: number;
|
||||
tokensSeen: number;
|
||||
cardsMined: number;
|
||||
lookupCount: number;
|
||||
lookupHits: number;
|
||||
}
|
||||
|
||||
export interface SessionTimelineRow {
|
||||
sampleMs: number;
|
||||
totalWatchedMs: number;
|
||||
activeWatchedMs: number;
|
||||
linesSeen: number;
|
||||
wordsSeen: number;
|
||||
tokensSeen: number;
|
||||
cardsMined: number;
|
||||
}
|
||||
|
||||
export interface ImmersionSessionRollupRow {
|
||||
rollupDayOrMonth: number;
|
||||
videoId: number | null;
|
||||
totalSessions: number;
|
||||
totalActiveMin: number;
|
||||
totalLinesSeen: number;
|
||||
totalWordsSeen: number;
|
||||
totalTokensSeen: number;
|
||||
totalCards: number;
|
||||
cardsPerHour: number | null;
|
||||
wordsPerMin: number | null;
|
||||
lookupHitRate: number | null;
|
||||
}
|
||||
|
||||
export interface ProbeMetadata {
|
||||
durationMs: number | null;
|
||||
codecId: number | null;
|
||||
containerId: number | null;
|
||||
widthPx: number | null;
|
||||
heightPx: number | null;
|
||||
fpsX100: number | null;
|
||||
bitrateKbps: number | null;
|
||||
audioCodecId: number | null;
|
||||
}
|
||||
Reference in New Issue
Block a user