mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
docs(sync): remote machine only needs the app - checklist, docs, changelog
Sync window drops the launcher-missing warning (self-spawn always works), the setup checklist explains automatic remote discovery (SubMiner on PATH, macOS /Applications, or the optional launcher), and the remote resolver gains tests for app-binary fallback and --remote-cmd probing.
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Stats Sync Without the Launcher: The stats sync engine now runs inside the app. The sync window no longer needs bun or the `subminer` command-line launcher installed, and a remote machine only needs SubMiner itself — the app answers `SubMiner --sync-cli sync ...` headless (works over SSH with no display), and remote lookup finds the app binary or the launcher automatically.
|
||||||
|
|
||||||
## v0.18.0 (2026-07-10)
|
## v0.18.0 (2026-07-10)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
**Added**
|
||||||
|
- Stats Sync Without the Launcher: The stats sync engine now runs inside the app. The sync window no longer needs bun or the `subminer` command-line launcher installed, and a remote machine only needs SubMiner itself — the app answers `SubMiner --sync-cli sync ...` headless (works over SSH with no display), and remote lookup finds the app binary or the launcher automatically.
|
||||||
|
|
||||||
## v0.18.0 (2026-07-10)
|
## v0.18.0 (2026-07-10)
|
||||||
|
|
||||||
**Added**
|
**Added**
|
||||||
|
|||||||
@@ -81,15 +81,15 @@ Series whose directories are not currently accessible (e.g. an unmounted network
|
|||||||
|
|
||||||
## Sync Between Machines
|
## Sync Between Machines
|
||||||
|
|
||||||
`subminer sync <host>` merges immersion stats and watch history between two machines over SSH, so both end up with the union of sessions, lifetime totals, vocabulary counts, daily/monthly charts, and `--history` entries. `<host>` is anything `ssh` accepts (`user@hostname` or an ssh config alias); SubMiner must be installed on both machines at the same version.
|
`subminer sync <host>` merges immersion stats and watch history between two machines over SSH, so both end up with the union of sessions, lifetime totals, vocabulary counts, daily/monthly charts, and `--history` entries. `<host>` is anything `ssh` accepts (`user@hostname` or an ssh config alias); SubMiner must be installed on both machines at the same version. The command-line launcher is optional on both sides: the app itself answers sync commands (`SubMiner --sync-cli sync ...`), the sync window runs the engine in-app, and the remote side is found automatically whether it has the launcher or just the app.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
subminer sync macbook # two-way sync with the host "macbook"
|
subminer sync macbook # two-way sync with the host "macbook"
|
||||||
subminer sync macbook --push # merge local data into macbook only
|
subminer sync macbook --push # merge local data into macbook only
|
||||||
subminer sync macbook --pull # merge macbook data into local only
|
subminer sync macbook --pull # merge macbook data into local only
|
||||||
subminer sync user@192.168.1.20 # explicit user@host
|
subminer sync user@192.168.1.20 # explicit user@host
|
||||||
subminer sync macbook --remote-cmd ~/bin/subminer # custom remote launcher path
|
subminer sync macbook --remote-cmd ~/bin/subminer # custom remote SubMiner/launcher path
|
||||||
subminer sync macbook --check # test SSH + remote launcher without syncing
|
subminer sync macbook --check # test SSH + remote SubMiner without syncing
|
||||||
subminer sync --ui # open the sync window (also in the tray menu)
|
subminer sync --ui # open the sync window (also in the tray menu)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -97,7 +97,9 @@ How it works: each side takes a consistent snapshot of its database (`VACUUM INT
|
|||||||
|
|
||||||
For a one-way transfer, `--push` snapshots the local database and merges it into the host without changing the local database. `--pull` snapshots the host and merges it into the local database without changing the host. These modes add missing data; they do not delete destination-only data or make the destination an exact mirror.
|
For a one-way transfer, `--push` snapshots the local database and merges it into the host without changing the local database. `--pull` snapshots the host and merges it into the local database without changing the host. These modes add missing data; they do not delete destination-only data or make the destination an exact mirror.
|
||||||
|
|
||||||
Close SubMiner (and stop the background stats daemon, `subminer stats -s`) on both machines before syncing; the command refuses to run while a SubMiner process may be writing the database (`--force` overrides). The mpv safety check requires a live socket connection, so a stale socket file left after mpv exits does not block sync. Both machines must be on the same SubMiner version — the sync aborts on a stats schema mismatch. Remote sync checks standard SubMiner and Bun locations (`~/.local/bin`, `~/.bun/bin`, Homebrew, `/usr/local/bin`, `/usr/bin`, and `/bin`) even when the non-interactive SSH shell omits them from `PATH`.
|
Close SubMiner (and stop the background stats daemon, `subminer stats -s`) on both machines before syncing; the command refuses to run while a SubMiner process may be writing the database (`--force` overrides). The mpv safety check requires a live socket connection, so a stale socket file left after mpv exits does not block sync. Both machines must be on the same SubMiner version — the sync aborts on a stats schema mismatch.
|
||||||
|
|
||||||
|
On the remote, sync looks for the `subminer` launcher first (PATH and `~/.local/bin`), then the app binary in `--sync-cli` mode (`SubMiner` on PATH, then the standard macOS `/Applications` and `~/Applications` installs), checking standard SubMiner and Bun locations (`~/.local/bin`, `~/.bun/bin`, Homebrew, `/usr/local/bin`, `/usr/bin`, and `/bin`) even when the non-interactive SSH shell omits them from `PATH`. An AppImage in a custom location can be addressed with `--remote-cmd /path/to/SubMiner.AppImage` (or symlink it as `SubMiner` somewhere on the remote PATH).
|
||||||
|
|
||||||
Two lower-level modes are used internally over SSH and also work standalone for manual transfers (e.g. via a USB drive):
|
Two lower-level modes are used internally over SSH and also work standalone for manual transfers (e.g. via a USB drive):
|
||||||
|
|
||||||
@@ -108,14 +110,14 @@ subminer sync --merge /tmp/stats.sqlite # merge a snapshot file into the lo
|
|||||||
|
|
||||||
Unfinished sessions (a crash mid-playback) are skipped until the app finalizes them; they sync on the next run. Word/kanji "known" state from Anki is not part of the database and does not sync — each machine derives it from its own Anki collection.
|
Unfinished sessions (a crash mid-playback) are skipped until the app finalizes them; they sync on the next run. Word/kanji "known" state from Anki is not part of the database and does not sync — each machine derives it from its own Anki collection.
|
||||||
|
|
||||||
`subminer sync <host> --check` verifies a host without touching any data: it probes the SSH connection, locates the remote `subminer` launcher, and reports its version. `--json` switches any sync mode to machine-readable NDJSON progress output (this is what the sync window consumes).
|
`subminer sync <host> --check` verifies a host without touching any data: it probes the SSH connection, locates SubMiner on the remote (launcher or app binary), and reports its version. `--json` switches any sync mode to machine-readable NDJSON progress output (this is what the sync window consumes).
|
||||||
|
|
||||||
### Sync window
|
### Sync window
|
||||||
|
|
||||||
`subminer sync --ui` (or **Sync Stats & History** in the tray menu) opens a dedicated window for the same engine:
|
`subminer sync --ui` (or **Sync Stats & History** in the tray menu) opens a dedicated window for the same engine:
|
||||||
|
|
||||||
- **Devices** — saved hosts with a per-host direction (two-way / push / pull), an auto-sync toggle, last-sync status, and one-click **Sync now** / **Test** / **Remove**. Hosts synced from the command line appear here automatically.
|
- **Devices** — saved hosts with a per-host direction (two-way / push / pull), an auto-sync toggle, last-sync status, and one-click **Sync now** / **Test** / **Remove**. Hosts synced from the command line appear here automatically.
|
||||||
- **Add a device** — test SSH + remote launcher availability before saving, with a setup checklist for first-time SSH configuration.
|
- **Add a device** — test SSH + remote SubMiner availability before saving, with a setup checklist for first-time SSH configuration.
|
||||||
- **Activity** — live stage-by-stage progress, remote output, and a merge summary (sessions, words, kanji, rollups) when a run finishes. Runs can be cancelled, and guard failures offer a one-click `--force` retry.
|
- **Activity** — live stage-by-stage progress, remote output, and a merge summary (sessions, words, kanji, rollups) when a run finishes. Runs can be cancelled, and guard failures offer a one-click `--force` retry.
|
||||||
- **Snapshots** — create manual database snapshots (stored in `/tmp/subminer-db-snapshots/` by default), merge a snapshot file into the local database, or reveal/delete existing snapshots.
|
- **Snapshots** — create manual database snapshots (stored in `/tmp/subminer-db-snapshots/` by default), merge a snapshot file into the local database, or reveal/delete existing snapshots.
|
||||||
|
|
||||||
|
|||||||
@@ -49,3 +49,41 @@ test('resolveRemoteSubminerCommand verifies the launcher under the remote runtim
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolveRemoteSubminerCommand falls back to the app binary in --sync-cli mode', () => {
|
||||||
|
const probed: string[] = [];
|
||||||
|
const command = resolveRemoteSubminerCommand('media-box', null, (_host, remoteCommand) => {
|
||||||
|
probed.push(remoteCommand);
|
||||||
|
return { status: remoteCommand.includes('SubMiner --sync-cli') ? 0 : 1, stdout: '', stderr: '' };
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(command, / SubMiner --sync-cli$/);
|
||||||
|
// Launcher candidates (PATH + ~/.local/bin) are tried before app binaries.
|
||||||
|
assert.equal(probed.length, 3);
|
||||||
|
assert.match(probed[0]!, / subminer --help /);
|
||||||
|
assert.match(probed[1]!, / ~\/\.local\/bin\/subminer --help /);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveRemoteSubminerCommand probes a user override as app first, then launcher', () => {
|
||||||
|
const probed: string[] = [];
|
||||||
|
const asApp = resolveRemoteSubminerCommand('media-box', '/opt/SubMiner.AppImage', (_host, cmd) => {
|
||||||
|
probed.push(cmd);
|
||||||
|
return { status: cmd.includes('--sync-cli') ? 0 : 1, stdout: '', stderr: '' };
|
||||||
|
});
|
||||||
|
assert.match(asApp, /'\/opt\/SubMiner\.AppImage' --sync-cli$/);
|
||||||
|
|
||||||
|
const asLauncher = resolveRemoteSubminerCommand('media-box', '/opt/subminer', (_host, cmd) => {
|
||||||
|
return { status: cmd.includes('--sync-cli') ? 1 : 0, stdout: '', stderr: '' };
|
||||||
|
});
|
||||||
|
assert.match(asLauncher, /'\/opt\/subminer'$/);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
resolveRemoteSubminerCommand('media-box', '/missing', () => ({
|
||||||
|
status: 127,
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
})),
|
||||||
|
/Remote command not found on media-box: \/missing/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
-28
@@ -1,28 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"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"}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
"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
@@ -1 +0,0 @@
|
|||||||
{"version":3,"file":"immersion-db-schema.d.ts","sourceRoot":"","sources":["immersion-db-schema.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,wBAAwB,+rYA+TpC,CAAC"}
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
"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
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"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"}
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { extractSyncCliTokens, parseSyncCliTokens } from './cli-args';
|
||||||
|
|
||||||
|
test('extractSyncCliTokens returns tokens after --sync-cli', () => {
|
||||||
|
assert.equal(extractSyncCliTokens(['/bin/electron', '/app']), null);
|
||||||
|
assert.deepEqual(extractSyncCliTokens(['/bin/electron', '/app', '--sync-cli', 'sync', 'host']), [
|
||||||
|
'sync',
|
||||||
|
'host',
|
||||||
|
]);
|
||||||
|
// A repeated flag (e.g. resolved remote command + forwarded argv) is ignored.
|
||||||
|
assert.deepEqual(extractSyncCliTokens(['/app', '--sync-cli', '--sync-cli', 'sync', 'h']), [
|
||||||
|
'sync',
|
||||||
|
'h',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSyncCliTokens handles help, version, and run modes', () => {
|
||||||
|
assert.deepEqual(parseSyncCliTokens(['--help']), { kind: 'help' });
|
||||||
|
assert.deepEqual(parseSyncCliTokens(['--version']), { kind: 'version' });
|
||||||
|
|
||||||
|
const run = parseSyncCliTokens(['sync', 'media-box', '--pull', '--force', '--json']);
|
||||||
|
assert.equal(run.kind, 'run');
|
||||||
|
if (run.kind === 'run') {
|
||||||
|
assert.equal(run.args.syncHost, 'media-box');
|
||||||
|
assert.equal(run.args.syncDirection, 'pull');
|
||||||
|
assert.equal(run.args.syncForce, true);
|
||||||
|
assert.equal(run.args.syncJson, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = parseSyncCliTokens(['sync', '--snapshot', '/tmp/x.sqlite', '--db', '/tmp/db']);
|
||||||
|
assert.equal(snapshot.kind, 'run');
|
||||||
|
if (snapshot.kind === 'run') {
|
||||||
|
assert.equal(snapshot.args.syncSnapshotPath, '/tmp/x.sqlite');
|
||||||
|
assert.equal(snapshot.args.syncDbPath, '/tmp/db');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSyncCliTokens mirrors launcher sync validation', () => {
|
||||||
|
assert.equal(parseSyncCliTokens([]).kind, 'error');
|
||||||
|
assert.equal(parseSyncCliTokens(['sync']).kind, 'error');
|
||||||
|
assert.equal(parseSyncCliTokens(['sync', 'h', '--push', '--pull']).kind, 'error');
|
||||||
|
assert.equal(parseSyncCliTokens(['sync', '--check']).kind, 'error');
|
||||||
|
assert.equal(parseSyncCliTokens(['sync', '--check', '--snapshot', '/tmp/x', 'h']).kind, 'error');
|
||||||
|
assert.equal(parseSyncCliTokens(['sync', 'h', '--snapshot', '/tmp/x']).kind, 'error');
|
||||||
|
assert.equal(parseSyncCliTokens(['sync', 'h', '--bogus']).kind, 'error');
|
||||||
|
assert.equal(parseSyncCliTokens(['sync', 'h', 'extra']).kind, 'error');
|
||||||
|
assert.equal(parseSyncCliTokens(['sync', '--snapshot']).kind, 'error');
|
||||||
|
});
|
||||||
@@ -180,6 +180,13 @@ test('direct runtime commands forward through app control', () => {
|
|||||||
test('entry-only and internal commands do not forward through app control', () => {
|
test('entry-only and internal commands do not forward through app control', () => {
|
||||||
assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe'], {}), false);
|
assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe'], {}), false);
|
||||||
assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--help'], {}), false);
|
assert.equal(shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--help'], {}), false);
|
||||||
|
assert.equal(
|
||||||
|
shouldForwardStartupArgvViaAppControl(
|
||||||
|
['SubMiner.exe', '--sync-cli', 'sync', 'media-box', '--json'],
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--generate-config'], {}),
|
shouldForwardStartupArgvViaAppControl(['SubMiner.exe', '--generate-config'], {}),
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -123,14 +123,12 @@ export function createSyncUiRuntime(deps: SyncUiRuntimeDeps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSnapshot(): SyncUiSnapshot {
|
function getSnapshot(): SyncUiSnapshot {
|
||||||
const resolution = deps.resolveLauncherCommand();
|
|
||||||
return {
|
return {
|
||||||
dbPath: deps.getDbPath(),
|
dbPath: deps.getDbPath(),
|
||||||
hosts: readState(),
|
hosts: readState(),
|
||||||
snapshotsDir: deps.snapshotsDir,
|
snapshotsDir: deps.snapshotsDir,
|
||||||
snapshots: listSnapshots(),
|
snapshots: listSnapshots(),
|
||||||
run: runState(),
|
run: runState(),
|
||||||
launcherPath: resolution.command ? resolution.command.join(' ') : null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,6 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="sync-scroll">
|
<div class="sync-scroll">
|
||||||
<div id="launcherWarning" class="banner banner-warn hidden"></div>
|
|
||||||
|
|
||||||
<section class="panel" aria-labelledby="devicesTitle">
|
<section class="panel" aria-labelledby="devicesTitle">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2 id="devicesTitle">Devices</h2>
|
<h2 id="devicesTitle">Devices</h2>
|
||||||
@@ -71,14 +69,13 @@
|
|||||||
<code>ssh-copy-id user@hostname</code> (no password prompts).
|
<code>ssh-copy-id user@hostname</code> (no password prompts).
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
The remote machine needs SubMiner's command-line launcher installed (<code
|
The remote machine just needs SubMiner installed. It is found automatically as
|
||||||
>subminer</code
|
<code>SubMiner</code> on the remote PATH, a macOS <code>/Applications</code>
|
||||||
>
|
install, or the optional <code>subminer</code> command-line launcher.
|
||||||
on its PATH, or in <code>~/.local/bin</code>).
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Use <em>Test connection</em> to verify both. It checks SSH and the remote launcher
|
Use <em>Test connection</em> to verify both. It checks SSH and finds SubMiner on
|
||||||
in one go.
|
the remote in one go.
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ function el<T extends HTMLElement>(id: string): T {
|
|||||||
|
|
||||||
const runPill = el<HTMLDivElement>('runPill');
|
const runPill = el<HTMLDivElement>('runPill');
|
||||||
const dbPathEl = el<HTMLDivElement>('dbPath');
|
const dbPathEl = el<HTMLDivElement>('dbPath');
|
||||||
const launcherWarning = el<HTMLDivElement>('launcherWarning');
|
|
||||||
const hostList = el<HTMLDivElement>('hostList');
|
const hostList = el<HTMLDivElement>('hostList');
|
||||||
const hostListEmpty = el<HTMLDivElement>('hostListEmpty');
|
const hostListEmpty = el<HTMLDivElement>('hostListEmpty');
|
||||||
const autoIntervalInput = el<HTMLInputElement>('autoIntervalInput');
|
const autoIntervalInput = el<HTMLInputElement>('autoIntervalInput');
|
||||||
@@ -385,12 +384,6 @@ async function refresh(): Promise<void> {
|
|||||||
if (document.activeElement !== autoIntervalInput) {
|
if (document.activeElement !== autoIntervalInput) {
|
||||||
autoIntervalInput.value = String(snapshot.hosts.autoSyncIntervalMinutes);
|
autoIntervalInput.value = String(snapshot.hosts.autoSyncIntervalMinutes);
|
||||||
}
|
}
|
||||||
launcherWarning.classList.toggle('hidden', snapshot.launcherPath !== null);
|
|
||||||
if (snapshot.launcherPath === null) {
|
|
||||||
launcherWarning.textContent =
|
|
||||||
'The subminer command-line launcher was not found. Install it from SubMiner setup ' +
|
|
||||||
'(or install bun). Syncing runs through the launcher.';
|
|
||||||
}
|
|
||||||
setRunPill();
|
setRunPill();
|
||||||
renderHosts();
|
renderHosts();
|
||||||
renderSnapshots();
|
renderSnapshots();
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export interface SyncUiSnapshot {
|
|||||||
snapshotsDir: string;
|
snapshotsDir: string;
|
||||||
snapshots: SyncUiSnapshotFile[];
|
snapshots: SyncUiSnapshotFile[];
|
||||||
run: SyncUiRunState;
|
run: SyncUiRunState;
|
||||||
launcherPath: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncUiRunRequest {
|
export interface SyncUiRunRequest {
|
||||||
|
|||||||
Reference in New Issue
Block a user