Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
780a3fa8cb
|
|||
|
3d715c8b89
|
|||
|
cbb19864c4
|
|||
|
32c62b076b
|
|||
|
2d2813ec2b
|
|||
|
d57eae4863
|
|||
|
4a43232ffa
|
|||
|
dfde19cc4d
|
|||
|
58d54311d8
|
|||
|
be31f96f02
|
|||
|
cdb1475a54
|
|||
|
a2e49b369b
|
|||
| 7f13aed50a | |||
|
8b21a2bca8
|
|||
|
925413adfe
|
|||
| d0644ab2eb | |||
| d253710c2e | |||
|
c3df510e4f
|
|||
| 187f68e5b6 | |||
|
0e254cbbef
|
|||
|
7b94adafbd
|
|||
| 61f39d1e09 | |||
| e7739de51c | |||
| ad1d240f20 | |||
|
8b0ef662bc
|
|||
| 0a58c20ad7 | |||
| 38ddb29aa0 | |||
| 8b9a70c5a6 | |||
| 48a084914a | |||
| a042b04357 | |||
| 35ca2afc6f | |||
| b14f977e33 | |||
| eef4500599 | |||
|
73af1451b7
|
|||
| 36a3704815 | |||
| 359cb0a301 | |||
|
4b10e85053
|
|||
| c942a2cf2d | |||
| f65afa6046 | |||
|
389d8e06e0
|
|||
|
0008b55b70
|
|||
|
d16ae9c745
|
|||
| 36f94151b8 | |||
| 5326ad32f5 | |||
|
d199376364
|
|||
|
3aee88c150
|
|||
| 70da3ee8bd | |||
|
aa8eb753f6
|
|||
|
8d73de8731
|
|||
|
5a98397efe
|
|||
| a117c5759c | |||
| ae7e6f82a8 | |||
| 1158be5b39 | |||
| 33e767458f | |||
| 94a65416ae | |||
| 0a384a22c9 | |||
|
b3b45521b6
|
@@ -55,9 +55,6 @@ jobs:
|
||||
- name: Verify generated config examples
|
||||
run: bun run verify:config-example
|
||||
|
||||
- name: Internal docs knowledge-base checks
|
||||
run: bun run test:docs:kb
|
||||
|
||||
- name: Test suite (source)
|
||||
run: bun run test:fast
|
||||
|
||||
@@ -71,6 +68,9 @@ jobs:
|
||||
path: coverage/test-src/lcov.info
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Stats UI tests
|
||||
run: bun run test:stats
|
||||
|
||||
- name: Launcher smoke suite (source)
|
||||
run: bun run test:launcher:smoke:src
|
||||
|
||||
|
||||
@@ -70,6 +70,9 @@ jobs:
|
||||
path: coverage/test-src/lcov.info
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Stats UI tests
|
||||
run: bun run test:stats
|
||||
|
||||
- name: Launcher smoke suite (source)
|
||||
run: bun run test:launcher:smoke:src
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@ jobs:
|
||||
path: coverage/test-src/lcov.info
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Stats UI tests
|
||||
run: bun run test:stats
|
||||
|
||||
- name: Launcher smoke suite (source)
|
||||
run: bun run test:launcher:smoke:src
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
/package-lock.json
|
||||
|
||||
# Superpowers brainstorming
|
||||
.superpowers/
|
||||
@@ -16,6 +17,8 @@ coverage/
|
||||
|
||||
# Launcher build artifact (produced by make build-launcher)
|
||||
/subminer
|
||||
/main-entry.js
|
||||
/main-entry.js.map
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
@@ -42,7 +42,10 @@ Start here, then leave this file.
|
||||
- Config/schema/defaults: `bun run test:config`; if template/defaults changed, `bun run generate:config-example`
|
||||
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
|
||||
- Stats dashboard UI (`stats/`): `bun run test:stats`
|
||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||
- Docs-only: `bun run docs:test`, then `bun run docs:build`
|
||||
- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
|
||||
|
||||
## Docs Upkeep
|
||||
|
||||
|
||||
@@ -1,5 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## v0.17.2 (2026-06-28)
|
||||
|
||||
### Fixed
|
||||
- YouTube Background Cache: Fixed Windows YouTube background media cache startup for YouTube URLs opened directly in mpv, including resolved stream URLs when mpv still exposes the original YouTube playlist entry, so queued Anki media updates can append audio and images after the cache finishes.
|
||||
- YouTube Subtitle Picker: Manual subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal. Subtitle download progress is replaced with a transient success notification after tracks load.
|
||||
|
||||
## v0.17.1 (2026-06-27)
|
||||
|
||||
### Added
|
||||
- YouTube Media Cache Mode: Adds `youtube.mediaCache.mode` with `direct` and `background` options. Background mode uses a yt-dlp cache download when direct stream extraction is unreliable — creates a text-only card immediately, queues media updates for mined notes, and fills audio/image fields once the download finishes. Progress is announced via overlay/OSD notifications. Downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`). Switching back to direct mode cancels any in-flight background download.
|
||||
|
||||
### Fixed
|
||||
- Log Export: Fixed log filenames to use the local date so exports around UTC midnight include the current day's logs rather than stale prior-day files. Expanded export redaction to mask IPs, emails, auth and cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL parameters.
|
||||
- YouTube Card Media: Improved media generation reliability by sending safer ffmpeg options for resolved streams and skipping stale stream maps (including cached YouTube files). Hardened background cache downloads with IPv4 and extractor retry flags; failed downloads now notify the user and clear queued media updates instead of leaving them silently pending. Stale background cache files are cleaned on startup and before each new download.
|
||||
|
||||
## v0.17.0 (2026-06-15)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Subtitle Delay Keybindings**: Updated default overlay subtitle delay and step bindings to match mpv conventions: `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` with OSD feedback. The previous SubMiner-only adjacent-cue delay action has been removed.
|
||||
- **Update Notifications**: New installs now default update notifications to overlay-only instead of overlay + system notifications.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Anki – Highlight Word**: Fixed bolding of the mined word in Kiku sentence and sentence-furigana fields when the source Yomitan sentence did not already contain bold markup.
|
||||
- **Anki – Lapis/Kiku Word Cards**: Fixed word-and-sentence marker missing from Lapis/Kiku word cards enriched through SubMiner, which could hide sentence context on the card front.
|
||||
- **Anki – Windows Media Generation**: Fixed known-word cache refreshes when no deck is configured, and fixed audio/image generation after background launches by recreating missing FFmpeg temp directories before clipping.
|
||||
- **Character Dictionary – Windows**: Fixed the Windows "SubMiner mpv" shortcut so character dictionary auto-sync can fall back to mpv's current video path when app media state is not yet ready.
|
||||
- **Notifications**: Restored the SubMiner app icon on system notifications that do not supply a custom notification image.
|
||||
- **Overlay – macOS Yomitan Popup**: Fixed Yomitan popup focus after card mining or popup reload; fixed popups staying open when clicking transparent overlay space — click-away now closes the popup and returns click passthrough to mpv without a hide/reappear cycle.
|
||||
- **Overlay – Linux Auto-Pause Startup**: Fixed the visible overlay on Linux auto-paused startup to stay interactive during the initial measurement gap; startup subtitle cache misses now paint raw text before tokenization finishes, and temporarily empty `sub-text` refreshes are resolved before warm readiness resumes playback.
|
||||
- **Overlay – Playlist Advance**: Fixed the visible overlay being dismissed when mpv advances to the next playlist item, including when the next episode loads after the warm transition delay.
|
||||
- **Overlay – Windows Subtitle Bar**: Fixed shaky hover and click interaction on the Windows subtitle bar when a video attaches to an already-running background SubMiner instance.
|
||||
- **Stats – AniList Linking**: Fixed manual AniList linking from the stats anime page so auto-searches strip the generated "Season N" suffix and query only the anime title.
|
||||
- **Updates – Linux Support Assets**: Fixed Linux updates to correctly install and refresh the launcher runtime plugin copy and rofi theme alongside AppImage and launcher updates; unrelated SubMiner data directories are now left untouched and plugin copies are staged before replacing the live runtime. Fixed first-launch playback on fresh installs by auto-installing missing managed support assets from the bundled app before mpv starts.
|
||||
|
||||
### Docs
|
||||
|
||||
- **Linux Update Flows**: Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal launcher playback auto-installs those managed support assets on a fresh install if either is missing.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
|
||||
- **Runtime Modules**: Split main-process runtime wiring into focused modules without changing user-facing behavior; hardened helpers against stale background stats daemon PIDs, stalled subtitle extraction, and dropped async errors.
|
||||
- **Release CI**: Fixed GitHub release notes to preserve the `What's Changed` and `New Contributors` attribution sections when CI regenerates from the committed changelog; scoped prerelease note reuse to the same base version so a new beta line starts from current fragments.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.16.0 (2026-06-10)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -213,6 +213,7 @@ On **Windows**, just run `SubMiner.exe` and the setup will open automatically on
|
||||
subminer video.mkv # launch mpv with SubMiner
|
||||
subminer /path/to/dir # pick a file with fzf
|
||||
subminer -R /path/to/dir # pick a file with rofi (Linux only)
|
||||
subminer -H # browse local watch history (replay / next episode / browse)
|
||||
```
|
||||
|
||||
On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 433 B After Width: | Height: | Size: 253 B |
|
Before Width: | Height: | Size: 580 B After Width: | Height: | Size: 366 B |
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: mining
|
||||
|
||||
- Normalized generated card audio by default during media extraction, with `ankiConnect.media.normalizeAudio` available to keep raw source loudness when needed.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- `subminer app` background launches now start the stats server automatically when `stats.autoStartServer` is enabled, and skip startup when a background stats server is already running.
|
||||
@@ -0,0 +1,6 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed character-dictionary surname/given-name splitting for unspaced AniList native names where romanized reading length misled the boundary guess (東紫乃 split as 東紫+乃, 渡辺真奈美 as 渡辺真+奈美), so surnames like 東 and 渡辺 were never highlighted with character portraits and unmatched leading kanji (渡) lost hover lookup entirely. Name parts are now resolved with MeCab person-name POS tags and reading validation when MeCab is available; without MeCab, terms are generated for both plausible boundaries so the real surname still matches. Existing snapshots regenerate automatically (format version bump), and snapshots built without MeCab are upgraded to exact MeCab splits once MeCab is detected.
|
||||
- Fixed subtitle tokenization dropping a character entirely when Yomitan's greedy scan consumed trailing punctuation/whitespace past the term (e.g. 平 in 「(東)平 (平)え?」 matched across the paren with no exact-source headword). The scanner now retries shorter windows so the prefix term still tokenizes with its name match and portrait.
|
||||
- Fixed character-name highlights being stripped when MeCab's context-dependent POS tag put the token in the annotation noise filter (e.g. 平 tagged as prefix 接頭詞 in 「あっ 平 これ…」 lost its highlight and portrait even though hover lookup showed the character). Confirmed name matches now survive the POS exclusion filter, and name matching alone keeps the annotation stage active when all other annotations are disabled.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Removed 確かに and やはり from the annotation stop-word list so they get frequency/JLPT highlighting again and count toward vocabulary stats; the list now only covers interjections, pronouns, and grammar fragments.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Cover art is now fetched eagerly when a new series starts playing, instead of waiting for the first visit to its series detail page, so the stats timeline shows the best-guess AniList image right away. The stats covers endpoint also backfills missing series art in the background, so existing series without an image pick one up on the next stats page load.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: launcher
|
||||
|
||||
- Moved fzf previews below launcher menus so long titles and metadata have more horizontal room.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: launcher
|
||||
|
||||
- Show cover art icons in the rofi watch-history picker, reusing AniList covers already stored in the stats database (extracted to `~/.cache/subminer/covers`).
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Kanji-bearing nouns that MeCab tags as non-independent (非自立) — e.g. 日 in いい日だったな, 点, 以外 — now keep frequency/JLPT highlighting and count toward vocabulary stats. Yomitan segments them as standalone vocabulary tokens, so the MeCab POS filter only suppresses kana grammar nouns (こと, もの, とき) it was meant for.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Fixed cancelling the Kiku field grouping dialog showing two "Field grouping cancelled" notifications when grouping was started via the trigger shortcut: the manual workflow already notifies about its outcome (cancelled, UI unavailable, failed), and the trigger path re-notified on top of it. The workflow now owns all outcome notifications, and a previously silent failure (the original card no longer loadable) gets its own message.
|
||||
@@ -0,0 +1,6 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed Kiku manual field grouping freezing the overlay after adding a duplicate card: the field grouping modal now reliably appears above fullscreen mpv on Hyprland/Wayland by re-asserting window placement until the compositor maps the modal window, instead of a single post-show attempt that raced the async map and left the dialog invisible.
|
||||
- Fixed manual field grouping staying broken after the first attempt: the request resolver is now always cleared once a choice is made or the request is abandoned, so later grouping attempts no longer short-circuit to an instant "Field grouping cancelled".
|
||||
- Fixed a timed-out or failed field grouping request leaving an orphaned, invisible modal window covering mpv: abandoned requests now tear down the modal window and close the dialog so the overlay recovers immediately.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed words being highlighted green as known when a same-spelled Anki card taught a different reading (e.g. とこ parsed as 床 "bed" matching a known 床/ゆか "floor" card). The known-word cache now stores each card's word together with its reading and only matches when the token's reading agrees; cards without a reading field keep matching in any reading as before.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: launcher
|
||||
|
||||
- Added `subminer -H` / `--history` to browse local watch history, replay the last watched episode, continue to the next episode, or browse episodes with fzf/rofi.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: launcher
|
||||
|
||||
- Added `subminer sync <host>` to merge immersion stats and watch history between two machines over SSH. Each side snapshots its database (`VACUUM INTO`), snapshots are exchanged with `scp`, and each machine merges the other's data as an insert-only union keyed on session UUIDs / video keys / series title keys, so re-syncing is idempotent and nothing is double-counted. Lifetime totals and daily/monthly rollups are updated incrementally (history older than the session retention window is preserved); remote-only historical rollups are copied only when they do not conflict with retained local session history. `subminer sync --snapshot <file>` and `subminer sync --merge <file>` expose the underlying steps for manual transfers. The command refuses to run while the stats daemon or a live mpv session is active (`--force` overrides), ignores stale mpv socket files, keeps the guard in place through local/remote merges, supplies standard SubMiner and Bun paths to non-interactive SSH commands, verifies the remote launcher starts, reports remote stderr on failures, and aborts on stats schema version mismatches.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed character-name annotations dropping for an entire subtitle line when it contained any chunk the dictionary scanner could not match (e.g. an interjection like やほっ before a name): scanner metadata is now merged per token into the parseText segmentation instead of being discarded on any span mismatch.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed single-kana grammar tokens counting as known words by borrowing the reading of an unrelated Anki note (よ in 全然いいよ matched a card read よ such as 夜, standalone え matched 絵), which painted them with the known-word highlight. Reading-only known-word matching now requires at least two kana; single-kana cards still match by their word field, and tokens genuinely present in the known-words cache (e.g. です) keep their highlight.
|
||||
- Standalone suffix tokens (MeCab pos2 接尾, e.g. さん, れる) are now excluded from JLPT/frequency/N+1 annotations by default, matching how particles and interjections are treated. Cache-backed known-word highlighting still applies; override via the pos2 exclusion config if you want them annotated.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: branding
|
||||
|
||||
- Replaced the SubMiner app icon with new pixel-art submarine artwork contributed by an anonymous community member, used across the app icon, tray, notifications, README, docs site, and stats page.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed secondary subtitles stacking dozens of one-syllable lines down the screen during karaoke-typeset openings/endings, which made the hover-pause band cover the whole video: karaoke-like event spam is now collapsed into a single deduped line, and the secondary subtitle area is height-capped so it always stays a strip at the top.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Session known-word counts no longer show 0 everywhere. The stats server's known-word cache parser only understood the v1/v2 cache formats, so after the reading-aware v3 cache upgrade it silently treated the cache as missing; it now flattens v3 note entries into the headword set.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Show all trend chart titles by default, persist hidden-title choices, and add a per-chart top-title limit selector.
|
||||
@@ -0,0 +1,7 @@
|
||||
type: internal
|
||||
area: testing
|
||||
|
||||
- Test lanes are now defined once in `scripts/test-lanes.ts` and discovered by directory instead of hand-maintained file lists in `package.json`; the unused `test:core:*`/`test:config:dist`/`test:full` scripts were removed.
|
||||
- `scripts/run-test-lane.mjs` runs each test file in an isolated `bun test` process with a wall timeout, so a hanging test or leaked global can no longer cascade failures across the lane.
|
||||
- Previously orphaned suites now run in CI: the stats dashboard tests (`bun run test:stats`), the `scripts/**` tests (`bun run test:scripts`, including the change-verification skill tests), the `test-plugin-process-start-retries.lua` plugin test, and the runtime-compat dist slice (now part of `bun run test:fast`).
|
||||
- The change-verification skill gained a `stats` lane for `stats/` edits.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed subtitle text that Yomitan's parser cannot match (e.g. the truncated volitional in とこ戻ろ…) being rendered as plain, non-interactive text: it was invisible to hover/lookup and excluded from the n+1 word count, which could wrongly mark a sentence as n+1. Unparsed runs are now kept as hoverable tokens matching Yomitan's own segmentation; bracketed SFX/speaker captions and punctuation-only runs are still skipped.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: youtube
|
||||
|
||||
- Fixed direct YouTube stream media extraction by parsing mpv EDL stream URLs with their byte-length guards, preventing trailing EDL segment options from corrupting signed googlevideo URLs and causing ffmpeg 403 errors.
|
||||
@@ -172,7 +172,7 @@
|
||||
"updates": {
|
||||
"enabled": true, // Run automatic update checks in the background. Values: true | false
|
||||
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
|
||||
"notificationType": "both", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
|
||||
"notificationType": "overlay", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
|
||||
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease
|
||||
}, // Automatic update check behavior.
|
||||
|
||||
@@ -290,15 +290,41 @@
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Shift+BracketRight", // Key setting.
|
||||
"key": "Ctrl+Shift+ArrowLeft", // Key setting.
|
||||
"command": [
|
||||
"__sub-delay-next-line"
|
||||
"sub-step",
|
||||
-1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Shift+BracketLeft", // Key setting.
|
||||
"key": "Ctrl+Shift+ArrowRight", // Key setting.
|
||||
"command": [
|
||||
"__sub-delay-prev-line"
|
||||
"sub-step",
|
||||
1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "KeyZ", // Key setting.
|
||||
"command": [
|
||||
"add",
|
||||
"sub-delay",
|
||||
-0.1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Shift+KeyZ", // Key setting.
|
||||
"command": [
|
||||
"add",
|
||||
"sub-delay",
|
||||
0.1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "KeyX", // Key setting.
|
||||
"command": [
|
||||
"add",
|
||||
"sub-delay",
|
||||
0.1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
@@ -533,6 +559,7 @@
|
||||
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Values: true | false
|
||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
@@ -542,7 +569,7 @@
|
||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
|
||||
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
|
||||
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.
|
||||
}, // Known words setting.
|
||||
"behavior": {
|
||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
||||
@@ -592,7 +619,11 @@
|
||||
"primarySubLanguages": [
|
||||
"ja",
|
||||
"jpn"
|
||||
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||
], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||
"mediaCache": {
|
||||
"mode": "direct", // How YouTube card audio/images are extracted. Values: direct | background
|
||||
"maxHeight": 720 // Maximum video height downloaded for the YouTube background media cache. Set to 0 for unlimited.
|
||||
} // Media cache setting.
|
||||
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -161,13 +161,14 @@ Audio is extracted from the video file using the subtitle's start and end timest
|
||||
"ankiConnect": {
|
||||
"media": {
|
||||
"generateAudio": true,
|
||||
"normalizeAudio": true, // normalize generated clip loudness
|
||||
"audioPadding": 0, // optional seconds before and after subtitle timing
|
||||
"maxMediaDuration": 30 // cap total duration in seconds
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream.
|
||||
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream. Generated sentence audio is loudness-normalized by default during extraction; set `normalizeAudio` to `false` to keep raw source loudness.
|
||||
|
||||
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
|
||||
|
||||
@@ -347,6 +348,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
||||
"imageType": "static",
|
||||
"imageFormat": "jpg",
|
||||
"imageQuality": 92,
|
||||
"normalizeAudio": true,
|
||||
"audioPadding": 0,
|
||||
"maxMediaDuration": 30,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,61 @@
|
||||
# Changelog
|
||||
|
||||
## v0.16.0 (2026-06-10)
|
||||
## v0.17.2 (2026-06-28)
|
||||
|
||||
**Fixed**
|
||||
- YouTube Background Cache: Fixed Windows YouTube background media cache startup for YouTube URLs opened directly in mpv, including resolved stream URLs when mpv still exposes the original YouTube playlist entry, so queued Anki media updates can append audio and images after the cache finishes.
|
||||
- YouTube Subtitle Picker: Manual subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal. Subtitle download progress is replaced with a transient success notification after tracks load.
|
||||
|
||||
## v0.17.1 (2026-06-27)
|
||||
|
||||
**Added**
|
||||
- YouTube Media Cache Mode: Adds `youtube.mediaCache.mode` with `direct` and `background` options. Background mode uses a yt-dlp cache download when direct stream extraction is unreliable — creates a text-only card immediately, queues media updates for mined notes, and fills audio/image fields once the download finishes. Progress is announced via overlay/OSD notifications. Downloads are capped at 720p by default (`youtube.mediaCache.maxHeight`). Switching back to direct mode cancels any in-flight background download.
|
||||
|
||||
**Fixed**
|
||||
- Log Export: Fixed log filenames to use the local date so exports around UTC midnight include the current day's logs rather than stale prior-day files. Expanded export redaction to mask IPs, emails, auth and cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL parameters.
|
||||
- YouTube Card Media: Improved media generation reliability by sending safer ffmpeg options for resolved streams and skipping stale stream maps (including cached YouTube files). Hardened background cache downloads with IPv4 and extractor retry flags; failed downloads now notify the user and clear queued media updates instead of leaving them silently pending. Stale background cache files are cleaned on startup and before each new download.
|
||||
|
||||
## v0.17.0 (2026-06-15)
|
||||
|
||||
**Changed**
|
||||
|
||||
- **Subtitle Delay Keybindings**: Updated default overlay subtitle delay and step bindings to match mpv conventions: `z`, `Z`, and `x` adjust `sub-delay`; `Ctrl+Shift+Left/Right` run native `sub-step` with OSD feedback. The previous SubMiner-only adjacent-cue delay action has been removed.
|
||||
- **Update Notifications**: New installs now default update notifications to overlay-only instead of overlay + system notifications.
|
||||
|
||||
**Fixed**
|
||||
|
||||
- **Anki – Highlight Word**: Fixed bolding of the mined word in Kiku sentence and sentence-furigana fields when the source Yomitan sentence did not already contain bold markup.
|
||||
- **Anki – Lapis/Kiku Word Cards**: Fixed word-and-sentence marker missing from Lapis/Kiku word cards enriched through SubMiner, which could hide sentence context on the card front.
|
||||
- **Anki – Windows Media Generation**: Fixed known-word cache refreshes when no deck is configured, and fixed audio/image generation after background launches by recreating missing FFmpeg temp directories before clipping.
|
||||
- **Character Dictionary – Windows**: Fixed the Windows "SubMiner mpv" shortcut so character dictionary auto-sync can fall back to mpv's current video path when app media state is not yet ready.
|
||||
- **Notifications**: Restored the SubMiner app icon on system notifications that do not supply a custom notification image.
|
||||
- **Overlay – macOS Yomitan Popup**: Fixed Yomitan popup focus after card mining or popup reload; fixed popups staying open when clicking transparent overlay space — click-away now closes the popup and returns click passthrough to mpv without a hide/reappear cycle.
|
||||
- **Overlay – Linux Auto-Pause Startup**: Fixed the visible overlay on Linux auto-paused startup to stay interactive during the initial measurement gap; startup subtitle cache misses now paint raw text before tokenization finishes, and temporarily empty `sub-text` refreshes are resolved before warm readiness resumes playback.
|
||||
- **Overlay – Playlist Advance**: Fixed the visible overlay being dismissed when mpv advances to the next playlist item, including when the next episode loads after the warm transition delay.
|
||||
- **Overlay – Windows Subtitle Bar**: Fixed shaky hover and click interaction on the Windows subtitle bar when a video attaches to an already-running background SubMiner instance.
|
||||
- **Stats – AniList Linking**: Fixed manual AniList linking from the stats anime page so auto-searches strip the generated "Season N" suffix and query only the anime title.
|
||||
- **Updates – Linux Support Assets**: Fixed Linux updates to correctly install and refresh the launcher runtime plugin copy and rofi theme alongside AppImage and launcher updates; unrelated SubMiner data directories are now left untouched and plugin copies are staged before replacing the live runtime. Fixed first-launch playback on fresh installs by auto-installing missing managed support assets from the bundled app before mpv starts.
|
||||
|
||||
**Docs**
|
||||
|
||||
- **Linux Update Flows**: Documented that Linux update flows manage the launcher runtime plugin copy and rofi theme from `subminer-assets.tar.gz`, and that normal launcher playback auto-installs those managed support assets on a fresh install if either is missing.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
**Internal**
|
||||
|
||||
- **Runtime Modules**: Split main-process runtime wiring into focused modules without changing user-facing behavior; hardened helpers against stale background stats daemon PIDs, stalled subtitle extraction, and dropped async errors.
|
||||
- **Release CI**: Fixed GitHub release notes to preserve the `What's Changed` and `New Contributors` attribution sections when CI regenerates from the committed changelog; scoped prerelease note reuse to the same base version so a new beta line starts from current fragments.
|
||||
|
||||
</details>
|
||||
|
||||
## Previous Versions
|
||||
|
||||
<details>
|
||||
<summary>v0.16.x</summary>
|
||||
|
||||
<h2>v0.16.0 (2026-06-10)</h2>
|
||||
|
||||
**Breaking Changes**
|
||||
|
||||
@@ -48,7 +103,7 @@
|
||||
|
||||
</details>
|
||||
|
||||
## Previous Versions
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v0.15.x</summary>
|
||||
|
||||
@@ -56,6 +56,8 @@ A single character produces many searchable terms so that names are recognized r
|
||||
- Family name alone: 須々木
|
||||
- Given name alone: 心一
|
||||
|
||||
Unspaced native names (AniList often stores 渡辺真奈美 without a separator) are split into family/given parts with MeCab when it is available: person-name POS tags (姓/名) decide the boundary, validated against AniList's romanized first/last name readings. Without MeCab, a length heuristic based on the romanized readings guesses the boundary — and because that guess can be ambiguous (東紫乃 could be 東+紫乃 or 東紫+乃), terms are generated for the top two candidate boundaries so the real surname still matches. Snapshots built without MeCab are regenerated automatically once MeCab becomes available, upgrading them to the exact splits.
|
||||
|
||||
**Middle-dot removal** (common in katakana foreign names):
|
||||
|
||||
- ア・リ・ス → アリス (combined), plus individual segments
|
||||
|
||||
@@ -188,6 +188,9 @@ Control the minimum log level for runtime output:
|
||||
| `files.launcher` | boolean | Write launcher command logs (default: `true`) |
|
||||
| `files.mpv` | boolean | Write mpv player logs. Enable temporarily for mpv/plugin debugging. |
|
||||
|
||||
Log filenames use the local calendar date, for example `app-YYYY-MM-DD.log`, `launcher-YYYY-MM-DD.log`, and `mpv-YYYY-MM-DD.log`.
|
||||
Log export creates a sanitized copy of those files; it does not rewrite the original log files on disk.
|
||||
|
||||
### Updates
|
||||
|
||||
Configure automatic update checks and update notifications:
|
||||
@@ -197,7 +200,7 @@ Configure automatic update checks and update notifications:
|
||||
"updates": {
|
||||
"enabled": true,
|
||||
"checkIntervalHours": 24,
|
||||
"notificationType": "both",
|
||||
"notificationType": "overlay",
|
||||
"channel": "stable"
|
||||
}
|
||||
}
|
||||
@@ -207,7 +210,7 @@ Configure automatic update checks and update notifications:
|
||||
| -------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `updates.enabled` | `true`, `false` | Enable automatic background update checks. Manual tray and `subminer -u` checks are always allowed. |
|
||||
| `checkIntervalHours` | number | Minimum hours between automatic update checks. Default `24`. |
|
||||
| `notificationType` | `"overlay"` \| `"system"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"both"`, which means overlay + system. |
|
||||
| `notificationType` | `"overlay"` \| `"system"` \| `"both"` \| `"none"` | How SubMiner announces available updates. Default `"overlay"`. `"both"` means overlay + system. |
|
||||
| `channel` | `"stable"` \| `"prerelease"` | Release channel used for update checks. Use `"prerelease"` to test beta/RC releases. |
|
||||
|
||||
When `notificationType` is `"overlay"` or `"both"`, update-available overlay notifications include an **Update** button that starts the app update flow.
|
||||
@@ -571,26 +574,29 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
||||
|
||||
**Default keybindings:**
|
||||
|
||||
| Key | Command | Description |
|
||||
| -------------------- | ----------------------------- | --------------------------------------- |
|
||||
| `Space` | `["cycle", "pause"]` | Toggle pause |
|
||||
| `KeyF` | `["cycle", "fullscreen"]` | Toggle fullscreen |
|
||||
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
|
||||
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
|
||||
| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
|
||||
| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
|
||||
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
||||
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
|
||||
| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
|
||||
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
|
||||
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
|
||||
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
|
||||
| `Shift+BracketLeft` | `["__sub-delay-prev-line"]` | Shift subtitle delay to previous cue |
|
||||
| `Shift+BracketRight` | `["__sub-delay-next-line"]` | Shift subtitle delay to next cue |
|
||||
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
|
||||
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
|
||||
| `KeyQ` | `["quit"]` | Quit mpv |
|
||||
| `Ctrl+KeyW` | `["quit"]` | Quit mpv |
|
||||
| Key | Command | Description |
|
||||
| ----------------------- | ----------------------------- | --------------------------------------- |
|
||||
| `Space` | `["cycle", "pause"]` | Toggle pause |
|
||||
| `KeyF` | `["cycle", "fullscreen"]` | Toggle fullscreen |
|
||||
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
|
||||
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
|
||||
| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
|
||||
| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
|
||||
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
||||
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
|
||||
| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds |
|
||||
| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds |
|
||||
| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle |
|
||||
| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle |
|
||||
| `Ctrl+Shift+ArrowLeft` | `["sub-step", -1]` | Shift subtitle delay to previous cue |
|
||||
| `Ctrl+Shift+ArrowRight` | `["sub-step", 1]` | Shift subtitle delay to next cue |
|
||||
| `KeyZ` | `["add", "sub-delay", -0.1]` | Shift subtitles 100 ms earlier |
|
||||
| `Shift+KeyZ` | `["add", "sub-delay", 0.1]` | Delay subtitles by 100 ms |
|
||||
| `KeyX` | `["add", "sub-delay", 0.1]` | Delay subtitles by 100 ms |
|
||||
| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end |
|
||||
| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end |
|
||||
| `KeyQ` | `["quit"]` | Quit mpv |
|
||||
| `Ctrl+KeyW` | `["quit"]` | Quit mpv |
|
||||
|
||||
**Custom keybindings example:**
|
||||
|
||||
@@ -616,11 +622,11 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
||||
{ "key": "Space", "command": null }
|
||||
```
|
||||
|
||||
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__sub-delay-next-line` shifts subtitle delay so the active line aligns to the next cue start in the active subtitle source. `__sub-delay-prev-line` shifts subtitle delay so the active line aligns to the previous cue start. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
|
||||
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
|
||||
|
||||
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
|
||||
|
||||
For subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`) and subtitle delay commands (`sub-delay`), SubMiner also shows an mpv OSD notification after the command runs.
|
||||
Subtitle delay commands (`sub-delay`, `sub-step`) show a native mpv OSD notification after the command runs. Subtitle-position and subtitle-track proxy commands (`sub-pos`, `sid`, `secondary-sid`) show playback feedback through the configured notification surface.
|
||||
|
||||
**See `config.example.jsonc`** for more keybinding examples and configuration options.
|
||||
|
||||
@@ -655,26 +661,26 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| -------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
|
||||
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
|
||||
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
|
||||
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
|
||||
| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when automatic card updates are disabled) |
|
||||
| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) |
|
||||
| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) |
|
||||
| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) |
|
||||
| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) |
|
||||
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
|
||||
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
|
||||
| `openCharacterDictionaryManager` | string \| `null` | Opens the loaded character dictionary manager (default: `"CommandOrControl+D"`) |
|
||||
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
|
||||
| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) |
|
||||
| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
|
||||
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
|
||||
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
|
||||
| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
|
||||
| Option | Values | Description |
|
||||
| -------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
|
||||
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
|
||||
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) |
|
||||
| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) |
|
||||
| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when automatic card updates are disabled) |
|
||||
| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) |
|
||||
| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) |
|
||||
| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) |
|
||||
| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) |
|
||||
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
|
||||
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
|
||||
| `openCharacterDictionaryManager` | string \| `null` | Opens the loaded character dictionary manager (default: `"CommandOrControl+D"`) |
|
||||
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
|
||||
| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) |
|
||||
| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
|
||||
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
|
||||
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
|
||||
| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
|
||||
| `toggleNotificationHistory` | string \| `null` | Toggles the overlay notification history panel (default: `"CommandOrControl+N"`). The panel slides in from the same edge as notifications (right when notifications are centered). |
|
||||
|
||||
**See `config.example.jsonc`** for the complete list of shortcut configuration options.
|
||||
@@ -945,6 +951,7 @@ Enable automatic Anki card creation and updates with media generation:
|
||||
"animatedMaxWidth": 640,
|
||||
"animatedMaxHeight": 0,
|
||||
"animatedCrf": 35,
|
||||
"normalizeAudio": true,
|
||||
"audioPadding": 0,
|
||||
"fallbackDuration": 3,
|
||||
"maxMediaDuration": 30
|
||||
@@ -974,57 +981,58 @@ This example is intentionally compact. The option table below documents availabl
|
||||
|
||||
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
|
||||
|
||||
| Option | Values | Description |
|
||||
| ------------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
|
||||
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
||||
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
|
||||
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
|
||||
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
|
||||
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
|
||||
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
|
||||
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
||||
| Option | Values | Description |
|
||||
| ------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
|
||||
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
||||
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
|
||||
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
|
||||
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
|
||||
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
|
||||
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
|
||||
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
||||
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. |
|
||||
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
||||
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
|
||||
| `fields.image` | string | Card field for images (default: `Picture`) |
|
||||
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
|
||||
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
|
||||
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
|
||||
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
|
||||
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
||||
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
|
||||
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
|
||||
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
|
||||
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
|
||||
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
|
||||
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
|
||||
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
|
||||
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
|
||||
| `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. |
|
||||
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
|
||||
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
||||
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
|
||||
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) |
|
||||
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
|
||||
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
||||
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
|
||||
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
|
||||
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
||||
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
|
||||
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
|
||||
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
|
||||
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
|
||||
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
|
||||
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
||||
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
|
||||
| `fields.image` | string | Card field for images (default: `Picture`) |
|
||||
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
|
||||
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
|
||||
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
|
||||
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
|
||||
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
||||
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||
| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. |
|
||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
|
||||
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
|
||||
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
|
||||
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
|
||||
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
|
||||
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
|
||||
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
|
||||
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
|
||||
| `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. |
|
||||
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
|
||||
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
||||
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
|
||||
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) |
|
||||
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
|
||||
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
||||
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
|
||||
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
|
||||
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
||||
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
|
||||
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
|
||||
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
|
||||
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
|
||||
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
|
||||
|
||||
`ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
|
||||
API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
|
||||
@@ -1517,14 +1525,24 @@ Set defaults used by managed subtitle auto-selection and the `subminer` launcher
|
||||
```json
|
||||
{
|
||||
"youtube": {
|
||||
"primarySubLanguages": ["ja", "jpn"]
|
||||
"primarySubLanguages": ["ja", "jpn"],
|
||||
"mediaCache": {
|
||||
"mode": "direct",
|
||||
"maxHeight": 720
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| --------------------- | -------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) |
|
||||
| Option | Values | Description |
|
||||
| ---------------------- | ------------------------ | ------------------------------------------------------------------------------------------------ |
|
||||
| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) |
|
||||
| `mediaCache.mode` | `direct` \| `background` | YouTube card audio/image extraction mode (default `direct`) |
|
||||
| `mediaCache.maxHeight` | number | Maximum background cache download height. Set `0` for unlimited (default `720`) |
|
||||
|
||||
`mediaCache.mode: "direct"` extracts card media from the active YouTube stream URL. `mediaCache.mode: "background"` starts a separate yt-dlp media download after YouTube playback has loaded, including YouTube URLs opened directly in mpv and resolved stream URLs when mpv still exposes the original YouTube playlist entry. Playback and subtitle loading do not wait for that download. Use background mode if direct card media generation hits YouTube `403` errors from expiring stream URLs.
|
||||
|
||||
Background cache downloads are capped by `mediaCache.maxHeight`, which defaults to 720p; set it to `0` to let yt-dlp choose the best available height. Downloads use IPv4 and yt-dlp retry flags to reduce YouTube throttling failures. SubMiner announces when the background cache download starts and when the cache is ready, using the configured notification surface; overlay and OSD messages queue until the overlay or mpv is ready. If you mine cards before the cache is ready, SubMiner creates the text fields immediately, queues the audio/image work for those note IDs, shows a status notification, and fills the media fields once the cached file is ready. If the cache download fails, SubMiner shows a failure notification, shows queued-card failure notifications, and clears the pending updates.
|
||||
|
||||
Current launcher behavior:
|
||||
|
||||
|
||||
@@ -80,18 +80,24 @@ Default lanes:
|
||||
|
||||
```bash
|
||||
bun run test # alias for test:fast
|
||||
bun run test:fast # default fast lane
|
||||
bun run test:full # maintained source + launcher-unit + runtime compat surface
|
||||
bun run test:fast # full source lanes: src + launcher-unit + scripts + runtime compat
|
||||
bun run test:runtime:compat # compiled/runtime compatibility slice only
|
||||
bun run test:env # launcher/plugin + env-sensitive verification
|
||||
bun run test:stats # stats dashboard UI suite
|
||||
bun run test:immersion:sqlite # SQLite persistence lane
|
||||
bun run test:subtitle # maintained alass/ffsubsync subtitle surface
|
||||
```
|
||||
|
||||
- `bun run test` and `bun run test:fast` cover config/core suites plus representative entry/runtime, Anki integration, release-workflow coverage, typecheck, and runtime-registry checks.
|
||||
- `bun run test:full` is the maintained full surface: Bun-compatible `src/**` discovery, Bun-compatible launcher unit discovery, and the compiled/runtime compatibility lane for suites routed through `dist/**`.
|
||||
Test lane membership is defined once in `scripts/test-lanes.ts` and discovered by
|
||||
directory, so new test files join their lane automatically. `scripts/run-test-lane.mjs`
|
||||
runs each test file in its own `bun test` process (per-file isolation) so a hanging
|
||||
test or leaked global in one file cannot cascade into the rest of the lane; pass
|
||||
`--jobs N` to parallelize or `--single-process` for one shared process.
|
||||
|
||||
- `bun run test` and `bun run test:fast` cover the full discovered `src/**` suite, launcher unit tests, `scripts/**` tests, and the compiled/runtime compatibility lane.
|
||||
- `bun run test:runtime:compat` covers the compiled/runtime slice directly: `ipc`, `anki-jimaku-ipc`, `overlay-manager`, `config-validation`, `startup-config`, and `registry`.
|
||||
- `bun run test:env` covers environment-sensitive checks: launcher smoke/plugin verification plus the Bun source SQLite lane.
|
||||
- `bun run test:stats` runs the stats dashboard suite under `stats/src/**`.
|
||||
- `bun run test:immersion:sqlite` is the reproducible persistence lane when you need real DB-backed SQLite coverage under Bun.
|
||||
|
||||
The Bun-managed discovery lanes intentionally exclude a small compiled/runtime-focused set: `src/core/services/ipc.test.ts`, `src/core/services/anki-jimaku-ipc.test.ts`, `src/core/services/overlay-manager.test.ts`, `src/main/config-validation.test.ts`, `src/main/runtime/startup-config.test.ts`, and `src/main/runtime/registry.test.ts`. `bun run test:runtime:compat` keeps them in the standard workflow via `dist/**`.
|
||||
@@ -126,11 +132,11 @@ Focused commands:
|
||||
```bash
|
||||
bun run test:config # Source-level config schema/validation tests
|
||||
bun run test:launcher # Launcher regression tests (config discovery + command routing)
|
||||
bun run test:core # Source-level core regression tests (default lane)
|
||||
bun run test:launcher:smoke:src # Launcher e2e smoke: launcher -> mpv IPC -> overlay start/stop wiring
|
||||
bun run test:launcher:env:src # Launcher smoke + Lua plugin gate
|
||||
bun run test:src # Bun-managed maintained src/** discovery lane
|
||||
bun run test:launcher:unit:src # Bun-managed maintained launcher unit lane
|
||||
bun run test:scripts # Bun-managed scripts/** test lane
|
||||
bun run test:immersion:sqlite:src # Bun source lane
|
||||
```
|
||||
|
||||
@@ -144,8 +150,6 @@ Smoke and optional deep dist commands:
|
||||
bun run build # compile dist artifacts
|
||||
bun run test:immersion:sqlite # compile + run SQLite-backed immersion tests under Bun
|
||||
bun run test:smoke:dist # explicit smoke scope for compiled runtime
|
||||
bun run test:config:dist # optional full dist config suite
|
||||
bun run test:core:dist # optional full dist core suite
|
||||
```
|
||||
|
||||
Use `bun run test:immersion:sqlite` when you need real DB-backed coverage for the immersion tracker.
|
||||
|
||||
@@ -25,6 +25,7 @@ Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_
|
||||
|
||||
- Leave `dbPath` empty to use the default location (`immersion.sqlite` in SubMiner's app-data directory).
|
||||
- Set an explicit path to move the database (useful for backups, cloud syncing, or external tools).
|
||||
- To share stats and watch history between two machines, use [`subminer sync <host>`](/launcher-script#sync-between-machines) instead of file-level cloud sync — it merges both databases without one side overwriting the other.
|
||||
|
||||
## Stats Dashboard
|
||||
|
||||
@@ -95,7 +96,7 @@ Stats server config lives under `stats`:
|
||||
|
||||
- `toggleKey` is overlay-local, not a system-wide shortcut.
|
||||
- `serverPort` controls the localhost dashboard URL.
|
||||
- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running.
|
||||
- `autoStartServer` starts the local stats HTTP server on launch once immersion tracking is active, or reuses the dedicated background stats server when one is already running. Background app launches (`subminer app`) start the stats server immediately, registering it so later launches reuse it instead of starting another one.
|
||||
- `autoOpenBrowser` controls whether `subminer stats` launches the dashboard URL in your browser after ensuring the server is running.
|
||||
- `subminer stats` forces the dashboard server to start even when `autoStartServer` is `false`.
|
||||
- `subminer stats -b` starts or reuses the dedicated background stats daemon and exits after startup acknowledgement.
|
||||
|
||||
@@ -12,20 +12,20 @@ Three steps to get started:
|
||||
|
||||
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
|
||||
|
||||
| Dependency | Status | What it does |
|
||||
| -------------------- | ----------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| mpv | Required | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
|
||||
| ffmpeg | Recommended | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
|
||||
| Dependency | Status | What it does |
|
||||
| -------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| mpv | Required | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
|
||||
| ffmpeg | Recommended | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
|
||||
| MeCab + mecab-ipadic | Recommended | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
|
||||
| yt-dlp | Optional | YouTube playback and subtitle extraction. |
|
||||
| fzf | Optional | Terminal-based video picker in the launcher. |
|
||||
| rofi | Optional | GUI-based video picker (Linux). |
|
||||
| chafa | Optional | Thumbnail previews in fzf. |
|
||||
| ffmpegthumbnailer | Optional | Video thumbnail generation for the picker. |
|
||||
| guessit | Optional | Better AniSkip title/season/episode parsing. |
|
||||
| alass | Optional | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
|
||||
| ffsubsync | Optional | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. |
|
||||
| fuse2 | Linux only | Required to run the AppImage. |
|
||||
| yt-dlp | Optional | YouTube playback and subtitle extraction. |
|
||||
| fzf | Optional | Terminal-based video picker in the launcher. |
|
||||
| rofi | Optional | GUI-based video picker (Linux). |
|
||||
| chafa | Optional | Thumbnail previews in fzf. |
|
||||
| ffmpegthumbnailer | Optional | Video thumbnail generation for the picker. |
|
||||
| guessit | Optional | Better AniSkip title/season/episode parsing. |
|
||||
| alass | Optional | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
|
||||
| ffsubsync | Optional | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. |
|
||||
| fuse2 | Linux only | Required to run the AppImage. |
|
||||
|
||||
### Linux
|
||||
|
||||
@@ -300,11 +300,11 @@ subminer -u
|
||||
subminer --update
|
||||
```
|
||||
|
||||
SubMiner verifies AppImage, launcher, and rofi theme downloads against `SHA256SUMS.txt`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself.
|
||||
SubMiner verifies AppImage, launcher, and Linux support-asset downloads against `SHA256SUMS.txt`. On Linux those support assets include the launcher-managed runtime plugin copy under `SubMiner/plugin/subminer` plus the rofi theme at `SubMiner/themes/subminer.rasi`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself.
|
||||
|
||||
The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. On Linux it replaces the running `.AppImage` in place via `electron-updater`; AppImages managed by a system package (for example the AUR `/opt/SubMiner/SubMiner.AppImage`) are skipped so the package manager stays in charge.
|
||||
The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. On Linux it replaces the running `.AppImage` in place via `electron-updater` and refreshes the managed support assets from `subminer-assets.tar.gz`; AppImages managed by a system package (for example the AUR `/opt/SubMiner/SubMiner.AppImage`) are skipped so the package manager stays in charge.
|
||||
|
||||
`subminer -u` also performs the AppImage update directly from the launcher process, which is useful when SubMiner is not currently running.
|
||||
`subminer -u` also performs the AppImage, launcher, and managed support-asset updates directly from the launcher process, which is useful when SubMiner is not currently running.
|
||||
|
||||
## How It All Fits Together
|
||||
|
||||
@@ -312,13 +312,14 @@ SubMiner is an overlay that sits on top of mpv. It connects to mpv through an IP
|
||||
|
||||
The `subminer` launcher handles mpv IPC socket setup automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or `\\.\pipe\subminer-socket` on Windows) - without it the overlay starts but subtitles won't appear.
|
||||
|
||||
The bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
|
||||
The bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. On Linux, the `subminer` launcher now checks for its managed runtime plugin copy and rofi theme before every mpv-managed launch and installs those support assets from the bundled app automatically if either one is missing. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
|
||||
|
||||
## Platform Notes
|
||||
|
||||
### macOS
|
||||
|
||||
**MeCab paths (Homebrew):**
|
||||
|
||||
- Apple Silicon (M1/M2): `/opt/homebrew/bin/mecab`
|
||||
- Intel: `/usr/local/bin/mecab`
|
||||
|
||||
@@ -361,17 +362,21 @@ sudo chmod +x /usr/local/bin/subminer
|
||||
|
||||
## Optional Extras
|
||||
|
||||
### Rofi Theme (Linux Only)
|
||||
### Linux Support Assets
|
||||
|
||||
SubMiner ships a custom rofi theme in the release assets:
|
||||
SubMiner ships the Linux rofi theme plus the launcher-managed runtime plugin copy in `subminer-assets.tar.gz`:
|
||||
|
||||
```bash
|
||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
||||
mkdir -p ~/.local/share/SubMiner/themes
|
||||
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
||||
mkdir -p ~/.local/share/SubMiner/plugin
|
||||
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer
|
||||
```
|
||||
|
||||
Override with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`.
|
||||
`subminer -u` and the tray updater keep those Linux support assets in sync automatically once the `SubMiner` data dir exists. Normal Linux launcher playback also auto-installs the managed runtime plugin copy and rofi theme from the bundled app if either support asset is missing, so manual extraction is mainly useful for pre-seeding or custom setups.
|
||||
|
||||
Override the theme path with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`.
|
||||
|
||||
Next: [Usage](/usage) - learn about the `subminer` wrapper, keybindings, and YouTube playback.
|
||||
|
||||
@@ -34,15 +34,19 @@ subminer -R -r -d ~/Anime # rofi picker, recursive
|
||||
subminer -R /directory # rofi picker, directory shortcut
|
||||
```
|
||||
|
||||
rofi shows a GUI menu with icon thumbnails when available. SubMiner ships a custom rofi theme bundled in the release assets tarball:
|
||||
rofi shows a GUI menu with icon thumbnails when available. SubMiner ships the rofi theme plus the Linux launcher-managed runtime plugin copy in the release assets tarball:
|
||||
|
||||
```bash
|
||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
||||
mkdir -p ~/.local/share/SubMiner/themes
|
||||
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
||||
mkdir -p ~/.local/share/SubMiner/plugin
|
||||
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer
|
||||
```
|
||||
|
||||
Once the `SubMiner` data dir exists, `subminer -u` refreshes both assets automatically. Normal Linux launcher playback also checks for the managed runtime plugin copy and rofi theme before mpv launch and installs them from the bundled app automatically if either one is missing.
|
||||
|
||||
The theme is auto-detected from these paths (first match wins):
|
||||
|
||||
- `$SUBMINER_ROFI_THEME` environment variable (absolute path)
|
||||
@@ -57,6 +61,46 @@ Override with the `SUBMINER_ROFI_THEME` environment variable:
|
||||
SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R
|
||||
```
|
||||
|
||||
## Watch History
|
||||
|
||||
`subminer -H` (or `--history`) browses your local watch history, sourced from the immersion tracker database. It works with both pickers: fzf by default, rofi with `-R -H`.
|
||||
|
||||
```bash
|
||||
subminer -H # fzf history browser
|
||||
subminer -R -H # rofi history browser
|
||||
```
|
||||
|
||||
The first menu lists every locally watched series, most recently watched first, using the parsed media title (e.g. the anime title) when available and the directory name otherwise. Selecting a series opens an action menu:
|
||||
|
||||
- **Replay last watched** — replays the most recently watched episode
|
||||
- **Next episode** — plays the episode after the last watched one (continues into the next season directory when the season ends)
|
||||
- **Browse episodes** — lists the video files in the series directory in episode order, using the same fzf/rofi episode picker as directory browsing; if the series has multiple season directories, a season menu is shown first
|
||||
|
||||
Series whose directories are not currently accessible (e.g. an unmounted network share) are hidden from the list. Watch history requires the immersion tracker database (`immersionTracking.dbPath`, default `<config dir>/immersion.sqlite`), which SubMiner populates during playback.
|
||||
|
||||
## 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.
|
||||
|
||||
```bash
|
||||
subminer sync macbook # two-way sync with the host "macbook"
|
||||
subminer sync user@192.168.1.20 # explicit user@host
|
||||
subminer sync macbook --remote-cmd ~/bin/subminer # custom remote launcher path
|
||||
```
|
||||
|
||||
How it works: each side takes a consistent snapshot of its database (`VACUUM INTO`), the snapshots are exchanged over `scp`, and each machine merges the other's snapshot into its own database. The merge is an insert-only union keyed on stable identifiers (session UUIDs, video keys, series title keys, word/kanji identity), so it is safe to re-run at any time — syncing twice changes nothing, and nothing is ever overwritten or summed twice. Lifetime totals and rollup charts are updated incrementally, so history older than the session retention window is preserved on both sides.
|
||||
|
||||
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`.
|
||||
|
||||
Two lower-level modes are used internally over SSH and also work standalone for manual transfers (e.g. via a USB drive):
|
||||
|
||||
```bash
|
||||
subminer sync --snapshot /tmp/stats.sqlite # write a consistent snapshot of the local database
|
||||
subminer sync --merge /tmp/stats.sqlite # merge a snapshot file into the local database
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
@@ -79,12 +123,13 @@ subminer stats -b # start background stats daemon
|
||||
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows |
|
||||
| `subminer doctor` | Dependency + config + socket diagnostics |
|
||||
| `subminer settings` | Open the SubMiner settings window |
|
||||
| `subminer logs -e` | Export a sanitized log ZIP and print its path |
|
||||
| `subminer logs -e` | Export a sanitized local-date log ZIP and print its path |
|
||||
| `subminer config path` | Print active config file path |
|
||||
| `subminer config show` | Print active config contents |
|
||||
| `subminer mpv status` | Check mpv socket readiness |
|
||||
| `subminer mpv socket` | Print active socket path |
|
||||
| `subminer mpv idle` | Launch detached idle mpv instance |
|
||||
| `subminer sync <host>` | Two-way stats/history sync with another machine over SSH |
|
||||
| `subminer dictionary <path>` | Generate character dictionary ZIP from file/dir target |
|
||||
| `subminer dictionary --candidates <path>` | List AniList candidate matches for character dictionary correction |
|
||||
| `subminer dictionary --select <id> <path>` | Pin an AniList media ID for that target series |
|
||||
@@ -101,6 +146,7 @@ Use `subminer <subcommand> -h` for command-specific help.
|
||||
| `-d, --directory` | Video search directory (default: cwd) |
|
||||
| `-r, --recursive` | Search directories recursively |
|
||||
| `-R, --rofi` | Use rofi instead of fzf |
|
||||
| `-H, --history` | Browse local watch history (see [Watch History](#watch-history)) |
|
||||
| `--setup` | Open first-run setup popup manually |
|
||||
| `-v, --version` | Print installed SubMiner version |
|
||||
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
|
||||
@@ -113,7 +159,7 @@ Use `subminer <subcommand> -h` for command-specific help.
|
||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |
|
||||
|
||||
On Linux, `subminer -u` updates from the launcher process itself. It can check and replace the AppImage, launcher, and rofi theme even when SubMiner is already running in the tray.
|
||||
On Linux, `subminer -u` updates from the launcher process itself. It can check and replace the AppImage, launcher, runtime plugin copy, and rofi theme even when SubMiner is already running in the tray.
|
||||
|
||||
With default plugin settings (`auto_start=yes`, `auto_start_visible_overlay=yes`, `auto_start_pause_until_ready=yes`), explicit start flags are usually unnecessary.
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ The plugin ships as a modular Lua package under `plugin/subminer/` (entry point
|
||||
|
||||
Launch mpv through the SubMiner app, the `subminer` launcher, or the packaged Windows SubMiner mpv shortcut. These paths pass mpv a bundled plugin path for that playback session only, leaving regular mpv playback untouched.
|
||||
|
||||
On Linux, the launcher-managed runtime plugin copy lives under the SubMiner data dir (`$XDG_DATA_HOME/SubMiner/plugin/subminer` by default, plus `/usr/local/share/SubMiner` or `/usr/share/SubMiner` for system installs). `subminer -u` and the tray updater keep that managed copy current. This is separate from mpv's global `scripts/` directory.
|
||||
|
||||
If setup detects an older global SubMiner plugin in mpv's `scripts` directory, use **Remove legacy mpv plugin** in first-run setup. The global plugin is not needed once runtime loading is available.
|
||||
|
||||
mpv must have IPC enabled for SubMiner to connect:
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 15 KiB |
@@ -172,7 +172,7 @@
|
||||
"updates": {
|
||||
"enabled": true, // Run automatic update checks in the background. Values: true | false
|
||||
"checkIntervalHours": 24, // Minimum hours between automatic update checks.
|
||||
"notificationType": "both", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
|
||||
"notificationType": "overlay", // How SubMiner announces available updates. overlay shows notifications on the overlay, system uses OS notifications, both uses overlay and system. osd and osd-system are legacy config-file-only values. Values: overlay | system | both | none | osd | osd-system
|
||||
"channel": "stable" // Release channel used for update checks. Values: stable | prerelease
|
||||
}, // Automatic update check behavior.
|
||||
|
||||
@@ -290,15 +290,41 @@
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Shift+BracketRight", // Key setting.
|
||||
"key": "Ctrl+Shift+ArrowLeft", // Key setting.
|
||||
"command": [
|
||||
"__sub-delay-next-line"
|
||||
"sub-step",
|
||||
-1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Shift+BracketLeft", // Key setting.
|
||||
"key": "Ctrl+Shift+ArrowRight", // Key setting.
|
||||
"command": [
|
||||
"__sub-delay-prev-line"
|
||||
"sub-step",
|
||||
1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "KeyZ", // Key setting.
|
||||
"command": [
|
||||
"add",
|
||||
"sub-delay",
|
||||
-0.1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "Shift+KeyZ", // Key setting.
|
||||
"command": [
|
||||
"add",
|
||||
"sub-delay",
|
||||
0.1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
"key": "KeyX", // Key setting.
|
||||
"command": [
|
||||
"add",
|
||||
"sub-delay",
|
||||
0.1
|
||||
] // Command setting.
|
||||
},
|
||||
{
|
||||
@@ -533,6 +559,7 @@
|
||||
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Values: true | false
|
||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
@@ -542,7 +569,7 @@
|
||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Cache matches always receive known-word highlighting even when POS filters suppress other annotation types. Values: headword | surface
|
||||
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }.
|
||||
"decks": {} // Decks and expression/word fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word"] }. Reading fields (Reading, Word Reading, ExpressionReading) are always probed so cached words match only in the reading their note teaches; words from notes without readings match in any reading.
|
||||
}, // Known words setting.
|
||||
"behavior": {
|
||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
||||
@@ -592,7 +619,11 @@
|
||||
"primarySubLanguages": [
|
||||
"ja",
|
||||
"jpn"
|
||||
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||
], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||
"mediaCache": {
|
||||
"mode": "direct", // How YouTube card audio/images are extracted. Values: direct | background
|
||||
"maxHeight": 720 // Maximum video height downloaded for the YouTube background media cache. Set to 0 for unlimited.
|
||||
} // Media cache setting.
|
||||
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||
|
||||
// ==========================================
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 633 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -43,31 +43,34 @@ The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcu
|
||||
|
||||
These control playback and subtitle display. They require overlay window focus.
|
||||
|
||||
| Shortcut | Action |
|
||||
| -------------------- | --------------------------------------------------- |
|
||||
| `Space` | Toggle mpv pause |
|
||||
| `F` | Toggle fullscreen |
|
||||
| Shortcut | Action |
|
||||
| -------------------- | ---------------------------------------------------------- |
|
||||
| `Space` | Toggle mpv pause |
|
||||
| `F` | Toggle fullscreen |
|
||||
| `V` | Cycle primary subtitle bar mode (hidden → visible → hover) |
|
||||
| `J` | Cycle primary subtitle track |
|
||||
| `Shift+J` | Cycle secondary subtitle track |
|
||||
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
|
||||
| `ArrowRight` | Seek forward 5 seconds |
|
||||
| `ArrowLeft` | Seek backward 5 seconds |
|
||||
| `ArrowUp` | Seek forward 60 seconds |
|
||||
| `ArrowDown` | Seek backward 60 seconds |
|
||||
| `Shift+H` | Jump to previous subtitle |
|
||||
| `Shift+L` | Jump to next subtitle |
|
||||
| `Shift+[` | Shift subtitle delay to previous subtitle cue |
|
||||
| `Shift+]` | Shift subtitle delay to next subtitle cue |
|
||||
| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) |
|
||||
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
|
||||
| `Q` | Quit mpv |
|
||||
| `Ctrl+W` | Quit mpv |
|
||||
| `Right-click` | Toggle pause (outside subtitle area) |
|
||||
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
|
||||
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
|
||||
| `J` | Cycle primary subtitle track |
|
||||
| `Shift+J` | Cycle secondary subtitle track |
|
||||
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
|
||||
| `ArrowRight` | Seek forward 5 seconds |
|
||||
| `ArrowLeft` | Seek backward 5 seconds |
|
||||
| `ArrowUp` | Seek forward 60 seconds |
|
||||
| `ArrowDown` | Seek backward 60 seconds |
|
||||
| `Shift+H` | Jump to previous subtitle |
|
||||
| `Shift+L` | Jump to next subtitle |
|
||||
| `Ctrl+Shift+Left` | Shift subtitle delay to previous subtitle cue |
|
||||
| `Ctrl+Shift+Right` | Shift subtitle delay to next subtitle cue |
|
||||
| `z` | Shift subtitles 100 ms earlier |
|
||||
| `Shift+Z` | Delay subtitles by 100 ms |
|
||||
| `x` | Delay subtitles by 100 ms |
|
||||
| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) |
|
||||
| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) |
|
||||
| `Q` | Quit mpv |
|
||||
| `Ctrl+W` | Quit mpv |
|
||||
| `Right-click` | Toggle pause (outside subtitle area) |
|
||||
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
|
||||
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
|
||||
|
||||
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V`, `Ctrl/Cmd+A`, and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
|
||||
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V`, `Ctrl/Cmd+A`, and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
|
||||
|
||||
On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions.
|
||||
|
||||
@@ -75,19 +78,19 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
||||
|
||||
## Subtitle & Feature Shortcuts
|
||||
|
||||
| Shortcut | Action | Config key |
|
||||
| ------------------ | -------------------------------------------------------- | ----------------------------------------------- |
|
||||
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
||||
| `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` |
|
||||
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
||||
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
|
||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||
| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
|
||||
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
|
||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
||||
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
|
||||
| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
|
||||
| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` |
|
||||
| Shortcut | Action | Config key |
|
||||
| ------------------ | -------------------------------------------------------- | ------------------------------------------ |
|
||||
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
||||
| `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` |
|
||||
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
||||
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
|
||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||
| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
|
||||
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
|
||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
||||
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
|
||||
| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
|
||||
| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` |
|
||||
|
||||
The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`.
|
||||
|
||||
@@ -108,17 +111,17 @@ Controller input only drives the overlay while keyboard-only mode is enabled. Th
|
||||
|
||||
When the mpv plugin is installed, all commands use a `y` chord prefix - press `y`, then the second key within 1 second.
|
||||
|
||||
| Chord | Action |
|
||||
| ----- | -------------------------------------- |
|
||||
| `y-y` | Open SubMiner menu (OSD) |
|
||||
| `y-s` | Start overlay |
|
||||
| `y-S` | Stop overlay |
|
||||
| `y-t` | Toggle visible overlay |
|
||||
| Chord | Action |
|
||||
| ----- | ---------------------------------------------------------- |
|
||||
| `y-y` | Open SubMiner menu (OSD) |
|
||||
| `y-s` | Start overlay |
|
||||
| `y-S` | Stop overlay |
|
||||
| `y-t` | Toggle visible overlay |
|
||||
| `v` | Cycle primary subtitle bar mode (hidden → visible → hover) |
|
||||
| `y-o` | Open Yomitan settings |
|
||||
| `y-r` | Restart overlay |
|
||||
| `y-c` | Check overlay status |
|
||||
| `y-h` | Open session help |
|
||||
| `y-o` | Open Yomitan settings |
|
||||
| `y-r` | Restart overlay |
|
||||
| `y-c` | Check overlay status |
|
||||
| `y-h` | Open session help |
|
||||
|
||||
The bare `v` plugin binding intentionally overrides mpv's native primary subtitle visibility toggle so it cycles the SubMiner primary subtitle bar (hidden → visible → hover) instead.
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ From there, subtitles render as interactive, hoverable word spans and you mine c
|
||||
| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` |
|
||||
| **mpv plugin** (all platforms) | Bundled and injected at runtime. Provides `y` chord keybindings for controlling the overlay from within mpv. No manual install needed. | Automatic when using the launcher or shortcut |
|
||||
|
||||
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
|
||||
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. On Linux, normal `subminer` playback auto-installs the launcher-managed runtime plugin copy from the bundled app if that managed copy is missing, so no separate plugin install is needed for standard launcher usage. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
|
||||
|
||||
## Live Config Reload
|
||||
|
||||
@@ -111,6 +111,9 @@ subminer config show # Print active config contents
|
||||
subminer mpv socket # Print active mpv socket path
|
||||
subminer mpv status # Exit 0 if socket is ready, else exit 1
|
||||
subminer mpv idle # Launch detached idle mpv with SubMiner defaults
|
||||
subminer sync media-box # Sync stats/watch history with an SSH host
|
||||
subminer sync --snapshot ~/subminer-snapshot.sqlite # Write a local DB snapshot
|
||||
subminer sync --merge ~/subminer-snapshot.sqlite # Merge a snapshot into the local DB
|
||||
subminer dictionary /path/to/file-or-directory # Generate character dictionary ZIP from target (manual Yomitan import)
|
||||
subminer dictionary --candidates /path/to/file.mkv
|
||||
subminer dictionary --select 21355 /path/to/file.mkv
|
||||
@@ -148,7 +151,7 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin corre
|
||||
SubMiner.AppImage --help # Show all options
|
||||
```
|
||||
|
||||
The tray menu includes `Export Logs`, which creates the same sanitized log ZIP as `subminer logs -e` and shows the archive path when complete.
|
||||
The tray menu includes `Export Logs`, which creates the same sanitized local-date log ZIP as `subminer logs -e` and shows the archive path when complete. Export sanitization masks common PII and secrets, including home-directory usernames, IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL query strings. The exported copy is sanitized; source log files remain unredacted on disk.
|
||||
|
||||
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
|
||||
|
||||
@@ -191,9 +194,10 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
|
||||
- `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
|
||||
- `subminer doctor`: health checks for core dependencies and runtime paths.
|
||||
- `subminer settings`: open the SubMiner settings window (also `subminer --settings`).
|
||||
- `subminer logs -e`: export a sanitized ZIP of today's logs, or the most recent logs when no current-day log exists.
|
||||
- `subminer logs -e`: export a sanitized ZIP of today's local-date logs, or the most recent logs when no current-day log exists. The exported copy masks common PII and secrets; on-disk logs are unchanged.
|
||||
- `subminer config`: config file helpers (`path`, `show`).
|
||||
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
|
||||
- `subminer sync <host>`: sync immersion stats and watch history with another machine over SSH. The host is the SSH destination (`user@host` or an SSH config alias). Remote launcher checks include standard SubMiner and Bun paths even when SSH omits them from `PATH`. Use `--snapshot <file>` to write a consistent local stats DB snapshot, `--merge <file>` to merge a snapshot into the local stats DB, and `--force` to skip the running stats/mpv safety check. Advanced options: `--db <file>` overrides the local stats DB path, and `--remote-cmd <cmd>` overrides the `subminer` command used on the remote host.
|
||||
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
|
||||
- Use `subminer dictionary --candidates <path>` and `subminer dictionary --select <id> <path>` to correct AniList character-dictionary matches for a whole series.
|
||||
- `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`). A _texthooker_ is a web page that displays the current subtitle line as selectable text, so browser-based dictionary extensions and other tools can read along with playback.
|
||||
|
||||
@@ -74,20 +74,30 @@ Press **Ctrl+Alt+C** during YouTube playback to open the subtitle picker overlay
|
||||
- Select different primary and secondary tracks
|
||||
- Retry track loading if the auto-load failed or picked the wrong track
|
||||
|
||||
SubMiner shows an "Opening YouTube subtitle picker..." status through your configured notification
|
||||
surface while it probes tracks and prepares the modal, then updates the subtitle download progress
|
||||
card to a success notification after the selected tracks load.
|
||||
|
||||
The picker displays each track with its language, kind (manual/auto), and title when available.
|
||||
|
||||
## Subtitle Format Handling
|
||||
|
||||
SubMiner handles several YouTube subtitle formats transparently:
|
||||
|
||||
| Format | Handling |
|
||||
| ------ | -------- |
|
||||
| `srt`, `vtt` | Used directly (preferred for manual tracks) |
|
||||
| Format | Handling |
|
||||
| ---------------------- | -------------------------------------------------------- |
|
||||
| `srt`, `vtt` | Used directly (preferred for manual tracks) |
|
||||
| `srv1`, `srv2`, `srv3` | YouTube TimedText XML --- converted to VTT automatically |
|
||||
| Auto-generated VTT | Normalized to remove rolling-caption text duplication |
|
||||
| Auto-generated VTT | Normalized to remove rolling-caption text duplication |
|
||||
|
||||
For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (TimedText XML produces cleaner output). For manual tracks, `srt` > `vtt` is preferred.
|
||||
|
||||
## Card Media Cache
|
||||
|
||||
By default, YouTube card audio and screenshots are extracted directly from mpv's active stream URLs. If generated card media fails with YouTube `403` errors, set `youtube.mediaCache.mode` to `"background"`. Background mode starts a separate `yt-dlp` media download after playback loads, including YouTube URLs opened directly in mpv and resolved stream URLs when mpv still exposes the original YouTube playlist entry. It creates text fields immediately, queues audio/image work for mined notes, and fills those fields once the local cache file is ready.
|
||||
|
||||
Background cache downloads use IPv4 and retry flags to reduce YouTube throttling failures. If the background download still fails, SubMiner shows a cache failure notification, shows queued-card failure notifications, and clears those pending updates so cards are not left waiting silently.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### Primary Subtitle Languages
|
||||
@@ -95,13 +105,13 @@ For auto-generated tracks, SubMiner prefers `srv3` > `srv2` > `srv1` > `vtt` (Ti
|
||||
```jsonc
|
||||
{
|
||||
"youtube": {
|
||||
"primarySubLanguages": ["ja", "jpn"]
|
||||
}
|
||||
"primarySubLanguages": ["ja", "jpn"],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| Option | Type | Description |
|
||||
| --------------------- | ---------- | ------------------------------------------------------------------------------------- |
|
||||
| `primarySubLanguages` | `string[]` | Language priority for YouTube primary subtitle auto-loading (default `["ja", "jpn"]`) |
|
||||
|
||||
### Secondary Subtitle Languages
|
||||
@@ -113,16 +123,16 @@ Secondary track selection uses the shared `secondarySub` config:
|
||||
"secondarySub": {
|
||||
"secondarySubLanguages": [],
|
||||
"autoLoadSecondarySub": false,
|
||||
"defaultMode": "hover"
|
||||
}
|
||||
"defaultMode": "hover",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track. Default is empty (`[]`). For YouTube, SubMiner always tries an English track first regardless of this list. |
|
||||
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary track (default: `false`) |
|
||||
| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
|
||||
| Option | Type | Description |
|
||||
| ----------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `secondarySubLanguages` | `string[]` | Extra language codes (e.g. `["eng", "en"]`) used when auto-selecting a secondary track. Default is empty (`[]`). For YouTube, SubMiner always tries an English track first regardless of this list. |
|
||||
| `autoLoadSecondarySub` | `boolean` | Auto-detect and load a matching secondary track (default: `false`) |
|
||||
| `defaultMode` | `"hidden"` / `"visible"` / `"hover"` | Initial display mode for secondary subtitles (default: `"hover"`) |
|
||||
|
||||
Precedence: CLI flag > environment variable > `config.jsonc` > built-in default.
|
||||
|
||||
@@ -131,6 +141,7 @@ Precedence: CLI flag > environment variable > `config.jsonc` > built-in default.
|
||||
- **No subtitles found**: The video may not have Japanese subtitles. Open the picker with `Ctrl+Alt+C` to see all available tracks.
|
||||
- **yt-dlp not found**: Install `yt-dlp` and ensure it is on `PATH`, or set `SUBMINER_YTDLP_BIN` to the binary path.
|
||||
- **Probe timeout**: `yt-dlp` has a 15-second timeout per operation. Slow connections or rate-limited IPs may hit this. Retry or update `yt-dlp`.
|
||||
- **Card media `403` errors**: Switch `youtube.mediaCache.mode` from `"direct"` to `"background"` so card media is generated from a local `yt-dlp` cache instead of ffmpeg reading an expiring YouTube stream URL.
|
||||
- **Auto-caption quality**: YouTube auto-generated captions vary in quality. Manual subtitles (when available) are always preferred.
|
||||
- **`ytsearch:` targets**: `subminer ytsearch:"keyword"` plays the first search result. Subtitle availability depends on the matched video.
|
||||
- **Secondary subtitle fails**: Secondary track failures never block playback. The primary subtitle loads independently.
|
||||
|
||||
@@ -61,8 +61,10 @@
|
||||
committed file — so review it before committing. If you add more
|
||||
`changes/*.md` fragments for a later beta/RC, rerun
|
||||
`bun run changelog:prerelease-notes --version <version>`; the generator uses
|
||||
the existing prerelease notes as the baseline and asks Claude to merge only
|
||||
the new fragment material. Do not run `bun run changelog:build`.
|
||||
the existing prerelease notes as the baseline only when their hidden
|
||||
`prerelease-base-version` marker matches the current base version, and asks
|
||||
Claude to merge only the new fragment material. Do not run
|
||||
`bun run changelog:build`.
|
||||
6. Tag the commit: `git tag v<version>`.
|
||||
7. Push commit + tag.
|
||||
|
||||
@@ -77,7 +79,7 @@ Notes:
|
||||
- `changelog:check` now rejects tag/package version mismatches.
|
||||
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
|
||||
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
|
||||
- `release/release-notes.md` (and `release/prerelease-notes.md`) end with GitHub-style attribution: a `## What’s Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
|
||||
- `release/release-notes.md` (and `release/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
|
||||
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
|
||||
- Do not tag while `changes/*.md` fragments still exist.
|
||||
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
|
||||
|
||||
@@ -61,7 +61,7 @@ External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out o
|
||||
|
||||
#### Subtitle File Parsing
|
||||
|
||||
A new cue parser that extracts both timing and text content from subtitle files. The existing `parseSrtOrVttStartTimes` in `subtitle-delay-shift.ts` only extracts timing; this needs a companion that also extracts the dialogue text.
|
||||
A cue parser extracts both timing and text content from subtitle files for prefetching.
|
||||
|
||||
**Parsed cue structure:**
|
||||
```typescript
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Subtitle Overlay Priming
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-06-01
|
||||
Last verified: 2026-06-14
|
||||
Owner: Kyle Yasuda
|
||||
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
||||
|
||||
@@ -79,9 +79,19 @@ prefetch work and re-centers prefetch around the live playback time.
|
||||
window so readiness can still arrive before fallback resumes playback.
|
||||
- If mpv is already on a subtitle, SubMiner still prefers the resolved current subtitle payload and
|
||||
waits for a fresh measured subtitle rectangle before signaling readiness.
|
||||
- If the startup subtitle has no cached annotations yet, autoplay priming emits a raw first-paint
|
||||
subtitle payload before background tokenization. The tokenized payload replaces it when ready, but
|
||||
the visible overlay can paint and measure the line before the mpv startup gate resumes playback.
|
||||
- If startup `sub-text` is temporarily empty, autoplay priming refreshes the active subtitle source
|
||||
and then awaits cue-based priming before synthetic warm readiness can proceed. A parsed current or
|
||||
imminent cue is treated as the startup subtitle so the visible overlay can paint and measure it
|
||||
before playback resumes.
|
||||
- If mpv is before the first subtitle, SubMiner sends a synthetic warm readiness payload after
|
||||
tokenization warmup and visible overlay content-ready. This releases playback without waiting for
|
||||
a later subtitle event that cannot happen while mpv is paused.
|
||||
- After a synthetic warm readiness release, SubMiner briefly polls/refreshes the current subtitle
|
||||
again. This covers Linux/mpv startup cases where `sub-text` is still empty while paused but becomes
|
||||
available right after playback resumes, without waiting for the next subtitle property change.
|
||||
|
||||
## Linux/X11 Window Shape
|
||||
|
||||
@@ -95,6 +105,15 @@ prefetch work and re-centers prefetch around the live playback time.
|
||||
overlay window remained mapped above mpv.
|
||||
- Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and
|
||||
the Linux cursor-poll fallback, not bounding-shape clipping.
|
||||
- Visible-overlay show/reset marks Linux pointer passthrough state dirty even when the logical
|
||||
interaction state is already inactive. The next cursor-poll tick must still reapply
|
||||
`setIgnoreMouseEvents(true, { forward: true })`; otherwise a newly shown Electron overlay can keep
|
||||
full-window input capture and block both mpv and overlay controls before the first subtitle
|
||||
measurement.
|
||||
- Visible-overlay show also starts a short Linux input grace before the first content measurement.
|
||||
Native Wayland surfaces can become inert while `setIgnoreMouseEvents(true)` is active; keeping the
|
||||
overlay interactive during this startup gap lets notifications and overlay mouse bindings work
|
||||
until subtitle/sidebar/notification rectangles are reported.
|
||||
|
||||
## Config And Migration
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Remote Sync Runtime PATH Design
|
||||
|
||||
## Problem
|
||||
|
||||
`subminer sync <host>` can locate a remote launcher while still failing to run
|
||||
it. The generated launcher uses `#!/usr/bin/env bun`, and non-interactive SSH
|
||||
shells commonly omit Bun's install directory from `PATH`.
|
||||
|
||||
## Design
|
||||
|
||||
Remote launcher probes and invocations will use a deterministic `PATH` prefix
|
||||
covering SubMiner and Bun's supported/common locations:
|
||||
|
||||
- `$HOME/.local/bin`
|
||||
- `$HOME/.bun/bin`
|
||||
- `/opt/homebrew/bin`
|
||||
- `/usr/local/bin`
|
||||
- `/usr/bin`
|
||||
- `/bin`
|
||||
|
||||
Resolution will run `<candidate> --help` under that PATH instead of only using
|
||||
`command -v`. This confirms both the launcher and its shebang runtime work
|
||||
before sync creates or transfers snapshots. The same prefixed command returned
|
||||
by resolution will be used for remote snapshot and merge operations.
|
||||
|
||||
`--remote-cmd` remains a launcher path override and receives the same runtime
|
||||
PATH. Remote commands continue to be shell-quoted where user-controlled.
|
||||
|
||||
## Validation
|
||||
|
||||
Add focused resolver coverage that simulates an SSH shell where only the
|
||||
prefixed PATH can start SubMiner. Run launcher unit tests, type checking, docs
|
||||
tests/build, and the SubMiner launcher/docs verification lanes.
|
||||
@@ -0,0 +1,31 @@
|
||||
# SSH Resolver Testability Design
|
||||
|
||||
## Problem
|
||||
|
||||
The remote launcher resolver test installs a temporary `ssh` executable and
|
||||
mutates `process.env.PATH` after Bun starts. Bun's synchronous process launcher
|
||||
does not reliably use that late PATH change, so the focused test fails locally
|
||||
and in CI even though the command-building behavior is correct.
|
||||
|
||||
## Design
|
||||
|
||||
Allow `resolveRemoteSubminerCommand` to accept an optional remote runner that
|
||||
defaults to the production `runSsh` function. Existing callers retain the same
|
||||
behavior and two-argument API. The focused test will provide a small runner,
|
||||
exercise the real candidate and PATH construction, and assert the host and
|
||||
remote command passed across the SSH boundary.
|
||||
|
||||
Alternatives rejected:
|
||||
|
||||
- Explicitly forwarding `process.env` to `spawnSync` still depends on Bun's
|
||||
executable-resolution behavior and changes production code to support a test
|
||||
harness.
|
||||
- Module-level mocking couples the test to loader behavior and obscures the
|
||||
resolver's actual dependency.
|
||||
|
||||
## Validation
|
||||
|
||||
First update the focused test and confirm it remains red while the resolver
|
||||
ignores the injected runner. Then add the minimal runner parameter and confirm
|
||||
the focused test passes. Run the launcher verification lane, type checking,
|
||||
and the repository's default handoff gate.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Sync Stale Socket Guard Design
|
||||
|
||||
## Problem
|
||||
|
||||
The sync quiescence guard treats any configured mpv socket path as a running
|
||||
session when the filesystem entry exists. Unix socket files can remain after
|
||||
mpv exits, so a stale `/tmp/subminer-socket` blocks the remote merge even when
|
||||
nothing is listening.
|
||||
|
||||
## Design
|
||||
|
||||
Reuse the launcher's existing Unix socket connection check. The sync guard will
|
||||
block only when it can connect to the configured socket. Missing, stale, or
|
||||
otherwise unreachable socket paths will not block sync, and sync will not
|
||||
delete them.
|
||||
|
||||
Because socket connection checks are asynchronous, the sync dispatch, merge
|
||||
mode, host mode, and quiescence checks will become async. The already-async
|
||||
launcher entrypoint will await sync dispatch. Snapshot, transfer, merge,
|
||||
cleanup, and error behavior otherwise remain unchanged.
|
||||
|
||||
## Validation
|
||||
|
||||
Add focused tests proving a stale socket is ignored and a live socket remains a
|
||||
hard failure. Re-run sync command and socket tests, type checking, launcher
|
||||
tests, docs checks, and the SubMiner launcher/docs verification lanes.
|
||||
@@ -3,10 +3,22 @@
|
||||
# Verification
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-05-23
|
||||
Last verified: 2026-07-06
|
||||
Owner: Kyle Yasuda
|
||||
Read when: selecting the right verification lane for a change
|
||||
|
||||
## Lane Infrastructure
|
||||
|
||||
- Lane membership is defined once in `scripts/test-lanes.ts` and discovered by
|
||||
directory — new test files join their lane automatically; never hand-list test
|
||||
files in `package.json`.
|
||||
- `scripts/run-test-lane.mjs` runs each test file in its own `bun test` process
|
||||
(per-file isolation with a wall timeout) so a hanging test or leaked global in
|
||||
one file cannot cascade into the rest of the lane. `--jobs N` parallelizes;
|
||||
`--single-process` restores the shared-process mode for debugging.
|
||||
- `bun run test:fast` is the full source gate: discovered `src/**`, launcher
|
||||
unit, `scripts/**`, and the compiled runtime-compat slice.
|
||||
|
||||
## Default Handoff Gate
|
||||
|
||||
```bash
|
||||
@@ -31,6 +43,8 @@ bun run docs:build
|
||||
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed
|
||||
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||
- Runtime-compat / compiled behavior: `bun run test:runtime:compat`
|
||||
- Stats dashboard UI: `bun run test:stats`
|
||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||
- Coverage for the maintained source lane: `bun run test:coverage:src`
|
||||
- Deep/local full gate: default handoff gate above
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// Minimal ambient typing for bun:sqlite. The launcher always runs under bun
|
||||
// (see the build banner in package.json), but the repo typechecks with plain
|
||||
// tsc which has no bun type definitions.
|
||||
declare module 'bun:sqlite' {
|
||||
export interface RunResult {
|
||||
changes: number;
|
||||
lastInsertRowid: number | bigint;
|
||||
}
|
||||
|
||||
export interface Statement<ReturnType = unknown, ParamsType extends unknown[] = unknown[]> {
|
||||
all(...params: ParamsType): ReturnType[];
|
||||
get(...params: ParamsType): ReturnType | undefined;
|
||||
run(...params: ParamsType): RunResult;
|
||||
}
|
||||
|
||||
export class Database {
|
||||
constructor(
|
||||
filename: string,
|
||||
options?: { readonly?: boolean; readwrite?: boolean; create?: boolean },
|
||||
);
|
||||
query<ReturnType = unknown, ParamsType extends unknown[] = unknown[]>(
|
||||
sql: string,
|
||||
): Statement<ReturnType, ParamsType>;
|
||||
prepare<ReturnType = unknown, ParamsType extends unknown[] = unknown[]>(
|
||||
sql: string,
|
||||
): Statement<ReturnType, ParamsType>;
|
||||
run(sql: string, ...params: unknown[]): RunResult;
|
||||
close(throwOnError?: boolean): void;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { runConfigCommand } from './config-command.js';
|
||||
import { runDictionaryCommand } from './dictionary-command.js';
|
||||
import { runDoctorCommand } from './doctor-command.js';
|
||||
import { runLogsCommand } from './logs-command.js';
|
||||
import { runMpvPreAppCommand } from './mpv-command.js';
|
||||
import { runMpvPostAppCommand, runMpvPreAppCommand } from './mpv-command.js';
|
||||
import { runAppPassthroughCommand } from './app-command.js';
|
||||
import { runStatsCommand } from './stats-command.js';
|
||||
import { runUpdateCommand } from './update-command.js';
|
||||
@@ -262,7 +262,9 @@ test('mpv pre-app command exits non-zero when socket is not ready', async () =>
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await runMpvPreAppCommand(context, {
|
||||
ensureRuntimePluginReady: async () => {},
|
||||
waitForUnixSocketReady: async () => false,
|
||||
resolveRuntimePluginPath: () => null,
|
||||
launchMpvIdleDetached: async () => {},
|
||||
});
|
||||
},
|
||||
@@ -270,6 +272,32 @@ test('mpv pre-app command exits non-zero when socket is not ready', async () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('mpv idle command ensures Linux runtime plugin before detached launch', async () => {
|
||||
const context = createContext();
|
||||
context.args.mpvIdle = true;
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = await runMpvPostAppCommand(context, {
|
||||
ensureRuntimePluginReady: async () => {
|
||||
calls.push('plugin');
|
||||
},
|
||||
waitForUnixSocketReady: async () => {
|
||||
calls.push('wait');
|
||||
return true;
|
||||
},
|
||||
launchMpvIdleDetached: async () => {
|
||||
calls.push('launch');
|
||||
},
|
||||
resolveRuntimePluginPath: () => {
|
||||
calls.push('resolve');
|
||||
return '/tmp/plugin/main.lua';
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait']);
|
||||
});
|
||||
|
||||
test('dictionary command forwards --dictionary and target path to app binary', () => {
|
||||
const context = createContext();
|
||||
context.args.dictionary = true;
|
||||
@@ -361,7 +389,7 @@ test('update command runs direct Linux release update without launching Electron
|
||||
'direct:/tmp/subminer.app:/tmp/subminer:stable',
|
||||
'info:AppImage update: not-found',
|
||||
'info:Launcher update: updated',
|
||||
'info:Rofi theme update: skipped',
|
||||
'info:Support assets update: skipped',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fail, log } from '../log.js';
|
||||
import { commandExists } from '../util.js';
|
||||
import {
|
||||
collectVideos,
|
||||
findRofiTheme,
|
||||
formatPickerLaunchError,
|
||||
showFzfMenu,
|
||||
showRofiMenu,
|
||||
} from '../picker.js';
|
||||
import {
|
||||
findNextEpisode,
|
||||
groupHistoryBySeries,
|
||||
listSeasonDirs,
|
||||
materializeCoverArt,
|
||||
queryLocalWatchHistory,
|
||||
resolveImmersionDbPath,
|
||||
sortVideosByEpisode,
|
||||
type HistorySeriesEntry,
|
||||
} from '../history.js';
|
||||
import type { Args } from '../types.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
function checkPickerDependencies(args: Args): void {
|
||||
if (args.useRofi) {
|
||||
if (!commandExists('rofi')) fail('Missing dependency: rofi');
|
||||
return;
|
||||
}
|
||||
if (!commandExists('fzf')) fail('Missing dependency: fzf');
|
||||
}
|
||||
|
||||
function showRofiIndexMenu(
|
||||
labels: string[],
|
||||
prompt: string,
|
||||
themePath: string | null,
|
||||
icons: Array<string | null> = [],
|
||||
): number {
|
||||
const rofiArgs = ['-dmenu', '-i', '-matching', 'fuzzy', '-format', 'i', '-p', prompt];
|
||||
const hasIcons = icons.some(Boolean);
|
||||
if (hasIcons) rofiArgs.push('-show-icons');
|
||||
if (themePath) {
|
||||
rofiArgs.push('-theme', themePath);
|
||||
} else {
|
||||
rofiArgs.push('-theme-str', 'configuration { font: "Noto Sans CJK JP Regular 8";}');
|
||||
}
|
||||
if (hasIcons) {
|
||||
rofiArgs.push('-theme-str', 'configuration { show-icons: true; }');
|
||||
rofiArgs.push('-theme-str', 'element-icon { enabled: true; size: 3em; }');
|
||||
}
|
||||
const lines = labels.map((label, index) =>
|
||||
icons[index] ? `${label}\u0000icon\u001f${icons[index]}` : label,
|
||||
);
|
||||
const result = spawnSync('rofi', rofiArgs, {
|
||||
input: `${lines.join('\n')}\n`,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
});
|
||||
if (result.error) {
|
||||
fail(formatPickerLaunchError('rofi', result.error as NodeJS.ErrnoException));
|
||||
}
|
||||
const out = (result.stdout || '').trim();
|
||||
if (!out) return -1;
|
||||
const idx = Number.parseInt(out, 10);
|
||||
return Number.isInteger(idx) && idx >= 0 && idx < labels.length ? idx : -1;
|
||||
}
|
||||
|
||||
function showFzfIndexMenu(labels: string[], prompt: string): number {
|
||||
const lines = labels.map((label, index) => `${index}\t${label}`);
|
||||
const result = spawnSync(
|
||||
'fzf',
|
||||
[
|
||||
'--ansi',
|
||||
'--reverse',
|
||||
'--ignore-case',
|
||||
`--prompt=${prompt}: `,
|
||||
'--delimiter=\t',
|
||||
'--with-nth=2..',
|
||||
],
|
||||
{
|
||||
input: `${lines.join('\n')}\n`,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
},
|
||||
);
|
||||
if (result.error) {
|
||||
fail(formatPickerLaunchError('fzf', result.error as NodeJS.ErrnoException));
|
||||
}
|
||||
const picked = (result.stdout || '').trim();
|
||||
const tab = picked.indexOf('\t');
|
||||
if (tab === -1) return -1;
|
||||
const idx = Number.parseInt(picked.slice(0, tab), 10);
|
||||
return Number.isInteger(idx) && idx >= 0 && idx < labels.length ? idx : -1;
|
||||
}
|
||||
|
||||
function pickIndex(
|
||||
labels: string[],
|
||||
prompt: string,
|
||||
useRofi: boolean,
|
||||
themePath: string | null,
|
||||
icons: Array<string | null> = [],
|
||||
): number {
|
||||
if (labels.length === 0) return -1;
|
||||
return useRofi
|
||||
? showRofiIndexMenu(labels, prompt, themePath, icons)
|
||||
: showFzfIndexMenu(labels, prompt);
|
||||
}
|
||||
|
||||
function formatEpisodeLabel(entry: HistorySeriesEntry): string {
|
||||
const { parsedSeason, parsedEpisode } = entry.lastWatched;
|
||||
if (parsedEpisode === null) return '';
|
||||
return parsedSeason !== null ? `S${parsedSeason}E${parsedEpisode}` : `E${parsedEpisode}`;
|
||||
}
|
||||
|
||||
function formatSeriesLabel(entry: HistorySeriesEntry): string {
|
||||
const episodeLabel = formatEpisodeLabel(entry);
|
||||
return episodeLabel ? `${entry.displayName} [last: ${episodeLabel}]` : entry.displayName;
|
||||
}
|
||||
|
||||
function pickEpisodeFromDir(dir: string, context: LauncherCommandContext): string | null {
|
||||
const { args, scriptPath } = context;
|
||||
const videos = sortVideosByEpisode(collectVideos(dir, false));
|
||||
if (videos.length === 0) {
|
||||
fail(`No video files found in: ${dir}`);
|
||||
}
|
||||
const selected = args.useRofi
|
||||
? showRofiMenu(videos, dir, false, scriptPath, args.logLevel)
|
||||
: showFzfMenu(videos);
|
||||
return selected || null;
|
||||
}
|
||||
|
||||
function browseEpisodes(
|
||||
entry: HistorySeriesEntry,
|
||||
context: LauncherCommandContext,
|
||||
themePath: string | null,
|
||||
): string | null {
|
||||
const { args } = context;
|
||||
const seasons = listSeasonDirs(entry.seriesRoot);
|
||||
let dir = entry.seriesRoot;
|
||||
|
||||
if (seasons.length > 1) {
|
||||
const idx = pickIndex(
|
||||
seasons.map((season) => season.name),
|
||||
`${entry.displayName} — Season`,
|
||||
args.useRofi,
|
||||
themePath,
|
||||
);
|
||||
if (idx < 0) return null;
|
||||
dir = seasons[idx]!.path;
|
||||
} else if (seasons.length === 1 && collectVideos(dir, false).length === 0) {
|
||||
dir = seasons[0]!.path;
|
||||
}
|
||||
|
||||
return pickEpisodeFromDir(dir, context);
|
||||
}
|
||||
|
||||
export async function runHistoryCommand(context: LauncherCommandContext): Promise<string | null> {
|
||||
const { args, scriptPath } = context;
|
||||
|
||||
checkPickerDependencies(args);
|
||||
const themePath = args.useRofi ? findRofiTheme(scriptPath) : null;
|
||||
|
||||
const dbPath = resolveImmersionDbPath();
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
fail(`Watch history database not found: ${dbPath}`);
|
||||
}
|
||||
|
||||
const rows = queryLocalWatchHistory(dbPath);
|
||||
const series = groupHistoryBySeries(rows);
|
||||
if (series.length === 0) {
|
||||
fail('No local watch history found (or watched directories are not accessible).');
|
||||
}
|
||||
|
||||
log('info', args.logLevel, `Watch history: ${series.length} series found in ${dbPath}`);
|
||||
|
||||
const coverPaths = args.useRofi
|
||||
? materializeCoverArt(
|
||||
dbPath,
|
||||
series.map((seriesEntry) => seriesEntry.coverBlobHash),
|
||||
)
|
||||
: new Map<string, string>();
|
||||
const seriesIcons = series.map((seriesEntry) =>
|
||||
seriesEntry.coverBlobHash ? (coverPaths.get(seriesEntry.coverBlobHash) ?? null) : null,
|
||||
);
|
||||
|
||||
const seriesIdx = pickIndex(
|
||||
series.map(formatSeriesLabel),
|
||||
'Watch History',
|
||||
args.useRofi,
|
||||
themePath,
|
||||
seriesIcons,
|
||||
);
|
||||
if (seriesIdx < 0) return null;
|
||||
const entry = series[seriesIdx]!;
|
||||
|
||||
const lastPath = path.resolve(entry.lastWatched.sourcePath);
|
||||
const lastExists = fs.existsSync(lastPath);
|
||||
const nextEpisode = findNextEpisode(lastPath);
|
||||
|
||||
const actions: Array<{ kind: 'replay' | 'next' | 'browse'; label: string }> = [];
|
||||
if (lastExists) {
|
||||
actions.push({ kind: 'replay', label: `Replay last watched — ${path.basename(lastPath)}` });
|
||||
}
|
||||
if (nextEpisode) {
|
||||
actions.push({ kind: 'next', label: `Next episode — ${path.basename(nextEpisode)}` });
|
||||
}
|
||||
actions.push({ kind: 'browse', label: 'Browse episodes' });
|
||||
|
||||
const entryIcon = seriesIcons[seriesIdx] ?? null;
|
||||
const actionIdx = pickIndex(
|
||||
actions.map((action) => action.label),
|
||||
entry.displayName,
|
||||
args.useRofi,
|
||||
themePath,
|
||||
actions.map(() => entryIcon),
|
||||
);
|
||||
if (actionIdx < 0) return null;
|
||||
|
||||
switch (actions[actionIdx]!.kind) {
|
||||
case 'replay':
|
||||
return lastPath;
|
||||
case 'next':
|
||||
return nextEpisode;
|
||||
case 'browse':
|
||||
return browseEpisodes(entry, context, themePath);
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
resolveLauncherRuntimePluginPath,
|
||||
} from '../mpv.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
|
||||
|
||||
interface MpvCommandDeps {
|
||||
ensureRuntimePluginReady(context: LauncherCommandContext): Promise<void>;
|
||||
waitForUnixSocketReady(socketPath: string, timeoutMs: number): Promise<boolean>;
|
||||
resolveRuntimePluginPath(context: LauncherCommandContext): string | null;
|
||||
launchMpvIdleDetached(
|
||||
socketPath: string,
|
||||
appPath: string,
|
||||
@@ -18,7 +21,19 @@ interface MpvCommandDeps {
|
||||
}
|
||||
|
||||
const defaultDeps: MpvCommandDeps = {
|
||||
ensureRuntimePluginReady: async (context) => {
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
appPath: context.appPath ?? undefined,
|
||||
scriptPath: context.scriptPath,
|
||||
logLevel: context.args.logLevel,
|
||||
});
|
||||
},
|
||||
waitForUnixSocketReady,
|
||||
resolveRuntimePluginPath: (context) =>
|
||||
resolveLauncherRuntimePluginPath({
|
||||
appPath: context.appPath ?? '',
|
||||
scriptPath: context.scriptPath,
|
||||
}),
|
||||
launchMpvIdleDetached,
|
||||
};
|
||||
|
||||
@@ -58,11 +73,12 @@ export async function runMpvPostAppCommand(
|
||||
fail('SubMiner app binary not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
|
||||
}
|
||||
|
||||
await deps.ensureRuntimePluginReady(context);
|
||||
await deps.launchMpvIdleDetached(
|
||||
mpvSocketPath,
|
||||
appPath,
|
||||
args,
|
||||
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||
deps.resolveRuntimePluginPath(context),
|
||||
{
|
||||
...pluginRuntimeConfig,
|
||||
backend: args.backend,
|
||||
|
||||
@@ -35,6 +35,14 @@ function createContext(): LauncherCommandContext {
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
history: false,
|
||||
sync: false,
|
||||
syncHost: '',
|
||||
syncSnapshotPath: '',
|
||||
syncMergePath: '',
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
@@ -112,6 +120,7 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {},
|
||||
chooseTarget: async (_args, _scriptPath) => ({ target: context.args.target, kind: 'url' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
@@ -161,6 +170,7 @@ test('youtube app-owned playback disables mpv plugin auto-start', async () => {
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'url' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
@@ -227,6 +237,7 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
|
||||
try {
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
@@ -278,6 +289,7 @@ test('plugin auto-start playback attaches a warm background app through the laun
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
@@ -351,6 +363,7 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
@@ -420,6 +433,7 @@ test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
@@ -441,3 +455,34 @@ test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is
|
||||
|
||||
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']);
|
||||
});
|
||||
|
||||
test('playback command ensures Linux runtime plugin before mpv launch', async () => {
|
||||
const context = createContext();
|
||||
context.args = {
|
||||
...context.args,
|
||||
target: '/tmp/movie.mkv',
|
||||
targetKind: 'file',
|
||||
};
|
||||
const calls: string[] = [];
|
||||
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady: async () => {},
|
||||
ensureRuntimePluginReady: async () => {
|
||||
calls.push('plugin');
|
||||
},
|
||||
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||
checkDependencies: () => {},
|
||||
registerCleanup: () => {},
|
||||
startMpv: async () => {
|
||||
calls.push('startMpv');
|
||||
},
|
||||
waitForUnixSocketReady: async () => true,
|
||||
startOverlay: async () => {},
|
||||
launchAppCommandDetached: () => {},
|
||||
log: () => {},
|
||||
cleanupPlaybackSession: async () => {},
|
||||
getMpvProc: () => null,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['plugin', 'startMpv']);
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { Args } from '../types.js';
|
||||
import { nowMs } from '../time.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
import { ensureLauncherSetupReady } from '../setup-gate.js';
|
||||
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
|
||||
import {
|
||||
getDefaultConfigDir,
|
||||
getSetupStatePath,
|
||||
@@ -144,6 +145,13 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
||||
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
|
||||
return runPlaybackCommandWithDeps(context, {
|
||||
ensurePlaybackSetupReady,
|
||||
ensureRuntimePluginReady: async (commandContext) => {
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
appPath: commandContext.appPath ?? undefined,
|
||||
scriptPath: commandContext.scriptPath,
|
||||
logLevel: commandContext.args.logLevel,
|
||||
});
|
||||
},
|
||||
chooseTarget,
|
||||
checkDependencies,
|
||||
registerCleanup,
|
||||
@@ -160,6 +168,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
||||
|
||||
type PlaybackCommandDeps = {
|
||||
ensurePlaybackSetupReady: (context: LauncherCommandContext) => Promise<void>;
|
||||
ensureRuntimePluginReady: (context: LauncherCommandContext) => Promise<void>;
|
||||
chooseTarget: (
|
||||
args: Args,
|
||||
scriptPath: string,
|
||||
@@ -253,6 +262,8 @@ export async function runPlaybackCommandWithDeps(
|
||||
);
|
||||
}
|
||||
|
||||
await deps.ensureRuntimePluginReady(context);
|
||||
|
||||
await deps.startMpv(
|
||||
selectedTarget.target,
|
||||
selectedTarget.kind,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
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 type { Args } from '../types.js';
|
||||
import { createEmptyMergeSummary } from '../sync/sync-shared.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
import { ensureTrackerQuiescent, runSyncCommand, type SyncCommandDeps } from './sync-command.js';
|
||||
|
||||
function makeContext(overrides: Partial<Args>): LauncherCommandContext {
|
||||
return {
|
||||
args: {
|
||||
sync: true,
|
||||
syncHost: '',
|
||||
syncSnapshotPath: '',
|
||||
syncMergePath: '',
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
logLevel: 'warn',
|
||||
...overrides,
|
||||
} as Args,
|
||||
scriptPath: '/tmp/subminer',
|
||||
scriptName: 'subminer',
|
||||
mpvSocketPath: '',
|
||||
pluginRuntimeConfig: {},
|
||||
appPath: null,
|
||||
launcherJellyfinConfig: {},
|
||||
processAdapter: process,
|
||||
} as unknown as LauncherCommandContext;
|
||||
}
|
||||
|
||||
function ok(stdout = ''): { status: number; stdout: string; stderr: string } {
|
||||
return { status: 0, stdout, stderr: '' };
|
||||
}
|
||||
|
||||
test('ensureTrackerQuiescent ignores stale sockets but rejects live sockets', async () => {
|
||||
const context = makeContext({ syncDbPath: '/tmp/local.sqlite' });
|
||||
context.mpvSocketPath = '/tmp/subminer-socket';
|
||||
let socketConnectable = false;
|
||||
const deps: Partial<SyncCommandDeps> = {
|
||||
realpathSync: (() => '/tracker.sqlite') as unknown as typeof fs.realpathSync,
|
||||
findLiveStatsDaemonPid: () => null,
|
||||
canConnectUnixSocket: async () => socketConnectable,
|
||||
fail: (message: string): never => {
|
||||
throw new Error(message);
|
||||
},
|
||||
};
|
||||
|
||||
await ensureTrackerQuiescent(context, '/tmp/local.sqlite', deps);
|
||||
|
||||
socketConnectable = true;
|
||||
await assert.rejects(
|
||||
async () => ensureTrackerQuiescent(context, '/tmp/local.sqlite', deps),
|
||||
/mpv\/SubMiner session appears to be running/,
|
||||
);
|
||||
});
|
||||
|
||||
test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes', async () => {
|
||||
const calls: string[] = [];
|
||||
const deps: Partial<SyncCommandDeps> = {
|
||||
createDbSnapshot: (dbPath: string, outPath: string) => {
|
||||
calls.push(`snapshot:${dbPath}->${outPath}`);
|
||||
},
|
||||
mergeSnapshotIntoDb: (dbPath: string, snapshotPath: string) => {
|
||||
calls.push(`merge:${dbPath}<-${snapshotPath}`);
|
||||
return createEmptyMergeSummary();
|
||||
},
|
||||
formatMergeSummary: () => 'summary',
|
||||
ensureTrackerQuiescent: async () => {
|
||||
calls.push('quiescent');
|
||||
},
|
||||
assertSafeSshHost: (host: string) => {
|
||||
calls.push(`host:${host}`);
|
||||
},
|
||||
resolveRemoteSubminerCommand: () => 'subminer',
|
||||
runSsh: (_host: string, command: string) => {
|
||||
calls.push(`ssh:${command}`);
|
||||
return command.startsWith('mktemp ') ? ok('/tmp/subminer-sync.remote\n') : ok();
|
||||
},
|
||||
runScp: (from: string, to: string) => {
|
||||
calls.push(`scp:${from}->${to}`);
|
||||
},
|
||||
fail: (message: string): never => {
|
||||
throw new Error(message);
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
await runSyncCommand(
|
||||
makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }),
|
||||
deps,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'));
|
||||
|
||||
await runSyncCommand(
|
||||
makeContext({ syncDbPath: '/tmp/local.sqlite', syncMergePath: '/tmp/in.sqlite' }),
|
||||
deps,
|
||||
);
|
||||
assert.ok(calls.includes('quiescent'));
|
||||
assert.ok(calls.includes('merge:/tmp/local.sqlite<-/tmp/in.sqlite'));
|
||||
|
||||
await runSyncCommand(
|
||||
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }),
|
||||
deps,
|
||||
);
|
||||
assert.ok(calls.includes('host:media-box'));
|
||||
|
||||
await assert.rejects(
|
||||
() => runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite' }), deps),
|
||||
/sync requires a host, --snapshot <file>, or --merge <file>/,
|
||||
);
|
||||
});
|
||||
|
||||
test('runHostSync keeps tracker quiescent through local and remote merge and cleans up after failure', async () => {
|
||||
const calls: string[] = [];
|
||||
let localTmpDir = '';
|
||||
const deps: Partial<SyncCommandDeps> = {
|
||||
createDbSnapshot: (_dbPath: string, outPath: string) => {
|
||||
calls.push(`snapshot:${outPath}`);
|
||||
fs.writeFileSync(outPath, 'snapshot');
|
||||
},
|
||||
mergeSnapshotIntoDb: () => {
|
||||
calls.push('local-merge');
|
||||
return createEmptyMergeSummary();
|
||||
},
|
||||
formatMergeSummary: () => 'summary',
|
||||
ensureTrackerQuiescent: async () => {
|
||||
calls.push('quiescent');
|
||||
},
|
||||
assertSafeSshHost: () => {},
|
||||
resolveRemoteSubminerCommand: () => 'subminer',
|
||||
mkdtempSync: ((prefix: string) => {
|
||||
localTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), path.basename(prefix)));
|
||||
return localTmpDir;
|
||||
}) as typeof fs.mkdtempSync,
|
||||
runSsh: (_host: string, command: string) => {
|
||||
calls.push(`ssh:${command}`);
|
||||
if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n');
|
||||
if (command.includes(' sync --snapshot ')) return ok();
|
||||
if (command.includes(' sync --merge ')) {
|
||||
return { status: 9, stdout: 'remote output', stderr: 'remote merge exploded' };
|
||||
}
|
||||
return ok();
|
||||
},
|
||||
runScp: (from: string, to: string) => {
|
||||
calls.push(`scp:${from}->${to}`);
|
||||
if (!to.includes(':')) fs.writeFileSync(to, 'pulled');
|
||||
},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
|
||||
/Remote merge failed on media-box[\s\S]*remote merge exploded/,
|
||||
);
|
||||
assert.equal(calls.filter((call) => call === 'quiescent').length, 3);
|
||||
assert.ok(calls.indexOf('quiescent') < calls.findIndex((call) => call.startsWith('snapshot:')));
|
||||
assert.ok(calls.includes('local-merge'));
|
||||
assert.ok(calls.some((call) => call.startsWith('ssh:rm -rf ')));
|
||||
assert.equal(fs.existsSync(localTmpDir), false);
|
||||
});
|
||||
|
||||
test('runHostSync includes remote snapshot stderr in failures', async () => {
|
||||
const deps: Partial<SyncCommandDeps> = {
|
||||
createDbSnapshot: (_dbPath: string, outPath: string) => {
|
||||
fs.writeFileSync(outPath, 'snapshot');
|
||||
},
|
||||
ensureTrackerQuiescent: async () => {},
|
||||
assertSafeSshHost: () => {},
|
||||
resolveRemoteSubminerCommand: () => 'subminer',
|
||||
runSsh: (_host: string, command: string) => {
|
||||
if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n');
|
||||
if (command.includes(' sync --snapshot ')) {
|
||||
return { status: 5, stdout: '', stderr: 'snapshot permission denied' };
|
||||
}
|
||||
return ok();
|
||||
},
|
||||
runScp: () => {},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runSyncCommand(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
|
||||
/Remote snapshot failed on media-box[\s\S]*snapshot permission denied/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fail, log } from '../log.js';
|
||||
import { resolveImmersionDbPath } from '../history-db.js';
|
||||
import {
|
||||
createDbSnapshot,
|
||||
findLiveStatsDaemonPid,
|
||||
formatMergeSummary,
|
||||
mergeSnapshotIntoDb,
|
||||
} from '../sync/sync-db.js';
|
||||
import {
|
||||
assertSafeSshHost,
|
||||
resolveRemoteSubminerCommand,
|
||||
runScp,
|
||||
runSsh,
|
||||
shellQuote,
|
||||
} from '../sync/ssh.js';
|
||||
import { resolvePathMaybe } from '../util.js';
|
||||
import { canConnectUnixSocket } from '../mpv.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
import type { RemoteRunResult } from '../sync/ssh.js';
|
||||
|
||||
export interface SyncCommandDeps {
|
||||
createDbSnapshot: typeof createDbSnapshot;
|
||||
mergeSnapshotIntoDb: typeof mergeSnapshotIntoDb;
|
||||
formatMergeSummary: typeof formatMergeSummary;
|
||||
findLiveStatsDaemonPid: typeof findLiveStatsDaemonPid;
|
||||
assertSafeSshHost: typeof assertSafeSshHost;
|
||||
resolveRemoteSubminerCommand: typeof resolveRemoteSubminerCommand;
|
||||
runScp: typeof runScp;
|
||||
runSsh: typeof runSsh;
|
||||
fail: typeof fail;
|
||||
log: typeof log;
|
||||
canConnectUnixSocket: typeof canConnectUnixSocket;
|
||||
realpathSync: typeof fs.realpathSync;
|
||||
mkdtempSync: typeof fs.mkdtempSync;
|
||||
rmSync: typeof fs.rmSync;
|
||||
consoleLog: typeof console.log;
|
||||
writeStdout: typeof process.stdout.write;
|
||||
ensureTrackerQuiescent: (context: LauncherCommandContext, dbPath: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function resolveDbPath(context: LauncherCommandContext): string {
|
||||
const override = context.args.syncDbPath.trim();
|
||||
return override ? resolvePathMaybe(override) : resolveImmersionDbPath();
|
||||
}
|
||||
|
||||
function isTrackerDb(dbPath: string, deps: SyncCommandDeps): boolean {
|
||||
const trackerDbPath = resolveImmersionDbPath();
|
||||
try {
|
||||
return deps.realpathSync(dbPath) === deps.realpathSync(trackerDbPath);
|
||||
} catch {
|
||||
return dbPath === trackerDbPath;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureTrackerQuiescent(
|
||||
context: LauncherCommandContext,
|
||||
dbPath: string,
|
||||
inputDeps: Partial<SyncCommandDeps> = {},
|
||||
): Promise<void> {
|
||||
const deps = resolveSyncCommandDeps(inputDeps);
|
||||
if (context.args.syncForce) return;
|
||||
// A running SubMiner only holds the tracker's own database; --db pointed
|
||||
// elsewhere needs no guard.
|
||||
if (!isTrackerDb(dbPath, deps)) return;
|
||||
const daemonPid = deps.findLiveStatsDaemonPid(dbPath);
|
||||
if (daemonPid !== null) {
|
||||
deps.fail(
|
||||
`The SubMiner stats server is running (pid ${daemonPid}). Stop it with "subminer stats -s" (or close SubMiner) before syncing, or pass --force.`,
|
||||
);
|
||||
}
|
||||
if (context.mpvSocketPath && (await deps.canConnectUnixSocket(context.mpvSocketPath))) {
|
||||
deps.fail(
|
||||
`An mpv/SubMiner session appears to be running (socket ${context.mpvSocketPath}). Close it before syncing, or pass --force.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSyncCommandDeps: SyncCommandDeps = {
|
||||
createDbSnapshot,
|
||||
mergeSnapshotIntoDb,
|
||||
formatMergeSummary,
|
||||
findLiveStatsDaemonPid,
|
||||
assertSafeSshHost,
|
||||
resolveRemoteSubminerCommand,
|
||||
runScp,
|
||||
runSsh,
|
||||
fail,
|
||||
log,
|
||||
canConnectUnixSocket,
|
||||
realpathSync: fs.realpathSync,
|
||||
mkdtempSync: fs.mkdtempSync,
|
||||
rmSync: fs.rmSync,
|
||||
consoleLog: console.log,
|
||||
writeStdout: process.stdout.write.bind(process.stdout),
|
||||
ensureTrackerQuiescent: async (context, dbPath) => ensureTrackerQuiescent(context, dbPath),
|
||||
};
|
||||
|
||||
function resolveSyncCommandDeps(inputDeps: Partial<SyncCommandDeps> = {}): SyncCommandDeps {
|
||||
return { ...defaultSyncCommandDeps, ...inputDeps };
|
||||
}
|
||||
|
||||
export function runSnapshotMode(
|
||||
context: LauncherCommandContext,
|
||||
dbPath: string,
|
||||
inputDeps: Partial<SyncCommandDeps> = {},
|
||||
): void {
|
||||
const deps = resolveSyncCommandDeps(inputDeps);
|
||||
const outPath = resolvePathMaybe(context.args.syncSnapshotPath);
|
||||
deps.createDbSnapshot(dbPath, outPath);
|
||||
deps.consoleLog(outPath);
|
||||
}
|
||||
|
||||
export async function runMergeMode(
|
||||
context: LauncherCommandContext,
|
||||
dbPath: string,
|
||||
inputDeps: Partial<SyncCommandDeps> = {},
|
||||
): Promise<void> {
|
||||
const deps = resolveSyncCommandDeps(inputDeps);
|
||||
await deps.ensureTrackerQuiescent(context, dbPath);
|
||||
const snapshotPath = resolvePathMaybe(context.args.syncMergePath);
|
||||
const summary = deps.mergeSnapshotIntoDb(dbPath, snapshotPath);
|
||||
deps.consoleLog(deps.formatMergeSummary(summary));
|
||||
}
|
||||
|
||||
function cleanupRemote(host: string, remoteTmpDir: string, deps: SyncCommandDeps): void {
|
||||
if (!remoteTmpDir.startsWith('/tmp/')) return;
|
||||
deps.runSsh(host, `rm -rf ${shellQuote(remoteTmpDir)}`);
|
||||
}
|
||||
|
||||
function formatRemoteRunError(message: string, run: RemoteRunResult): string {
|
||||
const stderr = run.stderr.trim();
|
||||
return stderr ? `${message}\n${stderr}` : message;
|
||||
}
|
||||
|
||||
export async function runHostSync(
|
||||
context: LauncherCommandContext,
|
||||
dbPath: string,
|
||||
inputDeps: Partial<SyncCommandDeps> = {},
|
||||
): Promise<void> {
|
||||
const deps = resolveSyncCommandDeps(inputDeps);
|
||||
const { args } = context;
|
||||
const host = args.syncHost;
|
||||
deps.assertSafeSshHost(host);
|
||||
|
||||
await deps.ensureTrackerQuiescent(context, dbPath);
|
||||
|
||||
const remoteCmd = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null);
|
||||
deps.log('debug', args.logLevel, `Remote subminer command: ${remoteCmd}`);
|
||||
|
||||
const localTmpDir = deps.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-'));
|
||||
let remoteTmpDir = '';
|
||||
try {
|
||||
// Signal failures by throwing (not fail(), which exits synchronously and
|
||||
// would skip the finally cleanup, leaking temp dirs holding snapshot data).
|
||||
// main().catch() reports the message the same way fail() would.
|
||||
const mktemp = deps.runSsh(host, 'mktemp -d /tmp/subminer-sync.XXXXXX');
|
||||
remoteTmpDir = mktemp.stdout.trim();
|
||||
if (mktemp.status !== 0 || !remoteTmpDir.startsWith('/tmp/')) {
|
||||
throw new Error(`Could not create a temporary directory on ${host}.`);
|
||||
}
|
||||
|
||||
const forceFlag = args.syncForce ? ' --force' : '';
|
||||
|
||||
deps.consoleLog(`Snapshotting local database (${dbPath})...`);
|
||||
const localSnapshot = path.join(localTmpDir, 'local.sqlite');
|
||||
deps.createDbSnapshot(dbPath, localSnapshot);
|
||||
|
||||
deps.consoleLog(`Snapshotting ${host}...`);
|
||||
const remoteSnapshot = `${remoteTmpDir}/snapshot.sqlite`;
|
||||
const snapshotRun = deps.runSsh(
|
||||
host,
|
||||
`${remoteCmd} sync --snapshot ${shellQuote(remoteSnapshot)}${forceFlag}`,
|
||||
);
|
||||
if (snapshotRun.status !== 0) {
|
||||
throw new Error(formatRemoteRunError(`Remote snapshot failed on ${host}.`, snapshotRun));
|
||||
}
|
||||
|
||||
const pulledSnapshot = path.join(localTmpDir, 'remote.sqlite');
|
||||
deps.runScp(`${host}:${remoteSnapshot}`, pulledSnapshot);
|
||||
const incomingSnapshot = `${remoteTmpDir}/incoming.sqlite`;
|
||||
deps.runScp(localSnapshot, `${host}:${incomingSnapshot}`);
|
||||
|
||||
deps.consoleLog(`\nMerging ${host} -> local:`);
|
||||
await deps.ensureTrackerQuiescent(context, dbPath);
|
||||
const summary = deps.mergeSnapshotIntoDb(dbPath, pulledSnapshot);
|
||||
deps.consoleLog(deps.formatMergeSummary(summary));
|
||||
|
||||
deps.consoleLog(`\nMerging local -> ${host}:`);
|
||||
await deps.ensureTrackerQuiescent(context, dbPath);
|
||||
const mergeRun = deps.runSsh(
|
||||
host,
|
||||
`${remoteCmd} sync --merge ${shellQuote(incomingSnapshot)}${forceFlag}`,
|
||||
);
|
||||
deps.writeStdout(mergeRun.stdout);
|
||||
if (mergeRun.status !== 0) {
|
||||
throw new Error(
|
||||
formatRemoteRunError(
|
||||
`Remote merge failed on ${host}. The local database was updated; re-run "subminer sync ${host}" once the remote issue is fixed.`,
|
||||
mergeRun,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
deps.consoleLog('\nSync complete.');
|
||||
} finally {
|
||||
deps.rmSync(localTmpDir, { recursive: true, force: true });
|
||||
if (remoteTmpDir) {
|
||||
try {
|
||||
cleanupRemote(host, remoteTmpDir, deps);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSyncCommand(
|
||||
context: LauncherCommandContext,
|
||||
inputDeps: Partial<SyncCommandDeps> = {},
|
||||
): Promise<boolean> {
|
||||
const deps = resolveSyncCommandDeps(inputDeps);
|
||||
const { args } = context;
|
||||
if (!args.sync) return false;
|
||||
|
||||
const dbPath = resolveDbPath(context);
|
||||
if (args.syncSnapshotPath) {
|
||||
runSnapshotMode(context, dbPath, deps);
|
||||
} else if (args.syncMergePath) {
|
||||
await runMergeMode(context, dbPath, deps);
|
||||
} else if (args.syncHost) {
|
||||
await runHostSync(context, dbPath, deps);
|
||||
} else {
|
||||
deps.fail('sync requires a host, --snapshot <file>, or --merge <file>.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -34,7 +34,10 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
|
||||
return {
|
||||
appImage: { status: 'updated' },
|
||||
launcher: { status: 'updated' },
|
||||
supportAssets: [{ status: 'skipped' }],
|
||||
supportAssets: [
|
||||
{ status: 'updated', component: 'theme', message: 'Installed theme.' },
|
||||
{ status: 'skipped', component: 'plugin', message: 'Plugin already up to date.' },
|
||||
],
|
||||
};
|
||||
},
|
||||
readMainConfig: () => ({ updates: { channel: 'prerelease' } }),
|
||||
@@ -48,7 +51,8 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
|
||||
'direct:/home/kyle/.local/bin/SubMiner.AppImage:/home/kyle/.local/bin/subminer:prerelease',
|
||||
'info:AppImage update: updated',
|
||||
'info:Launcher update: updated',
|
||||
'info:Rofi theme update: skipped',
|
||||
'info:Support assets (theme) update: updated - Installed theme.',
|
||||
'info:Support assets (plugin) update: skipped - Plugin already up to date.',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -101,7 +105,7 @@ test('runUpdateCommand skips Linux asset replacement when release is not newer',
|
||||
'fetch:https://api.github.com/repos/ksyasuda/SubMiner/releases',
|
||||
'info:AppImage update: up to date',
|
||||
'info:Launcher update: up to date',
|
||||
'info:Rofi theme update: up to date',
|
||||
'info:Support assets update: up to date',
|
||||
]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
@@ -39,7 +39,12 @@ type DirectReleaseUpdateRequest = {
|
||||
type DirectReleaseUpdateResult = {
|
||||
appImage: { status: string; command?: string; message?: string };
|
||||
launcher: { status: string; command?: string; message?: string };
|
||||
supportAssets: Array<{ status: string; command?: string; message?: string }>;
|
||||
supportAssets: Array<{
|
||||
status: string;
|
||||
component?: 'theme' | 'plugin';
|
||||
command?: string;
|
||||
message?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type UpdateCommandDeps = {
|
||||
@@ -124,20 +129,29 @@ function readUpdateChannel(root: Record<string, unknown> | null): UpdateChannel
|
||||
|
||||
function logUpdateResult(
|
||||
label: string,
|
||||
result: { status: string; command?: string; message?: string },
|
||||
result: {
|
||||
status: string;
|
||||
component?: 'theme' | 'plugin';
|
||||
command?: string;
|
||||
message?: string;
|
||||
},
|
||||
configuredLogLevel: NonNullable<LauncherCommandContext['args']['logLevel']>,
|
||||
deps: Pick<UpdateCommandDeps, 'log'>,
|
||||
): void {
|
||||
const displayStatus = result.status === 'up-to-date' ? 'up to date' : result.status;
|
||||
deps.log('info', configuredLogLevel, `${label} update: ${displayStatus}`);
|
||||
const componentLabel = result.component ? ` (${result.component})` : '';
|
||||
const detailSuffix = result.message ? ` - ${result.message}` : '';
|
||||
deps.log(
|
||||
'info',
|
||||
configuredLogLevel,
|
||||
`${label}${componentLabel} update: ${displayStatus}${detailSuffix}`,
|
||||
);
|
||||
if (result.command) {
|
||||
deps.log(
|
||||
'warn',
|
||||
configuredLogLevel,
|
||||
`${label} update requires manual command: ${result.command}`,
|
||||
`${label}${componentLabel} update requires manual command: ${result.command}`,
|
||||
);
|
||||
} else if (result.message) {
|
||||
deps.log('warn', configuredLogLevel, `${label} update note: ${result.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +201,7 @@ export async function runUpdateCommand(
|
||||
logUpdateResult('AppImage', result.appImage, logLevel, resolvedDeps);
|
||||
logUpdateResult('Launcher', result.launcher, logLevel, resolvedDeps);
|
||||
for (const supportResult of result.supportAssets) {
|
||||
logUpdateResult('Rofi theme', supportResult, logLevel, resolvedDeps);
|
||||
logUpdateResult('Support assets', supportResult, logLevel, resolvedDeps);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -135,6 +135,14 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncHost: null,
|
||||
syncSnapshotPath: null,
|
||||
syncMergePath: null,
|
||||
syncRemoteCmd: null,
|
||||
syncDbPath: null,
|
||||
syncForce: false,
|
||||
syncLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
@@ -181,6 +189,14 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncHost: null,
|
||||
syncSnapshotPath: null,
|
||||
syncMergePath: null,
|
||||
syncRemoteCmd: null,
|
||||
syncDbPath: null,
|
||||
syncForce: false,
|
||||
syncLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
@@ -220,6 +236,14 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncHost: null,
|
||||
syncSnapshotPath: null,
|
||||
syncMergePath: null,
|
||||
syncRemoteCmd: null,
|
||||
syncDbPath: null,
|
||||
syncForce: false,
|
||||
syncLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
@@ -257,6 +281,14 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncHost: null,
|
||||
syncSnapshotPath: null,
|
||||
syncMergePath: null,
|
||||
syncRemoteCmd: null,
|
||||
syncDbPath: null,
|
||||
syncForce: false,
|
||||
syncLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
|
||||
@@ -198,6 +198,14 @@ export function createDefaultArgs(
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
history: false,
|
||||
sync: false,
|
||||
syncHost: '',
|
||||
syncSnapshotPath: '',
|
||||
syncMergePath: '',
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
logLevel: loggingConfig.level ?? 'warn',
|
||||
logRotation: loggingConfig.rotation ?? 7,
|
||||
passwordStore: '',
|
||||
@@ -231,6 +239,7 @@ export function applyRootOptionsToArgs(
|
||||
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
|
||||
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
|
||||
if (options.rofi === true) parsed.useRofi = true;
|
||||
if (options.history === true) parsed.history = true;
|
||||
if (options.update === true) parsed.update = true;
|
||||
if (options.version === true) parsed.version = true;
|
||||
if (options.settings === true) parsed.settings = true;
|
||||
@@ -262,6 +271,16 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
||||
) {
|
||||
fail('Dictionary target path is required.');
|
||||
}
|
||||
if (invocations.syncTriggered) {
|
||||
parsed.sync = true;
|
||||
parsed.syncHost = invocations.syncHost ?? '';
|
||||
parsed.syncSnapshotPath = invocations.syncSnapshotPath ?? '';
|
||||
parsed.syncMergePath = invocations.syncMergePath ?? '';
|
||||
parsed.syncRemoteCmd = invocations.syncRemoteCmd ?? '';
|
||||
parsed.syncDbPath = invocations.syncDbPath ?? '';
|
||||
parsed.syncForce = invocations.syncForce;
|
||||
if (invocations.syncLogLevel) parsed.logLevel = parseLogLevel(invocations.syncLogLevel);
|
||||
}
|
||||
if (invocations.doctorTriggered) parsed.doctor = true;
|
||||
if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true;
|
||||
if (invocations.logsTriggered && !invocations.logsExport) {
|
||||
|
||||
@@ -38,6 +38,14 @@ export interface CliInvocations {
|
||||
statsCleanupVocab: boolean;
|
||||
statsCleanupLifetime: boolean;
|
||||
statsLogLevel: string | null;
|
||||
syncTriggered: boolean;
|
||||
syncHost: string | null;
|
||||
syncSnapshotPath: string | null;
|
||||
syncMergePath: string | null;
|
||||
syncRemoteCmd: string | null;
|
||||
syncDbPath: string | null;
|
||||
syncForce: boolean;
|
||||
syncLogLevel: string | null;
|
||||
doctorTriggered: boolean;
|
||||
doctorLogLevel: string | null;
|
||||
doctorRefreshKnownWords: boolean;
|
||||
@@ -64,6 +72,7 @@ function applyRootOptions(program: Command): void {
|
||||
.option('--settings', 'Open settings window')
|
||||
.option('-u, --update', 'Check for updates')
|
||||
.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');
|
||||
}
|
||||
@@ -97,6 +106,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
|
||||
'dictionary',
|
||||
'dict',
|
||||
'stats',
|
||||
'sync',
|
||||
'texthooker',
|
||||
'app',
|
||||
'bin',
|
||||
@@ -159,6 +169,14 @@ export function parseCliPrograms(
|
||||
let statsCleanupVocab = false;
|
||||
let statsCleanupLifetime = false;
|
||||
let statsLogLevel: string | null = null;
|
||||
let syncTriggered = false;
|
||||
let syncHost: string | null = null;
|
||||
let syncSnapshotPath: string | null = null;
|
||||
let syncMergePath: string | null = null;
|
||||
let syncRemoteCmd: string | null = null;
|
||||
let syncDbPath: string | null = null;
|
||||
let syncForce = false;
|
||||
let syncLogLevel: string | null = null;
|
||||
let doctorLogLevel: string | null = null;
|
||||
let doctorRefreshKnownWords = false;
|
||||
let logsTriggered = false;
|
||||
@@ -288,6 +306,37 @@ export function parseCliPrograms(
|
||||
statsLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('sync')
|
||||
.description('Sync stats and watch history with another machine over SSH')
|
||||
.argument('[host]', 'SSH destination (user@host or an ssh config alias)')
|
||||
.option('--snapshot <file>', 'Write a consistent snapshot of the local stats database')
|
||||
.option('--merge <file>', 'Merge a snapshot database file into the local stats database')
|
||||
.option('--db <file>', 'Override the local stats database path')
|
||||
.option('--remote-cmd <cmd>', 'subminer command to run on the remote host')
|
||||
.option('-f, --force', 'Skip the running-app safety check')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((rawHost: string | undefined, options: Record<string, unknown>) => {
|
||||
const host = typeof rawHost === 'string' ? rawHost.trim() : '';
|
||||
const snapshot = typeof options.snapshot === 'string' ? options.snapshot.trim() : '';
|
||||
const merge = typeof options.merge === 'string' ? options.merge.trim() : '';
|
||||
const modes = [Boolean(host), Boolean(snapshot), Boolean(merge)].filter(Boolean).length;
|
||||
if (modes === 0) {
|
||||
throw new Error('Sync requires a host, --snapshot <file>, or --merge <file>.');
|
||||
}
|
||||
if (modes > 1) {
|
||||
throw new Error('Sync host, --snapshot, and --merge cannot be combined.');
|
||||
}
|
||||
syncTriggered = true;
|
||||
syncHost = host || null;
|
||||
syncSnapshotPath = snapshot || null;
|
||||
syncMergePath = merge || null;
|
||||
syncRemoteCmd = typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() || null : null;
|
||||
syncDbPath = typeof options.db === 'string' ? options.db.trim() || null : null;
|
||||
syncForce = options.force === true;
|
||||
syncLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('doctor')
|
||||
.description('Run dependency and environment checks')
|
||||
@@ -399,6 +448,14 @@ export function parseCliPrograms(
|
||||
statsCleanupVocab,
|
||||
statsCleanupLifetime,
|
||||
statsLogLevel,
|
||||
syncTriggered,
|
||||
syncHost,
|
||||
syncSnapshotPath,
|
||||
syncMergePath,
|
||||
syncRemoteCmd,
|
||||
syncDbPath,
|
||||
syncForce,
|
||||
syncLogLevel,
|
||||
doctorTriggered,
|
||||
doctorLogLevel,
|
||||
doctorRefreshKnownWords,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { withReadonlyWalRetry } from './history-db.js';
|
||||
|
||||
const COVER_EXTENSIONS = ['.jpg', '.png', '.webp', '.gif'] as const;
|
||||
const SAFE_COVER_HASH_PATTERN = /^[a-z0-9_-]+$/i;
|
||||
|
||||
export function getDefaultCoverCacheDir(): string {
|
||||
return path.join(os.homedir(), '.cache', 'subminer', 'covers');
|
||||
}
|
||||
|
||||
export function detectImageExtension(blob: Buffer): string {
|
||||
if (blob.length >= 8 && blob.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) {
|
||||
return '.png';
|
||||
}
|
||||
if (blob.length >= 3 && blob[0] === 0xff && blob[1] === 0xd8 && blob[2] === 0xff) {
|
||||
return '.jpg';
|
||||
}
|
||||
if (
|
||||
blob.length >= 12 &&
|
||||
blob.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
blob.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return '.webp';
|
||||
}
|
||||
if (blob.length >= 4 && blob.subarray(0, 3).toString('ascii') === 'GIF') {
|
||||
return '.gif';
|
||||
}
|
||||
return '.jpg';
|
||||
}
|
||||
|
||||
function findCachedCover(cacheDir: string, hash: string): string | null {
|
||||
for (const ext of COVER_EXTENSIONS) {
|
||||
const candidate = path.join(cacheDir, `${hash}${ext}`);
|
||||
try {
|
||||
if (fs.statSync(candidate).size > 0) return candidate;
|
||||
} catch {
|
||||
// not cached with this extension
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function queryCoverBlobs(
|
||||
dbPath: string,
|
||||
hashes: string[],
|
||||
options: { readonly?: boolean; readwrite?: boolean; create?: boolean },
|
||||
): Map<string, Buffer> {
|
||||
const blobs = new Map<string, Buffer>();
|
||||
const db = new Database(dbPath, options);
|
||||
try {
|
||||
const hasBlobTable = db
|
||||
.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'imm_cover_art_blobs'`)
|
||||
.get();
|
||||
if (!hasBlobTable) return blobs;
|
||||
|
||||
const stmt = db.query<{ cover_blob: Uint8Array | null }>(
|
||||
'SELECT cover_blob FROM imm_cover_art_blobs WHERE blob_hash = ?',
|
||||
);
|
||||
for (const hash of hashes) {
|
||||
const row = stmt.get(hash);
|
||||
if (row?.cover_blob && row.cover_blob.length > 0) {
|
||||
blobs.set(hash, Buffer.from(row.cover_blob));
|
||||
}
|
||||
}
|
||||
return blobs;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeCoverHash(hash: string | null | undefined): hash is string {
|
||||
return typeof hash === 'string' && SAFE_COVER_HASH_PATTERN.test(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures cover art blobs referenced by hash exist as image files in the cache
|
||||
* directory, extracting missing ones from the stats database. Returns a map of
|
||||
* blob hash to on-disk image path for every cover that could be materialized.
|
||||
*/
|
||||
export function materializeCoverArt(
|
||||
dbPath: string,
|
||||
hashes: Array<string | null | undefined>,
|
||||
cacheDir: string = getDefaultCoverCacheDir(),
|
||||
): Map<string, string> {
|
||||
const wanted = Array.from(new Set(hashes.filter(isSafeCoverHash)));
|
||||
const resolved = new Map<string, string>();
|
||||
if (wanted.length === 0) return resolved;
|
||||
|
||||
const missing: string[] = [];
|
||||
for (const hash of wanted) {
|
||||
const cached = findCachedCover(cacheDir, hash);
|
||||
if (cached) {
|
||||
resolved.set(hash, cached);
|
||||
} else {
|
||||
missing.push(hash);
|
||||
}
|
||||
}
|
||||
if (missing.length === 0) return resolved;
|
||||
|
||||
let blobs: Map<string, Buffer>;
|
||||
try {
|
||||
blobs = withReadonlyWalRetry(dbPath, (options) => queryCoverBlobs(dbPath, missing, options));
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
if (blobs.size === 0) return resolved;
|
||||
|
||||
try {
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
} catch {
|
||||
return resolved;
|
||||
}
|
||||
for (const [hash, blob] of blobs) {
|
||||
const target = path.join(cacheDir, `${hash}${detectImageExtension(blob)}`);
|
||||
try {
|
||||
fs.writeFileSync(target, blob);
|
||||
resolved.set(hash, target);
|
||||
} catch {
|
||||
// cache write failure just means no icon for this entry
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { resolveConfigDir } from '../src/config/path-resolution.js';
|
||||
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
|
||||
import type { HistoryVideoRow } from './history-types.js';
|
||||
import { resolvePathMaybe } from './util.js';
|
||||
|
||||
export function resolveImmersionDbPath(): string {
|
||||
const root = readLauncherMainConfigObject();
|
||||
const tracking =
|
||||
root?.immersionTracking &&
|
||||
typeof root.immersionTracking === 'object' &&
|
||||
!Array.isArray(root.immersionTracking)
|
||||
? (root.immersionTracking as Record<string, unknown>)
|
||||
: null;
|
||||
const configured = typeof tracking?.dbPath === 'string' ? tracking.dbPath.trim() : '';
|
||||
if (configured) return resolvePathMaybe(configured);
|
||||
|
||||
const configDir = resolveConfigDir({
|
||||
platform: process.platform,
|
||||
appDataDir: process.env.APPDATA,
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
existsSync: fs.existsSync,
|
||||
});
|
||||
return path.join(configDir, 'immersion.sqlite');
|
||||
}
|
||||
|
||||
interface RawHistoryRow {
|
||||
video_id: number;
|
||||
source_path: string | null;
|
||||
parsed_title: string | null;
|
||||
parsed_season: number | null;
|
||||
parsed_episode: number | null;
|
||||
anime_title: string | null;
|
||||
last_watched_ms: number | bigint | null;
|
||||
cover_blob_hash: string | null;
|
||||
}
|
||||
|
||||
export function queryLocalWatchHistory(dbPath: string): HistoryVideoRow[] {
|
||||
return withReadonlyWalRetry(dbPath, (options) => readHistoryRows(dbPath, options));
|
||||
}
|
||||
|
||||
export function withReadonlyWalRetry<T>(
|
||||
dbPath: string,
|
||||
query: (options: { readonly?: boolean; readwrite?: boolean; create?: boolean }) => T,
|
||||
): T {
|
||||
try {
|
||||
return query({ readonly: true });
|
||||
} catch (error) {
|
||||
if (!isReadonlyWalRetryError(error, dbPath)) throw error;
|
||||
return query({ readwrite: true, create: false });
|
||||
}
|
||||
}
|
||||
|
||||
export function isReadonlyWalRetryError(error: unknown, dbPath: string): boolean {
|
||||
if (!isWalModeSqliteDatabase(dbPath)) return false;
|
||||
const code =
|
||||
typeof error === 'object' && error !== null && 'code' in error
|
||||
? String((error as { code?: unknown }).code ?? '')
|
||||
: '';
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const text = `${code} ${message}`.toLowerCase();
|
||||
return (
|
||||
text.includes('readonly') ||
|
||||
text.includes('read-only') ||
|
||||
text.includes('attempt to write a readonly database') ||
|
||||
text.includes('sqlite_cantopen') ||
|
||||
text.includes('unable to open database file')
|
||||
);
|
||||
}
|
||||
|
||||
function isWalModeSqliteDatabase(dbPath: string): boolean {
|
||||
const header = Buffer.alloc(20);
|
||||
let fd: number | null = null;
|
||||
try {
|
||||
fd = fs.openSync(dbPath, 'r');
|
||||
if (fs.readSync(fd, header, 0, header.length, 0) < header.length) return false;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
if (fd !== null) fs.closeSync(fd);
|
||||
}
|
||||
return header.subarray(0, 16).toString('ascii') === 'SQLite format 3\0' && header[18] === 2;
|
||||
}
|
||||
|
||||
function tableExists(db: Database, tableName: string): boolean {
|
||||
return Boolean(
|
||||
db.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(tableName),
|
||||
);
|
||||
}
|
||||
|
||||
function readHistoryRows(
|
||||
dbPath: string,
|
||||
options: { readonly?: boolean; readwrite?: boolean; create?: boolean },
|
||||
): HistoryVideoRow[] {
|
||||
const db = new Database(dbPath, options);
|
||||
try {
|
||||
const hasMediaArt = tableExists(db, 'imm_media_art');
|
||||
const coverSelect = hasMediaArt
|
||||
? `COALESCE(
|
||||
ma.cover_blob_hash,
|
||||
(SELECT ma2.cover_blob_hash
|
||||
FROM imm_media_art ma2
|
||||
JOIN imm_videos v2 ON v2.video_id = ma2.video_id
|
||||
WHERE v2.anime_id = v.anime_id AND ma2.cover_blob_hash IS NOT NULL
|
||||
LIMIT 1)
|
||||
) AS cover_blob_hash`
|
||||
: 'NULL AS cover_blob_hash';
|
||||
const coverJoin = hasMediaArt ? 'LEFT JOIN imm_media_art ma ON ma.video_id = v.video_id' : '';
|
||||
const rows = db
|
||||
.query<RawHistoryRow>(
|
||||
`
|
||||
SELECT
|
||||
v.video_id,
|
||||
v.source_path,
|
||||
v.parsed_title,
|
||||
v.parsed_season,
|
||||
v.parsed_episode,
|
||||
COALESCE(a.title_romaji, a.canonical_title) AS anime_title,
|
||||
MAX(CAST(s.started_at_ms AS INTEGER)) AS last_watched_ms,
|
||||
${coverSelect}
|
||||
FROM imm_sessions s
|
||||
JOIN imm_videos v ON v.video_id = s.video_id
|
||||
LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||
${coverJoin}
|
||||
WHERE v.source_type = 1 AND v.source_path IS NOT NULL AND v.source_path != ''
|
||||
GROUP BY v.video_id
|
||||
ORDER BY last_watched_ms DESC
|
||||
`,
|
||||
)
|
||||
.all();
|
||||
|
||||
return rows
|
||||
.filter((row) => typeof row.source_path === 'string' && row.source_path.length > 0)
|
||||
.map((row) => ({
|
||||
videoId: row.video_id,
|
||||
sourcePath: row.source_path!,
|
||||
parsedTitle: row.parsed_title,
|
||||
parsedSeason: row.parsed_season,
|
||||
parsedEpisode: row.parsed_episode,
|
||||
animeTitle: row.anime_title,
|
||||
lastWatchedMs: Number(row.last_watched_ms ?? 0),
|
||||
coverBlobHash: row.cover_blob_hash,
|
||||
}));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { parseMediaInfo } from '../src/jimaku/utils.js';
|
||||
import { collectVideos } from './picker.js';
|
||||
import type { HistorySeriesEntry, HistoryVideoRow, SeasonDirEntry } from './history-types.js';
|
||||
|
||||
const SEASON_DIR_PATTERN = /^(?:season|s)[\s._-]*(\d{1,3})\b/i;
|
||||
|
||||
export function seasonNumberFromDirName(name: string): number | null {
|
||||
const match = name.trim().match(SEASON_DIR_PATTERN);
|
||||
if (!match) return null;
|
||||
const parsed = Number.parseInt(match[1]!, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function resolveSeriesRoot(filePath: string): string {
|
||||
const parent = path.dirname(filePath);
|
||||
if (seasonNumberFromDirName(path.basename(parent)) !== null) {
|
||||
return path.dirname(parent);
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
export function groupHistoryBySeries(
|
||||
rows: HistoryVideoRow[],
|
||||
existsFn: (candidate: string) => boolean = fs.existsSync,
|
||||
): HistorySeriesEntry[] {
|
||||
const byRoot = new Map<string, HistorySeriesEntry>();
|
||||
const sorted = [...rows].sort((a, b) => b.lastWatchedMs - a.lastWatchedMs);
|
||||
|
||||
for (const row of sorted) {
|
||||
const seriesRoot = resolveSeriesRoot(row.sourcePath);
|
||||
const existing = byRoot.get(seriesRoot);
|
||||
if (existing) {
|
||||
if (existing.coverBlobHash === null && row.coverBlobHash !== null) {
|
||||
existing.coverBlobHash = row.coverBlobHash;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!existsFn(seriesRoot)) continue;
|
||||
const displayName =
|
||||
row.parsedTitle?.trim() || row.animeTitle?.trim() || path.basename(seriesRoot);
|
||||
byRoot.set(seriesRoot, {
|
||||
seriesRoot,
|
||||
displayName,
|
||||
lastWatched: row,
|
||||
coverBlobHash: row.coverBlobHash,
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(byRoot.values());
|
||||
}
|
||||
|
||||
function compareNatural(a: string, b: string): number {
|
||||
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
export function sortVideosByEpisode(videos: string[]): string[] {
|
||||
const parsed = videos.map((video) => ({ video, info: parseMediaInfo(video) }));
|
||||
parsed.sort((a, b) => {
|
||||
if (a.info.episode !== null && b.info.episode !== null) {
|
||||
const seasonA = a.info.season ?? 0;
|
||||
const seasonB = b.info.season ?? 0;
|
||||
if (seasonA !== seasonB) return seasonA - seasonB;
|
||||
if (a.info.episode !== b.info.episode) return a.info.episode - b.info.episode;
|
||||
}
|
||||
return compareNatural(a.video, b.video);
|
||||
});
|
||||
return parsed.map((entry) => entry.video);
|
||||
}
|
||||
|
||||
function dirContainsVideo(dir: string): boolean {
|
||||
return collectVideos(dir, true).length > 0;
|
||||
}
|
||||
|
||||
export function listSeasonDirs(seriesRoot: string): SeasonDirEntry[] {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(seriesRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const dirs = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: path.join(seriesRoot, entry.name),
|
||||
season: seasonNumberFromDirName(entry.name),
|
||||
}))
|
||||
.filter((entry) => dirContainsVideo(entry.path));
|
||||
|
||||
dirs.sort((a, b) => {
|
||||
if (a.season !== null && b.season !== null && a.season !== b.season) {
|
||||
return a.season - b.season;
|
||||
}
|
||||
return compareNatural(a.name, b.name);
|
||||
});
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function findFirstEpisodeInNextSeason(resolvedLast: string, dir: string): string | null {
|
||||
const seriesRoot = resolveSeriesRoot(resolvedLast);
|
||||
if (seriesRoot === dir) return null;
|
||||
const seasons = listSeasonDirs(seriesRoot);
|
||||
const currentIdx = seasons.findIndex((season) => path.resolve(season.path) === dir);
|
||||
if (currentIdx < 0 || currentIdx + 1 >= seasons.length) return null;
|
||||
const nextSeason = sortVideosByEpisode(collectVideos(seasons[currentIdx + 1]!.path, false));
|
||||
return nextSeason[0] ?? null;
|
||||
}
|
||||
|
||||
export function findNextEpisode(lastPath: string): string | null {
|
||||
const resolvedLast = path.resolve(lastPath);
|
||||
const dir = path.dirname(resolvedLast);
|
||||
const episodes = sortVideosByEpisode(collectVideos(dir, false));
|
||||
const idx = episodes.indexOf(resolvedLast);
|
||||
|
||||
if (idx >= 0) {
|
||||
if (idx + 1 < episodes.length) return episodes[idx + 1]!;
|
||||
} else {
|
||||
const lastInfo = parseMediaInfo(resolvedLast);
|
||||
if (lastInfo.episode !== null) {
|
||||
const candidate = episodes.find((episode) => {
|
||||
const info = parseMediaInfo(episode);
|
||||
return info.episode !== null && info.episode > lastInfo.episode!;
|
||||
});
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return findFirstEpisodeInNextSeason(resolvedLast, dir);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface HistoryVideoRow {
|
||||
videoId: number;
|
||||
sourcePath: string;
|
||||
parsedTitle: string | null;
|
||||
parsedSeason: number | null;
|
||||
parsedEpisode: number | null;
|
||||
animeTitle: string | null;
|
||||
lastWatchedMs: number;
|
||||
coverBlobHash: string | null;
|
||||
}
|
||||
|
||||
export interface HistorySeriesEntry {
|
||||
seriesRoot: string;
|
||||
displayName: string;
|
||||
lastWatched: HistoryVideoRow;
|
||||
coverBlobHash: string | null;
|
||||
}
|
||||
|
||||
export interface SeasonDirEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
season: number | null;
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
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 { Database } from 'bun:sqlite';
|
||||
import {
|
||||
detectImageExtension,
|
||||
findNextEpisode,
|
||||
groupHistoryBySeries,
|
||||
isReadonlyWalRetryError,
|
||||
listSeasonDirs,
|
||||
materializeCoverArt,
|
||||
queryLocalWatchHistory,
|
||||
resolveSeriesRoot,
|
||||
seasonNumberFromDirName,
|
||||
sortVideosByEpisode,
|
||||
type HistoryVideoRow,
|
||||
} from './history.js';
|
||||
|
||||
function makeRow(overrides: Partial<HistoryVideoRow> = {}): HistoryVideoRow {
|
||||
return {
|
||||
videoId: 1,
|
||||
sourcePath: '/media/anime/Show/Season-1/Show - S01E01.mkv',
|
||||
parsedTitle: 'Show',
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: 1,
|
||||
animeTitle: null,
|
||||
lastWatchedMs: 1000,
|
||||
coverBlobHash: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('seasonNumberFromDirName detects common season directory names', () => {
|
||||
assert.equal(seasonNumberFromDirName('Season-1'), 1);
|
||||
assert.equal(seasonNumberFromDirName('Season 2'), 2);
|
||||
assert.equal(seasonNumberFromDirName('S03'), 3);
|
||||
assert.equal(seasonNumberFromDirName('season_04'), 4);
|
||||
assert.equal(seasonNumberFromDirName('Specials'), null);
|
||||
assert.equal(seasonNumberFromDirName('Show Name'), null);
|
||||
});
|
||||
|
||||
test('resolveSeriesRoot skips season directories', () => {
|
||||
assert.equal(
|
||||
resolveSeriesRoot('/media/anime/Show/Season-1/Show - S01E01.mkv'),
|
||||
'/media/anime/Show',
|
||||
);
|
||||
assert.equal(resolveSeriesRoot('/media/anime/Show/Show - 01.mkv'), '/media/anime/Show');
|
||||
});
|
||||
|
||||
test('groupHistoryBySeries keeps most recent entry per series root', () => {
|
||||
const rows = [
|
||||
makeRow({ videoId: 1, parsedEpisode: 1, lastWatchedMs: 1000 }),
|
||||
makeRow({
|
||||
videoId: 2,
|
||||
sourcePath: '/media/anime/Show/Season-1/Show - S01E02.mkv',
|
||||
parsedEpisode: 2,
|
||||
lastWatchedMs: 3000,
|
||||
}),
|
||||
makeRow({
|
||||
videoId: 3,
|
||||
sourcePath: '/media/anime/Other/Other - 05.mkv',
|
||||
parsedTitle: 'Other',
|
||||
parsedSeason: null,
|
||||
parsedEpisode: 5,
|
||||
lastWatchedMs: 2000,
|
||||
}),
|
||||
];
|
||||
|
||||
const series = groupHistoryBySeries(rows, () => true);
|
||||
|
||||
assert.equal(series.length, 2);
|
||||
assert.equal(series[0]?.displayName, 'Show');
|
||||
assert.equal(series[0]?.seriesRoot, '/media/anime/Show');
|
||||
assert.equal(series[0]?.lastWatched.parsedEpisode, 2);
|
||||
assert.equal(series[1]?.displayName, 'Other');
|
||||
});
|
||||
|
||||
test('groupHistoryBySeries filters series roots that no longer exist', () => {
|
||||
const rows = [
|
||||
makeRow({ videoId: 1 }),
|
||||
makeRow({
|
||||
videoId: 2,
|
||||
sourcePath: '/gone/anime/Missing/Season-1/Missing - S01E01.mkv',
|
||||
parsedTitle: 'Missing',
|
||||
lastWatchedMs: 5000,
|
||||
}),
|
||||
];
|
||||
|
||||
const series = groupHistoryBySeries(rows, (candidate) => !candidate.startsWith('/gone/'));
|
||||
|
||||
assert.equal(series.length, 1);
|
||||
assert.equal(series[0]?.displayName, 'Show');
|
||||
});
|
||||
|
||||
test('groupHistoryBySeries falls back to directory name for display', () => {
|
||||
const rows = [
|
||||
makeRow({
|
||||
sourcePath: '/media/anime/Some Show Dir/video.mkv',
|
||||
parsedTitle: null,
|
||||
animeTitle: null,
|
||||
}),
|
||||
];
|
||||
|
||||
const series = groupHistoryBySeries(rows, () => true);
|
||||
|
||||
assert.equal(series[0]?.displayName, 'Some Show Dir');
|
||||
});
|
||||
|
||||
test('sortVideosByEpisode orders by parsed episode with natural fallback', () => {
|
||||
const videos = [
|
||||
'/media/Show/Show - S01E10 - Ten.mkv',
|
||||
'/media/Show/Show - S01E02 - Two.mkv',
|
||||
'/media/Show/Show - S01E01 - One.mkv',
|
||||
];
|
||||
|
||||
assert.deepEqual(sortVideosByEpisode(videos), [
|
||||
'/media/Show/Show - S01E01 - One.mkv',
|
||||
'/media/Show/Show - S01E02 - Two.mkv',
|
||||
'/media/Show/Show - S01E10 - Ten.mkv',
|
||||
]);
|
||||
});
|
||||
|
||||
function createSeriesTree(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-'));
|
||||
const seriesRoot = path.join(root, 'Show');
|
||||
const season1 = path.join(seriesRoot, 'Season-1');
|
||||
const season2 = path.join(seriesRoot, 'Season-2');
|
||||
fs.mkdirSync(season1, { recursive: true });
|
||||
fs.mkdirSync(season2, { recursive: true });
|
||||
fs.mkdirSync(path.join(seriesRoot, 'extras-empty'), { recursive: true });
|
||||
for (const name of ['Show - S01E01.mkv', 'Show - S01E02.mkv', 'Show - S01E03.mkv']) {
|
||||
fs.writeFileSync(path.join(season1, name), '');
|
||||
}
|
||||
fs.writeFileSync(path.join(season2, 'Show - S02E01.mkv'), '');
|
||||
fs.writeFileSync(path.join(season1, 'notes.txt'), '');
|
||||
return seriesRoot;
|
||||
}
|
||||
|
||||
test('listSeasonDirs returns only video-bearing directories in season order', () => {
|
||||
const seriesRoot = createSeriesTree();
|
||||
try {
|
||||
const seasons = listSeasonDirs(seriesRoot);
|
||||
assert.deepEqual(
|
||||
seasons.map((entry) => entry.name),
|
||||
['Season-1', 'Season-2'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
seasons.map((entry) => entry.season),
|
||||
[1, 2],
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findNextEpisode advances within a season and across seasons', () => {
|
||||
const seriesRoot = createSeriesTree();
|
||||
try {
|
||||
const season1 = path.join(seriesRoot, 'Season-1');
|
||||
const season2 = path.join(seriesRoot, 'Season-2');
|
||||
|
||||
assert.equal(
|
||||
findNextEpisode(path.join(season1, 'Show - S01E02.mkv')),
|
||||
path.join(season1, 'Show - S01E03.mkv'),
|
||||
);
|
||||
assert.equal(
|
||||
findNextEpisode(path.join(season1, 'Show - S01E03.mkv')),
|
||||
path.join(season2, 'Show - S02E01.mkv'),
|
||||
);
|
||||
assert.equal(findNextEpisode(path.join(season2, 'Show - S02E01.mkv')), null);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findNextEpisode falls back to episode numbers when file was removed', () => {
|
||||
const seriesRoot = createSeriesTree();
|
||||
try {
|
||||
const season1 = path.join(seriesRoot, 'Season-1');
|
||||
const missing = path.join(season1, 'Show - S01E02 - Deleted Cut.mkv');
|
||||
assert.equal(findNextEpisode(missing), path.join(season1, 'Show - S01E03.mkv'));
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('findNextEpisode advances seasons when a deleted file was the last episode', () => {
|
||||
const seriesRoot = createSeriesTree();
|
||||
try {
|
||||
const season1 = path.join(seriesRoot, 'Season-1');
|
||||
const season2 = path.join(seriesRoot, 'Season-2');
|
||||
const missing = path.join(season1, 'Show - S01E03 - Deleted Cut.mkv');
|
||||
assert.equal(findNextEpisode(missing), path.join(season2, 'Show - S02E01.mkv'));
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(seriesRoot), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const PNG_MAGIC = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
|
||||
|
||||
function createHistoryDb(
|
||||
dbPath: string,
|
||||
options: { wal?: boolean; coverArt?: boolean } = {},
|
||||
): void {
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
if (options.wal) db.run('PRAGMA journal_mode = WAL;');
|
||||
db.run(`
|
||||
CREATE TABLE imm_anime(
|
||||
anime_id INTEGER PRIMARY KEY,
|
||||
canonical_title TEXT,
|
||||
title_romaji TEXT
|
||||
);
|
||||
`);
|
||||
db.run(`
|
||||
CREATE TABLE imm_videos(
|
||||
video_id INTEGER PRIMARY KEY,
|
||||
anime_id INTEGER,
|
||||
source_type INTEGER,
|
||||
source_path TEXT,
|
||||
parsed_title TEXT,
|
||||
parsed_season INTEGER,
|
||||
parsed_episode INTEGER
|
||||
);
|
||||
`);
|
||||
db.run(`
|
||||
CREATE TABLE imm_sessions(
|
||||
session_id INTEGER PRIMARY KEY,
|
||||
video_id INTEGER,
|
||||
started_at_ms TEXT
|
||||
);
|
||||
`);
|
||||
db.run(`INSERT INTO imm_anime VALUES (1, 'Show Season 1', 'Show Romaji');`);
|
||||
db.run(`
|
||||
INSERT INTO imm_videos VALUES
|
||||
(1, 1, 1, '/media/Show/Season-1/Show - S01E01.mkv', 'Show', 1, 1),
|
||||
(2, 1, 1, '/media/Show/Season-1/Show - S01E02.mkv', 'Show', 1, 2),
|
||||
(3, NULL, 2, NULL, 'Remote Show', NULL, NULL),
|
||||
(4, NULL, 1, '', 'Empty Path', NULL, NULL);
|
||||
`);
|
||||
db.run(`
|
||||
INSERT INTO imm_sessions VALUES
|
||||
(1, 1, '1000'),
|
||||
(2, 1, '5000'),
|
||||
(3, 2, '3000'),
|
||||
(4, 3, '9000');
|
||||
`);
|
||||
if (options.coverArt) {
|
||||
db.run(`
|
||||
CREATE TABLE imm_media_art(
|
||||
video_id INTEGER PRIMARY KEY,
|
||||
cover_blob_hash TEXT
|
||||
);
|
||||
`);
|
||||
db.run(`
|
||||
CREATE TABLE imm_cover_art_blobs(
|
||||
blob_hash TEXT PRIMARY KEY,
|
||||
cover_blob BLOB NOT NULL
|
||||
);
|
||||
`);
|
||||
// Art only on video 1; video 2 resolves it through the shared anime_id.
|
||||
db.run(`INSERT INTO imm_media_art VALUES (1, 'hash-1');`);
|
||||
db.query('INSERT INTO imm_cover_art_blobs VALUES (?, ?)').run('hash-1', PNG_MAGIC);
|
||||
}
|
||||
if (options.wal) db.run('PRAGMA wal_checkpoint(TRUNCATE);');
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function assertHistoryRows(dbPath: string): void {
|
||||
const rows = queryLocalWatchHistory(dbPath);
|
||||
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0]?.videoId, 1);
|
||||
assert.equal(rows[0]?.lastWatchedMs, 5000);
|
||||
assert.equal(rows[0]?.animeTitle, 'Show Romaji');
|
||||
assert.equal(rows[1]?.videoId, 2);
|
||||
assert.equal(rows[1]?.lastWatchedMs, 3000);
|
||||
}
|
||||
|
||||
test('queryLocalWatchHistory returns local files ordered by most recent session', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-db-'));
|
||||
const dbPath = path.join(dir, 'immersion.sqlite');
|
||||
try {
|
||||
createHistoryDb(dbPath);
|
||||
assertHistoryRows(dbPath);
|
||||
const rows = queryLocalWatchHistory(dbPath);
|
||||
assert.equal(rows[0]?.coverBlobHash, null);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('queryLocalWatchHistory resolves cover hashes directly and via shared anime', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-art-'));
|
||||
const dbPath = path.join(dir, 'immersion.sqlite');
|
||||
try {
|
||||
createHistoryDb(dbPath, { coverArt: true });
|
||||
const rows = queryLocalWatchHistory(dbPath);
|
||||
assert.equal(rows[0]?.videoId, 1);
|
||||
assert.equal(rows[0]?.coverBlobHash, 'hash-1');
|
||||
assert.equal(rows[1]?.videoId, 2);
|
||||
assert.equal(rows[1]?.coverBlobHash, 'hash-1');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('materializeCoverArt extracts blobs to the cache dir and reuses cached files', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-covers-'));
|
||||
const dbPath = path.join(dir, 'immersion.sqlite');
|
||||
const cacheDir = path.join(dir, 'covers');
|
||||
try {
|
||||
createHistoryDb(dbPath, { coverArt: true });
|
||||
|
||||
const covers = materializeCoverArt(
|
||||
dbPath,
|
||||
['hash-1', 'hash-1', null, 'hash-missing'],
|
||||
cacheDir,
|
||||
);
|
||||
const coverPath = covers.get('hash-1');
|
||||
assert.ok(coverPath);
|
||||
assert.equal(path.extname(coverPath!), '.png');
|
||||
assert.ok(fs.statSync(coverPath!).size > 0);
|
||||
assert.equal(covers.has('hash-missing'), false);
|
||||
|
||||
// Cached file is reused even when the database has disappeared.
|
||||
fs.rmSync(dbPath);
|
||||
const cachedCovers = materializeCoverArt(dbPath, ['hash-1'], cacheDir);
|
||||
assert.equal(cachedCovers.get('hash-1'), coverPath);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('materializeCoverArt rejects cover hashes that escape the cache dir', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-cover-safety-'));
|
||||
const dbPath = path.join(dir, 'immersion.sqlite');
|
||||
const cacheDir = path.join(dir, 'covers');
|
||||
const unsafeHash = '../escape';
|
||||
try {
|
||||
createHistoryDb(dbPath, { coverArt: true });
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
db.query('INSERT INTO imm_cover_art_blobs VALUES (?, ?)').run(unsafeHash, PNG_MAGIC);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
const covers = materializeCoverArt(dbPath, [unsafeHash], cacheDir);
|
||||
|
||||
assert.equal(covers.has(unsafeHash), false);
|
||||
assert.equal(fs.existsSync(path.join(dir, 'escape.png')), false);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('detectImageExtension identifies common cover formats', () => {
|
||||
assert.equal(detectImageExtension(PNG_MAGIC), '.png');
|
||||
assert.equal(detectImageExtension(Buffer.from([0xff, 0xd8, 0xff, 0xe0])), '.jpg');
|
||||
assert.equal(detectImageExtension(Buffer.from('RIFF0000WEBPVP8 ', 'ascii')), '.webp');
|
||||
assert.equal(detectImageExtension(Buffer.from('GIF89a', 'ascii')), '.gif');
|
||||
assert.equal(detectImageExtension(Buffer.from('unknown', 'ascii')), '.jpg');
|
||||
});
|
||||
|
||||
test('groupHistoryBySeries backfills cover hash from older rows of the same series', () => {
|
||||
const rows = [
|
||||
makeRow({ videoId: 2, parsedEpisode: 2, lastWatchedMs: 3000, coverBlobHash: null }),
|
||||
makeRow({ videoId: 1, parsedEpisode: 1, lastWatchedMs: 1000, coverBlobHash: 'hash-1' }),
|
||||
];
|
||||
|
||||
const series = groupHistoryBySeries(rows, () => true);
|
||||
|
||||
assert.equal(series.length, 1);
|
||||
assert.equal(series[0]?.lastWatched.videoId, 2);
|
||||
assert.equal(series[0]?.coverBlobHash, 'hash-1');
|
||||
});
|
||||
|
||||
test('queryLocalWatchHistory reads a cleanly-closed WAL database', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-wal-'));
|
||||
const dbPath = path.join(dir, 'immersion.sqlite');
|
||||
try {
|
||||
createHistoryDb(dbPath, { wal: true });
|
||||
// Reproduce the state after the app shuts down cleanly: WAL journal mode
|
||||
// with no -wal/-shm sidecar files on disk. A read-only connection then
|
||||
// fails at query time because it cannot recreate them.
|
||||
fs.rmSync(`${dbPath}-wal`, { force: true });
|
||||
fs.rmSync(`${dbPath}-shm`, { force: true });
|
||||
assertHistoryRows(dbPath);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('isReadonlyWalRetryError only accepts readonly errors from WAL-mode databases', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-history-retry-'));
|
||||
const walDbPath = path.join(dir, 'wal.sqlite');
|
||||
const rollbackDbPath = path.join(dir, 'rollback.sqlite');
|
||||
try {
|
||||
createHistoryDb(walDbPath, { wal: true });
|
||||
createHistoryDb(rollbackDbPath);
|
||||
|
||||
assert.equal(
|
||||
isReadonlyWalRetryError(
|
||||
Object.assign(new Error('attempt to write a readonly database'), {
|
||||
code: 'SQLITE_READONLY',
|
||||
}),
|
||||
walDbPath,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isReadonlyWalRetryError(
|
||||
Object.assign(new Error('unable to open database file'), {
|
||||
code: 'SQLITE_CANTOPEN',
|
||||
}),
|
||||
walDbPath,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isReadonlyWalRetryError(new Error('no such table: imm_sessions'), walDbPath),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isReadonlyWalRetryError(
|
||||
Object.assign(new Error('attempt to write a readonly database'), {
|
||||
code: 'SQLITE_READONLY',
|
||||
}),
|
||||
rollbackDbPath,
|
||||
),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './history-art.js';
|
||||
export * from './history-db.js';
|
||||
export * from './history-navigation.js';
|
||||
export type { HistorySeriesEntry, HistoryVideoRow, SeasonDirEntry } from './history-types.js';
|
||||
@@ -0,0 +1,142 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Args } from './types.js';
|
||||
import { runJellyfinPlayMenuWithDeps } from './jellyfin.js';
|
||||
|
||||
function createArgs(): Args {
|
||||
return {
|
||||
backend: 'auto',
|
||||
directory: '.',
|
||||
recursive: false,
|
||||
profile: '',
|
||||
startOverlay: false,
|
||||
youtubeMode: 'download',
|
||||
whisperBin: '',
|
||||
whisperModel: '',
|
||||
whisperVadModel: '',
|
||||
whisperThreads: 0,
|
||||
youtubeSubgenOutDir: '',
|
||||
youtubeSubgenAudioFormat: '',
|
||||
youtubeSubgenKeepTemp: false,
|
||||
youtubeFixWithAi: false,
|
||||
youtubePrimarySubLangs: [],
|
||||
youtubeSecondarySubLangs: [],
|
||||
youtubeAudioLangs: [],
|
||||
youtubeWhisperSourceLanguage: '',
|
||||
aiConfig: {},
|
||||
useTexthooker: false,
|
||||
autoStartOverlay: false,
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
history: false,
|
||||
sync: false,
|
||||
syncHost: '',
|
||||
syncSnapshotPath: '',
|
||||
syncMergePath: '',
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
target: '',
|
||||
targetKind: '',
|
||||
jimakuApiKey: '',
|
||||
jimakuApiKeyCommand: '',
|
||||
jimakuApiBaseUrl: '',
|
||||
jimakuLanguagePreference: 'ja',
|
||||
jimakuMaxEntryResults: 20,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
jellyfinPlay: false,
|
||||
jellyfinDiscovery: false,
|
||||
dictionary: false,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
stats: false,
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
logsExport: false,
|
||||
version: false,
|
||||
settings: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
mpvSocket: false,
|
||||
mpvStatus: false,
|
||||
mpvArgs: '',
|
||||
appPassthrough: false,
|
||||
appArgs: [],
|
||||
jellyfinServer: 'https://jellyfin.example.test',
|
||||
jellyfinUsername: '',
|
||||
jellyfinPassword: '',
|
||||
launchMode: 'normal',
|
||||
};
|
||||
}
|
||||
|
||||
test('Jellyfin playback ensures Linux runtime plugin before detached idle mpv bootstrap', async () => {
|
||||
const originalAccessToken = process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
|
||||
const originalUserId = process.env.SUBMINER_JELLYFIN_USER_ID;
|
||||
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = 'token';
|
||||
process.env.SUBMINER_JELLYFIN_USER_ID = 'user';
|
||||
const calls: string[] = [];
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runJellyfinPlayMenuWithDeps(
|
||||
'/tmp/SubMiner.AppImage',
|
||||
createArgs(),
|
||||
'/tmp/subminer',
|
||||
'/tmp/subminer.sock',
|
||||
{
|
||||
loadLauncherJellyfinConfig: () => ({}),
|
||||
findRofiTheme: () => null,
|
||||
resolveJellyfinSelection: async () => 'item-123',
|
||||
resolveJellyfinSelectionViaApp: async () => {
|
||||
throw new Error('unexpected app-based selection');
|
||||
},
|
||||
hasStoredJellyfinSession: () => true,
|
||||
requestJellyfinPreviewAuthFromApp: async () => null,
|
||||
resolveLauncherMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
||||
pathExists: () => false,
|
||||
ensureRuntimePluginReady: async () => {
|
||||
calls.push('plugin');
|
||||
},
|
||||
waitForUnixSocketReady: async () => {
|
||||
calls.push('wait');
|
||||
return true;
|
||||
},
|
||||
launchMpvIdleDetached: async () => {
|
||||
calls.push('launch');
|
||||
},
|
||||
resolveLauncherRuntimePluginPath: () => {
|
||||
calls.push('resolve');
|
||||
return '/tmp/plugin/main.lua';
|
||||
},
|
||||
runAppCommandWithInheritLogged: () => {
|
||||
calls.push('handoff');
|
||||
throw new Error('stop after handoff');
|
||||
},
|
||||
log: () => {},
|
||||
},
|
||||
),
|
||||
/stop after handoff/,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ['plugin', 'resolve', 'launch', 'wait', 'handoff']);
|
||||
} finally {
|
||||
if (originalAccessToken === undefined) {
|
||||
delete process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN;
|
||||
} else {
|
||||
process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN = originalAccessToken;
|
||||
}
|
||||
if (originalUserId === undefined) {
|
||||
delete process.env.SUBMINER_JELLYFIN_USER_ID;
|
||||
} else {
|
||||
process.env.SUBMINER_JELLYFIN_USER_ID = originalUserId;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -30,9 +30,54 @@ import {
|
||||
resolveLauncherRuntimePluginPath,
|
||||
waitForUnixSocketReady,
|
||||
} from './mpv.js';
|
||||
import { ensureLinuxRuntimePluginAvailable } from './runtime-plugin-preflight.js';
|
||||
|
||||
const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;]*m/g;
|
||||
|
||||
type JellyfinPlayMenuDeps = {
|
||||
loadLauncherJellyfinConfig: typeof loadLauncherJellyfinConfig;
|
||||
findRofiTheme: typeof findRofiTheme;
|
||||
resolveJellyfinSelection: typeof resolveJellyfinSelection;
|
||||
hasStoredJellyfinSession: typeof hasStoredJellyfinSession;
|
||||
requestJellyfinPreviewAuthFromApp: typeof requestJellyfinPreviewAuthFromApp;
|
||||
resolveLauncherMainConfigPath: typeof resolveLauncherMainConfigPath;
|
||||
resolveJellyfinSelectionViaApp: typeof resolveJellyfinSelectionViaApp;
|
||||
pathExists: (candidate: string) => boolean;
|
||||
ensureRuntimePluginReady: (options: {
|
||||
appPath: string;
|
||||
scriptPath: string;
|
||||
logLevel: Args['logLevel'];
|
||||
}) => Promise<void>;
|
||||
waitForUnixSocketReady: typeof waitForUnixSocketReady;
|
||||
launchMpvIdleDetached: typeof launchMpvIdleDetached;
|
||||
resolveLauncherRuntimePluginPath: typeof resolveLauncherRuntimePluginPath;
|
||||
runAppCommandWithInheritLogged: typeof runAppCommandWithInheritLogged;
|
||||
log: typeof log;
|
||||
};
|
||||
|
||||
const defaultJellyfinPlayMenuDeps: JellyfinPlayMenuDeps = {
|
||||
loadLauncherJellyfinConfig,
|
||||
findRofiTheme,
|
||||
resolveJellyfinSelection,
|
||||
hasStoredJellyfinSession,
|
||||
requestJellyfinPreviewAuthFromApp,
|
||||
resolveLauncherMainConfigPath,
|
||||
resolveJellyfinSelectionViaApp,
|
||||
pathExists: (candidate) => fs.existsSync(candidate),
|
||||
ensureRuntimePluginReady: async ({ appPath, scriptPath, logLevel }) => {
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
appPath,
|
||||
scriptPath,
|
||||
logLevel,
|
||||
});
|
||||
},
|
||||
waitForUnixSocketReady,
|
||||
launchMpvIdleDetached,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
runAppCommandWithInheritLogged,
|
||||
log,
|
||||
};
|
||||
|
||||
export function sanitizeServerUrl(value: string): string {
|
||||
return value.trim().replace(/\/+$/, '');
|
||||
}
|
||||
@@ -974,7 +1019,17 @@ export async function runJellyfinPlayMenu(
|
||||
scriptPath: string,
|
||||
mpvSocketPath: string,
|
||||
): Promise<never> {
|
||||
const config = loadLauncherJellyfinConfig();
|
||||
return runJellyfinPlayMenuWithDeps(appPath, args, scriptPath, mpvSocketPath);
|
||||
}
|
||||
|
||||
export async function runJellyfinPlayMenuWithDeps(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
scriptPath: string,
|
||||
mpvSocketPath: string,
|
||||
deps: JellyfinPlayMenuDeps = defaultJellyfinPlayMenuDeps,
|
||||
): Promise<never> {
|
||||
const config = deps.loadLauncherJellyfinConfig();
|
||||
const envAccessToken = (process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN || '').trim();
|
||||
const envUserId = (process.env.SUBMINER_JELLYFIN_USER_ID || '').trim();
|
||||
const session: JellyfinSessionConfig = {
|
||||
@@ -986,58 +1041,71 @@ export async function runJellyfinPlayMenu(
|
||||
iconCacheDir: config.iconCacheDir || '',
|
||||
};
|
||||
|
||||
const rofiTheme = args.useRofi ? findRofiTheme(scriptPath) : null;
|
||||
const rofiTheme = args.useRofi ? deps.findRofiTheme(scriptPath) : null;
|
||||
if (args.useRofi && !rofiTheme) {
|
||||
log('warn', args.logLevel, 'Rofi theme not found for Jellyfin picker; using rofi defaults.');
|
||||
deps.log(
|
||||
'warn',
|
||||
args.logLevel,
|
||||
'Rofi theme not found for Jellyfin picker; using rofi defaults.',
|
||||
);
|
||||
}
|
||||
|
||||
const hasDirectSession = Boolean(session.serverUrl && session.accessToken && session.userId);
|
||||
let itemId = '';
|
||||
if (hasDirectSession) {
|
||||
itemId = await resolveJellyfinSelection(args, session, rofiTheme);
|
||||
itemId = await deps.resolveJellyfinSelection(args, session, rofiTheme);
|
||||
} else {
|
||||
const configPath = resolveLauncherMainConfigPath();
|
||||
if (!hasStoredJellyfinSession(configPath)) {
|
||||
const configPath = deps.resolveLauncherMainConfigPath();
|
||||
if (!deps.hasStoredJellyfinSession(configPath)) {
|
||||
fail(
|
||||
'Missing Jellyfin session. Run `subminer jellyfin -l --server <url> --username <user> --password <pass>` first.',
|
||||
);
|
||||
}
|
||||
const previewAuth = await requestJellyfinPreviewAuthFromApp(appPath, args);
|
||||
const previewAuth = await deps.requestJellyfinPreviewAuthFromApp(appPath, args);
|
||||
if (previewAuth) {
|
||||
session.serverUrl = previewAuth.serverUrl || session.serverUrl;
|
||||
session.accessToken = previewAuth.accessToken;
|
||||
session.userId = previewAuth.userId || session.userId;
|
||||
log('debug', args.logLevel, 'Jellyfin preview auth bridge ready for picker image previews.');
|
||||
deps.log(
|
||||
'debug',
|
||||
args.logLevel,
|
||||
'Jellyfin preview auth bridge ready for picker image previews.',
|
||||
);
|
||||
} else {
|
||||
log(
|
||||
deps.log(
|
||||
'debug',
|
||||
args.logLevel,
|
||||
'Jellyfin preview auth bridge unavailable; picker image previews may be disabled.',
|
||||
);
|
||||
}
|
||||
itemId = await resolveJellyfinSelectionViaApp(appPath, args, session, rofiTheme);
|
||||
itemId = await deps.resolveJellyfinSelectionViaApp(appPath, args, session, rofiTheme);
|
||||
}
|
||||
log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
|
||||
log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
|
||||
deps.log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
|
||||
deps.log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
|
||||
let mpvReady = false;
|
||||
if (fs.existsSync(mpvSocketPath)) {
|
||||
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 250);
|
||||
if (deps.pathExists(mpvSocketPath)) {
|
||||
mpvReady = await deps.waitForUnixSocketReady(mpvSocketPath, 250);
|
||||
}
|
||||
if (!mpvReady) {
|
||||
await launchMpvIdleDetached(
|
||||
await deps.ensureRuntimePluginReady({ appPath, scriptPath, logLevel: args.logLevel });
|
||||
await deps.launchMpvIdleDetached(
|
||||
mpvSocketPath,
|
||||
appPath,
|
||||
args,
|
||||
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||
deps.resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||
);
|
||||
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||
mpvReady = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||
}
|
||||
log('debug', args.logLevel, `MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`);
|
||||
deps.log(
|
||||
'debug',
|
||||
args.logLevel,
|
||||
`MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`,
|
||||
);
|
||||
if (!mpvReady) {
|
||||
fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
|
||||
}
|
||||
const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
|
||||
if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
|
||||
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
|
||||
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
||||
deps.runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import { getDefaultLauncherLogFile, getDefaultMpvLogFile } from './types.js';
|
||||
import { localDateKey } from '../src/shared/log-files.js';
|
||||
|
||||
test('getDefaultMpvLogFile uses APPDATA on windows', () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = localDateKey(new Date());
|
||||
const resolved = getDefaultMpvLogFile({
|
||||
platform: 'win32',
|
||||
homeDir: 'C:\\Users\\tester',
|
||||
@@ -20,7 +21,7 @@ test('getDefaultMpvLogFile uses APPDATA on windows', () => {
|
||||
});
|
||||
|
||||
test('getDefaultLauncherLogFile uses launcher prefix', () => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = localDateKey(new Date());
|
||||
const resolved = getDefaultLauncherLogFile({
|
||||
platform: 'linux',
|
||||
homeDir: '/home/tester',
|
||||
|
||||
@@ -65,11 +65,36 @@ function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv
|
||||
APPDATA: xdgConfigHome,
|
||||
LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'),
|
||||
XDG_CONFIG_HOME: xdgConfigHome,
|
||||
// Pin the data dir under the temp home so the Linux runtime-plugin preflight
|
||||
// resolves managed asset paths deterministically (not the CI runner's).
|
||||
XDG_DATA_HOME: path.join(homeDir, '.local', 'share'),
|
||||
PATH: pathValue,
|
||||
Path: pathValue,
|
||||
};
|
||||
}
|
||||
|
||||
// On Linux the playback path runs `ensureLinuxRuntimePluginAvailable`, which —
|
||||
// when the runtime plugin/theme are missing — spawns the app with
|
||||
// `--ensure-linux-runtime-plugin-assets` and polls up to 30s
|
||||
// (RESPONSE_TIMEOUT_MS) for an install response. A fake app that just exits
|
||||
// never writes that response, so the launcher hangs and the test times out on
|
||||
// Linux CI (the preflight is a no-op on macOS/Windows). This shell prelude makes
|
||||
// the fake app install the managed plugin/theme and write the response, matching
|
||||
// launcher/smoke.e2e.test.ts. Prepend it to each fake app that reaches playback.
|
||||
const RUNTIME_PLUGIN_PREFLIGHT_SH = `if [ "$1" = "--ensure-linux-runtime-plugin-assets" ]; then
|
||||
data="\${XDG_DATA_HOME:-$HOME/.local/share}/SubMiner"
|
||||
mkdir -p "$data/plugin/subminer" "$data/themes"
|
||||
printf -- '-- test plugin\\n' > "$data/plugin/subminer/main.lua"
|
||||
printf 'test=true\\n' > "$data/plugin/subminer.conf"
|
||||
printf '/* test theme */\\n' > "$data/themes/subminer.rasi"
|
||||
if [ "$2" = "--ensure-linux-runtime-plugin-assets-response-path" ] && [ -n "$3" ]; then
|
||||
mkdir -p "$(dirname "$3")"
|
||||
printf '{"ok":true,"status":"installed","path":"%s"}' "$data/plugin/subminer/main.lua" > "$3"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
`;
|
||||
|
||||
test('config path uses XDG_CONFIG_HOME override', () => {
|
||||
withTempDir((root) => {
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
@@ -237,7 +262,7 @@ test('doctor refresh-known-words forwards app refresh command without requiring
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -264,7 +289,7 @@ test('launcher settings option forwards app settings window command', () => {
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -288,7 +313,7 @@ test('launcher settings command forwards app settings window command', () => {
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -369,7 +394,7 @@ test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, ()
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
||||
fs.writeFileSync(appPath, `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}exit 0\n`);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
fs.writeFileSync(
|
||||
@@ -460,7 +485,7 @@ test('launcher forwards non-info log level into mpv logging args', { timeout: 15
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
||||
fs.writeFileSync(appPath, `#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}exit 0\n`);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
fs.writeFileSync(
|
||||
@@ -539,7 +564,7 @@ test('launcher routes youtube urls through regular playback startup', { timeout:
|
||||
);
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -566,17 +591,22 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
|
||||
fs.chmodSync(path.join(binDir, 'yt-dlp'), 0o755);
|
||||
fs.chmodSync(path.join(binDir, 'ffmpeg'), 0o755);
|
||||
|
||||
// Note: no SUBMINER_TEST_CAPTURE here. When set, the launcher intercepts
|
||||
// *every* app command — including the Linux runtime-plugin preflight's
|
||||
// `--ensure-linux-runtime-plugin-assets` install — and returns without
|
||||
// running the fake app, so the preflight would poll 30s for a response that
|
||||
// never arrives and time out. This test asserts on the mpv args, not on
|
||||
// captured app args, so capture isn't needed.
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
||||
Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
|
||||
DISPLAY: ':99',
|
||||
XDG_SESSION_TYPE: 'x11',
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_MPV_ARGS: mpvArgsPath,
|
||||
SUBMINER_TEST_CAPTURE: path.join(root, 'captured-args.txt'),
|
||||
};
|
||||
const result = runLauncher(['https://www.youtube.com/watch?v=abc123'], env);
|
||||
// Pass an explicit backend so overlay startup doesn't probe for a display
|
||||
// (headless CI has none), matching launcher/smoke.e2e.test.ts.
|
||||
const result = runLauncher(['--backend', 'x11', 'https://www.youtube.com/watch?v=abc123'], env);
|
||||
|
||||
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||
const forwardedArgs = fs
|
||||
@@ -597,7 +627,7 @@ test('dictionary command forwards --dictionary and --dictionary-target to app co
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -626,7 +656,7 @@ test('dictionary command forwards manual AniList selection modes to app command
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -763,7 +793,7 @@ test('jellyfin discovery routes to app --background and remote announce with log
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -790,7 +820,7 @@ test('jellyfin discovery via jf alias forwards remote announce for cast visibili
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -817,7 +847,7 @@ test('jellyfin login routes credentials to app command', () => {
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
@@ -856,7 +886,7 @@ test('jellyfin setup forwards password-store to app command', () => {
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
`#!/bin/sh\n${RUNTIME_PLUGIN_PREFLIGHT_SH}if [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n`,
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import { runDictionaryCommand } from './commands/dictionary-command.js';
|
||||
import { runLogsCommand } from './commands/logs-command.js';
|
||||
import { runStatsCommand } from './commands/stats-command.js';
|
||||
import { runJellyfinCommand } from './commands/jellyfin-command.js';
|
||||
import { runHistoryCommand } from './commands/history-command.js';
|
||||
import { runSyncCommand } from './commands/sync-command.js';
|
||||
import { runPlaybackCommand } from './commands/playback-command.js';
|
||||
import { runUpdateCommand } from './commands/update-command.js';
|
||||
|
||||
@@ -106,6 +108,10 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await runSyncCommand(context)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedAppPath = ensureAppPath(context);
|
||||
state.appPath = resolvedAppPath;
|
||||
log('debug', args.logLevel, `Using SubMiner app binary: ${resolvedAppPath}`);
|
||||
@@ -142,6 +148,16 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (appContext.args.history) {
|
||||
const selected = await runHistoryCommand(appContext);
|
||||
if (!selected) {
|
||||
log('info', args.logLevel, 'No watch history selection made, exiting');
|
||||
return;
|
||||
}
|
||||
appContext.args.target = selected;
|
||||
appContext.args.targetKind = 'file';
|
||||
}
|
||||
|
||||
await runPlaybackCommand(appContext);
|
||||
}
|
||||
|
||||
|
||||
@@ -570,6 +570,14 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
texthookerOnly: false,
|
||||
texthookerOpenBrowser: false,
|
||||
useRofi: false,
|
||||
history: false,
|
||||
sync: false,
|
||||
syncHost: '',
|
||||
syncSnapshotPath: '',
|
||||
syncMergePath: '',
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
logLevel: 'error',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
|
||||
@@ -1683,7 +1683,7 @@ async function sleepMs(ms: number): Promise<void> {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function canConnectUnixSocket(socketPath: string): Promise<boolean> {
|
||||
export async function canConnectUnixSocket(socketPath: string): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const socket = net.createConnection(socketPath);
|
||||
let settled = false;
|
||||
|
||||
@@ -42,6 +42,19 @@ test('parseArgs maps root settings window option', () => {
|
||||
assert.equal(parsed.settings, true);
|
||||
});
|
||||
|
||||
test('parseArgs maps root watch history flags', () => {
|
||||
const shortParsed = parseArgs(['-H'], 'subminer', {});
|
||||
const longParsed = parseArgs(['--history'], 'subminer', {});
|
||||
const rofiParsed = parseArgs(['-R', '-H'], 'subminer', {});
|
||||
const defaultParsed = parseArgs([], 'subminer', {});
|
||||
|
||||
assert.equal(shortParsed.history, true);
|
||||
assert.equal(longParsed.history, true);
|
||||
assert.equal(rofiParsed.history, true);
|
||||
assert.equal(rofiParsed.useRofi, true);
|
||||
assert.equal(defaultParsed.history, false);
|
||||
});
|
||||
|
||||
test('parseArgs maps root update flags without conflicting with jellyfin username', () => {
|
||||
const shortParsed = parseArgs(['-u'], 'subminer', {});
|
||||
const longParsed = parseArgs(['--update'], 'subminer', {});
|
||||
|
||||
@@ -56,7 +56,7 @@ export function showFzfFlatMenu(
|
||||
`--prompt=${prompt}`,
|
||||
'--delimiter=\t',
|
||||
'--with-nth=2',
|
||||
'--preview-window=right:50%:wrap',
|
||||
'--preview-window=down:50%:wrap',
|
||||
'--preview',
|
||||
previewCommand,
|
||||
];
|
||||
@@ -468,7 +468,7 @@ thumb=$(get_thumb)
|
||||
'--prompt=Select Video: ',
|
||||
'--delimiter=\t',
|
||||
'--with-nth=1',
|
||||
'--preview-window=right:50%:wrap',
|
||||
'--preview-window=down:50%:wrap',
|
||||
'--preview',
|
||||
previewCmd,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
ensureLinuxRuntimePluginAvailable,
|
||||
installManagedPluginAssetsViaApp,
|
||||
} from './runtime-plugin-preflight';
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'darwin',
|
||||
detectInstalledPlugin: () => {
|
||||
calls.push('detect');
|
||||
return false;
|
||||
},
|
||||
resolveRuntimePluginPath: () => {
|
||||
calls.push('resolve');
|
||||
return null;
|
||||
},
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
log: () => {
|
||||
calls.push('log');
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable skips install when installed global plugin and managed theme exist', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
detectInstalledPlugin: () => {
|
||||
calls.push('detect');
|
||||
return true;
|
||||
},
|
||||
resolveRuntimePluginPath: () => {
|
||||
calls.push('resolve');
|
||||
return null;
|
||||
},
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
isManagedThemeAvailable: () => {
|
||||
calls.push('theme');
|
||||
return true;
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['detect', 'theme']);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable skips install when managed runtime path and theme already resolve', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
xdgDataHome: '/tmp/xdg-data',
|
||||
detectInstalledPlugin: () => {
|
||||
calls.push('detect');
|
||||
return false;
|
||||
},
|
||||
resolveRuntimePluginPath: () => {
|
||||
calls.push('resolve');
|
||||
return '/tmp/plugin/main.lua';
|
||||
},
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
isManagedThemeAvailable: () => {
|
||||
calls.push('theme');
|
||||
return true;
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['detect', 'resolve', 'theme']);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme is missing', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
xdgDataHome: '/tmp/xdg-data',
|
||||
detectInstalledPlugin: () => {
|
||||
calls.push('detect');
|
||||
return false;
|
||||
},
|
||||
resolveRuntimePluginPath: () => {
|
||||
calls.push('resolve');
|
||||
return '/tmp/plugin/main.lua';
|
||||
},
|
||||
isManagedThemeAvailable: () => {
|
||||
calls.push('theme');
|
||||
return false;
|
||||
},
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
log: (level, _configured, message) => {
|
||||
calls.push(`${level}:${message}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'detect',
|
||||
'resolve',
|
||||
'theme',
|
||||
'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
|
||||
'install',
|
||||
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
|
||||
'resolve',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => {
|
||||
const calls: string[] = [];
|
||||
let resolveCount = 0;
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
xdgDataHome: '/tmp/xdg-data',
|
||||
detectInstalledPlugin: () => false,
|
||||
resolveRuntimePluginPath: () => {
|
||||
resolveCount += 1;
|
||||
calls.push(`resolve:${resolveCount}`);
|
||||
return resolveCount === 1 ? null : '/tmp/plugin/main.lua';
|
||||
},
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
log: (level, _configured, message) => {
|
||||
calls.push(`${level}:${message}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'resolve:1',
|
||||
'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
|
||||
'install',
|
||||
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
|
||||
'resolve:2',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable fails when install result is not ok', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
detectInstalledPlugin: () => false,
|
||||
resolveRuntimePluginPath: () => null,
|
||||
installManagedPluginAssets: async () => ({
|
||||
ok: false,
|
||||
status: 'failed',
|
||||
error: 'copy failed',
|
||||
}),
|
||||
log: () => {},
|
||||
}),
|
||||
/copy failed/,
|
||||
);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable fails when runtime path remains unresolved after install', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
detectInstalledPlugin: () => false,
|
||||
resolveRuntimePluginPath: () => null,
|
||||
installManagedPluginAssets: async () => ({
|
||||
ok: true,
|
||||
status: 'installed',
|
||||
path: '/tmp/plugin/main.lua',
|
||||
}),
|
||||
log: () => {},
|
||||
}),
|
||||
/managed runtime plugin assets could not be installed/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('installManagedPluginAssetsViaApp returns launch errors without waiting for a response file', async () => {
|
||||
let waited = false;
|
||||
|
||||
const result = await installManagedPluginAssetsViaApp(
|
||||
{
|
||||
appPath: '/opt/SubMiner/subminer',
|
||||
},
|
||||
{
|
||||
runAppCommandCaptureOutput: () => ({
|
||||
status: 1,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
error: new Error('spawn failed'),
|
||||
}),
|
||||
waitForInstallResponse: async () => {
|
||||
waited = true;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: false,
|
||||
status: 'failed',
|
||||
error: 'spawn failed',
|
||||
});
|
||||
assert.equal(waited, false);
|
||||
});
|
||||
|
||||
test('installManagedPluginAssetsViaApp does not let temp cleanup errors mask install result', async () => {
|
||||
const originalRmSync = fs.rmSync;
|
||||
fs.rmSync = ((targetPath, options) => {
|
||||
if (String(targetPath).includes('subminer-runtime-plugin-')) {
|
||||
throw new Error('cleanup failed');
|
||||
}
|
||||
return originalRmSync(targetPath, options);
|
||||
}) as typeof fs.rmSync;
|
||||
|
||||
try {
|
||||
const result = await installManagedPluginAssetsViaApp(
|
||||
{
|
||||
appPath: '/opt/SubMiner/subminer',
|
||||
},
|
||||
{
|
||||
runAppCommandCaptureOutput: () => ({
|
||||
status: 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
}),
|
||||
waitForInstallResponse: async () => ({
|
||||
ok: true,
|
||||
status: 'installed',
|
||||
path: '/tmp/plugin/main.lua',
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
status: 'installed',
|
||||
path: '/tmp/plugin/main.lua',
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync = originalRmSync;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { log as launcherLog } from './log.js';
|
||||
import { runAppCommandCaptureOutput, resolveLauncherRuntimePluginPath } from './mpv.js';
|
||||
import { nowMs } from './time.js';
|
||||
import { sleep } from './util.js';
|
||||
import { detectInstalledMpvPlugin } from '../src/main/runtime/first-run-setup-plugin.js';
|
||||
import {
|
||||
resolveManagedLinuxRuntimePluginPaths,
|
||||
type EnsureLinuxRuntimePluginAssetsResult,
|
||||
} from '../src/main/runtime/linux-runtime-plugin-assets.js';
|
||||
|
||||
const RESPONSE_TIMEOUT_MS = 30_000;
|
||||
|
||||
type PreflightLog = (
|
||||
level: 'debug' | 'info' | 'warn' | 'error',
|
||||
configured: 'debug' | 'info' | 'warn' | 'error',
|
||||
message: string,
|
||||
) => void;
|
||||
|
||||
type EnsureLinuxRuntimePluginAvailableOptions = {
|
||||
appPath?: string;
|
||||
scriptPath?: string;
|
||||
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
xdgConfigHome?: string;
|
||||
xdgDataHome?: string;
|
||||
appDataDir?: string;
|
||||
detectInstalledPlugin?: () => boolean;
|
||||
resolveRuntimePluginPath?: () => string | null;
|
||||
isManagedThemeAvailable?: () => boolean;
|
||||
installManagedPluginAssets?: () => Promise<EnsureLinuxRuntimePluginAssetsResult>;
|
||||
log?: PreflightLog;
|
||||
};
|
||||
|
||||
type RuntimePluginPreflightResponse = {
|
||||
ok: boolean;
|
||||
status: 'installed' | 'already-present' | 'failed';
|
||||
path?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function resolveConfiguredLogLevel(
|
||||
logLevel: EnsureLinuxRuntimePluginAvailableOptions['logLevel'],
|
||||
): 'debug' | 'info' | 'warn' | 'error' {
|
||||
return logLevel ?? 'warn';
|
||||
}
|
||||
|
||||
async function waitForInstallResponse(
|
||||
responsePath: string,
|
||||
): Promise<RuntimePluginPreflightResponse | null> {
|
||||
const deadline = nowMs() + RESPONSE_TIMEOUT_MS;
|
||||
while (nowMs() < deadline) {
|
||||
try {
|
||||
if (fs.existsSync(responsePath)) {
|
||||
return JSON.parse(fs.readFileSync(responsePath, 'utf8')) as RuntimePluginPreflightResponse;
|
||||
}
|
||||
} catch {
|
||||
// retry until timeout
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type InstallManagedPluginAssetsViaAppDeps = {
|
||||
runAppCommandCaptureOutput?: typeof runAppCommandCaptureOutput;
|
||||
waitForInstallResponse?: typeof waitForInstallResponse;
|
||||
};
|
||||
|
||||
export async function installManagedPluginAssetsViaApp(
|
||||
options: {
|
||||
appPath: string;
|
||||
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
||||
},
|
||||
deps: InstallManagedPluginAssetsViaAppDeps = {},
|
||||
): Promise<EnsureLinuxRuntimePluginAssetsResult> {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-runtime-plugin-'));
|
||||
const responsePath = path.join(tempDir, 'response.json');
|
||||
const runAppCommand = deps.runAppCommandCaptureOutput ?? runAppCommandCaptureOutput;
|
||||
const waitForResponse = deps.waitForInstallResponse ?? waitForInstallResponse;
|
||||
try {
|
||||
const appArgs = [
|
||||
'--ensure-linux-runtime-plugin-assets',
|
||||
'--ensure-linux-runtime-plugin-assets-response-path',
|
||||
responsePath,
|
||||
];
|
||||
const result = runAppCommand(options.appPath, appArgs);
|
||||
if (result.error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 'failed',
|
||||
error: result.error.message,
|
||||
};
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr.trim();
|
||||
const stdout = result.stdout.trim();
|
||||
return {
|
||||
ok: false,
|
||||
status: 'failed',
|
||||
error:
|
||||
stderr ||
|
||||
stdout ||
|
||||
`Linux runtime plugin asset install command exited with status ${result.status}.`,
|
||||
};
|
||||
}
|
||||
const response = await waitForResponse(responsePath);
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
const stderr = result.stderr.trim();
|
||||
const stdout = result.stdout.trim();
|
||||
return {
|
||||
ok: false,
|
||||
status: 'failed',
|
||||
error:
|
||||
stderr ||
|
||||
stdout ||
|
||||
`Timed out waiting for Linux runtime plugin asset response after app exit status ${result.status}.`,
|
||||
};
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Avoid hiding the install failure or success result behind temp cleanup errors.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureLinuxRuntimePluginAvailable(
|
||||
options: EnsureLinuxRuntimePluginAvailableOptions,
|
||||
): Promise<void> {
|
||||
const platform = options.platform ?? process.platform;
|
||||
if (platform !== 'linux') {
|
||||
return;
|
||||
}
|
||||
|
||||
const configuredLogLevel = resolveConfiguredLogLevel(options.logLevel);
|
||||
const log = options.log ?? launcherLog;
|
||||
const homeDir = options.homeDir ?? os.homedir();
|
||||
const detectInstalledPlugin =
|
||||
options.detectInstalledPlugin ??
|
||||
(() =>
|
||||
detectInstalledMpvPlugin({
|
||||
platform,
|
||||
homeDir,
|
||||
xdgConfigHome: options.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
|
||||
appDataDir: options.appDataDir ?? process.env.APPDATA,
|
||||
}).installed);
|
||||
const installedPluginAvailable = detectInstalledPlugin();
|
||||
const managedPaths = resolveManagedLinuxRuntimePluginPaths({
|
||||
homeDir,
|
||||
xdgDataHome: options.xdgDataHome ?? process.env.XDG_DATA_HOME,
|
||||
});
|
||||
|
||||
const resolveRuntimePluginPath =
|
||||
options.resolveRuntimePluginPath ??
|
||||
(() => {
|
||||
if (!options.appPath) return null;
|
||||
return resolveLauncherRuntimePluginPath({
|
||||
appPath: options.appPath,
|
||||
scriptPath: options.scriptPath,
|
||||
platform,
|
||||
homeDir,
|
||||
env: process.env,
|
||||
});
|
||||
});
|
||||
const isManagedThemeAvailable =
|
||||
options.isManagedThemeAvailable ?? (() => fs.existsSync(managedPaths.themePath));
|
||||
const runtimePluginAvailable = installedPluginAvailable || Boolean(resolveRuntimePluginPath());
|
||||
if (runtimePluginAvailable && isManagedThemeAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
log(
|
||||
'info',
|
||||
configuredLogLevel,
|
||||
'Linux runtime support assets missing; installing managed plugin/theme assets.',
|
||||
);
|
||||
const installManagedPluginAssets =
|
||||
options.installManagedPluginAssets ??
|
||||
(() => {
|
||||
if (!options.appPath) {
|
||||
throw new Error(
|
||||
'Linux managed runtime plugin assets could not be installed. Launch aborted before starting mpv.',
|
||||
);
|
||||
}
|
||||
return installManagedPluginAssetsViaApp({
|
||||
appPath: options.appPath,
|
||||
logLevel: options.logLevel,
|
||||
});
|
||||
});
|
||||
const installResult = await installManagedPluginAssets();
|
||||
if (!installResult.ok) {
|
||||
const message = installResult.error || 'Unknown Linux runtime plugin asset install failure.';
|
||||
log(
|
||||
'warn',
|
||||
configuredLogLevel,
|
||||
`Managed Linux runtime support asset install failed: ${message}`,
|
||||
);
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
log(
|
||||
'info',
|
||||
configuredLogLevel,
|
||||
`Managed Linux runtime support assets installed: plugin=${installResult.path ?? 'unknown path'} theme=${managedPaths.themePath}`,
|
||||
);
|
||||
const runtimePluginPath = resolveRuntimePluginPath();
|
||||
if (runtimePluginPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message =
|
||||
`Linux managed runtime plugin assets could not be installed. ` +
|
||||
`Checked path: ${managedPaths.pluginEntrypointPath}. ` +
|
||||
'Launch aborted before starting mpv.';
|
||||
log('warn', configuredLogLevel, message);
|
||||
throw new Error(message);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type SmokeCase = {
|
||||
artifactsDir: string;
|
||||
binDir: string;
|
||||
xdgConfigHome: string;
|
||||
xdgDataHome: string;
|
||||
appDataDir: string;
|
||||
localAppDataDir: string;
|
||||
homeDir: string;
|
||||
@@ -64,6 +65,7 @@ function createSmokeCase(name: string): SmokeCase {
|
||||
const artifactsDir = path.join(root, 'artifacts');
|
||||
const binDir = path.join(root, 'bin');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const xdgDataHome = path.join(root, 'xdg-data');
|
||||
const appDataDir = path.join(root, 'AppData', 'Roaming');
|
||||
const localAppDataDir = path.join(root, 'AppData', 'Local');
|
||||
const homeDir = path.join(root, 'home');
|
||||
@@ -135,6 +137,7 @@ process.on('SIGTERM', closeAndExit);
|
||||
fakeAppBasePath,
|
||||
`#!/usr/bin/env bun
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const logPath = ${JSON.stringify(fakeAppLogPath)};
|
||||
const startPath = ${JSON.stringify(fakeAppStartLogPath)};
|
||||
@@ -154,6 +157,25 @@ if (entry.argv.includes('--stop')) {
|
||||
if (entry.argv.includes('--app-ping')) {
|
||||
process.exit(process.env.SUBMINER_FAKE_APP_RUNNING === '1' ? 0 : 1);
|
||||
}
|
||||
if (entry.argv.includes('--ensure-linux-runtime-plugin-assets')) {
|
||||
const responseFlagIndex = entry.argv.indexOf('--ensure-linux-runtime-plugin-assets-response-path');
|
||||
const responsePath = responseFlagIndex >= 0 ? entry.argv[responseFlagIndex + 1] : '';
|
||||
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(process.env.HOME || '', '.local', 'share');
|
||||
const dataDir = path.join(xdgDataHome, 'SubMiner');
|
||||
const pluginDir = path.join(dataDir, 'plugin', 'subminer');
|
||||
const pluginConfigPath = path.join(dataDir, 'plugin', 'subminer.conf');
|
||||
const themePath = path.join(dataDir, 'themes', 'subminer.rasi');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.mkdirSync(path.dirname(themePath), { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginDir, 'main.lua'), '-- smoke plugin\\n');
|
||||
fs.writeFileSync(pluginConfigPath, 'smoke=true\\n');
|
||||
fs.writeFileSync(themePath, '/* smoke theme */\\n');
|
||||
if (responsePath) {
|
||||
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
||||
fs.writeFileSync(responsePath, JSON.stringify({ ok: true, status: 'installed', path: path.join(pluginDir, 'main.lua') }));
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
`,
|
||||
@@ -164,6 +186,7 @@ process.exit(0);
|
||||
artifactsDir,
|
||||
binDir,
|
||||
xdgConfigHome,
|
||||
xdgDataHome,
|
||||
appDataDir,
|
||||
localAppDataDir,
|
||||
homeDir,
|
||||
@@ -181,6 +204,7 @@ function makeTestEnv(smokeCase: SmokeCase): NodeJS.ProcessEnv {
|
||||
...process.env,
|
||||
HOME: smokeCase.homeDir,
|
||||
XDG_CONFIG_HOME: smokeCase.xdgConfigHome,
|
||||
XDG_DATA_HOME: smokeCase.xdgDataHome,
|
||||
APPDATA: smokeCase.appDataDir,
|
||||
LOCALAPPDATA: smokeCase.localAppDataDir,
|
||||
SUBMINER_APPIMAGE_PATH: smokeCase.fakeAppPath,
|
||||
@@ -495,7 +519,7 @@ test(
|
||||
);
|
||||
|
||||
test(
|
||||
'launcher start-overlay attaches to a running background app without spawning another app command',
|
||||
'launcher start-overlay attaches to a running background app without spawning another app start command',
|
||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
|
||||
@@ -530,7 +554,15 @@ test(
|
||||
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
|
||||
|
||||
assert.equal(result.status, unixSocketDenied ? 3 : 0);
|
||||
assert.equal(appEntries.length, 0);
|
||||
if (process.platform === 'linux') {
|
||||
assert.equal(appEntries.length > 0, true);
|
||||
assert.equal(
|
||||
appEntries.every((entry) =>
|
||||
(entry.argv as string[]).includes('--ensure-linux-runtime-plugin-assets'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
assert.equal(appStartEntries.length, 0);
|
||||
assert.equal(appStopEntries.length, 0);
|
||||
assert.equal(controlEntries.length, 1);
|
||||
@@ -587,6 +619,13 @@ test(
|
||||
/subminer-auto_start_pause_until_ready_owns_initial_pause=yes/,
|
||||
);
|
||||
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
|
||||
if (process.platform === 'linux') {
|
||||
assert.match(result.stdout, /managed plugin\/theme assets/i);
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(smokeCase.xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi')),
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { insertRow, tableExists, type SyncMergeSummary } from './sync-shared.js';
|
||||
|
||||
const ANIME_COPY_COLUMNS = [
|
||||
'normalized_title_key',
|
||||
'canonical_title',
|
||||
'anilist_id',
|
||||
'title_romaji',
|
||||
'title_english',
|
||||
'title_native',
|
||||
'episodes_total',
|
||||
'description',
|
||||
'metadata_json',
|
||||
'CREATED_DATE',
|
||||
'LAST_UPDATE_DATE',
|
||||
] as const;
|
||||
|
||||
const VIDEO_COPY_COLUMNS = [
|
||||
'video_key',
|
||||
'canonical_title',
|
||||
'source_type',
|
||||
'source_path',
|
||||
'source_url',
|
||||
'parsed_basename',
|
||||
'parsed_title',
|
||||
'parsed_season',
|
||||
'parsed_episode',
|
||||
'parser_source',
|
||||
'parser_confidence',
|
||||
'parse_metadata_json',
|
||||
'watched',
|
||||
'duration_ms',
|
||||
'file_size_bytes',
|
||||
'codec_id',
|
||||
'container_id',
|
||||
'width_px',
|
||||
'height_px',
|
||||
'fps_x100',
|
||||
'bitrate_kbps',
|
||||
'audio_codec_id',
|
||||
'hash_sha256',
|
||||
'screenshot_path',
|
||||
'metadata_json',
|
||||
'CREATED_DATE',
|
||||
'LAST_UPDATE_DATE',
|
||||
] as const;
|
||||
|
||||
const MEDIA_ART_COPY_COLUMNS = [
|
||||
'anilist_id',
|
||||
'cover_url',
|
||||
'cover_blob',
|
||||
'cover_blob_hash',
|
||||
'title_romaji',
|
||||
'title_english',
|
||||
'episodes_total',
|
||||
'fetched_at_ms',
|
||||
'CREATED_DATE',
|
||||
'LAST_UPDATE_DATE',
|
||||
] as const;
|
||||
|
||||
const YOUTUBE_COPY_COLUMNS = [
|
||||
'youtube_video_id',
|
||||
'video_url',
|
||||
'video_title',
|
||||
'video_thumbnail_url',
|
||||
'channel_id',
|
||||
'channel_name',
|
||||
'channel_url',
|
||||
'channel_thumbnail_url',
|
||||
'uploader_id',
|
||||
'uploader_url',
|
||||
'description',
|
||||
'metadata_json',
|
||||
'fetched_at_ms',
|
||||
'CREATED_DATE',
|
||||
'LAST_UPDATE_DATE',
|
||||
] as const;
|
||||
|
||||
const WORD_COPY_COLUMNS = [
|
||||
'headword',
|
||||
'word',
|
||||
'reading',
|
||||
'part_of_speech',
|
||||
'pos1',
|
||||
'pos2',
|
||||
'pos3',
|
||||
'first_seen',
|
||||
'last_seen',
|
||||
'frequency',
|
||||
'frequency_rank',
|
||||
] as const;
|
||||
|
||||
type SqlRow = Record<string, unknown>;
|
||||
|
||||
function selectAll(db: Database, sql: string, params: unknown[] = []): SqlRow[] {
|
||||
return db.query<SqlRow>(sql).all(...params);
|
||||
}
|
||||
|
||||
export function mergeAnime(
|
||||
local: Database,
|
||||
remote: Database,
|
||||
summary: SyncMergeSummary,
|
||||
): Map<number, number> {
|
||||
const map = new Map<number, number>();
|
||||
const byAnilist = local.prepare<SqlRow>('SELECT anime_id FROM imm_anime WHERE anilist_id = ?');
|
||||
const byTitleKey = local.prepare<SqlRow>(
|
||||
'SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?',
|
||||
);
|
||||
const fillMissing = local.prepare(
|
||||
`UPDATE imm_anime
|
||||
SET
|
||||
title_romaji = COALESCE(title_romaji, ?),
|
||||
title_english = COALESCE(title_english, ?),
|
||||
title_native = COALESCE(title_native, ?),
|
||||
episodes_total = COALESCE(episodes_total, ?),
|
||||
description = COALESCE(description, ?)
|
||||
WHERE anime_id = ?`,
|
||||
);
|
||||
|
||||
for (const row of selectAll(
|
||||
remote,
|
||||
`SELECT anime_id, ${ANIME_COPY_COLUMNS.join(', ')} FROM imm_anime`,
|
||||
)) {
|
||||
const remoteId = Number(row.anime_id);
|
||||
const existing =
|
||||
(row.anilist_id !== null ? byAnilist.get(row.anilist_id) : undefined) ??
|
||||
byTitleKey.get(row.normalized_title_key);
|
||||
if (existing) {
|
||||
const localId = Number(existing.anime_id);
|
||||
map.set(remoteId, localId);
|
||||
fillMissing.run(
|
||||
row.title_romaji,
|
||||
row.title_english,
|
||||
row.title_native,
|
||||
row.episodes_total,
|
||||
row.description,
|
||||
localId,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// No local row matched by anilist_id (checked first in `existing` above)
|
||||
// or title key, so the remote anilist_id — if any — is free to insert as-is.
|
||||
const values = ANIME_COPY_COLUMNS.map((column) => row[column]);
|
||||
map.set(remoteId, insertRow(local, 'imm_anime', ANIME_COPY_COLUMNS, values));
|
||||
summary.animeAdded += 1;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export interface VideoMergeResult {
|
||||
videoIdMap: Map<number, number>;
|
||||
addedVideoIds: Set<number>;
|
||||
}
|
||||
|
||||
export function mergeVideos(
|
||||
local: Database,
|
||||
remote: Database,
|
||||
animeIdMap: Map<number, number>,
|
||||
summary: SyncMergeSummary,
|
||||
): VideoMergeResult {
|
||||
const videoIdMap = new Map<number, number>();
|
||||
const addedVideoIds = new Set<number>();
|
||||
const byKey = local.prepare<SqlRow>(
|
||||
'SELECT video_id, watched FROM imm_videos WHERE video_key = ?',
|
||||
);
|
||||
const setWatched = local.prepare('UPDATE imm_videos SET watched = 1 WHERE video_id = ?');
|
||||
|
||||
for (const row of selectAll(
|
||||
remote,
|
||||
`SELECT video_id, anime_id, ${VIDEO_COPY_COLUMNS.join(', ')} FROM imm_videos`,
|
||||
)) {
|
||||
const remoteId = Number(row.video_id);
|
||||
const mappedAnimeId =
|
||||
row.anime_id === null ? null : (animeIdMap.get(Number(row.anime_id)) ?? null);
|
||||
const existing = byKey.get(row.video_key);
|
||||
if (existing) {
|
||||
const localId = Number(existing.video_id);
|
||||
videoIdMap.set(remoteId, localId);
|
||||
if (Number(row.watched) > 0 && Number(existing.watched) <= 0) {
|
||||
setWatched.run(localId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const columns = ['anime_id', ...VIDEO_COPY_COLUMNS];
|
||||
const values = [mappedAnimeId, ...VIDEO_COPY_COLUMNS.map((column) => row[column])];
|
||||
const localId = insertRow(local, 'imm_videos', columns, values);
|
||||
videoIdMap.set(remoteId, localId);
|
||||
addedVideoIds.add(remoteId);
|
||||
summary.videosAdded += 1;
|
||||
}
|
||||
return { videoIdMap, addedVideoIds };
|
||||
}
|
||||
|
||||
export function mergeMediaMetadata(
|
||||
local: Database,
|
||||
remote: Database,
|
||||
videoIdMap: Map<number, number>,
|
||||
addedVideoIds: Set<number>,
|
||||
): void {
|
||||
if (videoIdMap.size === 0) return;
|
||||
const metadataVideoIds = new Set<number>([...addedVideoIds, ...videoIdMap.keys()]);
|
||||
|
||||
const hasBlobStore =
|
||||
tableExists(local, 'imm_cover_art_blobs') && tableExists(remote, 'imm_cover_art_blobs');
|
||||
const copyBlob = hasBlobStore
|
||||
? local.prepare(
|
||||
`INSERT INTO imm_cover_art_blobs (blob_hash, cover_blob, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(blob_hash) DO NOTHING`,
|
||||
)
|
||||
: null;
|
||||
const readBlob = hasBlobStore
|
||||
? remote.prepare<SqlRow>('SELECT * FROM imm_cover_art_blobs WHERE blob_hash = ?')
|
||||
: null;
|
||||
|
||||
if (tableExists(remote, 'imm_media_art') && tableExists(local, 'imm_media_art')) {
|
||||
const localArtExists = local.prepare<SqlRow>(
|
||||
'SELECT 1 FROM imm_media_art WHERE video_id = ? LIMIT 1',
|
||||
);
|
||||
for (const remoteVideoId of metadataVideoIds) {
|
||||
const localVideoId = videoIdMap.get(remoteVideoId)!;
|
||||
if (localArtExists.get(localVideoId)) continue;
|
||||
const row = remote
|
||||
.query<SqlRow>(
|
||||
`SELECT ${MEDIA_ART_COPY_COLUMNS.join(', ')} FROM imm_media_art WHERE video_id = ?`,
|
||||
)
|
||||
.get(remoteVideoId);
|
||||
if (!row) continue;
|
||||
if (row.cover_blob_hash && copyBlob && readBlob) {
|
||||
const blob = readBlob.get(row.cover_blob_hash);
|
||||
if (blob) {
|
||||
copyBlob.run(blob.blob_hash, blob.cover_blob, blob.CREATED_DATE, blob.LAST_UPDATE_DATE);
|
||||
}
|
||||
}
|
||||
insertRow(
|
||||
local,
|
||||
'imm_media_art',
|
||||
['video_id', ...MEDIA_ART_COPY_COLUMNS],
|
||||
[localVideoId, ...MEDIA_ART_COPY_COLUMNS.map((column) => row[column])],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (tableExists(remote, 'imm_youtube_videos') && tableExists(local, 'imm_youtube_videos')) {
|
||||
const localYoutubeExists = local.prepare<SqlRow>(
|
||||
'SELECT 1 FROM imm_youtube_videos WHERE video_id = ? LIMIT 1',
|
||||
);
|
||||
for (const remoteVideoId of metadataVideoIds) {
|
||||
const localVideoId = videoIdMap.get(remoteVideoId)!;
|
||||
if (localYoutubeExists.get(localVideoId)) continue;
|
||||
const row = remote
|
||||
.query<SqlRow>(
|
||||
`SELECT ${YOUTUBE_COPY_COLUMNS.join(', ')} FROM imm_youtube_videos WHERE video_id = ?`,
|
||||
)
|
||||
.get(remoteVideoId);
|
||||
if (!row) continue;
|
||||
insertRow(
|
||||
local,
|
||||
'imm_youtube_videos',
|
||||
['video_id', ...YOUTUBE_COPY_COLUMNS],
|
||||
[localVideoId, ...YOUTUBE_COPY_COLUMNS.map((column) => row[column])],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeExcludedWords(
|
||||
local: Database,
|
||||
remote: Database,
|
||||
summary: SyncMergeSummary,
|
||||
): void {
|
||||
if (
|
||||
!tableExists(remote, 'imm_stats_excluded_words') ||
|
||||
!tableExists(local, 'imm_stats_excluded_words')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const insert = local.prepare(
|
||||
`INSERT INTO imm_stats_excluded_words (headword, word, reading, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(headword, word, reading) DO NOTHING`,
|
||||
);
|
||||
for (const row of selectAll(
|
||||
remote,
|
||||
'SELECT headword, word, reading, CREATED_DATE, LAST_UPDATE_DATE FROM imm_stats_excluded_words',
|
||||
)) {
|
||||
const result = insert.run(
|
||||
row.headword,
|
||||
row.word,
|
||||
row.reading,
|
||||
row.CREATED_DATE,
|
||||
row.LAST_UPDATE_DATE,
|
||||
);
|
||||
summary.excludedWordsAdded += result.changes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily maps remote imm_words / imm_kanji ids onto local rows by natural key
|
||||
* ((headword, word, reading) / kanji). New rows are copied with the remote's
|
||||
* accumulated frequency; rows that already exist locally get their frequency
|
||||
* incremented later with only the occurrence counts this merge adds (the
|
||||
* remote total would double-count lines merged in earlier syncs).
|
||||
*/
|
||||
export class LexiconResolver {
|
||||
private readonly wordMap = new Map<number, { localId: number; isNew: boolean }>();
|
||||
private readonly kanjiMap = new Map<number, { localId: number; isNew: boolean }>();
|
||||
readonly wordFrequencyDeltas = new Map<number, number>();
|
||||
readonly kanjiFrequencyDeltas = new Map<number, number>();
|
||||
|
||||
constructor(
|
||||
private readonly local: Database,
|
||||
private readonly remote: Database,
|
||||
private readonly summary: SyncMergeSummary,
|
||||
) {}
|
||||
|
||||
resolveWord(remoteWordId: number): number {
|
||||
const cached = this.wordMap.get(remoteWordId);
|
||||
if (cached) return cached.localId;
|
||||
|
||||
const row = this.remote
|
||||
.query<SqlRow>(`SELECT ${WORD_COPY_COLUMNS.join(', ')} FROM imm_words WHERE id = ?`)
|
||||
.get(remoteWordId);
|
||||
if (!row) throw new Error(`Snapshot references missing imm_words row ${remoteWordId}`);
|
||||
|
||||
const existing = this.local
|
||||
.query<SqlRow>('SELECT id FROM imm_words WHERE headword IS ? AND word IS ? AND reading IS ?')
|
||||
.get(row.headword, row.word, row.reading);
|
||||
let entry: { localId: number; isNew: boolean };
|
||||
if (existing) {
|
||||
entry = { localId: Number(existing.id), isNew: false };
|
||||
this.local
|
||||
.prepare(
|
||||
`UPDATE imm_words
|
||||
SET first_seen = MIN(COALESCE(first_seen, ?), COALESCE(?, first_seen)),
|
||||
last_seen = MAX(COALESCE(last_seen, ?), COALESCE(?, last_seen))
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(row.first_seen, row.first_seen, row.last_seen, row.last_seen, entry.localId);
|
||||
} else {
|
||||
const localId = insertRow(
|
||||
this.local,
|
||||
'imm_words',
|
||||
WORD_COPY_COLUMNS,
|
||||
WORD_COPY_COLUMNS.map((column) => row[column]),
|
||||
);
|
||||
entry = { localId, isNew: true };
|
||||
this.summary.wordsAdded += 1;
|
||||
}
|
||||
this.wordMap.set(remoteWordId, entry);
|
||||
return entry.localId;
|
||||
}
|
||||
|
||||
resolveKanji(remoteKanjiId: number): number {
|
||||
const cached = this.kanjiMap.get(remoteKanjiId);
|
||||
if (cached) return cached.localId;
|
||||
|
||||
const row = this.remote
|
||||
.query<SqlRow>('SELECT kanji, first_seen, last_seen, frequency FROM imm_kanji WHERE id = ?')
|
||||
.get(remoteKanjiId);
|
||||
if (!row) throw new Error(`Snapshot references missing imm_kanji row ${remoteKanjiId}`);
|
||||
|
||||
const existing = this.local
|
||||
.query<SqlRow>('SELECT id FROM imm_kanji WHERE kanji IS ?')
|
||||
.get(row.kanji);
|
||||
let entry: { localId: number; isNew: boolean };
|
||||
if (existing) {
|
||||
entry = { localId: Number(existing.id), isNew: false };
|
||||
this.local
|
||||
.prepare(
|
||||
`UPDATE imm_kanji
|
||||
SET first_seen = MIN(COALESCE(first_seen, ?), COALESCE(?, first_seen)),
|
||||
last_seen = MAX(COALESCE(last_seen, ?), COALESCE(?, last_seen))
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(row.first_seen, row.first_seen, row.last_seen, row.last_seen, entry.localId);
|
||||
} else {
|
||||
const localId = insertRow(
|
||||
this.local,
|
||||
'imm_kanji',
|
||||
['kanji', 'first_seen', 'last_seen', 'frequency'],
|
||||
[row.kanji, row.first_seen, row.last_seen, row.frequency],
|
||||
);
|
||||
entry = { localId, isNew: true };
|
||||
this.summary.kanjiAdded += 1;
|
||||
}
|
||||
this.kanjiMap.set(remoteKanjiId, entry);
|
||||
return entry.localId;
|
||||
}
|
||||
|
||||
addWordOccurrences(remoteWordId: number, count: number): void {
|
||||
const entry = this.wordMap.get(remoteWordId);
|
||||
if (!entry || entry.isNew) return;
|
||||
this.wordFrequencyDeltas.set(
|
||||
entry.localId,
|
||||
(this.wordFrequencyDeltas.get(entry.localId) ?? 0) + count,
|
||||
);
|
||||
}
|
||||
|
||||
addKanjiOccurrences(remoteKanjiId: number, count: number): void {
|
||||
const entry = this.kanjiMap.get(remoteKanjiId);
|
||||
if (!entry || entry.isNew) return;
|
||||
this.kanjiFrequencyDeltas.set(
|
||||
entry.localId,
|
||||
(this.kanjiFrequencyDeltas.get(entry.localId) ?? 0) + count,
|
||||
);
|
||||
}
|
||||
|
||||
applyFrequencyDeltas(): void {
|
||||
const updateWord = this.local.prepare(
|
||||
'UPDATE imm_words SET frequency = COALESCE(frequency, 0) + ? WHERE id = ?',
|
||||
);
|
||||
for (const [localId, delta] of this.wordFrequencyDeltas) {
|
||||
updateWord.run(delta, localId);
|
||||
}
|
||||
const updateKanji = this.local.prepare(
|
||||
'UPDATE imm_kanji SET frequency = COALESCE(frequency, 0) + ? WHERE id = ?',
|
||||
);
|
||||
for (const [localId, delta] of this.kanjiFrequencyDeltas) {
|
||||
updateKanji.run(delta, localId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { nowDbTimestamp, tableExists, type SyncMergeSummary } from './sync-shared.js';
|
||||
|
||||
type SqlRow = Record<string, unknown>;
|
||||
|
||||
const LOCAL_DAY_EXPR = `CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER)`;
|
||||
const LOCAL_MONTH_EXPR = `CAST(strftime('%Y%m', CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') AS INTEGER)`;
|
||||
|
||||
// Ported from upsertDailyRollupsForGroups / upsertMonthlyRollupsForGroups in
|
||||
// src/core/services/immersion-tracker/maintenance.ts — must stay in sync.
|
||||
const DAILY_ROLLUP_UPSERT = `
|
||||
WITH matching_sessions AS (
|
||||
SELECT * FROM imm_sessions
|
||||
WHERE ${LOCAL_DAY_EXPR} = ? AND video_id = ?
|
||||
),
|
||||
session_metrics AS (
|
||||
SELECT
|
||||
t.session_id,
|
||||
MAX(t.active_watched_ms) AS max_active_ms,
|
||||
MAX(t.lines_seen) AS max_lines,
|
||||
MAX(t.tokens_seen) AS max_tokens,
|
||||
MAX(t.cards_mined) AS max_cards,
|
||||
MAX(t.lookup_count) AS max_lookups,
|
||||
MAX(t.lookup_hits) AS max_hits
|
||||
FROM imm_session_telemetry t
|
||||
JOIN matching_sessions s ON s.session_id = t.session_id
|
||||
GROUP BY t.session_id
|
||||
)
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, cards_per_hour, tokens_per_min, lookup_hit_rate,
|
||||
CREATED_DATE, LAST_UPDATE_DATE
|
||||
)
|
||||
SELECT
|
||||
${LOCAL_DAY_EXPR.replace('started_at_ms', 's.started_at_ms')} AS rollup_day,
|
||||
s.video_id AS video_id,
|
||||
COUNT(DISTINCT s.session_id) AS total_sessions,
|
||||
COALESCE(SUM(COALESCE(sm.max_active_ms, s.active_watched_ms)), 0) / 60000.0 AS total_active_min,
|
||||
COALESCE(SUM(COALESCE(sm.max_lines, s.lines_seen)), 0) AS total_lines_seen,
|
||||
COALESCE(SUM(COALESCE(sm.max_tokens, s.tokens_seen)), 0) AS total_tokens_seen,
|
||||
COALESCE(SUM(COALESCE(sm.max_cards, s.cards_mined)), 0) AS total_cards,
|
||||
CASE
|
||||
WHEN COALESCE(SUM(COALESCE(sm.max_active_ms, s.active_watched_ms)), 0) > 0
|
||||
THEN (COALESCE(SUM(COALESCE(sm.max_cards, s.cards_mined)), 0) * 60.0)
|
||||
/ (COALESCE(SUM(COALESCE(sm.max_active_ms, s.active_watched_ms)), 0) / 60000.0)
|
||||
ELSE NULL
|
||||
END AS cards_per_hour,
|
||||
CASE
|
||||
WHEN COALESCE(SUM(COALESCE(sm.max_active_ms, s.active_watched_ms)), 0) > 0
|
||||
THEN COALESCE(SUM(COALESCE(sm.max_tokens, s.tokens_seen)), 0)
|
||||
/ (COALESCE(SUM(COALESCE(sm.max_active_ms, s.active_watched_ms)), 0) / 60000.0)
|
||||
ELSE NULL
|
||||
END AS tokens_per_min,
|
||||
CASE
|
||||
WHEN COALESCE(SUM(COALESCE(sm.max_lookups, s.lookup_count)), 0) > 0
|
||||
THEN CAST(COALESCE(SUM(COALESCE(sm.max_hits, s.lookup_hits)), 0) AS REAL)
|
||||
/ CAST(COALESCE(SUM(COALESCE(sm.max_lookups, s.lookup_count)), 0) AS REAL)
|
||||
ELSE NULL
|
||||
END AS lookup_hit_rate,
|
||||
? AS CREATED_DATE,
|
||||
? AS LAST_UPDATE_DATE
|
||||
FROM matching_sessions s
|
||||
LEFT JOIN session_metrics sm ON s.session_id = sm.session_id
|
||||
GROUP BY rollup_day, s.video_id
|
||||
ON CONFLICT (rollup_day, video_id) DO UPDATE SET
|
||||
total_sessions = excluded.total_sessions,
|
||||
total_active_min = excluded.total_active_min,
|
||||
total_lines_seen = excluded.total_lines_seen,
|
||||
total_tokens_seen = excluded.total_tokens_seen,
|
||||
total_cards = excluded.total_cards,
|
||||
cards_per_hour = excluded.cards_per_hour,
|
||||
tokens_per_min = excluded.tokens_per_min,
|
||||
lookup_hit_rate = excluded.lookup_hit_rate,
|
||||
CREATED_DATE = COALESCE(imm_daily_rollups.CREATED_DATE, excluded.CREATED_DATE),
|
||||
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE
|
||||
`;
|
||||
|
||||
const MONTHLY_ROLLUP_UPSERT = `
|
||||
WITH matching_sessions AS (
|
||||
SELECT * FROM imm_sessions
|
||||
WHERE ${LOCAL_MONTH_EXPR} = ? AND video_id = ?
|
||||
),
|
||||
session_metrics AS (
|
||||
SELECT
|
||||
t.session_id,
|
||||
MAX(t.active_watched_ms) AS max_active_ms,
|
||||
MAX(t.lines_seen) AS max_lines,
|
||||
MAX(t.tokens_seen) AS max_tokens,
|
||||
MAX(t.cards_mined) AS max_cards
|
||||
FROM imm_session_telemetry t
|
||||
JOIN matching_sessions s ON s.session_id = t.session_id
|
||||
GROUP BY t.session_id
|
||||
)
|
||||
INSERT INTO imm_monthly_rollups (
|
||||
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
)
|
||||
SELECT
|
||||
${LOCAL_MONTH_EXPR.replace('started_at_ms', 's.started_at_ms')} AS rollup_month,
|
||||
s.video_id AS video_id,
|
||||
COUNT(DISTINCT s.session_id) AS total_sessions,
|
||||
COALESCE(SUM(COALESCE(sm.max_active_ms, s.active_watched_ms)), 0) / 60000.0 AS total_active_min,
|
||||
COALESCE(SUM(COALESCE(sm.max_lines, s.lines_seen)), 0) AS total_lines_seen,
|
||||
COALESCE(SUM(COALESCE(sm.max_tokens, s.tokens_seen)), 0) AS total_tokens_seen,
|
||||
COALESCE(SUM(COALESCE(sm.max_cards, s.cards_mined)), 0) AS total_cards,
|
||||
? AS CREATED_DATE,
|
||||
? AS LAST_UPDATE_DATE
|
||||
FROM matching_sessions s
|
||||
LEFT JOIN session_metrics sm ON s.session_id = sm.session_id
|
||||
GROUP BY rollup_month, s.video_id
|
||||
ON CONFLICT (rollup_month, video_id) DO UPDATE SET
|
||||
total_sessions = excluded.total_sessions,
|
||||
total_active_min = excluded.total_active_min,
|
||||
total_lines_seen = excluded.total_lines_seen,
|
||||
total_tokens_seen = excluded.total_tokens_seen,
|
||||
total_cards = excluded.total_cards,
|
||||
CREATED_DATE = COALESCE(imm_monthly_rollups.CREATED_DATE, excluded.CREATED_DATE),
|
||||
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE
|
||||
`;
|
||||
|
||||
/**
|
||||
* Recompute daily/monthly rollup groups touched by the newly merged sessions
|
||||
* from the (now merged) local session + telemetry data. The maintenance
|
||||
* watermark is left alone: telemetry newer than it gets recomputed again by
|
||||
* the app later, which is idempotent.
|
||||
*/
|
||||
export function refreshRollupsForNewSessions(
|
||||
local: Database,
|
||||
newSessionIds: number[],
|
||||
summary: SyncMergeSummary,
|
||||
): void {
|
||||
if (newSessionIds.length === 0) return;
|
||||
|
||||
const groups = new Map<string, { day: number; month: number; videoId: number }>();
|
||||
for (let offset = 0; offset < newSessionIds.length; offset += 500) {
|
||||
const chunk = newSessionIds.slice(offset, offset + 500);
|
||||
const rows = local
|
||||
.query<SqlRow>(
|
||||
`SELECT DISTINCT ${LOCAL_DAY_EXPR} AS rollup_day, ${LOCAL_MONTH_EXPR} AS rollup_month, video_id
|
||||
FROM imm_sessions WHERE session_id IN (${chunk.map(() => '?').join(',')})`,
|
||||
)
|
||||
.all(...chunk);
|
||||
for (const row of rows) {
|
||||
const day = Number(row.rollup_day);
|
||||
const month = Number(row.rollup_month);
|
||||
const videoId = Number(row.video_id);
|
||||
groups.set(`${day}-${videoId}`, { day, month, videoId });
|
||||
}
|
||||
}
|
||||
|
||||
const stampMs = nowDbTimestamp();
|
||||
const deleteDaily = local.prepare(
|
||||
'DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?',
|
||||
);
|
||||
const deleteMonthly = local.prepare(
|
||||
'DELETE FROM imm_monthly_rollups WHERE rollup_month = ? AND video_id = ?',
|
||||
);
|
||||
const upsertDaily = local.prepare(DAILY_ROLLUP_UPSERT);
|
||||
const upsertMonthly = local.prepare(MONTHLY_ROLLUP_UPSERT);
|
||||
|
||||
const monthlyGroups = new Set<string>();
|
||||
for (const { day, month, videoId } of groups.values()) {
|
||||
deleteDaily.run(day, videoId);
|
||||
upsertDaily.run(day, videoId, stampMs, stampMs);
|
||||
summary.rollupGroupsRecomputed += 1;
|
||||
const monthKey = `${month}-${videoId}`;
|
||||
if (!monthlyGroups.has(monthKey)) {
|
||||
monthlyGroups.add(monthKey);
|
||||
deleteMonthly.run(month, videoId);
|
||||
upsertMonthly.run(month, videoId, stampMs, stampMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions are pruned after a retention window, but rollups are kept much
|
||||
* longer — the remote's older rollup history can't be reconstructed from
|
||||
* merged sessions. Copy remote rollup rows for groups where the local DB has
|
||||
* neither a rollup row nor any sessions (i.e. history only the remote knows).
|
||||
* Groups both machines have data for are never summed, to avoid
|
||||
* double-counting sessions that earlier syncs already shared.
|
||||
*/
|
||||
export function copyRemoteOnlyRollups(
|
||||
local: Database,
|
||||
remote: Database,
|
||||
videoIdMap: Map<number, number>,
|
||||
summary: SyncMergeSummary,
|
||||
): void {
|
||||
if (!tableExists(remote, 'imm_daily_rollups') || !tableExists(local, 'imm_daily_rollups')) return;
|
||||
|
||||
const localDailyExists = local.prepare(
|
||||
'SELECT 1 FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ? LIMIT 1',
|
||||
);
|
||||
const localDaySessions = local.prepare(
|
||||
`SELECT 1 FROM imm_sessions WHERE video_id = ? AND ${LOCAL_DAY_EXPR} = ? LIMIT 1`,
|
||||
);
|
||||
const localMonthSessions = local.prepare(
|
||||
`SELECT 1 FROM imm_sessions WHERE video_id = ? AND ${LOCAL_MONTH_EXPR} = ? LIMIT 1`,
|
||||
);
|
||||
const localMonthSessionsForDay = local.prepare(
|
||||
`SELECT 1 FROM imm_sessions
|
||||
WHERE video_id = ?
|
||||
AND ${LOCAL_MONTH_EXPR} = CAST(strftime('%Y%m', CAST(? AS INTEGER) * 86400, 'unixepoch', 'localtime') AS INTEGER)
|
||||
LIMIT 1`,
|
||||
);
|
||||
const insertDaily = local.prepare(
|
||||
`INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, cards_per_hour, tokens_per_min, lookup_hit_rate,
|
||||
CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
for (const row of remote.query<SqlRow>('SELECT * FROM imm_daily_rollups').all()) {
|
||||
if (row.video_id === null) continue;
|
||||
const localVideoId = videoIdMap.get(Number(row.video_id));
|
||||
if (localVideoId === undefined) continue;
|
||||
if (localDailyExists.get(row.rollup_day, localVideoId)) continue;
|
||||
if (localDaySessions.get(localVideoId, row.rollup_day)) continue;
|
||||
if (localMonthSessionsForDay.get(localVideoId, row.rollup_day)) continue;
|
||||
insertDaily.run(
|
||||
row.rollup_day,
|
||||
localVideoId,
|
||||
row.total_sessions,
|
||||
row.total_active_min,
|
||||
row.total_lines_seen,
|
||||
row.total_tokens_seen,
|
||||
row.total_cards,
|
||||
row.cards_per_hour,
|
||||
row.tokens_per_min,
|
||||
row.lookup_hit_rate,
|
||||
row.CREATED_DATE,
|
||||
row.LAST_UPDATE_DATE,
|
||||
);
|
||||
summary.dailyRollupsCopied += 1;
|
||||
}
|
||||
|
||||
const localMonthlyExists = local.prepare(
|
||||
'SELECT 1 FROM imm_monthly_rollups WHERE rollup_month = ? AND video_id = ? LIMIT 1',
|
||||
);
|
||||
const insertMonthly = local.prepare(
|
||||
`INSERT INTO imm_monthly_rollups (
|
||||
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
for (const row of remote.query<SqlRow>('SELECT * FROM imm_monthly_rollups').all()) {
|
||||
if (row.video_id === null) continue;
|
||||
const localVideoId = videoIdMap.get(Number(row.video_id));
|
||||
if (localVideoId === undefined) continue;
|
||||
if (localMonthlyExists.get(row.rollup_month, localVideoId)) continue;
|
||||
if (localMonthSessions.get(localVideoId, row.rollup_month)) continue;
|
||||
insertMonthly.run(
|
||||
row.rollup_month,
|
||||
localVideoId,
|
||||
row.total_sessions,
|
||||
row.total_active_min,
|
||||
row.total_lines_seen,
|
||||
row.total_tokens_seen,
|
||||
row.total_cards,
|
||||
row.CREATED_DATE,
|
||||
row.LAST_UPDATE_DATE,
|
||||
);
|
||||
summary.monthlyRollupsCopied += 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import type { LexiconResolver } from './merge-catalog.js';
|
||||
import { insertRow, nowDbTimestamp, type SyncMergeSummary } from './sync-shared.js';
|
||||
|
||||
const SESSION_COPY_COLUMNS = [
|
||||
'session_uuid',
|
||||
'started_at_ms',
|
||||
'ended_at_ms',
|
||||
'status',
|
||||
'locale_id',
|
||||
'target_lang_id',
|
||||
'difficulty_tier',
|
||||
'subtitle_mode',
|
||||
'ended_media_ms',
|
||||
'total_watched_ms',
|
||||
'active_watched_ms',
|
||||
'lines_seen',
|
||||
'tokens_seen',
|
||||
'cards_mined',
|
||||
'lookup_count',
|
||||
'lookup_hits',
|
||||
'yomitan_lookup_count',
|
||||
'pause_count',
|
||||
'pause_ms',
|
||||
'seek_forward_count',
|
||||
'seek_backward_count',
|
||||
'media_buffer_events',
|
||||
'CREATED_DATE',
|
||||
'LAST_UPDATE_DATE',
|
||||
] as const;
|
||||
|
||||
const TELEMETRY_COPY_COLUMNS = [
|
||||
'sample_ms',
|
||||
'total_watched_ms',
|
||||
'active_watched_ms',
|
||||
'lines_seen',
|
||||
'tokens_seen',
|
||||
'cards_mined',
|
||||
'lookup_count',
|
||||
'lookup_hits',
|
||||
'yomitan_lookup_count',
|
||||
'pause_count',
|
||||
'pause_ms',
|
||||
'seek_forward_count',
|
||||
'seek_backward_count',
|
||||
'media_buffer_events',
|
||||
'CREATED_DATE',
|
||||
'LAST_UPDATE_DATE',
|
||||
] as const;
|
||||
|
||||
const EVENT_COPY_COLUMNS = [
|
||||
'ts_ms',
|
||||
'event_type',
|
||||
'line_index',
|
||||
'segment_start_ms',
|
||||
'segment_end_ms',
|
||||
'tokens_delta',
|
||||
'cards_delta',
|
||||
'payload_json',
|
||||
'CREATED_DATE',
|
||||
'LAST_UPDATE_DATE',
|
||||
] as const;
|
||||
|
||||
const LINE_COPY_COLUMNS = [
|
||||
'line_index',
|
||||
'segment_start_ms',
|
||||
'segment_end_ms',
|
||||
'text',
|
||||
'secondary_text',
|
||||
'CREATED_DATE',
|
||||
'LAST_UPDATE_DATE',
|
||||
] as const;
|
||||
|
||||
type SqlRow = Record<string, unknown>;
|
||||
|
||||
export interface SessionMergeResult {
|
||||
newSessionIds: number[];
|
||||
}
|
||||
|
||||
export function mergeSessions(
|
||||
local: Database,
|
||||
remote: Database,
|
||||
videoIdMap: Map<number, number>,
|
||||
animeIdMap: Map<number, number>,
|
||||
lexicon: LexiconResolver,
|
||||
summary: SyncMergeSummary,
|
||||
): SessionMergeResult {
|
||||
const newSessionIds: number[] = [];
|
||||
const uuidExists = local.prepare<SqlRow>(
|
||||
'SELECT session_id FROM imm_sessions WHERE session_uuid = ?',
|
||||
);
|
||||
|
||||
const remoteSessions = remote
|
||||
.query<SqlRow>(
|
||||
`SELECT session_id, video_id, ${SESSION_COPY_COLUMNS.join(', ')}
|
||||
FROM imm_sessions
|
||||
ORDER BY CAST(started_at_ms AS REAL) ASC, session_id ASC`,
|
||||
)
|
||||
.all();
|
||||
|
||||
for (const session of remoteSessions) {
|
||||
if (session.ended_at_ms === null) {
|
||||
// Stale ACTIVE sessions are finalized by the app on its next startup;
|
||||
// they will sync once they carry final numbers.
|
||||
summary.activeSessionsSkipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (uuidExists.get(session.session_uuid)) {
|
||||
summary.sessionsAlreadyPresent += 1;
|
||||
continue;
|
||||
}
|
||||
const localVideoId = videoIdMap.get(Number(session.video_id));
|
||||
if (localVideoId === undefined) {
|
||||
throw new Error(`Snapshot session ${String(session.session_uuid)} references missing video row`);
|
||||
}
|
||||
|
||||
const localSessionId = insertRow(
|
||||
local,
|
||||
'imm_sessions',
|
||||
['video_id', ...SESSION_COPY_COLUMNS],
|
||||
[localVideoId, ...SESSION_COPY_COLUMNS.map((column) => session[column])],
|
||||
);
|
||||
newSessionIds.push(localSessionId);
|
||||
summary.sessionsMerged += 1;
|
||||
|
||||
const remoteSessionId = Number(session.session_id);
|
||||
copyTelemetry(local, remote, remoteSessionId, localSessionId, summary);
|
||||
const eventIdMap = copyEvents(local, remote, remoteSessionId, localSessionId, summary);
|
||||
copySubtitleLines(
|
||||
local,
|
||||
remote,
|
||||
remoteSessionId,
|
||||
localSessionId,
|
||||
localVideoId,
|
||||
animeIdMap,
|
||||
eventIdMap,
|
||||
lexicon,
|
||||
summary,
|
||||
);
|
||||
applyMergedSessionLifetime(local, localSessionId, localVideoId, session);
|
||||
}
|
||||
|
||||
return { newSessionIds };
|
||||
}
|
||||
|
||||
function copyTelemetry(
|
||||
local: Database,
|
||||
remote: Database,
|
||||
remoteSessionId: number,
|
||||
localSessionId: number,
|
||||
summary: SyncMergeSummary,
|
||||
): void {
|
||||
const rows = remote
|
||||
.query<SqlRow>(
|
||||
`SELECT ${TELEMETRY_COPY_COLUMNS.join(', ')} FROM imm_session_telemetry
|
||||
WHERE session_id = ? ORDER BY telemetry_id ASC`,
|
||||
)
|
||||
.all(remoteSessionId);
|
||||
for (const row of rows) {
|
||||
insertRow(
|
||||
local,
|
||||
'imm_session_telemetry',
|
||||
['session_id', ...TELEMETRY_COPY_COLUMNS],
|
||||
[localSessionId, ...TELEMETRY_COPY_COLUMNS.map((column) => row[column])],
|
||||
);
|
||||
summary.telemetryRowsAdded += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function copyEvents(
|
||||
local: Database,
|
||||
remote: Database,
|
||||
remoteSessionId: number,
|
||||
localSessionId: number,
|
||||
summary: SyncMergeSummary,
|
||||
): Map<number, number> {
|
||||
const eventIdMap = new Map<number, number>();
|
||||
const rows = remote
|
||||
.query<SqlRow>(
|
||||
`SELECT event_id, ${EVENT_COPY_COLUMNS.join(', ')} FROM imm_session_events
|
||||
WHERE session_id = ? ORDER BY event_id ASC`,
|
||||
)
|
||||
.all(remoteSessionId);
|
||||
for (const row of rows) {
|
||||
const localEventId = insertRow(
|
||||
local,
|
||||
'imm_session_events',
|
||||
['session_id', ...EVENT_COPY_COLUMNS],
|
||||
[localSessionId, ...EVENT_COPY_COLUMNS.map((column) => row[column])],
|
||||
);
|
||||
eventIdMap.set(Number(row.event_id), localEventId);
|
||||
summary.eventsAdded += 1;
|
||||
}
|
||||
return eventIdMap;
|
||||
}
|
||||
|
||||
function copySubtitleLines(
|
||||
local: Database,
|
||||
remote: Database,
|
||||
remoteSessionId: number,
|
||||
localSessionId: number,
|
||||
localVideoId: number,
|
||||
animeIdMap: Map<number, number>,
|
||||
eventIdMap: Map<number, number>,
|
||||
lexicon: LexiconResolver,
|
||||
summary: SyncMergeSummary,
|
||||
): void {
|
||||
const rows = remote
|
||||
.query<SqlRow>(
|
||||
`SELECT line_id, event_id, anime_id, ${LINE_COPY_COLUMNS.join(', ')} FROM imm_subtitle_lines
|
||||
WHERE session_id = ? ORDER BY line_id ASC`,
|
||||
)
|
||||
.all(remoteSessionId);
|
||||
const wordOccurrences = remote.prepare<SqlRow>(
|
||||
'SELECT word_id, occurrence_count FROM imm_word_line_occurrences WHERE line_id = ?',
|
||||
);
|
||||
const kanjiOccurrences = remote.prepare<SqlRow>(
|
||||
'SELECT kanji_id, occurrence_count FROM imm_kanji_line_occurrences WHERE line_id = ?',
|
||||
);
|
||||
const insertWordOccurrence = local.prepare(
|
||||
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count) VALUES (?, ?, ?)
|
||||
ON CONFLICT(line_id, word_id) DO UPDATE SET occurrence_count = occurrence_count + excluded.occurrence_count`,
|
||||
);
|
||||
const insertKanjiOccurrence = local.prepare(
|
||||
`INSERT INTO imm_kanji_line_occurrences (line_id, kanji_id, occurrence_count) VALUES (?, ?, ?)
|
||||
ON CONFLICT(line_id, kanji_id) DO UPDATE SET occurrence_count = occurrence_count + excluded.occurrence_count`,
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
const localEventId = row.event_id === null ? null : (eventIdMap.get(Number(row.event_id)) ?? null);
|
||||
const localAnimeId = row.anime_id === null ? null : (animeIdMap.get(Number(row.anime_id)) ?? null);
|
||||
const localLineId = insertRow(
|
||||
local,
|
||||
'imm_subtitle_lines',
|
||||
['session_id', 'event_id', 'video_id', 'anime_id', ...LINE_COPY_COLUMNS],
|
||||
[
|
||||
localSessionId,
|
||||
localEventId,
|
||||
localVideoId,
|
||||
localAnimeId,
|
||||
...LINE_COPY_COLUMNS.map((column) => row[column]),
|
||||
],
|
||||
);
|
||||
summary.subtitleLinesAdded += 1;
|
||||
|
||||
for (const occurrence of wordOccurrences.all(row.line_id)) {
|
||||
const localWordId = lexicon.resolveWord(Number(occurrence.word_id));
|
||||
const count = Number(occurrence.occurrence_count);
|
||||
insertWordOccurrence.run(localLineId, localWordId, count);
|
||||
lexicon.addWordOccurrences(Number(occurrence.word_id), count);
|
||||
}
|
||||
for (const occurrence of kanjiOccurrences.all(row.line_id)) {
|
||||
const localKanjiId = lexicon.resolveKanji(Number(occurrence.kanji_id));
|
||||
const count = Number(occurrence.occurrence_count);
|
||||
insertKanjiOccurrence.run(localLineId, localKanjiId, count);
|
||||
lexicon.addKanjiOccurrences(Number(occurrence.kanji_id), count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Port of applySessionLifetimeSummary (src/core/services/immersion-tracker/
|
||||
* lifetime.ts) for sessions arriving out of chronological order. The
|
||||
* "first session of the day / for this video" checks are order-independent
|
||||
* here (any other session counts, not just earlier ones): the local machine
|
||||
* already credited active_days/episodes_started when its own session was
|
||||
* applied, even if the merged session started earlier that day.
|
||||
*/
|
||||
function applyMergedSessionLifetime(
|
||||
local: Database,
|
||||
sessionId: number,
|
||||
videoId: number,
|
||||
session: SqlRow,
|
||||
): void {
|
||||
const updatedAtMs = nowDbTimestamp();
|
||||
const applied = local
|
||||
.prepare(
|
||||
`INSERT INTO imm_lifetime_applied_sessions (session_id, applied_at_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(session_id) DO NOTHING`,
|
||||
)
|
||||
.run(sessionId, session.ended_at_ms, updatedAtMs, updatedAtMs);
|
||||
if (applied.changes <= 0) return;
|
||||
|
||||
const telemetry = local
|
||||
.query<SqlRow>(
|
||||
`SELECT active_watched_ms, cards_mined, lines_seen, tokens_seen
|
||||
FROM imm_session_telemetry
|
||||
WHERE session_id = ?
|
||||
ORDER BY sample_ms DESC, telemetry_id DESC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(sessionId);
|
||||
|
||||
const metric = (telemetryValue: unknown, sessionValue: unknown): number => {
|
||||
const fromTelemetry = telemetry ? Number(telemetryValue) : Number.NaN;
|
||||
const value = Number.isFinite(fromTelemetry) ? fromTelemetry : Number(sessionValue);
|
||||
return Math.max(0, Math.floor(Number.isFinite(value) ? value : 0));
|
||||
};
|
||||
const activeMs = metric(telemetry?.active_watched_ms, session.active_watched_ms);
|
||||
const cardsMined = metric(telemetry?.cards_mined, session.cards_mined);
|
||||
const linesSeen = metric(telemetry?.lines_seen, session.lines_seen);
|
||||
const tokensSeen = metric(telemetry?.tokens_seen, session.tokens_seen);
|
||||
|
||||
const video = local
|
||||
.query<SqlRow>('SELECT anime_id, watched FROM imm_videos WHERE video_id = ?')
|
||||
.get(videoId);
|
||||
const watched = Number(video?.watched ?? 0);
|
||||
const animeId = video?.anime_id === null || video?.anime_id === undefined ? null : Number(video.anime_id);
|
||||
|
||||
const mediaLifetime = local
|
||||
.query<SqlRow>('SELECT completed FROM imm_lifetime_media WHERE video_id = ?')
|
||||
.get(videoId);
|
||||
const hasOtherSessionForVideo = Boolean(
|
||||
local
|
||||
.query('SELECT 1 FROM imm_sessions WHERE video_id = ? AND session_id != ? LIMIT 1')
|
||||
.get(videoId, sessionId),
|
||||
);
|
||||
const isFirstSessionForVideoRun = !mediaLifetime && !hasOtherSessionForVideo;
|
||||
const isFirstCompletedSessionForVideoRun = watched > 0 && Number(mediaLifetime?.completed ?? 0) <= 0;
|
||||
|
||||
const hasOtherSessionOnDay = Boolean(
|
||||
local
|
||||
.query(
|
||||
`SELECT 1 FROM imm_sessions
|
||||
WHERE session_id != ?
|
||||
AND CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER)
|
||||
= CAST(julianday(CAST(? AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER)
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(sessionId, session.started_at_ms),
|
||||
);
|
||||
|
||||
let animeCompletedDelta = 0;
|
||||
if (animeId !== null && watched > 0 && isFirstCompletedSessionForVideoRun) {
|
||||
const animeLifetime = local
|
||||
.query<SqlRow>('SELECT episodes_completed FROM imm_lifetime_anime WHERE anime_id = ?')
|
||||
.get(animeId);
|
||||
const anime = local
|
||||
.query<SqlRow>('SELECT episodes_total FROM imm_anime WHERE anime_id = ?')
|
||||
.get(animeId);
|
||||
const episodesCompletedBefore = Number(animeLifetime?.episodes_completed ?? 0);
|
||||
const episodesTotal = anime?.episodes_total === null || anime?.episodes_total === undefined
|
||||
? null
|
||||
: Number(anime.episodes_total);
|
||||
if (
|
||||
episodesTotal !== null &&
|
||||
episodesTotal > 0 &&
|
||||
episodesCompletedBefore < episodesTotal &&
|
||||
episodesCompletedBefore + 1 >= episodesTotal
|
||||
) {
|
||||
animeCompletedDelta = 1;
|
||||
}
|
||||
}
|
||||
|
||||
local
|
||||
.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 + ?,
|
||||
anime_completed = anime_completed + ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE global_id = 1`,
|
||||
)
|
||||
.run(
|
||||
activeMs,
|
||||
cardsMined,
|
||||
hasOtherSessionOnDay ? 0 : 1,
|
||||
isFirstSessionForVideoRun ? 1 : 0,
|
||||
isFirstCompletedSessionForVideoRun ? 1 : 0,
|
||||
animeCompletedDelta,
|
||||
updatedAtMs,
|
||||
);
|
||||
|
||||
local
|
||||
.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),
|
||||
first_watched_ms = CASE
|
||||
WHEN excluded.first_watched_ms IS NULL THEN first_watched_ms
|
||||
WHEN first_watched_ms IS NULL THEN excluded.first_watched_ms
|
||||
WHEN excluded.first_watched_ms < first_watched_ms THEN excluded.first_watched_ms
|
||||
ELSE first_watched_ms
|
||||
END,
|
||||
last_watched_ms = CASE
|
||||
WHEN excluded.last_watched_ms IS NULL THEN last_watched_ms
|
||||
WHEN last_watched_ms IS NULL THEN excluded.last_watched_ms
|
||||
WHEN excluded.last_watched_ms > last_watched_ms THEN excluded.last_watched_ms
|
||||
ELSE last_watched_ms
|
||||
END,
|
||||
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE`,
|
||||
)
|
||||
.run(
|
||||
videoId,
|
||||
activeMs,
|
||||
cardsMined,
|
||||
linesSeen,
|
||||
tokensSeen,
|
||||
watched > 0 ? 1 : 0,
|
||||
session.started_at_ms,
|
||||
session.ended_at_ms,
|
||||
updatedAtMs,
|
||||
updatedAtMs,
|
||||
);
|
||||
|
||||
if (animeId !== null) {
|
||||
local
|
||||
.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,
|
||||
first_watched_ms = CASE
|
||||
WHEN excluded.first_watched_ms IS NULL THEN first_watched_ms
|
||||
WHEN first_watched_ms IS NULL THEN excluded.first_watched_ms
|
||||
WHEN excluded.first_watched_ms < first_watched_ms THEN excluded.first_watched_ms
|
||||
ELSE first_watched_ms
|
||||
END,
|
||||
last_watched_ms = CASE
|
||||
WHEN excluded.last_watched_ms IS NULL THEN last_watched_ms
|
||||
WHEN last_watched_ms IS NULL THEN excluded.last_watched_ms
|
||||
WHEN excluded.last_watched_ms > last_watched_ms THEN excluded.last_watched_ms
|
||||
ELSE last_watched_ms
|
||||
END,
|
||||
LAST_UPDATE_DATE = excluded.LAST_UPDATE_DATE`,
|
||||
)
|
||||
.run(
|
||||
animeId,
|
||||
activeMs,
|
||||
cardsMined,
|
||||
linesSeen,
|
||||
tokensSeen,
|
||||
isFirstSessionForVideoRun ? 1 : 0,
|
||||
isFirstCompletedSessionForVideoRun ? 1 : 0,
|
||||
session.started_at_ms,
|
||||
session.ended_at_ms,
|
||||
updatedAtMs,
|
||||
updatedAtMs,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { assertSafeSshHost, resolveRemoteSubminerCommand, runScp, shellQuote } from './ssh.js';
|
||||
|
||||
test('assertSafeSshHost rejects option-like hosts', () => {
|
||||
assert.throws(() => assertSafeSshHost('-oProxyCommand=touch pwned'), /looks like an option/);
|
||||
assert.throws(() => assertSafeSshHost('-lroot'), /looks like an option/);
|
||||
});
|
||||
|
||||
test('assertSafeSshHost accepts normal destinations', () => {
|
||||
assert.doesNotThrow(() => assertSafeSshHost('macbook'));
|
||||
assert.doesNotThrow(() => assertSafeSshHost('user@192.168.1.20'));
|
||||
assert.doesNotThrow(() => assertSafeSshHost('ssh-alias'));
|
||||
});
|
||||
|
||||
test('shellQuote escapes single quotes and wraps in quotes', () => {
|
||||
assert.equal(shellQuote('subminer'), `'subminer'`);
|
||||
assert.equal(shellQuote(`a'; rm -rf ~; '`), `'a'\\''; rm -rf ~; '\\'''`);
|
||||
});
|
||||
|
||||
test('runScp rejects option-like local endpoints before spawning scp', () => {
|
||||
assert.throws(() => runScp('-oProxyCommand=sh', '/tmp/out.sqlite'), /looks like an option/);
|
||||
assert.throws(() => runScp('/tmp/in.sqlite', '-bad-destination'), /looks like an option/);
|
||||
});
|
||||
|
||||
test('runScp rejects option-like remote host components', () => {
|
||||
assert.throws(
|
||||
() => runScp('-oProxyCommand=sh:/tmp/in.sqlite', '/tmp/out.sqlite'),
|
||||
/SSH host that looks like an option/,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveRemoteSubminerCommand verifies the launcher under the remote runtime PATH', () => {
|
||||
const calls: Array<{ host: string; remoteCommand: string }> = [];
|
||||
const command = resolveRemoteSubminerCommand('macbook', null, (host, remoteCommand) => {
|
||||
calls.push({ host, remoteCommand });
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
command,
|
||||
'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH" subminer',
|
||||
);
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
host: 'macbook',
|
||||
remoteCommand:
|
||||
'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH" subminer --help >/dev/null 2>&1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
export interface RemoteRunResult {
|
||||
status: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ssh/scp have no `--` terminator for the destination, so a host that starts
|
||||
* with `-` (e.g. `-oProxyCommand=...`) is parsed as an option. Reject those
|
||||
* before spawning.
|
||||
*/
|
||||
export function assertSafeSshHost(host: string): void {
|
||||
if (host.startsWith('-')) {
|
||||
throw new Error(`Refusing to use SSH host that looks like an option: ${host}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command on the SSH host. stdin stays attached so interactive prompts
|
||||
* can still read from the terminal; stdout/stderr are captured for callers
|
||||
* that need actionable remote failure messages.
|
||||
*/
|
||||
export function runSsh(host: string, remoteCommand: string): RemoteRunResult {
|
||||
assertSafeSshHost(host);
|
||||
const result = spawnSync('ssh', [host, remoteCommand], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
});
|
||||
if (result.error) {
|
||||
throw new Error(`Failed to run ssh: ${(result.error as Error).message}`);
|
||||
}
|
||||
return { status: result.status ?? 1, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
||||
}
|
||||
|
||||
function assertSafeScpEndpoint(endpoint: string): void {
|
||||
const colon = endpoint.indexOf(':');
|
||||
const slash = endpoint.indexOf('/');
|
||||
if (colon <= 0 || (slash !== -1 && slash < colon)) {
|
||||
if (endpoint.startsWith('-')) {
|
||||
throw new Error(`Refusing to use scp endpoint that looks like an option: ${endpoint}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const host = endpoint.slice(0, colon);
|
||||
const remotePath = endpoint.slice(colon + 1);
|
||||
assertSafeSshHost(host);
|
||||
if (remotePath.startsWith('-')) {
|
||||
throw new Error(`Refusing to use scp remote path that looks like an option: ${remotePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function runScp(from: string, to: string): void {
|
||||
assertSafeScpEndpoint(from);
|
||||
assertSafeScpEndpoint(to);
|
||||
const result = spawnSync('scp', ['-q', from, to], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['inherit', 'inherit', 'inherit'],
|
||||
});
|
||||
if (result.error) {
|
||||
throw new Error(`Failed to run scp: ${(result.error as Error).message}`);
|
||||
}
|
||||
if ((result.status ?? 1) !== 0) {
|
||||
throw new Error(`scp failed copying ${from} -> ${to}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", `'\\''`)}'`;
|
||||
}
|
||||
|
||||
const REMOTE_RUNTIME_PATH =
|
||||
'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"';
|
||||
|
||||
/**
|
||||
* Non-interactive SSH shells often miss user-installed launchers and Bun.
|
||||
* Probe the launcher under the same deterministic PATH used by sync itself.
|
||||
*/
|
||||
export function resolveRemoteSubminerCommand(
|
||||
host: string,
|
||||
preferred: string | null,
|
||||
runRemote: typeof runSsh = runSsh,
|
||||
): string {
|
||||
// Trusted defaults stay unquoted so the remote shell expands `~`; a
|
||||
// user-supplied override is shell-quoted to prevent command injection.
|
||||
const candidates: Array<{ value: string; invocation: string }> = preferred
|
||||
? [{ value: preferred, invocation: shellQuote(preferred) }]
|
||||
: [
|
||||
{ value: 'subminer', invocation: 'subminer' },
|
||||
{ value: '~/.local/bin/subminer', invocation: '~/.local/bin/subminer' },
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const command = `${REMOTE_RUNTIME_PATH} ${candidate.invocation}`;
|
||||
const probe = runRemote(host, `${command} --help >/dev/null 2>&1`);
|
||||
if (probe.status === 0) {
|
||||
return command;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
preferred
|
||||
? `Remote command not found on ${host}: ${preferred}`
|
||||
: `subminer not found on ${host} (tried PATH and ~/.local/bin/subminer). Pass --remote-cmd <path>.`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
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 { Database } from 'bun:sqlite';
|
||||
import { createDbSnapshot, mergeSnapshotIntoDb } from './sync-db.js';
|
||||
import {
|
||||
createImmersionDbFixture,
|
||||
insertFixtureSession,
|
||||
} from '../test-support/immersion-db-fixture.js';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const BASE_MS = Date.UTC(2026, 5, 1, 12, 0, 0);
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-test-'));
|
||||
}
|
||||
|
||||
function makeDbPair(): { dir: string; localPath: string; remotePath: string } {
|
||||
const dir = makeTmpDir();
|
||||
const localPath = path.join(dir, 'local.sqlite');
|
||||
const remotePath = path.join(dir, 'remote.sqlite');
|
||||
createImmersionDbFixture(localPath);
|
||||
createImmersionDbFixture(remotePath);
|
||||
return { dir, localPath, remotePath };
|
||||
}
|
||||
|
||||
function queryOne<T extends Record<string, unknown>>(
|
||||
dbPath: string,
|
||||
sql: string,
|
||||
params: unknown[] = [],
|
||||
): T | undefined {
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
try {
|
||||
return db.query<T>(sql).get(...params) as T | undefined;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function count(dbPath: string, sql: string, params: unknown[] = []): number {
|
||||
return Number(queryOne<{ n: number }>(dbPath, sql, params)?.n ?? 0);
|
||||
}
|
||||
|
||||
function withWritableDb<T>(dbPath: string, fn: (db: Database) => T): T {
|
||||
const db = new Database(dbPath, { readwrite: true });
|
||||
try {
|
||||
return fn(db);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
test('merges remote-only sessions with catalog, lifetime, and rollups', () => {
|
||||
const { dir, localPath, remotePath } = makeDbPair();
|
||||
try {
|
||||
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 + DAY_MS,
|
||||
activeWatchedMs: 900_000,
|
||||
cardsMined: 3,
|
||||
applyLifetime: true,
|
||||
words: [
|
||||
{ headword: '見る', word: '見た', reading: 'みた', count: 5 },
|
||||
{ headword: '食べる', word: '食べた', reading: 'たべた', count: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
const summary = mergeSnapshotIntoDb(localPath, remotePath);
|
||||
assert.equal(summary.sessionsMerged, 1);
|
||||
assert.equal(summary.animeAdded, 1);
|
||||
assert.equal(summary.videosAdded, 1);
|
||||
assert.equal(summary.wordsAdded, 1);
|
||||
assert.equal(summary.subtitleLinesAdded, 2);
|
||||
assert.equal(summary.telemetryRowsAdded, 1);
|
||||
|
||||
assert.equal(count(localPath, 'SELECT COUNT(*) AS n FROM imm_sessions'), 2);
|
||||
const global = queryOne<{
|
||||
total_sessions: number;
|
||||
total_active_ms: number;
|
||||
total_cards: number;
|
||||
active_days: number;
|
||||
episodes_started: number;
|
||||
}>(
|
||||
localPath,
|
||||
'SELECT total_sessions, total_active_ms, total_cards, active_days, episodes_started FROM imm_lifetime_global WHERE global_id = 1',
|
||||
);
|
||||
assert.equal(global?.total_sessions, 2);
|
||||
assert.equal(global?.total_active_ms, 1_200_000 + 900_000);
|
||||
assert.equal(global?.total_cards, 2 + 3);
|
||||
assert.equal(global?.active_days, 2);
|
||||
assert.equal(global?.episodes_started, 2);
|
||||
|
||||
// Existing word: local 2 + merged 5; new word carries remote frequency.
|
||||
const sharedWord = queryOne<{ frequency: number }>(
|
||||
localPath,
|
||||
`SELECT frequency FROM imm_words WHERE word = '見た'`,
|
||||
);
|
||||
assert.equal(sharedWord?.frequency, 7);
|
||||
const newWord = queryOne<{ frequency: number }>(
|
||||
localPath,
|
||||
`SELECT frequency FROM imm_words WHERE word = '食べた'`,
|
||||
);
|
||||
assert.equal(newWord?.frequency, 1);
|
||||
|
||||
// The merged session's rollup group was recomputed.
|
||||
assert.equal(summary.rollupGroupsRecomputed, 1);
|
||||
const mergedVideoId = Number(
|
||||
queryOne<{ video_id: number }>(
|
||||
localPath,
|
||||
`SELECT video_id FROM imm_videos WHERE video_key = 'showb-e1'`,
|
||||
)?.video_id,
|
||||
);
|
||||
assert.equal(
|
||||
count(localPath, 'SELECT COUNT(*) AS n FROM imm_daily_rollups WHERE video_id = ?', [
|
||||
mergedVideoId,
|
||||
]),
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('is idempotent: re-merging the same snapshot changes nothing', () => {
|
||||
const { dir, localPath, remotePath } = makeDbPair();
|
||||
try {
|
||||
insertFixtureSession(remotePath, {
|
||||
uuid: 'remote-1',
|
||||
videoKey: 'showa-e1',
|
||||
animeTitleKey: 'showa',
|
||||
startedAtMs: BASE_MS,
|
||||
applyLifetime: true,
|
||||
words: [{ headword: '見る', word: '見た', reading: 'みた', count: 4 }],
|
||||
});
|
||||
|
||||
mergeSnapshotIntoDb(localPath, remotePath);
|
||||
const globalAfterFirst = queryOne<Record<string, unknown>>(
|
||||
localPath,
|
||||
'SELECT * FROM imm_lifetime_global WHERE global_id = 1',
|
||||
);
|
||||
const summary = mergeSnapshotIntoDb(localPath, remotePath);
|
||||
|
||||
assert.equal(summary.sessionsMerged, 0);
|
||||
assert.equal(summary.sessionsAlreadyPresent, 1);
|
||||
assert.equal(summary.wordsAdded, 0);
|
||||
assert.equal(count(localPath, 'SELECT COUNT(*) AS n FROM imm_sessions'), 1);
|
||||
assert.equal(count(localPath, 'SELECT COUNT(*) AS n FROM imm_subtitle_lines'), 1);
|
||||
const globalAfterSecond = queryOne<Record<string, unknown>>(
|
||||
localPath,
|
||||
'SELECT * FROM imm_lifetime_global WHERE global_id = 1',
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ ...globalAfterSecond, LAST_UPDATE_DATE: null },
|
||||
{ ...globalAfterFirst, LAST_UPDATE_DATE: null },
|
||||
);
|
||||
assert.equal(
|
||||
Number(
|
||||
queryOne<{ frequency: number }>(
|
||||
localPath,
|
||||
`SELECT frequency FROM imm_words WHERE word = '見た'`,
|
||||
)?.frequency,
|
||||
),
|
||||
4,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('preserves anilist_id when inserting a new anime from the snapshot', () => {
|
||||
const { dir, localPath, remotePath } = makeDbPair();
|
||||
try {
|
||||
insertFixtureSession(remotePath, {
|
||||
uuid: 'remote-1',
|
||||
videoKey: 'showb-e1',
|
||||
animeTitleKey: 'showb',
|
||||
startedAtMs: BASE_MS,
|
||||
applyLifetime: true,
|
||||
});
|
||||
withWritableDb(remotePath, (remoteDb) => {
|
||||
remoteDb
|
||||
.prepare(`UPDATE imm_anime SET anilist_id = 12345 WHERE normalized_title_key = 'showb'`)
|
||||
.run();
|
||||
});
|
||||
|
||||
const summary = mergeSnapshotIntoDb(localPath, remotePath);
|
||||
assert.equal(summary.animeAdded, 1);
|
||||
const anime = queryOne<{ anilist_id: number }>(
|
||||
localPath,
|
||||
`SELECT anilist_id FROM imm_anime WHERE normalized_title_key = 'showb'`,
|
||||
);
|
||||
assert.equal(anime?.anilist_id, 12345);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('matches an existing local anime by anilist_id even when the title key differs', () => {
|
||||
const { dir, localPath, remotePath } = makeDbPair();
|
||||
try {
|
||||
insertFixtureSession(localPath, {
|
||||
uuid: 'local-1',
|
||||
videoKey: 'showa-e1',
|
||||
animeTitleKey: 'show-romaji',
|
||||
startedAtMs: BASE_MS,
|
||||
applyLifetime: true,
|
||||
});
|
||||
insertFixtureSession(remotePath, {
|
||||
uuid: 'remote-1',
|
||||
videoKey: 'showa-e2',
|
||||
animeTitleKey: 'show-native',
|
||||
startedAtMs: BASE_MS + DAY_MS,
|
||||
applyLifetime: true,
|
||||
});
|
||||
withWritableDb(localPath, (localDb) => {
|
||||
localDb
|
||||
.prepare(`UPDATE imm_anime SET anilist_id = 999 WHERE normalized_title_key = 'show-romaji'`)
|
||||
.run();
|
||||
});
|
||||
withWritableDb(remotePath, (remoteDb) => {
|
||||
remoteDb
|
||||
.prepare(`UPDATE imm_anime SET anilist_id = 999 WHERE normalized_title_key = 'show-native'`)
|
||||
.run();
|
||||
});
|
||||
|
||||
const summary = mergeSnapshotIntoDb(localPath, remotePath);
|
||||
// Same anilist_id → one anime, not two.
|
||||
assert.equal(summary.animeAdded, 0);
|
||||
assert.equal(count(localPath, 'SELECT COUNT(*) AS n FROM imm_anime'), 1);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('matches shared videos by video_key and merges the watched flag', () => {
|
||||
const { dir, localPath, remotePath } = makeDbPair();
|
||||
try {
|
||||
insertFixtureSession(localPath, {
|
||||
uuid: 'local-1',
|
||||
videoKey: 'showa-e1',
|
||||
animeTitleKey: 'showa',
|
||||
startedAtMs: BASE_MS,
|
||||
applyLifetime: true,
|
||||
});
|
||||
insertFixtureSession(remotePath, {
|
||||
uuid: 'remote-1',
|
||||
videoKey: 'showa-e1',
|
||||
animeTitleKey: 'showa',
|
||||
startedAtMs: BASE_MS + DAY_MS,
|
||||
watched: true,
|
||||
applyLifetime: true,
|
||||
});
|
||||
|
||||
const summary = mergeSnapshotIntoDb(localPath, remotePath);
|
||||
assert.equal(summary.videosAdded, 0);
|
||||
assert.equal(summary.animeAdded, 0);
|
||||
assert.equal(count(localPath, 'SELECT COUNT(*) AS n FROM imm_videos'), 1);
|
||||
const video = queryOne<{ watched: number }>(
|
||||
localPath,
|
||||
`SELECT watched FROM imm_videos WHERE video_key = 'showa-e1'`,
|
||||
);
|
||||
assert.equal(video?.watched, 1);
|
||||
|
||||
// Both sessions now credit the same video; episode was started once and
|
||||
// completed once (by the remote session that watched it to the end).
|
||||
const global = queryOne<{
|
||||
episodes_started: number;
|
||||
episodes_completed: number;
|
||||
total_sessions: number;
|
||||
}>(
|
||||
localPath,
|
||||
'SELECT episodes_started, episodes_completed, total_sessions FROM imm_lifetime_global WHERE global_id = 1',
|
||||
);
|
||||
assert.equal(global?.total_sessions, 2);
|
||||
assert.equal(global?.episodes_started, 1);
|
||||
assert.equal(global?.episodes_completed, 1);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('backfills media metadata for matched videos when local metadata is absent', () => {
|
||||
const { dir, localPath, remotePath } = makeDbPair();
|
||||
try {
|
||||
insertFixtureSession(localPath, {
|
||||
uuid: 'local-1',
|
||||
videoKey: 'showa-e1',
|
||||
animeTitleKey: 'showa',
|
||||
startedAtMs: BASE_MS,
|
||||
applyLifetime: true,
|
||||
});
|
||||
insertFixtureSession(remotePath, {
|
||||
uuid: 'remote-1',
|
||||
videoKey: 'showa-e1',
|
||||
animeTitleKey: 'showa',
|
||||
startedAtMs: BASE_MS + DAY_MS,
|
||||
applyLifetime: true,
|
||||
});
|
||||
const remoteDb = new Database(remotePath, { readwrite: true });
|
||||
try {
|
||||
const remoteVideoId = Number(
|
||||
remoteDb
|
||||
.query<{
|
||||
video_id: number;
|
||||
}>(`SELECT video_id FROM imm_videos WHERE video_key = 'showa-e1'`)
|
||||
.get()?.video_id,
|
||||
);
|
||||
remoteDb
|
||||
.prepare(
|
||||
`INSERT INTO imm_media_art (video_id, anilist_id, cover_url, title_romaji, fetched_at_ms)
|
||||
VALUES (?, 123, 'https://example.test/cover.jpg', 'Show A', ?)`,
|
||||
)
|
||||
.run(remoteVideoId, String(BASE_MS));
|
||||
remoteDb
|
||||
.prepare(
|
||||
`INSERT INTO imm_youtube_videos (video_id, youtube_video_id, video_url, video_title, fetched_at_ms)
|
||||
VALUES (?, 'yt-1', 'https://youtube.test/watch?v=yt-1', 'Remote Video', ?)`,
|
||||
)
|
||||
.run(remoteVideoId, String(BASE_MS));
|
||||
} finally {
|
||||
remoteDb.close();
|
||||
}
|
||||
|
||||
const summary = mergeSnapshotIntoDb(localPath, remotePath);
|
||||
assert.equal(summary.videosAdded, 0);
|
||||
const localVideoId = Number(
|
||||
queryOne<{ video_id: number }>(
|
||||
localPath,
|
||||
`SELECT video_id FROM imm_videos WHERE video_key = 'showa-e1'`,
|
||||
)?.video_id,
|
||||
);
|
||||
const art = queryOne<{ cover_url: string; title_romaji: string }>(
|
||||
localPath,
|
||||
'SELECT cover_url, title_romaji FROM imm_media_art WHERE video_id = ?',
|
||||
[localVideoId],
|
||||
);
|
||||
assert.equal(art?.cover_url, 'https://example.test/cover.jpg');
|
||||
assert.equal(art?.title_romaji, 'Show A');
|
||||
const youtube = queryOne<{ youtube_video_id: string; video_title: string }>(
|
||||
localPath,
|
||||
'SELECT youtube_video_id, video_title FROM imm_youtube_videos WHERE video_id = ?',
|
||||
[localVideoId],
|
||||
);
|
||||
assert.equal(youtube?.youtube_video_id, 'yt-1');
|
||||
assert.equal(youtube?.video_title, 'Remote Video');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('does not double-count active_days when a merged session starts earlier on an already-credited day', () => {
|
||||
const { dir, localPath, remotePath } = makeDbPair();
|
||||
try {
|
||||
insertFixtureSession(localPath, {
|
||||
uuid: 'local-1',
|
||||
videoKey: 'showa-e1',
|
||||
startedAtMs: BASE_MS + 3_600_000,
|
||||
applyLifetime: true,
|
||||
});
|
||||
insertFixtureSession(remotePath, {
|
||||
uuid: 'remote-1',
|
||||
videoKey: 'showb-e1',
|
||||
startedAtMs: BASE_MS,
|
||||
applyLifetime: true,
|
||||
});
|
||||
|
||||
mergeSnapshotIntoDb(localPath, remotePath);
|
||||
const global = queryOne<{ active_days: number; total_sessions: number }>(
|
||||
localPath,
|
||||
'SELECT active_days, total_sessions FROM imm_lifetime_global WHERE global_id = 1',
|
||||
);
|
||||
assert.equal(global?.total_sessions, 2);
|
||||
assert.equal(global?.active_days, 1);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('skips unfinished sessions', () => {
|
||||
const { dir, localPath, remotePath } = makeDbPair();
|
||||
try {
|
||||
insertFixtureSession(remotePath, {
|
||||
uuid: 'remote-active',
|
||||
videoKey: 'showa-e1',
|
||||
startedAtMs: BASE_MS,
|
||||
endedAtMs: null,
|
||||
});
|
||||
|
||||
const summary = mergeSnapshotIntoDb(localPath, remotePath);
|
||||
assert.equal(summary.sessionsMerged, 0);
|
||||
assert.equal(summary.activeSessionsSkipped, 1);
|
||||
assert.equal(count(localPath, 'SELECT COUNT(*) AS n FROM imm_sessions'), 0);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('copies remote-only historical rollups but never sums shared groups', () => {
|
||||
const { dir, localPath, remotePath } = makeDbPair();
|
||||
try {
|
||||
// Remote has a video plus an old rollup row whose sessions were pruned.
|
||||
insertFixtureSession(remotePath, {
|
||||
uuid: 'remote-1',
|
||||
videoKey: 'old-show-e1',
|
||||
startedAtMs: BASE_MS,
|
||||
applyLifetime: true,
|
||||
});
|
||||
withWritableDb(remotePath, (remoteDb) => {
|
||||
const remoteVideoId = Number(
|
||||
remoteDb
|
||||
.query<{
|
||||
video_id: number;
|
||||
}>(`SELECT video_id FROM imm_videos WHERE video_key = 'old-show-e1'`)
|
||||
.get()?.video_id,
|
||||
);
|
||||
remoteDb
|
||||
.prepare(
|
||||
`INSERT INTO imm_daily_rollups (rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, total_tokens_seen, total_cards)
|
||||
VALUES (10000, ?, 4, 120.5, 400, 3200, 9)`,
|
||||
)
|
||||
.run(remoteVideoId);
|
||||
});
|
||||
|
||||
// Local already has its own rollup row for a shared group.
|
||||
insertFixtureSession(localPath, {
|
||||
uuid: 'local-1',
|
||||
videoKey: 'old-show-e1',
|
||||
startedAtMs: BASE_MS,
|
||||
applyLifetime: true,
|
||||
});
|
||||
const localVideoId = withWritableDb(localPath, (localDb) => {
|
||||
const videoId = Number(
|
||||
localDb
|
||||
.query<{
|
||||
video_id: number;
|
||||
}>(`SELECT video_id FROM imm_videos WHERE video_key = 'old-show-e1'`)
|
||||
.get()?.video_id,
|
||||
);
|
||||
localDb
|
||||
.prepare(
|
||||
`INSERT INTO imm_daily_rollups (rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, total_tokens_seen, total_cards)
|
||||
VALUES (10000, ?, 2, 60.0, 200, 1600, 4)`,
|
||||
)
|
||||
.run(videoId);
|
||||
return videoId;
|
||||
});
|
||||
|
||||
const summary = mergeSnapshotIntoDb(localPath, remotePath);
|
||||
// Shared group (day 10000) untouched; the remote-only session's own group
|
||||
// was recomputed, not copied.
|
||||
assert.equal(summary.dailyRollupsCopied, 0);
|
||||
const shared = queryOne<{ total_sessions: number }>(
|
||||
localPath,
|
||||
'SELECT total_sessions FROM imm_daily_rollups WHERE rollup_day = 10000 AND video_id = ?',
|
||||
[localVideoId],
|
||||
);
|
||||
assert.equal(shared?.total_sessions, 2);
|
||||
|
||||
// Now a rollup for a video with no local sessions at all gets copied.
|
||||
withWritableDb(remotePath, (remoteDb2) => {
|
||||
const orphanVideoId = Number(
|
||||
remoteDb2
|
||||
.prepare(
|
||||
`INSERT INTO imm_videos (video_key, canonical_title, source_type, watched, duration_ms)
|
||||
VALUES ('pruned-show-e1', 'pruned-show-e1', 1, 1, 1440000)`,
|
||||
)
|
||||
.run().lastInsertRowid,
|
||||
);
|
||||
remoteDb2
|
||||
.prepare(
|
||||
`INSERT INTO imm_daily_rollups (rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, total_tokens_seen, total_cards)
|
||||
VALUES (9000, ?, 3, 90.0, 300, 2400, 6)`,
|
||||
)
|
||||
.run(orphanVideoId);
|
||||
});
|
||||
|
||||
const summary2 = mergeSnapshotIntoDb(localPath, remotePath);
|
||||
assert.equal(summary2.dailyRollupsCopied, 1);
|
||||
const copiedVideoId = Number(
|
||||
queryOne<{ video_id: number }>(
|
||||
localPath,
|
||||
`SELECT video_id FROM imm_videos WHERE video_key = 'pruned-show-e1'`,
|
||||
)?.video_id,
|
||||
);
|
||||
const copied = queryOne<{ total_sessions: number; total_active_min: number }>(
|
||||
localPath,
|
||||
'SELECT total_sessions, total_active_min FROM imm_daily_rollups WHERE rollup_day = 9000 AND video_id = ?',
|
||||
[copiedVideoId],
|
||||
);
|
||||
assert.equal(copied?.total_sessions, 3);
|
||||
assert.equal(copied?.total_active_min, 90.0);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('does not copy remote-only daily rollups into months with local sessions', () => {
|
||||
const { dir, localPath, remotePath } = makeDbPair();
|
||||
try {
|
||||
insertFixtureSession(localPath, {
|
||||
uuid: 'local-1',
|
||||
videoKey: 'mixed-month-e1',
|
||||
animeTitleKey: 'mixed-month',
|
||||
startedAtMs: BASE_MS,
|
||||
applyLifetime: true,
|
||||
});
|
||||
const remoteDb = new Database(remotePath, { readwrite: true });
|
||||
try {
|
||||
const remoteVideoId = Number(
|
||||
remoteDb
|
||||
.prepare(
|
||||
`INSERT INTO imm_videos (video_key, canonical_title, source_type, watched, duration_ms)
|
||||
VALUES ('mixed-month-e1', 'mixed-month-e1', 1, 1, 1440000)`,
|
||||
)
|
||||
.run().lastInsertRowid,
|
||||
);
|
||||
remoteDb
|
||||
.prepare(
|
||||
`INSERT INTO imm_daily_rollups (rollup_day, video_id, total_sessions, total_active_min, total_lines_seen, total_tokens_seen, total_cards)
|
||||
VALUES (20615, ?, 3, 90.0, 300, 2400, 6)`,
|
||||
)
|
||||
.run(remoteVideoId);
|
||||
remoteDb
|
||||
.prepare(
|
||||
`INSERT INTO imm_monthly_rollups (rollup_month, video_id, total_sessions, total_active_min, total_lines_seen, total_tokens_seen, total_cards)
|
||||
VALUES (202606, ?, 3, 90.0, 300, 2400, 6)`,
|
||||
)
|
||||
.run(remoteVideoId);
|
||||
} finally {
|
||||
remoteDb.close();
|
||||
}
|
||||
|
||||
const summary = mergeSnapshotIntoDb(localPath, remotePath);
|
||||
assert.equal(summary.dailyRollupsCopied, 0);
|
||||
assert.equal(summary.monthlyRollupsCopied, 0);
|
||||
const localVideoId = Number(
|
||||
queryOne<{ video_id: number }>(
|
||||
localPath,
|
||||
`SELECT video_id FROM imm_videos WHERE video_key = 'mixed-month-e1'`,
|
||||
)?.video_id,
|
||||
);
|
||||
assert.equal(
|
||||
count(
|
||||
localPath,
|
||||
'SELECT COUNT(*) AS n FROM imm_daily_rollups WHERE rollup_day = 20615 AND video_id = ?',
|
||||
[localVideoId],
|
||||
),
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects snapshots at a different schema version', () => {
|
||||
const { dir, localPath, remotePath } = makeDbPair();
|
||||
try {
|
||||
withWritableDb(remotePath, (remoteDb) => {
|
||||
remoteDb.prepare('UPDATE imm_schema_version SET schema_version = 17').run();
|
||||
});
|
||||
|
||||
assert.throws(() => mergeSnapshotIntoDb(localPath, remotePath), /schema version 17/);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('createDbSnapshot produces a mergeable copy', () => {
|
||||
const { dir, localPath, remotePath } = makeDbPair();
|
||||
try {
|
||||
insertFixtureSession(remotePath, {
|
||||
uuid: 'remote-1',
|
||||
videoKey: 'showa-e1',
|
||||
startedAtMs: BASE_MS,
|
||||
applyLifetime: true,
|
||||
});
|
||||
const snapshotPath = path.join(dir, 'snapshot.sqlite');
|
||||
createDbSnapshot(remotePath, snapshotPath);
|
||||
assert.ok(fs.existsSync(snapshotPath));
|
||||
|
||||
const summary = mergeSnapshotIntoDb(localPath, snapshotPath);
|
||||
assert.equal(summary.sessionsMerged, 1);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import fs from 'node:fs';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import {
|
||||
LexiconResolver,
|
||||
mergeAnime,
|
||||
mergeExcludedWords,
|
||||
mergeMediaMetadata,
|
||||
mergeVideos,
|
||||
} from './merge-catalog.js';
|
||||
import { mergeSessions } from './merge-sessions.js';
|
||||
import { copyRemoteOnlyRollups, refreshRollupsForNewSessions } from './merge-rollups.js';
|
||||
import {
|
||||
assertMergeableSchema,
|
||||
createEmptyMergeSummary,
|
||||
type SyncMergeSummary,
|
||||
} from './sync-shared.js';
|
||||
|
||||
export type { SyncMergeSummary } from './sync-shared.js';
|
||||
export { createDbSnapshot, findLiveStatsDaemonPid } from './sync-shared.js';
|
||||
|
||||
/**
|
||||
* Merge a snapshot of another machine's immersion database into the local
|
||||
* one. Insert-only union keyed on natural keys (session_uuid, video_key,
|
||||
* normalized_title_key, word/kanji identity); lifetime and rollup aggregates
|
||||
* are updated incrementally so history older than the session retention
|
||||
* window is preserved on both sides. Idempotent: re-merging the same
|
||||
* snapshot is a no-op.
|
||||
*/
|
||||
export function mergeSnapshotIntoDb(localDbPath: string, snapshotPath: string): SyncMergeSummary {
|
||||
if (!fs.existsSync(localDbPath)) {
|
||||
throw new Error(`Local stats database not found: ${localDbPath}`);
|
||||
}
|
||||
if (!fs.existsSync(snapshotPath)) {
|
||||
throw new Error(`Snapshot database not found: ${snapshotPath}`);
|
||||
}
|
||||
|
||||
const remote = new Database(snapshotPath, { readonly: true });
|
||||
let local: Database;
|
||||
try {
|
||||
local = new Database(localDbPath, { readwrite: true, create: false });
|
||||
} catch (error) {
|
||||
remote.close();
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
assertMergeableSchema(remote, 'Snapshot');
|
||||
assertMergeableSchema(local, 'Local');
|
||||
|
||||
const summary = createEmptyMergeSummary();
|
||||
local.run('PRAGMA foreign_keys = ON');
|
||||
local.run('PRAGMA busy_timeout = 5000');
|
||||
local.run('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const animeIdMap = mergeAnime(local, remote, summary);
|
||||
const { videoIdMap, addedVideoIds } = mergeVideos(local, remote, animeIdMap, summary);
|
||||
mergeMediaMetadata(local, remote, videoIdMap, addedVideoIds);
|
||||
mergeExcludedWords(local, remote, summary);
|
||||
|
||||
const lexicon = new LexiconResolver(local, remote, summary);
|
||||
const { newSessionIds } = mergeSessions(
|
||||
local,
|
||||
remote,
|
||||
videoIdMap,
|
||||
animeIdMap,
|
||||
lexicon,
|
||||
summary,
|
||||
);
|
||||
lexicon.applyFrequencyDeltas();
|
||||
refreshRollupsForNewSessions(local, newSessionIds, summary);
|
||||
copyRemoteOnlyRollups(local, remote, videoIdMap, summary);
|
||||
|
||||
local.run('COMMIT');
|
||||
return summary;
|
||||
} catch (error) {
|
||||
local.run('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
local.close();
|
||||
remote.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function formatMergeSummary(summary: SyncMergeSummary): string {
|
||||
const lines = [
|
||||
`Sessions merged: ${summary.sessionsMerged} (${summary.sessionsAlreadyPresent} already present, ${summary.activeSessionsSkipped} unfinished skipped)`,
|
||||
];
|
||||
const detail: string[] = [];
|
||||
if (summary.animeAdded) detail.push(`${summary.animeAdded} series`);
|
||||
if (summary.videosAdded) detail.push(`${summary.videosAdded} videos`);
|
||||
if (summary.wordsAdded) detail.push(`${summary.wordsAdded} words`);
|
||||
if (summary.kanjiAdded) detail.push(`${summary.kanjiAdded} kanji`);
|
||||
if (summary.subtitleLinesAdded) detail.push(`${summary.subtitleLinesAdded} subtitle lines`);
|
||||
if (summary.excludedWordsAdded) detail.push(`${summary.excludedWordsAdded} excluded words`);
|
||||
if (detail.length > 0) lines.push(`Added: ${detail.join(', ')}`);
|
||||
if (summary.dailyRollupsCopied || summary.monthlyRollupsCopied) {
|
||||
lines.push(
|
||||
`Historical rollups copied: ${summary.dailyRollupsCopied} daily, ${summary.monthlyRollupsCopied} monthly`,
|
||||
);
|
||||
}
|
||||
if (summary.rollupGroupsRecomputed) {
|
||||
lines.push(`Rollup groups recomputed: ${summary.rollupGroupsRecomputed}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { SCHEMA_VERSION } from '../../src/core/services/immersion-tracker/types.js';
|
||||
import { withReadonlyWalRetry } from '../history-db.js';
|
||||
import { resolveConfigDir } from '../../src/config/path-resolution.js';
|
||||
|
||||
export { SCHEMA_VERSION };
|
||||
|
||||
export interface SyncMergeSummary {
|
||||
sessionsMerged: number;
|
||||
sessionsAlreadyPresent: number;
|
||||
activeSessionsSkipped: number;
|
||||
animeAdded: number;
|
||||
videosAdded: number;
|
||||
wordsAdded: number;
|
||||
kanjiAdded: number;
|
||||
subtitleLinesAdded: number;
|
||||
telemetryRowsAdded: number;
|
||||
eventsAdded: number;
|
||||
excludedWordsAdded: number;
|
||||
dailyRollupsCopied: number;
|
||||
monthlyRollupsCopied: number;
|
||||
rollupGroupsRecomputed: number;
|
||||
}
|
||||
|
||||
export function createEmptyMergeSummary(): SyncMergeSummary {
|
||||
return {
|
||||
sessionsMerged: 0,
|
||||
sessionsAlreadyPresent: 0,
|
||||
activeSessionsSkipped: 0,
|
||||
animeAdded: 0,
|
||||
videosAdded: 0,
|
||||
wordsAdded: 0,
|
||||
kanjiAdded: 0,
|
||||
subtitleLinesAdded: 0,
|
||||
telemetryRowsAdded: 0,
|
||||
eventsAdded: 0,
|
||||
excludedWordsAdded: 0,
|
||||
dailyRollupsCopied: 0,
|
||||
monthlyRollupsCopied: 0,
|
||||
rollupGroupsRecomputed: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function nowDbTimestamp(): string {
|
||||
return String(Date.now());
|
||||
}
|
||||
|
||||
export function tableExists(db: Database, tableName: string): boolean {
|
||||
return Boolean(
|
||||
db.query(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`).get(tableName),
|
||||
);
|
||||
}
|
||||
|
||||
export function readSchemaVersion(db: Database): number | null {
|
||||
if (!tableExists(db, 'imm_schema_version')) return null;
|
||||
const row = db
|
||||
.query<{ schema_version: number }>(
|
||||
'SELECT MAX(schema_version) AS schema_version FROM imm_schema_version',
|
||||
)
|
||||
.get();
|
||||
return typeof row?.schema_version === 'number' ? row.schema_version : null;
|
||||
}
|
||||
|
||||
export function assertMergeableSchema(db: Database, label: string): void {
|
||||
const version = readSchemaVersion(db);
|
||||
if (version === null) {
|
||||
throw new Error(
|
||||
`${label} database has no schema version. Run SubMiner once on that machine so the stats database is initialized.`,
|
||||
);
|
||||
}
|
||||
if (version !== SCHEMA_VERSION) {
|
||||
throw new Error(
|
||||
`${label} database is at schema version ${version} but this launcher expects ${SCHEMA_VERSION}. Update SubMiner on both machines to the same version and run each app once before syncing.`,
|
||||
);
|
||||
}
|
||||
for (const table of ['imm_sessions', 'imm_videos', 'imm_lifetime_global']) {
|
||||
if (!tableExists(db, table)) {
|
||||
throw new Error(`${label} database is missing table ${table}; cannot sync.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function insertRow(
|
||||
db: Database,
|
||||
table: string,
|
||||
columns: readonly string[],
|
||||
values: unknown[],
|
||||
): number {
|
||||
const sql = `INSERT INTO ${table} (${columns.join(', ')}) VALUES (${columns.map(() => '?').join(', ')})`;
|
||||
// db.query() caches the prepared statement per SQL string; this runs once
|
||||
// per copied row, so re-preparing via db.prepare() would dominate merge time.
|
||||
const result = db.query(sql).run(...values);
|
||||
return Number(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
export function createDbSnapshot(dbPath: string, outPath: string): void {
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
throw new Error(`Stats database not found: ${dbPath}`);
|
||||
}
|
||||
fs.rmSync(outPath, { force: true });
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
withReadonlyWalRetry(dbPath, (options) => {
|
||||
const db = new Database(dbPath, options);
|
||||
try {
|
||||
assertMergeableSchema(db, 'Local');
|
||||
db.prepare('VACUUM INTO ?').run(outPath);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface DaemonStateFile {
|
||||
pid?: unknown;
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// EPERM means the process exists but we can't signal it → still alive.
|
||||
// Only ESRCH (no such process) means it's actually gone.
|
||||
return (error as NodeJS.ErrnoException)?.code === 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
function statsDaemonStateCandidates(dbPath: string): string[] {
|
||||
const homeDir = os.homedir();
|
||||
const candidates = new Set<string>([path.join(path.dirname(dbPath), 'stats-daemon.json')]);
|
||||
const configDir = resolveConfigDir({
|
||||
platform: process.platform,
|
||||
appDataDir: process.env.APPDATA,
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir,
|
||||
existsSync: fs.existsSync,
|
||||
});
|
||||
candidates.add(path.join(configDir, 'stats-daemon.json'));
|
||||
if (process.platform === 'darwin') {
|
||||
candidates.add(path.join(homeDir, 'Library', 'Application Support', 'SubMiner', 'stats-daemon.json'));
|
||||
}
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort guard against merging while a SubMiner process holds the
|
||||
* tracker's write queue in memory. Detects the background stats daemon via
|
||||
* its pid state file; the interactive app is caught by the mpv-socket check
|
||||
* in the sync command.
|
||||
*/
|
||||
export function findLiveStatsDaemonPid(dbPath: string): number | null {
|
||||
for (const statePath of statsDaemonStateCandidates(dbPath)) {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(statePath, 'utf8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as DaemonStateFile;
|
||||
const pid = typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) ? parsed.pid : 0;
|
||||
if (pid > 0 && isProcessAlive(pid)) {
|
||||
return pid;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||