mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-28 16:49:50 -07:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
9f4888293b
|
|||
|
29332b103e
|
|||
| 8b2cee2c58 | |||
| 2e2ee3f028 | |||
| 49b926e08c | |||
| 66f8ca4f80 | |||
| 6fe1e0fee4 | |||
| 0ab840b362 | |||
| 21a06c12fb | |||
|
14070acceb
|
|||
|
7d81342f0f
|
|||
| 4b7f750919 | |||
| 6ab3d823a4 |
@@ -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/
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- Stats Sync Without the Launcher: The stats sync engine now 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.
|
||||
- Stats Sync With Windows Remotes: Sync now 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; SubMiner is found in its default Windows install location automatically.
|
||||
|
||||
## v0.18.0 (2026-07-10)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
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.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: shortcuts
|
||||
|
||||
- Made the clipboard-video playlist shortcut configurable through `shortcuts.appendClipboardVideoToQueue`.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: app
|
||||
|
||||
- Fixed "Service Crash" desktop notifications (KDE DrKonqi) after closing a video when running the Linux AppImage: on quit, the AppImage runtime unmounted the FUSE squashfs while Chromium utility children (notably the network service) were still shutting down, killing them with SIGBUS. Background launches (`--background`, used by the mpv plugin and the launcher) now run through a small supervisor that mounts the AppImage via `--appimage-mount`, executes `AppRun` from that mount, and releases the mount only after no process is still executing from it. Set `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1` to restore the old direct launch.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed `mpv.pauseUntilOverlayReady` releasing playback seconds before tokenization warmup finished: startup subtitle priming emits the current cue untokenized so the overlay can paint early, and that emission was treated as the autoplay-readiness signal as soon as the overlay window loaded. The autoplay gate now ignores untokenized subtitle payloads while tokenization warmup is pending, so playback resumes only after the first tokenized delivery (or the post-warmup release). Most visible when resuming mid-episode or when a subtitle cue starts within the first two seconds.
|
||||
@@ -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.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: sync
|
||||
|
||||
- Fixed word/kanji frequencies double-counting across syncs when the remote snapshot contained a stale active session (e.g. after a crash): a word new to the local machine adopted the remote's full lifetime frequency, which already included the active session's partial occurrences, and those occurrences were added again when the session finalized and synced. Newly adopted words/kanji now exclude active-session counts, which arrive once the session completes.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: sync
|
||||
|
||||
- Fixed stats sync copying a remote daily rollup that should have been recomputed when the day was the 1st of a month and the machine was on a negative UTC offset (the Americas), which could leave that day's totals wrong after a sync. The rollup day is now read back at local noon instead of UTC midnight, so it always resolves to the correct civil month.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: added
|
||||
area: sync
|
||||
|
||||
- Added a sync window (`subminer sync --ui`, or **Sync Stats & History** in the tray menu) for cross-machine immersion sync: saved devices with per-host direction (two-way/push/pull) and remove, one-click sync with live stage-by-stage progress and merge summaries, connection testing for first-time setup, cancellable runs with a one-click `--force` retry when the running-app guard trips, and manual database snapshots (create/merge/reveal/delete, stored in `/tmp/subminer-db-snapshots/` by default). Hosts with auto-sync enabled are synced in the background on a configurable interval while no mpv session or stats server is writing the database, with results reported as overlay notifications. Hosts synced from the CLI are remembered in `<config dir>/sync-hosts.json` and show up in the window automatically. When launched with `subminer sync --ui`, closing the window also exits the attached CLI process cleanly.
|
||||
- Added `subminer sync <host> --check` to test the SSH connection and remote launcher availability without syncing, and `subminer sync --json` for machine-readable NDJSON progress output (the protocol the sync window consumes).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: internal
|
||||
area: stats
|
||||
|
||||
- Removed the unused stats IPC data transport and unified the stats dashboard's HTTP wire types with the backend contract.
|
||||
+13
-1
@@ -205,11 +205,13 @@
|
||||
"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.
|
||||
"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.
|
||||
"toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility.
|
||||
"toggleNotificationHistory": "CommandOrControl+N" // Accelerator that toggles the overlay notification history panel.
|
||||
"toggleNotificationHistory": "CommandOrControl+N", // Accelerator that toggles the overlay notification history panel.
|
||||
"appendClipboardVideoToQueue": "CommandOrControl+A" // Accelerator that appends a video path from the clipboard to the mpv playlist.
|
||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
|
||||
// ==========================================
|
||||
@@ -611,6 +613,16 @@
|
||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
||||
}, // Jimaku API configuration and defaults.
|
||||
|
||||
// ==========================================
|
||||
// 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.
|
||||
// ==========================================
|
||||
"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
|
||||
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||
|
||||
@@ -327,6 +327,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
|
||||
{ text: 'Jellyfin', link: '/jellyfin-integration' },
|
||||
{ text: 'YouTube', link: '/youtube-integration' },
|
||||
{ text: 'Jimaku', link: '/jimaku-integration' },
|
||||
{ text: 'TsukiHime', link: '/tsukihime-integration' },
|
||||
{ text: 'AniList', link: '/anilist-integration' },
|
||||
{ text: 'AniSkip', link: '/aniskip-integration' },
|
||||
{ text: 'Character Dictionary', link: '/character-dictionary' },
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
**Added**
|
||||
- Stats Sync Without the Launcher: The stats sync engine now 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.
|
||||
- Stats Sync With Windows Remotes: Sync now 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; SubMiner is found in its default Windows install location automatically.
|
||||
|
||||
## v0.18.0 (2026-07-10)
|
||||
|
||||
**Added**
|
||||
|
||||
@@ -655,6 +655,7 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
"openJimaku": "Ctrl+Shift+J",
|
||||
"toggleSubtitleSidebar": "Backslash",
|
||||
"toggleNotificationHistory": "CommandOrControl+N",
|
||||
"appendClipboardVideoToQueue": "CommandOrControl+A",
|
||||
"multiCopyTimeoutMs": 3000
|
||||
}
|
||||
}
|
||||
@@ -681,6 +682,7 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
|
||||
| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
|
||||
| `toggleNotificationHistory` | string \| `null` | Toggles the overlay notification history panel (default: `"CommandOrControl+N"`). The panel slides in from the same edge as notifications (right when notifications are centered). |
|
||||
| `appendClipboardVideoToQueue` | string \| `null` | Appends a video file path from the clipboard to the mpv playlist (default: `"CommandOrControl+A"`). Works whether the overlay or mpv has focus. |
|
||||
|
||||
**See `config.example.jsonc`** for the complete list of shortcut configuration options.
|
||||
|
||||
@@ -820,7 +822,7 @@ When automatic card updates are disabled, new cards are detected but not automat
|
||||
| `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) |
|
||||
| `Ctrl+D` | Open loaded character dictionary manager |
|
||||
| `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) |
|
||||
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) |
|
||||
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (configurable via `shortcuts.appendClipboardVideoToQueue`) |
|
||||
|
||||
**Multi-line copy workflow:**
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ 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. 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 — so the command-line launcher is optional everywhere.
|
||||
`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"
|
||||
@@ -93,11 +93,11 @@ subminer sync macbook --check # test SSH + remote SubMiner without sync
|
||||
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.
|
||||
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).
|
||||
|
||||
@@ -110,22 +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.
|
||||
`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` (or **Sync Stats & History** in the tray menu) opens a dedicated window for the same engine:
|
||||
`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 a merge summary (sessions, words, kanji, rollups) when a run finishes. Runs can be cancelled, and guard failures offer a one-click `--force` retry.
|
||||
- **Snapshots** — create manual database snapshots (stored in `/tmp/subminer-db-snapshots/` by default), merge a snapshot file into the local database, or reveal/delete existing snapshots.
|
||||
- **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) whenever no mpv session or stats server is using the database; results surface as overlay notifications. Host bookkeeping lives in `<config dir>/sync-hosts.json`.
|
||||
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
|
||||
|
||||
|
||||
@@ -205,11 +205,13 @@
|
||||
"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.
|
||||
"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.
|
||||
"toggleSubtitleSidebar": "Backslash", // Accelerator that toggles the subtitle sidebar visibility.
|
||||
"toggleNotificationHistory": "CommandOrControl+N" // Accelerator that toggles the overlay notification history panel.
|
||||
"toggleNotificationHistory": "CommandOrControl+N", // Accelerator that toggles the overlay notification history panel.
|
||||
"appendClipboardVideoToQueue": "CommandOrControl+A" // Accelerator that appends a video path from the clipboard to the mpv playlist.
|
||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
|
||||
// ==========================================
|
||||
@@ -611,6 +613,16 @@
|
||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
||||
}, // Jimaku API configuration and defaults.
|
||||
|
||||
// ==========================================
|
||||
// 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.
|
||||
// ==========================================
|
||||
"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
|
||||
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||
|
||||
@@ -66,9 +66,8 @@ These control playback and subtitle display. They require overlay window focus.
|
||||
| `Ctrl+W` | Quit mpv |
|
||||
| `Right-click` | Toggle pause (outside subtitle area) |
|
||||
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
|
||||
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
|
||||
|
||||
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V`, `Ctrl/Cmd+A`, and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
|
||||
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V` and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
|
||||
|
||||
On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions.
|
||||
|
||||
@@ -83,13 +82,17 @@ 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 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` |
|
||||
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` |
|
||||
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` (overlay) / `shortcuts.toggleSubtitleSidebar` (mpv session binding) |
|
||||
| `` ` `` | 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.
|
||||
|
||||
@@ -10,6 +10,8 @@ SubMiner's primary tokenizer is Yomitan itself - subtitle text is tokenized base
|
||||
|
||||
Before any of those layers render, SubMiner strips annotation metadata from tokens that are usually just subtitle glue or annotation noise. Standalone particles, auxiliaries, adnominals, common explanatory endings like `んです` / `のだ`, merged trailing quote-particle forms like `...って`, auxiliary-stem grammar tails like `そうだ` (MeCab POS3 `助動詞語幹`), repeated kana interjections, and similar non-lexical helper tokens remain hoverable in the subtitle text, but they render as plain tokens without known-word, N+1, frequency, JLPT, or name-match annotation styling.
|
||||
|
||||
Kanji vocabulary that MeCab labels `名詞/非自立`, such as `日` or `以外`, remains content for every annotation layer. The `非自立` exclusion only suppresses kana grammar nouns such as `こと` and `もの`.
|
||||
|
||||
## N+1 Word Highlighting
|
||||
|
||||
N+1 highlighting identifies sentences where you know every word except one, making them ideal mining targets. When enabled, SubMiner builds a local cache of your known vocabulary from Anki and highlights tokens accordingly.
|
||||
@@ -24,16 +26,16 @@ N+1 highlighting identifies sentences where you know every word except one, maki
|
||||
|
||||
**Key settings:**
|
||||
|
||||
| Option | Default | Description |
|
||||
| ----------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting |
|
||||
| `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes |
|
||||
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries |
|
||||
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
|
||||
| `ankiConnect.nPlusOne.enabled` | `false` | Enable N+1 target highlighting |
|
||||
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
|
||||
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word |
|
||||
| `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens |
|
||||
| Option | Default | Description |
|
||||
| ----------------------------------------- | ------------ | -------------------------------------------------------- |
|
||||
| `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting |
|
||||
| `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes |
|
||||
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries |
|
||||
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
|
||||
| `ankiConnect.nPlusOne.enabled` | `false` | Enable N+1 target highlighting |
|
||||
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
|
||||
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word |
|
||||
| `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens |
|
||||
|
||||
Prefer expression/word fields for `ankiConnect.knownWords.decks`. Reading-only fields can mark unrelated homophones as known, so only include them when that tradeoff is intentional.
|
||||
|
||||
|
||||
@@ -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.
|
||||
+4
-1
@@ -151,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
|
||||
@@ -172,7 +173,9 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin corre
|
||||
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. `--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.
|
||||
`--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.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -39,6 +39,7 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
- Runtime-option contracts: `src/types/runtime-options.ts`
|
||||
- Settings UI contracts: `src/types/settings.ts`
|
||||
- Session-binding contracts: `src/types/session-bindings.ts`
|
||||
- Stats HTTP wire contracts: `src/types/stats-wire.ts`, `src/types/stats-http-contract.ts`
|
||||
- Compatibility-only barrel: `src/types.ts`
|
||||
|
||||
## Ownership Heuristics
|
||||
|
||||
@@ -33,6 +33,10 @@ Trend charts now consume one chart-oriented backend payload from `/api/stats/tre
|
||||
|
||||
## Contract
|
||||
|
||||
Stats data crosses the process boundary over HTTP only. Shared JSON models live in `src/types/stats-wire.ts`; endpoint request/response mappings and the client interface live in `src/types/stats-http-contract.ts`. The server and stats client both type-check against those files. `stats/src/types/stats.ts` is a compatibility re-export, not an independent contract copy.
|
||||
|
||||
The stats preload bridge remains limited to native window behavior such as confirmation-dialog layering. Do not add stats data request channels back to Electron IPC.
|
||||
|
||||
The stats UI should treat the trends payload as chart-ready data. Presentation-only work in the client is fine, but rebuilding the main trend datasets from raw sessions should stay out of the render path.
|
||||
|
||||
For session detail timelines, omitting `limit` now means "return the full retained session telemetry/history". Explicit `limit` remains available for bounded callers, but the default stats UI path should not trim long sessions to the newest 200 samples.
|
||||
|
||||
@@ -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,14 +1,17 @@
|
||||
import {
|
||||
launchAppCommandDetached,
|
||||
launchAppBackgroundDetached,
|
||||
launchTexthookerOnly,
|
||||
runAppCommandInteractive,
|
||||
runAppCommandWithInherit,
|
||||
} from '../mpv.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
type AppCommandDeps = {
|
||||
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void;
|
||||
runAppCommandInteractive: (appPath: string, appArgs: string[]) => void;
|
||||
launchSyncUiDetached: (
|
||||
appPath: string,
|
||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||
) => void;
|
||||
launchAppBackgroundDetached: (
|
||||
appPath: string,
|
||||
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||
@@ -17,7 +20,8 @@ type AppCommandDeps = {
|
||||
|
||||
const defaultAppCommandDeps: AppCommandDeps = {
|
||||
runAppCommandWithInherit,
|
||||
runAppCommandInteractive,
|
||||
launchSyncUiDetached: (appPath, logLevel) =>
|
||||
launchAppCommandDetached(appPath, ['--sync-window'], logLevel, 'sync-ui'),
|
||||
launchAppBackgroundDetached,
|
||||
};
|
||||
|
||||
@@ -34,7 +38,7 @@ export function runAppPassthroughCommand(
|
||||
return true;
|
||||
}
|
||||
if (args.syncUi) {
|
||||
deps.runAppCommandInteractive(appPath, ['--sync-window']);
|
||||
deps.launchSyncUiDetached(appPath, args.logLevel);
|
||||
return true;
|
||||
}
|
||||
if (!args.appPassthrough) {
|
||||
|
||||
@@ -206,7 +206,7 @@ test('app command starts default macOS background app detached from launcher', (
|
||||
runAppCommandWithInherit: () => {
|
||||
calls.push('attached');
|
||||
},
|
||||
runAppCommandInteractive: () => calls.push('interactive'),
|
||||
launchSyncUiDetached: () => calls.push('sync-ui'),
|
||||
launchAppBackgroundDetached: (appPath, logLevel) => {
|
||||
calls.push(`detached:${appPath}:${logLevel}`);
|
||||
},
|
||||
@@ -226,7 +226,7 @@ test('app command starts default Linux background app detached from launcher', (
|
||||
runAppCommandWithInherit: () => {
|
||||
calls.push('attached');
|
||||
},
|
||||
runAppCommandInteractive: () => calls.push('interactive'),
|
||||
launchSyncUiDetached: () => calls.push('sync-ui'),
|
||||
launchAppBackgroundDetached: (appPath, logLevel) => {
|
||||
calls.push(`detached:${appPath}:${logLevel}`);
|
||||
},
|
||||
@@ -247,7 +247,7 @@ test('app command keeps explicit passthrough args attached', () => {
|
||||
runAppCommandWithInherit: (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
},
|
||||
runAppCommandInteractive: () => detached.push('interactive'),
|
||||
launchSyncUiDetached: () => detached.push('sync-ui'),
|
||||
launchAppBackgroundDetached: () => {
|
||||
detached.push('detached');
|
||||
},
|
||||
@@ -258,19 +258,19 @@ test('app command keeps explicit passthrough args attached', () => {
|
||||
assert.deepEqual(detached, []);
|
||||
});
|
||||
|
||||
test('sync UI command attaches the app directly to the terminal', () => {
|
||||
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'),
|
||||
runAppCommandInteractive: (_appPath, appArgs) => calls.push(`direct:${appArgs.join(' ')}`),
|
||||
launchSyncUiDetached: (appPath, logLevel) => calls.push(`sync-ui:${appPath}:${logLevel}`),
|
||||
launchAppBackgroundDetached: () => calls.push('detached'),
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(calls, ['direct:--sync-window']);
|
||||
assert.deepEqual(calls, ['sync-ui:/tmp/subminer.app:warn']);
|
||||
});
|
||||
|
||||
test('mpv pre-app command exits non-zero when socket is not ready', async () => {
|
||||
|
||||
@@ -37,17 +37,7 @@ function createContext(): LauncherCommandContext {
|
||||
useRofi: false,
|
||||
history: false,
|
||||
sync: false,
|
||||
syncHost: '',
|
||||
syncSnapshotPath: '',
|
||||
syncMergePath: '',
|
||||
syncDirection: 'both',
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
syncCliTokens: [],
|
||||
syncUi: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
|
||||
@@ -2,7 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { Args } from '../types.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
import { buildSyncCliArgv, runSyncCommand, type SyncCommandDeps } from './sync-command.js';
|
||||
import { runSyncCommand, type SyncCommandDeps } from './sync-command.js';
|
||||
|
||||
function makeContext(
|
||||
overrides: Partial<Args>,
|
||||
@@ -11,17 +11,7 @@ function makeContext(
|
||||
return {
|
||||
args: {
|
||||
sync: true,
|
||||
syncHost: '',
|
||||
syncSnapshotPath: '',
|
||||
syncMergePath: '',
|
||||
syncDirection: 'both',
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
syncCliTokens: [],
|
||||
logLevel: 'warn',
|
||||
...overrides,
|
||||
} as Args,
|
||||
@@ -35,24 +25,6 @@ function makeContext(
|
||||
} as unknown as LauncherCommandContext;
|
||||
}
|
||||
|
||||
function makeArgs(overrides: Partial<Parameters<typeof buildSyncCliArgv>[0]>) {
|
||||
return {
|
||||
syncHost: '',
|
||||
syncSnapshotPath: '',
|
||||
syncMergePath: '',
|
||||
syncDirection: 'both' as const,
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
logLevel: 'warn' as const,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async () => {
|
||||
const spawned: Array<{ appPath: string; appArgs: string[] }> = [];
|
||||
const deps: Partial<SyncCommandDeps> = {
|
||||
@@ -62,7 +34,7 @@ test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async ()
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
await runSyncCommand(makeContext({ syncHost: 'media-box', syncJson: true }), deps),
|
||||
await runSyncCommand(makeContext({ syncCliTokens: ['media-box', '--json'] }), deps),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(spawned, [
|
||||
@@ -76,19 +48,32 @@ test('runSyncCommand proxies sync argv to the app in --sync-cli mode', async ()
|
||||
assert.equal(spawned.length, 1);
|
||||
});
|
||||
|
||||
test('buildSyncCliArgv forwards every sync option', () => {
|
||||
assert.deepEqual(
|
||||
buildSyncCliArgv(
|
||||
makeArgs({
|
||||
syncHost: 'media-box',
|
||||
syncDirection: 'pull',
|
||||
syncRemoteCmd: '/opt/SubMiner.AppImage',
|
||||
syncDbPath: '/tmp/db.sqlite',
|
||||
syncForce: true,
|
||||
syncJson: true,
|
||||
logLevel: 'debug',
|
||||
}),
|
||||
),
|
||||
test('runSyncCommand forwards tokens verbatim and appends the effective log level', async () => {
|
||||
const spawned: string[][] = [];
|
||||
const deps: Partial<SyncCommandDeps> = {
|
||||
runAppCommand: (_appPath, appArgs) => {
|
||||
spawned.push(appArgs);
|
||||
},
|
||||
};
|
||||
|
||||
await runSyncCommand(
|
||||
makeContext({
|
||||
syncCliTokens: [
|
||||
'media-box',
|
||||
'--pull',
|
||||
'--remote-cmd',
|
||||
'/opt/SubMiner.AppImage',
|
||||
'--db',
|
||||
'/tmp/db.sqlite',
|
||||
'--force',
|
||||
'--json',
|
||||
],
|
||||
logLevel: 'debug',
|
||||
}),
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(spawned, [
|
||||
[
|
||||
'--sync-cli',
|
||||
'sync',
|
||||
@@ -103,32 +88,7 @@ test('buildSyncCliArgv forwards every sync option', () => {
|
||||
'--log-level',
|
||||
'debug',
|
||||
],
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildSyncCliArgv(makeArgs({ syncSnapshotPath: '/tmp/out.sqlite' })),
|
||||
['--sync-cli', 'sync', '--snapshot', '/tmp/out.sqlite', '--log-level', 'warn'],
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildSyncCliArgv(makeArgs({ syncHost: 'media-box', syncCheck: true })),
|
||||
['--sync-cli', 'sync', 'media-box', '--check', '--log-level', 'warn'],
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildSyncCliArgv(makeArgs({ syncMakeTemp: true })),
|
||||
['--sync-cli', 'sync', '--make-temp', '--log-level', 'warn'],
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildSyncCliArgv(makeArgs({ syncRemoveTempPath: '/tmp/subminer-sync-x' })),
|
||||
['--sync-cli', 'sync', '--remove-temp', '/tmp/subminer-sync-x', '--log-level', 'warn'],
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
buildSyncCliArgv(makeArgs({ syncMergePath: '/tmp/in.sqlite', syncForce: true })),
|
||||
['--sync-cli', 'sync', '--merge', '/tmp/in.sqlite', '--force', '--log-level', 'warn'],
|
||||
);
|
||||
]);
|
||||
});
|
||||
|
||||
test('runSyncCommand fails with a clear message when the app binary is missing', async () => {
|
||||
@@ -142,7 +102,7 @@ test('runSyncCommand fails with a clear message when the app binary is missing',
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => runSyncCommand(makeContext({ syncHost: 'media-box' }, null), deps),
|
||||
() => runSyncCommand(makeContext({ syncCliTokens: ['media-box'] }, null), deps),
|
||||
/SubMiner app binary not found \(sync runs inside the app\)/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { Args } from '../types.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
export interface SyncCommandDeps {
|
||||
@@ -13,46 +13,12 @@ const defaultSyncCommandDeps: SyncCommandDeps = {
|
||||
fail,
|
||||
};
|
||||
|
||||
type SyncArgs = Pick<
|
||||
Args,
|
||||
| 'syncHost'
|
||||
| 'syncSnapshotPath'
|
||||
| 'syncMergePath'
|
||||
| 'syncDirection'
|
||||
| 'syncRemoteCmd'
|
||||
| 'syncDbPath'
|
||||
| 'syncForce'
|
||||
| 'syncJson'
|
||||
| 'syncCheck'
|
||||
| 'syncMakeTemp'
|
||||
| 'syncRemoveTempPath'
|
||||
| 'logLevel'
|
||||
>;
|
||||
|
||||
/** Rebuild the app's --sync-cli argv from the launcher's parsed sync args. */
|
||||
export function buildSyncCliArgv(args: SyncArgs): string[] {
|
||||
const argv = ['--sync-cli', 'sync'];
|
||||
if (args.syncHost) argv.push(args.syncHost);
|
||||
if (args.syncSnapshotPath) argv.push('--snapshot', args.syncSnapshotPath);
|
||||
if (args.syncMergePath) argv.push('--merge', args.syncMergePath);
|
||||
if (args.syncMakeTemp) argv.push('--make-temp');
|
||||
if (args.syncRemoveTempPath) argv.push('--remove-temp', args.syncRemoveTempPath);
|
||||
if (args.syncDirection === 'push') argv.push('--push');
|
||||
if (args.syncDirection === 'pull') argv.push('--pull');
|
||||
if (args.syncCheck) argv.push('--check');
|
||||
if (args.syncRemoteCmd) argv.push('--remote-cmd', args.syncRemoteCmd);
|
||||
if (args.syncDbPath) argv.push('--db', args.syncDbPath);
|
||||
if (args.syncForce) argv.push('--force');
|
||||
if (args.syncJson) argv.push('--json');
|
||||
argv.push('--log-level', args.logLevel);
|
||||
return argv;
|
||||
}
|
||||
|
||||
/**
|
||||
* `subminer sync` is a thin proxy: the sync engine only executes inside the
|
||||
* SubMiner app (--sync-cli mode, libsql), so the launcher and the app cannot
|
||||
* drift apart. The launcher contributes its parser/help and app discovery;
|
||||
* the child owns the terminal and its exit code becomes the launcher's.
|
||||
* 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,
|
||||
@@ -68,6 +34,12 @@ export async function runSyncCommand(
|
||||
);
|
||||
return true; // fail() never returns; this only satisfies control-flow analysis
|
||||
}
|
||||
deps.runAppCommand(context.appPath, buildSyncCliArgv(context.args));
|
||||
deps.runAppCommand(context.appPath, [
|
||||
SYNC_CLI_FLAG,
|
||||
'sync',
|
||||
...context.args.syncCliTokens,
|
||||
'--log-level',
|
||||
context.args.logLevel,
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -136,17 +136,7 @@ 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,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
syncCliTokens: [],
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
@@ -197,17 +187,7 @@ 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,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
syncCliTokens: [],
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
@@ -251,17 +231,7 @@ 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,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
syncCliTokens: [],
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
@@ -303,17 +273,7 @@ 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,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
syncCliTokens: [],
|
||||
syncLogLevel: null,
|
||||
syncUiTriggered: false,
|
||||
syncUiLogLevel: null,
|
||||
|
||||
@@ -200,17 +200,7 @@ export function createDefaultArgs(
|
||||
useRofi: false,
|
||||
history: false,
|
||||
sync: false,
|
||||
syncHost: '',
|
||||
syncSnapshotPath: '',
|
||||
syncMergePath: '',
|
||||
syncDirection: 'both',
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
syncCliTokens: [],
|
||||
syncUi: false,
|
||||
logLevel: loggingConfig.level ?? 'warn',
|
||||
logRotation: loggingConfig.rotation ?? 7,
|
||||
@@ -279,17 +269,7 @@ 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.syncJson = invocations.syncJson;
|
||||
parsed.syncCheck = invocations.syncCheck;
|
||||
parsed.syncMakeTemp = invocations.syncMakeTemp;
|
||||
parsed.syncRemoveTempPath = invocations.syncRemoveTempPath ?? '';
|
||||
parsed.syncCliTokens = invocations.syncCliTokens;
|
||||
if (invocations.syncLogLevel) parsed.logLevel = parseLogLevel(invocations.syncLogLevel);
|
||||
}
|
||||
if (invocations.syncUiTriggered) {
|
||||
|
||||
@@ -43,42 +43,66 @@ 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.equal(push.invocations.syncDirection, 'push');
|
||||
assert.equal(pull.invocations.syncDirection, 'pull');
|
||||
});
|
||||
|
||||
test('parseCliPrograms rejects conflicting or hostless one-way sync directions', () => {
|
||||
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/,
|
||||
);
|
||||
});
|
||||
|
||||
test('parseCliPrograms captures sync --json and --check flags', () => {
|
||||
const json = parseCliPrograms(['sync', 'media-box', '--json'], 'subminer');
|
||||
assert.equal(json.invocations.syncJson, true);
|
||||
assert.equal(json.invocations.syncCheck, false);
|
||||
assert.deepEqual(pull.invocations.syncCliTokens, ['media-box', '--pull']);
|
||||
|
||||
const check = parseCliPrograms(['sync', 'media-box', '--check', '--json'], 'subminer');
|
||||
assert.equal(check.invocations.syncCheck, true);
|
||||
assert.equal(check.invocations.syncJson, true);
|
||||
assert.equal(check.invocations.syncHost, 'media-box');
|
||||
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 invalid sync --check combinations', () => {
|
||||
assert.throws(() => parseCliPrograms(['sync', '--check'], 'subminer'), /--check requires a host/);
|
||||
assert.throws(
|
||||
() => parseCliPrograms(['sync', 'media-box', '--check', '--push'], 'subminer'),
|
||||
/--check cannot be combined/,
|
||||
);
|
||||
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', () => {
|
||||
@@ -91,3 +115,16 @@ test('parseCliPrograms captures sync --ui', () => {
|
||||
/--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,17 +39,7 @@ 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;
|
||||
syncJson: boolean;
|
||||
syncCheck: boolean;
|
||||
syncMakeTemp: boolean;
|
||||
syncRemoveTempPath: string | null;
|
||||
syncCliTokens: string[];
|
||||
syncLogLevel: string | null;
|
||||
syncUiTriggered: boolean;
|
||||
syncUiLogLevel: string | null;
|
||||
@@ -181,17 +171,7 @@ 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 syncJson = false;
|
||||
let syncCheck = false;
|
||||
let syncMakeTemp = false;
|
||||
let syncRemoveTempPath: string | null = null;
|
||||
let syncCliTokens: string[] = [];
|
||||
let syncLogLevel: string | null = null;
|
||||
let syncUiTriggered = false;
|
||||
let syncUiLogLevel: string | null = null;
|
||||
@@ -360,6 +340,8 @@ export function parseCliPrograms(
|
||||
check ||
|
||||
makeTemp ||
|
||||
removeTemp ||
|
||||
options.remoteCmd !== undefined ||
|
||||
options.db !== undefined ||
|
||||
options.json === true ||
|
||||
options.force === true
|
||||
) {
|
||||
@@ -369,49 +351,25 @@ export function parseCliPrograms(
|
||||
syncUiLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||
return;
|
||||
}
|
||||
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.');
|
||||
}
|
||||
if (check && !host) {
|
||||
throw new Error('Sync --check requires a host.');
|
||||
}
|
||||
if (check && (push || pull || snapshot || merge)) {
|
||||
throw new Error(
|
||||
'Sync --check cannot be combined with --push, --pull, --snapshot, or --merge.',
|
||||
);
|
||||
}
|
||||
if ((makeTemp || removeTemp) && (push || pull || check)) {
|
||||
throw new Error('Sync --make-temp/--remove-temp cannot be combined with other sync options.');
|
||||
}
|
||||
const modes = [
|
||||
Boolean(host),
|
||||
Boolean(snapshot),
|
||||
Boolean(merge),
|
||||
makeTemp,
|
||||
Boolean(removeTemp),
|
||||
].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, --merge, --make-temp, and --remove-temp cannot be combined.');
|
||||
}
|
||||
// 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;
|
||||
syncJson = options.json === true;
|
||||
syncCheck = check;
|
||||
syncMakeTemp = makeTemp;
|
||||
syncRemoveTempPath = removeTemp || null;
|
||||
syncCliTokens = tokens;
|
||||
syncLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||
});
|
||||
|
||||
@@ -527,17 +485,7 @@ export function parseCliPrograms(
|
||||
statsCleanupLifetime,
|
||||
statsLogLevel,
|
||||
syncTriggered,
|
||||
syncHost,
|
||||
syncSnapshotPath,
|
||||
syncMergePath,
|
||||
syncDirection,
|
||||
syncRemoteCmd,
|
||||
syncDbPath,
|
||||
syncForce,
|
||||
syncJson,
|
||||
syncCheck,
|
||||
syncMakeTemp,
|
||||
syncRemoveTempPath,
|
||||
syncCliTokens,
|
||||
syncLogLevel,
|
||||
syncUiTriggered,
|
||||
syncUiLogLevel,
|
||||
|
||||
+2
-28
@@ -1,38 +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 { isReadonlyWalRetryError, withReadonlyWalRetry };
|
||||
|
||||
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;
|
||||
|
||||
@@ -31,17 +31,7 @@ function createArgs(): Args {
|
||||
useRofi: false,
|
||||
history: false,
|
||||
sync: false,
|
||||
syncHost: '',
|
||||
syncSnapshotPath: '',
|
||||
syncMergePath: '',
|
||||
syncDirection: 'both',
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
syncCliTokens: [],
|
||||
syncUi: false,
|
||||
logLevel: 'info',
|
||||
logRotation: 7,
|
||||
|
||||
+32
-11
@@ -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,17 +603,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
useRofi: false,
|
||||
history: false,
|
||||
sync: false,
|
||||
syncHost: '',
|
||||
syncSnapshotPath: '',
|
||||
syncMergePath: '',
|
||||
syncDirection: 'both',
|
||||
syncRemoteCmd: '',
|
||||
syncDbPath: '',
|
||||
syncForce: false,
|
||||
syncJson: false,
|
||||
syncCheck: false,
|
||||
syncMakeTemp: false,
|
||||
syncRemoveTempPath: '',
|
||||
syncCliTokens: [],
|
||||
syncUi: false,
|
||||
logLevel: 'error',
|
||||
logRotation: 7,
|
||||
|
||||
@@ -1277,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 = {},
|
||||
@@ -1432,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: [],
|
||||
|
||||
@@ -4,17 +4,11 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { openLibsqlSyncDb } from '../../src/core/services/stats-sync/libsql-driver.js';
|
||||
import { createDbSnapshot as createDbSnapshotWith } from '../../src/core/services/stats-sync/shared.js';
|
||||
import { mergeSnapshotIntoDb as mergeSnapshotIntoDbWith } from '../../src/core/services/stats-sync/merge.js';
|
||||
|
||||
// The engine only executes inside the app (libsql driver) in production, so
|
||||
// these merge tests run through that same binding; bun:sqlite is used only to
|
||||
// build fixtures and inspect results.
|
||||
const createDbSnapshot = (dbPath: string, outPath: string) =>
|
||||
createDbSnapshotWith(openLibsqlSyncDb, dbPath, outPath);
|
||||
const mergeSnapshotIntoDb = (localDbPath: string, snapshotPath: string) =>
|
||||
mergeSnapshotIntoDbWith(openLibsqlSyncDb, localDbPath, snapshotPath);
|
||||
// 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,
|
||||
|
||||
+2
-11
@@ -114,17 +114,8 @@ 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;
|
||||
syncJson: boolean;
|
||||
syncCheck: boolean;
|
||||
syncMakeTemp: boolean;
|
||||
syncRemoveTempPath: string;
|
||||
/** App-owned sync argv tokens forwarded verbatim to `--sync-cli sync`. */
|
||||
syncCliTokens: string[];
|
||||
syncUi: boolean;
|
||||
logLevel: LogLevel;
|
||||
logRotation: LogRotation;
|
||||
|
||||
+15
-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",
|
||||
@@ -23,7 +23,7 @@
|
||||
"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",
|
||||
"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",
|
||||
@@ -87,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",
|
||||
@@ -110,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",
|
||||
@@ -259,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,6 +254,8 @@ function M.create(ctx)
|
||||
return { "--open-runtime-options" }
|
||||
elseif action_id == "openJimaku" then
|
||||
return { "--open-jimaku" }
|
||||
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/);
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
+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', () => {
|
||||
|
||||
@@ -115,6 +115,7 @@ test('parseArgs captures session action forwarding flags', () => {
|
||||
'--toggle-stats-overlay',
|
||||
'--mark-watched',
|
||||
'--open-jimaku',
|
||||
'--open-tsukihime',
|
||||
'--open-youtube-picker',
|
||||
'--open-playlist-browser',
|
||||
'--toggle-primary-subtitle-bar',
|
||||
@@ -132,6 +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.openTsukihime, true);
|
||||
assert.equal(args.openYoutubePicker, true);
|
||||
assert.equal(args.openPlaylistBrowser, true);
|
||||
assert.equal(args.togglePrimarySubtitleBar, true);
|
||||
@@ -146,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']);
|
||||
|
||||
|
||||
+10
-1
@@ -38,6 +38,7 @@ export interface CliArgs {
|
||||
openControllerSelect: boolean;
|
||||
openControllerDebug: boolean;
|
||||
openJimaku: boolean;
|
||||
openTsukihime: boolean;
|
||||
openYoutubePicker: boolean;
|
||||
openPlaylistBrowser: boolean;
|
||||
replayCurrentSubtitle: boolean;
|
||||
@@ -147,6 +148,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
openControllerSelect: false,
|
||||
openControllerDebug: false,
|
||||
openJimaku: false,
|
||||
openTsukihime: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
@@ -295,7 +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-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;
|
||||
@@ -568,6 +572,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.openControllerSelect ||
|
||||
args.openControllerDebug ||
|
||||
args.openJimaku ||
|
||||
args.openTsukihime ||
|
||||
args.openYoutubePicker ||
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
@@ -646,6 +651,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.openControllerSelect &&
|
||||
!args.openControllerDebug &&
|
||||
!args.openJimaku &&
|
||||
!args.openTsukihime &&
|
||||
!args.openYoutubePicker &&
|
||||
!args.openPlaylistBrowser &&
|
||||
!args.replayCurrentSubtitle &&
|
||||
@@ -713,6 +719,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.openControllerSelect ||
|
||||
args.openControllerDebug ||
|
||||
args.openJimaku ||
|
||||
args.openTsukihime ||
|
||||
args.openYoutubePicker ||
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
@@ -774,6 +781,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
||||
!args.openControllerSelect &&
|
||||
!args.openControllerDebug &&
|
||||
!args.openJimaku &&
|
||||
!args.openTsukihime &&
|
||||
!args.openYoutubePicker &&
|
||||
!args.openPlaylistBrowser &&
|
||||
!args.replayCurrentSubtitle &&
|
||||
@@ -839,6 +847,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
||||
args.openControllerSelect ||
|
||||
args.openControllerDebug ||
|
||||
args.openJimaku ||
|
||||
args.openTsukihime ||
|
||||
args.openYoutubePicker ||
|
||||
args.openPlaylistBrowser ||
|
||||
args.replayCurrentSubtitle ||
|
||||
|
||||
@@ -37,8 +37,18 @@ const {
|
||||
notifications,
|
||||
auto_start_overlay,
|
||||
} = CORE_DEFAULT_CONFIG;
|
||||
const { ankiConnect, jimaku, anilist, mpv, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
|
||||
INTEGRATIONS_DEFAULT_CONFIG;
|
||||
const {
|
||||
ankiConnect,
|
||||
jimaku,
|
||||
tsukihime,
|
||||
anilist,
|
||||
mpv,
|
||||
yomitan,
|
||||
jellyfin,
|
||||
discordPresence,
|
||||
ai,
|
||||
youtubeSubgen,
|
||||
} = INTEGRATIONS_DEFAULT_CONFIG;
|
||||
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
|
||||
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
||||
const { stats } = STATS_DEFAULT_CONFIG;
|
||||
@@ -63,6 +73,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
subtitleSidebar,
|
||||
auto_start_overlay,
|
||||
jimaku,
|
||||
tsukihime,
|
||||
anilist,
|
||||
mpv,
|
||||
yomitan,
|
||||
|
||||
@@ -98,11 +98,13 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
openCharacterDictionaryManager: 'CommandOrControl+D',
|
||||
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
||||
openJimaku: 'Ctrl+Shift+J',
|
||||
openTsukihime: 'Ctrl+Shift+T',
|
||||
openSessionHelp: 'CommandOrControl+Slash',
|
||||
openControllerSelect: 'Alt+C',
|
||||
openControllerDebug: 'Alt+Shift+C',
|
||||
toggleSubtitleSidebar: 'Backslash',
|
||||
toggleNotificationHistory: 'CommandOrControl+N',
|
||||
appendClipboardVideoToQueue: 'CommandOrControl+A',
|
||||
},
|
||||
secondarySub: {
|
||||
secondarySubLanguages: [],
|
||||
|
||||
@@ -5,6 +5,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
ResolvedConfig,
|
||||
| 'ankiConnect'
|
||||
| 'jimaku'
|
||||
| 'tsukihime'
|
||||
| 'anilist'
|
||||
| 'mpv'
|
||||
| 'yomitan'
|
||||
@@ -96,6 +97,10 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
languagePreference: 'ja',
|
||||
maxEntryResults: 10,
|
||||
},
|
||||
tsukihime: {
|
||||
apiBaseUrl: 'https://api.tsukihime.org/v1',
|
||||
maxSearchResults: 10,
|
||||
},
|
||||
mpv: {
|
||||
executablePath: '',
|
||||
launchMode: 'normal',
|
||||
|
||||
@@ -615,6 +615,13 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.shortcuts.openJimaku,
|
||||
description: 'Accelerator that opens the Jimaku subtitle search modal.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openTsukihime',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openTsukihime,
|
||||
description:
|
||||
'Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openSessionHelp',
|
||||
kind: 'string',
|
||||
@@ -646,5 +653,11 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.shortcuts.toggleNotificationHistory,
|
||||
description: 'Accelerator that toggles the overlay notification history panel.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.appendClipboardVideoToQueue',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.appendClipboardVideoToQueue,
|
||||
description: 'Accelerator that appends a video path from the clipboard to the mpv playlist.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -400,6 +400,19 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.jimaku.maxEntryResults,
|
||||
description: 'Maximum Jimaku search results returned.',
|
||||
},
|
||||
{
|
||||
path: 'tsukihime.apiBaseUrl',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.tsukihime.apiBaseUrl,
|
||||
description:
|
||||
'Base URL of the TsukiHime API (Animetosho successor). No API key required.',
|
||||
},
|
||||
{
|
||||
path: 'tsukihime.maxSearchResults',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.tsukihime.maxSearchResults,
|
||||
description: 'Maximum TsukiHime search results returned.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.enabled',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -53,6 +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',
|
||||
|
||||
@@ -147,6 +147,14 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
notes: ['Hot-reload: Jimaku changes apply to the next Jimaku request.'],
|
||||
key: 'jimaku',
|
||||
},
|
||||
{
|
||||
title: 'TsukiHime',
|
||||
description: [
|
||||
'TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.',
|
||||
],
|
||||
notes: ['Hot-reload: TsukiHime changes apply to the next TsukiHime request.'],
|
||||
key: 'tsukihime',
|
||||
},
|
||||
{
|
||||
title: 'YouTube Playback Settings',
|
||||
description: [
|
||||
|
||||
@@ -236,7 +236,13 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
'openCharacterDictionaryManager',
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
'openTsukihime',
|
||||
'openSessionHelp',
|
||||
'openControllerSelect',
|
||||
'openControllerDebug',
|
||||
'toggleSubtitleSidebar',
|
||||
'toggleNotificationHistory',
|
||||
'appendClipboardVideoToQueue',
|
||||
] as const;
|
||||
|
||||
for (const key of shortcutKeys) {
|
||||
@@ -248,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,6 +80,32 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
const currentTsukihimeSource = isObject(src.tsukihime) ? src.tsukihime : null;
|
||||
if (src.tsukihime !== undefined && !currentTsukihimeSource) {
|
||||
warn('tsukihime', src.tsukihime, resolved.tsukihime, 'Expected object.');
|
||||
}
|
||||
|
||||
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.tsukihime.maxSearchResults = Math.floor(maxSearchResults);
|
||||
} else if (tsukihimeSource.maxSearchResults !== undefined) {
|
||||
warn(
|
||||
`${tsukihimeSourcePath}.maxSearchResults`,
|
||||
tsukihimeSource.maxSearchResults,
|
||||
resolved.tsukihime.maxSearchResults,
|
||||
'Expected positive number.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.youtubeSubgen)) {
|
||||
const whisperBin = asString(src.youtubeSubgen.whisperBin);
|
||||
if (whisperBin !== undefined) {
|
||||
|
||||
@@ -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,13 +595,14 @@ function subsectionForPath(path: string): string | undefined {
|
||||
leaf === 'openCharacterDictionaryManager' ||
|
||||
leaf === 'openRuntimeOptions' ||
|
||||
leaf === 'openJimaku' ||
|
||||
leaf === 'openTsukihime' ||
|
||||
leaf === 'openSessionHelp' ||
|
||||
leaf === 'openControllerSelect' ||
|
||||
leaf === 'openControllerDebug'
|
||||
) {
|
||||
return 'Open Panels';
|
||||
}
|
||||
if (leaf === 'triggerSubsync') return 'Playback';
|
||||
if (leaf === 'triggerSubsync' || leaf === 'appendClipboardVideoToQueue') return 'Playback';
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -170,6 +170,8 @@ const TRENDS_DASHBOARD = {
|
||||
},
|
||||
ratios: {
|
||||
lookupsPerHundred: [{ label: 'Mar 1', value: 5 }],
|
||||
cardsPerHour: [{ label: 'Mar 1', value: 12 }],
|
||||
readingSpeed: [{ label: 'Mar 1', value: 180 }],
|
||||
},
|
||||
librarySummary: [
|
||||
{
|
||||
|
||||
@@ -55,6 +55,11 @@ test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => {
|
||||
isRemoteMediaPath: () => false,
|
||||
downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }),
|
||||
onDownloadedSubtitle: () => {},
|
||||
searchTsukihimeEntries: async () => ({ ok: true, data: [] }),
|
||||
listTsukihimeFiles: async () => ({ ok: true, data: [] }),
|
||||
downloadTsukihimeSubtitle: async () => ({ ok: true, path: '/tmp/sub.en.ass' }),
|
||||
getTsukihimeSecondaryLanguages: () => ['en'],
|
||||
onDownloadedSecondarySubtitle: () => {},
|
||||
},
|
||||
registrar,
|
||||
);
|
||||
@@ -92,6 +97,109 @@ test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => {
|
||||
ok: false,
|
||||
error: { error: 'Invalid Jimaku download query payload', code: 400 },
|
||||
});
|
||||
|
||||
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 TsukiHime search query payload', code: 400 },
|
||||
});
|
||||
|
||||
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 TsukiHime files query payload', code: 400 },
|
||||
});
|
||||
|
||||
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 TsukiHime download query payload', code: 400 },
|
||||
});
|
||||
|
||||
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-TsukiHime URL.', code: 400 },
|
||||
});
|
||||
});
|
||||
|
||||
test('tsukihime downloads always route Japanese as primary', async () => {
|
||||
const { registrar, handleHandlers } = createFakeRegistrar();
|
||||
const primaryLoads: string[] = [];
|
||||
const secondaryLoads: string[] = [];
|
||||
registerAnkiJimakuIpcHandlers(
|
||||
{
|
||||
setAnkiConnectEnabled: () => {},
|
||||
clearAnkiHistory: () => {},
|
||||
refreshKnownWords: async () => {},
|
||||
respondFieldGrouping: () => {},
|
||||
buildKikuMergePreview: async () => ({ ok: true }),
|
||||
getJimakuMediaInfo: () => ({
|
||||
title: 'x',
|
||||
season: null,
|
||||
episode: null,
|
||||
confidence: 'high',
|
||||
filename: 'x.mkv',
|
||||
rawTitle: 'x',
|
||||
}),
|
||||
searchJimakuEntries: async () => ({ ok: true, data: [] }),
|
||||
listJimakuFiles: async () => ({ ok: true, data: [] }),
|
||||
resolveJimakuApiKey: async () => 'token',
|
||||
getCurrentMediaPath: () => '/tmp/a.mkv',
|
||||
isRemoteMediaPath: () => false,
|
||||
downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }),
|
||||
onDownloadedSubtitle: (path) => {
|
||||
primaryLoads.push(path);
|
||||
},
|
||||
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);
|
||||
},
|
||||
},
|
||||
registrar,
|
||||
);
|
||||
|
||||
const downloadHandler = handleHandlers.get(IPC_CHANNELS.request.tsukihimeDownloadFile)!;
|
||||
|
||||
const engResult = (await downloadHandler!(
|
||||
{},
|
||||
{
|
||||
entryId: 1,
|
||||
url: 'https://storage.tsukihime.org/attach/00000001/1.xz',
|
||||
name: 'episode.eng.ass',
|
||||
lang: 'eng',
|
||||
},
|
||||
)) as { ok: boolean };
|
||||
assert.equal(engResult.ok, true);
|
||||
assert.equal(primaryLoads.length, 0);
|
||||
assert.equal(secondaryLoads.length, 1);
|
||||
assert.match(secondaryLoads[0]!, /\.en.*\.ass$/);
|
||||
|
||||
const jpnResult = (await downloadHandler!(
|
||||
{},
|
||||
{
|
||||
entryId: 1,
|
||||
url: 'https://storage.tsukihime.org/attach/00000002/2.xz',
|
||||
name: 'episode.jpn.ass',
|
||||
lang: 'jpn',
|
||||
},
|
||||
)) as { ok: boolean };
|
||||
assert.equal(jpnResult.ok, true);
|
||||
assert.equal(secondaryLoads.length, 1);
|
||||
assert.equal(primaryLoads.length, 1);
|
||||
assert.match(primaryLoads[0]!, /\.ja.*\.ass$/);
|
||||
});
|
||||
|
||||
test('anki/jimaku IPC command handlers ignore malformed payloads', () => {
|
||||
@@ -124,6 +232,11 @@ test('anki/jimaku IPC command handlers ignore malformed payloads', () => {
|
||||
isRemoteMediaPath: () => false,
|
||||
downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }),
|
||||
onDownloadedSubtitle: () => {},
|
||||
searchTsukihimeEntries: async () => ({ ok: true, data: [] }),
|
||||
listTsukihimeFiles: async () => ({ ok: true, data: [] }),
|
||||
downloadTsukihimeSubtitle: async () => ({ ok: true, path: '/tmp/sub.en.ass' }),
|
||||
getTsukihimeSecondaryLanguages: () => ['en'],
|
||||
onDownloadedSecondarySubtitle: () => {},
|
||||
},
|
||||
registrar,
|
||||
);
|
||||
|
||||
@@ -4,6 +4,12 @@ import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { createLogger } from '../../logger';
|
||||
import {
|
||||
TsukihimeApiResponse,
|
||||
TsukihimeDownloadResult,
|
||||
TsukihimeEntry,
|
||||
TsukihimeFilesQuery,
|
||||
TsukihimeSearchQuery,
|
||||
TsukihimeSubtitleFile,
|
||||
JimakuApiResponse,
|
||||
JimakuDownloadResult,
|
||||
JimakuEntry,
|
||||
@@ -17,6 +23,9 @@ import {
|
||||
} from '../../types';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
parseTsukihimeDownloadQuery,
|
||||
parseTsukihimeFilesQuery,
|
||||
parseTsukihimeSearchQuery,
|
||||
parseJimakuDownloadQuery,
|
||||
parseJimakuFilesQuery,
|
||||
parseJimakuSearchQuery,
|
||||
@@ -24,6 +33,7 @@ import {
|
||||
parseKikuMergePreviewRequest,
|
||||
} from '../../shared/ipc/validators';
|
||||
import { buildJimakuSubtitleFilenameFromMediaPath } from './jimaku-download-path';
|
||||
import { tsukihimeLangToFilenameSuffix, isTsukihimeDownloadUrl } from '../../tsukihime/utils';
|
||||
|
||||
const { ipcMain } = electron;
|
||||
|
||||
@@ -47,6 +57,15 @@ export interface AnkiJimakuIpcDeps {
|
||||
headers: Record<string, string>,
|
||||
) => Promise<JimakuDownloadResult>;
|
||||
onDownloadedSubtitle: (pathToSubtitle: string) => void;
|
||||
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>;
|
||||
}
|
||||
|
||||
interface IpcMainRegistrar {
|
||||
@@ -186,4 +205,109 @@ export function registerAnkiJimakuIpcHandlers(
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.tsukihimeGetSecondaryLanguages, (): string[] => {
|
||||
return deps.getTsukihimeSecondaryLanguages();
|
||||
});
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.tsukihimeSearchEntries,
|
||||
async (_event, query: unknown): Promise<TsukihimeApiResponse<TsukihimeEntry[]>> => {
|
||||
const parsedQuery = parseTsukihimeSearchQuery(query);
|
||||
if (!parsedQuery) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { error: 'Invalid TsukiHime search query payload', code: 400 },
|
||||
};
|
||||
}
|
||||
return deps.searchTsukihimeEntries(parsedQuery);
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.tsukihimeListFiles,
|
||||
async (_event, query: unknown): Promise<TsukihimeApiResponse<TsukihimeSubtitleFile[]>> => {
|
||||
const parsedQuery = parseTsukihimeFilesQuery(query);
|
||||
if (!parsedQuery) {
|
||||
return { ok: false, error: { error: 'Invalid TsukiHime files query payload', code: 400 } };
|
||||
}
|
||||
return deps.listTsukihimeFiles(parsedQuery);
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.tsukihimeDownloadFile,
|
||||
async (_event, query: unknown): Promise<TsukihimeDownloadResult> => {
|
||||
const parsedQuery = parseTsukihimeDownloadQuery(query);
|
||||
if (!parsedQuery) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { error: 'Invalid TsukiHime download query payload', code: 400 },
|
||||
};
|
||||
}
|
||||
|
||||
if (!isTsukihimeDownloadUrl(parsedQuery.url)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { error: 'Refusing to download subtitle from a non-TsukiHime URL.', code: 400 },
|
||||
};
|
||||
}
|
||||
|
||||
const currentMediaPath = deps.getCurrentMediaPath();
|
||||
if (!currentMediaPath) {
|
||||
return { ok: false, error: { error: 'No media file loaded in MPV.' } };
|
||||
}
|
||||
|
||||
const mediaDir = deps.isRemoteMediaPath(currentMediaPath)
|
||||
? 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 = tsukihimeLangToFilenameSuffix(parsedQuery.lang);
|
||||
const subtitleFilename = buildJimakuSubtitleFilenameFromMediaPath(
|
||||
currentMediaPath,
|
||||
safeName,
|
||||
languageSuffix,
|
||||
);
|
||||
|
||||
const ext = path.extname(subtitleFilename);
|
||||
const baseName = ext ? subtitleFilename.slice(0, -ext.length) : subtitleFilename;
|
||||
let targetPath = path.join(mediaDir, subtitleFilename);
|
||||
if (fs.existsSync(targetPath)) {
|
||||
targetPath = path.join(mediaDir, `${baseName} (tsukihime-${parsedQuery.entryId})${ext}`);
|
||||
let counter = 2;
|
||||
while (fs.existsSync(targetPath)) {
|
||||
targetPath = path.join(
|
||||
mediaDir,
|
||||
`${baseName} (tsukihime-${parsedQuery.entryId}-${counter})${ext}`,
|
||||
);
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[tsukihime] download-file name="${parsedQuery.name}" entryId=${parsedQuery.entryId}`,
|
||||
);
|
||||
const result = await deps.downloadTsukihimeSubtitle(parsedQuery.url, targetPath);
|
||||
|
||||
if (result.ok) {
|
||||
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') {
|
||||
deps.onDownloadedSubtitle(result.path);
|
||||
} else {
|
||||
await deps.onDownloadedSecondarySubtitle(result.path);
|
||||
}
|
||||
} else {
|
||||
logger.error(
|
||||
`[tsukihime] download-file failed: ${result.error?.error ?? 'unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ interface RuntimeHarness {
|
||||
patches: boolean[];
|
||||
broadcasts: number;
|
||||
fetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
|
||||
sentCommands: Array<{ command: string[] }>;
|
||||
tsukihimeFetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
|
||||
sentCommands: Array<{ command: (string | number)[] }>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,21 +26,77 @@ function createHarness(): RuntimeHarness {
|
||||
endpoint: string;
|
||||
query?: Record<string, unknown>;
|
||||
}>,
|
||||
sentCommands: [] as Array<{ command: string[] }>,
|
||||
tsukihimeFetchCalls: [] as Array<{
|
||||
endpoint: string;
|
||||
query?: Record<string, unknown>;
|
||||
}>,
|
||||
sentCommands: [] as Array<{ command: (string | number)[] }>,
|
||||
};
|
||||
|
||||
const options: AnkiJimakuIpcRuntimeOptions = {
|
||||
patchAnkiConnectEnabled: (enabled) => {
|
||||
state.patches.push(enabled);
|
||||
},
|
||||
getResolvedConfig: () => ({}),
|
||||
getResolvedConfig: () => ({ tsukihime: { maxSearchResults: 2 } }),
|
||||
getRuntimeOptionsManager: () => null,
|
||||
tsukihimeFetchJson: async (endpoint, query) => {
|
||||
state.tsukihimeFetchCalls.push({
|
||||
endpoint,
|
||||
query: query as Record<string, unknown>,
|
||||
});
|
||||
if (endpoint.startsWith('/torrents/')) {
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
id: 606713,
|
||||
files: [
|
||||
{
|
||||
id: 9,
|
||||
filename: 'episode.mkv',
|
||||
attachments: [
|
||||
{
|
||||
id: 1955356,
|
||||
type: 1,
|
||||
info: { codec: 'ASS', lang: 'en', name: 'English subs' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
results: [
|
||||
{ id: 1, name: 'release a' },
|
||||
{ id: 2, name: 'release b' },
|
||||
{ id: 3, name: 'release c' },
|
||||
],
|
||||
} as never,
|
||||
};
|
||||
},
|
||||
getSubtitleTimingTracker: () => null,
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
send: (payload) => {
|
||||
state.sentCommands.push(payload);
|
||||
},
|
||||
request: async (command: unknown[]) => {
|
||||
state.sentCommands.push({ command } as never);
|
||||
return {
|
||||
data: [
|
||||
{ id: 1, type: 'sub', selected: true },
|
||||
{
|
||||
id: 3,
|
||||
type: 'sub',
|
||||
lang: 'en',
|
||||
external: true,
|
||||
'external-filename': '/tmp/video.en.ass',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
}),
|
||||
getAnkiIntegration: () => state.ankiIntegration as never,
|
||||
setAnkiIntegration: (integration) => {
|
||||
@@ -117,6 +174,10 @@ test('registerAnkiJimakuIpcRuntime provides full handler surface', () => {
|
||||
'isRemoteMediaPath',
|
||||
'downloadToFile',
|
||||
'onDownloadedSubtitle',
|
||||
'searchTsukihimeEntries',
|
||||
'listTsukihimeFiles',
|
||||
'downloadTsukihimeSubtitle',
|
||||
'onDownloadedSecondarySubtitle',
|
||||
];
|
||||
|
||||
for (const key of expected) {
|
||||
@@ -253,3 +314,92 @@ test('searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to
|
||||
registered.onDownloadedSubtitle!('/tmp/subtitle.ass');
|
||||
assert.deepEqual(state.sentCommands, [{ command: ['sub-add', '/tmp/subtitle.ass', 'select'] }]);
|
||||
});
|
||||
|
||||
test('onDownloadedSecondarySubtitle loads without stealing the primary track', async () => {
|
||||
const { registered, state } = createHarness();
|
||||
|
||||
await registered.onDownloadedSecondarySubtitle!('/tmp/video.en.ass');
|
||||
|
||||
assert.deepEqual(state.sentCommands[0], { command: ['sub-add', '/tmp/video.en.ass', 'auto'] });
|
||||
assert.deepEqual(state.sentCommands[1], { command: ['get_property', 'track-list'] });
|
||||
assert.deepEqual(state.sentCommands[2], { command: ['set_property', 'secondary-sid', 3] });
|
||||
});
|
||||
|
||||
test('onDownloadedSecondarySubtitle retries until mpv reports the new track', async () => {
|
||||
const state = {
|
||||
sentCommands: [] as Array<{ command: (string | number)[] }>,
|
||||
trackListCalls: 0,
|
||||
};
|
||||
|
||||
const options = {
|
||||
...createHarness().options,
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
send: (payload: { command: (string | number)[] }) => {
|
||||
state.sentCommands.push(payload);
|
||||
},
|
||||
request: async () => {
|
||||
state.trackListCalls += 1;
|
||||
// mpv has not registered the external file yet on the first poll.
|
||||
if (state.trackListCalls < 2) {
|
||||
return { data: [{ id: 1, type: 'sub', selected: true }] };
|
||||
}
|
||||
return {
|
||||
data: [
|
||||
{ id: 1, type: 'sub', selected: true },
|
||||
{ id: 4, type: 'sub', external: true, 'external-filename': '/tmp/video.en.ass' },
|
||||
],
|
||||
};
|
||||
},
|
||||
}),
|
||||
} as unknown as AnkiJimakuIpcRuntimeOptions;
|
||||
|
||||
let registered: Record<string, (...args: unknown[]) => unknown> = {};
|
||||
registerAnkiJimakuIpcRuntime(options, (deps) => {
|
||||
registered = deps as unknown as Record<string, (...args: unknown[]) => unknown>;
|
||||
});
|
||||
|
||||
await registered.onDownloadedSecondarySubtitle!('/tmp/video.en.ass');
|
||||
|
||||
assert.equal(state.trackListCalls, 2);
|
||||
assert.deepEqual(state.sentCommands.at(-1), {
|
||||
command: ['set_property', 'secondary-sid', 4],
|
||||
});
|
||||
});
|
||||
|
||||
test('searchTsukihimeEntries caps results using tsukihime.maxSearchResults', async () => {
|
||||
const { registered, state } = createHarness();
|
||||
|
||||
const searchResult = await registered.searchTsukihimeEntries!({ query: 'frieren 28' });
|
||||
assert.deepEqual(state.tsukihimeFetchCalls, [
|
||||
{
|
||||
endpoint: '/search/torrents',
|
||||
query: { q: 'frieren 28', limit: 2 },
|
||||
},
|
||||
]);
|
||||
assert.equal((searchResult as { ok: boolean }).ok, true);
|
||||
const entries = (searchResult as { data: Array<{ id: number }> }).data;
|
||||
assert.equal(entries.length, 2);
|
||||
assert.deepEqual(
|
||||
entries.map((entry) => entry.id),
|
||||
[1, 2],
|
||||
);
|
||||
});
|
||||
|
||||
test('listTsukihimeFiles extracts subtitle attachments from torrent detail', async () => {
|
||||
const { registered, state } = createHarness();
|
||||
|
||||
const filesResult = await registered.listTsukihimeFiles!({ entryId: 606713 });
|
||||
assert.deepEqual(state.tsukihimeFetchCalls, [
|
||||
{
|
||||
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.en.ass');
|
||||
assert.equal(files[0]!.url, 'https://storage.tsukihime.org/attach/001dd61c/1955356.xz');
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import * as fs from 'fs';
|
||||
import { AnkiIntegration } from '../../anki-integration';
|
||||
import { mergeAiConfig } from '../../ai/config';
|
||||
import {
|
||||
AiConfig,
|
||||
TsukihimeApiResponse,
|
||||
TsukihimeConfig,
|
||||
AnkiConnectConfig,
|
||||
JimakuApiResponse,
|
||||
JimakuEntry,
|
||||
@@ -13,6 +16,14 @@ import {
|
||||
OverlayNotificationPayload,
|
||||
} from '../../types';
|
||||
import { sortJimakuFiles } from '../../jimaku/utils';
|
||||
import {
|
||||
TSUKIHIME_API_BASE_URL,
|
||||
tsukihimeFetchJson as tsukihimeFetchJsonRequest,
|
||||
decompressXzFile,
|
||||
extractTsukihimeSubtitleFiles,
|
||||
isTsukihimeDownloadUrl,
|
||||
mapTsukihimeSearchResults,
|
||||
} from '../../tsukihime/utils';
|
||||
import type { AnkiJimakuIpcDeps } from './anki-jimaku-ipc';
|
||||
import { createLogger } from '../../logger';
|
||||
|
||||
@@ -20,7 +31,8 @@ export type RegisterAnkiJimakuIpcRuntimeHandler = (deps: AnkiJimakuIpcDeps) => v
|
||||
|
||||
interface MpvClientLike {
|
||||
connected: boolean;
|
||||
send: (payload: { command: string[] }) => void;
|
||||
send: (payload: { command: (string | number)[] }) => void;
|
||||
request?: (command: unknown[]) => Promise<{ data?: unknown }>;
|
||||
}
|
||||
|
||||
interface RuntimeOptionsManagerLike {
|
||||
@@ -33,7 +45,12 @@ interface SubtitleTimingTrackerLike {
|
||||
|
||||
export interface AnkiJimakuIpcRuntimeOptions {
|
||||
patchAnkiConnectEnabled: (enabled: boolean) => void;
|
||||
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig };
|
||||
getResolvedConfig: () => {
|
||||
ankiConnect?: AnkiConnectConfig;
|
||||
ai?: AiConfig;
|
||||
tsukihime?: TsukihimeConfig;
|
||||
secondarySub?: { secondarySubLanguages?: string[] };
|
||||
};
|
||||
getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null;
|
||||
getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null;
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
@@ -60,6 +77,10 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
||||
endpoint: string,
|
||||
query?: Record<string, string | number | boolean | null | undefined>,
|
||||
) => Promise<JimakuApiResponse<T>>;
|
||||
tsukihimeFetchJson?: <T>(
|
||||
endpoint: string,
|
||||
query?: Record<string, string | number | boolean | null | undefined>,
|
||||
) => Promise<TsukihimeApiResponse<T>>;
|
||||
getJimakuMaxEntryResults: () => number;
|
||||
getJimakuLanguagePreference: () => JimakuLanguagePreference;
|
||||
resolveJimakuApiKey: () => Promise<string | null>;
|
||||
@@ -68,6 +89,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
||||
url: string,
|
||||
destPath: string,
|
||||
headers: Record<string, string>,
|
||||
downloadOptions?: { isAllowedRedirect?: (url: URL) => boolean },
|
||||
) => Promise<
|
||||
| { ok: true; path: string }
|
||||
| {
|
||||
@@ -79,6 +101,34 @@ export interface AnkiJimakuIpcRuntimeOptions {
|
||||
|
||||
const logger = createLogger('main:anki-jimaku');
|
||||
|
||||
const DEFAULT_TSUKIHIME_MAX_SEARCH_RESULTS = 10;
|
||||
const SECONDARY_TRACK_LOOKUP_ATTEMPTS = 5;
|
||||
const SECONDARY_TRACK_LOOKUP_RETRY_MS = 100;
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
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_TSUKIHIME_MAX_SEARCH_RESULTS;
|
||||
}
|
||||
|
||||
function tsukihimeFetch<T>(
|
||||
options: AnkiJimakuIpcRuntimeOptions,
|
||||
endpoint: string,
|
||||
query: Record<string, string | number | boolean | null | undefined>,
|
||||
): Promise<TsukihimeApiResponse<T>> {
|
||||
if (options.tsukihimeFetchJson) {
|
||||
return options.tsukihimeFetchJson<T>(endpoint, query);
|
||||
}
|
||||
const baseUrl = options.getResolvedConfig().tsukihime?.apiBaseUrl || TSUKIHIME_API_BASE_URL;
|
||||
return tsukihimeFetchJsonRequest<T>(endpoint, query, { baseUrl });
|
||||
}
|
||||
|
||||
export function registerAnkiJimakuIpcRuntime(
|
||||
options: AnkiJimakuIpcRuntimeOptions,
|
||||
registerHandlers: RegisterAnkiJimakuIpcRuntimeHandler,
|
||||
@@ -191,11 +241,86 @@ export function registerAnkiJimakuIpcRuntime(
|
||||
getCurrentMediaPath: () => options.getCurrentMediaPath(),
|
||||
isRemoteMediaPath: (mediaPath) => options.isRemoteMediaPath(mediaPath),
|
||||
downloadToFile: (url, destPath, headers) => options.downloadToFile(url, destPath, headers),
|
||||
|
||||
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,
|
||||
// The API caps limit at 100.
|
||||
limit: Math.min(maxResults, 100),
|
||||
});
|
||||
if (!response.ok) return response;
|
||||
const entries = mapTsukihimeSearchResults(response.data, maxResults);
|
||||
logger.info(`[tsukihime] search-entries returned ${entries.length} results`);
|
||||
return { ok: true, data: entries };
|
||||
},
|
||||
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 = extractTsukihimeSubtitleFiles(response.data);
|
||||
logger.info(`[tsukihime] list-files returned ${files.length} subtitle attachments`);
|
||||
return { ok: true, data: files };
|
||||
},
|
||||
getTsukihimeSecondaryLanguages: () =>
|
||||
options.getResolvedConfig().secondarySub?.secondarySubLanguages ?? [],
|
||||
downloadTsukihimeSubtitle: async (url, destPath) => {
|
||||
const tempXzPath = `${destPath}.xz`;
|
||||
const downloaded = await options.downloadToFile(
|
||||
url,
|
||||
tempXzPath,
|
||||
{ 'User-Agent': 'SubMiner' },
|
||||
// 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);
|
||||
fs.promises.unlink(tempXzPath).catch(() => {});
|
||||
return result;
|
||||
},
|
||||
onDownloadedSubtitle: (pathToSubtitle) => {
|
||||
const mpvClient = options.getMpvClient();
|
||||
if (mpvClient && mpvClient.connected) {
|
||||
mpvClient.send({ command: ['sub-add', pathToSubtitle, 'select'] });
|
||||
}
|
||||
},
|
||||
onDownloadedSecondarySubtitle: async (pathToSubtitle) => {
|
||||
const mpvClient = options.getMpvClient();
|
||||
if (!mpvClient || !mpvClient.connected) return;
|
||||
mpvClient.send({ command: ['sub-add', pathToSubtitle, 'auto'] });
|
||||
const request = mpvClient.request;
|
||||
if (!request) return;
|
||||
|
||||
// sub-add is queued, so the track may not appear in the first track-list
|
||||
// reply; poll briefly before giving up.
|
||||
for (let attempt = 0; attempt < SECONDARY_TRACK_LOOKUP_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const response = await request(['get_property', 'track-list']);
|
||||
const tracks = Array.isArray(response?.data)
|
||||
? (response.data as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const added = tracks.find(
|
||||
(track) => track?.type === 'sub' && track['external-filename'] === pathToSubtitle,
|
||||
);
|
||||
if (added && typeof added.id === 'number') {
|
||||
mpvClient.send({ command: ['set_property', 'secondary-sid', added.id] });
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[tsukihime] failed to select downloaded subtitle as secondary:', error);
|
||||
return;
|
||||
}
|
||||
await delay(SECONDARY_TRACK_LOOKUP_RETRY_MS);
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`[tsukihime] could not find downloaded subtitle in track-list: ${pathToSubtitle}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openControllerSelect: false,
|
||||
openControllerDebug: false,
|
||||
openJimaku: false,
|
||||
openTsukihime: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
@@ -168,7 +169,14 @@ test('startAppLifecycle defers quit until async cleanup settles', async () => {
|
||||
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']);
|
||||
});
|
||||
|
||||
|
||||
@@ -219,7 +219,11 @@ export function startAppLifecycle(initialArgs: CliArgs, deps: AppLifecycleServic
|
||||
.finally(() => {
|
||||
quitCleanupPending = false;
|
||||
quitCleanupComplete = true;
|
||||
deps.quitApp();
|
||||
// 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());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openControllerSelect: false,
|
||||
openControllerDebug: false,
|
||||
openJimaku: false,
|
||||
openTsukihime: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
|
||||
@@ -544,6 +544,12 @@ export function handleCliCommand(
|
||||
);
|
||||
} else if (args.openJimaku) {
|
||||
dispatchCliSessionAction({ actionId: 'openJimaku' }, 'openJimaku', 'Open jimaku failed');
|
||||
} else if (args.openTsukihime) {
|
||||
dispatchCliSessionAction(
|
||||
{ actionId: 'openTsukihime' },
|
||||
'openTsukihime',
|
||||
'Open tsukihime failed',
|
||||
);
|
||||
} else if (args.openYoutubePicker) {
|
||||
dispatchCliSessionAction(
|
||||
{ actionId: 'openYoutubePicker' },
|
||||
|
||||
@@ -572,7 +572,7 @@ export class ImmersionTrackerService {
|
||||
range: '7d' | '30d' | '90d' | '365d' | 'all' = '30d',
|
||||
groupBy: 'day' | 'month' = 'day',
|
||||
fillEmptyBuckets = true,
|
||||
): Promise<unknown> {
|
||||
) {
|
||||
return getTrendsDashboard(this.db, range, groupBy, fillEmptyBuckets);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
SUBSYNC_TRIGGER: '__subsync-trigger',
|
||||
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',
|
||||
@@ -27,6 +29,9 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
|
||||
openJimaku: () => {
|
||||
calls.push('jimaku');
|
||||
},
|
||||
openTsukihime: () => {
|
||||
calls.push('tsukihime');
|
||||
},
|
||||
openYoutubeTrackPicker: () => {
|
||||
calls.push('youtube-picker');
|
||||
},
|
||||
@@ -148,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);
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface HandleMpvCommandFromIpcOptions {
|
||||
SUBSYNC_TRIGGER: string;
|
||||
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;
|
||||
@@ -19,6 +21,7 @@ export interface HandleMpvCommandFromIpcOptions {
|
||||
triggerSubsyncFromConfig: () => void;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
openJimaku: () => void;
|
||||
openTsukihime: () => void;
|
||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||
openPlaylistBrowser: () => void | Promise<void>;
|
||||
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
|
||||
@@ -112,6 +115,14 @@ export function handleMpvCommandFromIpc(
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
first === options.specialCommands.TSUKIHIME_OPEN ||
|
||||
first === options.specialCommands.ANIMETOSHO_OPEN
|
||||
) {
|
||||
options.openTsukihime();
|
||||
return;
|
||||
}
|
||||
|
||||
if (first === options.specialCommands.YOUTUBE_PICKER_OPEN) {
|
||||
void options.openYoutubeTrackPicker();
|
||||
return;
|
||||
|
||||
@@ -181,35 +181,6 @@ function createFakeImmersionTracker(
|
||||
): NonNullable<IpcServiceDeps['immersionTracker']> {
|
||||
return {
|
||||
recordYomitanLookup: () => {},
|
||||
getSessionSummaries: async () => [],
|
||||
getDailyRollups: async () => [],
|
||||
getMonthlyRollups: async () => [],
|
||||
getQueryHints: async () => ({
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
episodesToday: 0,
|
||||
activeAnimeCount: 0,
|
||||
totalActiveMin: 0,
|
||||
totalCards: 0,
|
||||
activeDays: 0,
|
||||
totalEpisodesWatched: 0,
|
||||
totalAnimeCompleted: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
}),
|
||||
getSessionTimeline: async () => [],
|
||||
getSessionEvents: async () => [],
|
||||
getVocabularyStats: async () => [],
|
||||
getKanjiStats: async () => [],
|
||||
getMediaLibrary: async () => [],
|
||||
getMediaDetail: async () => null,
|
||||
getMediaSessions: async () => [],
|
||||
getMediaDailyRollups: async () => [],
|
||||
getCoverArt: async () => null,
|
||||
markActiveVideoWatched: async () => false,
|
||||
...overrides,
|
||||
};
|
||||
@@ -776,172 +747,6 @@ test('registerIpcHandlers records yomitan lookup when subtitle context recording
|
||||
}
|
||||
});
|
||||
|
||||
test('registerIpcHandlers returns empty stats overview shape without a tracker', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
registerIpcHandlers(createRegisterIpcDeps(), registrar);
|
||||
|
||||
const overviewHandler = handlers.handle.get(IPC_CHANNELS.request.statsGetOverview);
|
||||
assert.ok(overviewHandler);
|
||||
assert.deepEqual(await overviewHandler!({}), {
|
||||
sessions: [],
|
||||
rollups: [],
|
||||
hints: {
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
episodesToday: 0,
|
||||
activeAnimeCount: 0,
|
||||
totalCards: 0,
|
||||
totalActiveMin: 0,
|
||||
activeDays: 0,
|
||||
totalEpisodesWatched: 0,
|
||||
totalAnimeCompleted: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('registerIpcHandlers validates and clamps stats request limits', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: Array<[string, number, number?]> = [];
|
||||
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
immersionTracker: {
|
||||
recordYomitanLookup: () => {},
|
||||
getSessionSummaries: async (limit = 0) => {
|
||||
calls.push(['sessions', limit]);
|
||||
return [];
|
||||
},
|
||||
getDailyRollups: async (limit = 0) => {
|
||||
calls.push(['daily', limit]);
|
||||
return [];
|
||||
},
|
||||
getMonthlyRollups: async (limit = 0) => {
|
||||
calls.push(['monthly', limit]);
|
||||
return [];
|
||||
},
|
||||
getQueryHints: async () => ({
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
episodesToday: 0,
|
||||
activeAnimeCount: 0,
|
||||
totalCards: 0,
|
||||
totalActiveMin: 0,
|
||||
activeDays: 0,
|
||||
totalEpisodesWatched: 0,
|
||||
totalAnimeCompleted: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
}),
|
||||
getSessionTimeline: async (sessionId: number, limit = 0) => {
|
||||
calls.push(['timeline', limit, sessionId]);
|
||||
return [];
|
||||
},
|
||||
getSessionEvents: async (sessionId: number, limit = 0) => {
|
||||
calls.push(['events', limit, sessionId]);
|
||||
return [];
|
||||
},
|
||||
getVocabularyStats: async (limit = 0) => {
|
||||
calls.push(['vocabulary', limit]);
|
||||
return [];
|
||||
},
|
||||
getKanjiStats: async (limit = 0) => {
|
||||
calls.push(['kanji', limit]);
|
||||
return [];
|
||||
},
|
||||
getMediaLibrary: async () => [],
|
||||
getMediaDetail: async () => null,
|
||||
getMediaSessions: async () => [],
|
||||
getMediaDailyRollups: async () => [],
|
||||
getCoverArt: async () => null,
|
||||
markActiveVideoWatched: async () => false,
|
||||
},
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetDailyRollups)!({}, -1);
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetMonthlyRollups)!(
|
||||
{},
|
||||
Number.POSITIVE_INFINITY,
|
||||
);
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetSessions)!({}, 9999);
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetSessionTimeline)!({}, 7, 12.5);
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetSessionEvents)!({}, 7, 0);
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetVocabulary)!({}, 1000);
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetKanji)!({}, NaN);
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
['daily', 60],
|
||||
['monthly', 24],
|
||||
['sessions', 500],
|
||||
['timeline', 200, 7],
|
||||
['events', 500, 7],
|
||||
['vocabulary', 500],
|
||||
['kanji', 100],
|
||||
]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers requests the full timeline when no limit is provided', async () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const calls: Array<[string, number | undefined, number]> = [];
|
||||
|
||||
registerIpcHandlers(
|
||||
createRegisterIpcDeps({
|
||||
immersionTracker: {
|
||||
recordYomitanLookup: () => {},
|
||||
getSessionSummaries: async () => [],
|
||||
getDailyRollups: async () => [],
|
||||
getMonthlyRollups: async () => [],
|
||||
getQueryHints: async () => ({
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
episodesToday: 0,
|
||||
activeAnimeCount: 0,
|
||||
totalCards: 0,
|
||||
totalActiveMin: 0,
|
||||
activeDays: 0,
|
||||
totalEpisodesWatched: 0,
|
||||
totalAnimeCompleted: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
}),
|
||||
getSessionTimeline: async (sessionId: number, limit?: number) => {
|
||||
calls.push(['timeline', limit, sessionId]);
|
||||
return [];
|
||||
},
|
||||
getSessionEvents: async () => [],
|
||||
getVocabularyStats: async () => [],
|
||||
getKanjiStats: async () => [],
|
||||
getMediaLibrary: async () => [],
|
||||
getMediaDetail: async () => null,
|
||||
getMediaSessions: async () => [],
|
||||
getMediaDailyRollups: async () => [],
|
||||
getCoverArt: async () => null,
|
||||
markActiveVideoWatched: async () => false,
|
||||
},
|
||||
}),
|
||||
registrar,
|
||||
);
|
||||
|
||||
await handlers.handle.get(IPC_CHANNELS.request.statsGetSessionTimeline)!({}, 7, undefined);
|
||||
|
||||
assert.deepEqual(calls, [['timeline', undefined, 7]]);
|
||||
});
|
||||
|
||||
test('registerIpcHandlers ignores malformed fire-and-forget payloads', () => {
|
||||
const { registrar, handlers } = createFakeIpcRegistrar();
|
||||
const saves: unknown[] = [];
|
||||
|
||||
@@ -132,35 +132,6 @@ export interface IpcServiceDeps {
|
||||
) => Promise<PlaylistBrowserMutationResult>;
|
||||
immersionTracker?: {
|
||||
recordYomitanLookup: () => void;
|
||||
getSessionSummaries: (limit?: number) => Promise<unknown>;
|
||||
getDailyRollups: (limit?: number) => Promise<unknown>;
|
||||
getMonthlyRollups: (limit?: number) => Promise<unknown>;
|
||||
getQueryHints: () => Promise<{
|
||||
totalSessions: number;
|
||||
activeSessions: number;
|
||||
episodesToday: number;
|
||||
activeAnimeCount: number;
|
||||
totalActiveMin: number;
|
||||
totalCards: number;
|
||||
activeDays: number;
|
||||
totalEpisodesWatched: number;
|
||||
totalAnimeCompleted: number;
|
||||
totalTokensSeen: number;
|
||||
totalLookupCount: number;
|
||||
totalLookupHits: number;
|
||||
totalYomitanLookupCount: number;
|
||||
newWordsToday: number;
|
||||
newWordsThisWeek: number;
|
||||
}>;
|
||||
getSessionTimeline: (sessionId: number, limit?: number) => Promise<unknown>;
|
||||
getSessionEvents: (sessionId: number, limit?: number) => Promise<unknown>;
|
||||
getVocabularyStats: (limit?: number) => Promise<unknown>;
|
||||
getKanjiStats: (limit?: number) => Promise<unknown>;
|
||||
getMediaLibrary: () => Promise<unknown>;
|
||||
getMediaDetail: (videoId: number) => Promise<unknown>;
|
||||
getMediaSessions: (videoId: number, limit?: number) => Promise<unknown>;
|
||||
getMediaDailyRollups: (videoId: number, limit?: number) => Promise<unknown>;
|
||||
getCoverArt: (videoId: number) => Promise<unknown>;
|
||||
markActiveVideoWatched: () => Promise<boolean>;
|
||||
} | null;
|
||||
}
|
||||
@@ -459,24 +430,6 @@ export function createIpcDepsRuntime(options: IpcDepsRuntimeOptions): IpcService
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar = ipcMain): void {
|
||||
const parsePositiveIntLimit = (
|
||||
value: unknown,
|
||||
defaultValue: number,
|
||||
maxValue: number,
|
||||
): number => {
|
||||
if (!Number.isInteger(value) || (value as number) < 1) {
|
||||
return defaultValue;
|
||||
}
|
||||
return Math.min(value as number, maxValue);
|
||||
};
|
||||
|
||||
const parsePositiveInteger = (value: unknown): number | null => {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
ipc.on(
|
||||
IPC_CHANNELS.command.setIgnoreMouseEvents,
|
||||
(event: unknown, ignore: unknown, options: unknown = {}) => {
|
||||
@@ -904,115 +857,4 @@ export function registerIpcHandlers(deps: IpcServiceDeps, ipc: IpcMainRegistrar
|
||||
return await deps.movePlaylistBrowserIndex(index as number, direction as 1 | -1);
|
||||
},
|
||||
);
|
||||
|
||||
// Stats request handlers
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetOverview, async () => {
|
||||
const tracker = deps.immersionTracker;
|
||||
if (!tracker) {
|
||||
return {
|
||||
sessions: [],
|
||||
rollups: [],
|
||||
hints: {
|
||||
totalSessions: 0,
|
||||
activeSessions: 0,
|
||||
episodesToday: 0,
|
||||
activeAnimeCount: 0,
|
||||
totalActiveMin: 0,
|
||||
totalCards: 0,
|
||||
activeDays: 0,
|
||||
totalEpisodesWatched: 0,
|
||||
totalAnimeCompleted: 0,
|
||||
totalTokensSeen: 0,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
newWordsToday: 0,
|
||||
newWordsThisWeek: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
const [sessions, rollups, hints] = await Promise.all([
|
||||
tracker.getSessionSummaries(5),
|
||||
tracker.getDailyRollups(14),
|
||||
tracker.getQueryHints(),
|
||||
]);
|
||||
return { sessions, rollups, hints };
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetDailyRollups, async (_event, limit: unknown) => {
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 60, 500);
|
||||
return deps.immersionTracker?.getDailyRollups(parsedLimit) ?? [];
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetMonthlyRollups, async (_event, limit: unknown) => {
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 24, 120);
|
||||
return deps.immersionTracker?.getMonthlyRollups(parsedLimit) ?? [];
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetSessions, async (_event, limit: unknown) => {
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 50, 500);
|
||||
return deps.immersionTracker?.getSessionSummaries(parsedLimit) ?? [];
|
||||
});
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.statsGetSessionTimeline,
|
||||
async (_event, sessionId: unknown, limit: unknown) => {
|
||||
const parsedSessionId = parsePositiveInteger(sessionId);
|
||||
if (parsedSessionId === null) return [];
|
||||
const parsedLimit = limit === undefined ? undefined : parsePositiveIntLimit(limit, 200, 1000);
|
||||
return deps.immersionTracker?.getSessionTimeline(parsedSessionId, parsedLimit) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.statsGetSessionEvents,
|
||||
async (_event, sessionId: unknown, limit: unknown) => {
|
||||
const parsedSessionId = parsePositiveInteger(sessionId);
|
||||
if (parsedSessionId === null) return [];
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 500, 1000);
|
||||
return deps.immersionTracker?.getSessionEvents(parsedSessionId, parsedLimit) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetVocabulary, async (_event, limit: unknown) => {
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 100, 500);
|
||||
return deps.immersionTracker?.getVocabularyStats(parsedLimit) ?? [];
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetKanji, async (_event, limit: unknown) => {
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 100, 500);
|
||||
return deps.immersionTracker?.getKanjiStats(parsedLimit) ?? [];
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetMediaLibrary, async () => {
|
||||
return deps.immersionTracker?.getMediaLibrary() ?? [];
|
||||
});
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetMediaDetail, async (_event, videoId: unknown) => {
|
||||
if (typeof videoId !== 'number') return null;
|
||||
return deps.immersionTracker?.getMediaDetail(videoId) ?? null;
|
||||
});
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.statsGetMediaSessions,
|
||||
async (_event, videoId: unknown, limit: unknown) => {
|
||||
if (typeof videoId !== 'number') return [];
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 100, 500);
|
||||
return deps.immersionTracker?.getMediaSessions(videoId, parsedLimit) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(
|
||||
IPC_CHANNELS.request.statsGetMediaDailyRollups,
|
||||
async (_event, videoId: unknown, limit: unknown) => {
|
||||
if (typeof videoId !== 'number') return [];
|
||||
const parsedLimit = parsePositiveIntLimit(limit, 90, 500);
|
||||
return deps.immersionTracker?.getMediaDailyRollups(videoId, parsedLimit) ?? [];
|
||||
},
|
||||
);
|
||||
|
||||
ipc.handle(IPC_CHANNELS.request.statsGetMediaCover, async (_event, videoId: unknown) => {
|
||||
if (typeof videoId !== 'number') return null;
|
||||
return deps.immersionTracker?.getCoverArt(videoId) ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,11 +28,13 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
|
||||
openCharacterDictionaryManager: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
toggleNotificationHistory: null,
|
||||
appendClipboardVideoToQueue: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -53,6 +55,9 @@ function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
|
||||
openJimaku: () => {
|
||||
calls.push('openJimaku');
|
||||
},
|
||||
openTsukihime: () => {
|
||||
calls.push('openTsukihime');
|
||||
},
|
||||
markAudioCard: async () => {
|
||||
calls.push('markAudioCard');
|
||||
},
|
||||
@@ -163,6 +168,7 @@ test('runOverlayShortcutLocalFallback dispatches matching single-step actions',
|
||||
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
|
||||
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
|
||||
openJimaku: () => handled.push('openJimaku'),
|
||||
openTsukihime: () => handled.push('openTsukihime'),
|
||||
markAudioCard: () => handled.push('markAudioCard'),
|
||||
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
|
||||
copySubtitle: () => handled.push('copySubtitle'),
|
||||
@@ -196,6 +202,7 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
|
||||
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
|
||||
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
|
||||
openJimaku: () => handled.push('openJimaku'),
|
||||
openTsukihime: () => handled.push('openTsukihime'),
|
||||
markAudioCard: () => handled.push('markAudioCard'),
|
||||
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
|
||||
copySubtitle: () => handled.push('copySubtitle'),
|
||||
@@ -216,6 +223,7 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
|
||||
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
|
||||
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
|
||||
openJimaku: () => handled.push('openJimaku'),
|
||||
openTsukihime: () => handled.push('openTsukihime'),
|
||||
markAudioCard: () => handled.push('markAudioCard'),
|
||||
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
|
||||
copySubtitle: () => handled.push('copySubtitle'),
|
||||
@@ -253,6 +261,7 @@ test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s
|
||||
openRuntimeOptions: () => {},
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openJimaku: () => {},
|
||||
openTsukihime: () => {},
|
||||
markAudioCard: () => {},
|
||||
copySubtitleMultiple: () => {},
|
||||
copySubtitle: () => {},
|
||||
@@ -289,6 +298,7 @@ test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut',
|
||||
openRuntimeOptions: () => {},
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openJimaku: () => {},
|
||||
openTsukihime: () => {},
|
||||
markAudioCard: () => {},
|
||||
copySubtitleMultiple: () => {},
|
||||
copySubtitle: () => {},
|
||||
@@ -321,6 +331,9 @@ test('runOverlayShortcutLocalFallback returns false when no action matches', ()
|
||||
openJimaku: () => {
|
||||
called = true;
|
||||
},
|
||||
openTsukihime: () => {
|
||||
called = true;
|
||||
},
|
||||
markAudioCard: () => {
|
||||
called = true;
|
||||
},
|
||||
@@ -403,6 +416,7 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured',
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
openTsukihime: () => {},
|
||||
}),
|
||||
cancelPendingMultiCopy: () => {},
|
||||
cancelPendingMineSentenceMultiple: () => {},
|
||||
@@ -430,6 +444,7 @@ test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
openTsukihime: () => {},
|
||||
}),
|
||||
cancelPendingMultiCopy: () => {
|
||||
calls.push('cancel-multi-copy');
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface OverlayShortcutFallbackHandlers {
|
||||
openRuntimeOptions: () => void;
|
||||
openCharacterDictionaryManager: () => void;
|
||||
openJimaku: () => void;
|
||||
openTsukihime: () => void;
|
||||
markAudioCard: () => void;
|
||||
copySubtitleMultiple: (timeoutMs: number) => void;
|
||||
copySubtitle: () => void;
|
||||
@@ -24,6 +25,7 @@ export interface OverlayShortcutRuntimeDeps {
|
||||
openRuntimeOptions: () => void;
|
||||
openCharacterDictionaryManager: () => void;
|
||||
openJimaku: () => void;
|
||||
openTsukihime: () => void;
|
||||
markAudioCard: () => Promise<void>;
|
||||
copySubtitleMultiple: (timeoutMs: number) => void;
|
||||
copySubtitle: () => void;
|
||||
@@ -103,12 +105,16 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
|
||||
openJimaku: () => {
|
||||
deps.openJimaku();
|
||||
},
|
||||
openTsukihime: () => {
|
||||
deps.openTsukihime();
|
||||
},
|
||||
};
|
||||
|
||||
const fallbackHandlers: OverlayShortcutFallbackHandlers = {
|
||||
openRuntimeOptions: overlayHandlers.openRuntimeOptions,
|
||||
openCharacterDictionaryManager: overlayHandlers.openCharacterDictionaryManager,
|
||||
openJimaku: overlayHandlers.openJimaku,
|
||||
openTsukihime: overlayHandlers.openTsukihime,
|
||||
markAudioCard: overlayHandlers.markAudioCard,
|
||||
copySubtitleMultiple: overlayHandlers.copySubtitleMultiple,
|
||||
copySubtitle: overlayHandlers.copySubtitle,
|
||||
@@ -153,6 +159,13 @@ export function runOverlayShortcutLocalFallback(
|
||||
},
|
||||
allowWhenRegistered: true,
|
||||
},
|
||||
{
|
||||
accelerator: shortcuts.openTsukihime,
|
||||
run: () => {
|
||||
handlers.openTsukihime();
|
||||
},
|
||||
allowWhenRegistered: true,
|
||||
},
|
||||
{
|
||||
accelerator: shortcuts.markAudioCard,
|
||||
run: () => {
|
||||
|
||||
@@ -23,11 +23,13 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
openCharacterDictionaryManager: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
toggleNotificationHistory: null,
|
||||
appendClipboardVideoToQueue: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -47,6 +49,7 @@ test('registerOverlayShortcuts reports active overlay shortcuts when configured'
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
openTsukihime: () => {},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
@@ -67,6 +70,7 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent'
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
openTsukihime: () => {},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
@@ -89,6 +93,7 @@ test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active
|
||||
openCharacterDictionaryManager: () => {},
|
||||
openRuntimeOptions: () => {},
|
||||
openJimaku: () => {},
|
||||
openTsukihime: () => {},
|
||||
}),
|
||||
cancelPendingMultiCopy: () => {
|
||||
calls.push('cancel-multi-copy');
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface OverlayShortcutHandlers {
|
||||
openCharacterDictionaryManager: () => void;
|
||||
openRuntimeOptions: () => void;
|
||||
openJimaku: () => void;
|
||||
openTsukihime: () => void;
|
||||
}
|
||||
|
||||
export interface OverlayShortcutLifecycleDeps {
|
||||
@@ -35,6 +36,7 @@ const OVERLAY_SHORTCUT_KEYS: Array<keyof Omit<ConfiguredShortcuts, 'multiCopyTim
|
||||
'openCharacterDictionaryManager',
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
'openTsukihime',
|
||||
];
|
||||
|
||||
function hasConfiguredOverlayShortcuts(shortcuts: ConfiguredShortcuts): boolean {
|
||||
|
||||
@@ -26,6 +26,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
||||
toggleSecondarySub: () => calls.push('secondary'),
|
||||
toggleSubtitleSidebar: () => calls.push('sidebar'),
|
||||
toggleNotificationHistory: () => calls.push('notification-history'),
|
||||
appendClipboardVideoToQueue: () => calls.push('append-clipboard-video'),
|
||||
markLastCardAsAudioCard: async () => {
|
||||
calls.push('audio');
|
||||
},
|
||||
@@ -39,6 +40,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
||||
openControllerSelect: () => calls.push('controller-select'),
|
||||
openControllerDebug: () => calls.push('controller-debug'),
|
||||
openJimaku: () => calls.push('jimaku'),
|
||||
openTsukihime: () => calls.push('tsukihime'),
|
||||
openYoutubeTrackPicker: () => {
|
||||
calls.push('youtube');
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface SessionActionExecutorDeps {
|
||||
toggleSecondarySub: () => void;
|
||||
toggleSubtitleSidebar: () => void;
|
||||
toggleNotificationHistory: () => void;
|
||||
appendClipboardVideoToQueue: () => void;
|
||||
markLastCardAsAudioCard: () => Promise<void>;
|
||||
markActiveVideoWatched: () => Promise<boolean>;
|
||||
openRuntimeOptionsPalette: () => void;
|
||||
@@ -23,6 +24,7 @@ export interface SessionActionExecutorDeps {
|
||||
openControllerSelect: () => void;
|
||||
openControllerDebug: () => void;
|
||||
openJimaku: () => void;
|
||||
openTsukihime: () => void;
|
||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
|
||||
replayCurrentSubtitle: () => void;
|
||||
@@ -82,6 +84,9 @@ export async function dispatchSessionAction(
|
||||
case 'toggleNotificationHistory':
|
||||
deps.toggleNotificationHistory();
|
||||
return;
|
||||
case 'appendClipboardVideoToQueue':
|
||||
deps.appendClipboardVideoToQueue();
|
||||
return;
|
||||
case 'markAudioCard':
|
||||
await deps.markLastCardAsAudioCard();
|
||||
return;
|
||||
@@ -111,6 +116,9 @@ export async function dispatchSessionAction(
|
||||
case 'openJimaku':
|
||||
deps.openJimaku();
|
||||
return;
|
||||
case 'openTsukihime':
|
||||
deps.openTsukihime();
|
||||
return;
|
||||
case 'openYoutubePicker':
|
||||
await deps.openYoutubeTrackPicker();
|
||||
return;
|
||||
|
||||
@@ -22,11 +22,13 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
openCharacterDictionaryManager: null,
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
openControllerDebug: null,
|
||||
toggleSubtitleSidebar: null,
|
||||
toggleNotificationHistory: null,
|
||||
appendClipboardVideoToQueue: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -318,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(),
|
||||
@@ -515,6 +532,8 @@ test('compileSessionBindings wires every configured shortcut key into the shared
|
||||
'openControllerSelect',
|
||||
'openControllerDebug',
|
||||
'toggleSubtitleSidebar',
|
||||
'toggleNotificationHistory',
|
||||
'appendClipboardVideoToQueue',
|
||||
];
|
||||
const shortcuts = createShortcuts();
|
||||
shortcutKeys.forEach((key, index) => {
|
||||
@@ -582,6 +601,42 @@ test('buildPluginSessionBindingsArtifact emits CLI args for plugin-bound session
|
||||
});
|
||||
});
|
||||
|
||||
test('appendClipboardVideoToQueue shortcut compiles to a session action with plugin CLI args', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts({
|
||||
appendClipboardVideoToQueue: 'CommandOrControl+A',
|
||||
}),
|
||||
keybindings: [],
|
||||
platform: 'linux',
|
||||
});
|
||||
|
||||
assert.deepEqual(result.warnings, []);
|
||||
const binding = result.bindings.find(
|
||||
(candidate) =>
|
||||
candidate.actionType === 'session-action' &&
|
||||
candidate.actionId === 'appendClipboardVideoToQueue',
|
||||
);
|
||||
assert.ok(binding);
|
||||
assert.deepEqual(binding.key, { code: 'KeyA', modifiers: ['ctrl'] });
|
||||
|
||||
const artifact = buildPluginSessionBindingsArtifact({
|
||||
bindings: result.bindings,
|
||||
warnings: result.warnings,
|
||||
numericSelectionTimeoutMs: 2500,
|
||||
now: new Date('2026-05-26T00:00:00.000Z'),
|
||||
});
|
||||
const pluginBinding = artifact.bindings.find(
|
||||
(candidate) =>
|
||||
candidate.actionType === 'session-action' &&
|
||||
candidate.actionId === 'appendClipboardVideoToQueue',
|
||||
);
|
||||
assert.ok(pluginBinding && 'cliArgs' in pluginBinding);
|
||||
assert.equal(pluginBinding.cliArgs?.[0], '--session-action');
|
||||
assert.deepEqual(JSON.parse(pluginBinding.cliArgs?.[1] ?? ''), {
|
||||
actionId: 'appendClipboardVideoToQueue',
|
||||
});
|
||||
});
|
||||
|
||||
test('buildPluginSessionBindingsArtifact preserves plugin selector CLI for no-count multi-line actions', () => {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts({
|
||||
|
||||
@@ -55,11 +55,13 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
|
||||
{ key: 'openCharacterDictionaryManager', actionId: 'openCharacterDictionaryManager' },
|
||||
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
|
||||
{ key: 'openJimaku', actionId: 'openJimaku' },
|
||||
{ key: 'openTsukihime', actionId: 'openTsukihime' },
|
||||
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },
|
||||
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
|
||||
{ key: 'openControllerDebug', actionId: 'openControllerDebug' },
|
||||
{ key: 'toggleSubtitleSidebar', actionId: 'toggleSubtitleSidebar' },
|
||||
{ key: 'toggleNotificationHistory', actionId: 'toggleNotificationHistory' },
|
||||
{ key: 'appendClipboardVideoToQueue', actionId: 'appendClipboardVideoToQueue' },
|
||||
];
|
||||
|
||||
function normalizeModifiers(modifiers: SessionKeyModifier[]): SessionKeyModifier[] {
|
||||
@@ -303,6 +305,10 @@ function resolveCommandBinding(
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'openJimaku' };
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.TSUKIHIME_OPEN || first === SPECIAL_COMMANDS.ANIMETOSHO_OPEN) {
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'openTsukihime' };
|
||||
}
|
||||
if (first === SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN) {
|
||||
if (command.length !== 1) return null;
|
||||
return { actionType: 'session-action', actionId: 'openYoutubePicker' };
|
||||
|
||||
@@ -40,6 +40,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openControllerSelect: false,
|
||||
openControllerDebug: false,
|
||||
openJimaku: false,
|
||||
openTsukihime: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
replayCurrentSubtitle: false,
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import type { Hono } from 'hono';
|
||||
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
|
||||
import { statsJson, type StatsCoverImagesRequest } from '../../types/stats-http-contract.js';
|
||||
import type { StatsCoverImage } from '../../types/stats-wire.js';
|
||||
|
||||
type StatsCoverImagePayload = {
|
||||
contentType: string;
|
||||
dataUrl: string;
|
||||
} | null;
|
||||
|
||||
type StatsCoverBatchBody = {
|
||||
animeIds?: unknown;
|
||||
videoIds?: unknown;
|
||||
};
|
||||
type StatsCoverImagePayload = StatsCoverImage | null;
|
||||
type StatsCoverBatchBody = Partial<Record<keyof StatsCoverImagesRequest, unknown>>;
|
||||
|
||||
const MAX_BACKGROUND_ANIME_COVER_FETCHES = 3;
|
||||
|
||||
@@ -130,7 +125,7 @@ export function registerStatsCoverRoutes(app: Hono, tracker: ImmersionTrackerSer
|
||||
}),
|
||||
);
|
||||
|
||||
return c.json({ anime, media });
|
||||
return c.json(statsJson('coverImages', { anime, media }));
|
||||
});
|
||||
|
||||
app.get('/api/stats/anime/:animeId/cover', async (c) => {
|
||||
|
||||
@@ -18,6 +18,12 @@ import {
|
||||
import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js';
|
||||
import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
|
||||
import { registerStatsCoverRoutes } from './stats-cover-routes.js';
|
||||
import {
|
||||
statsJson,
|
||||
type StatsAnilistSearchResult,
|
||||
type StatsAnkiBrowseResponse,
|
||||
} from '../../types/stats-http-contract.js';
|
||||
import type { StatsExcludedWord } from '../../types/stats-wire.js';
|
||||
import {
|
||||
resolveRetimedSecondarySubtitleTextFromSidecar,
|
||||
resolveSecondarySubtitleTextFromSidecar,
|
||||
@@ -46,12 +52,6 @@ export type StatsMiningTimingEvent = {
|
||||
noteId?: number;
|
||||
};
|
||||
|
||||
type StatsExcludedWordPayload = {
|
||||
headword: string;
|
||||
word: string;
|
||||
reading: string;
|
||||
};
|
||||
|
||||
function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number {
|
||||
if (raw === undefined) return fallback;
|
||||
const n = Number(raw);
|
||||
@@ -87,12 +87,12 @@ function parseEventTypesQuery(raw: string | undefined): number[] | undefined {
|
||||
return parsed.length > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function parseExcludedWordsBody(body: unknown): StatsExcludedWordPayload[] | null {
|
||||
function parseExcludedWordsBody(body: unknown): StatsExcludedWord[] | null {
|
||||
if (!body || typeof body !== 'object' || !Array.isArray((body as { words?: unknown }).words)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const words: StatsExcludedWordPayload[] = [];
|
||||
const words: StatsExcludedWord[] = [];
|
||||
for (const row of (body as { words: unknown[] }).words) {
|
||||
if (!row || typeof row !== 'object') return null;
|
||||
const { headword, word, reading } = row as Record<string, unknown>;
|
||||
@@ -320,20 +320,22 @@ function summarizeFilteredWordOccurrences(
|
||||
return { knownWordsSeen, totalWordsSeen };
|
||||
}
|
||||
|
||||
async function enrichSessionsWithKnownWordMetrics(
|
||||
tracker: ImmersionTrackerService,
|
||||
sessions: Array<{
|
||||
async function enrichSessionsWithKnownWordMetrics<
|
||||
Session extends {
|
||||
sessionId: number;
|
||||
tokensSeen: number;
|
||||
}>,
|
||||
},
|
||||
>(
|
||||
tracker: ImmersionTrackerService,
|
||||
sessions: Session[],
|
||||
knownWordsCachePath?: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
sessionId: number;
|
||||
tokensSeen: number;
|
||||
knownWordsSeen: number;
|
||||
knownWordRate: number;
|
||||
}>
|
||||
Array<
|
||||
Session & {
|
||||
knownWordsSeen: number;
|
||||
knownWordRate: number;
|
||||
}
|
||||
>
|
||||
> {
|
||||
const knownWordsSet = loadKnownWordsSet(knownWordsCachePath);
|
||||
if (!knownWordsSet) {
|
||||
@@ -603,46 +605,48 @@ export function createStatsApp(
|
||||
rawSessions,
|
||||
options?.knownWordCachePath,
|
||||
);
|
||||
return c.json({ sessions, rollups, hints });
|
||||
return c.json(statsJson('overview', { sessions, rollups, hints }));
|
||||
});
|
||||
|
||||
app.get('/api/stats/daily-rollups', async (c) => {
|
||||
const limit = parseIntQuery(c.req.query('limit'), 60, 500);
|
||||
const rollups = await tracker.getDailyRollups(limit);
|
||||
return c.json(rollups);
|
||||
return c.json(statsJson('dailyRollups', rollups));
|
||||
});
|
||||
|
||||
app.get('/api/stats/monthly-rollups', async (c) => {
|
||||
const limit = parseIntQuery(c.req.query('limit'), 24, 120);
|
||||
const rollups = await tracker.getMonthlyRollups(limit);
|
||||
return c.json(rollups);
|
||||
return c.json(statsJson('monthlyRollups', rollups));
|
||||
});
|
||||
|
||||
app.get('/api/stats/streak-calendar', async (c) => {
|
||||
const days = parseIntQuery(c.req.query('days'), 90, 365);
|
||||
return c.json(await tracker.getStreakCalendar(days));
|
||||
return c.json(statsJson('streakCalendar', await tracker.getStreakCalendar(days)));
|
||||
});
|
||||
|
||||
app.get('/api/stats/trends/episodes-per-day', async (c) => {
|
||||
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
||||
return c.json(await tracker.getEpisodesPerDay(limit));
|
||||
return c.json(statsJson('episodesPerDay', await tracker.getEpisodesPerDay(limit)));
|
||||
});
|
||||
|
||||
app.get('/api/stats/trends/new-anime-per-day', async (c) => {
|
||||
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
||||
return c.json(await tracker.getNewAnimePerDay(limit));
|
||||
return c.json(statsJson('newAnimePerDay', await tracker.getNewAnimePerDay(limit)));
|
||||
});
|
||||
|
||||
app.get('/api/stats/trends/watch-time-per-anime', async (c) => {
|
||||
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
||||
return c.json(await tracker.getWatchTimePerAnime(limit));
|
||||
return c.json(statsJson('watchTimePerAnime', await tracker.getWatchTimePerAnime(limit)));
|
||||
});
|
||||
|
||||
app.get('/api/stats/trends/dashboard', async (c) => {
|
||||
const range = parseTrendRange(c.req.query('range'));
|
||||
const groupBy = parseTrendGroupBy(c.req.query('groupBy'));
|
||||
const fillEmpty = parseTrendFillEmpty(c.req.query('fillEmpty'));
|
||||
return c.json(await tracker.getTrendsDashboard(range, groupBy, fillEmpty));
|
||||
return c.json(
|
||||
statsJson('trendsDashboard', await tracker.getTrendsDashboard(range, groupBy, fillEmpty)),
|
||||
);
|
||||
});
|
||||
|
||||
app.get('/api/stats/sessions', async (c) => {
|
||||
@@ -653,30 +657,30 @@ export function createStatsApp(
|
||||
rawSessions,
|
||||
options?.knownWordCachePath,
|
||||
);
|
||||
return c.json(sessions);
|
||||
return c.json(statsJson('sessions', sessions));
|
||||
});
|
||||
|
||||
app.get('/api/stats/sessions/:id/timeline', async (c) => {
|
||||
const id = parseIntQuery(c.req.param('id'), 0);
|
||||
if (id <= 0) return c.json([], 400);
|
||||
if (id <= 0) return c.json(statsJson('sessionTimeline', []), 400);
|
||||
const rawLimit = c.req.query('limit');
|
||||
const limit = rawLimit === undefined ? undefined : parseIntQuery(rawLimit, 200, 1000);
|
||||
const timeline = await tracker.getSessionTimeline(id, limit);
|
||||
return c.json(timeline);
|
||||
return c.json(statsJson('sessionTimeline', timeline));
|
||||
});
|
||||
|
||||
app.get('/api/stats/sessions/:id/events', async (c) => {
|
||||
const id = parseIntQuery(c.req.param('id'), 0);
|
||||
if (id <= 0) return c.json([], 400);
|
||||
if (id <= 0) return c.json(statsJson('sessionEvents', []), 400);
|
||||
const limit = parseIntQuery(c.req.query('limit'), 500, 1000);
|
||||
const eventTypes = parseEventTypesQuery(c.req.query('types'));
|
||||
const events = await tracker.getSessionEvents(id, limit, eventTypes);
|
||||
return c.json(events);
|
||||
return c.json(statsJson('sessionEvents', events));
|
||||
});
|
||||
|
||||
app.get('/api/stats/sessions/:id/known-words-timeline', async (c) => {
|
||||
const id = parseIntQuery(c.req.param('id'), 0);
|
||||
if (id <= 0) return c.json([], 400);
|
||||
if (id <= 0) return c.json(statsJson('sessionKnownWordsTimeline', []), 400);
|
||||
|
||||
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath) ?? new Set<string>();
|
||||
|
||||
@@ -720,18 +724,18 @@ export function createStatsApp(
|
||||
});
|
||||
}
|
||||
|
||||
return c.json(knownByLinesSeen);
|
||||
return c.json(statsJson('sessionKnownWordsTimeline', knownByLinesSeen));
|
||||
});
|
||||
|
||||
app.get('/api/stats/vocabulary', async (c) => {
|
||||
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
|
||||
const excludePos = c.req.query('excludePos')?.split(',').filter(Boolean);
|
||||
const vocab = await tracker.getVocabularyStats(limit, excludePos);
|
||||
return c.json(vocab);
|
||||
return c.json(statsJson('vocabulary', vocab));
|
||||
});
|
||||
|
||||
app.get('/api/stats/excluded-words', async (c) => {
|
||||
return c.json(await tracker.getStatsExcludedWords());
|
||||
return c.json(statsJson('excludedWords', await tracker.getStatsExcludedWords()));
|
||||
});
|
||||
|
||||
app.put('/api/stats/excluded-words', async (c) => {
|
||||
@@ -739,7 +743,7 @@ export function createStatsApp(
|
||||
const words = parseExcludedWordsBody(body);
|
||||
if (!words) return c.body(null, 400);
|
||||
await tracker.replaceStatsExcludedWords(words);
|
||||
return c.json({ ok: true });
|
||||
return c.json(statsJson('setExcludedWords', { ok: true }));
|
||||
});
|
||||
|
||||
app.get('/api/stats/vocabulary/occurrences', async (c) => {
|
||||
@@ -747,17 +751,17 @@ export function createStatsApp(
|
||||
const word = (c.req.query('word') ?? '').trim();
|
||||
const reading = (c.req.query('reading') ?? '').trim();
|
||||
if (!headword || !word) {
|
||||
return c.json([], 400);
|
||||
return c.json(statsJson('wordOccurrences', []), 400);
|
||||
}
|
||||
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
|
||||
const offset = parseIntQuery(c.req.query('offset'), 0, 10_000);
|
||||
const occurrences = await tracker.getWordOccurrences(headword, word, reading, limit, offset);
|
||||
return c.json(occurrences);
|
||||
return c.json(statsJson('wordOccurrences', occurrences));
|
||||
});
|
||||
|
||||
app.get('/api/stats/sentences/search', async (c) => {
|
||||
const query = (c.req.query('q') ?? '').trim();
|
||||
if (!query) return c.json([]);
|
||||
if (!query) return c.json(statsJson('sentenceSearch', []));
|
||||
const limit = parseIntQuery(c.req.query('limit'), 50, 100);
|
||||
const searchByHeadword = parseBooleanQuery(c.req.query('headword'), true);
|
||||
const searchOptions = await buildSentenceSearchOptions(
|
||||
@@ -766,24 +770,24 @@ export function createStatsApp(
|
||||
options?.resolveSentenceSearchHeadwords,
|
||||
);
|
||||
const rows = await tracker.searchSubtitleSentences(query, limit, searchOptions);
|
||||
return c.json(rows);
|
||||
return c.json(statsJson('sentenceSearch', rows));
|
||||
});
|
||||
|
||||
app.get('/api/stats/kanji', async (c) => {
|
||||
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
|
||||
const kanji = await tracker.getKanjiStats(limit);
|
||||
return c.json(kanji);
|
||||
return c.json(statsJson('kanji', kanji));
|
||||
});
|
||||
|
||||
app.get('/api/stats/kanji/occurrences', async (c) => {
|
||||
const kanji = (c.req.query('kanji') ?? '').trim();
|
||||
if (!kanji) {
|
||||
return c.json([], 400);
|
||||
return c.json(statsJson('kanjiOccurrences', []), 400);
|
||||
}
|
||||
const limit = parseIntQuery(c.req.query('limit'), 50, 500);
|
||||
const offset = parseIntQuery(c.req.query('offset'), 0, 10_000);
|
||||
const occurrences = await tracker.getKanjiOccurrences(kanji, limit, offset);
|
||||
return c.json(occurrences);
|
||||
return c.json(statsJson('kanjiOccurrences', occurrences));
|
||||
});
|
||||
|
||||
app.get('/api/stats/vocabulary/:wordId/detail', async (c) => {
|
||||
@@ -793,7 +797,7 @@ export function createStatsApp(
|
||||
if (!detail) return c.body(null, 404);
|
||||
const animeAppearances = await tracker.getWordAnimeAppearances(wordId);
|
||||
const similarWords = await tracker.getSimilarWords(wordId);
|
||||
return c.json({ detail, animeAppearances, similarWords });
|
||||
return c.json(statsJson('wordDetail', { detail, animeAppearances, similarWords }));
|
||||
});
|
||||
|
||||
app.get('/api/stats/kanji/:kanjiId/detail', async (c) => {
|
||||
@@ -803,17 +807,17 @@ export function createStatsApp(
|
||||
if (!detail) return c.body(null, 404);
|
||||
const animeAppearances = await tracker.getKanjiAnimeAppearances(kanjiId);
|
||||
const words = await tracker.getKanjiWords(kanjiId);
|
||||
return c.json({ detail, animeAppearances, words });
|
||||
return c.json(statsJson('kanjiDetail', { detail, animeAppearances, words }));
|
||||
});
|
||||
|
||||
app.get('/api/stats/media', async (c) => {
|
||||
const library = await tracker.getMediaLibrary();
|
||||
return c.json(library);
|
||||
return c.json(statsJson('mediaLibrary', library));
|
||||
});
|
||||
|
||||
app.get('/api/stats/media/:videoId', async (c) => {
|
||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||
if (videoId <= 0) return c.json(null, 400);
|
||||
if (videoId <= 0) return c.json(statsJson('error', null), 400);
|
||||
const [detail, rawSessions, rollups] = await Promise.all([
|
||||
tracker.getMediaDetail(videoId),
|
||||
tracker.getMediaSessions(videoId, 100),
|
||||
@@ -824,12 +828,12 @@ export function createStatsApp(
|
||||
rawSessions,
|
||||
options?.knownWordCachePath,
|
||||
);
|
||||
return c.json({ detail, sessions, rollups });
|
||||
return c.json(statsJson('mediaDetail', { detail, sessions, rollups }));
|
||||
});
|
||||
|
||||
app.get('/api/stats/anime', async (c) => {
|
||||
const rows = await tracker.getAnimeLibrary();
|
||||
return c.json(rows);
|
||||
return c.json(statsJson('animeLibrary', rows));
|
||||
});
|
||||
|
||||
app.get('/api/stats/anime/:animeId', async (c) => {
|
||||
@@ -841,21 +845,21 @@ export function createStatsApp(
|
||||
tracker.getAnimeEpisodes(animeId),
|
||||
tracker.getAnimeAnilistEntries(animeId),
|
||||
]);
|
||||
return c.json({ detail, episodes, anilistEntries });
|
||||
return c.json(statsJson('animeDetail', { detail, episodes, anilistEntries }));
|
||||
});
|
||||
|
||||
app.get('/api/stats/anime/:animeId/words', async (c) => {
|
||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||
const limit = parseIntQuery(c.req.query('limit'), 50, 200);
|
||||
if (animeId <= 0) return c.body(null, 400);
|
||||
return c.json(await tracker.getAnimeWords(animeId, limit));
|
||||
return c.json(statsJson('animeWords', await tracker.getAnimeWords(animeId, limit)));
|
||||
});
|
||||
|
||||
app.get('/api/stats/anime/:animeId/rollups', async (c) => {
|
||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||
const limit = parseIntQuery(c.req.query('limit'), 90, 365);
|
||||
if (animeId <= 0) return c.body(null, 400);
|
||||
return c.json(await tracker.getAnimeDailyRollups(animeId, limit));
|
||||
return c.json(statsJson('animeRollups', await tracker.getAnimeDailyRollups(animeId, limit)));
|
||||
});
|
||||
|
||||
app.patch('/api/stats/media/:videoId/watched', async (c) => {
|
||||
@@ -864,7 +868,7 @@ export function createStatsApp(
|
||||
const body = await c.req.json().catch(() => null);
|
||||
const watched = typeof body?.watched === 'boolean' ? body.watched : true;
|
||||
await tracker.setVideoWatched(videoId, watched);
|
||||
return c.json({ ok: true });
|
||||
return c.json(statsJson('setVideoWatched', { ok: true }));
|
||||
});
|
||||
|
||||
app.delete('/api/stats/sessions', async (c) => {
|
||||
@@ -874,26 +878,26 @@ export function createStatsApp(
|
||||
: [];
|
||||
if (ids.length === 0) return c.body(null, 400);
|
||||
await tracker.deleteSessions(ids);
|
||||
return c.json({ ok: true });
|
||||
return c.json(statsJson('deleteSessions', { ok: true }));
|
||||
});
|
||||
|
||||
app.delete('/api/stats/sessions/:sessionId', async (c) => {
|
||||
const sessionId = parseIntQuery(c.req.param('sessionId'), 0);
|
||||
if (sessionId <= 0) return c.body(null, 400);
|
||||
await tracker.deleteSession(sessionId);
|
||||
return c.json({ ok: true });
|
||||
return c.json(statsJson('deleteSession', { ok: true }));
|
||||
});
|
||||
|
||||
app.delete('/api/stats/media/:videoId', async (c) => {
|
||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||
if (videoId <= 0) return c.body(null, 400);
|
||||
await tracker.deleteVideo(videoId);
|
||||
return c.json({ ok: true });
|
||||
return c.json(statsJson('deleteVideo', { ok: true }));
|
||||
});
|
||||
|
||||
app.get('/api/stats/anilist/search', async (c) => {
|
||||
const query = (c.req.query('q') ?? '').trim();
|
||||
if (!query) return c.json([]);
|
||||
if (!query) return c.json(statsJson('anilistSearch', []));
|
||||
try {
|
||||
await options?.anilistRateLimiter?.acquire();
|
||||
const res = await fetch('https://graphql.anilist.co', {
|
||||
@@ -918,44 +922,66 @@ export function createStatsApp(
|
||||
});
|
||||
options?.anilistRateLimiter?.recordResponse(res.headers);
|
||||
if (res.status === 429) {
|
||||
return c.json([]);
|
||||
return c.json(statsJson('anilistSearch', []));
|
||||
}
|
||||
const json = (await res.json()) as { data?: { Page?: { media?: unknown[] } } };
|
||||
return c.json(json.data?.Page?.media ?? []);
|
||||
const json = (await res.json()) as {
|
||||
data?: { Page?: { media?: StatsAnilistSearchResult[] } };
|
||||
};
|
||||
return c.json(statsJson('anilistSearch', json.data?.Page?.media ?? []));
|
||||
} catch {
|
||||
return c.json([]);
|
||||
return c.json(statsJson('anilistSearch', []));
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/stats/known-words', (c) => {
|
||||
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
||||
if (!knownWordsSet) return c.json([]);
|
||||
return c.json([...knownWordsSet]);
|
||||
if (!knownWordsSet) return c.json(statsJson('knownWords', []));
|
||||
return c.json(statsJson('knownWords', [...knownWordsSet]));
|
||||
});
|
||||
|
||||
app.get('/api/stats/known-words-summary', async (c) => {
|
||||
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
||||
if (!knownWordsSet) return c.json({ totalUniqueWords: 0, knownWordCount: 0 });
|
||||
if (!knownWordsSet) {
|
||||
return c.json(statsJson('knownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }));
|
||||
}
|
||||
const headwords = await tracker.getAllDistinctHeadwords();
|
||||
return c.json(countKnownWords(headwords, knownWordsSet));
|
||||
return c.json(statsJson('knownWordsSummary', countKnownWords(headwords, knownWordsSet)));
|
||||
});
|
||||
|
||||
app.get('/api/stats/anime/:animeId/known-words-summary', async (c) => {
|
||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||
if (animeId <= 0) return c.json({ totalUniqueWords: 0, knownWordCount: 0 }, 400);
|
||||
if (animeId <= 0) {
|
||||
return c.json(
|
||||
statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
|
||||
400,
|
||||
);
|
||||
}
|
||||
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
||||
if (!knownWordsSet) return c.json({ totalUniqueWords: 0, knownWordCount: 0 });
|
||||
if (!knownWordsSet) {
|
||||
return c.json(
|
||||
statsJson('animeKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
|
||||
);
|
||||
}
|
||||
const headwords = await tracker.getAnimeDistinctHeadwords(animeId);
|
||||
return c.json(countKnownWords(headwords, knownWordsSet));
|
||||
return c.json(statsJson('animeKnownWordsSummary', countKnownWords(headwords, knownWordsSet)));
|
||||
});
|
||||
|
||||
app.get('/api/stats/media/:videoId/known-words-summary', async (c) => {
|
||||
const videoId = parseIntQuery(c.req.param('videoId'), 0);
|
||||
if (videoId <= 0) return c.json({ totalUniqueWords: 0, knownWordCount: 0 }, 400);
|
||||
if (videoId <= 0) {
|
||||
return c.json(
|
||||
statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
|
||||
400,
|
||||
);
|
||||
}
|
||||
const knownWordsSet = loadKnownWordsSet(options?.knownWordCachePath);
|
||||
if (!knownWordsSet) return c.json({ totalUniqueWords: 0, knownWordCount: 0 });
|
||||
if (!knownWordsSet) {
|
||||
return c.json(
|
||||
statsJson('mediaKnownWordsSummary', { totalUniqueWords: 0, knownWordCount: 0 }),
|
||||
);
|
||||
}
|
||||
const headwords = await tracker.getMediaDistinctHeadwords(videoId);
|
||||
return c.json(countKnownWords(headwords, knownWordsSet));
|
||||
return c.json(statsJson('mediaKnownWordsSummary', countKnownWords(headwords, knownWordsSet)));
|
||||
});
|
||||
|
||||
app.patch('/api/stats/anime/:animeId/anilist', async (c) => {
|
||||
@@ -964,7 +990,7 @@ export function createStatsApp(
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body?.anilistId) return c.body(null, 400);
|
||||
await tracker.reassignAnimeAnilist(animeId, body);
|
||||
return c.json({ ok: true });
|
||||
return c.json(statsJson('reassignAnimeAnilist', { ok: true }));
|
||||
});
|
||||
|
||||
registerStatsCoverRoutes(app, tracker);
|
||||
@@ -980,7 +1006,7 @@ export function createStatsApp(
|
||||
rawSessions,
|
||||
options?.knownWordCachePath,
|
||||
);
|
||||
return c.json({ sessions, words, cardEvents });
|
||||
return c.json(statsJson('episodeDetail', { sessions, words, cardEvents }));
|
||||
});
|
||||
|
||||
app.post('/api/stats/anki/browse', async (c) => {
|
||||
@@ -998,10 +1024,10 @@ export function createStatsApp(
|
||||
params: { query: `nid:${noteId}` },
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
return c.json(result);
|
||||
const result = (await response.json()) as StatsAnkiBrowseResponse;
|
||||
return c.json(statsJson('ankiBrowse', result));
|
||||
} catch {
|
||||
return c.json({ error: 'Failed to reach AnkiConnect' }, 502);
|
||||
return c.json(statsJson('error', { error: 'Failed to reach AnkiConnect' }), 502);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1012,7 +1038,7 @@ export function createStatsApp(
|
||||
(id: unknown): id is number => typeof id === 'number' && Number.isInteger(id) && id > 0,
|
||||
)
|
||||
: [];
|
||||
if (noteIds.length === 0) return c.json([]);
|
||||
if (noteIds.length === 0) return c.json(statsJson('ankiNotesInfo', []));
|
||||
const resolvedNoteIds = Array.from(
|
||||
new Set(
|
||||
noteIds.map((noteId) => {
|
||||
@@ -1039,13 +1065,16 @@ export function createStatsApp(
|
||||
result?: Array<{ noteId: number; fields: Record<string, { value: string }> }>;
|
||||
};
|
||||
return c.json(
|
||||
(result.result ?? []).map((note) => ({
|
||||
...note,
|
||||
preview: buildAnkiNotePreview(note.fields, ankiConfig),
|
||||
})),
|
||||
statsJson(
|
||||
'ankiNotesInfo',
|
||||
(result.result ?? []).map((note) => ({
|
||||
...note,
|
||||
preview: buildAnkiNotePreview(note.fields, ankiConfig),
|
||||
})),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
return c.json([], 502);
|
||||
return c.json(statsJson('ankiNotesInfo', []), 502);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1063,19 +1092,24 @@ export function createStatsApp(
|
||||
const mode = rawMode === 'audio' ? 'audio' : rawMode === 'word' ? 'word' : 'sentence';
|
||||
|
||||
if (!sourcePath || !sentence || !Number.isFinite(startMs) || !Number.isFinite(endMs)) {
|
||||
return c.json({ error: 'sourcePath, sentence, startMs, and endMs are required' }, 400);
|
||||
return c.json(
|
||||
statsJson('mineCard', {
|
||||
error: 'sourcePath, sentence, startMs, and endMs are required',
|
||||
}),
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (endMs <= startMs) {
|
||||
return c.json({ error: 'endMs must be greater than startMs' }, 400);
|
||||
return c.json(statsJson('mineCard', { error: 'endMs must be greater than startMs' }), 400);
|
||||
}
|
||||
|
||||
if (!existsSync(sourcePath)) {
|
||||
return c.json({ error: 'File not found' }, 404);
|
||||
return c.json(statsJson('mineCard', { error: 'File not found' }), 404);
|
||||
}
|
||||
|
||||
const ankiConfig = getAnkiConnectConfig();
|
||||
if (!ankiConfig) {
|
||||
return c.json({ error: 'AnkiConnect is not configured' }, 500);
|
||||
return c.json(statsJson('mineCard', { error: 'AnkiConnect is not configured' }), 500);
|
||||
}
|
||||
const secondarySubtitleLanguages = getSecondarySubtitleLanguages();
|
||||
let retimedSecondaryText = '';
|
||||
@@ -1203,7 +1237,7 @@ export function createStatsApp(
|
||||
|
||||
if (mode === 'word') {
|
||||
if (!options?.addYomitanNote) {
|
||||
return c.json({ error: 'Yomitan bridge not available' }, 500);
|
||||
return c.json(statsJson('mineCard', { error: 'Yomitan bridge not available' }), 500);
|
||||
}
|
||||
|
||||
const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([
|
||||
@@ -1219,9 +1253,9 @@ export function createStatsApp(
|
||||
|
||||
if (yomitanResult.status === 'rejected' || !yomitanResult.value) {
|
||||
return c.json(
|
||||
{
|
||||
statsJson('mineCard', {
|
||||
error: `Yomitan failed to create note: ${yomitanResult.status === 'rejected' ? (yomitanResult.reason as Error).message : 'no result'}`,
|
||||
},
|
||||
}),
|
||||
502,
|
||||
);
|
||||
}
|
||||
@@ -1336,7 +1370,7 @@ export function createStatsApp(
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ noteId, ...(errors.length > 0 ? { errors } : {}) });
|
||||
return c.json(statsJson('mineCard', { noteId, ...(errors.length > 0 ? { errors } : {}) }));
|
||||
}
|
||||
|
||||
const wordFieldName = getConfiguredWordFieldName(ankiConfig);
|
||||
@@ -1394,7 +1428,9 @@ export function createStatsApp(
|
||||
|
||||
if (addNoteResult.status === 'rejected') {
|
||||
return c.json(
|
||||
{ error: `Failed to add note: ${(addNoteResult.reason as Error).message}` },
|
||||
statsJson('mineCard', {
|
||||
error: `Failed to add note: ${(addNoteResult.reason as Error).message}`,
|
||||
}),
|
||||
502,
|
||||
);
|
||||
}
|
||||
@@ -1470,7 +1506,7 @@ export function createStatsApp(
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ noteId, ...(errors.length > 0 ? { errors } : {}) });
|
||||
return c.json(statsJson('mineCard', { noteId, ...(errors.length > 0 ? { errors } : {}) }));
|
||||
});
|
||||
|
||||
if (options?.staticDir) {
|
||||
|
||||
@@ -51,10 +51,11 @@ test('parseSyncCliTokens handles the temp-dir protocol modes', () => {
|
||||
assert.equal(parseSyncCliTokens(['sync', '--make-temp', '--remove-temp', '/tmp/x']).kind, 'error');
|
||||
});
|
||||
|
||||
test('parseSyncCliTokens mirrors launcher sync validation', () => {
|
||||
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');
|
||||
|
||||
@@ -16,8 +16,9 @@ export function extractSyncCliTokens(argv: readonly string[]): string[] | null {
|
||||
|
||||
/**
|
||||
* Parse launcher-style sync argv (`sync [host] [--snapshot f] ...`) for the
|
||||
* app's --sync-cli mode. Mirrors the launcher's `subminer sync` validation so
|
||||
* both entry points accept the same command lines and fail the same way.
|
||||
* 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' };
|
||||
@@ -120,7 +121,6 @@ export function parseSyncCliTokens(tokens: readonly string[]): ParsedSyncCli {
|
||||
return {
|
||||
kind: 'run',
|
||||
args: {
|
||||
sync: true,
|
||||
syncHost: host,
|
||||
syncSnapshotPath: snapshot,
|
||||
syncMergePath: merge,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user