mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
feat(sync): headless --sync-cli mode so sync only needs the app installed
The Electron app now answers launcher-style sync argv (--sync-cli sync [host|--snapshot|--merge] ... plus --help/--version) at entry, before any window or display initialization, backed by a libsql binding of the shared stats-sync engine. Works over SSH with no display server, so a remote machine no longer needs the bun launcher. The launcher accepts --sync-cli as a no-op so both invocation shapes stay equivalent.
This commit is contained in:
@@ -79,7 +79,11 @@ function applyRootOptions(program: Command): void {
|
||||
.option('-R, --rofi', 'Use rofi picker')
|
||||
.option('-H, --history', 'Browse local watch history')
|
||||
.option('-S, --start-overlay', 'Auto-start overlay')
|
||||
.option('-T, --no-texthooker', 'Disable texthooker-ui server');
|
||||
.option('-T, --no-texthooker', 'Disable texthooker-ui server')
|
||||
// The SubMiner app answers sync commands when invoked with --sync-cli.
|
||||
// Remote-command resolution may address the launcher the same way, so
|
||||
// accept the flag as a no-op to keep both invocation shapes equivalent.
|
||||
.option('--sync-cli', 'Compatibility no-op (sync commands work with or without it)');
|
||||
}
|
||||
|
||||
function buildSubcommandHelpText(program: Command): string {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
createImmersionDbFixture,
|
||||
insertFixtureSession,
|
||||
} from '../test-support/immersion-db-fixture.js';
|
||||
import { openLibsqlSyncDb } from '../../src/core/services/stats-sync/libsql-driver.js';
|
||||
import { createDbSnapshot } from '../../src/core/services/stats-sync/shared.js';
|
||||
import { mergeSnapshotIntoDb } from '../../src/core/services/stats-sync/merge.js';
|
||||
|
||||
const BASE_MS = Date.UTC(2026, 5, 1, 12, 0, 0);
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-libsql-sync-test-'));
|
||||
}
|
||||
|
||||
function countRows(dbPath: string, sql: string): number {
|
||||
const db = openLibsqlSyncDb(dbPath, { readonly: true });
|
||||
try {
|
||||
const row = db.query(sql).get() as { n: number } | undefined;
|
||||
return Number(row?.n ?? 0);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end pass of the shared engine over the libsql driver (the binding the
|
||||
// Electron app's --sync-cli mode uses): snapshot a fixture DB, merge it into
|
||||
// another, and re-merge to confirm idempotence.
|
||||
test('libsql driver runs snapshot and merge through the shared engine', () => {
|
||||
const dir = makeTmpDir();
|
||||
try {
|
||||
const localPath = path.join(dir, 'local.sqlite');
|
||||
const remotePath = path.join(dir, 'remote.sqlite');
|
||||
createImmersionDbFixture(localPath);
|
||||
createImmersionDbFixture(remotePath);
|
||||
insertFixtureSession(localPath, {
|
||||
uuid: 'local-1',
|
||||
videoKey: 'showa-e1',
|
||||
animeTitleKey: 'showa',
|
||||
startedAtMs: BASE_MS,
|
||||
applyLifetime: true,
|
||||
words: [{ headword: '見る', word: '見た', reading: 'みた', count: 2 }],
|
||||
});
|
||||
insertFixtureSession(remotePath, {
|
||||
uuid: 'remote-1',
|
||||
videoKey: 'showb-e1',
|
||||
animeTitleKey: 'showb',
|
||||
startedAtMs: BASE_MS + 86_400_000,
|
||||
activeWatchedMs: 900_000,
|
||||
cardsMined: 3,
|
||||
applyLifetime: true,
|
||||
words: [{ headword: '食べる', word: '食べた', reading: 'たべた', count: 1 }],
|
||||
});
|
||||
|
||||
const snapshotPath = path.join(dir, 'remote-snapshot.sqlite');
|
||||
createDbSnapshot(openLibsqlSyncDb, remotePath, snapshotPath);
|
||||
assert.ok(fs.existsSync(snapshotPath));
|
||||
assert.equal(countRows(snapshotPath, 'SELECT COUNT(*) AS n FROM imm_sessions'), 1);
|
||||
|
||||
const summary = mergeSnapshotIntoDb(openLibsqlSyncDb, localPath, snapshotPath);
|
||||
assert.equal(summary.sessionsMerged, 1);
|
||||
assert.equal(summary.videosAdded, 1);
|
||||
assert.equal(countRows(localPath, 'SELECT COUNT(*) AS n FROM imm_sessions'), 2);
|
||||
|
||||
const again = mergeSnapshotIntoDb(openLibsqlSyncDb, localPath, snapshotPath);
|
||||
assert.equal(again.sessionsMerged, 0);
|
||||
assert.equal(again.sessionsAlreadyPresent, 1);
|
||||
assert.equal(countRows(localPath, 'SELECT COUNT(*) AS n FROM imm_sessions'), 2);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
export declare function createImmersionDbFixture(dbPath: string): void;
|
||||
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 declare function insertFixtureSession(dbPath: string, input: FixtureSessionInput): void;
|
||||
//# sourceMappingURL=immersion-db-fixture.d.ts.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"immersion-db-fixture.d.ts","sourceRoot":"","sources":["immersion-db-fixture.ts"],"names":[],"mappings":"AAIA,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAsB7D;AAID,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,KAAK,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAClF,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,mBAAmB,GAAG,IAAI,CAsKrF"}
|
||||
@@ -0,0 +1,158 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createImmersionDbFixture = createImmersionDbFixture;
|
||||
exports.insertFixtureSession = insertFixtureSession;
|
||||
const bun_sqlite_1 = require("bun:sqlite");
|
||||
const types_js_1 = require("../../src/core/services/immersion-tracker/types.js");
|
||||
const immersion_db_schema_js_1 = require("./immersion-db-schema.js");
|
||||
function createImmersionDbFixture(dbPath) {
|
||||
const db = new bun_sqlite_1.Database(dbPath, { create: true });
|
||||
try {
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
db.run('PRAGMA journal_mode = WAL');
|
||||
for (const statement of immersion_db_schema_js_1.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(types_js_1.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;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function insertFixtureSession(dbPath, input) {
|
||||
const db = new bun_sqlite_1.Database(dbPath, { readwrite: true });
|
||||
const stamp = String(input.startedAtMs);
|
||||
try {
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
let animeId = null;
|
||||
if (input.animeTitleKey) {
|
||||
const existing = db
|
||||
.query('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('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('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, sessionId, videoId, animeId, data) {
|
||||
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('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);
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=immersion-db-fixture.js.map
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"immersion-db-schema.d.ts","sourceRoot":"","sources":["immersion-db-schema.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,wBAAwB,+rYA+TpC,CAAC"}
|
||||
@@ -0,0 +1,327 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.IMMERSION_DB_FIXTURE_DDL = void 0;
|
||||
// Schema-version-18 shape of the tables the sync merge touches (plus the
|
||||
// app's indexes), mirroring ensureSchema / ensureLifetimeSummaryTables /
|
||||
// ensureStatsExcludedWordsTable in src/core/services/immersion-tracker/storage.ts.
|
||||
exports.IMMERSION_DB_FIXTURE_DDL = `
|
||||
CREATE TABLE imm_schema_version (
|
||||
schema_version INTEGER PRIMARY KEY,
|
||||
applied_at_ms TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE imm_rollup_state(
|
||||
state_key TEXT PRIMARY KEY,
|
||||
state_value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE imm_anime(
|
||||
anime_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
normalized_title_key TEXT NOT NULL UNIQUE,
|
||||
canonical_title TEXT NOT NULL,
|
||||
anilist_id INTEGER UNIQUE,
|
||||
title_romaji TEXT,
|
||||
title_english TEXT,
|
||||
title_native TEXT,
|
||||
episodes_total INTEGER,
|
||||
description TEXT,
|
||||
metadata_json TEXT,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT
|
||||
);
|
||||
CREATE TABLE imm_videos(
|
||||
video_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
video_key TEXT NOT NULL UNIQUE,
|
||||
anime_id INTEGER,
|
||||
canonical_title TEXT NOT NULL,
|
||||
source_type INTEGER NOT NULL,
|
||||
source_path TEXT,
|
||||
source_url TEXT,
|
||||
parsed_basename TEXT,
|
||||
parsed_title TEXT,
|
||||
parsed_season INTEGER,
|
||||
parsed_episode INTEGER,
|
||||
parser_source TEXT,
|
||||
parser_confidence REAL,
|
||||
parse_metadata_json TEXT,
|
||||
watched INTEGER NOT NULL DEFAULT 0,
|
||||
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_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE TABLE imm_sessions(
|
||||
session_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_uuid TEXT NOT NULL UNIQUE,
|
||||
video_id INTEGER NOT NULL,
|
||||
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,
|
||||
ended_media_ms INTEGER,
|
||||
total_watched_ms INTEGER NOT NULL DEFAULT 0,
|
||||
active_watched_ms INTEGER NOT NULL DEFAULT 0,
|
||||
lines_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,
|
||||
yomitan_lookup_count 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,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id)
|
||||
);
|
||||
CREATE TABLE imm_session_telemetry(
|
||||
telemetry_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id 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,
|
||||
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,
|
||||
yomitan_lookup_count 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,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE imm_session_events(
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
ts_ms TEXT NOT NULL,
|
||||
event_type INTEGER NOT NULL,
|
||||
line_index INTEGER,
|
||||
segment_start_ms INTEGER,
|
||||
segment_end_ms INTEGER,
|
||||
tokens_delta INTEGER NOT NULL DEFAULT 0,
|
||||
cards_delta INTEGER NOT NULL DEFAULT 0,
|
||||
payload_json TEXT,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(session_id) REFERENCES imm_sessions(session_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE 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_tokens_seen INTEGER NOT NULL DEFAULT 0,
|
||||
total_cards INTEGER NOT NULL DEFAULT 0,
|
||||
cards_per_hour REAL,
|
||||
tokens_per_min REAL,
|
||||
lookup_hit_rate REAL,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
PRIMARY KEY (rollup_day, video_id)
|
||||
);
|
||||
CREATE TABLE 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_tokens_seen INTEGER NOT NULL DEFAULT 0,
|
||||
total_cards INTEGER NOT NULL DEFAULT 0,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
PRIMARY KEY (rollup_month, video_id)
|
||||
);
|
||||
CREATE TABLE imm_words(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
headword TEXT,
|
||||
word TEXT,
|
||||
reading TEXT,
|
||||
part_of_speech TEXT,
|
||||
pos1 TEXT,
|
||||
pos2 TEXT,
|
||||
pos3 TEXT,
|
||||
first_seen REAL,
|
||||
last_seen REAL,
|
||||
frequency INTEGER,
|
||||
frequency_rank INTEGER,
|
||||
UNIQUE(headword, word, reading)
|
||||
);
|
||||
CREATE TABLE imm_kanji(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kanji TEXT,
|
||||
first_seen REAL,
|
||||
last_seen REAL,
|
||||
frequency INTEGER,
|
||||
UNIQUE(kanji)
|
||||
);
|
||||
CREATE TABLE imm_subtitle_lines(
|
||||
line_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL,
|
||||
event_id INTEGER,
|
||||
video_id INTEGER NOT NULL,
|
||||
anime_id INTEGER,
|
||||
line_index INTEGER NOT NULL,
|
||||
segment_start_ms INTEGER,
|
||||
segment_end_ms INTEGER,
|
||||
text TEXT NOT NULL,
|
||||
secondary_text TEXT,
|
||||
CREATED_DATE INTEGER,
|
||||
LAST_UPDATE_DATE INTEGER,
|
||||
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,
|
||||
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE SET NULL
|
||||
);
|
||||
CREATE TABLE imm_word_line_occurrences(
|
||||
line_id INTEGER NOT NULL,
|
||||
word_id INTEGER NOT NULL,
|
||||
occurrence_count INTEGER NOT NULL,
|
||||
PRIMARY KEY(line_id, word_id),
|
||||
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(word_id) REFERENCES imm_words(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE imm_kanji_line_occurrences(
|
||||
line_id INTEGER NOT NULL,
|
||||
kanji_id INTEGER NOT NULL,
|
||||
occurrence_count INTEGER NOT NULL,
|
||||
PRIMARY KEY(line_id, kanji_id),
|
||||
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(kanji_id) REFERENCES imm_kanji(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE imm_media_art(
|
||||
video_id INTEGER PRIMARY KEY,
|
||||
anilist_id INTEGER,
|
||||
cover_url TEXT,
|
||||
cover_blob BLOB,
|
||||
cover_blob_hash TEXT,
|
||||
title_romaji TEXT,
|
||||
title_english TEXT,
|
||||
episodes_total 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
|
||||
);
|
||||
CREATE TABLE imm_youtube_videos(
|
||||
video_id INTEGER PRIMARY KEY,
|
||||
youtube_video_id TEXT NOT NULL,
|
||||
video_url TEXT NOT NULL,
|
||||
video_title TEXT,
|
||||
video_thumbnail_url TEXT,
|
||||
channel_id TEXT,
|
||||
channel_name TEXT,
|
||||
channel_url TEXT,
|
||||
channel_thumbnail_url TEXT,
|
||||
uploader_id TEXT,
|
||||
uploader_url TEXT,
|
||||
description TEXT,
|
||||
metadata_json TEXT,
|
||||
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
|
||||
);
|
||||
CREATE TABLE imm_cover_art_blobs(
|
||||
blob_hash TEXT PRIMARY KEY,
|
||||
cover_blob BLOB NOT NULL,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT
|
||||
);
|
||||
CREATE TABLE imm_lifetime_global(
|
||||
global_id INTEGER PRIMARY KEY CHECK(global_id = 1),
|
||||
total_sessions INTEGER NOT NULL DEFAULT 0,
|
||||
total_active_ms INTEGER NOT NULL DEFAULT 0,
|
||||
total_cards INTEGER NOT NULL DEFAULT 0,
|
||||
active_days INTEGER NOT NULL DEFAULT 0,
|
||||
episodes_started INTEGER NOT NULL DEFAULT 0,
|
||||
episodes_completed INTEGER NOT NULL DEFAULT 0,
|
||||
anime_completed INTEGER NOT NULL DEFAULT 0,
|
||||
last_rebuilt_ms TEXT,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT
|
||||
);
|
||||
CREATE TABLE imm_lifetime_anime(
|
||||
anime_id INTEGER PRIMARY KEY,
|
||||
total_sessions INTEGER NOT NULL DEFAULT 0,
|
||||
total_active_ms INTEGER NOT NULL DEFAULT 0,
|
||||
total_cards INTEGER NOT NULL DEFAULT 0,
|
||||
total_lines_seen INTEGER NOT NULL DEFAULT 0,
|
||||
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 TEXT,
|
||||
last_watched_ms TEXT,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(anime_id) REFERENCES imm_anime(anime_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE imm_lifetime_media(
|
||||
video_id INTEGER PRIMARY KEY,
|
||||
total_sessions INTEGER NOT NULL DEFAULT 0,
|
||||
total_active_ms INTEGER NOT NULL DEFAULT 0,
|
||||
total_cards INTEGER NOT NULL DEFAULT 0,
|
||||
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 TEXT,
|
||||
last_watched_ms TEXT,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
FOREIGN KEY(video_id) REFERENCES imm_videos(video_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE imm_lifetime_applied_sessions(
|
||||
session_id INTEGER PRIMARY KEY,
|
||||
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
|
||||
);
|
||||
CREATE TABLE imm_stats_excluded_words(
|
||||
headword TEXT NOT NULL,
|
||||
word TEXT NOT NULL,
|
||||
reading TEXT NOT NULL,
|
||||
CREATED_DATE TEXT,
|
||||
LAST_UPDATE_DATE TEXT,
|
||||
PRIMARY KEY(headword, word, reading)
|
||||
);
|
||||
CREATE INDEX idx_anime_normalized_title ON imm_anime(normalized_title_key);
|
||||
CREATE INDEX idx_anime_anilist_id ON imm_anime(anilist_id);
|
||||
CREATE INDEX idx_videos_anime_id ON imm_videos(anime_id);
|
||||
CREATE INDEX idx_sessions_video_started ON imm_sessions(video_id, started_at_ms DESC);
|
||||
CREATE INDEX idx_sessions_status_started ON imm_sessions(status, started_at_ms DESC);
|
||||
CREATE INDEX idx_sessions_started_at ON imm_sessions(started_at_ms DESC);
|
||||
CREATE INDEX idx_sessions_ended_at ON imm_sessions(ended_at_ms DESC);
|
||||
CREATE INDEX idx_telemetry_session_sample ON imm_session_telemetry(session_id, sample_ms DESC);
|
||||
CREATE INDEX idx_telemetry_sample_ms ON imm_session_telemetry(sample_ms DESC);
|
||||
CREATE INDEX idx_events_session_ts ON imm_session_events(session_id, ts_ms DESC);
|
||||
CREATE INDEX idx_events_type_ts ON imm_session_events(event_type, ts_ms DESC);
|
||||
CREATE INDEX idx_rollups_day_video ON imm_daily_rollups(rollup_day, video_id);
|
||||
CREATE INDEX idx_rollups_month_video ON imm_monthly_rollups(rollup_month, video_id);
|
||||
CREATE INDEX idx_words_headword_word_reading ON imm_words(headword, word, reading);
|
||||
CREATE INDEX idx_words_frequency ON imm_words(frequency DESC);
|
||||
CREATE INDEX idx_kanji_kanji ON imm_kanji(kanji);
|
||||
CREATE INDEX idx_kanji_frequency ON imm_kanji(frequency DESC);
|
||||
CREATE INDEX idx_subtitle_lines_session_line ON imm_subtitle_lines(session_id, line_index);
|
||||
CREATE INDEX idx_subtitle_lines_video_line ON imm_subtitle_lines(video_id, line_index);
|
||||
CREATE INDEX idx_subtitle_lines_anime_line ON imm_subtitle_lines(anime_id, line_index);
|
||||
CREATE INDEX idx_word_line_occurrences_word ON imm_word_line_occurrences(word_id, line_id);
|
||||
CREATE INDEX idx_kanji_line_occurrences_kanji ON imm_kanji_line_occurrences(kanji_id, line_id);
|
||||
CREATE INDEX idx_media_art_cover_blob_hash ON imm_media_art(cover_blob_hash);
|
||||
CREATE INDEX idx_media_art_anilist_id ON imm_media_art(anilist_id);
|
||||
CREATE INDEX idx_media_art_cover_url ON imm_media_art(cover_url);
|
||||
CREATE INDEX idx_youtube_videos_channel_id ON imm_youtube_videos(channel_id);
|
||||
CREATE INDEX idx_youtube_videos_youtube_video_id ON imm_youtube_videos(youtube_video_id);
|
||||
`;
|
||||
//# sourceMappingURL=immersion-db-schema.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"immersion-db-schema.js","sourceRoot":"","sources":["immersion-db-schema.ts"],"names":[],"mappings":";;;AAAA,yEAAyE;AACzE,yEAAyE;AACzE,mFAAmF;AACtE,QAAA,wBAAwB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+TvC,CAAC"}
|
||||
@@ -79,6 +79,10 @@ ${B}Jellyfin${R}
|
||||
--jellyfin-audio-stream-index ${D}N${R} Audio stream override
|
||||
--jellyfin-subtitle-stream-index ${D}N${R} Subtitle stream override
|
||||
|
||||
${B}Stats sync${R}
|
||||
--sync-cli sync ${D}[host] [opts]${R} Headless stats sync ${D}(same commands as "subminer sync";${R}
|
||||
${D}run --sync-cli --help for details)${R}
|
||||
|
||||
${B}Options${R}
|
||||
--socket ${D}PATH${R} mpv IPC socket path
|
||||
--backend ${D}BACKEND${R} Window tracker ${D}(auto, hyprland, sway, x11, macos, windows)${R}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { SyncFlowArgs } from './sync-flow';
|
||||
|
||||
export const SYNC_CLI_FLAG = '--sync-cli';
|
||||
|
||||
export type ParsedSyncCli =
|
||||
| { kind: 'help' }
|
||||
| { kind: 'version' }
|
||||
| { kind: 'run'; args: SyncFlowArgs }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
export function extractSyncCliTokens(argv: readonly string[]): string[] | null {
|
||||
const index = argv.indexOf(SYNC_CLI_FLAG);
|
||||
if (index === -1) return null;
|
||||
return argv.slice(index + 1).filter((token) => token !== SYNC_CLI_FLAG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse launcher-style sync argv (`sync [host] [--snapshot f] ...`) for the
|
||||
* app's --sync-cli mode. Mirrors the launcher's `subminer sync` validation so
|
||||
* both entry points accept the same command lines and fail the same way.
|
||||
*/
|
||||
export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
|
||||
if (tokens.includes('--help') || tokens.includes('-h')) return { kind: 'help' };
|
||||
if (tokens.includes('--version') || tokens.includes('-V')) return { kind: 'version' };
|
||||
|
||||
const rest = [...tokens];
|
||||
if (rest[0] !== 'sync') {
|
||||
return {
|
||||
kind: 'error',
|
||||
message: `Expected a "sync" command after ${SYNC_CLI_FLAG} (e.g. ${SYNC_CLI_FLAG} sync --snapshot <file>).`,
|
||||
};
|
||||
}
|
||||
rest.shift();
|
||||
|
||||
let host = '';
|
||||
let snapshot = '';
|
||||
let merge = '';
|
||||
let push = false;
|
||||
let pull = false;
|
||||
let check = false;
|
||||
let force = false;
|
||||
let json = false;
|
||||
let remoteCmd = '';
|
||||
let dbPath = '';
|
||||
let logLevel = 'warn';
|
||||
|
||||
const valueFlags = new Map<string, (value: string) => void>([
|
||||
['--snapshot', (value) => (snapshot = value.trim())],
|
||||
['--merge', (value) => (merge = value.trim())],
|
||||
['--remote-cmd', (value) => (remoteCmd = value.trim())],
|
||||
['--db', (value) => (dbPath = value.trim())],
|
||||
['--log-level', (value) => (logLevel = value.trim() || 'warn')],
|
||||
]);
|
||||
|
||||
for (let i = 0; i < rest.length; i += 1) {
|
||||
const token = rest[i]!;
|
||||
const assignValue = valueFlags.get(token.includes('=') ? token.slice(0, token.indexOf('=')) : token);
|
||||
if (assignValue) {
|
||||
if (token.includes('=')) {
|
||||
assignValue(token.slice(token.indexOf('=') + 1));
|
||||
continue;
|
||||
}
|
||||
const value = rest[i + 1];
|
||||
if (value === undefined) {
|
||||
return { kind: 'error', message: `Missing value for ${token}.` };
|
||||
}
|
||||
assignValue(value);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === '--push') push = true;
|
||||
else if (token === '--pull') pull = true;
|
||||
else if (token === '--check') check = true;
|
||||
else if (token === '--force' || token === '-f') force = true;
|
||||
else if (token === '--json') json = true;
|
||||
else if (token.startsWith('-')) {
|
||||
return { kind: 'error', message: `Unknown sync option: ${token}` };
|
||||
} else if (host) {
|
||||
return { kind: 'error', message: `Unexpected extra argument: ${token}` };
|
||||
} else {
|
||||
host = token.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (push && pull) return { kind: 'error', message: 'Sync --push and --pull cannot be combined.' };
|
||||
if ((push || pull) && !host) {
|
||||
return { kind: 'error', message: 'Sync --push and --pull require a host.' };
|
||||
}
|
||||
if (check && !host) return { kind: 'error', message: 'Sync --check requires a host.' };
|
||||
if (check && (push || pull || snapshot || merge)) {
|
||||
return {
|
||||
kind: 'error',
|
||||
message: 'Sync --check cannot be combined with --push, --pull, --snapshot, or --merge.',
|
||||
};
|
||||
}
|
||||
const modes = [Boolean(host), Boolean(snapshot), Boolean(merge)].filter(Boolean).length;
|
||||
if (modes === 0) {
|
||||
return { kind: 'error', message: 'Sync requires a host, --snapshot <file>, or --merge <file>.' };
|
||||
}
|
||||
if (modes > 1) {
|
||||
return { kind: 'error', message: 'Sync host, --snapshot, and --merge cannot be combined.' };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'run',
|
||||
args: {
|
||||
sync: true,
|
||||
syncHost: host,
|
||||
syncSnapshotPath: snapshot,
|
||||
syncMergePath: merge,
|
||||
syncDirection: push ? 'push' : pull ? 'pull' : 'both',
|
||||
syncRemoteCmd: remoteCmd,
|
||||
syncDbPath: dbPath,
|
||||
syncForce: force,
|
||||
syncJson: json,
|
||||
syncCheck: check,
|
||||
logLevel,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function syncCliUsage(): string {
|
||||
return [
|
||||
'SubMiner sync CLI',
|
||||
'',
|
||||
`Usage: SubMiner ${SYNC_CLI_FLAG} sync [host] [options]`,
|
||||
'',
|
||||
'Modes (exactly one):',
|
||||
' <host> Sync stats with an SSH destination (user@host or ssh alias)',
|
||||
' --snapshot <file> Write a consistent snapshot of the local stats database',
|
||||
' --merge <file> Merge a snapshot database file into the local stats database',
|
||||
'',
|
||||
'Options:',
|
||||
' --push Only merge local stats into the SSH host',
|
||||
' --pull Only merge stats from the SSH host into the local database',
|
||||
' --check Test the SSH connection and remote SubMiner availability',
|
||||
' --db <file> Override the local stats database path',
|
||||
' --remote-cmd <cmd> SubMiner app or launcher command to run on the remote host',
|
||||
' -f, --force Skip the running-app safety check',
|
||||
' --json Emit machine-readable NDJSON progress output',
|
||||
' --log-level <level> Log level',
|
||||
' --help Show this help',
|
||||
' --version Show the SubMiner version',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Database } from '../immersion-tracker/sqlite';
|
||||
import type { OpenSyncDb, SyncDb, SyncDbRunResult, SyncDbStatement } from './driver';
|
||||
|
||||
interface LibsqlStatement {
|
||||
run(...params: unknown[]): { changes: number; lastInsertRowid: number | bigint };
|
||||
get(...params: unknown[]): unknown;
|
||||
all(...params: unknown[]): unknown[];
|
||||
}
|
||||
|
||||
interface LibsqlDatabase {
|
||||
prepare(sql: string): LibsqlStatement;
|
||||
exec(sql: string): unknown;
|
||||
close(): unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* libsql (better-sqlite3 API) binding of the SyncDb driver for the Electron
|
||||
* app. prepare() is not cached by libsql, so query() keeps a per-connection
|
||||
* statement cache — the merge prepares a handful of statements and runs them
|
||||
* once per copied row.
|
||||
*/
|
||||
export const openLibsqlSyncDb: OpenSyncDb = (dbPath, options): SyncDb => {
|
||||
const db = new Database(dbPath, {
|
||||
readonly: options.readonly === true,
|
||||
fileMustExist: options.create !== true,
|
||||
}) as unknown as LibsqlDatabase;
|
||||
const statements = new Map<string, SyncDbStatement>();
|
||||
return {
|
||||
query(sql: string): SyncDbStatement {
|
||||
const cached = statements.get(sql);
|
||||
if (cached) return cached;
|
||||
const statement = db.prepare(sql);
|
||||
const wrapped: SyncDbStatement = {
|
||||
run(...params: unknown[]): SyncDbRunResult {
|
||||
return statement.run(...params);
|
||||
},
|
||||
get(...params: unknown[]): unknown {
|
||||
return statement.get(...params);
|
||||
},
|
||||
all(...params: unknown[]): unknown[] {
|
||||
return statement.all(...params);
|
||||
},
|
||||
};
|
||||
statements.set(sql, wrapped);
|
||||
return wrapped;
|
||||
},
|
||||
exec(sql: string): void {
|
||||
db.exec(sql);
|
||||
},
|
||||
close(): void {
|
||||
statements.clear();
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -158,6 +158,8 @@ export function shouldForwardStartupArgvViaAppControl(
|
||||
const args = parseCliArgs(argv);
|
||||
if (args.help || args.appPing || args.launchMpv || args.generateConfig) return false;
|
||||
if (resolveStatsDaemonCommandAction(argv) !== null) return false;
|
||||
// Sync CLI argv is handled headless at entry, never forwarded to a running app.
|
||||
if (argv.includes('--sync-cli')) return false;
|
||||
|
||||
return hasExplicitCommand(args);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from './main/runtime/first-run-setup-plugin';
|
||||
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
|
||||
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
|
||||
import { runSyncCliFromProcess, shouldHandleSyncCliAtEntry } from './main/sync-cli';
|
||||
import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error';
|
||||
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
|
||||
import {
|
||||
@@ -221,6 +222,14 @@ async function forwardStartupArgvViaAppControlIfAvailable(): Promise<boolean> {
|
||||
}
|
||||
|
||||
async function runEntryProcess(): Promise<void> {
|
||||
// Headless sync CLI: must run first (its own --help/--version handling) and
|
||||
// exit before app.whenReady() so it works over SSH with no display server.
|
||||
if (shouldHandleSyncCliAtEntry(process.argv, process.env)) {
|
||||
const exitCode = await runSyncCliFromProcess(process.argv, app.getVersion());
|
||||
app.exit(exitCode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) {
|
||||
const sanitizedEnv = sanitizeHelpEnv(process.env);
|
||||
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { parse as parseJsonc } from 'jsonc-parser';
|
||||
import { resolveConfigDir, resolveConfigFilePath } from '../config/path-resolution';
|
||||
import { getDefaultMpvSocketPath } from '../shared/mpv-socket-path';
|
||||
import {
|
||||
extractSyncCliTokens,
|
||||
parseSyncCliTokens,
|
||||
syncCliUsage,
|
||||
} from '../core/services/stats-sync/cli-args';
|
||||
import {
|
||||
createDbSnapshot,
|
||||
findLiveStatsDaemonPid,
|
||||
} from '../core/services/stats-sync/shared';
|
||||
import { formatMergeSummary, mergeSnapshotIntoDb } from '../core/services/stats-sync/merge';
|
||||
import {
|
||||
assertSafeSshHost,
|
||||
resolveRemoteSubminerCommand,
|
||||
runScp,
|
||||
runSsh,
|
||||
} from '../core/services/stats-sync/ssh';
|
||||
import { openLibsqlSyncDb } from '../core/services/stats-sync/libsql-driver';
|
||||
import {
|
||||
ensureTrackerQuiescentFlow,
|
||||
runSyncFlow,
|
||||
type SyncFlowDeps,
|
||||
} from '../core/services/stats-sync/sync-flow';
|
||||
import { recordSyncResult, readSyncHostsState, writeSyncHostsState, getSyncHostsPath } from '../shared/sync/sync-hosts-store';
|
||||
|
||||
export function shouldHandleSyncCliAtEntry(argv: readonly string[], env: NodeJS.ProcessEnv): boolean {
|
||||
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
|
||||
return extractSyncCliTokens(argv) !== null;
|
||||
}
|
||||
|
||||
function resolveAppConfigDir(): string {
|
||||
return resolveConfigDir({
|
||||
platform: process.platform,
|
||||
appDataDir: process.env.APPDATA,
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
existsSync: fs.existsSync,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveTildePath(input: string): string {
|
||||
return input.startsWith('~') ? path.join(os.homedir(), input.slice(1)) : path.resolve(input);
|
||||
}
|
||||
|
||||
// Same resolution as the launcher: honor a configured immersionTracking.dbPath
|
||||
// (raw config read, tolerant of comments), else <configDir>/immersion.sqlite.
|
||||
function resolveDefaultDbPath(): string {
|
||||
const configPath = resolveConfigFilePath({
|
||||
appDataDir: process.env.APPDATA,
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
existsSync: fs.existsSync,
|
||||
});
|
||||
let configured = '';
|
||||
try {
|
||||
const data = fs.readFileSync(configPath, 'utf8');
|
||||
const parsed = configPath.endsWith('.jsonc') ? parseJsonc(data) : JSON.parse(data);
|
||||
const tracking =
|
||||
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>).immersionTracking
|
||||
: null;
|
||||
if (tracking && typeof tracking === 'object' && !Array.isArray(tracking)) {
|
||||
const dbPath = (tracking as Record<string, unknown>).dbPath;
|
||||
if (typeof dbPath === 'string') configured = dbPath.trim();
|
||||
}
|
||||
} catch {
|
||||
// no config or unreadable config → default location
|
||||
}
|
||||
if (configured) return resolveTildePath(configured);
|
||||
return path.join(resolveAppConfigDir(), 'immersion.sqlite');
|
||||
}
|
||||
|
||||
async function canConnectUnixSocket(socketPath: string): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const socket = net.createConnection(socketPath);
|
||||
let settled = false;
|
||||
const finish = (value: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('error', () => finish(false));
|
||||
socket.setTimeout(400, () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
function recordHostSyncResultToDisk(
|
||||
host: string,
|
||||
status: 'success' | 'error',
|
||||
detail: string | null,
|
||||
): void {
|
||||
try {
|
||||
const filePath = getSyncHostsPath(resolveAppConfigDir());
|
||||
const state = recordSyncResult(readSyncHostsState(filePath), host, {
|
||||
atMs: Date.now(),
|
||||
status,
|
||||
detail,
|
||||
});
|
||||
writeSyncHostsState(filePath, state);
|
||||
} catch {
|
||||
// best effort: bookkeeping must never fail the sync itself
|
||||
}
|
||||
}
|
||||
|
||||
function buildSyncCliDeps(): SyncFlowDeps {
|
||||
const deps: SyncFlowDeps = {
|
||||
createDbSnapshot: (dbPath, outPath) => createDbSnapshot(openLibsqlSyncDb, dbPath, outPath),
|
||||
mergeSnapshotIntoDb: (localDbPath, snapshotPath) =>
|
||||
mergeSnapshotIntoDb(openLibsqlSyncDb, localDbPath, snapshotPath),
|
||||
formatMergeSummary,
|
||||
findLiveStatsDaemonPid,
|
||||
assertSafeSshHost,
|
||||
resolveRemoteSubminerCommand,
|
||||
runScp,
|
||||
runSsh,
|
||||
fail: (message: string): never => {
|
||||
throw new Error(message);
|
||||
},
|
||||
log: (level, configured, message) => {
|
||||
if (level === 'debug' && configured !== 'debug') return;
|
||||
console.error(message);
|
||||
},
|
||||
canConnectUnixSocket,
|
||||
realpathSync: (candidate) => fs.realpathSync(candidate),
|
||||
mkdtempSync: (prefix) => fs.mkdtempSync(prefix),
|
||||
rmSync: (target, options) => fs.rmSync(target, options),
|
||||
consoleLog: (message) => console.log(message),
|
||||
writeStdout: (text) => process.stdout.write(text),
|
||||
ensureTrackerQuiescent: async (context, dbPath) =>
|
||||
ensureTrackerQuiescentFlow(context, dbPath, deps),
|
||||
emitEvent: () => {},
|
||||
recordHostSyncResult: recordHostSyncResultToDisk,
|
||||
resolveDefaultDbPath,
|
||||
resolvePath: resolveTildePath,
|
||||
};
|
||||
return deps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Headless sync entry for the Electron app: answers the same launcher-style
|
||||
* `sync ...` argv as `subminer sync`, so a machine only needs the app
|
||||
* installed to participate in stats sync (locally or as an SSH remote). Runs
|
||||
* before any window/display initialization and never touches Electron APIs.
|
||||
*/
|
||||
export async function runSyncCliFromProcess(
|
||||
argv: readonly string[],
|
||||
appVersion: string,
|
||||
): Promise<number> {
|
||||
const tokens = extractSyncCliTokens(argv);
|
||||
const parsed = parseSyncCliTokens(tokens ?? []);
|
||||
if (parsed.kind === 'help') {
|
||||
console.log(syncCliUsage());
|
||||
return 0;
|
||||
}
|
||||
if (parsed.kind === 'version') {
|
||||
console.log(`SubMiner ${appVersion}`);
|
||||
return 0;
|
||||
}
|
||||
if (parsed.kind === 'error') {
|
||||
console.error(parsed.message);
|
||||
console.error(`\n${syncCliUsage()}`);
|
||||
return 2;
|
||||
}
|
||||
|
||||
const context = {
|
||||
args: parsed.args,
|
||||
mpvSocketPath: getDefaultMpvSocketPath(process.platform),
|
||||
};
|
||||
try {
|
||||
await runSyncFlow(context, buildSyncCliDeps());
|
||||
return 0;
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user