mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
Compare commits
6 Commits
6fe1e0fee4
...
9f4888293b
| Author | SHA1 | Date | |
|---|---|---|---|
|
9f4888293b
|
|||
|
29332b103e
|
|||
| 8b2cee2c58 | |||
| 2e2ee3f028 | |||
| 49b926e08c | |||
| 66f8ca4f80 |
@@ -8,98 +8,4 @@ on:
|
||||
|
||||
jobs:
|
||||
build-test-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.5
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
node_modules
|
||||
stats/node_modules
|
||||
vendor/subminer-yomitan/node_modules
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
cd stats && bun install --frozen-lockfile
|
||||
|
||||
- name: Lint changelog fragments
|
||||
run: bun run changelog:lint
|
||||
|
||||
- name: Lint stats (formatting)
|
||||
run: bun run lint:stats
|
||||
|
||||
- name: Enforce pull request changelog fragments (`skip-changelog` label bypass)
|
||||
if: github.event_name == 'pull_request'
|
||||
run: bun run changelog:pr-check --base-ref "origin/${{ github.base_ref }}" --head-ref "HEAD" --labels "${{ join(github.event.pull_request.labels.*.name, ',') }}"
|
||||
|
||||
- name: Build (TypeScript check)
|
||||
# Keep explicit typecheck for fast fail before full build/bundle.
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Verify generated config examples
|
||||
run: bun run verify:config-example
|
||||
|
||||
- name: Test suite (source)
|
||||
run: bun run test:fast
|
||||
|
||||
- name: Coverage suite (maintained source lane)
|
||||
run: bun run test:coverage:src
|
||||
|
||||
- name: Upload coverage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-test-src
|
||||
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
|
||||
|
||||
- name: Upload launcher smoke artifacts (on failure)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: launcher-smoke
|
||||
path: .tmp/launcher-smoke/**
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Build (bundle)
|
||||
run: bun run build
|
||||
|
||||
- name: Immersion SQLite verification
|
||||
run: bun run test:immersion:sqlite:dist
|
||||
|
||||
- name: Dist smoke suite
|
||||
run: bun run test:smoke:dist
|
||||
|
||||
- name: Security audit
|
||||
run: bun audit --audit-level high
|
||||
continue-on-error: true
|
||||
|
||||
- name: Build Bun subminer wrapper
|
||||
run: make build-launcher
|
||||
|
||||
- name: Verify Bun subminer wrapper
|
||||
run: dist/launcher/subminer --help >/dev/null
|
||||
|
||||
- name: Enforce generated launcher workflow
|
||||
run: bash scripts/verify-generated-launcher.sh
|
||||
uses: ./.github/workflows/quality-gate.yml
|
||||
|
||||
@@ -12,86 +12,9 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
quality-gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.5
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
node_modules
|
||||
stats/node_modules
|
||||
vendor/subminer-yomitan/node_modules
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
cd stats && bun install --frozen-lockfile
|
||||
|
||||
- name: Lint stats (formatting)
|
||||
run: bun run lint:stats
|
||||
|
||||
- name: Build (TypeScript check)
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Install Lua
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y lua5.4
|
||||
sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua
|
||||
lua -v
|
||||
|
||||
- name: Test suite (source)
|
||||
run: bun run test:fast
|
||||
|
||||
- name: Environment suite
|
||||
run: bun run test:env
|
||||
|
||||
- name: Coverage suite (maintained source lane)
|
||||
run: bun run test:coverage:src
|
||||
|
||||
- name: Upload coverage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-test-src
|
||||
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
|
||||
|
||||
- name: Upload launcher smoke artifacts (on failure)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: launcher-smoke
|
||||
path: .tmp/launcher-smoke/**
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Build (bundle)
|
||||
run: bun run build
|
||||
|
||||
- name: Immersion SQLite verification
|
||||
run: bun run test:immersion:sqlite:dist
|
||||
|
||||
- name: Dist smoke suite
|
||||
run: bun run test:smoke:dist
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/quality-gate.yml
|
||||
|
||||
build-linux:
|
||||
needs: [quality-gate]
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
name: Quality Gate
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality-gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.5
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
node_modules
|
||||
stats/node_modules
|
||||
vendor/subminer-yomitan/node_modules
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-bun-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
cd stats && bun install --frozen-lockfile
|
||||
|
||||
- name: Lint changelog fragments
|
||||
run: bun run changelog:lint
|
||||
|
||||
- name: Lint stats (formatting)
|
||||
run: bun run lint:stats
|
||||
|
||||
- name: Enforce pull request changelog fragments (`skip-changelog` label bypass)
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
|
||||
run: bun run changelog:pr-check --base-ref "origin/$BASE_REF" --head-ref "HEAD" --labels "$PR_LABELS"
|
||||
|
||||
- name: Build (TypeScript check)
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Verify generated config examples
|
||||
run: bun run verify:config-example
|
||||
|
||||
- name: Install Lua
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y lua5.4
|
||||
sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua
|
||||
lua -v
|
||||
|
||||
- name: Test suite (source)
|
||||
run: bun run test:fast
|
||||
|
||||
- name: Environment suite
|
||||
run: bun run test:env
|
||||
|
||||
- name: Coverage suite (maintained source lane)
|
||||
run: bun run test:coverage:src
|
||||
|
||||
- name: Upload coverage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-test-src
|
||||
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
|
||||
|
||||
- name: Upload launcher smoke artifacts (on failure)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: launcher-smoke
|
||||
path: .tmp/launcher-smoke/**
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Build (bundle)
|
||||
run: bun run build
|
||||
|
||||
- name: Immersion SQLite verification
|
||||
run: bun run test:immersion:sqlite:dist
|
||||
|
||||
- name: Dist smoke suite
|
||||
run: bun run test:smoke:dist
|
||||
|
||||
- name: Security audit
|
||||
run: bun audit --audit-level high
|
||||
|
||||
- name: Build Bun subminer wrapper
|
||||
run: make build-launcher
|
||||
|
||||
- name: Verify Bun subminer wrapper
|
||||
run: dist/launcher/subminer --help >/dev/null
|
||||
|
||||
- name: Enforce generated launcher workflow
|
||||
run: bash scripts/verify-generated-launcher.sh
|
||||
@@ -13,76 +13,9 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
quality-gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.5
|
||||
|
||||
- name: Cache dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.bun/install/cache
|
||||
node_modules
|
||||
stats/node_modules
|
||||
vendor/subminer-yomitan/node_modules
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
cd stats && bun install --frozen-lockfile
|
||||
|
||||
- name: Lint stats (formatting)
|
||||
run: bun run lint:stats
|
||||
|
||||
- name: Build (TypeScript check)
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Test suite (source)
|
||||
run: bun run test:fast
|
||||
|
||||
- name: Coverage suite (maintained source lane)
|
||||
run: bun run test:coverage:src
|
||||
|
||||
- name: Upload coverage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-test-src
|
||||
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
|
||||
|
||||
- name: Upload launcher smoke artifacts (on failure)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: launcher-smoke
|
||||
path: .tmp/launcher-smoke/**
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Build (bundle)
|
||||
run: bun run build
|
||||
|
||||
- name: Immersion SQLite verification
|
||||
run: bun run test:immersion:sqlite:dist
|
||||
|
||||
- name: Dist smoke suite
|
||||
run: bun run test:smoke:dist
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/quality-gate.yml
|
||||
|
||||
build-linux:
|
||||
needs: [quality-gate]
|
||||
|
||||
@@ -61,3 +61,6 @@ tests/*
|
||||
favicon.png
|
||||
.claude/*
|
||||
!stats/public/favicon.png
|
||||
|
||||
# Browser-automation session artifacts (page snapshots, console logs, downloads)
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -238,6 +238,7 @@ SubMiner builds on the work of these open-source projects:
|
||||
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
|
||||
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
|
||||
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
|
||||
| [TsukiHime](https://tsukihime.org) | Release-track subtitle search and downloads (Animetosho successor) |
|
||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
|
||||
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
|
||||
|
||||
|
||||
@@ -7,36 +7,42 @@
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/geist-mono": "^5.2.7",
|
||||
"@xhayper/discord-rpc": "^1.3.3",
|
||||
"axios": "^1.13.5",
|
||||
"@xhayper/discord-rpc": "^1.3.4",
|
||||
"axios": "^1.18.1",
|
||||
"commander": "^14.0.3",
|
||||
"electron-updater": "^6.8.3",
|
||||
"hono": "^4.12.7",
|
||||
"hono": "^4.12.28",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"koffi": "^2.15.6",
|
||||
"libsql": "^0.5.22",
|
||||
"ws": "^8.19.0",
|
||||
"ws": "^8.21.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"electron": "42.2.0",
|
||||
"electron": "42.6.0",
|
||||
"electron-builder": "26.8.2",
|
||||
"esbuild": "^0.25.12",
|
||||
"eslint": "^10.4.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "7.28.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "0.8.12",
|
||||
"@xmldom/xmldom": "0.8.13",
|
||||
"app-builder-lib": "26.8.2",
|
||||
"electron-builder-squirrel-windows": "26.8.2",
|
||||
"form-data": "4.0.6",
|
||||
"lodash": "4.18.0",
|
||||
"minimatch": "10.2.3",
|
||||
"picomatch": "4.0.4",
|
||||
"tar": "7.5.11",
|
||||
"tar": "7.5.16",
|
||||
"tmp": "0.2.7",
|
||||
},
|
||||
"packages": {
|
||||
"7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="],
|
||||
@@ -45,10 +51,12 @@
|
||||
|
||||
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
|
||||
"@discordjs/rest": ["@discordjs/rest@2.6.1", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.40", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "6.24.1" } }, "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg=="],
|
||||
"@discordjs/rest": ["@discordjs/rest@2.6.1", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.40", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "6.27.0" } }, "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg=="],
|
||||
|
||||
"@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="],
|
||||
|
||||
"@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.4", "", {}, "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg=="],
|
||||
|
||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||
|
||||
"@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="],
|
||||
@@ -215,13 +223,11 @@
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
|
||||
|
||||
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
|
||||
|
||||
"@xhayper/discord-rpc": ["@xhayper/discord-rpc@1.3.3", "", { "dependencies": { "@discordjs/rest": "^2.6.1", "@vladfrangu/async_event_emitter": "^2.4.7", "discord-api-types": "^0.38.42", "ws": "^8.20.0" } }, "sha512-Ih48GHiua7TtZgKO+f0uZPhCeQqb84fY2qUys/oMh8UbUfiUkUJLVCmd/v2AK0/pV33euh0aqSXo7+9LiPSwGw=="],
|
||||
"@xhayper/discord-rpc": ["@xhayper/discord-rpc@1.3.4", "", { "dependencies": { "@discordjs/rest": "^2.6.1", "@vladfrangu/async_event_emitter": "^2.4.7", "discord-api-types": "^0.38.47", "ws": "^8.20.0" } }, "sha512-ff0uEXuibh9wi+l4vOj7xInLUjtlTaQBje/SCyQkeXZ0j2V0y+Zge5PQIQFRHH9TjjGaYJkTofEcQhncM2q7/w=="],
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
|
||||
|
||||
"abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
|
||||
|
||||
@@ -229,7 +235,7 @@
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
|
||||
|
||||
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||
|
||||
@@ -257,7 +263,7 @@
|
||||
|
||||
"at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="],
|
||||
|
||||
"axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
|
||||
"axios": ["axios@1.18.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
@@ -271,8 +277,6 @@
|
||||
|
||||
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
||||
|
||||
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"builder-util": ["builder-util@26.8.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="],
|
||||
@@ -347,7 +351,7 @@
|
||||
|
||||
"dir-compare": ["dir-compare@4.2.0", "", { "dependencies": { "minimatch": "^3.0.5", "p-limit": "^3.1.0 " } }, "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ=="],
|
||||
|
||||
"discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
||||
"discord-api-types": ["discord-api-types@0.38.49", "", {}, "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg=="],
|
||||
|
||||
"dmg-builder": ["dmg-builder@26.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-DaWI+p4DOqiFVZFMovdGYammBOyJAiHHFWUTQ0Z7gNc0twfdIN0LvyJ+vFsgZEDR1fjgbpCj690IVtbYIsZObQ=="],
|
||||
|
||||
@@ -363,7 +367,7 @@
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
"electron": ["electron@42.2.0", "", { "dependencies": { "@electron/get": "^5.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-b2Tc7sIKiZEl0tBVwFM5GJ+FT5KYhmy9QJHjx8BGVZPVW2SctXWEvrE959ElB56qw7H05dBkhlikDA1DmpaAMw=="],
|
||||
"electron": ["electron@42.6.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-axGNgd+yCTg+vi1VEGrQqAj9WVWkePKwbICSAvMiT2eTaxhij9a/xhBHD6rXV8wrlW9ZfJzE5+xg752ImxrmTw=="],
|
||||
|
||||
"electron-builder": ["electron-builder@26.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.2", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-ieiiXPdgH3qrG6lcvy2mtnI5iEmAopmLuVRMSJ5j40weU0tgpNx0OAk9J5X5nnO0j9+KIkxHzwFZVUDk1U3aGw=="],
|
||||
|
||||
@@ -419,8 +423,6 @@
|
||||
|
||||
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
|
||||
|
||||
"extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="],
|
||||
|
||||
"extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
@@ -429,8 +431,6 @@
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
@@ -443,11 +443,11 @@
|
||||
|
||||
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
|
||||
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
|
||||
|
||||
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
|
||||
|
||||
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
|
||||
|
||||
@@ -487,9 +487,9 @@
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||
|
||||
"hono": ["hono@4.12.8", "", {}, "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A=="],
|
||||
"hono": ["hono@4.12.28", "", {}, "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA=="],
|
||||
|
||||
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
||||
|
||||
@@ -499,7 +499,7 @@
|
||||
|
||||
"http2-wrapper": ["http2-wrapper@1.0.3", "", { "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" } }, "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
|
||||
|
||||
"iconv-corefoundation": ["iconv-corefoundation@1.1.7", "", { "dependencies": { "cli-truncate": "^2.1.0", "node-addon-api": "^1.6.3" }, "os": "darwin" }, "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ=="],
|
||||
|
||||
@@ -663,8 +663,6 @@
|
||||
|
||||
"pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="],
|
||||
|
||||
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
@@ -685,7 +683,7 @@
|
||||
|
||||
"proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
||||
|
||||
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
|
||||
|
||||
@@ -767,7 +765,7 @@
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"tar": ["tar@7.5.11", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ=="],
|
||||
"tar": ["tar@7.5.16", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w=="],
|
||||
|
||||
"temp": ["temp@0.9.4", "", { "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="],
|
||||
|
||||
@@ -779,7 +777,7 @@
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
|
||||
"tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="],
|
||||
|
||||
"tmp-promise": ["tmp-promise@3.0.3", "", { "dependencies": { "tmp": "^0.2.0" } }, "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ=="],
|
||||
|
||||
@@ -793,7 +791,7 @@
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
|
||||
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
@@ -823,7 +821,7 @@
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||
|
||||
@@ -835,11 +833,13 @@
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"@discordjs/rest/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
|
||||
"@discordjs/rest/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
||||
|
||||
"@discordjs/rest/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
|
||||
|
||||
"@discordjs/util/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
||||
|
||||
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
||||
|
||||
@@ -863,6 +863,10 @@
|
||||
|
||||
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
|
||||
|
||||
"@npmcli/agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"@types/cacheable-request/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
@@ -877,12 +881,12 @@
|
||||
|
||||
"@types/ws/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
|
||||
"@types/yauzl/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
|
||||
"app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
|
||||
|
||||
"app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
|
||||
|
||||
"builder-util/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"cacache/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
@@ -893,8 +897,14 @@
|
||||
|
||||
"electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="],
|
||||
|
||||
"es-set-tostringtag/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"get-intrinsic/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||
@@ -909,6 +919,8 @@
|
||||
|
||||
"postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
|
||||
|
||||
"socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
|
||||
|
||||
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
@@ -929,14 +941,14 @@
|
||||
|
||||
"@types/ws/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"@types/yauzl/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"app-builder-lib/@electron/get/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"app-builder-lib/@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
|
||||
|
||||
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"builder-util/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"electron-winstaller/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Added an Animetosho integration for downloading English (and Japanese) subtitles, mirroring the Jimaku flow: `Ctrl+Shift+T` (configurable via `shortcuts.openAnimetosho`) opens an in-overlay modal with two language tabs (the first follows `secondarySub.secondarySubLanguages`, defaulting to English; the second is Japanese) that parses the current video filename, searches Animetosho releases, lists extracted text subtitle tracks filtered by the active tab, then downloads the chosen track, decompresses it (requires the `xz` binary), saves it next to the video with a language suffix (`<video>.en.<ext>`, `.ja` for Japanese tracks, etc.), and loads it into mpv immediately - Japanese tracks as the primary subtitle, other languages as the secondary subtitle. No API key is required; also reachable via `subminer --open-animetosho`, the `__animetosho-open` keybinding command, and configurable under a new `animetosho` config section.
|
||||
@@ -2,3 +2,4 @@ type: fixed
|
||||
area: overlay
|
||||
|
||||
- Applied configured primary POS exclusions consistently to merged trailing quote-particle tokens, preserved annotations for supplementary-plane kanji, and stopped treating katakana punctuation as kana-only annotation noise.
|
||||
- Kept kanji vocabulary tagged `名詞/非自立` eligible for N+1 highlighting, consistent with frequency, JLPT, and vocabulary persistence.
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Kept kanji vocabulary tagged `名詞/非自立` eligible for N+1 highlighting, consistent with frequency, JLPT, and vocabulary persistence.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: launcher
|
||||
|
||||
- Added `subminer sync <host>` to merge immersion stats and watch history between two machines over SSH, with `--push` and `--pull` for one-way insert-only transfers. 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: internal
|
||||
area: overlay
|
||||
|
||||
- Consolidated renderer modal state handling into a descriptor registry.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: internal
|
||||
area: release
|
||||
|
||||
- Consolidated pull request, stable release, and prerelease quality checks in one reusable workflow, with Lua mpv plugin tests and blocking high-severity dependency audits running in every gate.
|
||||
@@ -0,0 +1,8 @@
|
||||
type: added
|
||||
area: sync
|
||||
|
||||
- Added cross-machine immersion sync for stats and watch history over SSH, available as a window (**Sync Stats & History** in the tray menu, or `subminer sync --ui`) and as a command (`subminer sync <host>`, with `--push` / `--pull` for one-way insert-only transfers). The window keeps saved devices with per-host direction, one-click sync with live stage-by-stage progress and separate merge summaries for each machine, connection testing for first-time setup, cancellable runs while the app/stats server/playback is active, and manual database snapshots (create/merge/reveal/delete, stored in `/tmp/subminer-db-snapshots/` by default). Hosts with auto-sync enabled sync in the background on a configurable interval, including during playback, with results reported as overlay notifications; hosts synced from the CLI are remembered in `<config dir>/sync-hosts.json` and appear in the window automatically.
|
||||
- Merges are an insert-only union keyed on session UUIDs / video keys / series title keys, so re-syncing is idempotent and nothing is double-counted: each side snapshots its database (`VACUUM INTO`) from a consistent WAL point, snapshots are exchanged with `scp`, and each machine merges the other's data transactionally. Lifetime totals and daily/monthly rollups are updated incrementally (history older than the session retention window is preserved), unfinished sessions are excluded until a later sync sees them finalized, and remote-only historical rollups are copied only when they do not conflict with retained local session history. Sync aborts on stats schema version mismatches and refuses to run while the stats daemon or a live mpv session is active (`--force` overrides).
|
||||
- The sync engine runs only inside the app: the sync window and the `subminer sync` command both delegate to `SubMiner --sync-cli` (headless, works over SSH with no display), so neither machine needs bun or the command-line launcher. A remote machine only needs SubMiner itself, found automatically as the app binary or via the launcher proxy.
|
||||
- Windows remotes are supported: sync detects the remote shell (POSIX, cmd, or PowerShell) and manages remote temp files through SubMiner itself (`sync --make-temp` / `--remove-temp`) instead of `mktemp` / `rm`, so a Windows machine with the built-in OpenSSH Server works as a sync remote, found in its default Windows install location automatically.
|
||||
- Added supporting flags: `subminer sync <host> --check` tests the SSH connection and remote launcher availability without syncing, `subminer sync --snapshot <file>` and `--merge <file>` expose the underlying steps for manual transfers, and `subminer sync --json` emits machine-readable NDJSON progress (the protocol the sync window consumes).
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Added a TsukiHime integration for downloading primary and secondary subtitles, mirroring the Jimaku flow: `Ctrl+Shift+T` (configurable via `shortcuts.openTsukihime`) opens an in-overlay modal with a Japanese primary tab and a secondary tab that follows `secondarySub.secondarySubLanguages`. It parses the current video filename, searches TsukiHime releases, lists extracted text subtitle tracks filtered by the active tab, then downloads the chosen track, decompresses it (requires the `xz` binary), saves it next to the video with a language suffix (`<video>.en.<ext>`, `.ja` for Japanese tracks, etc.), and loads Japanese into mpv's primary slot or configured secondary tracks into its secondary slot. TsukiHime carries the Animetosho index and mirrors its attachment storage, so older releases stay reachable. No API key is required; also reachable via `subminer --open-tsukihime`, the `__tsukihime-open` keybinding command, and configurable under a new `tsukihime` config section.
|
||||
@@ -205,7 +205,7 @@
|
||||
"openCharacterDictionaryManager": "CommandOrControl+D", // Accelerator that opens the character dictionary manager modal.
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||
"openAnimetosho": "Ctrl+Shift+T", // Accelerator that opens the Animetosho subtitle search modal (English/Japanese tabs).
|
||||
"openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
|
||||
@@ -614,14 +614,14 @@
|
||||
}, // Jimaku API configuration and defaults.
|
||||
|
||||
// ==========================================
|
||||
// Animetosho
|
||||
// Animetosho subtitle search configuration (English and Japanese). No API key required.
|
||||
// Hot-reload: Animetosho changes apply to the next Animetosho request.
|
||||
// TsukiHime
|
||||
// TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
|
||||
// Hot-reload: TsukiHime changes apply to the next TsukiHime request.
|
||||
// ==========================================
|
||||
"animetosho": {
|
||||
"apiBaseUrl": "https://feed.animetosho.org", // Base URL of the Animetosho JSON feed API. No API key required.
|
||||
"maxSearchResults": 10 // Maximum Animetosho search results returned.
|
||||
}, // Animetosho subtitle search configuration (English and Japanese). No API key required.
|
||||
"tsukihime": {
|
||||
"apiBaseUrl": "https://api.tsukihime.org/v1", // Base URL of the TsukiHime API (Animetosho successor). No API key required.
|
||||
"maxSearchResults": 10 // Maximum TsukiHime search results returned.
|
||||
}, // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
|
||||
|
||||
// ==========================================
|
||||
// YouTube Playback Settings
|
||||
|
||||
@@ -327,7 +327,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
|
||||
{ text: 'Jellyfin', link: '/jellyfin-integration' },
|
||||
{ text: 'YouTube', link: '/youtube-integration' },
|
||||
{ text: 'Jimaku', link: '/jimaku-integration' },
|
||||
{ text: 'Animetosho', link: '/animetosho-integration' },
|
||||
{ text: 'TsukiHime', link: '/tsukihime-integration' },
|
||||
{ text: 'AniList', link: '/anilist-integration' },
|
||||
{ text: 'AniSkip', link: '/aniskip-integration' },
|
||||
{ text: 'Character Dictionary', link: '/character-dictionary' },
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
# Animetosho Integration
|
||||
|
||||
[Animetosho](https://animetosho.org) mirrors anime torrent releases and extracts every attachment - including embedded subtitle tracks - from the release files, hosting them for direct download. SubMiner integrates with the Animetosho JSON feed API so you can pull English subtitles for the currently playing episode straight from the overlay, no torrent client involved. Downloaded subtitles are decompressed, saved next to the video, and loaded into mpv immediately.
|
||||
|
||||
This is the English-side companion to the [Jimaku integration](/jimaku-integration): Jimaku covers Japanese subtitles, Animetosho covers your secondary language (English by default). Releases that ship multiple languages (e.g. Netflix `[MultiSub]` rips) expose them all; the modal's tabs pick which ones you see, and each download is saved with its own language suffix.
|
||||
|
||||
::: tip No API key required
|
||||
Unlike Jimaku, Animetosho needs no account or API key. The only requirement is the `xz` binary on your `PATH` - Animetosho serves extracted subtitles xz-compressed, and SubMiner shells out to `xz` to decompress them. Most Linux distributions ship it by default (package `xz` or `xz-utils`).
|
||||
:::
|
||||
|
||||
## How It Works
|
||||
|
||||
The integration runs through an in-overlay modal opened with `Ctrl+Shift+T` by default. The modal has two tabs that filter the subtitle tracks of the selected release by language: the first tab follows your `secondarySub.secondarySubLanguages` config (defaults to English when unset, and the tab is labeled accordingly - e.g. "German" if you configure `["de"]`), and the second tab is always **Japanese**. Tracks with no language tag stay visible on the first tab.
|
||||
|
||||
When you open the modal, SubMiner parses the current video filename to extract a title and episode number (same parser as Jimaku - `S01E03`, `1x03`, `E03`, and dash-separated numbers all work). If the filename yields a high-confidence match, SubMiner auto-searches immediately.
|
||||
|
||||
From there:
|
||||
|
||||
1. **Search** - SubMiner queries the Animetosho feed with `<title> <episode>`. Results appear as a list of releases (e.g. `[SubsPlease] ... - 28 (1080p)`), with size and file count.
|
||||
2. **Browse releases** - Select a release to list the text subtitle tracks extracted from its files. English tracks sort first; image-based tracks (PGS/VobSub) are filtered out.
|
||||
3. **Download** - Selecting a track downloads the xz-compressed subtitle from Animetosho's storage, decompresses it, saves it next to the video (or a temp directory for remote/streamed media), and loads it into mpv. Japanese tracks are selected as the **primary** subtitle; any other language loads as the **secondary** subtitle, so your Japanese primary track stays in place. The filename carries the track's language - `<video basename>.en.<ext>` for English, `.ja` for Japanese, and so on - so mpv and media servers detect the language correctly.
|
||||
|
||||
Because releases on Animetosho are the same files circulating as torrents, picking the release that matches your local file (same group, same version) gives you subtitles with exact timing - no resync needed. If your file is a raw or from a different group, pick any release of the same episode and adjust timing with the [subtitle sync tools](/troubleshooting#subtitle-sync-subsync) (`Ctrl+Alt+S`) if necessary.
|
||||
|
||||
### Modal Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
| ------------------------- | ------------------------------- |
|
||||
| `Enter` (in text field) | Search |
|
||||
| `Enter` (in list) | Select release / download track |
|
||||
| `Arrow Up` / `Arrow Down` | Navigate releases or tracks |
|
||||
| `Arrow Left` / `Arrow Right` | Switch English / Japanese tab |
|
||||
| `Escape` | Close modal |
|
||||
|
||||
## Configuration
|
||||
|
||||
The integration works out of the box. An optional `animetosho` section in `config.jsonc` tunes it:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"animetosho": {
|
||||
"apiBaseUrl": "https://feed.animetosho.org",
|
||||
"maxSearchResults": 10,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ----------------------------- | -------- | ------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `animetosho.apiBaseUrl` | `string` | `"https://feed.animetosho.org"` | Base URL of the Animetosho JSON feed API. Only change this if using a mirror. |
|
||||
| `animetosho.maxSearchResults` | `number` | `10` | Maximum number of releases returned per search. |
|
||||
|
||||
The keyboard shortcut is configured separately under `shortcuts`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"shortcuts": {
|
||||
"openAnimetosho": "Ctrl+Shift+T", // default; set to null to disable
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Other Ways to Open It
|
||||
|
||||
- CLI: `subminer --open-animetosho`
|
||||
- Keybinding command: bind any key to `["__animetosho-open"]` in the `keybindings` array
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"xz binary not found"** - install `xz`/`xz-utils` with your package manager.
|
||||
- **"Batch releases are not supported"** - the Animetosho feed API only exposes extracted attachments for single-file torrents. Pick the single-episode release for your episode instead of a season batch.
|
||||
- **"No text subtitle tracks in this release"** - the release only carries image-based subtitles (PGS/VobSub) or none at all; try a different release (fansub and SubsPlease-style releases almost always carry ASS tracks).
|
||||
- **Timing is off** - the subtitle came from a different release than your video file. Use the subtitle sync modal (`Ctrl+Alt+S`) or pick the release matching your file exactly.
|
||||
@@ -81,21 +81,27 @@ Series whose directories are not currently accessible (e.g. an unmounted network
|
||||
|
||||
## Sync Between Machines
|
||||
|
||||
`subminer sync <host>` merges immersion stats and watch history between two machines over SSH, so both end up with the union of sessions, lifetime totals, vocabulary counts, daily/monthly charts, and `--history` entries. `<host>` is anything `ssh` accepts (`user@hostname` or an ssh config alias); SubMiner must be installed on both machines at the same version.
|
||||
`subminer sync <host>` merges immersion stats and watch history between two machines over SSH, so both end up with the union of sessions, lifetime totals, vocabulary counts, daily/monthly charts, and `--history` entries. `<host>` is anything `ssh` accepts (`user@hostname` or an ssh config alias); SubMiner must be installed on both machines at the same version. The sync engine runs only inside the app (`SubMiner --sync-cli sync ...`): the sync window spawns it that way, `subminer sync` is a thin proxy that forwards to the installed app, and the remote side is found automatically whether it has the launcher or just the app. The command-line launcher is optional everywhere.
|
||||
|
||||
```bash
|
||||
subminer sync macbook # two-way sync with the host "macbook"
|
||||
subminer sync macbook --push # merge local data into macbook only
|
||||
subminer sync macbook --pull # merge macbook data into local only
|
||||
subminer sync user@192.168.1.20 # explicit user@host
|
||||
subminer sync macbook --remote-cmd ~/bin/subminer # custom remote launcher path
|
||||
subminer sync macbook --remote-cmd ~/bin/subminer # custom remote SubMiner/launcher path
|
||||
subminer sync macbook --check # test SSH + remote SubMiner without syncing
|
||||
subminer sync --ui # open the sync window (also in the tray menu)
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
For a one-way transfer, `--push` snapshots the local database and merges it into the host without changing the local database. `--pull` snapshots the host and merges it into the local database without changing the host. These modes add missing data; they do not delete destination-only data or make the destination an exact mirror.
|
||||
|
||||
Close SubMiner (and stop the background stats daemon, `subminer stats -s`) on both machines before syncing; the command refuses to run while a SubMiner process may be writing the database (`--force` overrides). The mpv safety check requires a live socket connection, so a stale socket file left after mpv exits does not block sync. Both machines must be on the same SubMiner version — the sync aborts on a stats schema mismatch. Remote sync checks standard SubMiner and Bun locations (`~/.local/bin`, `~/.bun/bin`, Homebrew, `/usr/local/bin`, `/usr/bin`, and `/bin`) even when the non-interactive SSH shell omits them from `PATH`.
|
||||
Command-line sync defaults to a cold-start safety check: close SubMiner (and stop the background stats daemon with `subminer stats -s`) on both machines before running it, or pass `--force`. Syncs started from the Sync window use live mode automatically, including scheduled auto-syncs while SubMiner or playback is active. SQLite WAL provides a consistent snapshot, the transactional merge serializes with live writes, and each machine's unfinished session is excluded from the transfer; that session syncs normally after it finishes. The mpv safety check requires a live socket connection, so a stale socket file left after mpv exits does not block command-line sync. Both machines must be on the same SubMiner version; otherwise, the sync aborts on a stats schema mismatch.
|
||||
|
||||
On the remote, sync looks for the `subminer` launcher first (PATH and `~/.local/bin`), then the app binary in `--sync-cli` mode (`SubMiner` on PATH, then the standard macOS `/Applications` and `~/Applications` installs), checking standard SubMiner and Bun locations (`~/.local/bin`, `~/.bun/bin`, Homebrew, `/usr/local/bin`, `/usr/bin`, and `/bin`) even when the non-interactive SSH shell omits them from `PATH`. An AppImage in a custom location can be addressed with `--remote-cmd /path/to/SubMiner.AppImage` (or symlink it as `SubMiner` somewhere on the remote PATH).
|
||||
|
||||
Windows remotes are supported: enable Windows' built-in **OpenSSH Server** and sync detects the remote shell (cmd or PowerShell) automatically, finding SubMiner in its default install location (`%LOCALAPPDATA%\Programs\SubMiner`), the launcher shim (`%LOCALAPPDATA%\SubMiner\bin`), or on PATH. Temp files on the remote are created and removed by SubMiner itself (`sync --make-temp` / `--remove-temp`), so no POSIX tools are required on the remote side.
|
||||
|
||||
Two lower-level modes are used internally over SSH and also work standalone for manual transfers (e.g. via a USB drive):
|
||||
|
||||
@@ -104,7 +110,22 @@ subminer sync --snapshot /tmp/stats.sqlite # write a consistent snapshot of th
|
||||
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.
|
||||
Unfinished sessions (a crash mid-playback) are skipped until the app finalizes them; they sync on the next run. Word/kanji "known" state from Anki is not part of the database and does not sync. Each machine derives it from its own Anki collection.
|
||||
|
||||
`subminer sync <host> --check` verifies a host without touching any data: it probes the SSH connection, locates SubMiner on the remote (launcher or app binary), and reports its version. `--json` switches any sync mode to machine-readable NDJSON progress output (this is what the sync window consumes).
|
||||
|
||||
`sync --make-temp` creates a restricted temporary directory and prints its path; `sync --remove-temp <dir>` removes one created by that command. They are internal SSH transfer helpers, exposed for compatibility but normally invoked only by sync itself. `SubMiner --sync-cli sync ...` is the packaged app's headless compatibility entrypoint; use `SubMiner --sync-cli --help` for its sync-specific help. The `subminer sync` launcher command selects this entrypoint automatically and runs AppImages in Node-only mode, so remote sync does not require a graphical session.
|
||||
|
||||
### Sync window
|
||||
|
||||
`subminer sync --ui` opens a dedicated window for the same engine in a detached app process, returning the shell immediately. Closing that standalone-launched window exits its app instance. Opening **Sync Stats & History** from the tray keeps the resident app running when the window closes:
|
||||
|
||||
- **Devices:** saved hosts with a per-host direction (two-way / push / pull), an auto-sync toggle, last-sync status, and one-click **Sync now** / **Test** / **Remove**. Hosts synced from the command line appear here automatically.
|
||||
- **Add a device:** test SSH + remote SubMiner availability before saving, with a setup checklist for first-time SSH configuration.
|
||||
- **Activity:** live stage-by-stage progress, remote output, and separate merge summaries (sessions, words, kanji, rollups) for each machine updated by the run. Runs can be cancelled and can proceed while the app, stats server, or playback is active.
|
||||
- **Snapshots:** create manual database snapshots (stored in `/tmp/subminer-db-snapshots/` by default), merge a snapshot file into the local database, or reveal/delete existing snapshots.
|
||||
|
||||
Hosts with **Auto-sync** enabled are synced in the background on a configurable interval (default every 60 minutes), including during active playback; results surface as overlay notifications. The unfinished playback session is skipped until a later sync sees it finalized. Host bookkeeping lives in `<config dir>/sync-hosts.json`.
|
||||
|
||||
## Common Commands
|
||||
|
||||
@@ -120,51 +141,53 @@ subminer stats -b # start background stats daemon
|
||||
|
||||
## Subcommands
|
||||
|
||||
| Subcommand | Purpose |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------ |
|
||||
| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login, `--logout`, `--setup`) |
|
||||
| `subminer stats` | Start the stats server (opens the dashboard when `stats.autoOpenBrowser` is on) |
|
||||
| `subminer stats -b` / `-s` | Start/reuse or stop the background stats daemon |
|
||||
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows (`-v` vocab, `-l` lifetime summaries) |
|
||||
| `subminer stats rebuild` / `backfill` | Rebuild or backfill rollup data |
|
||||
| Subcommand | Purpose |
|
||||
| ------------------------------------------ | ------------------------------------------------------------------------------------------------- |
|
||||
| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login, `--logout`, `--setup`) |
|
||||
| `subminer stats` | Start the stats server (opens the dashboard when `stats.autoOpenBrowser` is on) |
|
||||
| `subminer stats -b` / `-s` | Start/reuse or stop the background stats daemon |
|
||||
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows (`-v` vocab, `-l` lifetime summaries) |
|
||||
| `subminer stats rebuild` / `backfill` | Rebuild or backfill rollup data |
|
||||
| `subminer doctor` | Dependency + config + socket diagnostics (`--refresh-known-words` refreshes the known-word cache) |
|
||||
| `subminer settings` | Open the SubMiner settings window |
|
||||
| `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 sync <host> --push` | Merge local stats/history into another machine only |
|
||||
| `subminer sync <host> --pull` | Merge another machine's stats/history into the local database only |
|
||||
| `subminer dictionary <path>` / `dict` | 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 |
|
||||
| `subminer texthooker` | Launch texthooker-only mode |
|
||||
| `subminer texthooker -o` | Launch texthooker and open it in the default browser |
|
||||
| `subminer app` / `bin` | Pass arguments directly to SubMiner binary (e.g. `subminer app --setup`) |
|
||||
| `subminer settings` | Open the SubMiner settings window |
|
||||
| `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 sync <host> --push` | Merge local stats/history into another machine only |
|
||||
| `subminer sync <host> --pull` | Merge another machine's stats/history into the local database only |
|
||||
| `subminer sync <host> --check` | Test SSH connection and remote launcher availability |
|
||||
| `subminer sync --ui` | Open the sync window (saved devices, auto-sync, snapshots) |
|
||||
| `subminer dictionary <path>` / `dict` | 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 |
|
||||
| `subminer texthooker` | Launch texthooker-only mode |
|
||||
| `subminer texthooker -o` | Launch texthooker and open it in the default browser |
|
||||
| `subminer app` / `bin` | Pass arguments directly to SubMiner binary (e.g. `subminer app --setup`) |
|
||||
|
||||
Use `subminer <subcommand> -h` for command-specific help.
|
||||
|
||||
## Options
|
||||
|
||||
| Flag | Description |
|
||||
| --------------------- | -------------------------------------------------------------------- |
|
||||
| `-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)) |
|
||||
| Flag | Description |
|
||||
| --------------------- | --------------------------------------------------------------------------- |
|
||||
| `-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)) |
|
||||
| `-v, --version` | Print the launcher's own version (can differ from the installed app binary) |
|
||||
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
|
||||
| `--start` | Explicitly start overlay after mpv launches |
|
||||
| `-S, --start-overlay` | Force the visible overlay on start |
|
||||
| `-T, --no-texthooker` | Disable texthooker server |
|
||||
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
|
||||
| `-a, --args` | Pass additional mpv arguments as a quoted string |
|
||||
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) |
|
||||
| `--settings` | Open the SubMiner settings window |
|
||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
|
||||
| `--start` | Explicitly start overlay after mpv launches |
|
||||
| `-S, --start-overlay` | Force the visible overlay on start |
|
||||
| `-T, --no-texthooker` | Disable texthooker server |
|
||||
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
|
||||
| `-a, --args` | Pass additional mpv arguments as a quoted string |
|
||||
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) |
|
||||
| `--settings` | Open the SubMiner settings window |
|
||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||
|
||||
App-binary flags such as `--setup`, `--dev`, and `--debug` are not launcher flags - pass them through with `subminer app`, for example `subminer app --setup`.
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@
|
||||
"openCharacterDictionaryManager": "CommandOrControl+D", // Accelerator that opens the character dictionary manager modal.
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||
"openAnimetosho": "Ctrl+Shift+T", // Accelerator that opens the Animetosho subtitle search modal (English/Japanese tabs).
|
||||
"openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
|
||||
@@ -614,14 +614,14 @@
|
||||
}, // Jimaku API configuration and defaults.
|
||||
|
||||
// ==========================================
|
||||
// Animetosho
|
||||
// Animetosho subtitle search configuration (English and Japanese). No API key required.
|
||||
// Hot-reload: Animetosho changes apply to the next Animetosho request.
|
||||
// TsukiHime
|
||||
// TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
|
||||
// Hot-reload: TsukiHime changes apply to the next TsukiHime request.
|
||||
// ==========================================
|
||||
"animetosho": {
|
||||
"apiBaseUrl": "https://feed.animetosho.org", // Base URL of the Animetosho JSON feed API. No API key required.
|
||||
"maxSearchResults": 10 // Maximum Animetosho search results returned.
|
||||
}, // Animetosho subtitle search configuration (English and Japanese). No API key required.
|
||||
"tsukihime": {
|
||||
"apiBaseUrl": "https://api.tsukihime.org/v1", // Base URL of the TsukiHime API (Animetosho successor). No API key required.
|
||||
"maxSearchResults": 10 // Maximum TsukiHime search results returned.
|
||||
}, // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
|
||||
|
||||
// ==========================================
|
||||
// YouTube Playback Settings
|
||||
|
||||
@@ -82,7 +82,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
||||
| `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+Shift+T` | Open Animetosho subtitle search modal (EN/JA tabs) | `shortcuts.openAnimetosho` |
|
||||
| `Ctrl+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` |
|
||||
| `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` |
|
||||
@@ -91,6 +91,8 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
||||
| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
|
||||
| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` |
|
||||
|
||||
`shortcuts.openAnimetosho` remains accepted as a deprecated alias for `shortcuts.openTsukihime`. The current name takes precedence when both are configured.
|
||||
|
||||
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`.
|
||||
|
||||
The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# TsukiHime Integration
|
||||
|
||||
[TsukiHime](https://tsukihime.org) tracks anime torrent releases and extracts every attachment - including embedded subtitle tracks - from the release files, hosting them for direct download. SubMiner integrates with the TsukiHime API so you can pull English subtitles for the currently playing episode straight from the overlay, no torrent client involved. Downloaded subtitles are decompressed, saved next to the video, and loaded into mpv immediately.
|
||||
|
||||
This is the multi-language companion to the [Jimaku integration](/jimaku-integration). Releases that ship multiple languages (e.g. Netflix `[MultiSub]` rips) expose them all; the modal's tabs pick which ones you see, and each download is saved with its own language suffix.
|
||||
|
||||
::: tip Successor to Animetosho
|
||||
TsukiHime replaces [Animetosho](https://animetosho.org), which stops processing new releases in May 2026. TsukiHime imported the Animetosho index and mirrors its attachment storage, so older releases stay reachable alongside new ones.
|
||||
:::
|
||||
|
||||
::: tip No API key required
|
||||
Unlike Jimaku, TsukiHime needs no account or API key. The only requirement is the `xz` binary on your `PATH` - TsukiHime serves extracted subtitles xz-compressed, and SubMiner shells out to `xz` to decompress them. Most Linux distributions ship it by default (package `xz` or `xz-utils`).
|
||||
:::
|
||||
|
||||
## How It Works
|
||||
|
||||
The integration runs through an in-overlay modal opened with `Ctrl+Shift+T` by default. The modal has two tabs that filter the subtitle tracks of the selected release by role: the first follows `secondarySub.secondarySubLanguages` (English when unset), and the second is always **Japanese**, the currently supported primary subtitle language. Tracks with no language tag stay visible on the secondary tab.
|
||||
|
||||
When you open the modal, SubMiner parses the current video filename to extract a title and episode number (same parser as Jimaku - `S01E03`, `1x03`, `E03`, and dash-separated numbers all work). If the filename yields a high-confidence match, SubMiner auto-searches immediately.
|
||||
|
||||
From there:
|
||||
|
||||
1. **Search** - SubMiner queries TsukiHime with `<title> <episode>`. Results appear as a list of releases (e.g. `[SubsPlease] ... - 28 (1080p)`), each showing size, file count, and the subtitle languages the release carries.
|
||||
2. **Browse releases** - Select a release to list the text subtitle tracks extracted from its files. English tracks sort first; image-based tracks (PGS/VobSub) are filtered out.
|
||||
3. **Download** - Selecting a track downloads the xz-compressed subtitle from TsukiHime's storage, decompresses it, saves it next to the video (or a temp directory for remote/streamed media), and loads it into mpv. Japanese tracks are selected as mpv's **primary** subtitle. Tracks from the configured secondary tab are assigned to mpv's **secondary** subtitle slot without replacing the primary. The filename carries the track's language - `<video basename>.en.<ext>` for English, `.ja` for Japanese, and so on - so mpv and media servers detect the language correctly.
|
||||
|
||||
Because releases on TsukiHime are the same files circulating as torrents, picking the release that matches your local file (same group, same version) gives you subtitles with exact timing - no resync needed. If your file is a raw or from a different group, pick any release of the same episode and adjust timing with the [subtitle sync tools](/troubleshooting#subtitle-sync-subsync) (`Ctrl+Alt+S`) if necessary.
|
||||
|
||||
### Modal Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
| ---------------------------- | ------------------------------- |
|
||||
| `Enter` (in text field) | Search |
|
||||
| `Enter` (in list) | Select release / download track |
|
||||
| `Arrow Up` / `Arrow Down` | Navigate releases or tracks |
|
||||
| `Arrow Left` / `Arrow Right` | Switch English / Japanese tab |
|
||||
| `Escape` | Close modal |
|
||||
|
||||
## Configuration
|
||||
|
||||
The integration works out of the box. An optional `tsukihime` section in `config.jsonc` tunes it:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"tsukihime": {
|
||||
"apiBaseUrl": "https://api.tsukihime.org/v1",
|
||||
"maxSearchResults": 10,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ---------------------------- | -------- | -------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `tsukihime.apiBaseUrl` | `string` | `"https://api.tsukihime.org/v1"` | Base URL of the TsukiHime API. Only change this if using a mirror. |
|
||||
| `tsukihime.maxSearchResults` | `number` | `10` | Maximum number of releases returned per search (the API caps this at 100). |
|
||||
|
||||
The keyboard shortcut is configured separately under `shortcuts`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"shortcuts": {
|
||||
"openTsukihime": "Ctrl+Shift+T", // default; set to null to disable
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Existing Animetosho configuration remains compatible. SubMiner treats the old `animetosho` section and `shortcuts.openAnimetosho` setting as deprecated aliases. When old and current names are both present, `tsukihime` and `shortcuts.openTsukihime` take precedence.
|
||||
|
||||
## Other Ways to Open It
|
||||
|
||||
- CLI: `subminer --open-tsukihime`
|
||||
- Keybinding command: bind any key to `["__tsukihime-open"]` in the `keybindings` array
|
||||
|
||||
The previous `--open-animetosho` flag and `__animetosho-open` keybinding command remain accepted as deprecated aliases.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"xz binary not found"** - install `xz`/`xz-utils` with your package manager.
|
||||
- **"Batch releases are not supported"** - TsukiHime only exposes extracted attachments for single-file torrents. Pick the single-episode release for your episode instead of a season batch.
|
||||
- **"No text subtitle tracks in this release"** - the release only carries image-based subtitles (PGS/VobSub) or none at all; try a different release (fansub and SubsPlease-style releases almost always carry ASS tracks).
|
||||
- **Timing is off** - the subtitle came from a different release than your video file. Use the subtitle sync modal (`Ctrl+Alt+S`) or pick the release matching your file exactly.
|
||||
+13
-1
@@ -122,8 +122,13 @@ subminer mpv idle # Launch detached idle mpv with SubMiner defau
|
||||
subminer sync media-box # Sync stats/watch history with an SSH host
|
||||
subminer sync media-box --push # Merge this machine's stats into the host only
|
||||
subminer sync media-box --pull # Merge the host's stats into this machine only
|
||||
subminer sync media-box --check # Verify SSH and remote SubMiner without syncing
|
||||
subminer sync media-box --json # Emit machine-readable NDJSON progress
|
||||
subminer sync --ui # Open the Sync Stats & History window
|
||||
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 sync --make-temp # Create an internal sync temp directory
|
||||
subminer sync --remove-temp /tmp/subminer-sync-123 # Remove an internal sync temp directory
|
||||
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
|
||||
@@ -146,6 +151,7 @@ SubMiner.AppImage --show-visible-overlay # Force show visible overl
|
||||
SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay
|
||||
SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle bar visibility
|
||||
SubMiner.AppImage --toggle-subtitle-sidebar # Toggle the subtitle sidebar
|
||||
SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
|
||||
SubMiner.AppImage --start --dev # Enable app/dev mode only
|
||||
SubMiner.AppImage --start --debug # Alias for --dev
|
||||
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
|
||||
@@ -159,12 +165,18 @@ SubMiner.AppImage --jellyfin-libraries
|
||||
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search anime --jellyfin-limit 20
|
||||
SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID --jellyfin-audio-stream-index 1 --jellyfin-subtitle-stream-index 2 # Requires connected mpv IPC (--start)
|
||||
SubMiner.AppImage --jellyfin-remote-announce # Force cast-target capability announce + visibility check
|
||||
SubMiner.AppImage --sync-cli --help # Show the packaged app's headless sync help
|
||||
SubMiner.AppImage --sync-cli sync media-box # Run the sync engine directly in headless mode
|
||||
SubMiner.AppImage --dictionary # Generate character dictionary ZIP for current anime
|
||||
SubMiner.AppImage --dictionary-candidates # List AniList candidates for current character dictionary series
|
||||
SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin correct AniList media for series
|
||||
SubMiner.AppImage --help # Show all options
|
||||
```
|
||||
|
||||
`--check` performs connection and version checks without changing data. `--json` emits the NDJSON event protocol used by the sync window. `--ui` opens that window in a detached app process and returns the shell immediately; closing a standalone-launched Sync window exits that app instance. `--make-temp` and `--remove-temp` are internal remote-transfer helpers and should normally be left to SubMiner. The packaged app's `--sync-cli` flag selects its headless sync-compatible entrypoint; the `subminer sync` launcher command proxies to it automatically.
|
||||
|
||||
The previous `--open-animetosho` flag remains accepted as a deprecated alias for `--open-tsukihime`.
|
||||
|
||||
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.
|
||||
@@ -367,7 +379,7 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
|
||||
| Keybind | Action | Scope |
|
||||
| ------------- | ---------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| `Alt+Shift+O` | Toggle visible overlay | Works while the overlay or mpv has focus (configurable via `shortcuts.toggleVisibleOverlayGlobal`) |
|
||||
| `Alt+Shift+Y` | Open Yomitan settings | OS-global - registered with the system, works from any window |
|
||||
| `Alt+Shift+Y` | Open Yomitan settings | OS-global - registered with the system, works from any window |
|
||||
|
||||
`Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config.
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ Notes:
|
||||
- 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.
|
||||
- If you need to repair a published release body (for example, a prior version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
|
||||
- Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job.
|
||||
- CI, stable release, and prerelease workflows call the shared `.github/workflows/quality-gate.yml`; update that reusable workflow when changing common test coverage. Its environment suite includes the Lua mpv plugin tests.
|
||||
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
|
||||
- Stable release tags update `https://docs.subminer.moe/` and `https://docs.subminer.moe/v/<version>/` through `.github/workflows/docs-pages.yml`; `/main/` continues to show development docs from `main`.
|
||||
- Keep Cloudflare Pages Git auto-deploy disabled for `docs.subminer.moe`. Production docs are direct-uploaded by Wrangler from GitHub Actions with `--branch main`.
|
||||
|
||||
@@ -18,6 +18,11 @@ Read when: selecting the right verification lane for a change
|
||||
`--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.
|
||||
- `.github/workflows/quality-gate.yml` is the reusable `workflow_call` gate for
|
||||
pull requests, stable tags, and prerelease tags. Keep common quality steps
|
||||
there instead of copying them into caller workflows.
|
||||
- The reusable gate installs Lua and runs `bun run test:env`, so the shipped mpv
|
||||
plugin tests run for every pull request and tagged release.
|
||||
|
||||
## Default Handoff Gate
|
||||
|
||||
@@ -52,7 +57,14 @@ bun run docs:build
|
||||
|
||||
- `bun run test:coverage:src` runs the maintained `test:src` lane through a sharded coverage runner: one Bun coverage process per test file, then merged LCOV output.
|
||||
- Machine-readable output lands at `coverage/test-src/lcov.info`.
|
||||
- CI and release quality-gate runs upload that LCOV file as the `coverage-test-src` artifact.
|
||||
- Every reusable quality-gate run uploads that LCOV file as the
|
||||
`coverage-test-src` artifact.
|
||||
|
||||
## Dependency Audit Policy
|
||||
|
||||
- `bun audit --audit-level high` blocks the reusable quality gate.
|
||||
- Keep security overrides and dependency patches at the minimum fixed version.
|
||||
Remove them after the owning package ships and adopts a compatible fix.
|
||||
|
||||
## Rules
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
launchAppCommandDetached,
|
||||
launchAppBackgroundDetached,
|
||||
launchTexthookerOnly,
|
||||
runAppCommandWithInherit,
|
||||
@@ -7,6 +8,10 @@ import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
type AppCommandDeps = {
|
||||
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void;
|
||||
launchSyncUiDetached: (
|
||||
appPath: string,
|
||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||
) => void;
|
||||
launchAppBackgroundDetached: (
|
||||
appPath: string,
|
||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||
@@ -15,6 +20,8 @@ type AppCommandDeps = {
|
||||
|
||||
const defaultAppCommandDeps: AppCommandDeps = {
|
||||
runAppCommandWithInherit,
|
||||
launchSyncUiDetached: (appPath, logLevel) =>
|
||||
launchAppCommandDetached(appPath, ['--sync-window'], logLevel, 'sync-ui'),
|
||||
launchAppBackgroundDetached,
|
||||
};
|
||||
|
||||
@@ -30,6 +37,10 @@ export function runAppPassthroughCommand(
|
||||
deps.runAppCommandWithInherit(appPath, ['--settings']);
|
||||
return true;
|
||||
}
|
||||
if (args.syncUi) {
|
||||
deps.launchSyncUiDetached(appPath, args.logLevel);
|
||||
return true;
|
||||
}
|
||||
if (!args.appPassthrough) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -206,6 +206,7 @@ test('app command starts default macOS background app detached from launcher', (
|
||||
runAppCommandWithInherit: () => {
|
||||
calls.push('attached');
|
||||
},
|
||||
launchSyncUiDetached: () => calls.push('sync-ui'),
|
||||
launchAppBackgroundDetached: (appPath, logLevel) => {
|
||||
calls.push(`detached:${appPath}:${logLevel}`);
|
||||
},
|
||||
@@ -225,6 +226,7 @@ test('app command starts default Linux background app detached from launcher', (
|
||||
runAppCommandWithInherit: () => {
|
||||
calls.push('attached');
|
||||
},
|
||||
launchSyncUiDetached: () => calls.push('sync-ui'),
|
||||
launchAppBackgroundDetached: (appPath, logLevel) => {
|
||||
calls.push(`detached:${appPath}:${logLevel}`);
|
||||
},
|
||||
@@ -245,6 +247,7 @@ test('app command keeps explicit passthrough args attached', () => {
|
||||
runAppCommandWithInherit: (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
},
|
||||
launchSyncUiDetached: () => detached.push('sync-ui'),
|
||||
launchAppBackgroundDetached: () => {
|
||||
detached.push('detached');
|
||||
},
|
||||
@@ -255,6 +258,21 @@ test('app command keeps explicit passthrough args attached', () => {
|
||||
assert.deepEqual(detached, []);
|
||||
});
|
||||
|
||||
test('sync UI command launches the app detached from the terminal', () => {
|
||||
const context = createContext();
|
||||
context.args.syncUi = true;
|
||||
const calls: string[] = [];
|
||||
|
||||
const handled = runAppPassthroughCommand(context, {
|
||||
runAppCommandWithInherit: () => calls.push('piped'),
|
||||
launchSyncUiDetached: (appPath, logLevel) => calls.push(`sync-ui:${appPath}:${logLevel}`),
|
||||
launchAppBackgroundDetached: () => calls.push('detached'),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(calls, ['sync-ui:/tmp/subminer.app:warn']);
|
||||
});
|
||||
|
||||
test('mpv pre-app command exits non-zero when socket is not ready', async () => {
|
||||
const context = createContext();
|
||||
context.args.mpvStatus = true;
|
||||
|
||||
@@ -37,13 +37,8 @@ function createContext(): LauncherCommandContext {
|
||||
useRofi: false,
|
||||
history: false,
|
||||
sync: false,
|
||||
syncHost: '',
|
||||
syncSnapshotPath: '',
|
||||
syncMergePath: '',
|
||||
syncDirection: 'both',
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncCliTokens: [],
|
||||
syncUi: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
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';
|
||||
import { runSyncCommand, type SyncCommandDeps } from './sync-command.js';
|
||||
|
||||
function makeContext(overrides: Partial<Args>): LauncherCommandContext {
|
||||
function makeContext(
|
||||
overrides: Partial<Args>,
|
||||
appPath: string | null = '/opt/SubMiner/subminer-app',
|
||||
): LauncherCommandContext {
|
||||
return {
|
||||
args: {
|
||||
sync: true,
|
||||
syncHost: '',
|
||||
syncSnapshotPath: '',
|
||||
syncMergePath: '',
|
||||
syncDirection: 'both',
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncCliTokens: [],
|
||||
logLevel: 'warn',
|
||||
...overrides,
|
||||
} as Args,
|
||||
@@ -26,232 +19,90 @@ function makeContext(overrides: Partial<Args>): LauncherCommandContext {
|
||||
scriptName: 'subminer',
|
||||
mpvSocketPath: '',
|
||||
pluginRuntimeConfig: {},
|
||||
appPath: null,
|
||||
appPath,
|
||||
launcherJellyfinConfig: {},
|
||||
processAdapter: process,
|
||||
processAdapter: { platform: () => 'linux' },
|
||||
} 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;
|
||||
test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async () => {
|
||||
const spawned: Array<{ appPath: string; appArgs: string[] }> = [];
|
||||
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);
|
||||
runAppCommand: (appPath, appArgs) => {
|
||||
spawned.push({ appPath, appArgs });
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
await runSyncCommand(
|
||||
makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }),
|
||||
deps,
|
||||
),
|
||||
await runSyncCommand(makeContext({ syncCliTokens: ['media-box', '--json'] }), deps),
|
||||
true,
|
||||
);
|
||||
assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'));
|
||||
assert.deepEqual(spawned, [
|
||||
{
|
||||
appPath: '/opt/SubMiner/subminer-app',
|
||||
appArgs: ['--sync-cli', 'sync', 'media-box', '--json', '--log-level', 'warn'],
|
||||
},
|
||||
]);
|
||||
|
||||
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>/,
|
||||
);
|
||||
assert.equal(await runSyncCommand(makeContext({ sync: false }), deps), false);
|
||||
assert.equal(spawned.length, 1);
|
||||
});
|
||||
|
||||
test('runHostSync keeps tracker quiescent through local and remote merge and cleans up after failure', async () => {
|
||||
const calls: string[] = [];
|
||||
let localTmpDir = '';
|
||||
test('runSyncCommand forwards tokens verbatim and appends the effective log level', async () => {
|
||||
const spawned: string[][] = [];
|
||||
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');
|
||||
runAppCommand: (_appPath, appArgs) => {
|
||||
spawned.push(appArgs);
|
||||
},
|
||||
};
|
||||
|
||||
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/,
|
||||
);
|
||||
});
|
||||
|
||||
function makeDirectionDeps(calls: string[]): Partial<SyncCommandDeps> {
|
||||
return {
|
||||
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',
|
||||
runSsh: (_host: string, command: string) => {
|
||||
calls.push(`ssh:${command}`);
|
||||
if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n');
|
||||
return ok();
|
||||
},
|
||||
runScp: (from: string, to: string) => {
|
||||
calls.push(`scp:${from}->${to}`);
|
||||
if (!to.includes(':')) fs.writeFileSync(to, 'pulled');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('runHostSync push only snapshots locally and merges remotely', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await runSyncCommand(
|
||||
makeContext({
|
||||
syncDbPath: '/tmp/local.sqlite',
|
||||
syncHost: 'media-box',
|
||||
syncDirection: 'push',
|
||||
syncCliTokens: [
|
||||
'media-box',
|
||||
'--pull',
|
||||
'--remote-cmd',
|
||||
'/opt/SubMiner.AppImage',
|
||||
'--db',
|
||||
'/tmp/db.sqlite',
|
||||
'--force',
|
||||
'--json',
|
||||
],
|
||||
logLevel: 'debug',
|
||||
}),
|
||||
makeDirectionDeps(calls),
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.ok(calls.some((call) => call.startsWith('snapshot:')));
|
||||
assert.ok(calls.some((call) => call.includes(' sync --merge ')));
|
||||
assert.ok(calls.some((call) => call.startsWith('scp:') && call.includes('->media-box:')));
|
||||
assert.ok(!calls.some((call) => call.includes(' sync --snapshot ')));
|
||||
assert.ok(!calls.includes('local-merge'));
|
||||
assert.deepEqual(spawned, [
|
||||
[
|
||||
'--sync-cli',
|
||||
'sync',
|
||||
'media-box',
|
||||
'--pull',
|
||||
'--remote-cmd',
|
||||
'/opt/SubMiner.AppImage',
|
||||
'--db',
|
||||
'/tmp/db.sqlite',
|
||||
'--force',
|
||||
'--json',
|
||||
'--log-level',
|
||||
'debug',
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('runHostSync pull only snapshots remotely and merges locally', async () => {
|
||||
const calls: string[] = [];
|
||||
test('runSyncCommand fails with a clear message when the app binary is missing', async () => {
|
||||
const deps: Partial<SyncCommandDeps> = {
|
||||
runAppCommand: () => {
|
||||
throw new Error('should not spawn');
|
||||
},
|
||||
fail: (message: string): never => {
|
||||
throw new Error(message);
|
||||
},
|
||||
};
|
||||
|
||||
await runSyncCommand(
|
||||
makeContext({
|
||||
syncDbPath: '/tmp/local.sqlite',
|
||||
syncHost: 'media-box',
|
||||
syncDirection: 'pull',
|
||||
}),
|
||||
makeDirectionDeps(calls),
|
||||
await assert.rejects(
|
||||
() => runSyncCommand(makeContext({ syncCliTokens: ['media-box'] }, null), deps),
|
||||
/SubMiner app binary not found \(sync runs inside the app\)/,
|
||||
);
|
||||
|
||||
assert.ok(calls.some((call) => call.includes(' sync --snapshot ')));
|
||||
assert.ok(calls.some((call) => call.startsWith('scp:media-box:')));
|
||||
assert.ok(calls.includes('local-merge'));
|
||||
assert.ok(!calls.some((call) => call.startsWith('snapshot:')));
|
||||
assert.ok(!calls.some((call) => call.includes(' sync --merge ')));
|
||||
});
|
||||
|
||||
@@ -1,253 +1,45 @@
|
||||
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 { SYNC_CLI_FLAG } from '../../src/core/services/stats-sync/cli-args.js';
|
||||
import { fail } from '../log.js';
|
||||
import { runAppCommandInteractive } 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.`,
|
||||
);
|
||||
}
|
||||
runAppCommand: (appPath: string, appArgs: string[]) => void;
|
||||
fail: (message: string) => never;
|
||||
}
|
||||
|
||||
const defaultSyncCommandDeps: SyncCommandDeps = {
|
||||
createDbSnapshot,
|
||||
mergeSnapshotIntoDb,
|
||||
formatMergeSummary,
|
||||
findLiveStatsDaemonPid,
|
||||
assertSafeSshHost,
|
||||
resolveRemoteSubminerCommand,
|
||||
runScp,
|
||||
runSsh,
|
||||
runAppCommand: runAppCommandInteractive,
|
||||
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;
|
||||
const direction = args.syncDirection ?? 'both';
|
||||
const shouldPull = direction !== 'push';
|
||||
const shouldPush = direction !== 'pull';
|
||||
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' : '';
|
||||
|
||||
const localSnapshot = path.join(localTmpDir, 'local.sqlite');
|
||||
if (shouldPush) {
|
||||
deps.consoleLog(`Snapshotting local database (${dbPath})...`);
|
||||
deps.createDbSnapshot(dbPath, localSnapshot);
|
||||
}
|
||||
|
||||
const remoteSnapshot = `${remoteTmpDir}/snapshot.sqlite`;
|
||||
if (shouldPull) {
|
||||
deps.consoleLog(`Snapshotting ${host}...`);
|
||||
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');
|
||||
if (shouldPull) deps.runScp(`${host}:${remoteSnapshot}`, pulledSnapshot);
|
||||
const incomingSnapshot = `${remoteTmpDir}/incoming.sqlite`;
|
||||
if (shouldPush) deps.runScp(localSnapshot, `${host}:${incomingSnapshot}`);
|
||||
|
||||
if (shouldPull) {
|
||||
deps.consoleLog(`\nMerging ${host} -> local:`);
|
||||
await deps.ensureTrackerQuiescent(context, dbPath);
|
||||
const summary = deps.mergeSnapshotIntoDb(dbPath, pulledSnapshot);
|
||||
deps.consoleLog(deps.formatMergeSummary(summary));
|
||||
}
|
||||
|
||||
if (shouldPush) {
|
||||
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) {
|
||||
const retryCommand =
|
||||
direction === 'push' ? `subminer sync ${host} --push` : `subminer sync ${host}`;
|
||||
const localUpdate = shouldPull ? ' The local database was updated;' : '';
|
||||
throw new Error(
|
||||
formatRemoteRunError(
|
||||
`Remote merge failed on ${host}.${localUpdate} re-run "${retryCommand}" 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `subminer sync` is a thin proxy: the sync engine only executes inside the
|
||||
* SubMiner app (--sync-cli mode, libsql). The launcher contributes its
|
||||
* parser/help and app discovery; the app's parseSyncCliTokens owns validation,
|
||||
* so its errors reach the terminal through the child's inherited stdio. The
|
||||
* child owns the terminal and its exit code becomes the launcher's.
|
||||
*/
|
||||
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>.');
|
||||
const deps = { ...defaultSyncCommandDeps, ...inputDeps };
|
||||
if (!context.args.sync) return false;
|
||||
if (!context.appPath) {
|
||||
deps.fail(
|
||||
context.processAdapter.platform() === 'darwin'
|
||||
? 'SubMiner app binary not found (sync runs inside the app). Install SubMiner.app to /Applications or ~/Applications, or set SUBMINER_APPIMAGE_PATH.'
|
||||
: 'SubMiner app binary not found (sync runs inside the app). Install the SubMiner app, or set SUBMINER_APPIMAGE_PATH.',
|
||||
);
|
||||
return true; // fail() never returns; this only satisfies control-flow analysis
|
||||
}
|
||||
deps.runAppCommand(context.appPath, [
|
||||
SYNC_CLI_FLAG,
|
||||
'sync',
|
||||
...context.args.syncCliTokens,
|
||||
'--log-level',
|
||||
context.args.logLevel,
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -136,14 +136,10 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
||||
statsCleanupLifetime: false,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncHost: null,
|
||||
syncSnapshotPath: null,
|
||||
syncMergePath: null,
|
||||
syncDirection: 'both',
|
||||
syncRemoteCmd: null,
|
||||
syncDbPath: null,
|
||||
syncForce: false,
|
||||
syncCliTokens: [],
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
@@ -191,14 +187,10 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
|
||||
statsCleanupLifetime: false,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncHost: null,
|
||||
syncSnapshotPath: null,
|
||||
syncMergePath: null,
|
||||
syncDirection: 'both',
|
||||
syncRemoteCmd: null,
|
||||
syncDbPath: null,
|
||||
syncForce: false,
|
||||
syncCliTokens: [],
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
@@ -239,14 +231,10 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
|
||||
statsCleanupLifetime: false,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncHost: null,
|
||||
syncSnapshotPath: null,
|
||||
syncMergePath: null,
|
||||
syncDirection: 'both',
|
||||
syncRemoteCmd: null,
|
||||
syncDbPath: null,
|
||||
syncForce: false,
|
||||
syncCliTokens: [],
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
@@ -285,14 +273,10 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
||||
statsCleanupLifetime: false,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncHost: null,
|
||||
syncSnapshotPath: null,
|
||||
syncMergePath: null,
|
||||
syncDirection: 'both',
|
||||
syncRemoteCmd: null,
|
||||
syncDbPath: null,
|
||||
syncForce: false,
|
||||
syncCliTokens: [],
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
|
||||
@@ -200,13 +200,8 @@ export function createDefaultArgs(
|
||||
useRofi: false,
|
||||
history: false,
|
||||
sync: false,
|
||||
syncHost: '',
|
||||
syncSnapshotPath: '',
|
||||
syncMergePath: '',
|
||||
syncDirection: 'both',
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncCliTokens: [],
|
||||
syncUi: false,
|
||||
logLevel: loggingConfig.level ?? 'warn',
|
||||
logRotation: loggingConfig.rotation ?? 7,
|
||||
passwordStore: '',
|
||||
@@ -274,15 +269,13 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
||||
}
|
||||
if (invocations.syncTriggered) {
|
||||
parsed.sync = true;
|
||||
parsed.syncHost = invocations.syncHost ?? '';
|
||||
parsed.syncSnapshotPath = invocations.syncSnapshotPath ?? '';
|
||||
parsed.syncMergePath = invocations.syncMergePath ?? '';
|
||||
parsed.syncDirection = invocations.syncDirection;
|
||||
parsed.syncRemoteCmd = invocations.syncRemoteCmd ?? '';
|
||||
parsed.syncDbPath = invocations.syncDbPath ?? '';
|
||||
parsed.syncForce = invocations.syncForce;
|
||||
parsed.syncCliTokens = invocations.syncCliTokens;
|
||||
if (invocations.syncLogLevel) parsed.logLevel = parseLogLevel(invocations.syncLogLevel);
|
||||
}
|
||||
if (invocations.syncUiTriggered) {
|
||||
parsed.syncUi = true;
|
||||
if (invocations.syncUiLogLevel) parsed.logLevel = parseLogLevel(invocations.syncUiLogLevel);
|
||||
}
|
||||
if (invocations.doctorTriggered) parsed.doctor = true;
|
||||
if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true;
|
||||
if (invocations.logsTriggered && !invocations.logsExport) {
|
||||
|
||||
@@ -43,21 +43,88 @@ test('parseCliPrograms captures texthooker browser-open flag', () => {
|
||||
assert.equal(result.invocations.texthookerOpenBrowser, true);
|
||||
});
|
||||
|
||||
test('parseCliPrograms captures one-way sync directions', () => {
|
||||
test('parseCliPrograms lowers sync options into app-owned CLI tokens', () => {
|
||||
const push = parseCliPrograms(['sync', 'media-box', '--push'], 'subminer');
|
||||
assert.equal(push.invocations.syncTriggered, true);
|
||||
assert.deepEqual(push.invocations.syncCliTokens, ['media-box', '--push']);
|
||||
|
||||
const pull = parseCliPrograms(['sync', 'media-box', '--pull'], 'subminer');
|
||||
assert.deepEqual(pull.invocations.syncCliTokens, ['media-box', '--pull']);
|
||||
|
||||
assert.equal(push.invocations.syncDirection, 'push');
|
||||
assert.equal(pull.invocations.syncDirection, 'pull');
|
||||
const check = parseCliPrograms(['sync', 'media-box', '--check', '--json'], 'subminer');
|
||||
assert.deepEqual(check.invocations.syncCliTokens, ['media-box', '--check', '--json']);
|
||||
|
||||
const full = parseCliPrograms(
|
||||
[
|
||||
'sync',
|
||||
'media-box',
|
||||
'--remote-cmd',
|
||||
'/opt/SubMiner.AppImage',
|
||||
'--db',
|
||||
'/tmp/db.sqlite',
|
||||
'--force',
|
||||
'--log-level',
|
||||
'debug',
|
||||
],
|
||||
'subminer',
|
||||
);
|
||||
assert.deepEqual(full.invocations.syncCliTokens, [
|
||||
'media-box',
|
||||
'--remote-cmd',
|
||||
'/opt/SubMiner.AppImage',
|
||||
'--db',
|
||||
'/tmp/db.sqlite',
|
||||
'--force',
|
||||
]);
|
||||
assert.equal(full.invocations.syncLogLevel, 'debug');
|
||||
|
||||
const snapshot = parseCliPrograms(['sync', '--snapshot', '/tmp/out.sqlite'], 'subminer');
|
||||
assert.deepEqual(snapshot.invocations.syncCliTokens, ['--snapshot', '/tmp/out.sqlite']);
|
||||
|
||||
const merge = parseCliPrograms(['sync', '--merge', '/tmp/in.sqlite'], 'subminer');
|
||||
assert.deepEqual(merge.invocations.syncCliTokens, ['--merge', '/tmp/in.sqlite']);
|
||||
|
||||
const makeTemp = parseCliPrograms(['sync', '--make-temp'], 'subminer');
|
||||
assert.deepEqual(makeTemp.invocations.syncCliTokens, ['--make-temp']);
|
||||
|
||||
const removeTemp = parseCliPrograms(
|
||||
['sync', '--remove-temp', '/tmp/subminer-sync-x'],
|
||||
'subminer',
|
||||
);
|
||||
assert.deepEqual(removeTemp.invocations.syncCliTokens, ['--remove-temp', '/tmp/subminer-sync-x']);
|
||||
});
|
||||
|
||||
test('parseCliPrograms rejects conflicting or hostless one-way sync directions', () => {
|
||||
test('parseCliPrograms leaves sync validation to the app parser', () => {
|
||||
// Invalid combinations are forwarded; the app's parseSyncCliTokens rejects them.
|
||||
const invalid = parseCliPrograms(['sync', 'media-box', '--push', '--pull'], 'subminer');
|
||||
assert.equal(invalid.invocations.syncTriggered, true);
|
||||
assert.deepEqual(invalid.invocations.syncCliTokens, ['media-box', '--push', '--pull']);
|
||||
|
||||
const empty = parseCliPrograms(['sync'], 'subminer');
|
||||
assert.equal(empty.invocations.syncTriggered, true);
|
||||
assert.deepEqual(empty.invocations.syncCliTokens, []);
|
||||
});
|
||||
|
||||
test('parseCliPrograms captures sync --ui', () => {
|
||||
const result = parseCliPrograms(['sync', '--ui'], 'subminer');
|
||||
assert.equal(result.invocations.syncUiTriggered, true);
|
||||
assert.equal(result.invocations.syncTriggered, false);
|
||||
|
||||
assert.throws(
|
||||
() => parseCliPrograms(['sync', 'media-box', '--push', '--pull'], 'subminer'),
|
||||
/--push and --pull cannot be combined/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseCliPrograms(['sync', '--snapshot', '/tmp/stats.sqlite', '--push'], 'subminer'),
|
||||
/--push and --pull require a host/,
|
||||
() => parseCliPrograms(['sync', 'media-box', '--ui'], 'subminer'),
|
||||
/--ui cannot be combined/,
|
||||
);
|
||||
});
|
||||
|
||||
test('parseCliPrograms rejects sync --ui with --remote-cmd', () => {
|
||||
assert.throws(
|
||||
() => parseCliPrograms(['sync', '--ui', '--remote-cmd', '/opt/SubMiner.AppImage'], 'subminer'),
|
||||
{ message: 'Sync --ui cannot be combined with other sync options.' },
|
||||
);
|
||||
});
|
||||
|
||||
test('parseCliPrograms rejects sync --ui with --db', () => {
|
||||
assert.throws(() => parseCliPrograms(['sync', '--ui', '--db', '/tmp/db.sqlite'], 'subminer'), {
|
||||
message: 'Sync --ui cannot be combined with other sync options.',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,14 +39,10 @@ export interface CliInvocations {
|
||||
statsCleanupLifetime: boolean;
|
||||
statsLogLevel: string | null;
|
||||
syncTriggered: boolean;
|
||||
syncHost: string | null;
|
||||
syncSnapshotPath: string | null;
|
||||
syncMergePath: string | null;
|
||||
syncDirection: 'both' | 'push' | 'pull';
|
||||
syncRemoteCmd: string | null;
|
||||
syncDbPath: string | null;
|
||||
syncForce: boolean;
|
||||
syncCliTokens: string[];
|
||||
syncLogLevel: string | null;
|
||||
syncUiTriggered: boolean;
|
||||
syncUiLogLevel: string | null;
|
||||
doctorTriggered: boolean;
|
||||
doctorLogLevel: string | null;
|
||||
doctorRefreshKnownWords: boolean;
|
||||
@@ -75,7 +71,11 @@ function applyRootOptions(program: Command): void {
|
||||
.option('-R, --rofi', 'Use rofi picker')
|
||||
.option('-H, --history', 'Browse local watch history')
|
||||
.option('-S, --start-overlay', 'Auto-start overlay')
|
||||
.option('-T, --no-texthooker', 'Disable texthooker-ui server');
|
||||
.option('-T, --no-texthooker', 'Disable texthooker-ui server')
|
||||
// The SubMiner app answers sync commands when invoked with --sync-cli.
|
||||
// Remote-command resolution may address the launcher the same way, so
|
||||
// accept the flag as a no-op to keep both invocation shapes equivalent.
|
||||
.option('--sync-cli', 'Compatibility no-op (sync commands work with or without it)');
|
||||
}
|
||||
|
||||
function buildSubcommandHelpText(program: Command): string {
|
||||
@@ -171,14 +171,10 @@ export function parseCliPrograms(
|
||||
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 syncDirection: 'both' | 'push' | 'pull' = 'both';
|
||||
let syncRemoteCmd: string | null = null;
|
||||
let syncDbPath: string | null = null;
|
||||
let syncForce = false;
|
||||
let syncCliTokens: string[] = [];
|
||||
let syncLogLevel: string | null = null;
|
||||
let syncUiTriggered = false;
|
||||
let syncUiLogLevel: string | null = null;
|
||||
let doctorLogLevel: string | null = null;
|
||||
let doctorRefreshKnownWords = false;
|
||||
let logsTriggered = false;
|
||||
@@ -319,6 +315,11 @@ export function parseCliPrograms(
|
||||
.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('--check', 'Test the SSH connection and remote subminer availability')
|
||||
.option('--json', 'Emit machine-readable NDJSON progress output')
|
||||
.option('--make-temp', 'Create a sync temp directory and print its path (used over SSH)')
|
||||
.option('--remove-temp <dir>', 'Remove a sync temp directory created by --make-temp')
|
||||
.option('--ui', 'Open the SubMiner sync window')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((rawHost: string | undefined, options: Record<string, unknown>) => {
|
||||
const host = typeof rawHost === 'string' ? rawHost.trim() : '';
|
||||
@@ -326,28 +327,49 @@ export function parseCliPrograms(
|
||||
const merge = typeof options.merge === 'string' ? options.merge.trim() : '';
|
||||
const push = options.push === true;
|
||||
const pull = options.pull === true;
|
||||
if (push && pull) {
|
||||
throw new Error('Sync --push and --pull cannot be combined.');
|
||||
}
|
||||
if ((push || pull) && !host) {
|
||||
throw new Error('Sync --push and --pull require a host.');
|
||||
}
|
||||
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.');
|
||||
const check = options.check === true;
|
||||
const makeTemp = options.makeTemp === true;
|
||||
const removeTemp = typeof options.removeTemp === 'string' ? options.removeTemp.trim() : '';
|
||||
if (options.ui === true) {
|
||||
if (
|
||||
host ||
|
||||
snapshot ||
|
||||
merge ||
|
||||
push ||
|
||||
pull ||
|
||||
check ||
|
||||
makeTemp ||
|
||||
removeTemp ||
|
||||
options.remoteCmd !== undefined ||
|
||||
options.db !== undefined ||
|
||||
options.json === true ||
|
||||
options.force === true
|
||||
) {
|
||||
throw new Error('Sync --ui cannot be combined with other sync options.');
|
||||
}
|
||||
syncUiTriggered = true;
|
||||
syncUiLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||
return;
|
||||
}
|
||||
// No validation here: the app's parseSyncCliTokens owns the sync rules
|
||||
// and its error text reaches the terminal through the child's stdio.
|
||||
const remoteCmd = typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() : '';
|
||||
const dbPath = typeof options.db === 'string' ? options.db.trim() : '';
|
||||
const tokens: string[] = [];
|
||||
if (host) tokens.push(host);
|
||||
if (snapshot) tokens.push('--snapshot', snapshot);
|
||||
if (merge) tokens.push('--merge', merge);
|
||||
if (makeTemp) tokens.push('--make-temp');
|
||||
if (removeTemp) tokens.push('--remove-temp', removeTemp);
|
||||
if (push) tokens.push('--push');
|
||||
if (pull) tokens.push('--pull');
|
||||
if (check) tokens.push('--check');
|
||||
if (remoteCmd) tokens.push('--remote-cmd', remoteCmd);
|
||||
if (dbPath) tokens.push('--db', dbPath);
|
||||
if (options.force === true) tokens.push('--force');
|
||||
if (options.json === true) tokens.push('--json');
|
||||
syncTriggered = true;
|
||||
syncHost = host || null;
|
||||
syncSnapshotPath = snapshot || null;
|
||||
syncMergePath = merge || null;
|
||||
syncDirection = push ? 'push' : pull ? 'pull' : 'both';
|
||||
syncRemoteCmd =
|
||||
typeof options.remoteCmd === 'string' ? options.remoteCmd.trim() || null : null;
|
||||
syncDbPath = typeof options.db === 'string' ? options.db.trim() || null : null;
|
||||
syncForce = options.force === true;
|
||||
syncCliTokens = tokens;
|
||||
syncLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||
});
|
||||
|
||||
@@ -463,14 +485,10 @@ export function parseCliPrograms(
|
||||
statsCleanupLifetime,
|
||||
statsLogLevel,
|
||||
syncTriggered,
|
||||
syncHost,
|
||||
syncSnapshotPath,
|
||||
syncMergePath,
|
||||
syncDirection,
|
||||
syncRemoteCmd,
|
||||
syncDbPath,
|
||||
syncForce,
|
||||
syncCliTokens,
|
||||
syncLogLevel,
|
||||
syncUiTriggered,
|
||||
syncUiLogLevel,
|
||||
doctorTriggered,
|
||||
doctorLogLevel,
|
||||
doctorRefreshKnownWords,
|
||||
|
||||
+6
-69
@@ -1,32 +1,12 @@
|
||||
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';
|
||||
import { resolveImmersionDbPath } from '../src/core/services/stats-sync/db-path.js';
|
||||
import {
|
||||
isReadonlyWalRetryError,
|
||||
withReadonlyWalRetry,
|
||||
} from '../src/core/services/stats-sync/wal-retry.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');
|
||||
}
|
||||
export { isReadonlyWalRetryError, resolveImmersionDbPath, withReadonlyWalRetry };
|
||||
|
||||
interface RawHistoryRow {
|
||||
video_id: number;
|
||||
@@ -43,49 +23,6 @@ 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),
|
||||
|
||||
@@ -31,13 +31,8 @@ function createArgs(): Args {
|
||||
useRofi: false,
|
||||
history: false,
|
||||
sync: false,
|
||||
syncHost: '',
|
||||
syncSnapshotPath: '',
|
||||
syncMergePath: '',
|
||||
syncDirection: 'both',
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncCliTokens: [],
|
||||
syncUi: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
|
||||
+33
-7
@@ -119,6 +119,37 @@ test('runAppCommandCaptureOutput transports Linux AppImage args through environm
|
||||
}
|
||||
});
|
||||
|
||||
test('runAppCommandCaptureOutput runs Linux AppImage sync in Node-only mode', () => {
|
||||
const { dir } = createTempSocketPath();
|
||||
const appPath = path.join(dir, 'SubMiner.AppImage');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
'printf "args:%s\\n" "$*"',
|
||||
'printf "electron-node:%s\\n" "$ELECTRON_RUN_AS_NODE"',
|
||||
'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"',
|
||||
'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
try {
|
||||
const result = withPlatform('linux', () =>
|
||||
runAppCommandCaptureOutput(appPath, ['--sync-cli', 'sync', '--snapshot', '/tmp/out']),
|
||||
);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout, /^args:-e /m);
|
||||
assert.match(result.stdout, /^electron-node:1$/m);
|
||||
assert.match(result.stdout, /^argc:4$/m);
|
||||
assert.match(result.stdout, /^arg0:--sync-cli$/m);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('parseMpvArgString preserves empty quoted tokens', () => {
|
||||
assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [
|
||||
'--title',
|
||||
@@ -572,13 +603,8 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
useRofi: false,
|
||||
history: false,
|
||||
sync: false,
|
||||
syncHost: '',
|
||||
syncSnapshotPath: '',
|
||||
syncMergePath: '',
|
||||
syncDirection: 'both',
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncCliTokens: [],
|
||||
syncUi: false,
|
||||
logLevel: 'error',
|
||||
logRotation: 7,
|
||||
passwordStore: '',
|
||||
|
||||
+43
-2
@@ -3,6 +3,7 @@ import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import net from 'node:net';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import type { StdioOptions } from 'node:child_process';
|
||||
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
|
||||
import { buildMpvLoggingArgs } from '../src/shared/mpv-logging-args.js';
|
||||
import {
|
||||
@@ -1276,6 +1277,15 @@ function shouldTransportAppArgsForAppImage(appPath: string): boolean {
|
||||
return process.platform === 'linux' && /\.AppImage$/i.test(appPath);
|
||||
}
|
||||
|
||||
const APPIMAGE_SYNC_NODE_RUNNER = [
|
||||
'const root=process.env.APPDIR+"/resources/app.asar";',
|
||||
'const {runSyncCliFromProcess}=require(root+"/dist/main/sync-cli.js");',
|
||||
'const count=Number(process.env.SUBMINER_APP_ARGC);',
|
||||
'const argv=[process.execPath,...Array.from({length:count},(_,i)=>process.env["SUBMINER_APP_ARG_"+i]??"")];',
|
||||
'runSyncCliFromProcess(argv,require(root+"/package.json").version)',
|
||||
'.then(code=>process.exit(code),error=>{console.error(error);process.exit(1)});',
|
||||
].join('');
|
||||
|
||||
function buildAppEnv(
|
||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||
extraEnv: NodeJS.ProcessEnv = {},
|
||||
@@ -1431,6 +1441,16 @@ function maybeCaptureAppArgs(appArgs: string[]): boolean {
|
||||
|
||||
function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget {
|
||||
if (shouldTransportAppArgsForAppImage(appPath)) {
|
||||
if (appArgs[0] === '--sync-cli') {
|
||||
return {
|
||||
command: appPath,
|
||||
args: ['-e', APPIMAGE_SYNC_NODE_RUNNER],
|
||||
env: {
|
||||
...buildTransportedAppArgsEnv(appArgs),
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: appPath,
|
||||
args: [],
|
||||
@@ -1444,16 +1464,37 @@ function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget
|
||||
}
|
||||
|
||||
export function runAppCommandWithInherit(appPath: string, appArgs: string[]): void {
|
||||
runAppCommand(appPath, appArgs, ['ignore', 'pipe', 'pipe'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like runAppCommandWithInherit, but with the terminal fully attached: the
|
||||
* child owns stdin (ssh password/host-key prompts must reach the user) and
|
||||
* writes stdout/stderr directly (NDJSON --json output must stay unwrapped).
|
||||
* Used for `subminer sync`, which proxies to the app's --sync-cli mode.
|
||||
*/
|
||||
export function runAppCommandInteractive(appPath: string, appArgs: string[]): void {
|
||||
runAppCommand(appPath, appArgs, ['inherit', 'inherit', 'inherit'], false);
|
||||
}
|
||||
|
||||
function runAppCommand(
|
||||
appPath: string,
|
||||
appArgs: string[],
|
||||
stdio: StdioOptions,
|
||||
attachLogging: boolean,
|
||||
): void {
|
||||
if (maybeCaptureAppArgs(appArgs)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const target = resolveAppSpawnTarget(appPath, appArgs);
|
||||
const proc = spawn(target.command, target.args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio,
|
||||
env: buildAppEnv(process.env, target.env),
|
||||
});
|
||||
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
|
||||
if (attachLogging) {
|
||||
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
|
||||
}
|
||||
proc.once('error', (error) => {
|
||||
fail(`Failed to run app command: ${error.message}`);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,11 @@ 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';
|
||||
// The engine executes on libsql in production; these merge tests run through
|
||||
// that same driver. bun:sqlite is used only to build fixtures and inspect
|
||||
// results.
|
||||
import { createDbSnapshot } from '../../src/core/services/stats-sync/shared.js';
|
||||
import { mergeSnapshotIntoDb } from '../../src/core/services/stats-sync/merge.js';
|
||||
import {
|
||||
createImmersionDbFixture,
|
||||
insertFixtureSession,
|
||||
@@ -596,3 +600,61 @@ test('createDbSnapshot produces a mergeable copy', () => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('adopted word frequency excludes active-session counts that merge later', () => {
|
||||
const { dir, localPath, remotePath } = makeDbPair();
|
||||
try {
|
||||
// Remote has an ended session and a stale ACTIVE one (e.g. app crashed).
|
||||
// The remote tracker increments frequency live, so the word's frequency (5)
|
||||
// already includes the active session's 4 occurrences even though that
|
||||
// session's lines are skipped by the merge.
|
||||
insertFixtureSession(remotePath, {
|
||||
uuid: 'remote-ended',
|
||||
videoKey: 'showb-e1',
|
||||
animeTitleKey: 'showb',
|
||||
startedAtMs: BASE_MS,
|
||||
applyLifetime: true,
|
||||
words: [{ headword: '食べる', word: '食べた', reading: 'たべた', count: 1 }],
|
||||
});
|
||||
insertFixtureSession(remotePath, {
|
||||
uuid: 'remote-active',
|
||||
videoKey: 'showb-e2',
|
||||
animeTitleKey: 'showb',
|
||||
startedAtMs: BASE_MS + DAY_MS,
|
||||
endedAtMs: null,
|
||||
words: [{ headword: '食べる', word: '食べた', reading: 'たべた', count: 4 }],
|
||||
});
|
||||
|
||||
const first = mergeSnapshotIntoDb(localPath, remotePath);
|
||||
assert.equal(first.sessionsMerged, 1);
|
||||
assert.equal(first.activeSessionsSkipped, 1);
|
||||
assert.equal(first.wordsAdded, 1);
|
||||
// Only the ended session's count is adopted; the active session's slice
|
||||
// is re-added when that session finalizes and syncs.
|
||||
assert.equal(
|
||||
queryOne<{ frequency: number }>(localPath, `SELECT frequency FROM imm_words WHERE word = '食べた'`)
|
||||
?.frequency,
|
||||
1,
|
||||
);
|
||||
|
||||
// The remote app restarts and finalizes the stale session.
|
||||
withWritableDb(remotePath, (db) => {
|
||||
db.prepare(
|
||||
`UPDATE imm_sessions SET ended_at_ms = ?, status = 2
|
||||
WHERE session_uuid = 'remote-active'`,
|
||||
).run(String(BASE_MS + DAY_MS + 1_500_000));
|
||||
});
|
||||
|
||||
const second = mergeSnapshotIntoDb(localPath, remotePath);
|
||||
assert.equal(second.sessionsMerged, 1);
|
||||
assert.equal(second.sessionsAlreadyPresent, 1);
|
||||
// 1 (ended session) + 4 (finalized session), not 5 + 4 = 9.
|
||||
assert.equal(
|
||||
queryOne<{ frequency: number }>(localPath, `SELECT frequency FROM imm_words WHERE word = '食べた'`)
|
||||
?.frequency,
|
||||
5,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
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>.`,
|
||||
);
|
||||
}
|
||||
+3
-7
@@ -114,13 +114,9 @@ export interface Args {
|
||||
useRofi: boolean;
|
||||
history: boolean;
|
||||
sync: boolean;
|
||||
syncHost: string;
|
||||
syncSnapshotPath: string;
|
||||
syncMergePath: string;
|
||||
syncDirection: 'both' | 'push' | 'pull';
|
||||
syncRemoteCmd: string;
|
||||
syncDbPath: string;
|
||||
syncForce: boolean;
|
||||
/** App-owned sync argv tokens forwarded verbatim to `--sync-cli sync`. */
|
||||
syncCliTokens: string[];
|
||||
syncUi: boolean;
|
||||
logLevel: LogLevel;
|
||||
logRotation: LogRotation;
|
||||
passwordStore: string;
|
||||
|
||||
+16
-9
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.18.0",
|
||||
"version": "0.19.0-beta.1",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -20,9 +20,10 @@
|
||||
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --banner='#!/usr/bin/env bun' --outfile=dist/launcher/subminer",
|
||||
"build:stats": "cd stats && bun run build",
|
||||
"dev:stats": "cd stats && bun run dev",
|
||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:launcher && bun run build:assets",
|
||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets",
|
||||
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
||||
"build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap",
|
||||
"build:syncui": "esbuild src/syncui/syncui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/syncui/syncui.js --sourcemap && esbuild src/preload-syncui.ts --bundle --platform=node --format=cjs --target=node20 --external:electron --outfile=dist/preload-syncui.js --sourcemap",
|
||||
"changelog:build": "bun run scripts/build-changelog.ts build-release",
|
||||
"changelog:check": "bun run scripts/build-changelog.ts check",
|
||||
"changelog:docs": "bun run scripts/build-changelog.ts docs",
|
||||
@@ -86,13 +87,15 @@
|
||||
"build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs"
|
||||
},
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "0.8.12",
|
||||
"@xmldom/xmldom": "0.8.13",
|
||||
"app-builder-lib": "26.8.2",
|
||||
"electron-builder-squirrel-windows": "26.8.2",
|
||||
"form-data": "4.0.6",
|
||||
"lodash": "4.18.0",
|
||||
"minimatch": "10.2.3",
|
||||
"picomatch": "4.0.4",
|
||||
"tar": "7.5.11"
|
||||
"tar": "7.5.16",
|
||||
"tmp": "0.2.7"
|
||||
},
|
||||
"keywords": [
|
||||
"anki",
|
||||
@@ -109,21 +112,22 @@
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/geist-mono": "^5.2.7",
|
||||
"@xhayper/discord-rpc": "^1.3.3",
|
||||
"axios": "^1.13.5",
|
||||
"@xhayper/discord-rpc": "^1.3.4",
|
||||
"axios": "^1.18.1",
|
||||
"commander": "^14.0.3",
|
||||
"electron-updater": "^6.8.3",
|
||||
"hono": "^4.12.7",
|
||||
"hono": "^4.12.28",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"koffi": "^2.15.6",
|
||||
"libsql": "^0.5.22",
|
||||
"ws": "^8.19.0"
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"electron": "42.2.0",
|
||||
"electron": "42.6.0",
|
||||
"electron-builder": "26.8.2",
|
||||
"undici": "7.28.0",
|
||||
"esbuild": "^0.25.12",
|
||||
"eslint": "^10.4.0",
|
||||
"prettier": "^3.8.1",
|
||||
@@ -258,5 +262,8 @@
|
||||
"to": "launcher/subminer"
|
||||
}
|
||||
]
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@discordjs/rest@2.6.1": "patches/@discordjs%2Frest@2.6.1.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
diff --git a/package.json b/package.json
|
||||
index 02d2d8809a98c5d32b889fceba11458660f1fa6a..72cd7004d92809689daaeec1cd8a274e1cabdff5 100644
|
||||
--- a/package.json
|
||||
+++ b/package.json
|
||||
@@ -77,7 +77,7 @@
|
||||
"discord-api-types": "^0.38.40",
|
||||
"magic-bytes.js": "^1.13.0",
|
||||
"tslib": "^2.6.3",
|
||||
- "undici": "6.24.1",
|
||||
+ "undici": "6.27.0",
|
||||
"@discordjs/collection": "^2.1.1",
|
||||
"@discordjs/util": "^1.2.0"
|
||||
},
|
||||
@@ -254,8 +254,8 @@ function M.create(ctx)
|
||||
return { "--open-runtime-options" }
|
||||
elseif action_id == "openJimaku" then
|
||||
return { "--open-jimaku" }
|
||||
elseif action_id == "openAnimetosho" then
|
||||
return { "--open-animetosho" }
|
||||
elseif action_id == "openTsukihime" or action_id == "openAnimetosho" then
|
||||
return { "--open-tsukihime" }
|
||||
elseif action_id == "openYoutubePicker" then
|
||||
return { "--open-youtube-picker" }
|
||||
elseif action_id == "openSessionHelp" then
|
||||
|
||||
+19
-58
@@ -1,73 +1,34 @@
|
||||
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
||||
|
||||
<!-- prerelease-base-version: 0.18.0 -->
|
||||
<!-- prerelease-base-version: 0.19.0 -->
|
||||
|
||||
## Highlights
|
||||
### Added
|
||||
- **Watch History Browser**
|
||||
- New `subminer -H` / `--history` command lets you browse your local watch history, replay the last episode, jump to the next one, or pick an episode via fzf or rofi.
|
||||
- The rofi picker now shows AniList cover art for each show, making it easier to spot the right title at a glance.
|
||||
- **Card Audio Normalization**
|
||||
- Audio extracted for Anki cards is now volume-normalized by default, giving more consistent playback loudness across cards.
|
||||
- Prefer the original source volume? Disable it via the new `ankiConnect.media.normalizeAudio` setting.
|
||||
|
||||
- **Sync Stats & History**
|
||||
- Keep mining stats and watch history in sync across machines over SSH, from a new **Sync Stats & History** window (tray menu) or the `subminer sync <host>` command.
|
||||
- Syncing merges data safely, so nothing is duplicated even if you sync the same machines repeatedly, and hosts with auto-sync enabled sync in the background on a schedule, reporting results as overlay notifications.
|
||||
- Manual database snapshots (create, merge, reveal, delete) cover one-off transfers, and Windows machines running the built-in OpenSSH Server can be used as sync remotes too. No setup beyond SSH access is required on the remote side.
|
||||
|
||||
- **TsukiHime Subtitle Downloads**
|
||||
- Download Japanese and secondary-language subtitles for the current video directly from TsukiHime, mirroring the existing Jimaku flow: `Ctrl+Shift+T` opens an in-overlay search modal with separate tabs for the primary and secondary languages.
|
||||
- Matching releases are found automatically from the video filename; the chosen subtitle downloads and loads straight into mpv, no API key required.
|
||||
|
||||
### Changed
|
||||
- **New App Icon**
|
||||
- SubMiner now ships pixel-art submarine artwork contributed by an anonymous community member.
|
||||
- Applied across the app icon, tray icon, notifications, README, docs site, and stats page.
|
||||
- **Launcher Preview Layout**
|
||||
- fzf previews in the launcher now sit below the menu instead of beside it, giving long titles and metadata more horizontal room.
|
||||
|
||||
- **Clipboard-Video Shortcut**: The "append clipboard video to queue" shortcut is now configurable (`shortcuts.appendClipboardVideoToQueue`) instead of fixed.
|
||||
|
||||
### Fixed
|
||||
- **Character Name Highlighting in Subtitles**
|
||||
- Fixed unspaced Japanese names (e.g. 東紫乃, 渡辺真奈美) being split at the wrong point, which left surnames like 東 and 渡辺 without their character portrait or hover lookup.
|
||||
- Fixed names getting cut off or losing their highlight when caught by the subtitle scanner's punctuation handling, misclassified by grammar tagging, or swallowed entirely by a longer generic dictionary match (e.g. ヨータ disappearing inside a false とヨー match).
|
||||
- Fixed a single unrecognized word in a subtitle line (like a stray interjection) causing character-name highlighting to drop for the whole line instead of just that word.
|
||||
- No action needed — existing data upgrades automatically the next time a matching name is seen.
|
||||
- **Known-Word Highlighting**
|
||||
- Words are no longer marked "known" (green) just because they share spelling with a known Anki card that actually teaches a different reading (e.g. 床 read as とこ no longer falsely matches a known 床/ゆか card).
|
||||
- Kanji words are also no longer marked known just because a different mined word happens to share their reading (e.g. 渓谷/けいこく no longer falsely matches a known 警告/けいこく card).
|
||||
- Single-kana grammar tokens (particles like よ, え) no longer borrow an unrelated card's reading and get falsely painted as known.
|
||||
- Stats sessions now correctly reflect known-word counts again after the reading-aware matching upgrade, instead of showing 0 everywhere.
|
||||
- **Annotation Highlighting Refinements**
|
||||
- Restored frequency/JLPT highlighting and vocabulary-stat counting for words like 確かに and やはり, which were wrongly treated as grammar noise.
|
||||
- Kanji nouns that MeCab tags as "non-independent" (e.g. 日, 点, 以外) also keep their highlighting and stats counting again.
|
||||
- Suffix-only tokens (e.g. さん, れる) are now excluded from JLPT/frequency highlighting by default to match how particles and interjections are treated; known-word highlighting for them still works, and this is configurable.
|
||||
- **Unparsed Subtitle Text**
|
||||
- Subtitle text the dictionary can't recognize (like a truncated verb form) is now still hoverable for lookup and correctly counted toward a sentence's difficulty, instead of showing as dead, non-interactive text.
|
||||
- **Kiku Manual Field Grouping**
|
||||
- Fixed the field-grouping dialog getting stuck invisible behind fullscreen video on Hyprland/Wayland, and failing silently on repeated attempts after the first use.
|
||||
- Fixed a timed-out or failed grouping request leaving an invisible, stuck dialog covering the video; it now closes automatically so the overlay recovers.
|
||||
- Fixed a duplicate "Field grouping cancelled" notification appearing when grouping was cancelled via the trigger shortcut, and added a proper error message for the previously-silent case where the original card can no longer be loaded.
|
||||
- **Secondary Subtitles**
|
||||
- Karaoke-style secondary subtitles (common in opening/ending songs) no longer spam dozens of lines down the screen; repeated lines are now collapsed and the subtitle area is capped to a strip at the top.
|
||||
- **YouTube Extraction**
|
||||
- Fixed direct YouTube stream extraction occasionally corrupting the stream URL and causing failed audio/video capture.
|
||||
- **Background Stats Server**
|
||||
- Launching SubMiner in the background now correctly auto-starts the stats server when enabled, and won't start a duplicate if one's already running.
|
||||
- **Stats Trend Charts**
|
||||
- All trend chart titles now show by default, with the ability to hide specific titles (remembered across sessions) and cap how many top titles a chart displays.
|
||||
- **Stats Cover Art**
|
||||
- Cover art now loads as soon as a series starts playing instead of waiting for your first visit to its detail page, so the stats timeline shows artwork right away.
|
||||
- Existing series missing art are backfilled automatically the next time you open the stats page.
|
||||
|
||||
- **Word Highlighting Accuracy**: Fixed several cases of incorrect word highlighting and annotations, including inconsistent part-of-speech exclusions on merged quote-particle tokens, missing annotations for rare kanji, katakana punctuation wrongly treated as non-kana noise, and certain kanji vocabulary being skipped for next-level ("N+1") highlighting.
|
||||
- **Startup Playback Pausing Too Early**: Fixed playback resuming before subtitle processing had finished warming up, which could briefly show untranslated subtitles right after opening a video, most noticeable when resuming mid-episode.
|
||||
- **Linux AppImage Crash Notification on Quit**: Fixed a spurious "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(youtube): parse mpv EDL stream URLs with byte-length guards by @ksyasuda in #134
|
||||
- Normalize generated Anki audio by default by @ksyasuda in #135
|
||||
- feat(launcher): add -H/--history command to browse local watch history by @ksyasuda in #136
|
||||
- fix(overlay): prevent field grouping modal from freezing overlay on Hyprland by @ksyasuda in #138
|
||||
- fix(overlay): collapse karaoke syllable spam in secondary subtitles by @ksyasuda in #139
|
||||
- feat(stats): Trends dashboard overhaul — title visibility, ranking modes, calendar-accurate windows, tooltips by @ksyasuda in #140
|
||||
- feat(branding): replace app icon with contributed pixel-art set by @ksyasuda in #141
|
||||
- feat(anki): reading-aware known-word matching (cache v3) by @ksyasuda in #142
|
||||
- fix(stats): start stats server on background app launch by @ksyasuda in #144
|
||||
- fix(tokenizer): keep unparsed Yomitan tokens hoverable by @ksyasuda in #145
|
||||
- fix(overlay): resolve unspaced Japanese name splits and scan recovery by @ksyasuda in #146
|
||||
- fix(tokenizer): prevent grammar tokens from borrowing known-word highlight via unrelated readings by @ksyasuda in #147
|
||||
- fix(stats): fetch cover art eagerly at session start instead of on series page visit by @ksyasuda in #148
|
||||
- fix(overlay): keep frequency/JLPT highlight for kanji non-independent nouns by @ksyasuda in #150
|
||||
- fix(tokenizer): greedy name pre-pass to prevent generic matches swallowing character names by @ksyasuda in #151
|
||||
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
|
||||
- refactor(tokenizer): extract subtitle annotation filter into rule table by @ksyasuda in #162
|
||||
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
test('build:syncui bundles the sandboxed preload and keeps Electron external', () => {
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(import.meta.dir, '..', 'package.json'), 'utf8'),
|
||||
) as { scripts: Record<string, string> };
|
||||
const command = packageJson.scripts['build:syncui'] ?? '';
|
||||
|
||||
assert.match(command, /src\/preload-syncui\.ts/);
|
||||
assert.match(command, /--bundle/);
|
||||
assert.match(command, /--external:electron/);
|
||||
assert.match(command, /--outfile=dist\/preload-syncui\.js/);
|
||||
});
|
||||
@@ -9,6 +9,8 @@ const rendererSourceDir = path.join(repoRoot, 'src', 'renderer');
|
||||
const rendererOutputDir = path.join(repoRoot, 'dist', 'renderer');
|
||||
const settingsSourceDir = path.join(repoRoot, 'src', 'settings');
|
||||
const settingsOutputDir = path.join(repoRoot, 'dist', 'settings');
|
||||
const syncUiSourceDir = path.join(repoRoot, 'src', 'syncui');
|
||||
const syncUiOutputDir = path.join(repoRoot, 'dist', 'syncui');
|
||||
const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts');
|
||||
const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift');
|
||||
const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos');
|
||||
@@ -41,6 +43,10 @@ function copySettingsAssets() {
|
||||
copyAssets(settingsSourceDir, settingsOutputDir, 'settings');
|
||||
}
|
||||
|
||||
function copySyncUiAssets() {
|
||||
copyAssets(syncUiSourceDir, syncUiOutputDir, 'syncui');
|
||||
}
|
||||
|
||||
function fallbackToMacosSource() {
|
||||
copyFile(macosHelperSourcePath, macosHelperSourceCopyPath);
|
||||
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
|
||||
@@ -83,6 +89,7 @@ function buildMacosHelper() {
|
||||
function main() {
|
||||
copyRendererAssets();
|
||||
copySettingsAssets();
|
||||
copySyncUiAssets();
|
||||
buildMacosHelper();
|
||||
}
|
||||
|
||||
|
||||
@@ -237,6 +237,14 @@ local ctx = {
|
||||
actionType = "session-action",
|
||||
actionId = "openPlaylistBrowser",
|
||||
},
|
||||
{
|
||||
key = {
|
||||
code = "KeyT",
|
||||
modifiers = { "ctrl", "alt" },
|
||||
},
|
||||
actionType = "session-action",
|
||||
actionId = "openAnimetosho",
|
||||
},
|
||||
{
|
||||
key = {
|
||||
code = "KeyH",
|
||||
@@ -387,6 +395,7 @@ end
|
||||
local expected_cli_bindings = {
|
||||
{ keys = "Ctrl+Alt+c", flag = "--open-youtube-picker" },
|
||||
{ keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" },
|
||||
{ keys = "Ctrl+Alt+t", flag = "--open-tsukihime" },
|
||||
{ keys = "Ctrl+H", flag = "--replay-current-subtitle" },
|
||||
{ keys = "Ctrl+L", flag = "--play-next-subtitle" },
|
||||
{ keys = "w", flag = "--mark-watched" },
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
animetoshoLangToFilenameSuffix,
|
||||
animetoshoTrackMatchesLanguages,
|
||||
describeAnimetoshoTabLanguages,
|
||||
normalizeAnimetoshoLangCode,
|
||||
} from './lang.js';
|
||||
|
||||
test('normalizeAnimetoshoLangCode collapses 2/3-letter and region variants', () => {
|
||||
assert.equal(normalizeAnimetoshoLangCode('eng'), 'en');
|
||||
assert.equal(normalizeAnimetoshoLangCode('en'), 'en');
|
||||
assert.equal(normalizeAnimetoshoLangCode('en-US'), 'en');
|
||||
assert.equal(normalizeAnimetoshoLangCode('GER'), 'de');
|
||||
assert.equal(normalizeAnimetoshoLangCode('jpn'), 'ja');
|
||||
assert.equal(normalizeAnimetoshoLangCode('vie'), 'vie');
|
||||
assert.equal(normalizeAnimetoshoLangCode(''), '');
|
||||
});
|
||||
|
||||
test('animetoshoTrackMatchesLanguages matches across code forms', () => {
|
||||
assert.equal(animetoshoTrackMatchesLanguages('eng', ['en']), true);
|
||||
assert.equal(animetoshoTrackMatchesLanguages('eng', ['en', 'eng']), true);
|
||||
assert.equal(animetoshoTrackMatchesLanguages('ger', ['de']), true);
|
||||
assert.equal(animetoshoTrackMatchesLanguages('por', ['en']), false);
|
||||
assert.equal(animetoshoTrackMatchesLanguages('spa', ['en', 'de']), false);
|
||||
});
|
||||
|
||||
test('animetoshoTrackMatchesLanguages keeps unknown-language tracks visible', () => {
|
||||
assert.equal(animetoshoTrackMatchesLanguages('', ['en']), true);
|
||||
assert.equal(animetoshoTrackMatchesLanguages('und', ['en']), true);
|
||||
});
|
||||
|
||||
test('describeAnimetoshoTabLanguages names common languages and dedupes', () => {
|
||||
assert.equal(describeAnimetoshoTabLanguages(['en', 'eng']), 'English');
|
||||
assert.equal(describeAnimetoshoTabLanguages(['de']), 'German');
|
||||
assert.equal(describeAnimetoshoTabLanguages(['en', 'de']), 'English / German');
|
||||
assert.equal(describeAnimetoshoTabLanguages(['vie']), 'VIE');
|
||||
assert.equal(describeAnimetoshoTabLanguages([]), 'English');
|
||||
});
|
||||
|
||||
test('animetoshoLangToFilenameSuffix is re-exported from the pure module', () => {
|
||||
assert.equal(animetoshoLangToFilenameSuffix('jpn'), 'ja');
|
||||
assert.equal(animetoshoLangToFilenameSuffix('eng'), 'en');
|
||||
});
|
||||
@@ -1,233 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
import {
|
||||
animetoshoLangToFilenameSuffix,
|
||||
buildAnimetoshoAttachmentUrl,
|
||||
decompressXzFile,
|
||||
extractAnimetoshoSubtitleFiles,
|
||||
mapAnimetoshoSearchResults,
|
||||
} from './utils.js';
|
||||
|
||||
test('buildAnimetoshoAttachmentUrl pads the attachment id to 8 hex digits', () => {
|
||||
assert.equal(
|
||||
buildAnimetoshoAttachmentUrl(1955356),
|
||||
'https://animetosho.org/storage/attach/001dd61c/1955356.xz',
|
||||
);
|
||||
});
|
||||
|
||||
test('animetoshoLangToFilenameSuffix maps common ISO 639-2 codes to two-letter suffixes', () => {
|
||||
assert.equal(animetoshoLangToFilenameSuffix('eng'), 'en');
|
||||
assert.equal(animetoshoLangToFilenameSuffix('jpn'), 'ja');
|
||||
assert.equal(animetoshoLangToFilenameSuffix('ger'), 'de');
|
||||
assert.equal(animetoshoLangToFilenameSuffix('spa'), 'es');
|
||||
assert.equal(animetoshoLangToFilenameSuffix('POR'), 'pt');
|
||||
});
|
||||
|
||||
test('animetoshoLangToFilenameSuffix falls back to the raw code, and to en when unknown', () => {
|
||||
assert.equal(animetoshoLangToFilenameSuffix('vie'), 'vie');
|
||||
assert.equal(animetoshoLangToFilenameSuffix(''), 'en');
|
||||
assert.equal(animetoshoLangToFilenameSuffix(undefined), 'en');
|
||||
assert.equal(animetoshoLangToFilenameSuffix('und'), 'en');
|
||||
});
|
||||
|
||||
test('buildAnimetoshoAttachmentUrl rejects non-positive and non-integer ids', () => {
|
||||
assert.equal(buildAnimetoshoAttachmentUrl(0), null);
|
||||
assert.equal(buildAnimetoshoAttachmentUrl(-5), null);
|
||||
assert.equal(buildAnimetoshoAttachmentUrl(1.5), null);
|
||||
assert.equal(buildAnimetoshoAttachmentUrl(Number.NaN), null);
|
||||
});
|
||||
|
||||
test('mapAnimetoshoSearchResults maps valid entries and caps to maxResults', () => {
|
||||
const payload = [
|
||||
{
|
||||
id: 606713,
|
||||
title: '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv',
|
||||
timestamp: 1710000000,
|
||||
total_size: 1490354395,
|
||||
num_files: 1,
|
||||
},
|
||||
{ id: 'bogus', title: 'missing numeric id' },
|
||||
{ id: 606714, title: '[Erai-raws] Sousou no Frieren - 28 [1080p].mkv' },
|
||||
{ id: 606715, title: 'capped away' },
|
||||
];
|
||||
|
||||
const entries = mapAnimetoshoSearchResults(payload, 2);
|
||||
assert.equal(entries.length, 2);
|
||||
assert.deepEqual(entries[0], {
|
||||
id: 606713,
|
||||
title: '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv',
|
||||
timestamp: 1710000000,
|
||||
totalSize: 1490354395,
|
||||
numFiles: 1,
|
||||
});
|
||||
assert.equal(entries[1]!.id, 606714);
|
||||
assert.equal(entries[1]!.totalSize, null);
|
||||
assert.equal(entries[1]!.numFiles, null);
|
||||
});
|
||||
|
||||
test('mapAnimetoshoSearchResults returns empty list for non-array payloads', () => {
|
||||
assert.deepEqual(mapAnimetoshoSearchResults({ error: 'nope' }, 10), []);
|
||||
assert.deepEqual(mapAnimetoshoSearchResults(null, 10), []);
|
||||
});
|
||||
|
||||
const DETAIL_PAYLOAD = {
|
||||
id: 606713,
|
||||
title: '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv',
|
||||
files: [
|
||||
{
|
||||
id: 1151711,
|
||||
filename: '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv',
|
||||
attachments: [
|
||||
{
|
||||
id: 1955355,
|
||||
type: 'font',
|
||||
info: { name: 'arial.ttf' },
|
||||
size: 300000,
|
||||
},
|
||||
{
|
||||
id: 1955356,
|
||||
type: 'subtitle',
|
||||
info: { codec: 'ASS', lang: 'eng', name: 'English subs', trackid: 2 },
|
||||
size: 33075,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test('extractAnimetoshoSubtitleFiles keeps only text subtitle attachments with download urls', () => {
|
||||
const files = extractAnimetoshoSubtitleFiles(DETAIL_PAYLOAD);
|
||||
assert.equal(files.length, 1);
|
||||
const file = files[0]!;
|
||||
assert.equal(file.attachmentId, 1955356);
|
||||
assert.equal(file.lang, 'eng');
|
||||
assert.equal(file.trackName, 'English subs');
|
||||
assert.equal(file.size, 33075);
|
||||
assert.equal(file.url, 'https://animetosho.org/storage/attach/001dd61c/1955356.xz');
|
||||
assert.equal(file.sourceFilename, '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv');
|
||||
assert.equal(file.filename, '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].eng.ass');
|
||||
});
|
||||
|
||||
test('extractAnimetoshoSubtitleFiles skips image-based subtitle codecs', () => {
|
||||
const files = extractAnimetoshoSubtitleFiles({
|
||||
files: [
|
||||
{
|
||||
id: 1,
|
||||
filename: 'movie.mkv',
|
||||
attachments: [
|
||||
{ id: 10, type: 'subtitle', info: { codec: 'PGS', lang: 'eng' }, size: 100 },
|
||||
{ id: 11, type: 'subtitle', info: { codec: 'VobSub', lang: 'eng' }, size: 100 },
|
||||
{ id: 12, type: 'subtitle', info: { codec: 'SRT', lang: 'eng' }, size: 100 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
files.map((f) => f.attachmentId),
|
||||
[12],
|
||||
);
|
||||
assert.equal(files[0]!.filename, 'movie.eng.srt');
|
||||
});
|
||||
|
||||
test('extractAnimetoshoSubtitleFiles sorts English tracks first and disambiguates duplicates', () => {
|
||||
const files = extractAnimetoshoSubtitleFiles({
|
||||
files: [
|
||||
{
|
||||
id: 1,
|
||||
filename: 'episode.mkv',
|
||||
attachments: [
|
||||
{
|
||||
id: 21,
|
||||
type: 'subtitle',
|
||||
info: { codec: 'ASS', lang: 'ger', name: 'Deutsch' },
|
||||
size: 1,
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
type: 'subtitle',
|
||||
info: { codec: 'ASS', lang: 'eng', name: 'Signs & Songs' },
|
||||
size: 2,
|
||||
},
|
||||
{
|
||||
id: 23,
|
||||
type: 'subtitle',
|
||||
info: { codec: 'ASS', lang: 'eng', name: 'Full Subtitles' },
|
||||
size: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
files.map((f) => f.attachmentId),
|
||||
[22, 23, 21],
|
||||
);
|
||||
assert.equal(files[0]!.filename, 'episode.eng.signs-songs.ass');
|
||||
assert.equal(files[1]!.filename, 'episode.eng.full-subtitles.ass');
|
||||
assert.equal(files[2]!.filename, 'episode.ger.ass');
|
||||
});
|
||||
|
||||
test('extractAnimetoshoSubtitleFiles tolerates missing info fields', () => {
|
||||
const files = extractAnimetoshoSubtitleFiles({
|
||||
files: [
|
||||
{
|
||||
id: 1,
|
||||
attachments: [{ id: 31, type: 'subtitle', info: { codec: 'ASS' }, size: 5 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(files.length, 1);
|
||||
assert.equal(files[0]!.lang, '');
|
||||
assert.equal(files[0]!.trackName, null);
|
||||
assert.equal(files[0]!.filename, 'subtitle.ass');
|
||||
});
|
||||
|
||||
const hasXz = (() => {
|
||||
try {
|
||||
execFileSync('xz', ['--version'], { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
test('decompressXzFile round-trips an xz-compressed subtitle', { skip: !hasXz }, async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-animetosho-test-'));
|
||||
try {
|
||||
const plainPath = path.join(dir, 'sub.ass');
|
||||
const content = '[Script Info]\nTitle: test\n';
|
||||
fs.writeFileSync(plainPath, content, 'utf8');
|
||||
execFileSync('xz', ['-z', plainPath]);
|
||||
|
||||
const destPath = path.join(dir, 'out.ass');
|
||||
const result = await decompressXzFile(`${plainPath}.xz`, destPath);
|
||||
assert.equal(result.ok, true);
|
||||
if (result.ok) {
|
||||
assert.equal(result.path, destPath);
|
||||
}
|
||||
assert.equal(fs.readFileSync(destPath, 'utf8'), content);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('decompressXzFile reports an error for corrupt input', { skip: !hasXz }, async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-animetosho-test-'));
|
||||
try {
|
||||
const srcPath = path.join(dir, 'broken.xz');
|
||||
fs.writeFileSync(srcPath, 'not xz data');
|
||||
const result = await decompressXzFile(srcPath, path.join(dir, 'out.ass'));
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) {
|
||||
assert.match(result.error.error, /xz|decompress/i);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+8
-18
@@ -12,19 +12,6 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
|
||||
test('ci workflow lints changelog fragments', () => {
|
||||
assert.match(ciWorkflow, /bun run changelog:lint/);
|
||||
});
|
||||
|
||||
test('ci workflow checks pull requests for required changelog fragments', () => {
|
||||
assert.match(ciWorkflow, /bun run changelog:pr-check/);
|
||||
assert.match(ciWorkflow, /skip-changelog/);
|
||||
});
|
||||
|
||||
test('ci workflow verifies generated config examples stay in sync', () => {
|
||||
assert.match(ciWorkflow, /bun run verify:config-example/);
|
||||
});
|
||||
|
||||
test('package scripts expose a sharded maintained source coverage lane with lcov output', () => {
|
||||
assert.equal(
|
||||
packageJson.scripts['test:coverage:src'],
|
||||
@@ -32,11 +19,14 @@ test('package scripts expose a sharded maintained source coverage lane with lcov
|
||||
);
|
||||
});
|
||||
|
||||
test('ci workflow runs the maintained source coverage lane and uploads lcov output', () => {
|
||||
assert.match(ciWorkflow, /name: Coverage suite \(maintained source lane\)/);
|
||||
assert.match(ciWorkflow, /run: bun run test:coverage:src/);
|
||||
assert.match(ciWorkflow, /name: Upload coverage artifact/);
|
||||
assert.match(ciWorkflow, /path: coverage\/test-src\/lcov\.info/);
|
||||
test('ci delegates its gate instead of duplicating quality steps', () => {
|
||||
assert.match(
|
||||
ciWorkflow,
|
||||
/build-test-audit:\s*\n\s*uses: \.\/\.github\/workflows\/quality-gate\.yml/,
|
||||
);
|
||||
assert.doesNotMatch(ciWorkflow, /oven-sh\/setup-bun/);
|
||||
assert.doesNotMatch(ciWorkflow, /bun run test:coverage:src/);
|
||||
assert.doesNotMatch(ciWorkflow, /bun run test:env/);
|
||||
});
|
||||
|
||||
test('main docs deploy exists, serializes deploys, and uses Cloudflare credentials', () => {
|
||||
|
||||
+10
-2
@@ -115,7 +115,7 @@ test('parseArgs captures session action forwarding flags', () => {
|
||||
'--toggle-stats-overlay',
|
||||
'--mark-watched',
|
||||
'--open-jimaku',
|
||||
'--open-animetosho',
|
||||
'--open-tsukihime',
|
||||
'--open-youtube-picker',
|
||||
'--open-playlist-browser',
|
||||
'--toggle-primary-subtitle-bar',
|
||||
@@ -133,7 +133,7 @@ test('parseArgs captures session action forwarding flags', () => {
|
||||
assert.equal(args.toggleStatsOverlay, true);
|
||||
assert.equal(args.markWatched, true);
|
||||
assert.equal(args.openJimaku, true);
|
||||
assert.equal(args.openAnimetosho, true);
|
||||
assert.equal(args.openTsukihime, true);
|
||||
assert.equal(args.openYoutubePicker, true);
|
||||
assert.equal(args.openPlaylistBrowser, true);
|
||||
assert.equal(args.togglePrimarySubtitleBar, true);
|
||||
@@ -148,6 +148,14 @@ test('parseArgs captures session action forwarding flags', () => {
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
});
|
||||
|
||||
test('parseArgs keeps the legacy Animetosho open flag as a TsukiHime alias', () => {
|
||||
const args = parseArgs(['--open-animetosho']);
|
||||
|
||||
assert.equal(args.openTsukihime, true);
|
||||
assert.equal(hasExplicitCommand(args), true);
|
||||
assert.equal(shouldStartApp(args), true);
|
||||
});
|
||||
|
||||
test('parseArgs ignores retired subtitle delay shift flags', () => {
|
||||
const args = parseArgs(['--shift-sub-delay-prev-line', '--shift-sub-delay-next-line']);
|
||||
|
||||
|
||||
+17
-9
@@ -14,6 +14,7 @@ export interface CliArgs {
|
||||
togglePrimarySubtitleBar: boolean;
|
||||
yomitan: boolean;
|
||||
settings: boolean;
|
||||
syncWindow: boolean;
|
||||
setup: boolean;
|
||||
show: boolean;
|
||||
hide: boolean;
|
||||
@@ -37,7 +38,7 @@ export interface CliArgs {
|
||||
openControllerSelect: boolean;
|
||||
openControllerDebug: boolean;
|
||||
openJimaku: boolean;
|
||||
openAnimetosho: boolean;
|
||||
openTsukihime: boolean;
|
||||
openYoutubePicker: boolean;
|
||||
openPlaylistBrowser: boolean;
|
||||
replayCurrentSubtitle: boolean;
|
||||
@@ -123,6 +124,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -146,7 +148,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
openControllerSelect: false,
|
||||
openControllerDebug: false,
|
||||
openJimaku: false,
|
||||
openAnimetosho: false,
|
||||
openTsukihime: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
@@ -271,6 +273,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
|
||||
else if (arg === '--yomitan') args.yomitan = true;
|
||||
else if (arg === '--settings') args.settings = true;
|
||||
else if (arg === '--sync-window') args.syncWindow = true;
|
||||
else if (arg === '--setup') args.setup = true;
|
||||
else if (arg === '--show') args.show = true;
|
||||
else if (arg === '--hide') args.hide = true;
|
||||
@@ -294,8 +297,9 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--open-controller-select') args.openControllerSelect = true;
|
||||
else if (arg === '--open-controller-debug') args.openControllerDebug = true;
|
||||
else if (arg === '--open-jimaku') args.openJimaku = true;
|
||||
else if (arg === '--open-animetosho') args.openAnimetosho = true;
|
||||
else if (arg === '--open-youtube-picker') args.openYoutubePicker = true;
|
||||
else if (arg === '--open-tsukihime' || arg === '--open-animetosho') {
|
||||
args.openTsukihime = true;
|
||||
} else if (arg === '--open-youtube-picker') args.openYoutubePicker = true;
|
||||
else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true;
|
||||
else if (arg === '--replay-current-subtitle') args.replayCurrentSubtitle = true;
|
||||
else if (arg === '--play-next-subtitle') args.playNextSubtitle = true;
|
||||
@@ -544,6 +548,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.yomitan ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.setup ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
@@ -567,7 +572,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.openControllerSelect ||
|
||||
args.openControllerDebug ||
|
||||
args.openJimaku ||
|
||||
args.openAnimetosho ||
|
||||
args.openTsukihime ||
|
||||
args.openYoutubePicker ||
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
@@ -622,6 +627,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.yomitan &&
|
||||
!args.settings &&
|
||||
!args.syncWindow &&
|
||||
!args.setup &&
|
||||
!args.show &&
|
||||
!args.hide &&
|
||||
@@ -645,7 +651,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.openControllerSelect &&
|
||||
!args.openControllerDebug &&
|
||||
!args.openJimaku &&
|
||||
!args.openAnimetosho &&
|
||||
!args.openTsukihime &&
|
||||
!args.openYoutubePicker &&
|
||||
!args.openPlaylistBrowser &&
|
||||
!args.replayCurrentSubtitle &&
|
||||
@@ -693,6 +699,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.yomitan ||
|
||||
args.settings ||
|
||||
args.syncWindow ||
|
||||
args.setup ||
|
||||
args.copySubtitle ||
|
||||
args.copySubtitleMultiple ||
|
||||
@@ -712,7 +719,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.openControllerSelect ||
|
||||
args.openControllerDebug ||
|
||||
args.openJimaku ||
|
||||
args.openAnimetosho ||
|
||||
args.openTsukihime ||
|
||||
args.openYoutubePicker ||
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
@@ -750,6 +757,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
||||
!args.toggleVisibleOverlay &&
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.settings &&
|
||||
!args.syncWindow &&
|
||||
!args.show &&
|
||||
!args.hide &&
|
||||
!args.setup &&
|
||||
@@ -773,7 +781,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
||||
!args.openControllerSelect &&
|
||||
!args.openControllerDebug &&
|
||||
!args.openJimaku &&
|
||||
!args.openAnimetosho &&
|
||||
!args.openTsukihime &&
|
||||
!args.openYoutubePicker &&
|
||||
!args.openPlaylistBrowser &&
|
||||
!args.replayCurrentSubtitle &&
|
||||
@@ -839,7 +847,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
||||
args.openControllerSelect ||
|
||||
args.openControllerDebug ||
|
||||
args.openJimaku ||
|
||||
args.openAnimetosho ||
|
||||
args.openTsukihime ||
|
||||
args.openYoutubePicker ||
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
|
||||
@@ -79,6 +79,11 @@ ${B}Jellyfin${R}
|
||||
--jellyfin-audio-stream-index ${D}N${R} Audio stream override
|
||||
--jellyfin-subtitle-stream-index ${D}N${R} Subtitle stream override
|
||||
|
||||
${B}Stats sync${R}
|
||||
--sync-window Open the stats sync window
|
||||
--sync-cli sync ${D}[host] [opts]${R} Headless stats sync ${D}(same commands as "subminer sync";${R}
|
||||
${D}run SubMiner --sync-cli --help for details)${R}
|
||||
|
||||
${B}Options${R}
|
||||
--socket ${D}PATH${R} mpv IPC socket path
|
||||
--backend ${D}BACKEND${R} Window tracker ${D}(auto, hyprland, sway, x11, macos, windows)${R}
|
||||
|
||||
@@ -40,7 +40,7 @@ const {
|
||||
const {
|
||||
ankiConnect,
|
||||
jimaku,
|
||||
animetosho,
|
||||
tsukihime,
|
||||
anilist,
|
||||
mpv,
|
||||
yomitan,
|
||||
@@ -73,7 +73,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
subtitleSidebar,
|
||||
auto_start_overlay,
|
||||
jimaku,
|
||||
animetosho,
|
||||
tsukihime,
|
||||
anilist,
|
||||
mpv,
|
||||
yomitan,
|
||||
|
||||
@@ -98,7 +98,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
openCharacterDictionaryManager: 'CommandOrControl+D',
|
||||
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
||||
openJimaku: 'Ctrl+Shift+J',
|
||||
openAnimetosho: 'Ctrl+Shift+T',
|
||||
openTsukihime: 'Ctrl+Shift+T',
|
||||
openSessionHelp: 'CommandOrControl+Slash',
|
||||
openControllerSelect: 'Alt+C',
|
||||
openControllerDebug: 'Alt+Shift+C',
|
||||
|
||||
@@ -5,7 +5,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
ResolvedConfig,
|
||||
| 'ankiConnect'
|
||||
| 'jimaku'
|
||||
| 'animetosho'
|
||||
| 'tsukihime'
|
||||
| 'anilist'
|
||||
| 'mpv'
|
||||
| 'yomitan'
|
||||
@@ -97,8 +97,8 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
languagePreference: 'ja',
|
||||
maxEntryResults: 10,
|
||||
},
|
||||
animetosho: {
|
||||
apiBaseUrl: 'https://feed.animetosho.org',
|
||||
tsukihime: {
|
||||
apiBaseUrl: 'https://api.tsukihime.org/v1',
|
||||
maxSearchResults: 10,
|
||||
},
|
||||
mpv: {
|
||||
|
||||
@@ -616,11 +616,11 @@ export function buildCoreConfigOptionRegistry(
|
||||
description: 'Accelerator that opens the Jimaku subtitle search modal.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openAnimetosho',
|
||||
path: 'shortcuts.openTsukihime',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openAnimetosho,
|
||||
defaultValue: defaultConfig.shortcuts.openTsukihime,
|
||||
description:
|
||||
'Accelerator that opens the Animetosho subtitle search modal (English/Japanese tabs).',
|
||||
'Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openSessionHelp',
|
||||
|
||||
@@ -401,16 +401,17 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
description: 'Maximum Jimaku search results returned.',
|
||||
},
|
||||
{
|
||||
path: 'animetosho.apiBaseUrl',
|
||||
path: 'tsukihime.apiBaseUrl',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.animetosho.apiBaseUrl,
|
||||
description: 'Base URL of the Animetosho JSON feed API. No API key required.',
|
||||
defaultValue: defaultConfig.tsukihime.apiBaseUrl,
|
||||
description:
|
||||
'Base URL of the TsukiHime API (Animetosho successor). No API key required.',
|
||||
},
|
||||
{
|
||||
path: 'animetosho.maxSearchResults',
|
||||
path: 'tsukihime.maxSearchResults',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.animetosho.maxSearchResults,
|
||||
description: 'Maximum Animetosho search results returned.',
|
||||
defaultValue: defaultConfig.tsukihime.maxSearchResults,
|
||||
description: 'Maximum TsukiHime search results returned.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.enabled',
|
||||
|
||||
@@ -53,7 +53,9 @@ export const SPECIAL_COMMANDS = {
|
||||
SUBSYNC_TRIGGER: '__subsync-trigger',
|
||||
RUNTIME_OPTIONS_OPEN: '__runtime-options-open',
|
||||
JIMAKU_OPEN: '__jimaku-open',
|
||||
/** @deprecated Use TSUKIHIME_OPEN. */
|
||||
ANIMETOSHO_OPEN: '__animetosho-open',
|
||||
TSUKIHIME_OPEN: '__tsukihime-open',
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||
|
||||
@@ -148,12 +148,12 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
key: 'jimaku',
|
||||
},
|
||||
{
|
||||
title: 'Animetosho',
|
||||
title: 'TsukiHime',
|
||||
description: [
|
||||
'Animetosho subtitle search configuration (English and Japanese). No API key required.',
|
||||
'TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.',
|
||||
],
|
||||
notes: ['Hot-reload: Animetosho changes apply to the next Animetosho request.'],
|
||||
key: 'animetosho',
|
||||
notes: ['Hot-reload: TsukiHime changes apply to the next TsukiHime request.'],
|
||||
key: 'tsukihime',
|
||||
},
|
||||
{
|
||||
title: 'YouTube Playback Settings',
|
||||
|
||||
@@ -236,7 +236,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
'openCharacterDictionaryManager',
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
'openAnimetosho',
|
||||
'openTsukihime',
|
||||
'openSessionHelp',
|
||||
'openControllerSelect',
|
||||
'openControllerDebug',
|
||||
@@ -254,6 +254,20 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (src.shortcuts.openTsukihime === undefined) {
|
||||
const legacyOpenTsukihime = src.shortcuts.openAnimetosho;
|
||||
if (typeof legacyOpenTsukihime === 'string' || legacyOpenTsukihime === null) {
|
||||
resolved.shortcuts.openTsukihime = legacyOpenTsukihime;
|
||||
} else if (legacyOpenTsukihime !== undefined) {
|
||||
warn(
|
||||
'shortcuts.openAnimetosho',
|
||||
legacyOpenTsukihime,
|
||||
resolved.shortcuts.openTsukihime,
|
||||
'Expected string or null.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const timeout = asNumber(src.shortcuts.multiCopyTimeoutMs);
|
||||
if (timeout !== undefined && timeout > 0) {
|
||||
resolved.shortcuts.multiCopyTimeoutMs = Math.floor(timeout);
|
||||
|
||||
@@ -80,18 +80,27 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.animetosho)) {
|
||||
const apiBaseUrl = asString(src.animetosho.apiBaseUrl);
|
||||
if (apiBaseUrl !== undefined) resolved.animetosho.apiBaseUrl = apiBaseUrl;
|
||||
const currentTsukihimeSource = isObject(src.tsukihime) ? src.tsukihime : null;
|
||||
if (src.tsukihime !== undefined && !currentTsukihimeSource) {
|
||||
warn('tsukihime', src.tsukihime, resolved.tsukihime, 'Expected object.');
|
||||
}
|
||||
|
||||
const maxSearchResults = asNumber(src.animetosho.maxSearchResults);
|
||||
const legacyTsukihimeSource =
|
||||
src.tsukihime === undefined && isObject(src.animetosho) ? src.animetosho : null;
|
||||
const tsukihimeSource = currentTsukihimeSource ?? legacyTsukihimeSource;
|
||||
const tsukihimeSourcePath = currentTsukihimeSource ? 'tsukihime' : 'animetosho';
|
||||
if (tsukihimeSource) {
|
||||
const apiBaseUrl = asString(tsukihimeSource.apiBaseUrl);
|
||||
if (apiBaseUrl !== undefined) resolved.tsukihime.apiBaseUrl = apiBaseUrl;
|
||||
|
||||
const maxSearchResults = asNumber(tsukihimeSource.maxSearchResults);
|
||||
if (maxSearchResults !== undefined && Math.floor(maxSearchResults) > 0) {
|
||||
resolved.animetosho.maxSearchResults = Math.floor(maxSearchResults);
|
||||
} else if (src.animetosho.maxSearchResults !== undefined) {
|
||||
resolved.tsukihime.maxSearchResults = Math.floor(maxSearchResults);
|
||||
} else if (tsukihimeSource.maxSearchResults !== undefined) {
|
||||
warn(
|
||||
'animetosho.maxSearchResults',
|
||||
src.animetosho.maxSearchResults,
|
||||
resolved.animetosho.maxSearchResults,
|
||||
`${tsukihimeSourcePath}.maxSearchResults`,
|
||||
tsukihimeSource.maxSearchResults,
|
||||
resolved.tsukihime.maxSearchResults,
|
||||
'Expected positive number.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { asBoolean } from './shared';
|
||||
|
||||
export function applyTopLevelConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
const knownTopLevelKeys = new Set(Object.keys(resolved));
|
||||
const knownTopLevelKeys = new Set([...Object.keys(resolved), 'animetosho']);
|
||||
for (const key of Object.keys(src)) {
|
||||
if (!knownTopLevelKeys.has(key)) {
|
||||
warn(key, src[key], undefined, 'Unknown top-level config key; ignored.');
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { resolveConfig } from '../resolve';
|
||||
|
||||
test('resolveConfig maps legacy Animetosho settings to TsukiHime', () => {
|
||||
const { resolved, warnings } = resolveConfig({
|
||||
animetosho: {
|
||||
apiBaseUrl: 'https://legacy.example/v1',
|
||||
maxSearchResults: 23,
|
||||
},
|
||||
shortcuts: {
|
||||
openAnimetosho: 'Ctrl+Alt+T',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(resolved.tsukihime.apiBaseUrl, 'https://legacy.example/v1');
|
||||
assert.equal(resolved.tsukihime.maxSearchResults, 23);
|
||||
assert.equal(resolved.shortcuts.openTsukihime, 'Ctrl+Alt+T');
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
test('resolveConfig gives current TsukiHime settings precedence over legacy aliases', () => {
|
||||
const { resolved, warnings } = resolveConfig({
|
||||
animetosho: {
|
||||
apiBaseUrl: 'https://legacy.example/v1',
|
||||
maxSearchResults: 23,
|
||||
},
|
||||
tsukihime: {
|
||||
apiBaseUrl: 'https://current.example/v1',
|
||||
maxSearchResults: 7,
|
||||
},
|
||||
shortcuts: {
|
||||
openAnimetosho: 'Ctrl+Alt+T',
|
||||
openTsukihime: null,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(resolved.tsukihime.apiBaseUrl, 'https://current.example/v1');
|
||||
assert.equal(resolved.tsukihime.maxSearchResults, 7);
|
||||
assert.equal(resolved.shortcuts.openTsukihime, null);
|
||||
assert.deepEqual(warnings, []);
|
||||
});
|
||||
|
||||
test('resolveConfig does not fall back when the current TsukiHime setting is invalid', () => {
|
||||
const { resolved, warnings } = resolveConfig({
|
||||
animetosho: {
|
||||
apiBaseUrl: 'https://legacy.example/v1',
|
||||
maxSearchResults: 23,
|
||||
},
|
||||
tsukihime: 'invalid' as never,
|
||||
});
|
||||
|
||||
assert.equal(resolved.tsukihime.apiBaseUrl, 'https://api.tsukihime.org/v1');
|
||||
assert.equal(resolved.tsukihime.maxSearchResults, 10);
|
||||
assert.deepEqual(warnings, [
|
||||
{
|
||||
path: 'tsukihime',
|
||||
value: 'invalid',
|
||||
fallback: resolved.tsukihime,
|
||||
message: 'Expected object.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -247,6 +247,8 @@ test('settings registry routes playback-related integrations into integrations',
|
||||
assert.equal(field('jimaku.apiBaseUrl').section, 'Jimaku');
|
||||
assert.equal(field('subsync.replace').category, 'integrations');
|
||||
assert.equal(field('subsync.replace').section, 'Subtitle Sync');
|
||||
assert.equal(field('tsukihime.apiBaseUrl').category, 'integrations');
|
||||
assert.equal(field('tsukihime.apiBaseUrl').section, 'TsukiHime');
|
||||
});
|
||||
|
||||
test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
|
||||
|
||||
@@ -422,7 +422,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
if (path.startsWith('mpv.') || path.startsWith('youtube.')) {
|
||||
return { category: 'behavior', section: topSection(path) };
|
||||
}
|
||||
if (path.startsWith('jimaku.')) {
|
||||
if (path.startsWith('jimaku.') || path.startsWith('tsukihime.')) {
|
||||
return { category: 'integrations', section: topSection(path) };
|
||||
}
|
||||
if (path.startsWith('subsync.')) {
|
||||
@@ -486,6 +486,7 @@ function topSection(path: string): string {
|
||||
notifications: 'Notifications',
|
||||
subsync: 'Subtitle Sync',
|
||||
texthooker: 'Texthooker',
|
||||
tsukihime: 'TsukiHime',
|
||||
updates: 'Updates',
|
||||
websocket: 'WebSocket server',
|
||||
yomitan: 'Yomitan',
|
||||
@@ -594,7 +595,7 @@ function subsectionForPath(path: string): string | undefined {
|
||||
leaf === 'openCharacterDictionaryManager' ||
|
||||
leaf === 'openRuntimeOptions' ||
|
||||
leaf === 'openJimaku' ||
|
||||
leaf === 'openAnimetosho' ||
|
||||
leaf === 'openTsukihime' ||
|
||||
leaf === 'openSessionHelp' ||
|
||||
leaf === 'openControllerSelect' ||
|
||||
leaf === 'openControllerDebug'
|
||||
|
||||
@@ -55,10 +55,10 @@ test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => {
|
||||
isRemoteMediaPath: () => false,
|
||||
downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }),
|
||||
onDownloadedSubtitle: () => {},
|
||||
searchAnimetoshoEntries: async () => ({ ok: true, data: [] }),
|
||||
listAnimetoshoFiles: async () => ({ ok: true, data: [] }),
|
||||
downloadAnimetoshoSubtitle: async () => ({ ok: true, path: '/tmp/sub.en.ass' }),
|
||||
getAnimetoshoSecondaryLanguages: () => ['en'],
|
||||
searchTsukihimeEntries: async () => ({ ok: true, data: [] }),
|
||||
listTsukihimeFiles: async () => ({ ok: true, data: [] }),
|
||||
downloadTsukihimeSubtitle: async () => ({ ok: true, path: '/tmp/sub.en.ass' }),
|
||||
getTsukihimeSecondaryLanguages: () => ['en'],
|
||||
onDownloadedSecondarySubtitle: () => {},
|
||||
},
|
||||
registrar,
|
||||
@@ -98,41 +98,41 @@ test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => {
|
||||
error: { error: 'Invalid Jimaku download query payload', code: 400 },
|
||||
});
|
||||
|
||||
const animetoshoSearchHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoSearchEntries);
|
||||
assert.ok(animetoshoSearchHandler);
|
||||
const invalidAnimetoshoSearch = await animetoshoSearchHandler!({}, { query: 12 });
|
||||
assert.deepEqual(invalidAnimetoshoSearch, {
|
||||
const tsukihimeSearchHandler = handleHandlers.get(IPC_CHANNELS.request.tsukihimeSearchEntries);
|
||||
assert.ok(tsukihimeSearchHandler);
|
||||
const invalidTsukihimeSearch = await tsukihimeSearchHandler!({}, { query: 12 });
|
||||
assert.deepEqual(invalidTsukihimeSearch, {
|
||||
ok: false,
|
||||
error: { error: 'Invalid Animetosho search query payload', code: 400 },
|
||||
error: { error: 'Invalid TsukiHime search query payload', code: 400 },
|
||||
});
|
||||
|
||||
const animetoshoFilesHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoListFiles);
|
||||
assert.ok(animetoshoFilesHandler);
|
||||
const invalidAnimetoshoFiles = await animetoshoFilesHandler!({}, { entryId: 'x' });
|
||||
assert.deepEqual(invalidAnimetoshoFiles, {
|
||||
const tsukihimeFilesHandler = handleHandlers.get(IPC_CHANNELS.request.tsukihimeListFiles);
|
||||
assert.ok(tsukihimeFilesHandler);
|
||||
const invalidTsukihimeFiles = await tsukihimeFilesHandler!({}, { entryId: 'x' });
|
||||
assert.deepEqual(invalidTsukihimeFiles, {
|
||||
ok: false,
|
||||
error: { error: 'Invalid Animetosho files query payload', code: 400 },
|
||||
error: { error: 'Invalid TsukiHime files query payload', code: 400 },
|
||||
});
|
||||
|
||||
const animetoshoDownloadHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoDownloadFile);
|
||||
assert.ok(animetoshoDownloadHandler);
|
||||
const invalidAnimetoshoDownload = await animetoshoDownloadHandler!({}, { entryId: 1, url: '/x' });
|
||||
assert.deepEqual(invalidAnimetoshoDownload, {
|
||||
const tsukihimeDownloadHandler = handleHandlers.get(IPC_CHANNELS.request.tsukihimeDownloadFile);
|
||||
assert.ok(tsukihimeDownloadHandler);
|
||||
const invalidTsukihimeDownload = await tsukihimeDownloadHandler!({}, { entryId: 1, url: '/x' });
|
||||
assert.deepEqual(invalidTsukihimeDownload, {
|
||||
ok: false,
|
||||
error: { error: 'Invalid Animetosho download query payload', code: 400 },
|
||||
error: { error: 'Invalid TsukiHime download query payload', code: 400 },
|
||||
});
|
||||
|
||||
const foreignUrlDownload = await animetoshoDownloadHandler!(
|
||||
const foreignUrlDownload = await tsukihimeDownloadHandler!(
|
||||
{},
|
||||
{ entryId: 1, url: 'https://evil.example/attach/00000001/1.xz', name: 'sub.ass' },
|
||||
);
|
||||
assert.deepEqual(foreignUrlDownload, {
|
||||
ok: false,
|
||||
error: { error: 'Refusing to download subtitle from a non-Animetosho URL.', code: 400 },
|
||||
error: { error: 'Refusing to download subtitle from a non-TsukiHime URL.', code: 400 },
|
||||
});
|
||||
});
|
||||
|
||||
test('animetosho downloads route by language: secondary for eng, primary for jpn', async () => {
|
||||
test('tsukihime downloads always route Japanese as primary', async () => {
|
||||
const { registrar, handleHandlers } = createFakeRegistrar();
|
||||
const primaryLoads: string[] = [];
|
||||
const secondaryLoads: string[] = [];
|
||||
@@ -160,10 +160,10 @@ test('animetosho downloads route by language: secondary for eng, primary for jpn
|
||||
onDownloadedSubtitle: (path) => {
|
||||
primaryLoads.push(path);
|
||||
},
|
||||
searchAnimetoshoEntries: async () => ({ ok: true, data: [] }),
|
||||
listAnimetoshoFiles: async () => ({ ok: true, data: [] }),
|
||||
downloadAnimetoshoSubtitle: async (_url, destPath) => ({ ok: true, path: destPath }),
|
||||
getAnimetoshoSecondaryLanguages: () => ['en'],
|
||||
searchTsukihimeEntries: async () => ({ ok: true, data: [] }),
|
||||
listTsukihimeFiles: async () => ({ ok: true, data: [] }),
|
||||
downloadTsukihimeSubtitle: async (_url, destPath) => ({ ok: true, path: destPath }),
|
||||
getTsukihimeSecondaryLanguages: () => ['ja'],
|
||||
onDownloadedSecondarySubtitle: (path) => {
|
||||
secondaryLoads.push(path);
|
||||
},
|
||||
@@ -171,13 +171,13 @@ test('animetosho downloads route by language: secondary for eng, primary for jpn
|
||||
registrar,
|
||||
);
|
||||
|
||||
const downloadHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoDownloadFile)!;
|
||||
const downloadHandler = handleHandlers.get(IPC_CHANNELS.request.tsukihimeDownloadFile)!;
|
||||
|
||||
const engResult = (await downloadHandler!(
|
||||
{},
|
||||
{
|
||||
entryId: 1,
|
||||
url: 'https://animetosho.org/storage/attach/00000001/1.xz',
|
||||
url: 'https://storage.tsukihime.org/attach/00000001/1.xz',
|
||||
name: 'episode.eng.ass',
|
||||
lang: 'eng',
|
||||
},
|
||||
@@ -191,7 +191,7 @@ test('animetosho downloads route by language: secondary for eng, primary for jpn
|
||||
{},
|
||||
{
|
||||
entryId: 1,
|
||||
url: 'https://animetosho.org/storage/attach/00000002/2.xz',
|
||||
url: 'https://storage.tsukihime.org/attach/00000002/2.xz',
|
||||
name: 'episode.jpn.ass',
|
||||
lang: 'jpn',
|
||||
},
|
||||
@@ -232,10 +232,10 @@ test('anki/jimaku IPC command handlers ignore malformed payloads', () => {
|
||||
isRemoteMediaPath: () => false,
|
||||
downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }),
|
||||
onDownloadedSubtitle: () => {},
|
||||
searchAnimetoshoEntries: async () => ({ ok: true, data: [] }),
|
||||
listAnimetoshoFiles: async () => ({ ok: true, data: [] }),
|
||||
downloadAnimetoshoSubtitle: async () => ({ ok: true, path: '/tmp/sub.en.ass' }),
|
||||
getAnimetoshoSecondaryLanguages: () => ['en'],
|
||||
searchTsukihimeEntries: async () => ({ ok: true, data: [] }),
|
||||
listTsukihimeFiles: async () => ({ ok: true, data: [] }),
|
||||
downloadTsukihimeSubtitle: async () => ({ ok: true, path: '/tmp/sub.en.ass' }),
|
||||
getTsukihimeSecondaryLanguages: () => ['en'],
|
||||
onDownloadedSecondarySubtitle: () => {},
|
||||
},
|
||||
registrar,
|
||||
|
||||
@@ -4,12 +4,12 @@ import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { createLogger } from '../../logger';
|
||||
import {
|
||||
AnimetoshoApiResponse,
|
||||
AnimetoshoDownloadResult,
|
||||
AnimetoshoEntry,
|
||||
AnimetoshoFilesQuery,
|
||||
AnimetoshoSearchQuery,
|
||||
AnimetoshoSubtitleFile,
|
||||
TsukihimeApiResponse,
|
||||
TsukihimeDownloadResult,
|
||||
TsukihimeEntry,
|
||||
TsukihimeFilesQuery,
|
||||
TsukihimeSearchQuery,
|
||||
TsukihimeSubtitleFile,
|
||||
JimakuApiResponse,
|
||||
JimakuDownloadResult,
|
||||
JimakuEntry,
|
||||
@@ -23,9 +23,9 @@ import {
|
||||
} from '../../types';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
parseAnimetoshoDownloadQuery,
|
||||
parseAnimetoshoFilesQuery,
|
||||
parseAnimetoshoSearchQuery,
|
||||
parseTsukihimeDownloadQuery,
|
||||
parseTsukihimeFilesQuery,
|
||||
parseTsukihimeSearchQuery,
|
||||
parseJimakuDownloadQuery,
|
||||
parseJimakuFilesQuery,
|
||||
parseJimakuSearchQuery,
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
parseKikuMergePreviewRequest,
|
||||
} from '../../shared/ipc/validators';
|
||||
import { buildJimakuSubtitleFilenameFromMediaPath } from './jimaku-download-path';
|
||||
import { animetoshoLangToFilenameSuffix, isAnimetoshoDownloadUrl } from '../../animetosho/utils';
|
||||
import { tsukihimeLangToFilenameSuffix, isTsukihimeDownloadUrl } from '../../tsukihime/utils';
|
||||
|
||||
const { ipcMain } = electron;
|
||||
|
||||
@@ -57,14 +57,14 @@ export interface AnkiJimakuIpcDeps {
|
||||
headers: Record<string, string>,
|
||||
) => Promise<JimakuDownloadResult>;
|
||||
onDownloadedSubtitle: (pathToSubtitle: string) => void;
|
||||
searchAnimetoshoEntries: (
|
||||
query: AnimetoshoSearchQuery,
|
||||
) => Promise<AnimetoshoApiResponse<AnimetoshoEntry[]>>;
|
||||
listAnimetoshoFiles: (
|
||||
query: AnimetoshoFilesQuery,
|
||||
) => Promise<AnimetoshoApiResponse<AnimetoshoSubtitleFile[]>>;
|
||||
downloadAnimetoshoSubtitle: (url: string, destPath: string) => Promise<AnimetoshoDownloadResult>;
|
||||
getAnimetoshoSecondaryLanguages: () => string[];
|
||||
searchTsukihimeEntries: (
|
||||
query: TsukihimeSearchQuery,
|
||||
) => Promise<TsukihimeApiResponse<TsukihimeEntry[]>>;
|
||||
listTsukihimeFiles: (
|
||||
query: TsukihimeFilesQuery,
|
||||
) => Promise<TsukihimeApiResponse<TsukihimeSubtitleFile[]>>;
|
||||
downloadTsukihimeSubtitle: (url: string, destPath: string) => Promise<TsukihimeDownloadResult>;
|
||||
getTsukihimeSecondaryLanguages: () => string[];
|
||||
onDownloadedSecondarySubtitle: (pathToSubtitle: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
@@ -206,50 +206,50 @@ export function registerAnkiJimakuIpcHandlers(
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.animetoshoGetSecondaryLanguages, (): string[] => {
|
||||
return deps.getAnimetoshoSecondaryLanguages();
|
||||
ipc.handle(IPC_CHANNELS.request.tsukihimeGetSecondaryLanguages, (): string[] => {
|
||||
return deps.getTsukihimeSecondaryLanguages();
|
||||
});
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.animetoshoSearchEntries,
|
||||
async (_event, query: unknown): Promise<AnimetoshoApiResponse<AnimetoshoEntry[]>> => {
|
||||
const parsedQuery = parseAnimetoshoSearchQuery(query);
|
||||
IPC_CHANNELS.request.tsukihimeSearchEntries,
|
||||
async (_event, query: unknown): Promise<TsukihimeApiResponse<TsukihimeEntry[]>> => {
|
||||
const parsedQuery = parseTsukihimeSearchQuery(query);
|
||||
if (!parsedQuery) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { error: 'Invalid Animetosho search query payload', code: 400 },
|
||||
error: { error: 'Invalid TsukiHime search query payload', code: 400 },
|
||||
};
|
||||
}
|
||||
return deps.searchAnimetoshoEntries(parsedQuery);
|
||||
return deps.searchTsukihimeEntries(parsedQuery);
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.animetoshoListFiles,
|
||||
async (_event, query: unknown): Promise<AnimetoshoApiResponse<AnimetoshoSubtitleFile[]>> => {
|
||||
const parsedQuery = parseAnimetoshoFilesQuery(query);
|
||||
IPC_CHANNELS.request.tsukihimeListFiles,
|
||||
async (_event, query: unknown): Promise<TsukihimeApiResponse<TsukihimeSubtitleFile[]>> => {
|
||||
const parsedQuery = parseTsukihimeFilesQuery(query);
|
||||
if (!parsedQuery) {
|
||||
return { ok: false, error: { error: 'Invalid Animetosho files query payload', code: 400 } };
|
||||
return { ok: false, error: { error: 'Invalid TsukiHime files query payload', code: 400 } };
|
||||
}
|
||||
return deps.listAnimetoshoFiles(parsedQuery);
|
||||
return deps.listTsukihimeFiles(parsedQuery);
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.animetoshoDownloadFile,
|
||||
async (_event, query: unknown): Promise<AnimetoshoDownloadResult> => {
|
||||
const parsedQuery = parseAnimetoshoDownloadQuery(query);
|
||||
IPC_CHANNELS.request.tsukihimeDownloadFile,
|
||||
async (_event, query: unknown): Promise<TsukihimeDownloadResult> => {
|
||||
const parsedQuery = parseTsukihimeDownloadQuery(query);
|
||||
if (!parsedQuery) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { error: 'Invalid Animetosho download query payload', code: 400 },
|
||||
error: { error: 'Invalid TsukiHime download query payload', code: 400 },
|
||||
};
|
||||
}
|
||||
|
||||
if (!isAnimetoshoDownloadUrl(parsedQuery.url)) {
|
||||
if (!isTsukihimeDownloadUrl(parsedQuery.url)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { error: 'Refusing to download subtitle from a non-Animetosho URL.', code: 400 },
|
||||
error: { error: 'Refusing to download subtitle from a non-TsukiHime URL.', code: 400 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -259,13 +259,13 @@ export function registerAnkiJimakuIpcHandlers(
|
||||
}
|
||||
|
||||
const mediaDir = deps.isRemoteMediaPath(currentMediaPath)
|
||||
? fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-animetosho-'))
|
||||
? fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-tsukihime-'))
|
||||
: path.dirname(path.resolve(currentMediaPath));
|
||||
const safeName = path.basename(parsedQuery.name);
|
||||
if (!safeName) {
|
||||
return { ok: false, error: { error: 'Invalid subtitle filename.' } };
|
||||
}
|
||||
const languageSuffix = animetoshoLangToFilenameSuffix(parsedQuery.lang);
|
||||
const languageSuffix = tsukihimeLangToFilenameSuffix(parsedQuery.lang);
|
||||
const subtitleFilename = buildJimakuSubtitleFilenameFromMediaPath(
|
||||
currentMediaPath,
|
||||
safeName,
|
||||
@@ -276,24 +276,24 @@ export function registerAnkiJimakuIpcHandlers(
|
||||
const baseName = ext ? subtitleFilename.slice(0, -ext.length) : subtitleFilename;
|
||||
let targetPath = path.join(mediaDir, subtitleFilename);
|
||||
if (fs.existsSync(targetPath)) {
|
||||
targetPath = path.join(mediaDir, `${baseName} (animetosho-${parsedQuery.entryId})${ext}`);
|
||||
targetPath = path.join(mediaDir, `${baseName} (tsukihime-${parsedQuery.entryId})${ext}`);
|
||||
let counter = 2;
|
||||
while (fs.existsSync(targetPath)) {
|
||||
targetPath = path.join(
|
||||
mediaDir,
|
||||
`${baseName} (animetosho-${parsedQuery.entryId}-${counter})${ext}`,
|
||||
`${baseName} (tsukihime-${parsedQuery.entryId}-${counter})${ext}`,
|
||||
);
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[animetosho] download-file name="${parsedQuery.name}" entryId=${parsedQuery.entryId}`,
|
||||
`[tsukihime] download-file name="${parsedQuery.name}" entryId=${parsedQuery.entryId}`,
|
||||
);
|
||||
const result = await deps.downloadAnimetoshoSubtitle(parsedQuery.url, targetPath);
|
||||
const result = await deps.downloadTsukihimeSubtitle(parsedQuery.url, targetPath);
|
||||
|
||||
if (result.ok) {
|
||||
logger.info(`[animetosho] download-file saved to ${result.path}`);
|
||||
logger.info(`[tsukihime] download-file saved to ${result.path}`);
|
||||
// Japanese tracks take the primary slot; anything else loads as the
|
||||
// secondary subtitle so the Japanese primary stays in place.
|
||||
if (languageSuffix === 'ja') {
|
||||
@@ -303,7 +303,7 @@ export function registerAnkiJimakuIpcHandlers(
|
||||
}
|
||||
} else {
|
||||
logger.error(
|
||||
`[animetosho] download-file failed: ${result.error?.error ?? 'unknown error'}`,
|
||||
`[tsukihime] download-file failed: ${result.error?.error ?? 'unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ interface RuntimeHarness {
|
||||
patches: boolean[];
|
||||
broadcasts: number;
|
||||
fetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
|
||||
animetoshoFetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
|
||||
tsukihimeFetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
|
||||
sentCommands: Array<{ command: (string | number)[] }>;
|
||||
};
|
||||
}
|
||||
@@ -26,7 +26,7 @@ function createHarness(): RuntimeHarness {
|
||||
endpoint: string;
|
||||
query?: Record<string, unknown>;
|
||||
}>,
|
||||
animetoshoFetchCalls: [] as Array<{
|
||||
tsukihimeFetchCalls: [] as Array<{
|
||||
endpoint: string;
|
||||
query?: Record<string, unknown>;
|
||||
}>,
|
||||
@@ -37,17 +37,18 @@ function createHarness(): RuntimeHarness {
|
||||
patchAnkiConnectEnabled: (enabled) => {
|
||||
state.patches.push(enabled);
|
||||
},
|
||||
getResolvedConfig: () => ({ animetosho: { maxSearchResults: 2 } }),
|
||||
getResolvedConfig: () => ({ tsukihime: { maxSearchResults: 2 } }),
|
||||
getRuntimeOptionsManager: () => null,
|
||||
animetoshoFetchJson: async (endpoint, query) => {
|
||||
state.animetoshoFetchCalls.push({
|
||||
tsukihimeFetchJson: async (endpoint, query) => {
|
||||
state.tsukihimeFetchCalls.push({
|
||||
endpoint,
|
||||
query: query as Record<string, unknown>,
|
||||
});
|
||||
if ((query as Record<string, unknown>)?.show === 'torrent') {
|
||||
if (endpoint.startsWith('/torrents/')) {
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
id: 606713,
|
||||
files: [
|
||||
{
|
||||
id: 9,
|
||||
@@ -55,9 +56,8 @@ function createHarness(): RuntimeHarness {
|
||||
attachments: [
|
||||
{
|
||||
id: 1955356,
|
||||
type: 'subtitle',
|
||||
info: { codec: 'ASS', lang: 'eng', name: 'English subs' },
|
||||
size: 33075,
|
||||
type: 1,
|
||||
info: { codec: 'ASS', lang: 'en', name: 'English subs' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -67,11 +67,13 @@ function createHarness(): RuntimeHarness {
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
data: [
|
||||
{ id: 1, title: 'release a' },
|
||||
{ id: 2, title: 'release b' },
|
||||
{ id: 3, title: 'release c' },
|
||||
] as never,
|
||||
data: {
|
||||
results: [
|
||||
{ id: 1, name: 'release a' },
|
||||
{ id: 2, name: 'release b' },
|
||||
{ id: 3, name: 'release c' },
|
||||
],
|
||||
} as never,
|
||||
};
|
||||
},
|
||||
getSubtitleTimingTracker: () => null,
|
||||
@@ -172,9 +174,9 @@ test('registerAnkiJimakuIpcRuntime provides full handler surface', () => {
|
||||
'isRemoteMediaPath',
|
||||
'downloadToFile',
|
||||
'onDownloadedSubtitle',
|
||||
'searchAnimetoshoEntries',
|
||||
'listAnimetoshoFiles',
|
||||
'downloadAnimetoshoSubtitle',
|
||||
'searchTsukihimeEntries',
|
||||
'listTsukihimeFiles',
|
||||
'downloadTsukihimeSubtitle',
|
||||
'onDownloadedSecondarySubtitle',
|
||||
];
|
||||
|
||||
@@ -365,14 +367,14 @@ test('onDownloadedSecondarySubtitle retries until mpv reports the new track', as
|
||||
});
|
||||
});
|
||||
|
||||
test('searchAnimetoshoEntries caps results using animetosho.maxSearchResults', async () => {
|
||||
test('searchTsukihimeEntries caps results using tsukihime.maxSearchResults', async () => {
|
||||
const { registered, state } = createHarness();
|
||||
|
||||
const searchResult = await registered.searchAnimetoshoEntries!({ query: 'frieren 28' });
|
||||
assert.deepEqual(state.animetoshoFetchCalls, [
|
||||
const searchResult = await registered.searchTsukihimeEntries!({ query: 'frieren 28' });
|
||||
assert.deepEqual(state.tsukihimeFetchCalls, [
|
||||
{
|
||||
endpoint: '/json',
|
||||
query: { q: 'frieren 28', qx: 1 },
|
||||
endpoint: '/search/torrents',
|
||||
query: { q: 'frieren 28', limit: 2 },
|
||||
},
|
||||
]);
|
||||
assert.equal((searchResult as { ok: boolean }).ok, true);
|
||||
@@ -384,20 +386,20 @@ test('searchAnimetoshoEntries caps results using animetosho.maxSearchResults', a
|
||||
);
|
||||
});
|
||||
|
||||
test('listAnimetoshoFiles extracts subtitle attachments from torrent detail', async () => {
|
||||
test('listTsukihimeFiles extracts subtitle attachments from torrent detail', async () => {
|
||||
const { registered, state } = createHarness();
|
||||
|
||||
const filesResult = await registered.listAnimetoshoFiles!({ entryId: 606713 });
|
||||
assert.deepEqual(state.animetoshoFetchCalls, [
|
||||
const filesResult = await registered.listTsukihimeFiles!({ entryId: 606713 });
|
||||
assert.deepEqual(state.tsukihimeFetchCalls, [
|
||||
{
|
||||
endpoint: '/json',
|
||||
query: { show: 'torrent', id: 606713 },
|
||||
endpoint: '/torrents/606713',
|
||||
query: {},
|
||||
},
|
||||
]);
|
||||
assert.equal((filesResult as { ok: boolean }).ok, true);
|
||||
const files = (filesResult as { data: Array<Record<string, unknown>> }).data;
|
||||
assert.equal(files.length, 1);
|
||||
assert.equal(files[0]!.attachmentId, 1955356);
|
||||
assert.equal(files[0]!.filename, 'episode.eng.ass');
|
||||
assert.equal(files[0]!.url, 'https://animetosho.org/storage/attach/001dd61c/1955356.xz');
|
||||
assert.equal(files[0]!.filename, 'episode.en.ass');
|
||||
assert.equal(files[0]!.url, 'https://storage.tsukihime.org/attach/001dd61c/1955356.xz');
|
||||
});
|
||||
|
||||
@@ -3,8 +3,8 @@ import { AnkiIntegration } from '../../anki-integration';
|
||||
import { mergeAiConfig } from '../../ai/config';
|
||||
import {
|
||||
AiConfig,
|
||||
AnimetoshoApiResponse,
|
||||
AnimetoshoConfig,
|
||||
TsukihimeApiResponse,
|
||||
TsukihimeConfig,
|
||||
AnkiConnectConfig,
|
||||
JimakuApiResponse,
|
||||
JimakuEntry,
|
||||
@@ -17,13 +17,13 @@ import {
|
||||
} from '../../types';
|
||||
import { sortJimakuFiles } from '../../jimaku/utils';
|
||||
import {
|
||||
ANIMETOSHO_FEED_BASE_URL,
|
||||
animetoshoFetchJson as animetoshoFetchJsonRequest,
|
||||
TSUKIHIME_API_BASE_URL,
|
||||
tsukihimeFetchJson as tsukihimeFetchJsonRequest,
|
||||
decompressXzFile,
|
||||
extractAnimetoshoSubtitleFiles,
|
||||
isAnimetoshoDownloadUrl,
|
||||
mapAnimetoshoSearchResults,
|
||||
} from '../../animetosho/utils';
|
||||
extractTsukihimeSubtitleFiles,
|
||||
isTsukihimeDownloadUrl,
|
||||
mapTsukihimeSearchResults,
|
||||
} from '../../tsukihime/utils';
|
||||
import type { AnkiJimakuIpcDeps } from './anki-jimaku-ipc';
|
||||
import { createLogger } from '../../logger';
|
||||
|
||||
@@ -48,7 +48,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
||||
getResolvedConfig: () => {
|
||||
ankiConnect?: AnkiConnectConfig;
|
||||
ai?: AiConfig;
|
||||
animetosho?: AnimetoshoConfig;
|
||||
tsukihime?: TsukihimeConfig;
|
||||
secondarySub?: { secondarySubLanguages?: string[] };
|
||||
};
|
||||
getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null;
|
||||
@@ -77,10 +77,10 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
||||
endpoint: string,
|
||||
query?: Record<string, string | number | boolean | null | undefined>,
|
||||
) => Promise<JimakuApiResponse<T>>;
|
||||
animetoshoFetchJson?: <T>(
|
||||
tsukihimeFetchJson?: <T>(
|
||||
endpoint: string,
|
||||
query?: Record<string, string | number | boolean | null | undefined>,
|
||||
) => Promise<AnimetoshoApiResponse<T>>;
|
||||
) => Promise<TsukihimeApiResponse<T>>;
|
||||
getJimakuMaxEntryResults: () => number;
|
||||
getJimakuLanguagePreference: () => JimakuLanguagePreference;
|
||||
resolveJimakuApiKey: () => Promise<string | null>;
|
||||
@@ -101,7 +101,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
||||
|
||||
const logger = createLogger('main:anki-jimaku');
|
||||
|
||||
const DEFAULT_ANIMETOSHO_MAX_SEARCH_RESULTS = 10;
|
||||
const DEFAULT_TSUKIHIME_MAX_SEARCH_RESULTS = 10;
|
||||
const SECONDARY_TRACK_LOOKUP_ATTEMPTS = 5;
|
||||
const SECONDARY_TRACK_LOOKUP_RETRY_MS = 100;
|
||||
|
||||
@@ -109,24 +109,24 @@ function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function getAnimetoshoMaxSearchResults(options: AnkiJimakuIpcRuntimeOptions): number {
|
||||
const value = options.getResolvedConfig().animetosho?.maxSearchResults;
|
||||
function getTsukihimeMaxSearchResults(options: AnkiJimakuIpcRuntimeOptions): number {
|
||||
const value = options.getResolvedConfig().tsukihime?.maxSearchResults;
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
return Math.floor(value);
|
||||
}
|
||||
return DEFAULT_ANIMETOSHO_MAX_SEARCH_RESULTS;
|
||||
return DEFAULT_TSUKIHIME_MAX_SEARCH_RESULTS;
|
||||
}
|
||||
|
||||
function animetoshoFetch<T>(
|
||||
function tsukihimeFetch<T>(
|
||||
options: AnkiJimakuIpcRuntimeOptions,
|
||||
endpoint: string,
|
||||
query: Record<string, string | number | boolean | null | undefined>,
|
||||
): Promise<AnimetoshoApiResponse<T>> {
|
||||
if (options.animetoshoFetchJson) {
|
||||
return options.animetoshoFetchJson<T>(endpoint, query);
|
||||
): Promise<TsukihimeApiResponse<T>> {
|
||||
if (options.tsukihimeFetchJson) {
|
||||
return options.tsukihimeFetchJson<T>(endpoint, query);
|
||||
}
|
||||
const baseUrl = options.getResolvedConfig().animetosho?.apiBaseUrl || ANIMETOSHO_FEED_BASE_URL;
|
||||
return animetoshoFetchJsonRequest<T>(endpoint, query, { baseUrl });
|
||||
const baseUrl = options.getResolvedConfig().tsukihime?.apiBaseUrl || TSUKIHIME_API_BASE_URL;
|
||||
return tsukihimeFetchJsonRequest<T>(endpoint, query, { baseUrl });
|
||||
}
|
||||
|
||||
export function registerAnkiJimakuIpcRuntime(
|
||||
@@ -242,39 +242,41 @@ export function registerAnkiJimakuIpcRuntime(
|
||||
isRemoteMediaPath: (mediaPath) => options.isRemoteMediaPath(mediaPath),
|
||||
downloadToFile: (url, destPath, headers) => options.downloadToFile(url, destPath, headers),
|
||||
|
||||
searchAnimetoshoEntries: async (query) => {
|
||||
logger.info(`[animetosho] search-entries query: "${query.query}"`);
|
||||
const response = await animetoshoFetch<unknown>(options, '/json', {
|
||||
searchTsukihimeEntries: async (query) => {
|
||||
logger.info(`[tsukihime] search-entries query: "${query.query}"`);
|
||||
const maxResults = getTsukihimeMaxSearchResults(options);
|
||||
const response = await tsukihimeFetch<unknown>(options, '/search/torrents', {
|
||||
q: query.query,
|
||||
qx: 1,
|
||||
// The API caps limit at 100.
|
||||
limit: Math.min(maxResults, 100),
|
||||
});
|
||||
if (!response.ok) return response;
|
||||
const maxResults = getAnimetoshoMaxSearchResults(options);
|
||||
const entries = mapAnimetoshoSearchResults(response.data, maxResults);
|
||||
logger.info(`[animetosho] search-entries returned ${entries.length} results`);
|
||||
const entries = mapTsukihimeSearchResults(response.data, maxResults);
|
||||
logger.info(`[tsukihime] search-entries returned ${entries.length} results`);
|
||||
return { ok: true, data: entries };
|
||||
},
|
||||
listAnimetoshoFiles: async (query) => {
|
||||
logger.info(`[animetosho] list-files entryId=${query.entryId}`);
|
||||
const response = await animetoshoFetch<unknown>(options, '/json', {
|
||||
show: 'torrent',
|
||||
id: query.entryId,
|
||||
});
|
||||
listTsukihimeFiles: async (query) => {
|
||||
logger.info(`[tsukihime] list-files entryId=${query.entryId}`);
|
||||
const response = await tsukihimeFetch<unknown>(
|
||||
options,
|
||||
`/torrents/${encodeURIComponent(query.entryId)}`,
|
||||
{},
|
||||
);
|
||||
if (!response.ok) return response;
|
||||
const files = extractAnimetoshoSubtitleFiles(response.data);
|
||||
logger.info(`[animetosho] list-files returned ${files.length} subtitle attachments`);
|
||||
const files = extractTsukihimeSubtitleFiles(response.data);
|
||||
logger.info(`[tsukihime] list-files returned ${files.length} subtitle attachments`);
|
||||
return { ok: true, data: files };
|
||||
},
|
||||
getAnimetoshoSecondaryLanguages: () =>
|
||||
getTsukihimeSecondaryLanguages: () =>
|
||||
options.getResolvedConfig().secondarySub?.secondarySubLanguages ?? [],
|
||||
downloadAnimetoshoSubtitle: async (url, destPath) => {
|
||||
downloadTsukihimeSubtitle: async (url, destPath) => {
|
||||
const tempXzPath = `${destPath}.xz`;
|
||||
const downloaded = await options.downloadToFile(
|
||||
url,
|
||||
tempXzPath,
|
||||
{ 'User-Agent': 'SubMiner' },
|
||||
// animetosho.org redirects to storage.animetosho.org; keep the hop in-domain.
|
||||
{ isAllowedRedirect: (redirectUrl) => isAnimetoshoDownloadUrl(redirectUrl) },
|
||||
// The /tosho/ mirror 302s to storage.animetosho.org; keep the hop in-allowlist.
|
||||
{ isAllowedRedirect: (redirectUrl) => isTsukihimeDownloadUrl(redirectUrl) },
|
||||
);
|
||||
if (!downloaded.ok) return downloaded;
|
||||
const result = await decompressXzFile(tempXzPath, destPath);
|
||||
@@ -310,14 +312,14 @@ export function registerAnkiJimakuIpcRuntime(
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[animetosho] failed to select downloaded subtitle as secondary:', error);
|
||||
logger.warn('[tsukihime] failed to select downloaded subtitle as secondary:', error);
|
||||
return;
|
||||
}
|
||||
await delay(SECONDARY_TRACK_LOOKUP_RETRY_MS);
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`[animetosho] could not find downloaded subtitle in track-list: ${pathToSubtitle}`,
|
||||
`[tsukihime] could not find downloaded subtitle in track-list: ${pathToSubtitle}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -39,7 +40,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openControllerSelect: false,
|
||||
openControllerDebug: false,
|
||||
openJimaku: false,
|
||||
openAnimetosho: false,
|
||||
openTsukihime: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
@@ -140,6 +141,65 @@ test('startAppLifecycle still acquires lock for startup commands', () => {
|
||||
assert.equal(getLockCalls(), 1);
|
||||
});
|
||||
|
||||
test('startAppLifecycle defers quit until async cleanup settles', async () => {
|
||||
let willQuit: ((event: { preventDefault(): void }) => void) | null = null;
|
||||
let releaseCleanup: (() => void) | null = null;
|
||||
const cleanupDone = new Promise<void>((resolve) => {
|
||||
releaseCleanup = resolve;
|
||||
});
|
||||
let prevented = false;
|
||||
const { deps, calls } = createDeps({
|
||||
shouldStartApp: () => true,
|
||||
onWillQuit: (handler) => {
|
||||
willQuit = handler;
|
||||
},
|
||||
onWillQuitCleanup: () => cleanupDone,
|
||||
});
|
||||
|
||||
startAppLifecycle(makeArgs({ start: true }), deps);
|
||||
assert.ok(willQuit);
|
||||
(willQuit as (event: { preventDefault(): void }) => void)({
|
||||
preventDefault: () => {
|
||||
prevented = true;
|
||||
},
|
||||
});
|
||||
assert.equal(prevented, true);
|
||||
assert.deepEqual(calls, []);
|
||||
|
||||
assert.ok(releaseCleanup);
|
||||
(releaseCleanup as () => void)();
|
||||
await cleanupDone;
|
||||
// The re-quit must not fire in the microtask turn of the will-quit
|
||||
// dispatch: Electron drops a quit issued while the prevented quit is
|
||||
// still unwinding, leaving a windowless process alive.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(calls, []);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(calls, ['quitApp']);
|
||||
});
|
||||
|
||||
test('startAppLifecycle contains synchronous quit cleanup failures', () => {
|
||||
let willQuit: ((event: { preventDefault(): void }) => void) | null = null;
|
||||
const { deps, calls } = createDeps({
|
||||
shouldStartApp: () => true,
|
||||
onWillQuit: (handler) => {
|
||||
willQuit = handler;
|
||||
},
|
||||
onWillQuitCleanup: () => {
|
||||
throw new Error('cleanup exploded');
|
||||
},
|
||||
});
|
||||
|
||||
startAppLifecycle(makeArgs({ start: true }), deps);
|
||||
assert.ok(willQuit);
|
||||
assert.doesNotThrow(() =>
|
||||
(willQuit as (event: { preventDefault(): void }) => void)({ preventDefault: () => {} }),
|
||||
);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('startAppLifecycle app ping exits non-zero immediately when no running instance owns the lock', () => {
|
||||
const { deps, calls, getLockCalls } = createDeps({
|
||||
shouldStartApp: () => false,
|
||||
@@ -252,7 +312,7 @@ test('startAppLifecycle routes control socket commands through the second-instan
|
||||
},
|
||||
});
|
||||
|
||||
let willQuitHandler: (() => void) | null = null;
|
||||
let willQuitHandler: ((event: { preventDefault(): void }) => void) | null = null;
|
||||
deps.onWillQuit = (handler) => {
|
||||
willQuitHandler = handler;
|
||||
};
|
||||
@@ -274,7 +334,7 @@ test('startAppLifecycle routes control socket commands through the second-instan
|
||||
assert.deepEqual(handled, ['ready', 'second-instance:start']);
|
||||
|
||||
assert.ok(willQuitHandler);
|
||||
(willQuitHandler as () => void)();
|
||||
(willQuitHandler as (event: { preventDefault(): void }) => void)({ preventDefault: () => {} });
|
||||
assert.deepEqual(handled, ['ready', 'second-instance:start', 'control-close']);
|
||||
});
|
||||
|
||||
@@ -353,3 +413,22 @@ test('startAppLifecycle quits macOS setup-only launch when all windows close', (
|
||||
handler();
|
||||
assert.deepEqual(calls, ['quitApp']);
|
||||
});
|
||||
|
||||
test('startAppLifecycle quits macOS sync-window launch when its window closes', () => {
|
||||
let windowAllClosedHandler: (() => void) | null = null;
|
||||
const { deps, calls } = createDeps({
|
||||
shouldStartApp: () => true,
|
||||
isDarwinPlatform: () => true,
|
||||
shouldQuitOnWindowAllClosed: () => true,
|
||||
onWindowAllClosed: (handler) => {
|
||||
windowAllClosedHandler = handler;
|
||||
},
|
||||
});
|
||||
|
||||
startAppLifecycle(makeArgs({ syncWindow: true }), deps);
|
||||
|
||||
const handler = windowAllClosedHandler as (() => void) | null;
|
||||
assert.ok(handler);
|
||||
handler();
|
||||
assert.deepEqual(calls, ['quitApp']);
|
||||
});
|
||||
|
||||
@@ -16,11 +16,11 @@ export interface AppLifecycleServiceDeps {
|
||||
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
|
||||
whenReady: (handler: () => Promise<void>) => void;
|
||||
onWindowAllClosed: (handler: () => void) => void;
|
||||
onWillQuit: (handler: () => void) => void;
|
||||
onWillQuit: (handler: (event: { preventDefault(): void }) => void) => void;
|
||||
onActivate: (handler: () => void) => void;
|
||||
isDarwinPlatform: () => boolean;
|
||||
onReady: () => Promise<void>;
|
||||
onWillQuitCleanup: () => void;
|
||||
onWillQuitCleanup: () => void | Promise<void>;
|
||||
shouldRestoreWindowsOnActivate: () => boolean;
|
||||
restoreWindowsOnActivate: () => void;
|
||||
shouldQuitOnWindowAllClosed: () => boolean;
|
||||
@@ -44,7 +44,7 @@ export interface AppLifecycleDepsRuntimeOptions {
|
||||
logNoRunningInstance: () => void;
|
||||
startControlServer?: (handleArgv: (argv: string[]) => void) => (() => void) | void;
|
||||
onReady: () => Promise<void>;
|
||||
onWillQuitCleanup: () => void;
|
||||
onWillQuitCleanup: () => void | Promise<void>;
|
||||
shouldRestoreWindowsOnActivate: () => boolean;
|
||||
restoreWindowsOnActivate: () => void;
|
||||
shouldQuitOnWindowAllClosed: () => boolean;
|
||||
@@ -183,16 +183,48 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
||||
deps.onWindowAllClosed(() => {
|
||||
if (
|
||||
deps.shouldQuitOnWindowAllClosed() &&
|
||||
(!deps.isDarwinPlatform() || initialArgs.settings || initialArgs.setup)
|
||||
(!deps.isDarwinPlatform() ||
|
||||
initialArgs.settings ||
|
||||
initialArgs.setup ||
|
||||
initialArgs.syncWindow)
|
||||
) {
|
||||
deps.quitApp();
|
||||
}
|
||||
});
|
||||
|
||||
deps.onWillQuit(() => {
|
||||
let quitCleanupPending = false;
|
||||
let quitCleanupComplete = false;
|
||||
deps.onWillQuit((event) => {
|
||||
if (quitCleanupComplete) return;
|
||||
stopControlServer?.();
|
||||
stopControlServer = null;
|
||||
deps.onWillQuitCleanup();
|
||||
if (quitCleanupPending) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
let cleanup: void | Promise<void>;
|
||||
try {
|
||||
cleanup = deps.onWillQuitCleanup();
|
||||
} catch (error) {
|
||||
logger.error('App quit cleanup failed:', error);
|
||||
return;
|
||||
}
|
||||
if (!(cleanup instanceof Promise)) return;
|
||||
quitCleanupPending = true;
|
||||
event.preventDefault();
|
||||
void cleanup
|
||||
.catch((error) => {
|
||||
logger.error('App quit cleanup failed:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
quitCleanupPending = false;
|
||||
quitCleanupComplete = true;
|
||||
// A cleanup promise that settles in a microtask would re-quit while
|
||||
// Electron is still unwinding the prevented quit, and that quit call
|
||||
// is silently dropped, leaving a windowless process alive. Re-issue
|
||||
// the quit from a fresh macrotask instead.
|
||||
setImmediate(() => deps.quitApp());
|
||||
});
|
||||
});
|
||||
|
||||
deps.onActivate(() => {
|
||||
|
||||
@@ -21,6 +21,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -44,7 +45,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openControllerSelect: false,
|
||||
openControllerDebug: false,
|
||||
openJimaku: false,
|
||||
openAnimetosho: false,
|
||||
openTsukihime: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
@@ -138,6 +139,9 @@ function createDeps(overrides: Partial<CliCommandServiceDeps> = {}) {
|
||||
openConfigSettingsWindow: () => {
|
||||
calls.push('openConfigSettingsWindow');
|
||||
},
|
||||
openSyncUiWindow: () => {
|
||||
calls.push('openSyncUiWindow');
|
||||
},
|
||||
openFirstRunSetup: (force?: boolean) => {
|
||||
calls.push(`openFirstRunSetup:${force === true ? 'force' : 'default'}`);
|
||||
},
|
||||
@@ -660,6 +664,7 @@ test('createCliCommandDepsRuntime reconnects MPV client when reconnect hook exis
|
||||
openFirstRunSetup: () => {},
|
||||
openYomitanSettings: () => {},
|
||||
openConfigSettingsWindow: () => {},
|
||||
openSyncUiWindow: () => {},
|
||||
cycleSecondarySubMode: () => {},
|
||||
openRuntimeOptionsPalette: () => {},
|
||||
printHelp: () => {},
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface CliCommandServiceDeps {
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
openYomitanSettingsDelayed: (delayMs: number) => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
setVisibleOverlayVisible: (visible: boolean) => void;
|
||||
copyCurrentSubtitle: () => void;
|
||||
startPendingMultiCopy: (timeoutMs: number) => void;
|
||||
@@ -170,6 +171,7 @@ interface UiCliRuntime {
|
||||
openFirstRunSetup: (force?: boolean) => void;
|
||||
openYomitanSettings: () => void;
|
||||
openConfigSettingsWindow: () => void;
|
||||
openSyncUiWindow: () => void;
|
||||
cycleSecondarySubMode: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
printHelp: () => void;
|
||||
@@ -274,6 +276,7 @@ export function createCliCommandDepsRuntime(
|
||||
}, delayMs);
|
||||
},
|
||||
openConfigSettingsWindow: options.ui.openConfigSettingsWindow,
|
||||
openSyncUiWindow: options.ui.openSyncUiWindow,
|
||||
setVisibleOverlayVisible: options.overlay.setVisible,
|
||||
copyCurrentSubtitle: options.mining.copyCurrentSubtitle,
|
||||
startPendingMultiCopy: options.mining.startPendingMultiCopy,
|
||||
@@ -417,6 +420,8 @@ export function handleCliCommand(
|
||||
deps.openYomitanSettingsDelayed(1000);
|
||||
} else if (args.settings) {
|
||||
deps.openConfigSettingsWindow();
|
||||
} else if (args.syncWindow) {
|
||||
deps.openSyncUiWindow();
|
||||
} else if (args.show || args.showVisibleOverlay) {
|
||||
deps.setVisibleOverlayVisible(true);
|
||||
} else if (args.hide || args.hideVisibleOverlay) {
|
||||
@@ -539,11 +544,11 @@ export function handleCliCommand(
|
||||
);
|
||||
} else if (args.openJimaku) {
|
||||
dispatchCliSessionAction({ actionId: 'openJimaku' }, 'openJimaku', 'Open jimaku failed');
|
||||
} else if (args.openAnimetosho) {
|
||||
} else if (args.openTsukihime) {
|
||||
dispatchCliSessionAction(
|
||||
{ actionId: 'openAnimetosho' },
|
||||
'openAnimetosho',
|
||||
'Open animetosho failed',
|
||||
{ actionId: 'openTsukihime' },
|
||||
'openTsukihime',
|
||||
'Open tsukihime failed',
|
||||
);
|
||||
} else if (args.openYoutubePicker) {
|
||||
dispatchCliSessionAction(
|
||||
|
||||
@@ -13,6 +13,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
RUNTIME_OPTIONS_OPEN: '__runtime-options-open',
|
||||
JIMAKU_OPEN: '__jimaku-open',
|
||||
ANIMETOSHO_OPEN: '__animetosho-open',
|
||||
TSUKIHIME_OPEN: '__tsukihime-open',
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
|
||||
REPLAY_SUBTITLE: '__replay-subtitle',
|
||||
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
|
||||
@@ -28,8 +29,8 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
openJimaku: () => {
|
||||
calls.push('jimaku');
|
||||
},
|
||||
openAnimetosho: () => {
|
||||
calls.push('animetosho');
|
||||
openTsukihime: () => {
|
||||
calls.push('tsukihime');
|
||||
},
|
||||
openYoutubeTrackPicker: () => {
|
||||
calls.push('youtube-picker');
|
||||
@@ -152,6 +153,15 @@ test('handleMpvCommandFromIpc dispatches special jimaku open command', () => {
|
||||
assert.deepEqual(osd, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc keeps the legacy Animetosho command as a TsukiHime alias', () => {
|
||||
const { options, calls, sentCommands } = createOptions();
|
||||
|
||||
handleMpvCommandFromIpc(['__animetosho-open'], options);
|
||||
|
||||
assert.deepEqual(calls, ['tsukihime']);
|
||||
assert.deepEqual(sentCommands, []);
|
||||
});
|
||||
|
||||
test('handleMpvCommandFromIpc dispatches special playlist browser open command', async () => {
|
||||
const { options, calls, sentCommands, osd } = createOptions();
|
||||
handleMpvCommandFromIpc(['__playlist-browser-open'], options);
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface HandleMpvCommandFromIpcOptions {
|
||||
RUNTIME_OPTIONS_OPEN: string;
|
||||
JIMAKU_OPEN: string;
|
||||
ANIMETOSHO_OPEN: string;
|
||||
TSUKIHIME_OPEN: string;
|
||||
RUNTIME_OPTION_CYCLE_PREFIX: string;
|
||||
REPLAY_SUBTITLE: string;
|
||||
PLAY_NEXT_SUBTITLE: string;
|
||||
@@ -20,7 +21,7 @@ export interface HandleMpvCommandFromIpcOptions {
|
||||
triggerSubsyncFromConfig: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openJimaku: () => void;
|
||||
openAnimetosho: () => void;
|
||||
openTsukihime: () => void;
|
||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||
openPlaylistBrowser: () => void | Promise<void>;
|
||||
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
@@ -114,8 +115,11 @@ export function handleMpvCommandFromIpc(
|
||||
return;
|
||||
}
|
||||
|
||||
if (first === options.specialCommands.ANIMETOSHO_OPEN) {
|
||||
options.openAnimetosho();
|
||||
if (
|
||||
first === options.specialCommands.TSUKIHIME_OPEN ||
|
||||
first === options.specialCommands.ANIMETOSHO_OPEN
|
||||
) {
|
||||
options.openTsukihime();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
|
||||
openCharacterDictionaryManager: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openAnimetosho: null,
|
||||
openTsukihime: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
@@ -55,8 +55,8 @@ function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
|
||||
openJimaku: () => {
|
||||
calls.push('openJimaku');
|
||||
},
|
||||
openAnimetosho: () => {
|
||||
calls.push('openAnimetosho');
|
||||
openTsukihime: () => {
|
||||
calls.push('openTsukihime');
|
||||
},
|
||||
markAudioCard: async () => {
|
||||
calls.push('markAudioCard');
|
||||
@@ -168,7 +168,7 @@ test('runOverlayShortcutLocalFallback dispatches matching single-step actions',
|
||||
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
|
||||
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
|
||||
openJimaku: () => handled.push('openJimaku'),
|
||||
openAnimetosho: () => handled.push('openAnimetosho'),
|
||||
openTsukihime: () => handled.push('openTsukihime'),
|
||||
markAudioCard: () => handled.push('markAudioCard'),
|
||||
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
|
||||
copySubtitle: () => handled.push('copySubtitle'),
|
||||
@@ -202,7 +202,7 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
|
||||
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
|
||||
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
|
||||
openJimaku: () => handled.push('openJimaku'),
|
||||
openAnimetosho: () => handled.push('openAnimetosho'),
|
||||
openTsukihime: () => handled.push('openTsukihime'),
|
||||
markAudioCard: () => handled.push('markAudioCard'),
|
||||
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
|
||||
copySubtitle: () => handled.push('copySubtitle'),
|
||||
@@ -223,7 +223,7 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
|
||||
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
|
||||
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
|
||||
openJimaku: () => handled.push('openJimaku'),
|
||||
openAnimetosho: () => handled.push('openAnimetosho'),
|
||||
openTsukihime: () => handled.push('openTsukihime'),
|
||||
markAudioCard: () => handled.push('markAudioCard'),
|
||||
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
|
||||
copySubtitle: () => handled.push('copySubtitle'),
|
||||
@@ -261,7 +261,7 @@ test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s
|
||||
openRuntimeOptions: () => {},
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openJimaku: () => {},
|
||||
openAnimetosho: () => {},
|
||||
openTsukihime: () => {},
|
||||
markAudioCard: () => {},
|
||||
copySubtitleMultiple: () => {},
|
||||
copySubtitle: () => {},
|
||||
@@ -298,7 +298,7 @@ test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut',
|
||||
openRuntimeOptions: () => {},
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openJimaku: () => {},
|
||||
openAnimetosho: () => {},
|
||||
openTsukihime: () => {},
|
||||
markAudioCard: () => {},
|
||||
copySubtitleMultiple: () => {},
|
||||
copySubtitle: () => {},
|
||||
@@ -331,7 +331,7 @@ test('runOverlayShortcutLocalFallback returns false when no action matches', ()
|
||||
openJimaku: () => {
|
||||
called = true;
|
||||
},
|
||||
openAnimetosho: () => {
|
||||
openTsukihime: () => {
|
||||
called = true;
|
||||
},
|
||||
markAudioCard: () => {
|
||||
@@ -416,7 +416,7 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured',
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
openAnimetosho: () => {},
|
||||
openTsukihime: () => {},
|
||||
}),
|
||||
cancelPendingMultiCopy: () => {},
|
||||
cancelPendingMineSentenceMultiple: () => {},
|
||||
@@ -444,7 +444,7 @@ test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
openAnimetosho: () => {},
|
||||
openTsukihime: () => {},
|
||||
}),
|
||||
cancelPendingMultiCopy: () => {
|
||||
calls.push('cancel-multi-copy');
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface OverlayShortcutFallbackHandlers {
|
||||
openRuntimeOptions: () => void;
|
||||
openCharacterDictionaryManager: () => void;
|
||||
openJimaku: () => void;
|
||||
openAnimetosho: () => void;
|
||||
openTsukihime: () => void;
|
||||
markAudioCard: () => void;
|
||||
copySubtitleMultiple: (timeoutMs: number) => void;
|
||||
copySubtitle: () => void;
|
||||
@@ -25,7 +25,7 @@ export interface OverlayShortcutRuntimeDeps {
|
||||
openRuntimeOptions: () => void;
|
||||
openCharacterDictionaryManager: () => void;
|
||||
openJimaku: () => void;
|
||||
openAnimetosho: () => void;
|
||||
openTsukihime: () => void;
|
||||
markAudioCard: () => Promise<void>;
|
||||
copySubtitleMultiple: (timeoutMs: number) => void;
|
||||
copySubtitle: () => void;
|
||||
@@ -105,8 +105,8 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
|
||||
openJimaku: () => {
|
||||
deps.openJimaku();
|
||||
},
|
||||
openAnimetosho: () => {
|
||||
deps.openAnimetosho();
|
||||
openTsukihime: () => {
|
||||
deps.openTsukihime();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -114,7 +114,7 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
|
||||
openRuntimeOptions: overlayHandlers.openRuntimeOptions,
|
||||
openCharacterDictionaryManager: overlayHandlers.openCharacterDictionaryManager,
|
||||
openJimaku: overlayHandlers.openJimaku,
|
||||
openAnimetosho: overlayHandlers.openAnimetosho,
|
||||
openTsukihime: overlayHandlers.openTsukihime,
|
||||
markAudioCard: overlayHandlers.markAudioCard,
|
||||
copySubtitleMultiple: overlayHandlers.copySubtitleMultiple,
|
||||
copySubtitle: overlayHandlers.copySubtitle,
|
||||
@@ -160,9 +160,9 @@ export function runOverlayShortcutLocalFallback(
|
||||
allowWhenRegistered: true,
|
||||
},
|
||||
{
|
||||
accelerator: shortcuts.openAnimetosho,
|
||||
accelerator: shortcuts.openTsukihime,
|
||||
run: () => {
|
||||
handlers.openAnimetosho();
|
||||
handlers.openTsukihime();
|
||||
},
|
||||
allowWhenRegistered: true,
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
openCharacterDictionaryManager: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openAnimetosho: null,
|
||||
openTsukihime: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
@@ -49,7 +49,7 @@ test('registerOverlayShortcuts reports active overlay shortcuts when configured'
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
openAnimetosho: () => {},
|
||||
openTsukihime: () => {},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -70,7 +70,7 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent'
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
openAnimetosho: () => {},
|
||||
openTsukihime: () => {},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
@@ -93,7 +93,7 @@ test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
openAnimetosho: () => {},
|
||||
openTsukihime: () => {},
|
||||
}),
|
||||
cancelPendingMultiCopy: () => {
|
||||
calls.push('cancel-multi-copy');
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface OverlayShortcutHandlers {
|
||||
openCharacterDictionaryManager: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openJimaku: () => void;
|
||||
openAnimetosho: () => void;
|
||||
openTsukihime: () => void;
|
||||
}
|
||||
|
||||
export interface OverlayShortcutLifecycleDeps {
|
||||
@@ -36,7 +36,7 @@ const OVERLAY_SHORTCUT_KEYS: Array<keyof Omit<ConfiguredShortcuts, 'multiCopyTim
|
||||
'openCharacterDictionaryManager',
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
'openAnimetosho',
|
||||
'openTsukihime',
|
||||
];
|
||||
|
||||
function hasConfiguredOverlayShortcuts(shortcuts: ConfiguredShortcuts): boolean {
|
||||
|
||||
@@ -40,7 +40,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
||||
openControllerSelect: () => calls.push('controller-select'),
|
||||
openControllerDebug: () => calls.push('controller-debug'),
|
||||
openJimaku: () => calls.push('jimaku'),
|
||||
openAnimetosho: () => calls.push('animetosho'),
|
||||
openTsukihime: () => calls.push('tsukihime'),
|
||||
openYoutubeTrackPicker: () => {
|
||||
calls.push('youtube');
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface SessionActionExecutorDeps {
|
||||
openControllerSelect: () => void;
|
||||
openControllerDebug: () => void;
|
||||
openJimaku: () => void;
|
||||
openAnimetosho: () => void;
|
||||
openTsukihime: () => void;
|
||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
|
||||
replayCurrentSubtitle: () => void;
|
||||
@@ -116,8 +116,8 @@ export async function dispatchSessionAction(
|
||||
case 'openJimaku':
|
||||
deps.openJimaku();
|
||||
return;
|
||||
case 'openAnimetosho':
|
||||
deps.openAnimetosho();
|
||||
case 'openTsukihime':
|
||||
deps.openTsukihime();
|
||||
return;
|
||||
case 'openYoutubePicker':
|
||||
await deps.openYoutubeTrackPicker();
|
||||
|
||||
@@ -22,7 +22,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
openCharacterDictionaryManager: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openAnimetosho: null,
|
||||
openTsukihime: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
@@ -320,6 +320,21 @@ test('compileSessionBindings wires every default keybinding to an overlay or mpv
|
||||
}
|
||||
});
|
||||
|
||||
test('compileSessionBindings maps the legacy Animetosho command to the TsukiHime action', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts(),
|
||||
keybindings: [createKeybinding('Ctrl+Alt+T', ['__animetosho-open'])],
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.equal(result.bindings[0]?.actionType, 'session-action');
|
||||
assert.equal(
|
||||
result.bindings[0]?.actionType === 'session-action' ? result.bindings[0].actionId : null,
|
||||
'openTsukihime',
|
||||
);
|
||||
});
|
||||
|
||||
test('compileSessionBindings leaves retired subtitle-delay shift tokens as mpv commands', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts(),
|
||||
|
||||
@@ -55,7 +55,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
|
||||
{ key: 'openCharacterDictionaryManager', actionId: 'openCharacterDictionaryManager' },
|
||||
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
|
||||
{ key: 'openJimaku', actionId: 'openJimaku' },
|
||||
{ key: 'openAnimetosho', actionId: 'openAnimetosho' },
|
||||
{ key: 'openTsukihime', actionId: 'openTsukihime' },
|
||||
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },
|
||||
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
|
||||
{ key: 'openControllerDebug', actionId: 'openControllerDebug' },
|
||||
@@ -305,9 +305,9 @@ function resolveCommandBinding(
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'openJimaku' };
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.ANIMETOSHO_OPEN) {
|
||||
if (first === SPECIAL_COMMANDS.TSUKIHIME_OPEN || first === SPECIAL_COMMANDS.ANIMETOSHO_OPEN) {
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'openAnimetosho' };
|
||||
return { actionType: 'session-action', actionId: 'openTsukihime' };
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN) {
|
||||
if (command.length !== 1) return null;
|
||||
|
||||
@@ -16,6 +16,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
togglePrimarySubtitleBar: false,
|
||||
yomitan: false,
|
||||
settings: false,
|
||||
syncWindow: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -39,7 +40,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openControllerSelect: false,
|
||||
openControllerDebug: false,
|
||||
openJimaku: false,
|
||||
openAnimetosho: false,
|
||||
openTsukihime: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { extractSyncCliTokens, parseSyncCliTokens } from './cli-args';
|
||||
|
||||
test('extractSyncCliTokens returns tokens after --sync-cli', () => {
|
||||
assert.equal(extractSyncCliTokens(['/bin/electron', '/app']), null);
|
||||
assert.deepEqual(extractSyncCliTokens(['/bin/electron', '/app', '--sync-cli', 'sync', 'host']), [
|
||||
'sync',
|
||||
'host',
|
||||
]);
|
||||
// A repeated flag (e.g. resolved remote command + forwarded argv) is ignored.
|
||||
assert.deepEqual(extractSyncCliTokens(['/app', '--sync-cli', '--sync-cli', 'sync', 'h']), [
|
||||
'sync',
|
||||
'h',
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseSyncCliTokens handles help, version, and run modes', () => {
|
||||
assert.deepEqual(parseSyncCliTokens(['--help']), { kind: 'help' });
|
||||
assert.deepEqual(parseSyncCliTokens(['--version']), { kind: 'version' });
|
||||
|
||||
const run = parseSyncCliTokens(['sync', 'media-box', '--pull', '--force', '--json']);
|
||||
assert.equal(run.kind, 'run');
|
||||
if (run.kind === 'run') {
|
||||
assert.equal(run.args.syncHost, 'media-box');
|
||||
assert.equal(run.args.syncDirection, 'pull');
|
||||
assert.equal(run.args.syncForce, true);
|
||||
assert.equal(run.args.syncJson, true);
|
||||
}
|
||||
|
||||
const snapshot = parseSyncCliTokens(['sync', '--snapshot', '/tmp/x.sqlite', '--db', '/tmp/db']);
|
||||
assert.equal(snapshot.kind, 'run');
|
||||
if (snapshot.kind === 'run') {
|
||||
assert.equal(snapshot.args.syncSnapshotPath, '/tmp/x.sqlite');
|
||||
assert.equal(snapshot.args.syncDbPath, '/tmp/db');
|
||||
}
|
||||
});
|
||||
|
||||
test('parseSyncCliTokens handles the temp-dir protocol modes', () => {
|
||||
const makeTemp = parseSyncCliTokens(['sync', '--make-temp']);
|
||||
assert.equal(makeTemp.kind, 'run');
|
||||
if (makeTemp.kind === 'run') assert.equal(makeTemp.args.syncMakeTemp, true);
|
||||
|
||||
const removeTemp = parseSyncCliTokens(['sync', '--remove-temp', '/tmp/subminer-sync-x']);
|
||||
assert.equal(removeTemp.kind, 'run');
|
||||
if (removeTemp.kind === 'run') {
|
||||
assert.equal(removeTemp.args.syncRemoveTempPath, '/tmp/subminer-sync-x');
|
||||
}
|
||||
|
||||
assert.equal(parseSyncCliTokens(['sync', '--make-temp', 'host']).kind, 'error');
|
||||
assert.equal(parseSyncCliTokens(['sync', '--make-temp', '--remove-temp', '/tmp/x']).kind, 'error');
|
||||
});
|
||||
|
||||
test('parseSyncCliTokens owns the sync CLI validation rules', () => {
|
||||
assert.equal(parseSyncCliTokens([]).kind, 'error');
|
||||
assert.equal(parseSyncCliTokens(['sync']).kind, 'error');
|
||||
assert.equal(parseSyncCliTokens(['sync', 'h', '--push', '--pull']).kind, 'error');
|
||||
assert.equal(parseSyncCliTokens(['sync', '--snapshot', '/tmp/x', '--push']).kind, 'error');
|
||||
assert.equal(parseSyncCliTokens(['sync', '--check']).kind, 'error');
|
||||
assert.equal(parseSyncCliTokens(['sync', '--check', '--snapshot', '/tmp/x', 'h']).kind, 'error');
|
||||
assert.equal(parseSyncCliTokens(['sync', 'h', '--snapshot', '/tmp/x']).kind, 'error');
|
||||
assert.equal(parseSyncCliTokens(['sync', 'h', '--bogus']).kind, 'error');
|
||||
assert.equal(parseSyncCliTokens(['sync', 'h', 'extra']).kind, 'error');
|
||||
assert.equal(parseSyncCliTokens(['sync', '--snapshot']).kind, 'error');
|
||||
});
|
||||
|
||||
test('parseSyncCliTokens rejects an option-like token where a value is required', () => {
|
||||
// Without this guard `--snapshot --force` writes a snapshot to a file literally
|
||||
// named "--force" and silently drops the flag.
|
||||
assert.deepEqual(parseSyncCliTokens(['sync', '--snapshot', '--force']), {
|
||||
kind: 'error',
|
||||
message: 'Missing value for --snapshot.',
|
||||
});
|
||||
assert.deepEqual(parseSyncCliTokens(['sync', '--remove-temp', '--force']), {
|
||||
kind: 'error',
|
||||
message: 'Missing value for --remove-temp.',
|
||||
});
|
||||
// The `--flag=<value>` form still accepts values that begin with "-".
|
||||
const parsed = parseSyncCliTokens(['sync', '--snapshot=-weird-name.sqlite']);
|
||||
assert.equal(parsed.kind, 'run');
|
||||
assert.equal(parsed.kind === 'run' && parsed.args.syncSnapshotPath, '-weird-name.sqlite');
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { SyncFlowArgs } from './sync-flow';
|
||||
|
||||
export const SYNC_CLI_FLAG = '--sync-cli';
|
||||
|
||||
export type ParsedSyncCli =
|
||||
| { kind: 'help' }
|
||||
| { kind: 'version' }
|
||||
| { kind: 'run'; args: SyncFlowArgs }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
export function extractSyncCliTokens(argv: readonly string[]): string[] | null {
|
||||
const index = argv.indexOf(SYNC_CLI_FLAG);
|
||||
if (index === -1) return null;
|
||||
return argv.slice(index + 1).filter((token) => token !== SYNC_CLI_FLAG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse launcher-style sync argv (`sync [host] [--snapshot f] ...`) for the
|
||||
* app's --sync-cli mode. This is the single owner of sync CLI validation:
|
||||
* the launcher forwards `subminer sync` tokens verbatim, so both entry
|
||||
* points accept the same command lines and fail the same way.
|
||||
*/
|
||||
export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
|
||||
if (tokens.includes('--help') || tokens.includes('-h')) return { kind: 'help' };
|
||||
if (tokens.includes('--version') || tokens.includes('-V')) return { kind: 'version' };
|
||||
|
||||
const rest = [...tokens];
|
||||
if (rest[0] !== 'sync') {
|
||||
return {
|
||||
kind: 'error',
|
||||
message: `Expected a "sync" command after ${SYNC_CLI_FLAG} (e.g. ${SYNC_CLI_FLAG} sync --snapshot <file>).`,
|
||||
};
|
||||
}
|
||||
rest.shift();
|
||||
|
||||
let host = '';
|
||||
let snapshot = '';
|
||||
let merge = '';
|
||||
let push = false;
|
||||
let pull = false;
|
||||
let check = false;
|
||||
let force = false;
|
||||
let json = false;
|
||||
let makeTemp = false;
|
||||
let removeTemp = '';
|
||||
let remoteCmd = '';
|
||||
let dbPath = '';
|
||||
let logLevel = 'warn';
|
||||
|
||||
const valueFlags = new Map<string, (value: string) => void>([
|
||||
['--snapshot', (value) => (snapshot = value.trim())],
|
||||
['--merge', (value) => (merge = value.trim())],
|
||||
['--remove-temp', (value) => (removeTemp = value.trim())],
|
||||
['--remote-cmd', (value) => (remoteCmd = value.trim())],
|
||||
['--db', (value) => (dbPath = value.trim())],
|
||||
['--log-level', (value) => (logLevel = value.trim() || 'warn')],
|
||||
]);
|
||||
|
||||
for (let i = 0; i < rest.length; i += 1) {
|
||||
const token = rest[i]!;
|
||||
const assignValue = valueFlags.get(token.includes('=') ? token.slice(0, token.indexOf('=')) : token);
|
||||
if (assignValue) {
|
||||
if (token.includes('=')) {
|
||||
assignValue(token.slice(token.indexOf('=') + 1));
|
||||
continue;
|
||||
}
|
||||
const value = rest[i + 1];
|
||||
// An option-like token is never a value: `--snapshot --force` must fail
|
||||
// loudly instead of writing a snapshot to a file named "--force". Paths
|
||||
// that really do start with "-" can still be passed as `--snapshot=-x`.
|
||||
if (value === undefined || value.startsWith('-')) {
|
||||
return { kind: 'error', message: `Missing value for ${token}.` };
|
||||
}
|
||||
assignValue(value);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (token === '--push') push = true;
|
||||
else if (token === '--pull') pull = true;
|
||||
else if (token === '--check') check = true;
|
||||
else if (token === '--force' || token === '-f') force = true;
|
||||
else if (token === '--json') json = true;
|
||||
else if (token === '--make-temp') makeTemp = true;
|
||||
else if (token.startsWith('-')) {
|
||||
return { kind: 'error', message: `Unknown sync option: ${token}` };
|
||||
} else if (host) {
|
||||
return { kind: 'error', message: `Unexpected extra argument: ${token}` };
|
||||
} else {
|
||||
host = token.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (push && pull) return { kind: 'error', message: 'Sync --push and --pull cannot be combined.' };
|
||||
if ((push || pull) && !host) {
|
||||
return { kind: 'error', message: 'Sync --push and --pull require a host.' };
|
||||
}
|
||||
if (check && !host) return { kind: 'error', message: 'Sync --check requires a host.' };
|
||||
if (check && (push || pull || snapshot || merge)) {
|
||||
return {
|
||||
kind: 'error',
|
||||
message: 'Sync --check cannot be combined with --push, --pull, --snapshot, or --merge.',
|
||||
};
|
||||
}
|
||||
const modes = [
|
||||
Boolean(host),
|
||||
Boolean(snapshot),
|
||||
Boolean(merge),
|
||||
makeTemp,
|
||||
Boolean(removeTemp),
|
||||
].filter(Boolean).length;
|
||||
if (modes === 0) {
|
||||
return { kind: 'error', message: 'Sync requires a host, --snapshot <file>, or --merge <file>.' };
|
||||
}
|
||||
if (modes > 1) {
|
||||
return {
|
||||
kind: 'error',
|
||||
message: 'Sync host, --snapshot, --merge, --make-temp, and --remove-temp cannot be combined.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'run',
|
||||
args: {
|
||||
syncHost: host,
|
||||
syncSnapshotPath: snapshot,
|
||||
syncMergePath: merge,
|
||||
syncDirection: push ? 'push' : pull ? 'pull' : 'both',
|
||||
syncRemoteCmd: remoteCmd,
|
||||
syncDbPath: dbPath,
|
||||
syncForce: force,
|
||||
syncJson: json,
|
||||
syncCheck: check,
|
||||
syncMakeTemp: makeTemp,
|
||||
syncRemoveTempPath: removeTemp,
|
||||
logLevel,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function syncCliUsage(): string {
|
||||
return [
|
||||
'SubMiner sync CLI',
|
||||
'',
|
||||
`Usage: SubMiner ${SYNC_CLI_FLAG} sync [host] [options]`,
|
||||
'',
|
||||
'Modes (exactly one):',
|
||||
' <host> Sync stats with an SSH destination (user@host or ssh alias)',
|
||||
' --snapshot <file> Write a consistent snapshot of the local stats database',
|
||||
' --merge <file> Merge a snapshot database file into the local stats database',
|
||||
' --make-temp Create a sync temp directory and print its path (used over SSH)',
|
||||
' --remove-temp <dir> Remove a sync temp directory created by --make-temp',
|
||||
'',
|
||||
'Options:',
|
||||
' --push Only merge local stats into the SSH host',
|
||||
' --pull Only merge stats from the SSH host into the local database',
|
||||
' --check Test the SSH connection and remote SubMiner availability',
|
||||
' --db <file> Override the local stats database path',
|
||||
' --remote-cmd <cmd> SubMiner app or launcher command to run on the remote host',
|
||||
' -f, --force Skip the running-app safety check',
|
||||
' --json Emit machine-readable NDJSON progress output',
|
||||
' --log-level <level> Log level',
|
||||
' --help Show this help',
|
||||
' --version Show the SubMiner version',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { resolveConfigFilePath } from '../../../config/path-resolution';
|
||||
import { parseConfigContent } from '../../../config/parse';
|
||||
import { getDefaultConfigDir } from '../../../shared/setup-state';
|
||||
|
||||
/**
|
||||
* Default immersion stats database location, shared by the launcher's history
|
||||
* command and the app's --sync-cli mode: honor a configured
|
||||
* immersionTracking.dbPath (raw main-config read, tolerant of comments), else
|
||||
* <configDir>/immersion.sqlite. Electron- and libsql-free on purpose so the
|
||||
* bun launcher can import it.
|
||||
*/
|
||||
export function resolveImmersionDbPath(): string {
|
||||
const configPath = resolveConfigFilePath({
|
||||
appDataDir: process.env.APPDATA,
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
existsSync: fs.existsSync,
|
||||
});
|
||||
let configured = '';
|
||||
try {
|
||||
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf8'));
|
||||
const tracking =
|
||||
parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>).immersionTracking
|
||||
: null;
|
||||
if (tracking && typeof tracking === 'object' && !Array.isArray(tracking)) {
|
||||
const dbPath = (tracking as Record<string, unknown>).dbPath;
|
||||
if (typeof dbPath === 'string') configured = dbPath.trim();
|
||||
}
|
||||
} catch {
|
||||
// no config or unreadable config → default location
|
||||
}
|
||||
if (configured) {
|
||||
return configured.startsWith('~')
|
||||
? path.join(os.homedir(), configured.slice(1))
|
||||
: configured;
|
||||
}
|
||||
return path.join(getDefaultConfigDir(), 'immersion.sqlite');
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Database } from '../immersion-tracker/sqlite';
|
||||
import type { SyncDbOpenOptions } from './wal-retry';
|
||||
|
||||
export interface SyncDbRunResult {
|
||||
changes: number;
|
||||
lastInsertRowid: number | bigint;
|
||||
}
|
||||
|
||||
export interface SyncDbStatement {
|
||||
run(...params: unknown[]): SyncDbRunResult;
|
||||
get(...params: unknown[]): unknown;
|
||||
all(...params: unknown[]): unknown[];
|
||||
}
|
||||
|
||||
export interface SyncDb {
|
||||
/** Prepare (or reuse a cached prepared statement for) the given SQL. */
|
||||
query(sql: string): SyncDbStatement;
|
||||
/** Execute SQL that returns no rows (pragmas, transaction control). */
|
||||
exec(sql: string): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export type SqlRow = Record<string, unknown>;
|
||||
|
||||
export function selectAll(db: SyncDb, sql: string, params: unknown[] = []): SqlRow[] {
|
||||
return db.query(sql).all(...params) as SqlRow[];
|
||||
}
|
||||
|
||||
export function selectOne(db: SyncDb, sql: string, params: unknown[] = []): SqlRow | undefined {
|
||||
return (db.query(sql).get(...params) ?? undefined) as SqlRow | undefined;
|
||||
}
|
||||
|
||||
interface LibsqlDatabase {
|
||||
prepare(sql: string): SyncDbStatement;
|
||||
exec(sql: string): unknown;
|
||||
close(): unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* libsql (better-sqlite3 API) SQLite connection for the stats-sync engine.
|
||||
* prepare() is not cached by libsql, so query() keeps a per-connection
|
||||
* statement cache because the merge prepares a handful of statements and runs them
|
||||
* once per copied row, so re-preparing would dominate merge time.
|
||||
*/
|
||||
export function openLibsqlSyncDb(dbPath: string, options: SyncDbOpenOptions): SyncDb {
|
||||
const db = new Database(dbPath, {
|
||||
readonly: options.readonly === true,
|
||||
fileMustExist: options.create !== true,
|
||||
}) as unknown as LibsqlDatabase;
|
||||
const statements = new Map<string, SyncDbStatement>();
|
||||
return {
|
||||
query(sql: string): SyncDbStatement {
|
||||
let statement = statements.get(sql);
|
||||
if (!statement) {
|
||||
statement = db.prepare(sql);
|
||||
statements.set(sql, statement);
|
||||
}
|
||||
return statement;
|
||||
},
|
||||
exec(sql: string): void {
|
||||
db.exec(sql);
|
||||
},
|
||||
close(): void {
|
||||
statements.clear();
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { insertRow, tableExists, type SyncMergeSummary } from './sync-shared.js';
|
||||
import { selectAll, selectOne, type SqlRow, type SyncDb } from './libsql-driver';
|
||||
import { insertRow, tableExists, type SyncMergeSummary } from './shared';
|
||||
|
||||
const ANIME_COPY_COLUMNS = [
|
||||
'normalized_title_key',
|
||||
@@ -90,23 +90,15 @@ const WORD_COPY_COLUMNS = [
|
||||
'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,
|
||||
local: SyncDb,
|
||||
remote: SyncDb,
|
||||
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(
|
||||
const byAnilist = local.query('SELECT anime_id FROM imm_anime WHERE anilist_id = ?');
|
||||
const byTitleKey = local.query('SELECT anime_id FROM imm_anime WHERE normalized_title_key = ?');
|
||||
const fillMissing = local.query(
|
||||
`UPDATE imm_anime
|
||||
SET
|
||||
title_romaji = COALESCE(title_romaji, ?),
|
||||
@@ -122,9 +114,8 @@ export function mergeAnime(
|
||||
`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);
|
||||
const existing = ((row.anilist_id !== null ? byAnilist.get(row.anilist_id) : undefined) ??
|
||||
byTitleKey.get(row.normalized_title_key)) as SqlRow | undefined;
|
||||
if (existing) {
|
||||
const localId = Number(existing.anime_id);
|
||||
map.set(remoteId, localId);
|
||||
@@ -153,17 +144,15 @@ export interface VideoMergeResult {
|
||||
}
|
||||
|
||||
export function mergeVideos(
|
||||
local: Database,
|
||||
remote: Database,
|
||||
local: SyncDb,
|
||||
remote: SyncDb,
|
||||
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 = ?');
|
||||
const byKey = local.query('SELECT video_id, watched FROM imm_videos WHERE video_key = ?');
|
||||
const setWatched = local.query('UPDATE imm_videos SET watched = 1 WHERE video_id = ?');
|
||||
|
||||
for (const row of selectAll(
|
||||
remote,
|
||||
@@ -172,7 +161,7 @@ export function mergeVideos(
|
||||
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);
|
||||
const existing = byKey.get(row.video_key) as SqlRow | undefined;
|
||||
if (existing) {
|
||||
const localId = Number(existing.video_id);
|
||||
videoIdMap.set(remoteId, localId);
|
||||
@@ -192,8 +181,8 @@ export function mergeVideos(
|
||||
}
|
||||
|
||||
export function mergeMediaMetadata(
|
||||
local: Database,
|
||||
remote: Database,
|
||||
local: SyncDb,
|
||||
remote: SyncDb,
|
||||
videoIdMap: Map<number, number>,
|
||||
addedVideoIds: Set<number>,
|
||||
): void {
|
||||
@@ -203,31 +192,29 @@ export function mergeMediaMetadata(
|
||||
const hasBlobStore =
|
||||
tableExists(local, 'imm_cover_art_blobs') && tableExists(remote, 'imm_cover_art_blobs');
|
||||
const copyBlob = hasBlobStore
|
||||
? local.prepare(
|
||||
? local.query(
|
||||
`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 = ?')
|
||||
? remote.query('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',
|
||||
);
|
||||
const localArtExists = local.query('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);
|
||||
const row = selectOne(
|
||||
remote,
|
||||
`SELECT ${MEDIA_ART_COPY_COLUMNS.join(', ')} FROM imm_media_art WHERE video_id = ?`,
|
||||
[remoteVideoId],
|
||||
);
|
||||
if (!row) continue;
|
||||
if (row.cover_blob_hash && copyBlob && readBlob) {
|
||||
const blob = readBlob.get(row.cover_blob_hash);
|
||||
const blob = readBlob.get(row.cover_blob_hash) as SqlRow | undefined;
|
||||
if (blob) {
|
||||
copyBlob.run(blob.blob_hash, blob.cover_blob, blob.CREATED_DATE, blob.LAST_UPDATE_DATE);
|
||||
}
|
||||
@@ -242,17 +229,17 @@ export function mergeMediaMetadata(
|
||||
}
|
||||
|
||||
if (tableExists(remote, 'imm_youtube_videos') && tableExists(local, 'imm_youtube_videos')) {
|
||||
const localYoutubeExists = local.prepare<SqlRow>(
|
||||
const localYoutubeExists = local.query(
|
||||
'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);
|
||||
const row = selectOne(
|
||||
remote,
|
||||
`SELECT ${YOUTUBE_COPY_COLUMNS.join(', ')} FROM imm_youtube_videos WHERE video_id = ?`,
|
||||
[remoteVideoId],
|
||||
);
|
||||
if (!row) continue;
|
||||
insertRow(
|
||||
local,
|
||||
@@ -265,8 +252,8 @@ export function mergeMediaMetadata(
|
||||
}
|
||||
|
||||
export function mergeExcludedWords(
|
||||
local: Database,
|
||||
remote: Database,
|
||||
local: SyncDb,
|
||||
remote: SyncDb,
|
||||
summary: SyncMergeSummary,
|
||||
): void {
|
||||
if (
|
||||
@@ -275,7 +262,7 @@ export function mergeExcludedWords(
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const insert = local.prepare(
|
||||
const insert = local.query(
|
||||
`INSERT INTO imm_stats_excluded_words (headword, word, reading, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(headword, word, reading) DO NOTHING`,
|
||||
@@ -298,9 +285,11 @@ export function mergeExcludedWords(
|
||||
/**
|
||||
* 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).
|
||||
* accumulated frequency minus any counts owed to skipped ACTIVE sessions
|
||||
* (those merge later and re-add their counts); 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 }>();
|
||||
@@ -309,28 +298,61 @@ export class LexiconResolver {
|
||||
readonly kanjiFrequencyDeltas = new Map<number, number>();
|
||||
|
||||
constructor(
|
||||
private readonly local: Database,
|
||||
private readonly remote: Database,
|
||||
private readonly local: SyncDb,
|
||||
private readonly remote: SyncDb,
|
||||
private readonly summary: SyncMergeSummary,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Occurrence counts the remote's live tracker already baked into `frequency`
|
||||
* but that belong to skipped ACTIVE sessions. Those lines are not copied this
|
||||
* merge; they are re-added via addWordOccurrences/addKanjiOccurrences when
|
||||
* the session finalizes and syncs, so a newly adopted row must not carry them
|
||||
* or that slice would be counted twice.
|
||||
*/
|
||||
private pendingActiveSessionOccurrences(
|
||||
occurrenceTable: 'imm_word_line_occurrences' | 'imm_kanji_line_occurrences',
|
||||
idColumn: 'word_id' | 'kanji_id',
|
||||
remoteId: number,
|
||||
): number {
|
||||
const row = selectOne(
|
||||
this.remote,
|
||||
`SELECT COALESCE(SUM(o.occurrence_count), 0) AS pending
|
||||
FROM ${occurrenceTable} o
|
||||
JOIN imm_subtitle_lines l ON l.line_id = o.line_id
|
||||
JOIN imm_sessions s ON s.session_id = l.session_id
|
||||
WHERE o.${idColumn} = ? AND s.ended_at_ms IS NULL`,
|
||||
[remoteId],
|
||||
);
|
||||
return Number(row?.pending ?? 0);
|
||||
}
|
||||
|
||||
private adoptedFrequency(frequency: unknown, pending: number): unknown {
|
||||
if (pending <= 0 || typeof frequency !== 'number') return frequency;
|
||||
return Math.max(0, frequency - pending);
|
||||
}
|
||||
|
||||
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);
|
||||
const row = selectOne(
|
||||
this.remote,
|
||||
`SELECT ${WORD_COPY_COLUMNS.join(', ')} FROM imm_words WHERE id = ?`,
|
||||
[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);
|
||||
const existing = selectOne(
|
||||
this.local,
|
||||
'SELECT id FROM imm_words WHERE headword IS ? AND word IS ? AND reading IS ?',
|
||||
[row.headword, row.word, row.reading],
|
||||
);
|
||||
let entry: { localId: number; isNew: boolean };
|
||||
if (existing) {
|
||||
entry = { localId: Number(existing.id), isNew: false };
|
||||
this.local
|
||||
.prepare(
|
||||
.query(
|
||||
`UPDATE imm_words
|
||||
SET first_seen = MIN(COALESCE(first_seen, ?), COALESCE(?, first_seen)),
|
||||
last_seen = MAX(COALESCE(last_seen, ?), COALESCE(?, last_seen))
|
||||
@@ -338,11 +360,18 @@ export class LexiconResolver {
|
||||
)
|
||||
.run(row.first_seen, row.first_seen, row.last_seen, row.last_seen, entry.localId);
|
||||
} else {
|
||||
const pending = this.pendingActiveSessionOccurrences(
|
||||
'imm_word_line_occurrences',
|
||||
'word_id',
|
||||
remoteWordId,
|
||||
);
|
||||
const localId = insertRow(
|
||||
this.local,
|
||||
'imm_words',
|
||||
WORD_COPY_COLUMNS,
|
||||
WORD_COPY_COLUMNS.map((column) => row[column]),
|
||||
WORD_COPY_COLUMNS.map((column) =>
|
||||
column === 'frequency' ? this.adoptedFrequency(row[column], pending) : row[column],
|
||||
),
|
||||
);
|
||||
entry = { localId, isNew: true };
|
||||
this.summary.wordsAdded += 1;
|
||||
@@ -355,19 +384,21 @@ export class LexiconResolver {
|
||||
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);
|
||||
const row = selectOne(
|
||||
this.remote,
|
||||
'SELECT kanji, first_seen, last_seen, frequency FROM imm_kanji WHERE id = ?',
|
||||
[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);
|
||||
const existing = selectOne(this.local, 'SELECT id FROM imm_kanji WHERE kanji IS ?', [
|
||||
row.kanji,
|
||||
]);
|
||||
let entry: { localId: number; isNew: boolean };
|
||||
if (existing) {
|
||||
entry = { localId: Number(existing.id), isNew: false };
|
||||
this.local
|
||||
.prepare(
|
||||
.query(
|
||||
`UPDATE imm_kanji
|
||||
SET first_seen = MIN(COALESCE(first_seen, ?), COALESCE(?, first_seen)),
|
||||
last_seen = MAX(COALESCE(last_seen, ?), COALESCE(?, last_seen))
|
||||
@@ -375,11 +406,16 @@ export class LexiconResolver {
|
||||
)
|
||||
.run(row.first_seen, row.first_seen, row.last_seen, row.last_seen, entry.localId);
|
||||
} else {
|
||||
const pending = this.pendingActiveSessionOccurrences(
|
||||
'imm_kanji_line_occurrences',
|
||||
'kanji_id',
|
||||
remoteKanjiId,
|
||||
);
|
||||
const localId = insertRow(
|
||||
this.local,
|
||||
'imm_kanji',
|
||||
['kanji', 'first_seen', 'last_seen', 'frequency'],
|
||||
[row.kanji, row.first_seen, row.last_seen, row.frequency],
|
||||
[row.kanji, row.first_seen, row.last_seen, this.adoptedFrequency(row.frequency, pending)],
|
||||
);
|
||||
entry = { localId, isNew: true };
|
||||
this.summary.kanjiAdded += 1;
|
||||
@@ -407,13 +443,13 @@ export class LexiconResolver {
|
||||
}
|
||||
|
||||
applyFrequencyDeltas(): void {
|
||||
const updateWord = this.local.prepare(
|
||||
const updateWord = this.local.query(
|
||||
'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(
|
||||
const updateKanji = this.local.query(
|
||||
'UPDATE imm_kanji SET frequency = COALESCE(frequency, 0) + ? WHERE id = ?',
|
||||
);
|
||||
for (const [localId, delta] of this.kanjiFrequencyDeltas) {
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { nowDbTimestamp, tableExists, type SyncMergeSummary } from './sync-shared.js';
|
||||
|
||||
type SqlRow = Record<string, unknown>;
|
||||
import { selectAll, type SqlRow, type SyncDb } from './libsql-driver';
|
||||
import { nowDbTimestamp, tableExists, type SyncMergeSummary } from './shared';
|
||||
|
||||
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)`;
|
||||
@@ -125,7 +123,7 @@ const MONTHLY_ROLLUP_UPSERT = `
|
||||
* the app later, which is idempotent.
|
||||
*/
|
||||
export function refreshRollupsForNewSessions(
|
||||
local: Database,
|
||||
local: SyncDb,
|
||||
newSessionIds: number[],
|
||||
summary: SyncMergeSummary,
|
||||
): void {
|
||||
@@ -134,12 +132,12 @@ export function refreshRollupsForNewSessions(
|
||||
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);
|
||||
const rows = selectAll(
|
||||
local,
|
||||
`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(',')})`,
|
||||
chunk,
|
||||
);
|
||||
for (const row of rows) {
|
||||
const day = Number(row.rollup_day);
|
||||
const month = Number(row.rollup_month);
|
||||
@@ -149,14 +147,12 @@ export function refreshRollupsForNewSessions(
|
||||
}
|
||||
|
||||
const stampMs = nowDbTimestamp();
|
||||
const deleteDaily = local.prepare(
|
||||
'DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?',
|
||||
);
|
||||
const deleteMonthly = local.prepare(
|
||||
const deleteDaily = local.query('DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?');
|
||||
const deleteMonthly = local.query(
|
||||
'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 upsertDaily = local.query(DAILY_ROLLUP_UPSERT);
|
||||
const upsertMonthly = local.query(MONTHLY_ROLLUP_UPSERT);
|
||||
|
||||
const monthlyGroups = new Set<string>();
|
||||
for (const { day, month, videoId } of groups.values()) {
|
||||
@@ -181,36 +177,39 @@ export function refreshRollupsForNewSessions(
|
||||
* double-counting sessions that earlier syncs already shared.
|
||||
*/
|
||||
export function copyRemoteOnlyRollups(
|
||||
local: Database,
|
||||
remote: Database,
|
||||
local: SyncDb,
|
||||
remote: SyncDb,
|
||||
videoIdMap: Map<number, number>,
|
||||
summary: SyncMergeSummary,
|
||||
): void {
|
||||
if (!tableExists(remote, 'imm_daily_rollups') || !tableExists(local, 'imm_daily_rollups')) return;
|
||||
|
||||
const localDailyExists = local.prepare(
|
||||
const localDailyExists = local.query(
|
||||
'SELECT 1 FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ? LIMIT 1',
|
||||
);
|
||||
const localDaySessions = local.prepare(
|
||||
const localDaySessions = local.query(
|
||||
`SELECT 1 FROM imm_sessions WHERE video_id = ? AND ${LOCAL_DAY_EXPR} = ? LIMIT 1`,
|
||||
);
|
||||
const localMonthSessions = local.prepare(
|
||||
const localMonthSessions = local.query(
|
||||
`SELECT 1 FROM imm_sessions WHERE video_id = ? AND ${LOCAL_MONTH_EXPR} = ? LIMIT 1`,
|
||||
);
|
||||
const localMonthSessionsForDay = local.prepare(
|
||||
// rollup_day is a *local* epoch day, so anchor it at local noon (+43200)
|
||||
// before reading its month back: plain UTC midnight lands in the previous
|
||||
// civil month for the 1st of a month at any negative UTC offset.
|
||||
const localMonthSessionsForDay = local.query(
|
||||
`SELECT 1 FROM imm_sessions
|
||||
WHERE video_id = ?
|
||||
AND ${LOCAL_MONTH_EXPR} = CAST(strftime('%Y%m', CAST(? AS INTEGER) * 86400, 'unixepoch', 'localtime') AS INTEGER)
|
||||
AND ${LOCAL_MONTH_EXPR} = CAST(strftime('%Y%m', CAST(? AS INTEGER) * 86400 + 43200, 'unixepoch', 'localtime') AS INTEGER)
|
||||
LIMIT 1`,
|
||||
);
|
||||
const insertDaily = local.prepare(
|
||||
const insertDaily = local.query(
|
||||
`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()) {
|
||||
for (const row of selectAll(remote, 'SELECT * FROM imm_daily_rollups')) {
|
||||
if (row.video_id === null) continue;
|
||||
const localVideoId = videoIdMap.get(Number(row.video_id));
|
||||
if (localVideoId === undefined) continue;
|
||||
@@ -234,16 +233,16 @@ export function copyRemoteOnlyRollups(
|
||||
summary.dailyRollupsCopied += 1;
|
||||
}
|
||||
|
||||
const localMonthlyExists = local.prepare(
|
||||
const localMonthlyExists = local.query(
|
||||
'SELECT 1 FROM imm_monthly_rollups WHERE rollup_month = ? AND video_id = ? LIMIT 1',
|
||||
);
|
||||
const insertMonthly = local.prepare(
|
||||
const insertMonthly = local.query(
|
||||
`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()) {
|
||||
for (const row of selectAll(remote, 'SELECT * FROM imm_monthly_rollups')) {
|
||||
if (row.video_id === null) continue;
|
||||
const localVideoId = videoIdMap.get(Number(row.video_id));
|
||||
if (localVideoId === undefined) continue;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import type { LexiconResolver } from './merge-catalog.js';
|
||||
import { insertRow, nowDbTimestamp, type SyncMergeSummary } from './sync-shared.js';
|
||||
import type { LexiconResolver } from './merge-catalog';
|
||||
import { selectAll, selectOne, type SqlRow, type SyncDb } from './libsql-driver';
|
||||
import { insertRow, nowDbTimestamp, type SyncMergeSummary } from './shared';
|
||||
|
||||
const SESSION_COPY_COLUMNS = [
|
||||
'session_uuid',
|
||||
@@ -71,32 +71,27 @@ const LINE_COPY_COLUMNS = [
|
||||
'LAST_UPDATE_DATE',
|
||||
] as const;
|
||||
|
||||
type SqlRow = Record<string, unknown>;
|
||||
|
||||
export interface SessionMergeResult {
|
||||
newSessionIds: number[];
|
||||
}
|
||||
|
||||
export function mergeSessions(
|
||||
local: Database,
|
||||
remote: Database,
|
||||
local: SyncDb,
|
||||
remote: SyncDb,
|
||||
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 uuidExists = local.query('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();
|
||||
const remoteSessions = selectAll(
|
||||
remote,
|
||||
`SELECT session_id, video_id, ${SESSION_COPY_COLUMNS.join(', ')}
|
||||
FROM imm_sessions
|
||||
ORDER BY CAST(started_at_ms AS REAL) ASC, session_id ASC`,
|
||||
);
|
||||
|
||||
for (const session of remoteSessions) {
|
||||
if (session.ended_at_ms === null) {
|
||||
@@ -111,7 +106,9 @@ export function mergeSessions(
|
||||
}
|
||||
const localVideoId = videoIdMap.get(Number(session.video_id));
|
||||
if (localVideoId === undefined) {
|
||||
throw new Error(`Snapshot session ${String(session.session_uuid)} references missing video row`);
|
||||
throw new Error(
|
||||
`Snapshot session ${String(session.session_uuid)} references missing video row`,
|
||||
);
|
||||
}
|
||||
|
||||
const localSessionId = insertRow(
|
||||
@@ -144,18 +141,18 @@ export function mergeSessions(
|
||||
}
|
||||
|
||||
function copyTelemetry(
|
||||
local: Database,
|
||||
remote: Database,
|
||||
local: SyncDb,
|
||||
remote: SyncDb,
|
||||
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);
|
||||
const rows = selectAll(
|
||||
remote,
|
||||
`SELECT ${TELEMETRY_COPY_COLUMNS.join(', ')} FROM imm_session_telemetry
|
||||
WHERE session_id = ? ORDER BY telemetry_id ASC`,
|
||||
[remoteSessionId],
|
||||
);
|
||||
for (const row of rows) {
|
||||
insertRow(
|
||||
local,
|
||||
@@ -168,19 +165,19 @@ function copyTelemetry(
|
||||
}
|
||||
|
||||
function copyEvents(
|
||||
local: Database,
|
||||
remote: Database,
|
||||
local: SyncDb,
|
||||
remote: SyncDb,
|
||||
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);
|
||||
const rows = selectAll(
|
||||
remote,
|
||||
`SELECT event_id, ${EVENT_COPY_COLUMNS.join(', ')} FROM imm_session_events
|
||||
WHERE session_id = ? ORDER BY event_id ASC`,
|
||||
[remoteSessionId],
|
||||
);
|
||||
for (const row of rows) {
|
||||
const localEventId = insertRow(
|
||||
local,
|
||||
@@ -195,8 +192,8 @@ function copyEvents(
|
||||
}
|
||||
|
||||
function copySubtitleLines(
|
||||
local: Database,
|
||||
remote: Database,
|
||||
local: SyncDb,
|
||||
remote: SyncDb,
|
||||
remoteSessionId: number,
|
||||
localSessionId: number,
|
||||
localVideoId: number,
|
||||
@@ -205,30 +202,32 @@ function copySubtitleLines(
|
||||
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>(
|
||||
const rows = selectAll(
|
||||
remote,
|
||||
`SELECT line_id, event_id, anime_id, ${LINE_COPY_COLUMNS.join(', ')} FROM imm_subtitle_lines
|
||||
WHERE session_id = ? ORDER BY line_id ASC`,
|
||||
[remoteSessionId],
|
||||
);
|
||||
const wordOccurrences = remote.query(
|
||||
'SELECT word_id, occurrence_count FROM imm_word_line_occurrences WHERE line_id = ?',
|
||||
);
|
||||
const kanjiOccurrences = remote.prepare<SqlRow>(
|
||||
const kanjiOccurrences = remote.query(
|
||||
'SELECT kanji_id, occurrence_count FROM imm_kanji_line_occurrences WHERE line_id = ?',
|
||||
);
|
||||
const insertWordOccurrence = local.prepare(
|
||||
const insertWordOccurrence = local.query(
|
||||
`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(
|
||||
const insertKanjiOccurrence = local.query(
|
||||
`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 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',
|
||||
@@ -243,13 +242,13 @@ function copySubtitleLines(
|
||||
);
|
||||
summary.subtitleLinesAdded += 1;
|
||||
|
||||
for (const occurrence of wordOccurrences.all(row.line_id)) {
|
||||
for (const occurrence of wordOccurrences.all(row.line_id) as SqlRow[]) {
|
||||
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)) {
|
||||
for (const occurrence of kanjiOccurrences.all(row.line_id) as SqlRow[]) {
|
||||
const localKanjiId = lexicon.resolveKanji(Number(occurrence.kanji_id));
|
||||
const count = Number(occurrence.occurrence_count);
|
||||
insertKanjiOccurrence.run(localLineId, localKanjiId, count);
|
||||
@@ -267,14 +266,14 @@ function copySubtitleLines(
|
||||
* applied, even if the merged session started earlier that day.
|
||||
*/
|
||||
function applyMergedSessionLifetime(
|
||||
local: Database,
|
||||
local: SyncDb,
|
||||
sessionId: number,
|
||||
videoId: number,
|
||||
session: SqlRow,
|
||||
): void {
|
||||
const updatedAtMs = nowDbTimestamp();
|
||||
const applied = local
|
||||
.prepare(
|
||||
.query(
|
||||
`INSERT INTO imm_lifetime_applied_sessions (session_id, applied_at_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(session_id) DO NOTHING`,
|
||||
@@ -282,15 +281,15 @@ function applyMergedSessionLifetime(
|
||||
.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 telemetry = selectOne(
|
||||
local,
|
||||
`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`,
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
const metric = (telemetryValue: unknown, sessionValue: unknown): number => {
|
||||
const fromTelemetry = telemetry ? Number(telemetryValue) : Number.NaN;
|
||||
@@ -302,15 +301,18 @@ function applyMergedSessionLifetime(
|
||||
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 video = selectOne(local, 'SELECT anime_id, watched FROM imm_videos WHERE video_id = ?', [
|
||||
videoId,
|
||||
]);
|
||||
const watched = Number(video?.watched ?? 0);
|
||||
const animeId = video?.anime_id === null || video?.anime_id === undefined ? null : Number(video.anime_id);
|
||||
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 mediaLifetime = selectOne(
|
||||
local,
|
||||
'SELECT completed FROM imm_lifetime_media WHERE video_id = ?',
|
||||
[videoId],
|
||||
);
|
||||
const hasOtherSessionForVideo = Boolean(
|
||||
local
|
||||
.query('SELECT 1 FROM imm_sessions WHERE video_id = ? AND session_id != ? LIMIT 1')
|
||||
@@ -333,16 +335,19 @@ function applyMergedSessionLifetime(
|
||||
|
||||
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 animeLifetime = selectOne(
|
||||
local,
|
||||
'SELECT episodes_completed FROM imm_lifetime_anime WHERE anime_id = ?',
|
||||
[animeId],
|
||||
);
|
||||
const anime = selectOne(local, 'SELECT episodes_total FROM imm_anime WHERE anime_id = ?', [
|
||||
animeId,
|
||||
]);
|
||||
const episodesCompletedBefore = Number(animeLifetime?.episodes_completed ?? 0);
|
||||
const episodesTotal = anime?.episodes_total === null || anime?.episodes_total === undefined
|
||||
? null
|
||||
: Number(anime.episodes_total);
|
||||
const episodesTotal =
|
||||
anime?.episodes_total === null || anime?.episodes_total === undefined
|
||||
? null
|
||||
: Number(anime.episodes_total);
|
||||
if (
|
||||
episodesTotal !== null &&
|
||||
episodesTotal > 0 &&
|
||||
@@ -354,7 +359,7 @@ function applyMergedSessionLifetime(
|
||||
}
|
||||
|
||||
local
|
||||
.prepare(
|
||||
.query(
|
||||
`UPDATE imm_lifetime_global
|
||||
SET total_sessions = total_sessions + 1,
|
||||
total_active_ms = total_active_ms + ?,
|
||||
@@ -377,7 +382,7 @@ function applyMergedSessionLifetime(
|
||||
);
|
||||
|
||||
local
|
||||
.prepare(
|
||||
.query(
|
||||
`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
|
||||
@@ -419,7 +424,7 @@ function applyMergedSessionLifetime(
|
||||
|
||||
if (animeId !== null) {
|
||||
local
|
||||
.prepare(
|
||||
.query(
|
||||
`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,
|
||||
@@ -1,22 +1,21 @@
|
||||
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';
|
||||
} from './merge-catalog';
|
||||
import { mergeSessions } from './merge-sessions';
|
||||
import { copyRemoteOnlyRollups, refreshRollupsForNewSessions } from './merge-rollups';
|
||||
import {
|
||||
assertMergeableSchema,
|
||||
createEmptyMergeSummary,
|
||||
type SyncMergeSummary,
|
||||
} from './sync-shared.js';
|
||||
} from './shared';
|
||||
import { openLibsqlSyncDb, type SyncDb } from './libsql-driver';
|
||||
|
||||
export type { SyncMergeSummary } from './sync-shared.js';
|
||||
export { createDbSnapshot, findLiveStatsDaemonPid } from './sync-shared.js';
|
||||
export type { SyncMergeSummary } from './shared';
|
||||
|
||||
/**
|
||||
* Merge a snapshot of another machine's immersion database into the local
|
||||
@@ -34,10 +33,10 @@ export function mergeSnapshotIntoDb(localDbPath: string, snapshotPath: string):
|
||||
throw new Error(`Snapshot database not found: ${snapshotPath}`);
|
||||
}
|
||||
|
||||
const remote = new Database(snapshotPath, { readonly: true });
|
||||
let local: Database;
|
||||
const remote = openLibsqlSyncDb(snapshotPath, { readonly: true });
|
||||
let local: SyncDb;
|
||||
try {
|
||||
local = new Database(localDbPath, { readwrite: true, create: false });
|
||||
local = openLibsqlSyncDb(localDbPath, { create: false });
|
||||
} catch (error) {
|
||||
remote.close();
|
||||
throw error;
|
||||
@@ -47,9 +46,9 @@ export function mergeSnapshotIntoDb(localDbPath: string, snapshotPath: string):
|
||||
assertMergeableSchema(local, 'Local');
|
||||
|
||||
const summary = createEmptyMergeSummary();
|
||||
local.run('PRAGMA foreign_keys = ON');
|
||||
local.run('PRAGMA busy_timeout = 5000');
|
||||
local.run('BEGIN IMMEDIATE');
|
||||
local.exec('PRAGMA foreign_keys = ON');
|
||||
local.exec('PRAGMA busy_timeout = 5000');
|
||||
local.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const animeIdMap = mergeAnime(local, remote, summary);
|
||||
const { videoIdMap, addedVideoIds } = mergeVideos(local, remote, animeIdMap, summary);
|
||||
@@ -69,10 +68,10 @@ export function mergeSnapshotIntoDb(localDbPath: string, snapshotPath: string):
|
||||
refreshRollupsForNewSessions(local, newSessionIds, summary);
|
||||
copyRemoteOnlyRollups(local, remote, videoIdMap, summary);
|
||||
|
||||
local.run('COMMIT');
|
||||
local.exec('COMMIT');
|
||||
return summary;
|
||||
} catch (error) {
|
||||
local.run('ROLLBACK');
|
||||
local.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
@@ -1,29 +1,15 @@
|
||||
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';
|
||||
import { SCHEMA_VERSION } from '../immersion-tracker/types';
|
||||
import { getDefaultConfigDir } from '../../../shared/setup-state';
|
||||
import { withReadonlyWalRetry } from './wal-retry';
|
||||
import { openLibsqlSyncDb, selectOne, type SyncDb } from './libsql-driver';
|
||||
|
||||
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;
|
||||
}
|
||||
import type { SyncMergeSummary } from '../../../shared/sync/sync-events';
|
||||
export type { SyncMergeSummary };
|
||||
|
||||
export function createEmptyMergeSummary(): SyncMergeSummary {
|
||||
return {
|
||||
@@ -48,23 +34,19 @@ export function nowDbTimestamp(): string {
|
||||
return String(Date.now());
|
||||
}
|
||||
|
||||
export function tableExists(db: Database, tableName: string): boolean {
|
||||
export function tableExists(db: SyncDb, 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 {
|
||||
function readSchemaVersion(db: SyncDb): 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();
|
||||
const row = selectOne(db, 'SELECT MAX(schema_version) AS schema_version FROM imm_schema_version');
|
||||
return typeof row?.schema_version === 'number' ? row.schema_version : null;
|
||||
}
|
||||
|
||||
export function assertMergeableSchema(db: Database, label: string): void {
|
||||
export function assertMergeableSchema(db: SyncDb, label: string): void {
|
||||
const version = readSchemaVersion(db);
|
||||
if (version === null) {
|
||||
throw new Error(
|
||||
@@ -73,7 +55,7 @@ export function assertMergeableSchema(db: Database, label: string): void {
|
||||
}
|
||||
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.`,
|
||||
`${label} database is at schema version ${version} but this SubMiner install 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']) {
|
||||
@@ -84,14 +66,14 @@ export function assertMergeableSchema(db: Database, label: string): void {
|
||||
}
|
||||
|
||||
export function insertRow(
|
||||
db: Database,
|
||||
db: SyncDb,
|
||||
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.
|
||||
// query() caches the prepared statement per SQL string; this runs once per
|
||||
// copied row, so re-preparing each time would dominate merge time.
|
||||
const result = db.query(sql).run(...values);
|
||||
return Number(result.lastInsertRowid);
|
||||
}
|
||||
@@ -103,10 +85,10 @@ export function createDbSnapshot(dbPath: string, outPath: string): void {
|
||||
fs.rmSync(outPath, { force: true });
|
||||
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
||||
withReadonlyWalRetry(dbPath, (options) => {
|
||||
const db = new Database(dbPath, options);
|
||||
const db = openLibsqlSyncDb(dbPath, options);
|
||||
try {
|
||||
assertMergeableSchema(db, 'Local');
|
||||
db.prepare('VACUUM INTO ?').run(outPath);
|
||||
db.query('VACUUM INTO ?').run(outPath);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
@@ -131,16 +113,11 @@ function isProcessAlive(pid: number): boolean {
|
||||
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'));
|
||||
candidates.add(path.join(getDefaultConfigDir(), 'stats-daemon.json'));
|
||||
if (process.platform === 'darwin') {
|
||||
candidates.add(path.join(homeDir, 'Library', 'Application Support', 'SubMiner', 'stats-daemon.json'));
|
||||
candidates.add(
|
||||
path.join(homeDir, 'Library', 'Application Support', 'SubMiner', 'stats-daemon.json'),
|
||||
);
|
||||
}
|
||||
return [...candidates];
|
||||
}
|
||||
@@ -149,7 +126,7 @@ function statsDaemonStateCandidates(dbPath: string): string[] {
|
||||
* 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.
|
||||
* in the sync flow.
|
||||
*/
|
||||
export function findLiveStatsDaemonPid(dbPath: string): number | null {
|
||||
for (const statePath of statsDaemonStateCandidates(dbPath)) {
|
||||
@@ -0,0 +1,191 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
assertSafeSshHost,
|
||||
detectRemoteShellFlavor,
|
||||
quoteForRemoteShell,
|
||||
resolveRemoteSubminerCommand,
|
||||
runScp,
|
||||
shellQuote,
|
||||
type RemoteRunResult,
|
||||
} from './ssh';
|
||||
|
||||
function remoteResult(status: number, stdout = ''): RemoteRunResult {
|
||||
return { status, stdout, stderr: '' };
|
||||
}
|
||||
|
||||
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, 'posix', (host, remoteCommand) => {
|
||||
calls.push({ host, remoteCommand });
|
||||
return remoteResult(0);
|
||||
});
|
||||
|
||||
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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('resolveRemoteSubminerCommand falls back to the app binary in --sync-cli mode', () => {
|
||||
const probed: string[] = [];
|
||||
const command = resolveRemoteSubminerCommand(
|
||||
'media-box',
|
||||
null,
|
||||
'posix',
|
||||
(_host, remoteCommand) => {
|
||||
probed.push(remoteCommand);
|
||||
return remoteResult(remoteCommand.includes('SubMiner --sync-cli') ? 0 : 1);
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(command, / SubMiner --sync-cli$/);
|
||||
// Launcher candidates (PATH + ~/.local/bin) are tried before app binaries.
|
||||
assert.equal(probed.length, 3);
|
||||
assert.match(probed[0]!, / subminer --help /);
|
||||
assert.match(probed[1]!, / ~\/\.local\/bin\/subminer --help /);
|
||||
});
|
||||
|
||||
test('resolveRemoteSubminerCommand probes a user override as app first, then launcher', () => {
|
||||
const asApp = resolveRemoteSubminerCommand(
|
||||
'media-box',
|
||||
'/opt/SubMiner.AppImage',
|
||||
'posix',
|
||||
(_host, cmd) => remoteResult(cmd.includes('--sync-cli') ? 0 : 1),
|
||||
);
|
||||
assert.match(asApp, /'\/opt\/SubMiner\.AppImage' --sync-cli$/);
|
||||
|
||||
const asLauncher = resolveRemoteSubminerCommand(
|
||||
'media-box',
|
||||
'/opt/subminer',
|
||||
'posix',
|
||||
(_host, cmd) => remoteResult(cmd.includes('--sync-cli') ? 1 : 0),
|
||||
);
|
||||
assert.match(asLauncher, /'\/opt\/subminer'$/);
|
||||
|
||||
assert.throws(
|
||||
() => resolveRemoteSubminerCommand('media-box', '/missing', 'posix', () => remoteResult(127)),
|
||||
/Remote command not found on media-box: \/missing/,
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveRemoteSubminerCommand probes Windows install locations without a PATH prefix', () => {
|
||||
const probed: string[] = [];
|
||||
const command = resolveRemoteSubminerCommand('win-box', null, 'windows-cmd', (_host, cmd) => {
|
||||
probed.push(cmd);
|
||||
return remoteResult(cmd.includes('Programs\\SubMiner\\SubMiner.exe') ? 0 : 1);
|
||||
});
|
||||
|
||||
assert.equal(command, '"%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe" --sync-cli');
|
||||
assert.equal(probed[0], 'subminer --help');
|
||||
assert.equal(probed[1], '"%LOCALAPPDATA%\\SubMiner\\bin\\subminer.cmd" --help');
|
||||
|
||||
const powershell = resolveRemoteSubminerCommand(
|
||||
'win-box',
|
||||
null,
|
||||
'windows-powershell',
|
||||
(_host, cmd) => remoteResult(cmd.includes('Programs\\SubMiner\\SubMiner.exe') ? 0 : 1),
|
||||
);
|
||||
assert.equal(powershell, '& "$env:LOCALAPPDATA\\Programs\\SubMiner\\SubMiner.exe" --sync-cli');
|
||||
});
|
||||
|
||||
test('resolveRemoteSubminerCommand quotes Windows overrides with double quotes', () => {
|
||||
const command = resolveRemoteSubminerCommand(
|
||||
'win-box',
|
||||
'C:/Apps/SubMiner/SubMiner.exe',
|
||||
'windows-cmd',
|
||||
(_host, cmd) => remoteResult(cmd.includes('--sync-cli') ? 0 : 1),
|
||||
);
|
||||
assert.equal(command, '"C:/Apps/SubMiner/SubMiner.exe" --sync-cli');
|
||||
});
|
||||
|
||||
test('detectRemoteShellFlavor identifies posix, cmd, and powershell remotes', () => {
|
||||
assert.equal(
|
||||
detectRemoteShellFlavor('linux-box', (_host, cmd) =>
|
||||
cmd === 'uname -s' ? remoteResult(0, 'Linux\n') : remoteResult(1),
|
||||
),
|
||||
'posix',
|
||||
);
|
||||
assert.equal(
|
||||
detectRemoteShellFlavor('win-box', (_host, cmd) => {
|
||||
if (cmd === 'uname -s') return remoteResult(1);
|
||||
if (cmd === 'echo %OS%') return remoteResult(0, 'Windows_NT\r\n');
|
||||
return remoteResult(1);
|
||||
}),
|
||||
'windows-cmd',
|
||||
);
|
||||
assert.equal(
|
||||
detectRemoteShellFlavor('ps-box', (_host, cmd) => {
|
||||
if (cmd === 'uname -s') return remoteResult(1);
|
||||
if (cmd === 'echo %OS%') return remoteResult(0, '%OS%\r\n');
|
||||
if (cmd === 'echo $env:OS') return remoteResult(0, 'Windows_NT\r\n');
|
||||
return remoteResult(1);
|
||||
}),
|
||||
'windows-powershell',
|
||||
);
|
||||
// Unidentifiable remotes keep the pre-detection POSIX behavior.
|
||||
assert.equal(
|
||||
detectRemoteShellFlavor('odd-box', () => remoteResult(1)),
|
||||
'posix',
|
||||
);
|
||||
});
|
||||
|
||||
test('quoteForRemoteShell quotes per flavor and rejects unsafe Windows values', () => {
|
||||
assert.equal(quoteForRemoteShell('posix', "/tmp/it's"), `'/tmp/it'\\''s'`);
|
||||
assert.equal(
|
||||
quoteForRemoteShell('windows-cmd', 'C:/Users/First Last/AppData/Local/Temp/subminer-sync-ab'),
|
||||
'"C:/Users/First Last/AppData/Local/Temp/subminer-sync-ab"',
|
||||
);
|
||||
assert.throws(() => quoteForRemoteShell('windows-cmd', 'a"b'), /Refusing to quote/);
|
||||
assert.throws(() => quoteForRemoteShell('windows-cmd', 'C:/tmp/%TEMP%/db.sqlite'), /percent/);
|
||||
assert.throws(() => quoteForRemoteShell('windows-powershell', 'a\nb'), /Refusing to quote/);
|
||||
});
|
||||
|
||||
test('quoteForRemoteShell does not let PowerShell expand a quoted value', () => {
|
||||
// PowerShell expands $(...) and $var inside double quotes, so a single-quoted
|
||||
// literal is the only safe form; '' is the escape for an embedded quote.
|
||||
assert.equal(
|
||||
quoteForRemoteShell('windows-powershell', 'C:/tmp/$(calc.exe)'),
|
||||
`'C:/tmp/$(calc.exe)'`,
|
||||
);
|
||||
assert.equal(quoteForRemoteShell('windows-powershell', "C:/tmp/it's"), `'C:/tmp/it''s'`);
|
||||
assert.equal(
|
||||
quoteForRemoteShell('windows-powershell', 'C:/Users/First Last/Temp/subminer-sync-ab'),
|
||||
`'C:/Users/First Last/Temp/subminer-sync-ab'`,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { SYNC_CLI_FLAG } from './cli-args';
|
||||
|
||||
export interface RemoteRunResult {
|
||||
status: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export interface RunSshOptions {
|
||||
batchMode?: boolean;
|
||||
connectTimeoutSeconds?: number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
options: RunSshOptions = {},
|
||||
): RemoteRunResult {
|
||||
assertSafeSshHost(host);
|
||||
const args: string[] = [];
|
||||
if (options.batchMode) args.push('-o', 'BatchMode=yes');
|
||||
if (options.connectTimeoutSeconds !== undefined) {
|
||||
args.push('-o', `ConnectTimeout=${options.connectTimeoutSeconds}`);
|
||||
}
|
||||
args.push(host, remoteCommand);
|
||||
const result = spawnSync('ssh', args, {
|
||||
encoding: 'utf8',
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
timeout: options.timeoutMs,
|
||||
});
|
||||
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 {
|
||||
if (/^[A-Za-z]:[\\/]/.test(endpoint)) return;
|
||||
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("'", `'\\''`)}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shell that Windows OpenSSH hands remote commands to (cmd.exe by
|
||||
* default, PowerShell when DefaultShell is changed); it decides quoting,
|
||||
* environment-variable expansion, and which SubMiner install paths to probe.
|
||||
*/
|
||||
export type RemoteShellFlavor = 'posix' | 'windows-cmd' | 'windows-powershell';
|
||||
|
||||
/**
|
||||
* Identify the remote shell with probes that are harmless everywhere:
|
||||
* `uname -s` only succeeds on a POSIX shell, `echo %OS%` only expands under
|
||||
* cmd.exe, and `echo $env:OS` only expands under PowerShell. Defaults to
|
||||
* posix so unreachable/odd hosts fail with the familiar POSIX errors.
|
||||
*/
|
||||
export function detectRemoteShellFlavor(
|
||||
host: string,
|
||||
runRemote: (host: string, remoteCommand: string) => RemoteRunResult,
|
||||
): RemoteShellFlavor {
|
||||
const posixProbe = runRemote(host, 'uname -s');
|
||||
if (posixProbe.status === 0 && posixProbe.stdout.trim().length > 0) return 'posix';
|
||||
const cmdProbe = runRemote(host, 'echo %OS%');
|
||||
if (cmdProbe.status === 0 && cmdProbe.stdout.includes('Windows_NT')) return 'windows-cmd';
|
||||
const powershellProbe = runRemote(host, 'echo $env:OS');
|
||||
if (powershellProbe.status === 0 && powershellProbe.stdout.includes('Windows_NT')) {
|
||||
return 'windows-powershell';
|
||||
}
|
||||
return 'posix';
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote one argument for the detected remote shell. PowerShell expands $(...)
|
||||
* and $var inside double quotes, so it gets a single-quoted literal ('' escapes
|
||||
* a quote). cmd.exe has no single-quote form and treats ' literally, so it keeps
|
||||
* double quotes and rejects values carrying a double quote of their own.
|
||||
*/
|
||||
export function quoteForRemoteShell(flavor: RemoteShellFlavor, value: string): string {
|
||||
if (flavor === 'posix') return shellQuote(value);
|
||||
if (/[\r\n]/.test(value)) {
|
||||
throw new Error(`Refusing to quote a value with newlines for a Windows shell: ${value}`);
|
||||
}
|
||||
if (flavor === 'windows-powershell') {
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
if (value.includes('"')) {
|
||||
throw new Error(`Refusing to quote a value with quotes for a Windows shell: ${value}`);
|
||||
}
|
||||
if (value.includes('%')) {
|
||||
throw new Error(`Refusing to quote a value with percent signs for cmd.exe: ${value}`);
|
||||
}
|
||||
return `"${value}"`;
|
||||
}
|
||||
|
||||
const REMOTE_RUNTIME_PATH =
|
||||
'PATH="$HOME/.local/bin:$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"';
|
||||
|
||||
// The Electron app answers the same launcher-style `sync ...` argv when
|
||||
// invoked with --sync-cli (SYNC_CLI_FLAG), so a remote machine only needs the
|
||||
// app installed; the command-line launcher is one candidate, not a
|
||||
// requirement. Each candidate is the remote invocation to probe, in order.
|
||||
function defaultRemoteCandidates(flavor: RemoteShellFlavor): string[] {
|
||||
if (flavor === 'windows-cmd' || flavor === 'windows-powershell') {
|
||||
// cmd.exe expands %VAR% inside double quotes; PowerShell needs $env: and
|
||||
// the & call operator to run a quoted path.
|
||||
const launcherShim =
|
||||
flavor === 'windows-cmd'
|
||||
? `"%LOCALAPPDATA%\\SubMiner\\bin\\subminer.cmd"`
|
||||
: `& "$env:LOCALAPPDATA\\SubMiner\\bin\\subminer.cmd"`;
|
||||
const appInstall =
|
||||
flavor === 'windows-cmd'
|
||||
? `"%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe"`
|
||||
: `& "$env:LOCALAPPDATA\\Programs\\SubMiner\\SubMiner.exe"`;
|
||||
return [
|
||||
// Command-line launcher shim on PATH or in its default install dir.
|
||||
'subminer',
|
||||
launcherShim,
|
||||
// The app binary itself in sync-CLI mode (default NSIS install dir).
|
||||
`${appInstall} ${SYNC_CLI_FLAG}`,
|
||||
`SubMiner ${SYNC_CLI_FLAG}`,
|
||||
];
|
||||
}
|
||||
return [
|
||||
// Command-line launcher (bun script) on PATH or in its default install dir.
|
||||
'subminer',
|
||||
'~/.local/bin/subminer',
|
||||
// The app binary itself in sync-CLI mode.
|
||||
`SubMiner ${SYNC_CLI_FLAG}`,
|
||||
`/Applications/SubMiner.app/Contents/MacOS/SubMiner ${SYNC_CLI_FLAG}`,
|
||||
`~/Applications/SubMiner.app/Contents/MacOS/SubMiner ${SYNC_CLI_FLAG}`,
|
||||
];
|
||||
}
|
||||
|
||||
function preferredCandidates(flavor: RemoteShellFlavor, preferred: string): string[] {
|
||||
const quoted = quoteForRemoteShell(flavor, preferred);
|
||||
const invocation = flavor === 'windows-powershell' ? `& ${quoted}` : quoted;
|
||||
// App binaries also answer plain --help by opening the GUI-oriented help
|
||||
// path, so probe the sync-CLI shape first.
|
||||
return [`${invocation} ${SYNC_CLI_FLAG}`, invocation];
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-interactive POSIX SSH shells often miss user-installed launchers and
|
||||
* Bun, so those candidates are probed under the same deterministic PATH sync
|
||||
* itself uses; Windows shells get their default install locations instead.
|
||||
* Trusted defaults stay unquoted so the remote shell expands `~`/%VAR%; a
|
||||
* user-supplied override is quoted to prevent command injection and probed
|
||||
* both as an app binary (--sync-cli) and as a launcher.
|
||||
*/
|
||||
export function resolveRemoteSubminerCommand(
|
||||
host: string,
|
||||
preferred: string | null,
|
||||
flavor: RemoteShellFlavor = 'posix',
|
||||
runRemote: typeof runSsh = runSsh,
|
||||
): string {
|
||||
const candidates = preferred
|
||||
? preferredCandidates(flavor, preferred)
|
||||
: defaultRemoteCandidates(flavor);
|
||||
for (const candidate of candidates) {
|
||||
const command = flavor === 'posix' ? `${REMOTE_RUNTIME_PATH} ${candidate}` : candidate;
|
||||
const probe = runRemote(
|
||||
host,
|
||||
flavor === 'posix' ? `${command} --help >/dev/null 2>&1` : `${command} --help`,
|
||||
);
|
||||
if (probe.status === 0) {
|
||||
return command;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
preferred
|
||||
? `Remote command not found on ${host}: ${preferred}`
|
||||
: `SubMiner not found on ${host} (tried the subminer launcher and the SubMiner app binary in their default install locations). Pass --remote-cmd <path> pointing at the SubMiner app or launcher.`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
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 { createEmptyMergeSummary } from './shared';
|
||||
import {
|
||||
ensureTrackerQuiescentFlow,
|
||||
runSyncFlow,
|
||||
type SyncFlowContext,
|
||||
type SyncFlowDeps,
|
||||
} from './sync-flow';
|
||||
|
||||
function makeContext(overrides: Partial<SyncFlowContext['args']> = {}): SyncFlowContext {
|
||||
return {
|
||||
args: {
|
||||
syncHost: '',
|
||||
syncSnapshotPath: '',
|
||||
syncMergePath: '',
|
||||
syncDirection: 'both',
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
logLevel: 'warn',
|
||||
...overrides,
|
||||
},
|
||||
mpvSocketPath: '',
|
||||
};
|
||||
}
|
||||
|
||||
function ok(stdout = ''): { status: number; stdout: string; stderr: string } {
|
||||
return { status: 0, stdout, stderr: '' };
|
||||
}
|
||||
|
||||
// recordHostSyncResult defaults to a no-op here: the real disk-writing
|
||||
// implementations are bound by the entry points, never by the flow itself.
|
||||
function makeDeps(overrides: Partial<SyncFlowDeps> = {}): SyncFlowDeps {
|
||||
return {
|
||||
createDbSnapshot: () => {},
|
||||
mergeSnapshotIntoDb: () => createEmptyMergeSummary(),
|
||||
findLiveStatsDaemonPid: () => null,
|
||||
assertSafeSshHost: () => {},
|
||||
detectRemoteShellFlavor: () => 'posix',
|
||||
resolveRemoteSubminerCommand: () => 'subminer',
|
||||
runScp: () => {},
|
||||
runSsh: () => ok(),
|
||||
canConnectUnixSocket: async () => false,
|
||||
realpathSync: (candidate) => candidate,
|
||||
mkdtempSync: (prefix) => fs.mkdtempSync(prefix),
|
||||
rmSync: (target, options) => fs.rmSync(target, options),
|
||||
consoleLog: () => {},
|
||||
writeStdout: () => true,
|
||||
ensureTrackerQuiescent: async () => {},
|
||||
emitEvent: () => {},
|
||||
recordHostSyncResult: () => {},
|
||||
resolveDefaultDbPath: () => '/tracker.sqlite',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('ensureTrackerQuiescentFlow 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 = makeDeps({
|
||||
realpathSync: () => '/tracker.sqlite',
|
||||
canConnectUnixSocket: async () => socketConnectable,
|
||||
});
|
||||
|
||||
await ensureTrackerQuiescentFlow(context, '/tmp/local.sqlite', deps);
|
||||
|
||||
socketConnectable = true;
|
||||
await assert.rejects(
|
||||
async () => ensureTrackerQuiescentFlow(context, '/tmp/local.sqlite', deps),
|
||||
/mpv\/SubMiner session appears to be running/,
|
||||
);
|
||||
});
|
||||
|
||||
test('ensureTrackerQuiescentFlow rejects a live stats daemon and honors --force', async () => {
|
||||
const context = makeContext({ syncDbPath: '/tmp/local.sqlite' });
|
||||
const deps = makeDeps({
|
||||
realpathSync: () => '/tracker.sqlite',
|
||||
findLiveStatsDaemonPid: () => 4242,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
async () => ensureTrackerQuiescentFlow(context, '/tmp/local.sqlite', deps),
|
||||
/stats server is running \(pid 4242\)/,
|
||||
);
|
||||
|
||||
const forced = makeContext({ syncDbPath: '/tmp/local.sqlite', syncForce: true });
|
||||
await ensureTrackerQuiescentFlow(forced, '/tmp/local.sqlite', deps);
|
||||
});
|
||||
|
||||
test('runSyncFlow dispatches snapshot, merge, host, and missing-target modes', async () => {
|
||||
const calls: string[] = [];
|
||||
const deps = makeDeps({
|
||||
createDbSnapshot: (dbPath, outPath) => {
|
||||
calls.push(`snapshot:${dbPath}->${outPath}`);
|
||||
},
|
||||
mergeSnapshotIntoDb: (dbPath, snapshotPath) => {
|
||||
calls.push(`merge:${dbPath}<-${snapshotPath}`);
|
||||
return createEmptyMergeSummary();
|
||||
},
|
||||
ensureTrackerQuiescent: async () => {
|
||||
calls.push('quiescent');
|
||||
},
|
||||
assertSafeSshHost: (host) => {
|
||||
calls.push(`host:${host}`);
|
||||
},
|
||||
runSsh: (_host, command) => {
|
||||
calls.push(`ssh:${command}`);
|
||||
return command.includes(' sync --make-temp') ? ok('/tmp/subminer-sync-remote\n') : ok();
|
||||
},
|
||||
runScp: (from, to) => {
|
||||
calls.push(`scp:${from}->${to}`);
|
||||
},
|
||||
});
|
||||
|
||||
await runSyncFlow(
|
||||
makeContext({ syncDbPath: '/tmp/local.sqlite', syncSnapshotPath: '/tmp/out.sqlite' }),
|
||||
deps,
|
||||
);
|
||||
assert.ok(calls.includes('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'));
|
||||
assert.ok(
|
||||
calls.indexOf('quiescent') < calls.indexOf('snapshot:/tmp/local.sqlite->/tmp/out.sqlite'),
|
||||
);
|
||||
|
||||
await runSyncFlow(
|
||||
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 runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps);
|
||||
assert.ok(calls.includes('host:media-box'));
|
||||
|
||||
await assert.rejects(
|
||||
() => runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite' }), deps),
|
||||
/sync requires a host, --snapshot <file>, or --merge <file>/,
|
||||
);
|
||||
});
|
||||
|
||||
function makeHostDeps(calls: string[], overrides: Partial<SyncFlowDeps> = {}): SyncFlowDeps {
|
||||
return makeDeps({
|
||||
createDbSnapshot: (_dbPath, outPath) => {
|
||||
calls.push(`snapshot:${outPath}`);
|
||||
fs.writeFileSync(outPath, 'snapshot');
|
||||
},
|
||||
mergeSnapshotIntoDb: () => {
|
||||
calls.push('local-merge');
|
||||
return createEmptyMergeSummary();
|
||||
},
|
||||
ensureTrackerQuiescent: async () => {
|
||||
calls.push('quiescent');
|
||||
},
|
||||
runSsh: (_host, command) => {
|
||||
calls.push(`ssh:${command}`);
|
||||
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
|
||||
return ok();
|
||||
},
|
||||
runScp: (from, to) => {
|
||||
calls.push(`scp:${from}->${to}`);
|
||||
if (!to.includes(':')) fs.writeFileSync(to, 'pulled');
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test('runHostSync keeps tracker quiescent through both merges and cleans up after failure', async () => {
|
||||
const calls: string[] = [];
|
||||
let localTmpDir = '';
|
||||
const deps = makeHostDeps(calls, {
|
||||
mkdtempSync: (prefix) => {
|
||||
localTmpDir = fs.mkdtempSync(prefix);
|
||||
return localTmpDir;
|
||||
},
|
||||
runSsh: (_host, command) => {
|
||||
calls.push(`ssh:${command}`);
|
||||
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
|
||||
if (command.includes(' sync --merge ')) {
|
||||
return { status: 9, stdout: 'remote output', stderr: 'remote merge exploded' };
|
||||
}
|
||||
return ok();
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runSyncFlow(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.includes(' sync --remove-temp ')));
|
||||
assert.equal(fs.existsSync(localTmpDir), false);
|
||||
});
|
||||
|
||||
test('runHostSync includes remote snapshot stderr in failures', async () => {
|
||||
const deps = makeHostDeps([], {
|
||||
runSsh: (_host, command) => {
|
||||
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
|
||||
if (command.includes(' sync --snapshot ')) {
|
||||
return { status: 5, stdout: '', stderr: 'snapshot permission denied' };
|
||||
}
|
||||
return ok();
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }), deps),
|
||||
/Remote snapshot failed on media-box[\s\S]*snapshot permission denied/,
|
||||
);
|
||||
});
|
||||
|
||||
test('runHostSync push only snapshots locally and merges remotely', async () => {
|
||||
const calls: string[] = [];
|
||||
await runSyncFlow(
|
||||
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncDirection: 'push' }),
|
||||
makeHostDeps(calls),
|
||||
);
|
||||
|
||||
assert.ok(calls.some((call) => call.startsWith('snapshot:')));
|
||||
assert.ok(calls.some((call) => call.includes(' sync --merge ')));
|
||||
assert.ok(calls.some((call) => call.startsWith('scp:') && call.includes('->media-box:')));
|
||||
assert.ok(!calls.some((call) => call.includes(' sync --snapshot ')));
|
||||
assert.ok(!calls.includes('local-merge'));
|
||||
});
|
||||
|
||||
test('runHostSync pull only snapshots remotely and merges locally', async () => {
|
||||
const calls: string[] = [];
|
||||
await runSyncFlow(
|
||||
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncDirection: 'pull' }),
|
||||
makeHostDeps(calls),
|
||||
);
|
||||
|
||||
assert.ok(calls.some((call) => call.includes(' sync --snapshot ')));
|
||||
assert.ok(calls.some((call) => call.startsWith('scp:media-box:')));
|
||||
assert.ok(calls.includes('local-merge'));
|
||||
assert.ok(!calls.some((call) => call.startsWith('snapshot:')));
|
||||
assert.ok(!calls.some((call) => call.includes(' sync --merge ')));
|
||||
});
|
||||
|
||||
test('runSyncFlow --json emits NDJSON progress events and a final result', async () => {
|
||||
const lines: string[] = [];
|
||||
const remoteSummary = {
|
||||
...createEmptyMergeSummary(),
|
||||
sessionsMerged: 2,
|
||||
videosAdded: 1,
|
||||
};
|
||||
const deps = makeHostDeps([], {
|
||||
consoleLog: (line) => {
|
||||
lines.push(line);
|
||||
},
|
||||
runSsh: (_host, command) => {
|
||||
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
|
||||
if (command.includes(' sync --merge ')) {
|
||||
assert.match(command, / --json(?: |$)/);
|
||||
return ok(
|
||||
`${JSON.stringify({ type: 'merge-summary', target: 'local', summary: remoteSummary })}\n` +
|
||||
`${JSON.stringify({ type: 'result', ok: true, error: null })}\n`,
|
||||
);
|
||||
}
|
||||
return ok();
|
||||
},
|
||||
});
|
||||
|
||||
await runSyncFlow(
|
||||
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncJson: true }),
|
||||
deps,
|
||||
);
|
||||
|
||||
const events = lines.map((line) => JSON.parse(line));
|
||||
assert.ok(events.some((event) => event.type === 'stage' && event.stage === 'snapshot-local'));
|
||||
assert.ok(events.some((event) => event.type === 'merge-summary' && event.target === 'local'));
|
||||
assert.deepEqual(
|
||||
events.find((event) => event.type === 'merge-summary' && event.target === 'remote'),
|
||||
{ type: 'merge-summary', target: 'remote', summary: remoteSummary },
|
||||
);
|
||||
assert.deepEqual(events[events.length - 1], { type: 'result', ok: true, error: null });
|
||||
});
|
||||
|
||||
test('runSyncFlow --json emits an error result when the sync fails', async () => {
|
||||
const lines: string[] = [];
|
||||
const deps = makeHostDeps([], {
|
||||
consoleLog: (line) => {
|
||||
lines.push(line);
|
||||
},
|
||||
runSsh: (_host, command) => {
|
||||
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
|
||||
if (command.includes(' sync --merge ')) return { status: 9, stdout: '', stderr: 'boom' };
|
||||
return ok();
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(() =>
|
||||
runSyncFlow(
|
||||
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncJson: true }),
|
||||
deps,
|
||||
),
|
||||
);
|
||||
|
||||
const events = lines.map((line) => JSON.parse(line));
|
||||
const last = events[events.length - 1];
|
||||
assert.equal(last.type, 'result');
|
||||
assert.equal(last.ok, false);
|
||||
assert.match(last.error, /Remote merge failed/);
|
||||
});
|
||||
|
||||
test('runHostSync records host sync results for saved-host bookkeeping', async () => {
|
||||
const recorded: Array<{ host: string; status: string; detail: string | null }> = [];
|
||||
const record: SyncFlowDeps['recordHostSyncResult'] = (host, status, detail) => {
|
||||
recorded.push({ host, status, detail });
|
||||
};
|
||||
|
||||
await runSyncFlow(
|
||||
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }),
|
||||
makeHostDeps([], { recordHostSyncResult: record }),
|
||||
);
|
||||
assert.deepEqual(recorded[0], {
|
||||
host: 'media-box',
|
||||
status: 'success',
|
||||
detail: '0 sessions merged; pushed local stats',
|
||||
});
|
||||
|
||||
await assert.rejects(() =>
|
||||
runSyncFlow(
|
||||
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }),
|
||||
makeHostDeps([], {
|
||||
recordHostSyncResult: record,
|
||||
runSsh: (_host, command) => {
|
||||
if (command.includes(' sync --make-temp')) return ok('/tmp/subminer-sync-remote\n');
|
||||
if (command.includes(' sync --merge ')) return { status: 9, stdout: '', stderr: 'boom' };
|
||||
return ok();
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
assert.equal(recorded.length, 2);
|
||||
assert.equal(recorded[1]!.status, 'error');
|
||||
});
|
||||
|
||||
test('runCheckMode --json reports ssh and remote SubMiner status', async () => {
|
||||
const lines: string[] = [];
|
||||
const sshOptions: unknown[] = [];
|
||||
const deps = makeDeps({
|
||||
consoleLog: (line) => {
|
||||
lines.push(line);
|
||||
},
|
||||
resolveRemoteSubminerCommand: (host, _preferred, _flavor, runRemote) => {
|
||||
runRemote!(host, 'subminer --help');
|
||||
return 'subminer';
|
||||
},
|
||||
runSsh: (_host, command, options) => {
|
||||
sshOptions.push(options);
|
||||
if (command.includes('--version')) return ok('SubMiner 0.18.0\n');
|
||||
return ok('subminer-check-ok\n');
|
||||
},
|
||||
});
|
||||
|
||||
await runSyncFlow(
|
||||
makeContext({
|
||||
syncDbPath: '/tmp/local.sqlite',
|
||||
syncHost: 'media-box',
|
||||
syncCheck: true,
|
||||
syncJson: true,
|
||||
}),
|
||||
deps,
|
||||
);
|
||||
|
||||
const events = lines.map((line) => JSON.parse(line));
|
||||
const check = events.find((event) => event.type === 'check-result');
|
||||
assert.ok(check);
|
||||
assert.equal(check.host, 'media-box');
|
||||
assert.equal(check.sshOk, true);
|
||||
assert.equal(check.remoteCommand, 'subminer');
|
||||
assert.equal(check.remoteVersion, 'SubMiner 0.18.0');
|
||||
assert.equal(check.ok, true);
|
||||
assert.equal(check.error, null);
|
||||
assert.equal(sshOptions.length, 3);
|
||||
assert.ok(
|
||||
sshOptions.every(
|
||||
(options) =>
|
||||
JSON.stringify(options) ===
|
||||
JSON.stringify({ batchMode: true, connectTimeoutSeconds: 10, timeoutMs: 15_000 }),
|
||||
),
|
||||
);
|
||||
|
||||
const failLines: string[] = [];
|
||||
await assert.rejects(() =>
|
||||
runSyncFlow(
|
||||
makeContext({
|
||||
syncDbPath: '/tmp/local.sqlite',
|
||||
syncHost: 'media-box',
|
||||
syncCheck: true,
|
||||
syncJson: true,
|
||||
}),
|
||||
makeDeps({
|
||||
consoleLog: (line) => {
|
||||
failLines.push(line);
|
||||
},
|
||||
resolveRemoteSubminerCommand: () => {
|
||||
throw new Error('Could not find a runnable "subminer" on media-box.');
|
||||
},
|
||||
runSsh: () => ok('subminer-check-ok\n'),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const failEvents = failLines.map((line) => JSON.parse(line));
|
||||
const failedCheck = failEvents.find((event) => event.type === 'check-result');
|
||||
assert.ok(failedCheck);
|
||||
assert.equal(failedCheck.ok, false);
|
||||
assert.match(failedCheck.error, /Could not find a runnable/);
|
||||
});
|
||||
|
||||
test('runHostSync speaks Windows shells: app command, double quotes, temp protocol', async () => {
|
||||
const sshCommands: string[] = [];
|
||||
const scpCalls: string[] = [];
|
||||
const winTemp = 'C:\\Users\\First Last\\AppData\\Local\\Temp\\subminer-sync-remote';
|
||||
const appCmd = '"%LOCALAPPDATA%\\Programs\\SubMiner\\SubMiner.exe" --sync-cli';
|
||||
const deps = makeHostDeps([], {
|
||||
detectRemoteShellFlavor: () => 'windows-cmd',
|
||||
resolveRemoteSubminerCommand: () => appCmd,
|
||||
createDbSnapshot: (_dbPath, outPath) => {
|
||||
fs.writeFileSync(outPath, 'snapshot');
|
||||
},
|
||||
runSsh: (_host, command) => {
|
||||
sshCommands.push(command);
|
||||
if (command.includes(' sync --make-temp')) return ok(`${winTemp}\r\n`);
|
||||
return ok();
|
||||
},
|
||||
runScp: (from, to) => {
|
||||
scpCalls.push(`${from}->${to}`);
|
||||
if (!to.includes(':')) fs.writeFileSync(to, 'pulled');
|
||||
},
|
||||
});
|
||||
|
||||
await runSyncFlow(makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'win-box' }), deps);
|
||||
|
||||
const expectedDir = 'C:/Users/First Last/AppData/Local/Temp/subminer-sync-remote';
|
||||
assert.ok(sshCommands.includes(`${appCmd} sync --snapshot "${expectedDir}/snapshot.sqlite"`));
|
||||
assert.ok(sshCommands.includes(`${appCmd} sync --merge "${expectedDir}/incoming.sqlite"`));
|
||||
assert.ok(sshCommands.includes(`${appCmd} sync --remove-temp "${expectedDir}"`));
|
||||
assert.ok(scpCalls.some((call) => call.startsWith(`win-box:${expectedDir}/snapshot.sqlite->`)));
|
||||
assert.ok(scpCalls.some((call) => call.endsWith(`->win-box:${expectedDir}/incoming.sqlite`)));
|
||||
// No POSIX shell-isms reach a Windows remote.
|
||||
assert.ok(
|
||||
!sshCommands.some((command) => command.startsWith('mktemp') || command.startsWith('rm ')),
|
||||
);
|
||||
assert.ok(!sshCommands.some((command) => command.includes('PATH="$HOME')));
|
||||
});
|
||||
|
||||
test('runSyncFlow --make-temp prints a temp dir and --remove-temp only removes sync temp dirs', async () => {
|
||||
const printed: string[] = [];
|
||||
const removed: string[] = [];
|
||||
const madeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-'));
|
||||
try {
|
||||
await runSyncFlow(
|
||||
makeContext({ syncMakeTemp: true }),
|
||||
makeDeps({
|
||||
mkdtempSync: () => madeDir,
|
||||
consoleLog: (line) => {
|
||||
printed.push(line);
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(printed, [madeDir]);
|
||||
|
||||
const removeDeps = makeDeps({
|
||||
rmSync: (target) => {
|
||||
removed.push(target);
|
||||
},
|
||||
});
|
||||
await runSyncFlow(makeContext({ syncRemoveTempPath: madeDir }), removeDeps);
|
||||
assert.deepEqual(removed, [madeDir]);
|
||||
|
||||
await assert.rejects(
|
||||
() => runSyncFlow(makeContext({ syncRemoveTempPath: '/etc' }), removeDeps),
|
||||
/Refusing to remove a directory outside the sync temp area/,
|
||||
);
|
||||
assert.equal(removed.length, 1);
|
||||
} finally {
|
||||
fs.rmSync(madeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,482 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { formatMergeSummary } from './merge';
|
||||
import { quoteForRemoteShell } from './ssh';
|
||||
import type { RemoteRunResult, RemoteShellFlavor, RunSshOptions } from './ssh';
|
||||
import {
|
||||
parseSyncProgressLine,
|
||||
type SyncMergeSummary,
|
||||
type SyncProgressEvent,
|
||||
} from '../../../shared/sync/sync-events';
|
||||
import type { SyncResultStatus } from '../../../shared/sync/sync-hosts-store';
|
||||
|
||||
export interface SyncFlowArgs {
|
||||
syncHost: string;
|
||||
syncSnapshotPath: string;
|
||||
syncMergePath: string;
|
||||
syncDirection: 'both' | 'push' | 'pull' | null;
|
||||
syncRemoteCmd: string;
|
||||
syncDbPath: string;
|
||||
syncForce: boolean;
|
||||
syncJson: boolean;
|
||||
syncCheck: boolean;
|
||||
syncMakeTemp: boolean;
|
||||
syncRemoveTempPath: string;
|
||||
logLevel: string;
|
||||
}
|
||||
|
||||
export interface SyncFlowContext {
|
||||
args: SyncFlowArgs;
|
||||
mpvSocketPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process/IO seams the sync flow needs stubbed in tests: SSH/scp, the DB
|
||||
* snapshot/merge engine, filesystem, and progress/bookkeeping output. The
|
||||
* app's --sync-cli mode (src/main/sync-cli.ts) provides the only production
|
||||
* binding; pure helpers are imported directly.
|
||||
*/
|
||||
export interface SyncFlowDeps {
|
||||
createDbSnapshot: (dbPath: string, outPath: string) => void;
|
||||
mergeSnapshotIntoDb: (localDbPath: string, snapshotPath: string) => SyncMergeSummary;
|
||||
findLiveStatsDaemonPid: (dbPath: string) => number | null;
|
||||
assertSafeSshHost: (host: string) => void;
|
||||
detectRemoteShellFlavor: (
|
||||
host: string,
|
||||
runRemote: (host: string, remoteCommand: string) => RemoteRunResult,
|
||||
) => RemoteShellFlavor;
|
||||
resolveRemoteSubminerCommand: (
|
||||
host: string,
|
||||
preferred: string | null,
|
||||
flavor: RemoteShellFlavor,
|
||||
runRemote?: (host: string, remoteCommand: string) => RemoteRunResult,
|
||||
) => string;
|
||||
runScp: (from: string, to: string) => void;
|
||||
runSsh: (host: string, remoteCommand: string, options?: RunSshOptions) => RemoteRunResult;
|
||||
canConnectUnixSocket: (socketPath: string) => Promise<boolean>;
|
||||
realpathSync: (candidate: string) => string;
|
||||
mkdtempSync: (prefix: string) => string;
|
||||
rmSync: (target: string, options: { recursive: boolean; force: boolean }) => void;
|
||||
consoleLog: (message: string) => void;
|
||||
writeStdout: (text: string) => boolean;
|
||||
ensureTrackerQuiescent: (context: SyncFlowContext, dbPath: string) => Promise<void>;
|
||||
emitEvent: (event: SyncProgressEvent) => void;
|
||||
recordHostSyncResult: (host: string, status: SyncResultStatus, detail: string | null) => void;
|
||||
resolveDefaultDbPath: () => string;
|
||||
}
|
||||
|
||||
/** Expand a leading `~` (as ssh users write paths) and make the path absolute. */
|
||||
function resolveCliPath(input: string): string {
|
||||
return input.startsWith('~') ? path.join(os.homedir(), input.slice(1)) : path.resolve(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix shared by every sync temp dir, local or remote. Remote temp dirs are
|
||||
* created and removed by the remote SubMiner itself (sync --make-temp /
|
||||
* --remove-temp) so the flow never depends on mktemp/rm existing in the
|
||||
* remote shell. This is what makes Windows remotes work.
|
||||
*/
|
||||
const SYNC_TEMP_PREFIX = 'subminer-sync-';
|
||||
|
||||
function makeSyncTempDir(mkdtempSync: SyncFlowDeps['mkdtempSync']): string {
|
||||
return mkdtempSync(path.join(os.tmpdir(), SYNC_TEMP_PREFIX));
|
||||
}
|
||||
|
||||
/** Only dirs directly under os.tmpdir() with the sync prefix may be removed. */
|
||||
function assertRemovableSyncTempDir(target: string): string {
|
||||
const resolved = path.resolve(target.trim());
|
||||
const normalizeCase = (value: string) =>
|
||||
process.platform === 'win32' ? value.toLowerCase() : value;
|
||||
const insideTmp =
|
||||
normalizeCase(path.dirname(resolved)) === normalizeCase(path.resolve(os.tmpdir()));
|
||||
if (!insideTmp || !path.basename(resolved).startsWith(SYNC_TEMP_PREFIX)) {
|
||||
throw new Error(`Refusing to remove a directory outside the sync temp area: ${target}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function runMakeTempMode(deps: SyncFlowDeps): void {
|
||||
deps.consoleLog(makeSyncTempDir(deps.mkdtempSync));
|
||||
}
|
||||
|
||||
function runRemoveTempMode(context: SyncFlowContext, deps: SyncFlowDeps): void {
|
||||
const target = assertRemovableSyncTempDir(context.args.syncRemoveTempPath);
|
||||
deps.rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function resolveSyncDbPath(context: SyncFlowContext, deps: SyncFlowDeps): string {
|
||||
const override = context.args.syncDbPath.trim();
|
||||
return override ? resolveCliPath(override) : deps.resolveDefaultDbPath();
|
||||
}
|
||||
|
||||
function isTrackerDb(dbPath: string, deps: SyncFlowDeps): boolean {
|
||||
const trackerDbPath = deps.resolveDefaultDbPath();
|
||||
try {
|
||||
return deps.realpathSync(dbPath) === deps.realpathSync(trackerDbPath);
|
||||
} catch {
|
||||
return dbPath === trackerDbPath;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureTrackerQuiescentFlow(
|
||||
context: SyncFlowContext,
|
||||
dbPath: string,
|
||||
deps: SyncFlowDeps,
|
||||
): Promise<void> {
|
||||
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) {
|
||||
throw new Error(
|
||||
`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))) {
|
||||
throw new Error(
|
||||
`An mpv/SubMiner session appears to be running (socket ${context.mpvSocketPath}). Close it before syncing, or pass --force.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// In --json mode every line on stdout is an NDJSON event: human console output
|
||||
// is silenced and events are written through the original console logger.
|
||||
function withJsonEvents(deps: SyncFlowDeps): SyncFlowDeps {
|
||||
const writeLine = deps.consoleLog;
|
||||
return {
|
||||
...deps,
|
||||
consoleLog: () => {},
|
||||
writeStdout: () => true,
|
||||
emitEvent: (event) => writeLine(JSON.stringify(event)),
|
||||
};
|
||||
}
|
||||
|
||||
async function runSnapshotMode(
|
||||
context: SyncFlowContext,
|
||||
dbPath: string,
|
||||
deps: SyncFlowDeps,
|
||||
): Promise<void> {
|
||||
await deps.ensureTrackerQuiescent(context, dbPath);
|
||||
const outPath = resolveCliPath(context.args.syncSnapshotPath);
|
||||
deps.emitEvent({
|
||||
type: 'stage',
|
||||
stage: 'snapshot-local',
|
||||
message: `Snapshotting local database (${dbPath})`,
|
||||
});
|
||||
deps.createDbSnapshot(dbPath, outPath);
|
||||
deps.emitEvent({ type: 'snapshot-created', path: outPath });
|
||||
deps.consoleLog(outPath);
|
||||
}
|
||||
|
||||
async function runMergeMode(
|
||||
context: SyncFlowContext,
|
||||
dbPath: string,
|
||||
deps: SyncFlowDeps,
|
||||
): Promise<void> {
|
||||
await deps.ensureTrackerQuiescent(context, dbPath);
|
||||
const snapshotPath = resolveCliPath(context.args.syncMergePath);
|
||||
deps.emitEvent({
|
||||
type: 'stage',
|
||||
stage: 'merge-local',
|
||||
message: `Merging ${snapshotPath} into the local database`,
|
||||
});
|
||||
const summary = deps.mergeSnapshotIntoDb(dbPath, snapshotPath);
|
||||
deps.emitEvent({ type: 'merge-summary', target: 'local', summary });
|
||||
deps.consoleLog(formatMergeSummary(summary));
|
||||
}
|
||||
|
||||
function formatHostSyncDetail(
|
||||
direction: 'both' | 'push' | 'pull',
|
||||
pulledSummary: SyncMergeSummary | null,
|
||||
): string {
|
||||
if (!pulledSummary) return direction === 'push' ? 'Pushed local stats' : 'Sync complete';
|
||||
const merged = `${pulledSummary.sessionsMerged} session${pulledSummary.sessionsMerged === 1 ? '' : 's'} merged`;
|
||||
return direction === 'pull' ? merged : `${merged}; pushed local stats`;
|
||||
}
|
||||
|
||||
export async function runCheckMode(context: SyncFlowContext, deps: SyncFlowDeps): Promise<void> {
|
||||
const { args } = context;
|
||||
const host = args.syncHost;
|
||||
deps.assertSafeSshHost(host);
|
||||
|
||||
deps.consoleLog(`Checking SSH connection to ${host}...`);
|
||||
let remoteCommand: string | null = null;
|
||||
let remoteVersion: string | null = null;
|
||||
let error: string | null = null;
|
||||
|
||||
const runCheck = (checkHost: string, command: string) =>
|
||||
deps.runSsh(checkHost, command, {
|
||||
batchMode: true,
|
||||
connectTimeoutSeconds: 10,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
const probe = runCheck(host, 'echo subminer-check-ok');
|
||||
const sshOk = probe.status === 0 && probe.stdout.includes('subminer-check-ok');
|
||||
if (!sshOk) {
|
||||
error = formatRemoteRunError(`Could not reach ${host} over SSH.`, probe);
|
||||
} else {
|
||||
deps.consoleLog('SSH connection: ok');
|
||||
try {
|
||||
const flavor = deps.detectRemoteShellFlavor(host, runCheck);
|
||||
if (flavor !== 'posix') deps.consoleLog(`Remote platform: Windows (${flavor})`);
|
||||
remoteCommand = deps.resolveRemoteSubminerCommand(
|
||||
host,
|
||||
args.syncRemoteCmd || null,
|
||||
flavor,
|
||||
runCheck,
|
||||
);
|
||||
const version = runCheck(host, `${remoteCommand} --version`);
|
||||
remoteVersion = version.status === 0 ? version.stdout.trim() || null : null;
|
||||
deps.consoleLog(
|
||||
`Remote subminer: ${remoteCommand}${remoteVersion ? ` (${remoteVersion})` : ''}`,
|
||||
);
|
||||
} catch (resolveError) {
|
||||
error = resolveError instanceof Error ? resolveError.message : String(resolveError);
|
||||
}
|
||||
}
|
||||
|
||||
const ok = sshOk && remoteCommand !== null;
|
||||
deps.emitEvent({
|
||||
type: 'check-result',
|
||||
host,
|
||||
sshOk,
|
||||
remoteCommand,
|
||||
remoteVersion,
|
||||
ok,
|
||||
error,
|
||||
});
|
||||
if (!ok) {
|
||||
throw new Error(error ?? `Connection check failed for ${host}.`);
|
||||
}
|
||||
deps.consoleLog('Check passed.');
|
||||
}
|
||||
|
||||
// The remote validates --remove-temp against its own tmpdir; this guard only
|
||||
// keeps garbage output from an earlier failure out of the remote command.
|
||||
function cleanupRemote(
|
||||
host: string,
|
||||
remoteCmd: string,
|
||||
remoteTmpDir: string,
|
||||
quote: (value: string) => string,
|
||||
deps: SyncFlowDeps,
|
||||
): void {
|
||||
if (!path.posix.basename(remoteTmpDir).startsWith(SYNC_TEMP_PREFIX)) return;
|
||||
deps.runSsh(host, `${remoteCmd} sync --remove-temp ${quote(remoteTmpDir)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* `sync --make-temp` prints the created dir as its last stdout line (a
|
||||
* launcher wrapper may log above it). Backslashes are normalized to forward
|
||||
* slashes: scp, the remote SubMiner, and Windows itself all accept them, and
|
||||
* it keeps the later `${dir}/file` compositions valid on every platform.
|
||||
*/
|
||||
function parseRemoteTempDir(stdout: string): string {
|
||||
const lines = stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
const candidate = (lines[lines.length - 1] ?? '').replaceAll('\\', '/');
|
||||
return path.posix.basename(candidate).startsWith(SYNC_TEMP_PREFIX) ? candidate : '';
|
||||
}
|
||||
|
||||
function parseRemoteMergeSummary(stdout: string): SyncMergeSummary | null {
|
||||
for (const line of stdout.split('\n')) {
|
||||
const event = parseSyncProgressLine(line);
|
||||
if (event?.type === 'merge-summary' && event.target === 'local') return event.summary;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatRemoteRunError(message: string, run: RemoteRunResult): string {
|
||||
const stderr = run.stderr.trim();
|
||||
return stderr ? `${message}\n${stderr}` : message;
|
||||
}
|
||||
|
||||
export async function runHostSync(
|
||||
context: SyncFlowContext,
|
||||
dbPath: string,
|
||||
deps: SyncFlowDeps,
|
||||
): Promise<void> {
|
||||
const { args } = context;
|
||||
const host = args.syncHost;
|
||||
const direction = args.syncDirection ?? 'both';
|
||||
const shouldPull = direction !== 'push';
|
||||
const shouldPush = direction !== 'pull';
|
||||
deps.assertSafeSshHost(host);
|
||||
|
||||
await deps.ensureTrackerQuiescent(context, dbPath);
|
||||
|
||||
const flavor = deps.detectRemoteShellFlavor(host, deps.runSsh);
|
||||
const remoteCmd = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null, flavor);
|
||||
const quote = (value: string) => quoteForRemoteShell(flavor, value);
|
||||
if (args.logLevel === 'debug') {
|
||||
console.error(`Remote subminer command (${flavor}): ${remoteCmd}`);
|
||||
}
|
||||
|
||||
const localTmpDir = makeSyncTempDir(deps.mkdtempSync);
|
||||
let remoteTmpDir = '';
|
||||
let pulledSummary: SyncMergeSummary | null = null;
|
||||
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, `${remoteCmd} sync --make-temp`);
|
||||
remoteTmpDir = mktemp.status === 0 ? parseRemoteTempDir(mktemp.stdout) : '';
|
||||
if (!remoteTmpDir) {
|
||||
throw new Error(
|
||||
formatRemoteRunError(`Could not create a temporary directory on ${host}.`, mktemp),
|
||||
);
|
||||
}
|
||||
|
||||
const forceFlag = args.syncForce ? ' --force' : '';
|
||||
|
||||
const localSnapshot = path.join(localTmpDir, 'local.sqlite');
|
||||
if (shouldPush) {
|
||||
deps.consoleLog(`Snapshotting local database (${dbPath})...`);
|
||||
deps.emitEvent({
|
||||
type: 'stage',
|
||||
stage: 'snapshot-local',
|
||||
message: `Snapshotting local database (${dbPath})`,
|
||||
});
|
||||
deps.createDbSnapshot(dbPath, localSnapshot);
|
||||
}
|
||||
|
||||
const remoteSnapshot = `${remoteTmpDir}/snapshot.sqlite`;
|
||||
if (shouldPull) {
|
||||
deps.consoleLog(`Snapshotting ${host}...`);
|
||||
deps.emitEvent({ type: 'stage', stage: 'snapshot-remote', message: `Snapshotting ${host}` });
|
||||
const snapshotRun = deps.runSsh(
|
||||
host,
|
||||
`${remoteCmd} sync --snapshot ${quote(remoteSnapshot)}${forceFlag}`,
|
||||
);
|
||||
if (snapshotRun.status !== 0) {
|
||||
throw new Error(formatRemoteRunError(`Remote snapshot failed on ${host}.`, snapshotRun));
|
||||
}
|
||||
}
|
||||
|
||||
const pulledSnapshot = path.join(localTmpDir, 'remote.sqlite');
|
||||
if (shouldPull) {
|
||||
deps.emitEvent({
|
||||
type: 'stage',
|
||||
stage: 'download',
|
||||
message: `Copying snapshot from ${host}`,
|
||||
});
|
||||
deps.runScp(`${host}:${remoteSnapshot}`, pulledSnapshot);
|
||||
}
|
||||
const incomingSnapshot = `${remoteTmpDir}/incoming.sqlite`;
|
||||
if (shouldPush) {
|
||||
deps.emitEvent({ type: 'stage', stage: 'upload', message: `Copying snapshot to ${host}` });
|
||||
deps.runScp(localSnapshot, `${host}:${incomingSnapshot}`);
|
||||
}
|
||||
|
||||
if (shouldPull) {
|
||||
deps.consoleLog(`\nMerging ${host} -> local:`);
|
||||
deps.emitEvent({
|
||||
type: 'stage',
|
||||
stage: 'merge-local',
|
||||
message: `Merging ${host} into the local database`,
|
||||
});
|
||||
await deps.ensureTrackerQuiescent(context, dbPath);
|
||||
const summary = deps.mergeSnapshotIntoDb(dbPath, pulledSnapshot);
|
||||
pulledSummary = summary;
|
||||
deps.emitEvent({ type: 'merge-summary', target: 'local', summary });
|
||||
deps.consoleLog(formatMergeSummary(summary));
|
||||
}
|
||||
|
||||
if (shouldPush) {
|
||||
deps.consoleLog(`\nMerging local -> ${host}:`);
|
||||
deps.emitEvent({
|
||||
type: 'stage',
|
||||
stage: 'merge-remote',
|
||||
message: `Merging the local database into ${host}`,
|
||||
});
|
||||
await deps.ensureTrackerQuiescent(context, dbPath);
|
||||
const mergeRun = deps.runSsh(
|
||||
host,
|
||||
`${remoteCmd} sync --merge ${quote(incomingSnapshot)}${forceFlag}${args.syncJson ? ' --json' : ''}`,
|
||||
);
|
||||
deps.writeStdout(mergeRun.stdout);
|
||||
const remoteSummary = args.syncJson ? parseRemoteMergeSummary(mergeRun.stdout) : null;
|
||||
if (remoteSummary) {
|
||||
deps.emitEvent({ type: 'merge-summary', target: 'remote', summary: remoteSummary });
|
||||
} else if (mergeRun.stdout.trim()) {
|
||||
deps.emitEvent({ type: 'remote-output', text: mergeRun.stdout });
|
||||
}
|
||||
if (mergeRun.status !== 0) {
|
||||
const retryCommand =
|
||||
direction === 'push' ? `subminer sync ${host} --push` : `subminer sync ${host}`;
|
||||
const localUpdate = shouldPull ? ' The local database was updated;' : '';
|
||||
throw new Error(
|
||||
formatRemoteRunError(
|
||||
`Remote merge failed on ${host}.${localUpdate} re-run "${retryCommand}" once the remote issue is fixed.`,
|
||||
mergeRun,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
deps.consoleLog('\nSync complete.');
|
||||
deps.recordHostSyncResult(host, 'success', formatHostSyncDetail(direction, pulledSummary));
|
||||
} catch (error) {
|
||||
try {
|
||||
deps.recordHostSyncResult(
|
||||
host,
|
||||
'error',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
deps.rmSync(localTmpDir, { recursive: true, force: true });
|
||||
if (remoteTmpDir) {
|
||||
try {
|
||||
cleanupRemote(host, remoteCmd, remoteTmpDir, quote, deps);
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSyncFlow(
|
||||
context: SyncFlowContext,
|
||||
inputDeps: SyncFlowDeps,
|
||||
): Promise<void> {
|
||||
let deps = inputDeps;
|
||||
const { args } = context;
|
||||
if (args.syncJson) deps = withJsonEvents(deps);
|
||||
|
||||
try {
|
||||
if (args.syncMakeTemp) {
|
||||
runMakeTempMode(deps);
|
||||
} else if (args.syncRemoveTempPath) {
|
||||
runRemoveTempMode(context, deps);
|
||||
} else {
|
||||
const dbPath = resolveSyncDbPath(context, deps);
|
||||
if (args.syncCheck) {
|
||||
await runCheckMode(context, deps);
|
||||
} else if (args.syncSnapshotPath) {
|
||||
await runSnapshotMode(context, dbPath, deps);
|
||||
} else if (args.syncMergePath) {
|
||||
await runMergeMode(context, dbPath, deps);
|
||||
} else if (args.syncHost) {
|
||||
await runHostSync(context, dbPath, deps);
|
||||
} else {
|
||||
throw new Error('sync requires a host, --snapshot <file>, or --merge <file>.');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (args.syncJson) {
|
||||
deps.emitEvent({
|
||||
type: 'result',
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (args.syncJson) deps.emitEvent({ type: 'result', ok: true, error: null });
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user