Compare commits

...
21 Commits
Author SHA1 Message Date
sudacode e93b696e71 chore(release): prepare v0.20.0-beta.2 2026-09-22 22:24:10 -07:00
sudacode 7c161723cd fix(anki): regenerate sentence furigana from the final sentence (#268) 2026-09-22 21:43:19 -07:00
sudacode b0fb39a8f5 feat(overlay): add optional subtitle selection modal (#265) 2026-09-22 18:30:26 -07:00
Abdulrazzaq Alhendi a7a302bdd0 fix(jellyfin): respect Windows mpv configuration when casting (#267) 2026-09-22 18:29:53 -07:00
sudacode 6a1d1211ae fix(release): resolve style.css asar lookup with native separators
The shared-font check still handed asar a forward-slash path, which
fails on Windows the same way the inventory listing did.
2026-09-22 02:29:28 -07:00
sudacode 7585d430ae fix(release): unbreak packaging workflow on all three platforms
- Bump electron-builder to 26.16.1 so macOS signing passes the temp keychain
  password to set-key-partition-list (upstream #10066), which the macOS 26
  runner image now enforces
- Stat asar entries with their native path in the package audit; splitting on
  path.sep made every nested file look missing on Windows
- Run the package smoke with --no-sandbox on Linux runners without a setuid
  chrome-sandbox
- Load the packaged stats dashboard over loopback HTTP in the smoke, matching
  how the app serves it since #263, and ignore Chromium's cache-only font probe
2026-09-22 02:15:49 -07:00
sudacode 122ff9a3c5 chore(release): prepare v0.20.0-beta.1
Trim pending change fragments, drop the breaking flag from the stats
request-safety change, and generate prerelease notes.
2026-09-22 01:29:58 -07:00
sudacode 786bd5af81 chore(yomitan): update vendored fork to upstream 26.9.8 2026-09-21 20:53:26 -07:00
sudacode 42d1384c68 fix(jellyfin): support modern authentication (#264) 2026-09-21 20:40:57 -07:00
sudacode 1508863dbb feat(stats): add TMDB metadata for live-action dramas in the Library (#252) 2026-09-21 00:10:00 -07:00
sudacode 4dd30f44d1 chore(yomitan): update fork to upstream 26.9.8 2026-09-21 00:09:48 -07:00
sudacode ab48a5678e fix(stats): restrict local requests and serve the dashboard over HTTP (#263) 2026-09-20 23:36:55 -07:00
sudacode 2f21582666 fix(overlay): cancel pending window transitions and timing reviews (#262) 2026-09-20 23:35:48 -07:00
sudacode 383aed8bad fix(stats): harden server lifecycle and verify compiled runtime (#261) 2026-09-20 23:19:03 -07:00
sudacode f9c4e892dc fix(stats): reject malformed resource IDs before mutations (#259) 2026-09-20 22:49:27 -07:00
sudacode 7959e1a4e5 fix(anki): honor unlimited duration in stats mining (#258) 2026-09-20 22:49:12 -07:00
sudacode 917d52dd94 fix(config): validate AnkiConnect and field grouping settings (#257) 2026-09-20 22:48:54 -07:00
sudacode 05425cc5db fix(anki): separate word audio mapping for animation sync (#256) 2026-09-20 22:07:30 -07:00
sudacode 8097ed27e5 feat(stats): separate YouTube channels in the Library (#253) 2026-09-20 19:57:32 -07:00
sudacode 76e43d7e6d fix(config): align live save feedback with hot reload policy (#255) 2026-09-20 19:51:45 -07:00
Abdulrazzaq Alhendi 4f0762e840 feat(mining): add a screenshot frame picker to media review (#254) 2026-09-20 19:39:30 -07:00
285 changed files with 11774 additions and 1682 deletions

No files matched your search

+8
View File
@@ -13,6 +13,9 @@ on:
required: true required: true
APPLE_TEAM_ID: APPLE_TEAM_ID:
required: true required: true
# Project TMDB key baked into release artifacts; builds stay valid without it.
SUBMINER_TMDB_API_KEY:
required: false
permissions: permissions:
contents: read contents: read
@@ -69,6 +72,8 @@ jobs:
- name: Build AppImage - name: Build AppImage
run: bun run build:appimage run: bun run build:appimage
env:
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
- name: Build unversioned AppImage - name: Build unversioned AppImage
run: | run: |
@@ -168,6 +173,7 @@ jobs:
- name: Build signed + notarized macOS artifacts - name: Build signed + notarized macOS artifacts
run: bun run build:mac run: bun run build:mac
env: env:
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
CSC_LINK: ${{ secrets.CSC_LINK }} CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_ID: ${{ secrets.APPLE_ID }}
@@ -248,6 +254,8 @@ jobs:
- name: Build unsigned Windows artifacts - name: Build unsigned Windows artifacts
run: bun run build:win:unsigned run: bun run build:win:unsigned
env:
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
- name: Smoke packaged runtime assets - name: Smoke packaged runtime assets
shell: bash shell: bash
+1
View File
@@ -27,6 +27,7 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
release: release:
needs: [package] needs: [package]
+13 -13
View File
@@ -29,6 +29,9 @@ jobs:
- name: Verify Windows launcher bootstrap - name: Verify Windows launcher bootstrap
run: bun test src/main/runtime/windows-launcher-bootstrap.test.ts run: bun test src/main/runtime/windows-launcher-bootstrap.test.ts
- name: Verify native mpv process launch
run: bun test src/main/runtime/mpv-process.test.ts
- name: Verify POSIX launcher bootstrap - name: Verify POSIX launcher bootstrap
run: bun test src/main/runtime/posix-launcher-bootstrap.test.ts run: bun test src/main/runtime/posix-launcher-bootstrap.test.ts
@@ -93,12 +96,20 @@ jobs:
sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua
lua -v lua -v
- name: Test suite (source) - name: Launcher unit and script suites
run: bun run test:fast run: bun run test:launcher:unit:src && bun run test:scripts
- name: Environment suite - name: Environment suite
run: bun run test:env run: bun run test:env
- 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: Coverage suite (maintained source lane) - name: Coverage suite (maintained source lane)
run: bun run test:coverage:src run: bun run test:coverage:src
@@ -112,17 +123,6 @@ jobs:
- name: Stats UI tests - name: Stats UI tests
run: bun run test:stats 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) - name: Build (bundle)
run: bun run build run: bun run build
+1
View File
@@ -28,6 +28,7 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
SUBMINER_TMDB_API_KEY: ${{ secrets.SUBMINER_TMDB_API_KEY }}
release: release:
needs: [package] needs: [package]
+10 -10
View File
@@ -22,7 +22,7 @@
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"electron": "42.6.0", "electron": "42.6.0",
"electron-builder": "26.15.3", "electron-builder": "26.16.1",
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"eslint": "^10.8.0", "eslint": "^10.8.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
@@ -36,9 +36,9 @@
}, },
"overrides": { "overrides": {
"@xmldom/xmldom": "0.8.15", "@xmldom/xmldom": "0.8.15",
"app-builder-lib": "26.15.3", "app-builder-lib": "26.16.1",
"brace-expansion": "5.0.9", "brace-expansion": "5.0.9",
"electron-builder-squirrel-windows": "26.15.3", "electron-builder-squirrel-windows": "26.16.1",
"fast-uri": "3.1.6", "fast-uri": "3.1.6",
"form-data": "4.0.6", "form-data": "4.0.6",
"ip-address": "10.2.0", "ip-address": "10.2.0",
@@ -181,7 +181,7 @@
"@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="], "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="],
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.8.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q=="], "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.8.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q=="],
@@ -243,7 +243,7 @@
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"app-builder-lib": ["app-builder-lib@26.15.3", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^2.2.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.15.3", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.15.3", "electron-builder-squirrel-windows": "26.15.3" } }, "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow=="], "app-builder-lib": ["app-builder-lib@26.16.1", "", { "dependencies": { "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@noble/hashes": "^1.8.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.16.0", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.2.5", "pkijs": "^3.4.0", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "unzipper": "^0.12.3", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.16.1", "electron-builder-squirrel-windows": "26.16.1" } }, "sha512-FhaO6YOup01ZfQW0Z6gt3AyukJjv1gW4uFK47jTgwcHZKqyN/fSlK2LqPf9tAeZYLP2bRJLDzeOkRImsw2X4Pg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
@@ -273,7 +273,7 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"builder-util": ["builder-util@26.15.3", "", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "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-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw=="], "builder-util": ["builder-util@26.16.0", "", { "dependencies": { "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", "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-RLyJhB7Si3YkzKR9ubQslWuXW3Vhs3CGe1i+SeixBZ0qTd1mk3XBmssvY22TlB6CS5blyko8Gu1JzpYk8UkYAg=="],
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="], "builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
@@ -335,7 +335,7 @@
"discord-api-types": ["discord-api-types@0.38.49", "", {}, "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg=="], "discord-api-types": ["discord-api-types@0.38.49", "", {}, "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg=="],
"dmg-builder": ["dmg-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ=="], "dmg-builder": ["dmg-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-pnI/3Qb24Uk+rMTgIUrsVUKosVgwmBUdF8Zeb8TexOSbpq8MWc7v6l+n+FrEqVkjNZwzBN+XpDS9ENgZ/rkWAw=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
@@ -349,11 +349,11 @@
"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": ["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.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.3", "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-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA=="], "electron-builder": ["electron-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.16.1", "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-LrLK65QX5PUYYODXqp23FKrV7CILTtVY7mrJckNknO9jLNSMiqFkKbSMiDRw4CjOADMPVDdWLxY4mezOZWswxg=="],
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "electron-winstaller": "5.4.0" } }, "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA=="], "electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "electron-winstaller": "5.4.0" } }, "sha512-w0y44wSaT1l6R7CAGmeHn4nHPfvzDyCAU1xJyi1w9SbPYJpYn76SmHDzqHf8Y7l91cPWTdPYBpGQtB2T5mJ08A=="],
"electron-publish": ["electron-publish@26.15.3", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q=="], "electron-publish": ["electron-publish@26.16.0", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-Vt3KzQIiw9BImvNOYtndg9Mjki+tl4+1sQiC/+G5j8khWaENOJFWodiB+sUl6yyHwtd37avehskdtPw7f8y/+Q=="],
"electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="], "electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="],
+1 -2
View File
@@ -1,5 +1,4 @@
type: docs type: docs
area: launcher area: launcher
- Documented private-runtime launcher installation, simplified first-run setup, custom app locations, legacy launcher migration, and package-managed updates. Updated release-note download guidance for the bundled runtime and Windows launcher. - Documented the bundled-runtime launcher install, legacy launcher migration, package-managed updates, and the bundled Bun runtime's MIT and LGPL notices (installed under `/usr/share/licenses/subminer-bin` by the AUR package and included in `subminer-assets.tar.gz`).
- Documented the bundled Bun runtime's MIT and LGPL licensing, where the notices live in the app, and the corresponding-source release asset. The AUR package now lists the bundled runtime licenses and installs their notices under `/usr/share/licenses/subminer-bin`, and `subminer-assets.tar.gz` includes the same notices.
+1 -2
View File
@@ -1,5 +1,4 @@
type: changed type: changed
area: launcher area: launcher
- Made every installed and downloadable launcher use the Bun runtime included with SubMiner. Added a Windows `subminer.cmd` download, persistent private runtime caches for Linux and Windows, and migration for recognized legacy launchers. Deferred migrations survive overlapping update checks and remain pending when startup cannot safely refresh the launcher. - Every installed and downloadable launcher now uses the Bun runtime bundled with SubMiner instead of a system Bun. Recognized legacy launchers migrate automatically, Windows gets a `subminer.cmd` download, and first-run setup is reduced to a single optional launcher control with runtime repair guidance shown only when needed.
- Simplified first-run setup to a single optional launcher control, with runtime repair guidance shown only when needed.
+4
View File
@@ -0,0 +1,4 @@
type: internal
area: ci
- Removed duplicate source and launcher smoke executions from the reusable quality gate while preserving every distinct test lane and failure artifact.
+4
View File
@@ -0,0 +1,4 @@
type: internal
area: verification
- Replaced mislabeled dist source reruns with a small Electron-runtime smoke check for compiled stats startup, HTTP service, native SQLite, port conflicts, and cleanup.
+1 -3
View File
@@ -1,6 +1,4 @@
type: changed type: changed
area: sync area: sync
- Sync uses compressed, incremental rsync transfers on compatible macOS and Linux machines, caching the last received snapshot per peer to reduce traffic on subsequent syncs. Cache helpers work through the launcher; older apps and launchers fall back to compressed transfers without an upload cache. - Sync now uses compressed, incremental rsync transfers between compatible macOS and Linux machines, caching the last received snapshot per peer to cut traffic on later syncs. Machines without compatible rsync (including Windows) fall back to compressed scp, and older peers still work without the upload cache. Transfers abort after 30 minutes.
- Machines without compatible rsync, including Windows endpoints, automatically use compressed scp transfers.
- Rsync explicitly uses SSH and aborts transfers that exceed 30 minutes before merging.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Added `ankiConnect.fields.wordAudio` so word audio is read separately from the sentence-audio destination, fixing animated images that started moving immediately when `fields.audio` pointed to `SentenceAudio`.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: config
- Validate direct AnkiConnect, Kiku, and Senren settings before admitting them to runtime config, with warnings and defaults for invalid values.
+1 -4
View File
@@ -1,7 +1,4 @@
type: fixed type: fixed
area: jellyfin area: jellyfin
- Set the mpv title before loading Jellyfin streams and reject URL-derived titles from metadata lookups, Anki source fields, Discord presence, and stats. - Jellyfin streams no longer leak URL-derived titles or credential-bearing stream URLs into metadata lookups, Anki source fields, Discord presence, stats identities, or AniList retry keys. Previously cached credential-bearing parser metadata is cleaned up without touching watch history or library assignments.
- Keep authenticated stream URLs out of stats identities even when playback metadata has not arrived.
- Remove previously cached credential-bearing Jellyfin stream parser metadata without changing unrelated anime metadata, watch history, or library assignments.
- Use safe media identities for persisted AniList retry keys, discard URL-derived queued searches, and skip updates and retries whose media identity is empty.
+7
View File
@@ -0,0 +1,7 @@
type: fixed
area: jellyfin
- Jellyfin playback, subtitle, artwork, and remote-control URLs now authenticate with the `ApiKey` query parameter instead of the legacy `X-Emby-*` headers, so the integration works on Jellyfin 12 where legacy authorization is disabled by default.
- "Play on SubMiner" keeps working on Jellyfin 12: the cast-target websocket answers keep-alive requests and reconnects when the server stops replying instead of silently dying after about a minute.
- The Jellyfin "now playing" bar clears when you close or finish a cast video instead of running on to the end of the episode.
- Anki cards mined from Jellyfin playback get the episode title in the misc info field again instead of "Unknown media".
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Reject malformed resource IDs and partly invalid ID lists before stats library mutations or cover backfills run.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Stats server port conflicts are reported through status notifications instead of crashing SubMiner. Startup and shutdown are also more robust: concurrent startup requests are shared, background stop no longer disconnects foreground dashboards, and shutdown bounds how long it waits for active requests.
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Closing the overlay while media timing review is still loading now cancels setup and modal retries, restores playback if the review paused it, and cleans up the hidden preview player.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Treat `ankiConnect.media.maxMediaDuration: 0` as unlimited for stats dashboard mining, matching overlay mining and configuration.
+1 -2
View File
@@ -1,5 +1,4 @@
type: docs type: docs
area: subtitles area: subtitles
- Explain how dialogue generation retains uncertain audible sections, why songs may also be transcribed, how detected speech starts guide long-passage cuts to reduce early subtitles, and why each passage uses a fresh Whisper process. - Documented subtitle generation: model choice, VAD behavior, reference-track guided splitting, fallback behavior, and known limits.
- Document automatic loaded subtitle references, English dialogue preference, signs/songs filtering, fallback behavior, and the limits of reference-guided timing.
+4 -5
View File
@@ -1,8 +1,7 @@
type: added type: added
area: subtitles area: subtitles
- Generate local Japanese SRT subtitles with whisper.cpp from a standalone modal opened with Ctrl+Shift+G, the empty subtitle sidebar's generation button, or `subminer generate-subs`, with shared progress reporting, cancellation, safe output files, and automatic loading into the matching mpv video. The sidebar button hides while subtitle lines are loaded. - Generate Japanese SRT subtitles locally with whisper.cpp from a modal (Ctrl+Shift+G), the empty subtitle sidebar's generation button, or `subminer generate-subs`, with progress, cancellation, and automatic loading into mpv.
- Configure an existing multilingual model in Settings or choose an official multilingual model, including quantized variants, in the modal or launcher. The modal shows download sizes, speed and accuracy guidance, and a recommended starting model before explicitly downloading a verified SubMiner-managed model. Executable paths are optional overrides; empty fields find whisper-cli, ffmpeg, and ffprobe on PATH. The modal's Local tools check and the launcher name any missing executable and its setting before downloading a model or extracting audio, and generation confirms the destination directory grants write and search permissions up front. - Pick an official multilingual model (including quantized variants) with size and accuracy guidance and download it in-app, or point Settings at an existing model. `large-v3-turbo` is recommended when CUDA support is detected, `small` otherwise. whisper-cli, ffmpeg, and ffprobe are found on PATH unless overridden, and missing tools are named before any download starts.
- Optionally select Focus on spoken dialogue in the modal and use Download speech detection model to install the separate Silero model with progress and cancellation. The choice lasts for the session; a configured VAD model path sets the default. Retain uncertain audible sections so VAD rejection does not discard dialogue under music, accepting that songs may also be transcribed. Keep passages intact within Whisper's audio window, split longer passages near detected speech starts or quiet pauses with overlapping context to reduce early subtitle timing, and combine overlapping duplicate cues even when punctuation differs. Run each passage in a fresh Whisper process to prevent repeated-character output caused by state carried between files, at the cost of reloading the model per passage. Preserve original media timing and separate repeated dialogue. - Optional "Focus on spoken dialogue" mode uses a separately downloadable Silero VAD model, keeping uncertain audible sections so dialogue under music is not dropped (songs may be transcribed too).
- Recommend large-v3-turbo in the model picker when NVIDIA hardware and CUDA device support in the selected Whisper executable are confirmed; recommend small otherwise. Preserve configured and session model selections. Use a wider generation modal to reduce text wrapping and scrolling, while keeping it within smaller player windows. - Long passages are split near detected speech starts or quiet pauses, guided by an eligible embedded or external subtitle track already loaded in mpv when one is available, to reduce early subtitle timing. Each passage runs in a fresh Whisper process to avoid repeated-character output.
- Automatically use an eligible embedded or external subtitle track loaded in mpv to guide long-passage cuts, preferring English dialogue and excluding tracks marked signs, songs, forced, or generated. Honor active subtitle delays, retain unreferenced audio, and fall back to audio timing when references cannot be read. Available in the overlay and launcher, with or without VAD. The launcher captures audio selection, reference tracks, and delays before model setup so playback changes during a download do not mix inputs from different times, and rejects an unverified media snapshot before using mpv's selected audio stream.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: jellyfin
- Clarify that Windows mpv playback and Jellyfin casting can use a configured executable path instead of PATH.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Honor the configured mpv executable when Jellyfin starts playback, allowing casting when mpv is installed outside PATH, and detect portable plugins beside the selected executable.
+1 -2
View File
@@ -1,5 +1,4 @@
type: docs type: docs
area: jellyfin area: jellyfin
- Explain how Jellyfin media titles, unknown-title fallbacks, and stats identities keep stream credentials out of metadata. - Documented how Jellyfin media titles and stats identities keep stream credentials out of metadata.
- Document AniList rejection of empty media identities and the conditions for clearing cached Jellyfin parser metadata.
@@ -0,0 +1,4 @@
type: docs
area: mining
- Document independent screenshot selection in media timing review.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: mining
- Choose a still screenshot independently of the audio in media timing review, with a live preview, a separate time slider, and decoded-frame stepping. Local video and seekable remote streams such as Jellyfin are supported.
+1 -1
View File
@@ -1,4 +1,4 @@
type: added type: added
area: overlay area: overlay
- The overlay discovers non-conflicting keyboard bindings from mpv defaults, input.conf, and loaded scripts in the background. SubMiner controls and explicitly disabled bindings take precedence. Discovered bindings stay session-only and do not appear in SubMiner's help menu. - The overlay picks up non-conflicting keyboard bindings from mpv defaults, `input.conf`, and loaded scripts. SubMiner controls and explicitly disabled bindings take precedence; discovered bindings are session-only and not listed in the help menu.
+1 -2
View File
@@ -1,5 +1,4 @@
type: changed type: changed
area: release area: release
- Reduced installer and unpacked app size by excluding documentation demo media, dependency source maps, TypeScript files, test and fixture directories, other development files, and unused Koffi platform binaries, and sharing the existing Japanese UI font across windows. - Reduced installer and unpacked app size by dropping demo media, source maps, TypeScript sources, test fixtures, and unused Koffi binaries from the package, and sharing one Japanese UI font across windows. Release builds now publish package size reports with comparisons against the previous release.
- Added package content checks, published size reports with release comparisons, and packaged asset/native-module smoke checks to the shared stable and prerelease build workflow. Size growth is reported without blocking releases.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Cancel pending Linux overlay window replacements during teardown so a delayed close callback cannot reopen the overlay.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Keep word-card sentence furigana in sync with full stats-search context and expanded timing-review selections. Clear stale readings if regeneration fails.
@@ -0,0 +1,4 @@
type: docs
area: config
- Clarified live-setting save feedback, mixed restart warnings, and subtitle-generation reload behavior.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: config
- Settings marked LIVE now use the same reload policy as save results, fixing false restart warnings for notifications and subtitle generation.
- Mixed saves apply live changes and list only sections with changed fields that require a restart.
+1 -1
View File
@@ -1,4 +1,4 @@
type: added type: added
area: overlay area: overlay
- Select dialogue across subtitle sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button. Selection keeps the excerpt in view during playback and does not seek or require mining a card. - Select dialogue across subtitle sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button. Selecting does not seek or require mining a card.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: stats
- The stats server now rejects requests from non-loopback hosts and browser origins and requires `application/json` for mutation bodies. The in-app stats overlay loads from the local server so it shares the same protection. Reverse-proxied or Tailscale Serve dashboards are unsupported; scripts that POST must set a JSON content type.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: config
- Documented the subtitle selector setting, shortcut override, and primary/secondary track controls.
+5
View File
@@ -0,0 +1,5 @@
type: added
area: overlay
- Added an optional Catppuccin subtitle selection modal for primary and secondary mpv tracks. Enable it in Settings under Behavior, then press g followed by s. Disabling it restores mpv's subtitle selection binding.
- Single-key actions take priority over configured sequence prefixes. Conflicting sequences are disabled with a warning, and the existing y commands stay reserved.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: stats
- Documented TMDB linking, provider reassignment, merge compatibility, and credential command caching.
+6
View File
@@ -0,0 +1,6 @@
type: added
area: stats
- Live-action dramas and movies in the stats Library get posters, synopses, and titles from TMDB. Release builds include a project key; `tmdb.apiKey` (or `tmdb.apiKeyCommand`) overrides it and is required when running from source.
- Titles AniList cannot match are looked up on TMDB automatically when the parsed filename matches a Japanese live-action title exactly; otherwise use the new **Link to TMDB** action. Entries linked to the same TMDB title merge into one card, and the Library kind selector gained a Live Action option.
- Provider reassignment keeps the previous link and artwork if the replacement download fails. Merges and sync keep AniList and TMDB identities separate, and the merge dialog explains mixed selections instead of failing.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: anki
- Documented the separate word-audio field mapping; existing animated images need regeneration to pick up the fix.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: yomitan
- Updated bundled Yomitan with upstream 26.9.8 changes, including historical Japanese kana transformations, Ukrainian language support, and improvements to Anki duplicate searches and audio retrieval.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: stats
- Document YouTube channel filtering and video statistics in the Library.
+5
View File
@@ -0,0 +1,5 @@
type: added
area: stats
- YouTube channels are now a separate Library media kind. Existing channel entries migrate automatically without changing viewing history or manual video assignments.
- Added All Titles, Anime, and YouTube Library filters. Channels stay out of AniList matching, season repair, and duplicate recommendations, and merges or video moves cannot mix an anime entry with a YouTube channel.
+23 -2
View File
@@ -6,6 +6,15 @@
*/ */
{ {
// ==========================================
// Subtitle Selection
// Select primary and secondary mpv subtitle tracks from the overlay.
// Hot-reload: enabling or disabling updates the session shortcut immediately.
// ==========================================
"subtitleSelection": {
"enabled": false // Use the SubMiner modal to select primary and secondary subtitle tracks. When enabled, its shortcut overrides mpv subtitle selection. Values: true | false
}, // Select primary and secondary mpv subtitle tracks from the overlay.
// ========================================== // ==========================================
// Japanese Subtitle Generation // Japanese Subtitle Generation
// Generate timed Japanese subtitles from local audio using whisper.cpp. // Generate timed Japanese subtitles from local audio using whisper.cpp.
@@ -223,6 +232,7 @@
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal. "openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search 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). "openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).
"openSubtitleSelection": "g-s", // Open subtitle selection when enabled. Use g-s to press g then s. Set null to unbind.
"openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal. "openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal.
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. "openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
@@ -541,7 +551,7 @@
// ========================================== // ==========================================
// AnkiConnect Integration // AnkiConnect Integration
// Automatic Anki updates and media generation options. // Automatic Anki updates and media generation options.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running. // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/wordAudio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
// Shared AI provider transport settings are read from top-level ai and typically require restart. // Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still require restart. // Most other AnkiConnect settings still require restart.
// ========================================== // ==========================================
@@ -562,6 +572,7 @@
"fields": { "fields": {
"word": "Expression", // Card field for the mined word or expression text. "word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Card field that receives generated sentence audio. "audio": "ExpressionAudio", // Card field that receives generated sentence audio.
"wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.
"image": "Picture", // Card field that receives the captured screenshot or animated image. "image": "Picture", // Card field that receives the captured screenshot or animated image.
"sentence": "Sentence", // Card field that receives the source sentence text. "sentence": "Sentence", // Card field that receives the source sentence text.
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern). "miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
@@ -590,7 +601,7 @@
"reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false "reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips. "audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable. "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. 0 disables the cap.
}, // Media setting. }, // Media setting.
"knownWords": { "knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
@@ -658,6 +669,16 @@
"maxSearchResults": 10 // Maximum TsukiHime search results returned. "maxSearchResults": 10 // Maximum TsukiHime search results returned.
}, // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required. }, // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
// ==========================================
// TMDB
// TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.
// Hot-reload: TMDB changes apply to the next TMDB request.
// ==========================================
"tmdb": {
"apiKey": "", // Your own TMDB API key or read access token for live-action posters and synopses in the stats Library. Release builds bundle a project key, so set this only to use your own quota or when running from source (free under Settings > API on themoviedb.org).
"apiKeyCommand": "" // Shell command that prints the TMDB API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
}, // TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.
// ========================================== // ==========================================
// YouTube Playback Settings // YouTube Playback Settings
// Defaults for managed subtitle language preferences and YouTube subtitle loading. // Defaults for managed subtitle language preferences and YouTube subtitle loading.
+2
View File
@@ -71,6 +71,8 @@ SubMiner fetches cover art from AniList for display in the stats dashboard. When
A no-match result is cached for 5 minutes before SubMiner retries, preventing repeated API calls for unrecognized media. A no-match result is cached for 5 minutes before SubMiner retries, preventing repeated API calls for unrecognized media.
When AniList has no match, SubMiner tries [TMDB](/configuration#tmdb) next so live-action dramas and movies get a poster and synopsis too. See [Immersion tracking](/immersion-tracking#library) for how live-action entries are grouped.
If the automatic match is wrong, use **Change AniList Entry** on a title in the stats Library. Relinking rewrites the cached art for every episode of that title, and both the detail view and the Library grid pick up the new cover right away: the grid refetches after a relink, and cover responses carry an ETag and are revalidated on each request instead of being cached for a day. If the automatic match is wrong, use **Change AniList Entry** on a title in the stats Library. Relinking rewrites the cached art for every episode of that title, and both the detail view and the Library grid pick up the new cover right away: the grid refetches after a relink, and cover responses carry an ETag and are revalidated on each request instead of being cached for a day.
## Rate limiting ## Rate limiting
+16 -1
View File
@@ -19,6 +19,8 @@ This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [La
AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the port in AnkiConnect's settings, update `ankiConnect.url` in your SubMiner config. AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the port in AnkiConnect's settings, update `ankiConnect.url` in your SubMiner config.
AnkiConnect and Kiku/Senren settings follow the [configuration validation rules](/configuration#configuration-file): invalid values produce a warning and fall back to the option's default. Use JSON booleans such as `true`, not strings such as `"true"`, and a positive number for `ankiConnect.pollingRate`.
## Auto-enrichment transport ## Auto-enrichment transport
When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, and image fields automatically. Two detection methods are available: When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, and image fields automatically. Two detection methods are available:
@@ -125,6 +127,7 @@ SubMiner maps its data to your Anki note fields. Configure these under `ankiConn
"fields": { "fields": {
"word": "Expression", // mined word / expression text "word": "Expression", // mined word / expression text
"audio": "SentenceAudio", // sentence audio clip cut from the video "audio": "SentenceAudio", // sentence audio clip cut from the video
"wordAudio": "ExpressionAudio", // existing Yomitan word audio, read for animation sync
"image": "Picture", // screenshot or animated clip "image": "Picture", // screenshot or animated clip
"sentence": "Sentence", // subtitle text "sentence": "Sentence", // subtitle text
"miscInfo": "MiscInfo" // metadata (filename, timestamp) "miscInfo": "MiscInfo" // metadata (filename, timestamp)
@@ -136,6 +139,8 @@ SubMiner maps its data to your Anki note fields. Configure these under `ankiConn
Field names are matched against your Anki note type case-insensitively (an exact match wins, then a lowercase comparison). If a configured field does not exist on the note type, SubMiner skips it without error. Field names are matched against your Anki note type case-insensitively (an exact match wins, then a lowercase comparison). If a configured field does not exist on the note type, SubMiner skips it without error.
`fields.wordAudio` selects the existing dictionary-audio field used to calculate the animated image's opening freeze. This mapping only reads audio; `fields.audio` still controls where generated sentence audio is written. See [config.example.jsonc](/config.example.jsonc) for defaults.
These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names. These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names.
Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `<br>` newline). Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `<br>` newline).
@@ -183,12 +188,18 @@ Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMine
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`. The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
Overlay and stats-dashboard mining use the same `media.maxMediaDuration` limit. See the [configuration example](/config.example.jsonc) for its default and how to disable the cap.
Set `media.reviewTiming` to `true` to pause playback and check the clip before its media is generated. It applies to word, sentence, and audio cards. Set `media.reviewTiming` to `true` to pause playback and check the clip before its media is generated. It applies to word, sentence, and audio cards.
The review opens on the subtitle range plus your configured audio padding. Subtitles usually hang around after the dialogue has stopped, so once the waveform loads, an untouched clip end pulls back to just after the last speech in the line. The Line end rail still marks the original subtitle timing, Reset puts it back, and a line whose speech runs right through its end is left alone. The review opens on the subtitle range plus your configured audio padding. Subtitles usually hang around after the dialogue has stopped, so once the waveform loads, an untouched clip end pulls back to just after the last speech in the line. The Line end rail still marks the original subtitle timing, Reset puts it back, and a line whose speech runs right through its end is left alone.
**Adjusting the clip.** Drag either edge to trim, drag the middle to slide the whole clip without changing its length, or click anywhere on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys: 100 ms per press, or 500 ms with Shift. The 100 ms buttons do the same thing. Earlier and Later each reveal two more seconds of timeline without moving the selection. **Adjusting the clip.** Drag either edge to trim, drag the middle to slide the whole clip without changing its length, or click anywhere on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys: 100 ms per press, or 500 ms with Shift. The 100 ms buttons do the same thing. Earlier and Later each reveal two more seconds of timeline without moving the selection.
**Choosing the screenshot.** With still images enabled, drag the screenshot slider or use the Frame buttons to choose a video frame independently of the audio. Earlier and Later reveal more time for both sliders. The image follows the audio midpoint until you pick a frame, then stays fixed while you trim audio. Reset below the image restores automatic selection; the audio Reset affects only the audio. The screenshot slider also supports arrow keys, Home, and End.
The picker supports local video and seekable remote streams, including Jellyfin, without seeking the main player. A selected frame must load before you can confirm; if it fails, choose another frame or Reset. The picker is hidden when image generation is disabled or animated AVIF is selected. Animated images continue to use the reviewed audio range.
**Keys.** Space previews the selection with a playhead sweeping the clip. The preview ends when the hidden player has actually played the last sample, so Bluetooth output latency does not clip the tail. Enter confirms and Escape cancels. **Keys.** Space previews the selection with a playhead sweeping the clip. The preview ends when the hidden player has actually played the last sample, so Bluetooth output latency does not clip the tail. Enter confirms and Escape cancels.
**The waveform.** SubMiner reads a center channel when one carries dialogue and falls back to a mono mix otherwise, keeps only the 250 to 3500 Hz speech band, and draws each slice's loudness against the clip's own noise floor. Steady background music flattens out and dialogue stands up, which makes it much easier to tell adjacent lines apart. The mined subtitle appears as a tinted band with labeled line-start and line-end rails. If waveform analysis fails, the timing controls still work. **The waveform.** SubMiner reads a center channel when one carries dialogue and falls back to a mono mix otherwise, keeps only the 250 to 3500 Hz speech band, and draws each slice's loudness against the clip's own noise floor. Steady background music flattens out and dialogue stands up, which makes it much easier to tell adjacent lines apart. The mined subtitle appears as a tinted band with labeled line-start and line-end rails. If waveform analysis fails, the timing controls still work.
@@ -201,8 +212,12 @@ Confirming writes the combined lines to the sentence field. Reset drops the adde
**Canceling.** You can go back to editing, finish with the original timing, create the card without audio or an image, or discard it. Discard deletes an existing Yomitan or audio card, and skips creation entirely for a direct sentence card. A failed audio preview does not block confirmation or card creation. **Canceling.** You can go back to editing, finish with the original timing, create the card without audio or an image, or discard it. Discard deletes an existing Yomitan or audio card, and skips creation entirely for a direct sentence card. A failed audio preview does not block confirmation or card creation.
When word-card enrichment changes the sentence context, including an expanded timing-review selection, SubMiner regenerates `SentenceFurigana` from the final sentence. Unchanged sentences keep their existing furigana formatting. If generation fails, SubMiner clears stale furigana so compatible templates can fall back to `Sentence`.
Clipboard updates and stats-dashboard mining never open timing review. The option is off by default and hot-reloads. **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`) toggles it for the current session. Clipboard updates and stats-dashboard mining never open timing review. The option is off by default and hot-reloads. **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`) toggles it for the current session.
If SubMiner closes the overlay while a timing review is still loading, it cancels pending setup and modal retries and restores playback if the review paused it. A new timing review can start after the overlay reopens.
### Screenshots (static) ### Screenshots (static)
A single frame is captured at the current playback position. A single frame is captured at the current playback position.
@@ -237,7 +252,7 @@ SubMiner can produce an animated AVIF spanning the subtitle duration instead of
} }
``` ```
Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) in your FFmpeg build. Generation timeout is 60 seconds. `media.syncAnimatedImageToWordAudio` (default `true`) prepends a frozen first frame matching the existing word-audio duration, so the motion starts together with the sentence audio. Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) in your FFmpeg build. Generation timeout is 60 seconds. `media.syncAnimatedImageToWordAudio` (default `true`) prepends a frozen first frame matching the existing audio duration in `fields.wordAudio`, so the motion starts together with the sentence audio. The freeze is baked into the image when mined; changing the mapping does not repair previously generated images.
### Behavior options ### Behavior options
+39 -3
View File
@@ -61,7 +61,7 @@ The Settings window preserves existing JSONC comments, trailing commas, and unre
Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `jimaku.apiKeyCommand` when available. Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `jimaku.apiKeyCommand` when available.
Saving validates the candidate config before writing. Live-reloadable changes are applied immediately; other changes return a restart-required banner in the window. Saving validates the candidate config before writing. Saving only fields marked **LIVE** shows "Saved. Live settings applied." If a save also changes fields that need a restart, the banner lists only the sections containing those changed fields. Live changes still apply in the same save.
## Configuration file ## Configuration file
@@ -103,7 +103,7 @@ SubMiner watches the active config file (`config.jsonc` or `config.json`) while
Hot-reloadable settings include subtitle appearance, sidebar controls, keybindings, Hot-reloadable settings include subtitle appearance, sidebar controls, keybindings,
shortcuts, notifications, logging level, selected source-language preferences, shortcuts, notifications, logging level, selected source-language preferences,
Jimaku/Subsync settings, AniSkip settings (`mpv.aniskipEnabled`, `mpv.aniskipButtonKey`), Jimaku/Subsync and subtitle-generation settings, AniSkip settings (`mpv.aniskipEnabled`, `mpv.aniskipButtonKey`),
stats keys (`stats.toggleKey`, `stats.markWatchedKey`), the secondary-subtitle default stats keys (`stats.toggleKey`, `stats.markWatchedKey`), the secondary-subtitle default
mode, and the Anki deck, known-word, N+1, field, sentence-card, and Kiku options mode, and the Anki deck, known-word, N+1, field, sentence-card, and Kiku options
listed in the reference tables below. listed in the reference tables below.
@@ -156,6 +156,7 @@ The configuration file includes several main sections:
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults - [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
- [**TsukiHime**](#tsukihime) - Multi-language subtitle search and download - [**TsukiHime**](#tsukihime) - Multi-language subtitle search and download
- [**TMDB**](#tmdb) - Posters and synopses for live-action dramas and movies in the stats Library
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync` - [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
- [**AniList**](#anilist) - Optional post-watch progress updates - [**AniList**](#anilist) - Optional post-watch progress updates
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile - [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile
@@ -976,6 +977,7 @@ This example is intentionally compact. The option table below documents availabl
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. | | `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. |
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) | | `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
| `fields.audio` | string | Card field for the generated sentence audio clip (default: `ExpressionAudio`). Set this to a dedicated field such as `SentenceAudio` so it does not collide with the word audio Yomitan writes. | | `fields.audio` | string | Card field for the generated sentence audio clip (default: `ExpressionAudio`). Set this to a dedicated field such as `SentenceAudio` so it does not collide with the word audio Yomitan writes. |
| `fields.wordAudio` | string | Existing word-audio field read for the animated image's opening freeze. Independent of the sentence-audio destination in `fields.audio`; this mapping does not write audio. See [config.example.jsonc](/config.example.jsonc) for defaults. |
| `fields.image` | string | Card field for images (default: `Picture`) | | `fields.image` | string | Card field for images (default: `Picture`) |
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) | | `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) | | `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
@@ -996,7 +998,7 @@ This example is intentionally compact. The option table below documents availabl
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). | | `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
| `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. | | `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. |
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) | | `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) | | `media.maxMediaDuration` | number (seconds) | Maximum generated clip duration for overlay and stats-dashboard mining. See the [configuration example](/config.example.jsonc) for the default and disabling the cap. |
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) | | `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) | | `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) |
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) | | `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
@@ -1115,6 +1117,14 @@ When the manual merge popup opens, SubMiner pauses playback and closes any open
<a :href="withBase('/assets/kiku-integration.webm')" target="_blank" rel="noreferrer">Open demo in a new tab</a> <a :href="withBase('/assets/kiku-integration.webm')" target="_blank" rel="noreferrer">Open demo in a new tab</a>
## Subtitle Selection
Enable **Settings → Behavior → Subtitle Selection → Enabled** to choose mpv's primary and secondary subtitle tracks from a SubMiner modal. The feature is disabled by default. The dialog uses the same overlay focus and subtitle suppression behavior as the other modals.
Press `g` then `s` to open it. Both selectors include **None**. Choose different tracks and click **Apply** to load them into mpv, or close the dialog to keep the current selection. Embedded and already-loaded external subtitle tracks are listed with their title, language, and codec when available.
`subtitleSelection.enabled` controls the feature. `shortcuts.openSubtitleSelection` changes its shortcut, or accepts `null` to unbind it. Enabling the feature overrides mpv's binding for that shortcut when its first key is free; disabling it restores mpv's binding. Existing single-key actions take priority over sequences; see [shortcut conflicts](/shortcuts). Both settings apply immediately. See the [generated configuration example](/config.example.jsonc) for defaults.
## External integrations ## External integrations
### Jimaku ### Jimaku
@@ -1157,6 +1167,32 @@ The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift
See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, language tabs, and troubleshooting. See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, language tabs, and troubleshooting.
### TMDB
TMDB (The Movie Database) supplies posters, synopses, and show grouping for live-action dramas and movies in the stats [Library](/immersion-tracking#library). AniList only covers anime, so TMDB is what gives live-action titles a cover and a description.
Release builds ship with a project TMDB key, so nothing needs to be configured. Set your own key to use your own quota, or when running SubMiner from source, where no key is bundled. Create one for free under **Settings > API** on [themoviedb.org](https://www.themoviedb.org/settings/api); either the short API key or the long "API Read Access Token" works.
```json
{
"tmdb": {
"apiKey": "",
"apiKeyCommand": "cat ~/.tmdb_key"
}
}
```
| Option | Values | Description |
| -------------------- | ------ | -------------------------------------------------------------------------------------------------- |
| `tmdb.apiKey` | string | Your own TMDB API key or read access token; overrides the bundled key (default: empty) |
| `tmdb.apiKeyCommand` | string | Shell command that prints the key to stdout, used instead of `apiKey` to keep it out of the config |
Successful `apiKeyCommand` output is cached for the running client until `tmdb.apiKey` or `tmdb.apiKeyCommand` changes. Failed or empty command output uses the bundled key when available and waits 30 seconds before the next request can retry the command. Changing either credential setting resets this cooldown.
Changes apply to the next TMDB request without a restart.
This product uses the TMDB API but is not endorsed or certified by TMDB.
### Japanese subtitle generation ### Japanese subtitle generation
Open the standalone modal with `Ctrl+Shift+G`, configurable through `shortcuts.openSubtitleGeneration`, or use the subtitle sidebar button. See [shortcuts](/shortcuts) for the shared mpv and overlay keybindings. Open the standalone modal with `Ctrl+Shift+G`, configurable through `shortcuts.openSubtitleGeneration`, or use the subtitle sidebar button. See [shortcuts](/shortcuts) for the shared mpv and overlay keybindings.
+38 -6
View File
@@ -31,12 +31,42 @@ Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_
The same immersion data powers the stats dashboard. The same immersion data powers the stats dashboard.
The browser dashboard and in-app stats overlay both load from the local HTTP server.
The server accepts loopback hosts only and rejects requests from other browser origins,
including opaque origins such as `file://`. API clients without a browser origin can
still use the local API. Mutation requests with a body must use `application/json`;
bodyless deletion and Anki browse requests remain supported. Requests rejected by the
host or origin checks receive `403`; mutation bodies without a JSON content type
receive `415`.
Use the loopback dashboard URL directly. Reverse-proxied dashboards and Tailscale
Serve URLs are unsupported because their host or browser origin is not the local
server's origin. SSH stats synchronization is unchanged.
Scripts sending a JSON body must include the content type. For example, this
requests a duplicate-line cleanup preview without changing the database. Replace
the port if you configured a different `stats.serverPort`:
```bash
curl http://127.0.0.1:6969/api/stats/maintenance/duplicate-lines \
-H 'Content-Type: application/json' \
-d '{"dryRun":true}'
```
- In-app overlay: focus the visible overlay, then press the key from `stats.toggleKey` (default: `` ` `` / `Backquote`). - In-app overlay: focus the visible overlay, then press the key from `stats.toggleKey` (default: `` ` `` / `Backquote`).
- Launcher command: run `subminer stats` to start the local stats server on demand (it also opens the dashboard in your browser when `stats.autoOpenBrowser` is enabled; the default is `false`). - Launcher command: run `subminer stats` to start the local stats server on demand (it also opens the dashboard in your browser when `stats.autoOpenBrowser` is enabled; the default is `false`).
- Background server: run `subminer stats -b` to start or reuse a dedicated background stats daemon without keeping the launcher attached, and `subminer stats -s` to stop that daemon. - Background server: run `subminer stats -b` to start or reuse a dedicated background stats daemon without keeping the launcher attached, and `subminer stats -s` to stop that daemon.
- Maintenance commands: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand; `subminer stats cleanup -l` repairs lifetime summary tables non-destructively (recomputed from per-episode history, so lifetime totals older than the session retention window are kept); `subminer stats cleanup --duplicate-lines` collapses repeated lines left behind by typeset subtitles (see [Repeated Line Cleanup](#repeated-line-cleanup)). `subminer stats rebuild` and `subminer stats backfill` rebuild or backfill rollup data. - Maintenance commands: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand; `subminer stats cleanup -l` repairs lifetime summary tables non-destructively (recomputed from per-episode history, so lifetime totals older than the session retention window are kept); `subminer stats cleanup --duplicate-lines` collapses repeated lines left behind by typeset subtitles (see [Repeated Line Cleanup](#repeated-line-cleanup)). `subminer stats rebuild` and `subminer stats backfill` rebuild or backfill rollup data.
- Browser page: open `http://127.0.0.1:6969` directly if the local stats server is already running. - Browser page: open `http://127.0.0.1:6969` directly if the local stats server is already running.
SubMiner waits for the local server to bind before reporting that the dashboard is available. If another process already uses the configured port, the command reports the startup error and the desktop app stays open. Opening the in-app dashboard also reports startup failures through your configured status notifications.
`subminer stats -s` stops a background stats server or cancels a pending background start. It leaves a foreground-only server running, so an open in-app dashboard stays connected. Shutdown gives active HTTP requests one second to finish before closing their connections and finalizing stats.
### Stats API resource IDs
Resource IDs in URLs must be positive safe integers written as decimal digits without leading zeros, fractions, or exponent notation. ID lists in JSON bodies must contain positive safe integer numbers. Invalid IDs or list entries return `400` before any mutation; bulk requests do not apply just the valid subset. Pagination limits keep their existing rounding and bounds.
### Dashboard tabs ### Dashboard tabs
#### Overview #### Overview
@@ -53,14 +83,16 @@ Local files and Jellyfin items with detected season numbers are split into seaso
When older stats already grouped multiple seasons under one series entry, SubMiner moves parsed episodes into the season-specific entries on startup and rebuilds the affected summaries. When older stats already grouped multiple seasons under one series entry, SubMiner moves parsed episodes into the season-specific entries on startup and rebuilds the affected summaries.
**Live-action dramas and movies.** Anime covers come from AniList, which has no live-action titles. A title that AniList cannot match is looked up on [TMDB](/configuration#tmdb) instead (release builds bundle a key; source builds need your own): only a Japanese-language, non-animated result whose known titles match the parsed filename exactly is accepted, and it supplies the poster, synopsis, English and Japanese titles, and episode count. If nothing matches automatically, open the title and use **Link to TMDB** to search and pick it by hand. A TMDB show spans all of its seasons, so entries that resolve to the same TMDB title are merged into one card regardless of the season folder they came from, and the merged season titles are remembered so later episodes land on the same card. The **All Titles** / **Anime** / **Live Action** / **YouTube** selector above the grid narrows the Library to one kind, and a title's detail view shows whether it is a drama or a movie. Linking a title to AniList again turns it back into an anime entry. Changing providers downloads the replacement cover before saving the new link; a failed download leaves the previous link and artwork intact. A title without a cover clears the previous artwork. Automatic TMDB matching leaves existing AniList links unchanged.
Jellyfin stream URLs are normalized to stable item links before stats titles are shown, so playback query parameters are not displayed in the dashboard. Jellyfin stream URLs are normalized to stable item links before stats titles are shown, so playback query parameters are not displayed in the dashboard.
When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section. When YouTube channel metadata is available, the Library tab groups videos by creator/channel. Use the kind selector above the grid (**All Titles**, **Anime**, **Live Action**, **YouTube**) to filter the library. Channel pages show tracked videos and their stats without AniList controls. Existing channel entries are classified as YouTube automatically on startup, preserving viewing history and manual video assignments. Anime and YouTube entries with the same normalized title remain separate, including during stats sync. Channels are excluded from anime metadata matching, season repair, and duplicate recommendations.
A library entry is identified by its parsed title plus any detected season, so the same show can end up on several cards when releases disagree about the title or omit the season tag. Two fixes are available: A library entry is identified by its parsed title plus any detected season, so the same show can end up on several cards when releases disagree about the title or omit the season tag. Two fixes are available:
- **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over. SubMiner remembers the merged title variants, so future episodes parsed with one of those names join the kept entry instead of recreating a duplicate card. - **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over. AniList-linked and TMDB-linked entries cannot be merged together, and YouTube channels cannot be merged with anime or live-action entries. SubMiner remembers the merged title variants, so future episodes parsed with one of those names join the kept entry instead of recreating a duplicate card.
- **Move a single episode.** Hover an episode row in a title's episode list and use the **→** button to reassign it to another library entry. The correction is remembered, so later filename parsing or Jellyfin metadata cannot move that episode back. For local files, later episodes in the same directory inherit the correction when their detected seasons are compatible and every manual correction there points to the same entry; a file that parses to a title which already has its own library entry keeps that identity instead. Conflicting seasons or manual destinations are left for review. If the move empties the old entry, that card is removed and you are returned to the grid. - **Move a single episode.** Hover an episode row in a title's episode list and use the **→** button to reassign it to another library entry. Anime and live-action entries are interchangeable here, but a YouTube video can only move between channels. The correction is remembered, so later filename parsing or Jellyfin metadata cannot move that episode back. For local files, later episodes in the same directory inherit the correction when their detected seasons are compatible and every manual correction there points to the same entry; a file that parses to a title which already has its own library entry keeps that identity instead. Conflicting seasons or manual destinations are left for review. If the move empties the old entry, that card is removed and you are returned to the grid.
Once cover art resolves a series to an AniList entry, cards with compatible seasons are folded together automatically only when the searched title exactly matches an AniList title or synonym. A fuzzy result that points at an AniList entry already used by another card appears as a **Possible duplicate** review above the Library grid instead. Choose **Review merge** to compare the cards and pick which one to keep, or **Not duplicates** to dismiss that suggestion permanently. Entries with conflicting explicit season numbers are left alone rather than merged or suggested. Once cover art resolves a series to an AniList entry, cards with compatible seasons are folded together automatically only when the searched title exactly matches an AniList title or synonym. A fuzzy result that points at an AniList entry already used by another card appears as a **Possible duplicate** review above the Library grid instead. Choose **Review merge** to compare the cards and pick which one to keep, or **Not duplicates** to dismiss that suggestion permanently. Entries with conflicting explicit season numbers are left alone rather than merged or suggested.
@@ -120,7 +152,7 @@ Stats server config lives under `stats`:
The Search tab and the Vocabulary tab's word detail panel both mine from subtitle lines in your viewing history. Search matches sentence text and media titles, and **Search by headword** is enabled by default so dictionary-form searches such as `知らない` can find tracked subtitle lines with inflected variants. Turn that toggle off for exact text/title matching only. Each line with a valid source file offers sentence-card mining; word/audio mining is available when the selected word or searched word appears in the sentence: The Search tab and the Vocabulary tab's word detail panel both mine from subtitle lines in your viewing history. Search matches sentence text and media titles, and **Search by headword** is enabled by default so dictionary-form searches such as `知らない` can find tracked subtitle lines with inflected variants. Turn that toggle off for exact text/title matching only. Each line with a valid source file offers sentence-card mining; word/audio mining is available when the selected word or searched word appears in the sentence:
- **Mine Word** - performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via a short-lived hidden helper, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, and metadata extracted from the source video file. Requires Anki and Yomitan dictionaries to be loaded. - **Mine Word** - performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via a short-lived hidden helper, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, full-sentence readings in `SentenceFurigana` when that field exists, and metadata extracted from the source video file. Requires Anki and Yomitan dictionaries to be loaded.
- **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio and image from the source video. - **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio and image from the source video.
- **Mine Audio** - creates an audio-only card with the `IsAudioCard` flag, attaching only the sentence audio clip. - **Mine Audio** - creates an audio-only card with the `IsAudioCard` flag, attaching only the sentence audio clip.
@@ -327,14 +359,14 @@ LIMIT ?;
- Large-table reads are index-backed for `sample_ms`, session time windows, frequency-ranked words/kanji, and cover-art identity lookups. - Large-table reads are index-backed for `sample_ms`, session time windows, frequency-ranked words/kanji, and cover-art identity lookups.
- Workload-dependent tuning knobs remain at defaults unless you change them: `cache_size`, `mmap_size`, `temp_store`, `auto_vacuum`. - Workload-dependent tuning knobs remain at defaults unless you change them: `cache_size`, `mmap_size`, `temp_store`, `auto_vacuum`.
### Schema (v23) ### Schema (v24)
The exact schema version lives in `SCHEMA_VERSION` (`src/core/services/immersion-tracker/types.ts`) and is recorded in the `imm_schema_version` table. The exact schema version lives in `SCHEMA_VERSION` (`src/core/services/immersion-tracker/types.ts`) and is recorded in the `imm_schema_version` table.
Core tables: Core tables:
- `imm_videos` - video key/title/source metadata - `imm_videos` - video key/title/source metadata
- `imm_anime` - anime/series metadata referenced by videos and lifetime tables - `imm_anime` - series or YouTube channel metadata referenced by videos and lifetime tables, including the media kind (`anime`, `live_action` or `youtube`) and the AniList or TMDB link
- `imm_anime_title_aliases` - alternate titles that resolve to the same anime row - `imm_anime_title_aliases` - alternate titles that resolve to the same anime row
- `imm_anime_merge_recommendations` - candidate duplicate-series merges surfaced in the dashboard - `imm_anime_merge_recommendations` - candidate duplicate-series merges surfaced in the dashboard
- `imm_sessions` - session UUID, video reference, timing/status, final denormalized totals - `imm_sessions` - session UUID, video reference, timing/status, final denormalized totals
+2 -2
View File
@@ -120,10 +120,10 @@ pip install ffsubsync
Windows 10 or later. No compositor tools or window helpers are needed - native window tracking is built in. Windows 10 or later. No compositor tools or window helpers are needed - native window tracking is built in.
You need **mpv** (required) and **ffmpeg** (strongly recommended, for card audio and screenshots), and both must be on your `PATH`. You need **mpv** (required) and **ffmpeg** (strongly recommended, for card audio and screenshots). Put mpv on `PATH` or set `mpv.executablePath` during setup. ffmpeg must be on `PATH`.
::: tip What is PATH? ::: tip What is PATH?
`PATH` is the list of folders Windows searches when a program asks to run another program by name. SubMiner runs `mpv` and `ffmpeg` by name, so if their folders are not on `PATH`, SubMiner cannot find them even though they are installed. The routes below mostly handle `PATH` for you; the manual route explains how to add a folder yourself. `PATH` is the list of folders Windows searches when a program asks to run another program by name. SubMiner uses it to find ffmpeg and, unless an executable path is configured, mpv. The routes below mostly handle `PATH` for you; the manual route explains how to add a folder yourself.
::: :::
You can install these with a package manager or by hand. Coverage differs, so pick based on what you need: You can install these with a package manager or by hand. Coverage differs, so pick based on what you need:
+2 -1
View File
@@ -12,7 +12,7 @@ This is the recommended way to use Jellyfin with SubMiner. A terminal-only optio
## Requirements ## Requirements
- A Jellyfin server plus your username and password - A Jellyfin server plus your username and password (Jellyfin 12, which disables legacy authorization by default, is supported)
- SubMiner installed and running (see [Installation](/installation)) - SubMiner installed and running (see [Installation](/installation))
- On Linux, the session token is stored with `gnome-libsecret` by default - On Linux, the session token is stored with `gnome-libsecret` by default
@@ -50,6 +50,7 @@ From then on, pause / resume / seek / stop and audio or subtitle track changes y
## What happens during playback ## What happens during playback
- **mpv launches automatically.** If mpv isn't already running when you cast, SubMiner starts it with SubMiner defaults and the bundled mpv plugin, so keybindings work right away. - **mpv launches automatically.** If mpv isn't already running when you cast, SubMiner starts it with SubMiner defaults and the bundled mpv plugin, so keybindings work right away.
- **Windows respects your mpv settings.** Casting checks `mpv.executablePath`, then `SUBMINER_MPV_PATH`, then `PATH`. An invalid configured path prevents automatic startup.
- **The overlay is managed by SubMiner,** so your configured `subtitleStyle` controls how subtitles look. Use the [overlay-toggle shortcut](/shortcuts) to hide it for a session. - **The overlay is managed by SubMiner,** so your configured `subtitleStyle` controls how subtitles look. Use the [overlay-toggle shortcut](/shortcuts) to hide it for a session.
- **Resume works.** If Jellyfin has a saved position for the item, SubMiner seeks there on load. - **Resume works.** If Jellyfin has a saved position for the item, SubMiner seeks there on load.
- **Titles and credentials stay separate.** AniList, character dictionaries, Anki source fields, and Discord presence use media titles, never authenticated stream URLs. If a usable title is unavailable, lookups are skipped and source fields show an unknown-media label. Stats identifies Jellyfin videos by server and item ID without the stream URL or API key. - **Titles and credentials stay separate.** AniList, character dictionaries, Anki source fields, and Discord presence use media titles, never authenticated stream URLs. If a usable title is unavailable, lookups are skipped and source fields show an unknown-media label. Stats identifies Jellyfin videos by server and item ID without the stream URL or API key.
+23 -2
View File
@@ -6,6 +6,15 @@
*/ */
{ {
// ==========================================
// Subtitle Selection
// Select primary and secondary mpv subtitle tracks from the overlay.
// Hot-reload: enabling or disabling updates the session shortcut immediately.
// ==========================================
"subtitleSelection": {
"enabled": false // Use the SubMiner modal to select primary and secondary subtitle tracks. When enabled, its shortcut overrides mpv subtitle selection. Values: true | false
}, // Select primary and secondary mpv subtitle tracks from the overlay.
// ========================================== // ==========================================
// Japanese Subtitle Generation // Japanese Subtitle Generation
// Generate timed Japanese subtitles from local audio using whisper.cpp. // Generate timed Japanese subtitles from local audio using whisper.cpp.
@@ -223,6 +232,7 @@
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal. "openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search 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). "openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).
"openSubtitleSelection": "g-s", // Open subtitle selection when enabled. Use g-s to press g then s. Set null to unbind.
"openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal. "openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal.
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. "openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
@@ -541,7 +551,7 @@
// ========================================== // ==========================================
// AnkiConnect Integration // AnkiConnect Integration
// Automatic Anki updates and media generation options. // Automatic Anki updates and media generation options.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running. // Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/wordAudio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
// Shared AI provider transport settings are read from top-level ai and typically require restart. // Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still require restart. // Most other AnkiConnect settings still require restart.
// ========================================== // ==========================================
@@ -562,6 +572,7 @@
"fields": { "fields": {
"word": "Expression", // Card field for the mined word or expression text. "word": "Expression", // Card field for the mined word or expression text.
"audio": "ExpressionAudio", // Card field that receives generated sentence audio. "audio": "ExpressionAudio", // Card field that receives generated sentence audio.
"wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.
"image": "Picture", // Card field that receives the captured screenshot or animated image. "image": "Picture", // Card field that receives the captured screenshot or animated image.
"sentence": "Sentence", // Card field that receives the source sentence text. "sentence": "Sentence", // Card field that receives the source sentence text.
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern). "miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
@@ -590,7 +601,7 @@
"reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false "reviewTiming": false, // Review and preview subtitle media timing before SubMiner creates or enriches a mined card. Values: true | false
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips. "audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable. "fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. "maxMediaDuration": 30 // Maximum allowed media clip duration in seconds. 0 disables the cap.
}, // Media setting. }, // Media setting.
"knownWords": { "knownWords": {
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
@@ -658,6 +669,16 @@
"maxSearchResults": 10 // Maximum TsukiHime search results returned. "maxSearchResults": 10 // Maximum TsukiHime search results returned.
}, // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required. }, // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
// ==========================================
// TMDB
// TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.
// Hot-reload: TMDB changes apply to the next TMDB request.
// ==========================================
"tmdb": {
"apiKey": "", // Your own TMDB API key or read access token for live-action posters and synopses in the stats Library. Release builds bundle a project key, so set this only to use your own quota or when running from source (free under Settings > API on themoviedb.org).
"apiKeyCommand": "" // Shell command that prints the TMDB API key to stdout. Used instead of apiKey to avoid storing the key in plain text.
}, // TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.
// ========================================== // ==========================================
// YouTube Playback Settings // YouTube Playback Settings
// Defaults for managed subtitle language preferences and YouTube subtitle loading. // Defaults for managed subtitle language preferences and YouTube subtitle loading.
+3
View File
@@ -88,6 +88,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
| `Ctrl+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` | | `Ctrl+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` |
| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` | | `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | | `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
| `g` then `s` | Select primary and secondary subtitles, when enabled | `shortcuts.openSubtitleSelection` |
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | | `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` | | `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | `shortcuts.appendClipboardVideoToQueue` |
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` (overlay) / `shortcuts.toggleSubtitleSidebar` (mpv session binding) | | `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` (overlay) / `shortcuts.toggleSubtitleSidebar` (mpv session binding) |
@@ -98,6 +99,8 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
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 stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`.
Enable the subtitle selector in **Settings → Behavior → Subtitle Selection**. Its shortcut overrides mpv subtitle selection only while enabled. In the focused overlay, press the second key within one second. Single-key bindings take priority: if `g` already has an action in SubMiner or mpv, `g-s` is disabled with a conflict warning, and `g` still runs immediately. Remap the sequence or remove the conflicting single-key binding. The existing `y` prefix is reserved for its built-in commands. mpv bindings are checked on connection, configuration changes, and overlay focus; refresh the overlay after changing another script's bindings. See [subtitle selection](/configuration#subtitle-selection).
The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source. The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source.
In the sidebar, `Enter` seeks the keyboard-focused cue. `Space` keeps its configured playback action, normally pause/resume, even when a cue has focus. In the sidebar, `Enter` seeks the keyboard-focused cue. `Space` keeps its configured playback action, normally pause/resume, even when a cue has focus.
+4
View File
@@ -149,6 +149,10 @@ These settings come from `config.jsonc` (or built-in defaults); there are no CLI
- **Secondary subtitle fails**: Secondary track failures never block playback. The primary subtitle loads independently. - **Secondary subtitle fails**: Secondary track failures never block playback. The primary subtitle loads independently.
- **Native mpv secondary rendering**: Stays hidden during YouTube flows so the SubMiner overlay remains the visible secondary subtitle surface. - **Native mpv secondary rendering**: Stays hidden during YouTube flows so the SubMiner overlay remains the visible secondary subtitle surface.
## Viewing stats
The stats Library groups tracked YouTube videos by channel when channel metadata is available. Select **YouTube** in the Library filter to see those channels separately from anime. Each channel page lists its videos, watch time, vocabulary, and mined cards. See [Immersion Tracking](/immersion-tracking#library).
## Related pages ## Related pages
- [Usage - YouTube Playback](/usage#youtube-playback) - [Usage - YouTube Playback](/usage#youtube-playback)
+4 -1
View File
@@ -15,7 +15,10 @@
Stable and prerelease workflows share `.github/workflows/package-release.yml`. Stable and prerelease workflows share `.github/workflows/package-release.yml`.
Both callers explicitly pass the five required macOS signing/notarization Both callers explicitly pass the five required macOS signing/notarization
secrets; `GITHUB_TOKEN` remains automatically available to the reusable workflow. secrets plus the optional `SUBMINER_TMDB_API_KEY` (the project TMDB key that
`scripts/prepare-build-assets.mjs` stages into `dist/bundled-integration-keys.json`;
artifacts built without it simply require users to set `tmdb.apiKey`).
`GITHUB_TOKEN` remains automatically available to the reusable workflow.
Each platform verifies its ASAR and external resources before signing, then Each platform verifies its ASAR and external resources before signing, then
measures the signed app and installers before upload. Missing runtime assets, measures the signed app and installers before upload. Missing runtime assets,
foreign SQLite/Koffi binaries, duplicate UI fonts, demo media, source maps, foreign SQLite/Koffi binaries, duplicate UI fonts, demo media, source maps,
+3
View File
@@ -30,10 +30,13 @@ Update checks and startup launcher migration share a serialized update-state sto
- `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters. - `src/main/` owns composition, runtime setup, IPC wiring, and app lifecycle adapters.
- `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection. - `src/main/boot/` owns boot-phase assembly seams so `src/main.ts` can stay focused on lifecycle coordination and startup-path selection.
- `src/main/runtime/linux-overlay-mode-runtime.ts` owns Linux fullscreen mode state and window replacement. App cleanup cancels pending replacements; `main.ts` supplies window creation and subtitle refresh hooks.
- `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic. - `src/core/services/` owns focused runtime services plus pure or side-effect-bounded logic.
- `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same local media; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative. - `src/core/services/subtitle-generation*.ts` shares local whisper.cpp transcription, safe model downloads, and progress between the launcher and Electron. Optional dialogue mode retains both Silero-detected speech and other audible sections, omits confidently silent gaps, decodes passages independently, and restores original media timing. `src/main/runtime/subtitle-generation-runtime.ts` owns the overlay job lifecycle and only loads completed subtitles into the same local media; `src/shared/subtitle-generation*.ts` owns configuration, the multilingual model catalog, and IPC contracts. The overlay runtime retains a session model selection, validates picker requests through IPC, and keeps external model paths authoritative.
- Subtitle model recommendations use bounded `nvidia-smi` and Whisper CUDA discovery probes in `subtitle-generation-acceleration.ts`. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments. - Subtitle model recommendations use bounded `nvidia-smi` and Whisper CUDA discovery probes in `subtitle-generation-acceleration.ts`. The overlay runtime caches results by executable path for 30 seconds and exposes acceleration status through the existing status IPC. Recommendations do not alter model selection or transcription arguments.
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps. - `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
- `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes.
- `src/renderer/` owns overlay rendering and input behavior. - `src/renderer/` owns overlay rendering and input behavior.
- `src/config/` owns config definitions, defaults, loading, and resolution. - `src/config/` owns config definitions, defaults, loading, and resolution.
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel. - `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
+2
View File
@@ -21,6 +21,7 @@ Read when: you need to find the owner module for a behavior or test surface
`src/config/resolve/anki-connect/` `src/config/resolve/anki-connect/`
- Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts` - Overlay/window state: `src/core/services/overlay-*`, `src/main/overlay-*.ts`
- MPV runtime and protocol: `src/core/services/mpv*.ts` - MPV runtime and protocol: `src/core/services/mpv*.ts`
Windows executable lookup and detached process creation are shared in `src/main/runtime/mpv-process.ts`. The Windows launcher and Jellyfin handlers retain their own playback and connection workflows.
- Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/` - Subtitle/token pipeline: `src/core/services/subtitle-*.ts`, `src/core/services/tokenizer*`, `src/core/services/tokenizer/`, `src/subsync/`
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts` - Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
- Immersion tracking: `src/core/services/immersion-tracker/` - Immersion tracking: `src/core/services/immersion-tracker/`
@@ -30,6 +31,7 @@ Read when: you need to find the owner module for a behavior or test surface
- Immersion sync: `src/core/services/stats-sync/`, bound by `src/main/sync-cli.ts`. - Immersion sync: `src/core/services/stats-sync/`, bound by `src/main/sync-cli.ts`.
`snapshot-transfer.ts` selects compressed rsync or scp. `transfer-cache.ts` atomically retains the last successfully received snapshot per hashed peer/database identity under the config directory's `sync-transfer-cache/`. Cache copies seed isolated transfer directories; rsync verifies reconstructed files before the existing merge engine runs. The `--make-temp` / `--remove-temp` helpers accept an internal `--transfer-cache` key, with a fallback for older peers that do not recognize it. `snapshot-transfer.ts` selects compressed rsync or scp. `transfer-cache.ts` atomically retains the last successfully received snapshot per hashed peer/database identity under the config directory's `sync-transfer-cache/`. Cache copies seed isolated transfer directories; rsync verifies reconstructed files before the existing merge engine runs. The `--make-temp` / `--remove-temp` helpers accept an internal `--transfer-cache` key, with a fallback for older peers that do not recognize it.
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/` - AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
- TMDB live-action metadata: `src/core/services/tmdb/` (client + exact-title resolver), `src/core/services/immersion-tracker/live-action-link.ts` (links an entry to a TMDB title and merges other holders of the same title). The AniList cover-art fetcher calls the resolver as its fallback; `imm_anime.media_kind` marks the result and keeps the entry out of AniList season repair.
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*` - Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
- Window trackers: `src/window-trackers/` - Window trackers: `src/window-trackers/`
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support - Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
+28 -3
View File
@@ -22,9 +22,15 @@ Read when: selecting the right verification lane for a change
pull requests, stable tags, and prerelease tags. Keep common quality steps pull requests, stable tags, and prerelease tags. Keep common quality steps
there instead of copying them into caller workflows. there instead of copying them into caller workflows.
- The reusable gate installs Lua and runs `bun run test:env`, so the shipped mpv - 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. plugin tests and launcher smoke run for every pull request and tagged release.
Lua installation uses only the runner's Ubuntu package sources so unrelated Lua installation uses only the runner's Ubuntu package sources so unrelated
third-party repository failures do not block the gate. third-party repository failures do not block the gate.
- In the reusable gate, `test:coverage:src` is also the blocking execution of the
discovered `src/**` test lane. The coverage runner returns the failing test's
status, so CI does not rerun that lane through `test:fast`. Launcher unit and
script tests still run separately because they are outside the coverage lane.
- Launcher smoke artifacts are uploaded after `test:env` fails. CI does not rerun
launcher smoke solely to collect the same artifacts.
## Default Handoff Gate ## Default Handoff Gate
@@ -49,7 +55,7 @@ bun run docs:build
- Internal KB, `AGENTS.md`, or `.agents/skills/**` changes: `bun run test:docs:kb` - Internal KB, `AGENTS.md`, or `.agents/skills/**` changes: `bun run test:docs:kb`
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed - Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed
- Launcher/plugin: `bun run test:launcher` or `bun run test:env` - Launcher/plugin: `bun run test:launcher` or `bun run test:env`
- Runtime-compat / compiled behavior: `bun run test:runtime:compat` - Runtime-compat / compiled behavior after `bun run build`: `bun run test:runtime:compat`
- Stats dashboard UI: `bun run test:stats` - Stats dashboard UI: `bun run test:stats`
- Build/release scripts (`scripts/**`): `bun run test:scripts` - Build/release scripts (`scripts/**`): `bun run test:scripts`
- Packaging: build the platform package, then run `bun run test:package <resources-directory>`. - Packaging: build the platform package, then run `bun run test:package <resources-directory>`.
@@ -62,11 +68,30 @@ bun run docs:build
## Coverage Reporting ## Coverage Reporting
- `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. - `bun run test:coverage:src` runs the same discovered `bun-src-full` membership as
`test:src` through a sharded coverage runner: one Bun coverage process per test
file, then merged LCOV output.
- A failing coverage shard stops the runner with a nonzero status. Coverage is a
source test gate, not a report-only step.
- Machine-readable output lands at `coverage/test-src/lcov.info`. - Machine-readable output lands at `coverage/test-src/lcov.info`.
- Every reusable quality-gate run uploads that LCOV file as the - Every reusable quality-gate run uploads that LCOV file as the
`coverage-test-src` artifact. `coverage-test-src` artifact.
## Compiled Runtime Smoke
- `bun run test:smoke:dist` and its `test:runtime:compat` alias require an existing
full build and fail with the missing artifact paths when `dist/` or the stats UI
bundle is absent.
- The check runs the emitted stats daemon under Electron's Node runtime. It opens
the production HTTP server, queries the overview endpoint through native
libsql-backed storage, and leaves an HTTP request body unfinished before
shutting the daemon down. It verifies a clean exit and that the port and
ownership state are released despite the unfinished request.
- The check also occupies the configured port, requires startup to fail without
stale ownership state, releases the conflict, and verifies a clean retry.
- This is not a full Electron UI startup check. It does not require a display and
makes no claims about renderer, window, tray, or mpv behavior.
## Dependency Audit Policy ## Dependency Audit Policy
- `bun audit --audit-level high` blocks the reusable quality gate. - `bun audit --audit-level high` blocks the reusable quality gate.
+1 -2
View File
@@ -89,7 +89,6 @@ export async function jellyfinApiRequest<T>(
const url = `${session.serverUrl}${requestPath}`; const url = `${session.serverUrl}${requestPath}`;
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
'X-Emby-Token': session.accessToken,
Authorization: `MediaBrowser Token="${session.accessToken}"`, Authorization: `MediaBrowser Token="${session.accessToken}"`,
}, },
}); });
@@ -103,7 +102,7 @@ export async function jellyfinApiRequest<T>(
} }
function itemPreviewUrl(session: JellyfinSessionConfig, id: string): string { function itemPreviewUrl(session: JellyfinSessionConfig, id: string): string {
return `${session.serverUrl}/Items/${id}/Images/Primary?maxHeight=720&quality=85&api_key=${encodeURIComponent(session.accessToken)}`; return `${session.serverUrl}/Items/${id}/Images/Primary?maxHeight=720&quality=85&ApiKey=${encodeURIComponent(session.accessToken)}`;
} }
function jellyfinIconCacheDir(session: JellyfinSessionConfig): string { function jellyfinIconCacheDir(session: JellyfinSessionConfig): string {
+3 -3
View File
@@ -228,7 +228,7 @@ export function pickLibrary(
commandExists('chafa') && commandExists('curl') commandExists('chafa') && commandExists('curl')
? ` ? `
id={1} id={1}
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)} url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&ApiKey=${escapeShellSingle(session.accessToken)}
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
`.trim() `.trim()
: 'echo "Install curl + chafa for image preview"'; : 'echo "Install curl + chafa for image preview"';
@@ -266,7 +266,7 @@ export function pickItem(
commandExists('chafa') && commandExists('curl') commandExists('chafa') && commandExists('curl')
? ` ? `
id={1} id={1}
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)} url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&ApiKey=${escapeShellSingle(session.accessToken)}
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
`.trim() `.trim()
: 'echo "Install curl + chafa for image preview"'; : 'echo "Install curl + chafa for image preview"';
@@ -304,7 +304,7 @@ export function pickGroup(
commandExists('chafa') && commandExists('curl') commandExists('chafa') && commandExists('curl')
? ` ? `
id={1} id={1}
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)} url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&ApiKey=${escapeShellSingle(session.accessToken)}
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
`.trim() `.trim()
: 'echo "Install curl + chafa for image preview"'; : 'echo "Install curl + chafa for image preview"';
@@ -31,6 +31,7 @@ const SYNC_SCHEMA_OBJECTS = [
'imm_lifetime_applied_sessions', 'imm_lifetime_applied_sessions',
'imm_stats_excluded_words', 'imm_stats_excluded_words',
'idx_anime_normalized_title', 'idx_anime_normalized_title',
'idx_anime_namespace_title',
'idx_anime_anilist_id', 'idx_anime_anilist_id',
'idx_videos_anime_id', 'idx_videos_anime_id',
'idx_sessions_video_started', 'idx_sessions_video_started',
+5 -1
View File
@@ -12,7 +12,7 @@ export const IMMERSION_DB_FIXTURE_DDL = `
); );
CREATE TABLE imm_anime( CREATE TABLE imm_anime(
anime_id INTEGER PRIMARY KEY AUTOINCREMENT, anime_id INTEGER PRIMARY KEY AUTOINCREMENT,
normalized_title_key TEXT NOT NULL UNIQUE, normalized_title_key TEXT NOT NULL,
canonical_title TEXT NOT NULL, canonical_title TEXT NOT NULL,
anilist_id INTEGER UNIQUE, anilist_id INTEGER UNIQUE,
title_romaji TEXT, title_romaji TEXT,
@@ -20,10 +20,14 @@ export const IMMERSION_DB_FIXTURE_DDL = `
title_native TEXT, title_native TEXT,
episodes_total INTEGER, episodes_total INTEGER,
description TEXT, description TEXT,
media_kind TEXT NOT NULL DEFAULT 'anime',
tmdb_id INTEGER,
tmdb_type TEXT,
metadata_json TEXT, metadata_json TEXT,
CREATED_DATE TEXT, CREATED_DATE TEXT,
LAST_UPDATE_DATE TEXT LAST_UPDATE_DATE TEXT
); );
CREATE UNIQUE INDEX idx_anime_namespace_title ON imm_anime((media_kind = 'youtube'), normalized_title_key);
CREATE TABLE imm_videos( CREATE TABLE imm_videos(
video_id INTEGER PRIMARY KEY AUTOINCREMENT, video_id INTEGER PRIMARY KEY AUTOINCREMENT,
video_key TEXT NOT NULL UNIQUE, video_key TEXT NOT NULL UNIQUE,
+6 -6
View File
@@ -2,7 +2,7 @@
"name": "subminer", "name": "subminer",
"productName": "SubMiner", "productName": "SubMiner",
"desktopName": "SubMiner.desktop", "desktopName": "SubMiner.desktop",
"version": "0.19.6", "version": "0.20.0-beta.2",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"main": "dist/main-entry.js", "main": "dist/main-entry.js",
@@ -51,7 +51,7 @@
"test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts", "test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts",
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-process-start-retries.lua && lua scripts/test-plugin-restart-feedback.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua", "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-process-start-retries.lua && lua scripts/test-plugin-restart-feedback.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:smoke:dist": "bun scripts/run-test-lane.mjs bun-src-full", "test:smoke:dist": "env ELECTRON_RUN_AS_NODE=1 electron scripts/compiled-runtime-smoke.mjs",
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts", "test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
"test:immersion:sqlite:src": "bun test src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/storage-session.test.ts", "test:immersion:sqlite:src": "bun test src/core/services/immersion-tracker-service.test.ts src/core/services/immersion-tracker/storage-session.test.ts",
"test:immersion:sqlite:dist": "bun test dist/core/services/immersion-tracker-service.test.js dist/core/services/immersion-tracker/storage-session.test.js", "test:immersion:sqlite:dist": "bun test dist/core/services/immersion-tracker-service.test.js dist/core/services/immersion-tracker/storage-session.test.js",
@@ -63,7 +63,7 @@
"test:scripts": "bun scripts/run-test-lane.mjs scripts", "test:scripts": "bun scripts/run-test-lane.mjs scripts",
"test:stats": "bun scripts/run-test-lane.mjs stats", "test:stats": "bun scripts/run-test-lane.mjs stats",
"test:env": "bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src", "test:env": "bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src",
"test:runtime:compat": "bun run tsc && bun scripts/run-test-lane.mjs bun-src-full", "test:runtime:compat": "bun run test:smoke:dist",
"test": "bun run test:fast", "test": "bun run test:fast",
"test:config": "bun scripts/run-test-lane.mjs config", "test:config": "bun scripts/run-test-lane.mjs config",
"test:launcher": "bun scripts/run-test-lane.mjs launcher && bun run test:plugin:src", "test:launcher": "bun scripts/run-test-lane.mjs launcher && bun run test:plugin:src",
@@ -85,9 +85,9 @@
}, },
"overrides": { "overrides": {
"@xmldom/xmldom": "0.8.15", "@xmldom/xmldom": "0.8.15",
"app-builder-lib": "26.15.3", "app-builder-lib": "26.16.1",
"brace-expansion": "5.0.9", "brace-expansion": "5.0.9",
"electron-builder-squirrel-windows": "26.15.3", "electron-builder-squirrel-windows": "26.16.1",
"fast-uri": "3.1.6", "fast-uri": "3.1.6",
"form-data": "4.0.6", "form-data": "4.0.6",
"ip-address": "10.2.0", "ip-address": "10.2.0",
@@ -129,7 +129,7 @@
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"electron": "42.6.0", "electron": "42.6.0",
"electron-builder": "26.15.3", "electron-builder": "26.16.1",
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"eslint": "^10.8.0", "eslint": "^10.8.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
+122 -7
View File
@@ -91,6 +91,10 @@ function M.create(ctx)
end end
local function key_code_to_mpv_name(code) local function key_code_to_mpv_name(code)
local first, second = code:match("^Key([A-Z])%-Key([A-Z])$")
if first and second then
return string.lower(first) .. "-" .. string.lower(second)
end
if KEY_NAME_MAP[code] then if KEY_NAME_MAP[code] then
return KEY_NAME_MAP[code] return KEY_NAME_MAP[code]
end end
@@ -187,6 +191,110 @@ function M.create(ctx)
return bindings return bindings
end end
-- Match letter strokes, including mpv's uppercase spelling for Shift.
local function letter_key_signature(value)
if type(value) ~= "string" then
return nil
end
local modifiers = {}
while true do
local modifier, rest = value:match("^([%a]+)%+(.+)$")
if not modifier then
break
end
modifier = string.lower(modifier)
if not MODIFIER_MAP[modifier] then
return nil
end
modifiers[modifier] = true
value = rest
end
if not value:match("^[a-zA-Z]$") then
return nil
end
if value:match("^[A-Z]$") then
modifiers.shift = true
end
local parts = {}
for _, modifier in ipairs({ "ctrl", "alt", "shift", "meta" }) do
if modifiers[modifier] then
parts[#parts + 1] = modifier
end
end
parts[#parts + 1] = string.lower(value)
return table.concat(parts, "+")
end
local function external_single_keys()
local keys = {}
local native = mp.get_property_native and mp.get_property_native("input-bindings") or {}
for _, entry in ipairs(native or {}) do
local signature = letter_key_signature(entry.key)
if
signature
and type(entry.cmd) == "string"
and type(entry.priority) == "number"
and entry.priority >= 0
then
local owned = entry.owner == "subminer"
or (
entry.owner == nil
and (
entry.cmd:match("script%-binding%s+['\"]?subminer/")
or entry.cmd:match("script%-message%s+['\"]?subminer%-")
)
)
local previous = keys[signature]
if
not previous
or entry.priority > previous.priority
or (entry.priority == previous.priority and owned)
then
local command = entry.cmd:match("^%s*(.-)%s*$")
local flags = {
["no-osd"] = true,
["osd-bar"] = true,
["osd-msg"] = true,
["osd-msg-bar"] = true,
["osd-auto"] = true,
["expand-properties"] = true,
["raw"] = true,
["repeatable"] = true,
["nonrepeatable"] = true,
["nonscalable"] = true,
["async"] = true,
["sync"] = true,
}
while true do
local flag, rest = command:match("^(%S+)%s+(.+)$")
if not flags[flag] then
break
end
command = rest
end
keys[signature] = { priority = entry.priority, owned = owned, ignored = command == "ignore" }
end
end
end
return keys
end
local function sequence_conflict(binding, singles)
local code = binding.key and binding.key.code
local prefix = type(code) == "string" and code:match("^(Key[A-Z])%-Key[A-Z]$")
if not prefix then
return nil
end
local names = key_spec_to_mpv_bindings({ code = prefix, modifiers = binding.key.modifiers }) or {}
for _, name in ipairs(names) do
local existing = singles[letter_key_signature(name)]
if existing and not existing.owned and not existing.ignored then
return name
end
end
return nil
end
local function normalize_cli_args(cli_args) local function normalize_cli_args(cli_args)
if type(cli_args) ~= "table" then if type(cli_args) ~= "table" then
return nil return nil
@@ -391,17 +499,23 @@ function M.create(ctx)
local next_binding_names = {} local next_binding_names = {}
state.session_binding_generation = (state.session_binding_generation or 0) + 1 state.session_binding_generation = (state.session_binding_generation or 0) + 1
local generation = state.session_binding_generation local generation = state.session_binding_generation
local singles = external_single_keys()
for index, binding in ipairs(artifact.bindings) do for index, binding in ipairs(artifact.bindings) do
if not is_supported_binding(binding) then if not is_supported_binding(binding) then
subminer_log( subminer_log("warn", "session-bindings", "Skipped unsupported session binding from artifact")
"warn",
"session-bindings",
"Skipped unsupported session binding from artifact"
)
else else
local key_names = key_spec_to_mpv_bindings(binding.key) local key_names = key_spec_to_mpv_bindings(binding.key)
if key_names then local conflict = sequence_conflict(binding, singles)
if conflict then
local message = "Disabled sequence "
.. tostring(binding.originalKey or binding.key.code)
.. ": mpv already uses "
.. conflict
.. ". Single-key bindings take priority."
subminer_log("warn", "session-bindings", message)
show_osd(message)
elseif key_names then
for key_index, key_name in ipairs(key_names) do for key_index, key_name in ipairs(key_names) do
local name = "subminer-session-binding-" local name = "subminer-session-binding-"
.. tostring(generation) .. tostring(generation)
@@ -418,7 +532,8 @@ function M.create(ctx)
subminer_log( subminer_log(
"warn", "warn",
"session-bindings", "session-bindings",
"Skipped unsupported key code from artifact: " .. tostring(binding.key and binding.key.code or "unknown") "Skipped unsupported key code from artifact: "
.. tostring(binding.key and binding.key.code or "unknown")
) )
end end
end end
+104 -47
View File
@@ -1,68 +1,125 @@
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release. > This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
<!-- prerelease-base-version: 0.19.4 --> <!-- prerelease-version: 0.20.0-beta.2; since: v0.20.0-beta.1 -->
## Changes since v0.20.0-beta.1
- Added an optional subtitle selection modal for primary and secondary mpv subtitle tracks. Enable it in Settings > Behavior, then press `g` followed by `s`. Disabling it restores mpv's subtitle selection binding.
- Single-key shortcut actions now take priority over configured multi-key sequence prefixes, conflicting sequences are disabled with a warning, and existing `y` commands remain reserved.
- Jellyfin casting and playback now honor a configured mpv executable path, allowing playback when mpv is installed outside the system PATH, and portable plugins located beside that executable are now detected.
- Fixed word-card sentence furigana falling out of sync with full stats-search context and expanded timing-review selections; stale furigana is now cleared if regeneration fails.
## Highlights ## Highlights
### Added ### Added
- Library Merge & Reassignment - **Japanese Subtitle Generation**:
- Duplicate library cards for the same show can be combined: select entries in "Select" mode and use "Merge Selected" to combine their sessions, mined cards, and watch time onto one card. - Generate Japanese subtitles locally with whisper.cpp, right from a modal (`Ctrl+Shift+G`), the subtitle sidebar's generation button when no subtitles are loaded, or `subminer generate-subs`, with progress, cancellation, and automatic loading into mpv when it's done.
- Episodes can be moved to a different entry with a per-episode "→" button, fixing stray files that split off their own entry; manual assignments now survive later filename parsing, Jellyfin refreshes, and season repair. - Pick and download an official multilingual Whisper model in-app (including smaller quantized variants), or point Settings at one you already have. SubMiner recommends `large-v3-turbo` when CUDA is available and `small` otherwise, and tells you up front if `whisper-cli`, `ffmpeg`, or `ffprobe` can't be found.
- Exact AniList matches with compatible seasons merge automatically, while likely (fuzzy) matches surface as a dismissible "Possible duplicate" suggestion instead of merging silently. - An optional "Focus on spoken dialogue" mode uses a Silero VAD model to keep quiet or music-covered dialogue that would otherwise get dropped.
- Long passages split near natural speech pauses, guided by an existing subtitle track when one is loaded, giving tighter timing and fewer repeated-word glitches.
- Duplicate Line Cleanup - **Media Timing Review Frame Picker**:
- The Vocabulary tab's new **Duplicates** button scans a chosen time window for the repeated-line bursts described under Fixed below and collapses each burst to a single line once confirmed. - The screenshot used for a mined card can now be chosen independently of the audio clip, with its own live preview, time slider, and frame-by-frame stepping.
- A matching `subminer stats cleanup --duplicate-lines` command (with `--dry-run` and `--lookback-days <n>`) is available from the terminal. - Works for local video and for seekable remote streams like Jellyfin.
- Only the affected subtitle lines and the vocabulary counts they inflated are touched; watch time and lines-seen totals are left as recorded.
- **Overlay Keybinding Pickup**: The overlay now recognizes your mpv keybindings (from mpv's defaults, `input.conf`, and loaded scripts) as long as they don't conflict with SubMiner's own controls. Picked-up bindings work for the session but won't show up in the help menu.
- **Subtitle Selection Modal**:
- An optional subtitle selection modal lets you pick primary and secondary mpv subtitle tracks without leaving the overlay.
- Enable it in Settings under Behavior, then trigger it with `g` followed by `s`; turning it off restores mpv's normal subtitle selection binding.
- Single-key shortcuts always take priority over multi-key sequences, and any conflicting sequence is disabled with a warning instead of misbehaving.
- **Subtitle Sidebar Selection & Copy**: You can now select dialogue across multiple subtitle sidebar rows and copy it, without timestamps, using Ctrl/Cmd+C or the Copy button, without seeking or mining a card.
- **Jimaku Live Action Search**: The Jimaku modal has separate Anime and Live Action tabs (switch with Arrow Left/Right) so you can search Jimaku's live-action subtitle catalogue directly.
- **Live-Action TMDB Library**:
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB.
- Titles AniList can't match are looked up on TMDB automatically when the parsed filename matches a title exactly; otherwise use the new **Link to TMDB** action. Entries linked to the same TMDB title merge into one card, and the Library kind selector gained a Live Action option.
- Release builds already include a TMDB key; if you run from source, set `tmdb.apiKey` (or `tmdb.apiKeyCommand`) yourself.
- **YouTube Library Kind**:
- YouTube channels are now their own Library media kind, with new All Titles, Anime, and YouTube filters. Existing channel entries migrate automatically with viewing history and manual video assignments intact.
- Channels stay out of AniList matching, season repair, and duplicate recommendations, and can't be merged or moved into an anime entry.
### Changed
- **Bundled Bun Runtime**: Every SubMiner launcher, installed or downloaded, now runs on the Bun runtime bundled with the app instead of a system-wide Bun install. Recognized legacy launchers migrate automatically, Windows users get a new `subminer.cmd` download, and first-run setup now shows a single optional launcher control with runtime repair guidance only when something actually needs it.
- **Compressed Incremental Sync**: Cross-machine sync between compatible macOS/Linux machines now transfers only what changed, compressed, using a cached snapshot from the last sync to cut traffic further. Machines without a compatible rsync (including Windows) fall back to compressed scp automatically, older peers keep working, and transfers now time out after 30 minutes instead of hanging indefinitely.
- **Smaller Install Size**: Installers and the unpacked app are smaller after dropping demo media, source maps, TypeScript sources, test fixtures, and unused binaries, and sharing one Japanese UI font across windows. Release builds now publish a package-size comparison against the previous release.
- **Stats Server Request Safety**: The stats server, including the in-app stats overlay which now loads through it, only accepts requests from the local machine and requires a JSON content type for anything that changes data. If you were exposing the dashboard through a reverse proxy or Tailscale Serve, that's no longer supported, and any script posting to the stats API needs to send `Content-Type: application/json`.
- **Yomitan Updated**: Bundled Yomitan is updated to upstream 26.9.8, adding historical Japanese kana transformations and Ukrainian language support, plus improvements to Anki duplicate search and audio retrieval.
### Fixed ### Fixed
- Subtitle Duplication from Karaoke & Animated Signs - **Jellyfin**:
- Typeset ASS karaoke and animated signs no longer flood the overlay, subtitle sidebar, immersion history, mined cards, or stats with repeated glyph fragments or per-frame duplicates; the complete authored line is recovered instead, without merging genuinely repeated dialogue or separately positioned signs. - Playback, subtitles, artwork, and remote control now authenticate with an `ApiKey` parameter instead of legacy headers, so Jellyfin 12 works correctly even with legacy authorization disabled.
- Fragmented karaoke now preserves the spaces the author placed between words instead of joining them together, and lyric transitions (including seeking into the middle of a line) resolve to the clean line instead of a stray entrance or exit frame. - "Play on SubMiner" no longer silently drops the connection after about a minute on Jellyfin 12.
- The secondary overlay shares the same deduplication logic as the primary overlay, including collapsing lines that differ only by whitespace or trailing punctuation, and sidebar navigation moves between clean lyric lines while keeping the right line selected. - Casting now honors your configured mpv executable path, so playback works and portable plugins are detected correctly when mpv isn't on PATH.
- The "now playing" bar clears when you close or finish a cast video instead of running to the end of the episode.
- Anki cards mined from Jellyfin now get the real episode title in the misc info field instead of "Unknown media".
- Jellyfin streams no longer leak URL-derived titles or credential-bearing URLs into metadata, Anki fields, Discord presence, stats, or AniList lookups; previously cached data that had credentials in it is cleaned up automatically.
- Anki Media Generation - **Anki & Mining**:
- Sentence-audio generation no longer times out on slow network-mounted video files with many subtitle and font streams, and a failed extraction now reports a clear error instead of a raw `ENOENT`. - Word audio now reads from its own configured field (`ankiConnect.fields.wordAudio`) instead of the sentence-audio field, fixing animated word images that started moving immediately instead of on demand.
- Mined audio and animated AVIF clips now capture the subtitle line you actually mined, instead of whatever line happened to be on screen once slow audio extraction finished. - Setting `ankiConnect.media.maxMediaDuration` to `0` for unlimited duration now also applies when mining from the stats dashboard, matching overlay mining.
- Closing the overlay while a media timing review is still loading now properly cancels setup, restores playback if the review had paused it, and cleans up the hidden preview player.
- Word-card sentence furigana now stays in sync with the full stats-search context and expanded timing-review selections, and clears stale readings automatically if regeneration fails.
- Character Dictionary Performance & Notifications - **Settings**:
- Character dictionary generation, merged rebuilds, and imports no longer freeze the app on large dictionaries, and cached results (including character portraits) are reused across launches instead of regenerating everything every time. - AnkiConnect, Kiku, and Senren settings are now validated before use, with a warning and a safe default for anything invalid instead of a bad value reaching runtime.
- Portraits also now display correctly if their cache finishes loading after subtitles have already started showing. - Settings marked as applying live now correctly avoid showing a restart warning, and mixed saves apply the live parts immediately while listing only the sections that actually need a restart.
- Desktop progress notifications, including on Linux AppImage installs, now update in place instead of flickering closed and reopening.
- Overlay Reliability - **Overlay**:
- Overlay modals (settings, stats, etc.) now open promptly on the first shortcut press, including on repeated sessions on Windows, and appear above fullscreen mpv on macOS instead of switching Spaces or opening off-screen. - Clicking a subtitle sidebar cue no longer leaves Space bound to seeking back to it; Enter still seeks the focused cue, and Space keeps whatever playback action you've configured.
- The macOS window-tracking helper is now built for macOS 12.0+, so the overlay attaches to mpv on older systems like Ventura instead of crashing and getting stuck on "Overlay loading." - Hyprland recovery dialogs now stay above SubMiner windows instead of being covered by overlay placement updates.
- The overlay no longer gets stuck on "Overlay loading" indefinitely if mpv's connection stalls; it now retries and shows an actionable error after 30 seconds. - Fixed a rare case on Linux where a delayed window-close callback could reopen the overlay after it was torn down.
- Fixed native Wayland drag-and-drop from file managers like Thunar, and fixed system-wide mouse lag on Windows caused by the overlay's click-through handling.
- Stats Dashboard - **Stats**:
- Deletes, library merges, video moves, and AniList reassignments no longer freeze the stats dashboard or rebuild lifetime totals from scratch; large deletes that used to take minutes now finish in milliseconds. - Malformed or partly invalid resource IDs are now rejected before they can affect library mutations or cover-art backfills.
- Vocabulary totals and charts now count all tracked vocabulary instead of just the first page, and new-word history uses corrected daily rollups. - Stats server port conflicts now surface as a status notification instead of crashing SubMiner, and startup/shutdown are more robust: concurrent startup requests share one attempt, stopping a background instance no longer disconnects an open dashboard, and shutdown no longer waits indefinitely on active requests.
- Calendar labels respect time zones west of UTC, and vocabulary cards refresh automatically after editing the word exclusion list (with a Retry option if a load fails).
- Linux Launcher Thumbnails - **Subtitle Sidebar Gap Follow**: The subtitle sidebar now stays near actual playback position during gaps in files where a cue starts at time zero.
- Fixed missing MKV thumbnails in the Linux rofi picker when the system thumbnailer only registers legacy Matroska MIME aliases.
- **First Launch on macOS**: Fixed first launch exiting immediately when the SubMiner config directory didn't exist yet.
## What's Changed ## What's Changed
- feat(stats): add library entry merge and episode move by @ksyasuda in #190 - feat(sidebar): add dialogue selection and copying by @ksyasuda in #238
- fix(stats): stop counting duplicate typeset subtitle lines by @ksyasuda in #191 - feat(subtitles): add local Japanese subtitle generation by @ksyasuda in #240
- fix(media): tolerate slow MKV audio extraction by @ksyasuda in #195 - perf(stats): use compressed incremental snapshot transfers by @ksyasuda in #241
- fix(stats): subtract lifetime totals incrementally on delete by @ksyasuda in #196 - fix(startup): create config directory before singleton lock by @ksyasuda in #242
- fix(anki): snapshot mining media clip timing by @ksyasuda in #197 - feat(launcher): bundle Bun and use it across all launchers by @ksyasuda in #243
- fix(notifications): replace Linux progress updates in place by @ksyasuda in #198 - build(release): reduce package size and report release sizes by @ksyasuda in #244
- fix(overlay): support native Wayland file drag-and-drop by @ksyasuda in #199 - fix(overlay): keep Hyprland recovery dialogs above overlays by @ksyasuda in #245
- fix(overlay): keep macOS modal windows on fullscreen Spaces by @ksyasuda in #200 - feat(overlay): discover unclaimed mpv key bindings by @ksyasuda in #246
- fix(overlay): prevent Windows mouse lag during click-through tracking by @ksyasuda in #201 - fix(sidebar): preserve Space playback after cue seeking by @ksyasuda in #247
- fix(stats): report complete vocabulary totals and new-word history by @ksyasuda in #202 - fix(jellyfin): fix jellyfin media metadata by @ksyasuda in #250
- fix(mpv): recover from stalled IPC connects by @ksyasuda in #204 - feat(jimaku): add live-action subtitle search by @ksyasuda in #251
- fix(dictionary): prevent freezes and restore AppImage notifications by @ksyasuda in #205 - feat(stats): add TMDB metadata for live-action dramas in the Library by @ksyasuda in #252
- fix(subtitles): recover canonical lines from ASS animation by @ksyasuda in #207 - feat(stats): separate YouTube channels in the Library by @ksyasuda in #253
- fix(overlay): deduplicate secondary subtitle rendering by @ksyasuda in #208 - feat(mining): add a screenshot frame picker to media review by @aalhendi in #254
- fix(launcher): restore Matroska thumbnails in Linux rofi picker by @ksyasuda in #210 - fix(config): align live save feedback with hot reload policy by @ksyasuda in #255
- fix(character-dictionary): cache completed MeCab refreshes by @ksyasuda in #212 - fix(anki): separate word audio mapping for animation sync by @ksyasuda in #256
- fix(config): validate AnkiConnect and field grouping settings by @ksyasuda in #257
- fix(anki): honor unlimited duration in stats mining by @ksyasuda in #258
- fix(stats): reject malformed resource IDs before mutations by @ksyasuda in #259
- fix(stats): harden server lifecycle and verify compiled runtime by @ksyasuda in #261
- fix(overlay): cancel pending window transitions and timing reviews by @ksyasuda in #262
- fix(stats): restrict local requests and serve the dashboard over HTTP by @ksyasuda in #263
- fix(jellyfin): support modern authentication by @ksyasuda in #264
- feat(overlay): add optional subtitle selection modal by @ksyasuda in #265
- fix(jellyfin): respect Windows mpv configuration when casting by @aalhendi in #267
- fix(anki): regenerate sentence furigana from the final sentence by @ksyasuda in #268
## New Contributors
- @aalhendi made their first contribution in #254
## Installation ## Installation
+29
View File
@@ -0,0 +1,29 @@
import fs from 'node:fs';
import path from 'node:path';
/**
* Release builds carry a project-owned TMDB key so live-action lookups work
* without user setup. The key is injected from the SUBMINER_TMDB_API_KEY
* environment variable at build time (a GitHub Actions secret in CI) and never
* lives in the repository. The runtime reader is
* src/core/services/tmdb/bundled-api-key.ts; keep the file name in sync.
*/
export const BUNDLED_INTEGRATION_KEYS_FILENAME = 'bundled-integration-keys.json';
export const TMDB_API_KEY_ENV = 'SUBMINER_TMDB_API_KEY';
/**
* Write the bundled keys file into `distDir`, or remove a stale one when no
* key is present so a keyless build never ships an older key by accident.
* Returns the names of the keys staged.
*/
export function stageBundledIntegrationKeys(distDir, env = process.env) {
const outputPath = path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME);
const tmdbApiKey = env[TMDB_API_KEY_ENV]?.trim() ?? '';
if (!tmdbApiKey) {
fs.rmSync(outputPath, { force: true });
return [];
}
fs.mkdirSync(distDir, { recursive: true });
fs.writeFileSync(outputPath, `${JSON.stringify({ tmdbApiKey })}\n`, { mode: 0o644 });
return ['tmdb'];
}
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
BUNDLED_INTEGRATION_KEYS_FILENAME,
stageBundledIntegrationKeys,
} from './bundled-integration-keys.mjs';
function withDistDir(work: (distDir: string) => void): void {
const distDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-bundled-keys-'));
try {
work(distDir);
} finally {
fs.rmSync(distDir, { recursive: true, force: true });
}
}
test('stageBundledIntegrationKeys writes the TMDB key from the environment', () => {
withDistDir((distDir) => {
const staged = stageBundledIntegrationKeys(distDir, { SUBMINER_TMDB_API_KEY: ' abc123 ' });
assert.deepEqual(staged, ['tmdb']);
const written = JSON.parse(
fs.readFileSync(path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME), 'utf8'),
);
assert.deepEqual(written, { tmdbApiKey: 'abc123' });
});
});
test('stageBundledIntegrationKeys removes a stale file when the variable is unset', () => {
withDistDir((distDir) => {
const outputPath = path.join(distDir, BUNDLED_INTEGRATION_KEYS_FILENAME);
fs.writeFileSync(outputPath, '{"tmdbApiKey":"old"}');
assert.deepEqual(stageBundledIntegrationKeys(distDir, {}), []);
assert.equal(fs.existsSync(outputPath), false);
assert.deepEqual(stageBundledIntegrationKeys(distDir, { SUBMINER_TMDB_API_KEY: ' ' }), []);
});
});
+345
View File
@@ -0,0 +1,345 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { createServer } from 'node:net';
import { request as httpRequest } from 'node:http';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const START_TIMEOUT_MS = 15_000;
const STOP_TIMEOUT_MS = 5_000;
const POLL_INTERVAL_MS = 25;
const MAX_CHILD_OUTPUT_BYTES = 64 * 1024;
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRootArgIndex = process.argv.indexOf('--repo-root');
const repoRoot = resolve(
repoRootArgIndex === -1 ? join(scriptDir, '..') : (process.argv[repoRootArgIndex + 1] ?? ''),
);
const paths = {
mainEntry: join(repoRoot, 'dist', 'main-entry.js'),
daemonEntry: join(repoRoot, 'dist', 'stats-daemon-runner.js'),
statsServer: join(repoRoot, 'dist', 'core', 'services', 'stats-server.js'),
tracker: join(repoRoot, 'dist', 'core', 'services', 'immersion-tracker-service.js'),
statsIndex: join(repoRoot, 'stats', 'dist', 'index.html'),
};
function requireCompiledArtifacts() {
const missing = Object.values(paths).filter((artifactPath) => !existsSync(artifactPath));
if (missing.length > 0) {
throw new Error(
`Compiled runtime artifacts are missing. Run \`bun run build\` before this check:\n${missing
.map((artifactPath) => ` - ${artifactPath}`)
.join('\n')}`,
);
}
}
function requireElectronNodeRuntime() {
if (!process.versions.electron || process.env.ELECTRON_RUN_AS_NODE !== '1') {
throw new Error(
'This check must run with Electron in Node mode. Use `bun run test:smoke:dist`.',
);
}
}
function delay(ms) {
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
}
function listen(server, port = 0) {
return new Promise((resolvePromise, reject) => {
const onError = (error) => {
server.off('listening', onListening);
reject(error);
};
const onListening = () => {
server.off('error', onError);
const address = server.address();
if (!address || typeof address === 'string') {
reject(new Error('Could not resolve the stats smoke port.'));
return;
}
resolvePromise(address.port);
};
server.once('error', onError);
server.once('listening', onListening);
server.listen(port, '127.0.0.1');
});
}
function closeServer(server) {
return new Promise((resolvePromise, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolvePromise();
});
});
}
async function findAvailablePort() {
const reservation = createServer();
const port = await listen(reservation);
await closeServer(reservation);
return port;
}
function writeSmokeConfig(userDataPath, port) {
writeFileSync(
join(userDataPath, 'config.json'),
`${JSON.stringify({ stats: { serverPort: port } })}\n`,
);
}
function spawnDaemon(userDataPath, responsePath) {
const child = spawn(
process.execPath,
[
paths.daemonEntry,
'--stats-user-data-path',
userDataPath,
'--stats-response-path',
responsePath,
],
{
cwd: repoRoot,
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
},
);
let output = '';
const appendOutput = (chunk) => {
if (output.length >= MAX_CHILD_OUTPUT_BYTES) return;
output += String(chunk);
if (output.length > MAX_CHILD_OUTPUT_BYTES) {
output = `${output.slice(0, MAX_CHILD_OUTPUT_BYTES)}\n[child output truncated]\n`;
}
};
child.stdout.on('data', appendOutput);
child.stderr.on('data', appendOutput);
let exited = false;
const exit = new Promise((resolvePromise) => {
child.once('error', (error) => {
exited = true;
resolvePromise({ code: null, signal: null, error });
});
child.once('exit', (code, signal) => {
exited = true;
resolvePromise({ code, signal, error: null });
});
});
return { child, exit, hasExited: () => exited, getOutput: () => output };
}
async function waitForResponse(responsePath, daemon) {
const deadline = Date.now() + START_TIMEOUT_MS;
while (Date.now() < deadline) {
if (existsSync(responsePath)) {
try {
return JSON.parse(readFileSync(responsePath, 'utf8'));
} catch {
// The daemon may still be finishing the response file write.
}
}
if (daemon.hasExited()) {
const result = await daemon.exit;
throw new Error(
`Stats daemon exited before writing a startup response (${formatExit(result)}).\n${daemon.getOutput()}`,
);
}
await delay(POLL_INTERVAL_MS);
}
throw new Error(`Timed out waiting for stats daemon startup.\n${daemon.getOutput()}`);
}
function formatExit(result) {
if (result.error) return result.error.message;
if (result.signal) return `signal ${result.signal}`;
return `exit code ${result.code}`;
}
async function waitForExit(daemon, timeoutMs) {
return await Promise.race([daemon.exit, delay(timeoutMs).then(() => null)]);
}
async function stopDaemon(daemon) {
if (daemon.hasExited()) {
return await daemon.exit;
}
daemon.child.kill('SIGTERM');
const result = await waitForExit(daemon, STOP_TIMEOUT_MS);
if (result) return result;
daemon.child.kill('SIGKILL');
await daemon.exit;
throw new Error(`Stats daemon did not stop after SIGTERM.\n${daemon.getOutput()}`);
}
async function assertPortCanBind(port) {
const server = createServer();
try {
await listen(server, port);
} finally {
if (server.listening) await closeServer(server);
}
}
async function fetchOverview(url, daemon) {
const deadline = Date.now() + START_TIMEOUT_MS;
let lastError = null;
while (Date.now() < deadline) {
try {
return await fetch(`${url}/api/stats/overview`, {
signal: AbortSignal.timeout(START_TIMEOUT_MS),
});
} catch (error) {
lastError = error;
}
if (daemon.hasExited()) {
const result = await daemon.exit;
throw new Error(
`Stats daemon exited before accepting HTTP requests (${formatExit(result)}).\n${daemon.getOutput()}`,
);
}
await delay(POLL_INTERVAL_MS);
}
throw new Error(
`Timed out waiting for the stats HTTP server: ${lastError instanceof Error ? lastError.message : String(lastError)}\n${daemon.getOutput()}`,
);
}
async function runHealthyStartup(userDataPath, port, responseName) {
writeSmokeConfig(userDataPath, port);
const responsePath = join(userDataPath, responseName);
const statePath = join(userDataPath, 'stats-daemon.json');
const databasePath = join(userDataPath, 'immersion.sqlite');
const daemon = spawnDaemon(userDataPath, responsePath);
let shutdownResult;
let unfinishedRequest;
try {
const startup = await waitForResponse(responsePath, daemon);
assert.deepEqual(startup, { ok: true, url: `http://127.0.0.1:${port}` });
const response = await fetchOverview(startup.url, daemon);
assert.equal(response.status, 200);
assert.match(response.headers.get('content-type') ?? '', /^application\/json\b/);
const overview = await response.json();
assert.equal(typeof overview, 'object');
assert.ok(overview !== null);
assert.ok(Array.isArray(overview.sessions));
assert.ok(Array.isArray(overview.rollups));
assert.equal(typeof overview.hints, 'object');
const dashboard = await fetch(`${startup.url}/?overlay=1`, {
signal: AbortSignal.timeout(START_TIMEOUT_MS),
});
assert.equal(dashboard.status, 200);
assert.match(dashboard.headers.get('content-type') ?? '', /^text\/html\b/);
assert.match(await dashboard.text(), /id="root"/);
assert.ok(existsSync(databasePath), 'The compiled tracker did not create its SQLite database.');
assert.ok(
statSync(databasePath).size > 0,
'The compiled tracker created an empty SQLite file.',
);
// Leave a real request body unfinished so shutdown must bound its drain wait.
unfinishedRequest = httpRequest(new URL('/api/stats/anki/notesInfo', startup.url), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': '1000',
Expect: '100-continue',
},
signal: AbortSignal.timeout(START_TIMEOUT_MS),
});
await new Promise((resolvePromise, reject) => {
unfinishedRequest.on('error', reject);
unfinishedRequest.once('continue', () => {
unfinishedRequest.write('{');
resolvePromise();
});
unfinishedRequest.flushHeaders();
});
} finally {
try {
shutdownResult = await stopDaemon(daemon);
} finally {
unfinishedRequest?.destroy();
}
}
assert.equal(shutdownResult.code, 0, `Stats daemon shutdown failed.\n${daemon.getOutput()}`);
assert.equal(existsSync(statePath), false, 'Stats daemon state remained after shutdown.');
await assertPortCanBind(port);
}
async function runConflictRecovery() {
const userDataPath = mkdtempSync(join(tmpdir(), 'subminer-compiled-conflict-'));
const reservation = createServer();
const port = await listen(reservation);
const responsePath = join(userDataPath, 'conflict-response.json');
const statePath = join(userDataPath, 'stats-daemon.json');
writeSmokeConfig(userDataPath, port);
const daemon = spawnDaemon(userDataPath, responsePath);
const failures = [];
try {
const response = await waitForResponse(responsePath, daemon);
if (response.ok !== false) {
failures.push('The stats daemon reported success while its configured port was occupied.');
}
const result = await waitForExit(daemon, STOP_TIMEOUT_MS);
if (!result) {
failures.push('The stats daemon did not exit after its configured port failed to bind.');
} else if (result.code === 0) {
failures.push(
'The stats daemon exited successfully after its configured port failed to bind.',
);
}
if (existsSync(statePath)) {
failures.push(
'The stats daemon left ownership state behind after its configured port failed to bind.',
);
}
} finally {
await closeServer(reservation);
if (!daemon.hasExited()) {
await stopDaemon(daemon);
}
}
try {
await runHealthyStartup(userDataPath, port, 'recovery-response.json');
} finally {
rmSync(userDataPath, { recursive: true, force: true });
}
assert.deepEqual(failures, []);
}
async function main() {
requireCompiledArtifacts();
requireElectronNodeRuntime();
const userDataPath = mkdtempSync(join(tmpdir(), 'subminer-compiled-runtime-'));
try {
await runHealthyStartup(userDataPath, await findAvailablePort(), 'startup-response.json');
} finally {
rmSync(userDataPath, { recursive: true, force: true });
}
await runConflictRecovery();
process.stdout.write(
`Compiled runtime smoke passed with Electron ${process.versions.electron}, Node ${process.versions.node}, HTTP, and native SQLite.\n`,
);
}
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
process.exitCode = 1;
});
+28
View File
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
test('compiled runtime smoke fails clearly when build artifacts are missing', () => {
const emptyRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-compiled-missing-'));
try {
const result = spawnSync(
process.execPath,
['scripts/compiled-runtime-smoke.mjs', '--repo-root', emptyRepo],
{
cwd: path.resolve(import.meta.dir, '..'),
encoding: 'utf8',
},
);
assert.equal(result.status, 1);
assert.match(result.stderr, /Compiled runtime artifacts are missing/);
assert.match(result.stderr, /dist\/main-entry\.js/);
assert.match(result.stderr, /dist\/stats-daemon-runner\.js/);
assert.doesNotMatch(result.stderr, /must run with Electron/);
} finally {
fs.rmSync(emptyRepo, { recursive: true, force: true });
}
});
+6 -3
View File
@@ -51,10 +51,13 @@ function listFiles(root, prefix = '') {
}); });
} }
// asar resolves lookups with the platform separator, so stat with the listed
// native path and only normalize the reported name.
function listAppFiles(archive) { function listAppFiles(archive) {
return asar.listPackage(archive).flatMap((entry) => { return asar.listPackage(archive).flatMap((entry) => {
const name = entry.replaceAll('\\', '/').replace(/^\//, ''); const native = entry.replace(/^[\\/]/, '');
const stat = asar.statFile(archive, name); const stat = asar.statFile(archive, native);
const name = native.replaceAll('\\', '/');
return 'size' in stat ? [{ path: name, bytes: stat.size }] : []; return 'size' in stat ? [{ path: name, bytes: stat.size }] : [];
}); });
} }
@@ -129,7 +132,7 @@ function verifyContents(archive, resources, platform, arch) {
assert(!name.path.startsWith('minecard'), `Demo media shipped: ${name.path}`); assert(!name.path.startsWith('minecard'), `Demo media shipped: ${name.path}`);
} }
for (const ui of ['renderer', 'settings', 'syncui']) { for (const ui of ['renderer', 'settings', 'syncui']) {
const css = asar.extractFile(archive, `dist/${ui}/style.css`).toString(); const css = asar.extractFile(archive, path.join('dist', ui, 'style.css')).toString();
assert(css.includes('../fonts/MPLUS1[wght].ttf'), `Shared font missing from ${ui} CSS`); assert(css.includes('../fonts/MPLUS1[wght].ttf'), `Shared font missing from ${ui} CSS`);
} }
return entries; return entries;
+4 -1
View File
@@ -134,10 +134,12 @@ test('archive inventory handles native files without counting them twice on disk
mkdirSync(output); mkdirSync(output);
writeFileSync(path.join(input, 'main.js'), 'hello'); writeFileSync(path.join(input, 'main.js'), 'hello');
writeFileSync(path.join(input, 'native.node'), 'native'); writeFileSync(path.join(input, 'native.node'), 'native');
mkdirSync(path.join(input, 'dist', 'ai'), { recursive: true });
writeFileSync(path.join(input, 'dist', 'ai', 'client.js'), 'nested');
const archive = path.join(output, 'app.asar'); const archive = path.join(output, 'app.asar');
await createPackageFromStreams( await createPackageFromStreams(
archive, archive,
['main.js', 'native.node'].map((name) => ({ ['main.js', 'native.node', 'dist/ai/client.js'].map((name) => ({
path: name, path: name,
type: 'file', type: 'file',
unpacked: name.endsWith('.node'), unpacked: name.endsWith('.node'),
@@ -148,6 +150,7 @@ test('archive inventory handles native files without counting them twice on disk
assert.deepEqual(listAppFiles(archive), [ assert.deepEqual(listAppFiles(archive), [
{ path: 'main.js', bytes: 5 }, { path: 'main.js', bytes: 5 },
{ path: 'native.node', bytes: 6 }, { path: 'native.node', bytes: 6 },
{ path: 'dist/ai/client.js', bytes: 6 },
]); ]);
assert.equal( assert.equal(
listFiles(output).reduce((sum: number, entry: { bytes: number }) => sum + entry.bytes, 0), listFiles(output).reduce((sum: number, entry: { bytes: number }) => sum + entry.bytes, 0),
+13
View File
@@ -3,6 +3,7 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { stageBundledIntegrationKeys, TMDB_API_KEY_ENV } from './bundled-integration-keys.mjs';
const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..'); const repoRoot = path.resolve(scriptDir, '..');
@@ -97,6 +98,17 @@ function buildMacosHelper() {
} }
} }
// Only the key names are logged, never the values: CI masks secrets, but a
// stray echo would still leak them into local build logs.
function stageIntegrationKeys() {
const staged = stageBundledIntegrationKeys(path.join(repoRoot, 'dist'));
process.stdout.write(
staged.length > 0
? `Staged bundled integration keys: ${staged.join(', ')}\n`
: `No bundled integration keys (${TMDB_API_KEY_ENV} unset)\n`,
);
}
function main() { function main() {
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(repoRoot, 'dist', 'fonts'), { fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(repoRoot, 'dist', 'fonts'), {
recursive: true, recursive: true,
@@ -106,6 +118,7 @@ function main() {
copySettingsAssets(); copySettingsAssets();
copySyncUiAssets(); copySyncUiAssets();
buildMacosHelper(); buildMacosHelper();
stageIntegrationKeys();
} }
main(); main();
+34 -2
View File
@@ -1,8 +1,10 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { resolve } from 'node:path'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { mergeLcovReports, resolveCoverageDir } from './run-coverage-lane'; import { mergeLcovReports, resolveCoverageDir, runCoverageLane } from './run-coverage-lane';
test('mergeLcovReports combines duplicate source-file counters across shard outputs', () => { test('mergeLcovReports combines duplicate source-file counters across shard outputs', () => {
const merged = mergeLcovReports([ const merged = mergeLcovReports([
@@ -72,3 +74,33 @@ test('resolveCoverageDir keeps coverage output inside the repository', () => {
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '../escape'])); assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '../escape']));
assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '/tmp/escape'])); assert.throws(() => resolveCoverageDir(repoRoot, ['--coverage-dir', '/tmp/escape']));
}); });
test('runCoverageLane returns a failure when a discovered test fails', () => {
const repoRoot = mkdtempSync(join(tmpdir(), 'subminer-coverage-failure-'));
try {
mkdirSync(join(repoRoot, 'src'));
writeFileSync(
join(repoRoot, 'src', 'failure.test.ts'),
[
"import assert from 'node:assert/strict';",
"import test from 'node:test';",
'',
"test('intentional coverage failure', () => {",
" assert.fail('coverage runner must propagate this failure');",
'});',
'',
].join('\n'),
);
assert.notEqual(
runCoverageLane({
repoRootDir: repoRoot,
argv: ['bun-src-full', '--coverage-dir', 'coverage/test-src'],
stdio: 'pipe',
}),
0,
);
} finally {
rmSync(repoRoot, { recursive: true, force: true });
}
});
+11 -7
View File
@@ -201,14 +201,18 @@ export function mergeLcovReports(reports: string[]): string {
return chunks.length > 0 ? `${chunks.join('\n')}\n` : ''; return chunks.length > 0 ? `${chunks.join('\n')}\n` : '';
} }
function runCoverageLane(): number { export function runCoverageLane(
const laneName = process.argv[2]; options: { repoRootDir?: string; argv?: string[]; stdio?: 'inherit' | 'pipe' } = {},
): number {
const repoRootDir = options.repoRootDir ?? repoRoot;
const argv = options.argv ?? process.argv.slice(2);
const laneName = argv[0];
if (laneName === undefined) { if (laneName === undefined) {
process.stderr.write('Missing coverage lane name\n'); process.stderr.write('Missing coverage lane name\n');
return 1; return 1;
} }
const coverageDir = resolveCoverageDir(repoRoot, process.argv.slice(3)); const coverageDir = resolveCoverageDir(repoRootDir, argv.slice(1));
const shardRoot = join(coverageDir, '.shards'); const shardRoot = join(coverageDir, '.shards');
mkdirSync(coverageDir, { recursive: true }); mkdirSync(coverageDir, { recursive: true });
rmSync(shardRoot, { recursive: true, force: true }); rmSync(shardRoot, { recursive: true, force: true });
@@ -216,7 +220,7 @@ function runCoverageLane(): number {
let files: string[]; let files: string[];
try { try {
files = collectLaneFiles(repoRoot, laneName); files = collectLaneFiles(repoRootDir, laneName);
} catch (error) { } catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : error}\n`); process.stderr.write(`${error instanceof Error ? error.message : error}\n`);
return 1; return 1;
@@ -230,8 +234,8 @@ function runCoverageLane(): number {
'bun', 'bun',
['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`], ['test', '--coverage', '--coverage-reporter=lcov', '--coverage-dir', shardDir, `./${file}`],
{ {
cwd: repoRoot, cwd: repoRootDir,
stdio: 'inherit', stdio: options.stdio ?? 'inherit',
}, },
); );
@@ -253,7 +257,7 @@ function runCoverageLane(): number {
writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8'); writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
process.stdout.write( process.stdout.write(
`Merged LCOV written to ${relative(repoRoot, join(coverageDir, 'lcov.info'))}\n`, `Merged LCOV written to ${relative(repoRootDir, join(coverageDir, 'lcov.info'))}\n`,
); );
return 0; return 0;
} finally { } finally {
+6 -1
View File
@@ -14,7 +14,12 @@ delete env.ELECTRON_RUN_AS_NODE;
try { try {
const result = spawnSync( const result = spawnSync(
require('electron'), require('electron'),
[fileURLToPath(new URL('./smoke-package.cjs', import.meta.url)), path.resolve(resources)], [
fileURLToPath(new URL('./smoke-package.cjs', import.meta.url)),
path.resolve(resources),
// CI runners lack a setuid chrome-sandbox; this harness never loads remote content.
...(process.platform === 'linux' ? ['--no-sandbox'] : []),
],
{ env, stdio: 'inherit', timeout: 75_000 }, { env, stdio: 'inherit', timeout: 75_000 },
); );
if (result.error) throw result.error; if (result.error) throw result.error;
+52 -8
View File
@@ -1,6 +1,7 @@
// Run with the pinned Electron runtime against a finished app's resources folder. // Run with the pinned Electron runtime against a finished app's resources folder.
const { app, BrowserWindow, session } = require('electron'); const { app, BrowserWindow, session } = require('electron');
const fs = require('node:fs'); const fs = require('node:fs');
const http = require('node:http');
const path = require('node:path'); const path = require('node:path');
const { createRequire } = require('node:module'); const { createRequire } = require('node:module');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
@@ -21,6 +22,39 @@ const timeout = setTimeout(() => {
app.exit(1); app.exit(1);
}, 60_000); }, 60_000);
const STATIC_TYPES = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.json': 'application/json',
};
// The stats dashboard is served by the stats HTTP server in the app, so load it
// over loopback HTTP from the packaged stats/dist and treat missing static
// assets as failures. API routes are not part of this smoke and may 404.
function serveStatsDist(root, failedRequests) {
const server = http.createServer((req, res) => {
const pathname = new URL(req.url, 'http://127.0.0.1').pathname;
const relative = pathname === '/' ? 'index.html' : pathname.slice(1);
try {
const body = fs.readFileSync(path.join(root, relative));
res.writeHead(200, {
'Content-Type': STATIC_TYPES[path.extname(relative)] ?? 'application/octet-stream',
});
res.end(body);
} catch {
if (!pathname.startsWith('/api/')) failedRequests.push(`${req.url}: missing static asset`);
res.writeHead(404).end();
}
});
server.listen(0, '127.0.0.1');
return server;
}
async function smoke() { async function smoke() {
await app.whenReady(); await app.whenReady();
const packagedRequire = createRequire(path.join(archive, 'package.json')); const packagedRequire = createRequire(path.join(archive, 'package.json'));
@@ -50,10 +84,17 @@ async function smoke() {
); );
assert(extension.id, 'Yomitan extension failed to load'); assert(extension.id, 'Yomitan extension failed to load');
const failedRequests = []; const failedRequests = [];
session.defaultSession.webRequest.onErrorOccurred({ urls: ['file://*/*'] }, (details) => { session.defaultSession.webRequest.onErrorOccurred(
if (details.error !== 'net::ERR_ABORTED') { urls: ['file://*/*', 'http://127.0.0.1/*'] },
failedRequests.push(`${details.url}: ${details.error}`); (details) => {
}); // Chromium probes the cache before fetching @font-face fonts; an uncached
// font reports ERR_CACHE_MISS and is then fetched normally.
if (!['net::ERR_ABORTED', 'net::ERR_CACHE_MISS'].includes(details.error))
failedRequests.push(`${details.url}: ${details.error}`);
},
);
const statsServer = serveStatsDist(path.join(archive, 'stats', 'dist'), failedRequests);
await once(statsServer, 'listening');
for (const ui of ['renderer', 'settings', 'syncui', 'stats']) { for (const ui of ['renderer', 'settings', 'syncui', 'stats']) {
const win = new BrowserWindow({ const win = new BrowserWindow({
show: false, show: false,
@@ -63,10 +104,12 @@ async function smoke() {
}, },
}); });
try { try {
await win.loadFile( if (ui === 'stats') {
path.join(archive, ui === 'stats' ? 'stats/dist/index.html' : `dist/${ui}/index.html`), await win.loadURL(`http://127.0.0.1:${statsServer.address().port}/`);
); // Let in-flight font requests settle before the window goes away.
if (ui !== 'stats') { await win.webContents.executeJavaScript('document.fonts.ready.then(() => true)');
} else {
await win.loadFile(path.join(archive, `dist/${ui}/index.html`));
const loaded = await win.webContents.executeJavaScript( const loaded = await win.webContents.executeJavaScript(
`document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`, `document.fonts.load('400 16px "M PLUS 1"', '日本語').then(fonts => fonts.length > 0 && fonts.every(font => font.status === 'loaded'))`,
); );
@@ -76,6 +119,7 @@ async function smoke() {
win.destroy(); win.destroy();
} }
} }
statsServer.close();
assert.deepEqual(failedRequests, [], 'Packaged UI resources failed to load'); assert.deepEqual(failedRequests, [], 'Packaged UI resources failed to load');
console.log( console.log(
'Package smoke passed: SQLite, platform FFI, texthooker, Yomitan loading, UI pages, shared Japanese font.', 'Package smoke passed: SQLite, platform FFI, texthooker, Yomitan loading, UI pages, shared Japanese font.',
+42 -9
View File
@@ -69,6 +69,12 @@ local ctx = {
return { return {
numericSelectionTimeoutMs = 3000, numericSelectionTimeoutMs = 3000,
bindings = { bindings = {
{
key = { code = "KeyG-KeyS", modifiers = {} },
actionType = "session-action",
actionId = "openSubtitleSelection",
cliArgs = { "--session-action", '{"actionId":"openSubtitleSelection"}' },
},
{ {
key = { key = {
code = "KeyO", code = "KeyO",
@@ -312,7 +318,8 @@ local ctx = {
cliArgs = { "--session-action", '{"actionId":"openFuturePanel"}' }, cliArgs = { "--session-action", '{"actionId":"openFuturePanel"}' },
}, },
}, },
}, nil },
nil
end, end,
}, },
state = { state = {
@@ -430,17 +437,11 @@ assert_true(play_next_call ~= nil, "play-next binding should invoke CLI action")
assert_true(play_next_call[2] == "--play-next-subtitle", "play-next binding should pass CLI flag") assert_true(play_next_call[2] == "--play-next-subtitle", "play-next binding should pass CLI flag")
local character_dictionary_manager = find_binding("Ctrl+d") local character_dictionary_manager = find_binding("Ctrl+d")
assert_true( assert_true(character_dictionary_manager ~= nil, "character dictionary manager binding should be registered")
character_dictionary_manager ~= nil,
"character dictionary manager binding should be registered"
)
character_dictionary_manager.fn() character_dictionary_manager.fn()
local character_dictionary_manager_call = recorded.async_calls[#recorded.async_calls] local character_dictionary_manager_call = recorded.async_calls[#recorded.async_calls]
assert_true( assert_true(character_dictionary_manager_call ~= nil, "character dictionary manager binding should invoke CLI action")
character_dictionary_manager_call ~= nil,
"character dictionary manager binding should invoke CLI action"
)
assert_true( assert_true(
character_dictionary_manager_call[2] == "--session-action", character_dictionary_manager_call[2] == "--session-action",
"character dictionary manager binding should use generic session action CLI flag" "character dictionary manager binding should use generic session action CLI flag"
@@ -474,3 +475,35 @@ assert_true(call[2] == "--mine-sentence-multiple", "CLI action should enter mine
assert_true(call[3] == nil, "CLI action should not bind a plugin-side digit count") assert_true(call[3] == nil, "CLI action should not bind a plugin-side digit count")
print("plugin session binding regression tests: OK") print("plugin session binding regression tests: OK")
local selector = find_binding("g-s")
assert_true(selector ~= nil, "subtitle selection should override mpv g-s with a forced sequence")
selector.fn()
local selection_call = recorded.async_calls[#recorded.async_calls]
assert_true(
selection_call[3] == '{"actionId":"openSubtitleSelection"}',
"subtitle selection should dispatch its session action"
)
local native_bindings = {}
function mp.get_property_native(name)
assert_true(name == "input-bindings", "only native input bindings should be queried")
return native_bindings
end
for _, case in ipairs({
{ key = "g", priority = 1, enabled = false },
{ key = "G", priority = 1, enabled = true },
{ key = "Shift+g", priority = 1, enabled = true },
{ key = "Ctrl+g", priority = 1, enabled = true },
{ key = "g", priority = -1, enabled = true },
{ key = "g", priority = 1, cmd = "ignore", enabled = true },
{ key = "g", priority = 1, cmd = "no-osd ignore", enabled = true },
}) do
native_bindings = { { key = case.key, cmd = case.cmd or "show-text single", priority = case.priority } }
recorded.bindings = {}
assert_true(bindings.reload_bindings(), "binding reload should succeed")
assert_true((find_binding("g-s") ~= nil) == case.enabled, "sequence prefix conflict: " .. case.key)
end
assert_true(#recorded.osd > 0, "native prefix conflicts should be visible")
print("plugin sequence conflict tests: OK")
+22
View File
@@ -1545,3 +1545,25 @@ test('Anki metadata rejects a credential-bearing media title before metadata arr
const result = privateApi.formatMiscInfoPattern('stream?api_key=test-secret', 426); const result = privateApi.formatMiscInfoPattern('stream?api_key=test-secret', 426);
assert.equal(result, '[SubMiner] Unknown media | Unknown media (00:07:06)'); assert.equal(result, '[SubMiner] Unknown media | Unknown media (00:07:06)');
}); });
test('AnkiIntegration.formatMiscInfoPattern treats ApiKey stream paths like legacy api_key ones', () => {
const integration = new AnkiIntegration(
{ metadata: { pattern: '[SubMiner] %f (%t)' } } as never,
{} as never,
{
currentSubText: '',
currentVideoPath: 'stream?static=true&ApiKey=secret-token&MediaSourceId=ms-1',
currentTimePos: 426,
currentSubStart: 426,
currentSubEnd: 428,
currentMediaTitle: '[Jellyfin/direct] Bocchi the Rock! - S01E02',
send: () => true,
} as unknown as never,
);
const privateApi = integration as unknown as {
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
};
const result = privateApi.formatMiscInfoPattern('audio_123.mp3', 426);
assert.equal(result, '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)');
assert.equal(result.includes('ApiKey='), false);
});
+22 -4
View File
@@ -185,7 +185,7 @@ function extractFilenameFromMediaPath(rawPath: string): string {
function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): boolean { function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): boolean {
const loweredPath = rawPath.toLowerCase(); const loweredPath = rawPath.toLowerCase();
const loweredFilename = filename.toLowerCase(); const loweredFilename = filename.toLowerCase();
if (loweredPath.includes('api_key=')) { if (loweredPath.includes('api_key=') || loweredPath.includes('apikey=')) {
return true; return true;
} }
if (loweredPath.startsWith('http://') || loweredPath.startsWith('https://')) { if (loweredPath.startsWith('http://') || loweredPath.startsWith('https://')) {
@@ -243,6 +243,9 @@ export class AnkiIntegration {
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null; private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
private knownWordCacheUpdatedCallback: (() => void) | null = null; private knownWordCacheUpdatedCallback: (() => void) | null = null;
private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null; private consumeSubtitleMiningContextCallback: (() => SubtitleMiningContext | null) | null = null;
private generateSentenceFuriganaCallback:
| ((text: string, highlightedText?: string) => Promise<string | null>)
| null = null;
private mediaTimingReviewCallback: private mediaTimingReviewCallback:
| ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>) | ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>)
| null = null; | null = null;
@@ -666,6 +669,13 @@ export class AnkiIntegration {
processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields), processSentence: (mpvSentence, noteFields) => this.processSentence(mpvSentence, noteFields),
processSentenceFurigana: (sentenceFurigana, noteFields) => processSentenceFurigana: (sentenceFurigana, noteFields) =>
this.processSentenceFurigana(sentenceFurigana, noteFields), this.processSentenceFurigana(sentenceFurigana, noteFields),
generateSentenceFurigana: async (text, noteFields) =>
this.generateSentenceFuriganaCallback?.(
text,
this.config.behavior?.highlightWord === false
? undefined
: this.getSentenceHighlightText(noteFields),
) ?? null,
setCardTypeFields: (updatedFields, availableFieldNames, cardKind) => setCardTypeFields: (updatedFields, availableFieldNames, cardKind) =>
this.setCardTypeFields(updatedFields, availableFieldNames, cardKind), this.setCardTypeFields(updatedFields, availableFieldNames, cardKind),
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
@@ -1118,9 +1128,11 @@ export class AnkiIntegration {
return null; return null;
} }
const mediaRange = this.getSubtitleMediaRange(context); const mediaRange = this.getSubtitleMediaRange(context);
const timestamp = context const timestamp =
? mediaRange.startTime + (mediaRange.endTime - mediaRange.startTime) / 2 context?.screenshotTime ??
: this.mpvClient.currentTimePos || 0; (context
? mediaRange.startTime + (mediaRange.endTime - mediaRange.startTime) / 2
: this.mpvClient.currentTimePos || 0);
if (this.config.media?.imageType === 'avif') { if (this.config.media?.imageType === 'avif') {
return this.mediaGenerator.generateAnimatedImage( return this.mediaGenerator.generateAnimatedImage(
@@ -1781,6 +1793,10 @@ export class AnkiIntegration {
this.consumeSubtitleMiningContextCallback = callback; this.consumeSubtitleMiningContextCallback = callback;
} }
setSentenceFuriganaGenerator(callback: typeof this.generateSentenceFuriganaCallback): void {
this.generateSentenceFuriganaCallback = callback;
}
setMediaTimingReviewCallback( setMediaTimingReviewCallback(
callback: ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>) | null, callback: ((request: MediaTimingReviewRequest) => Promise<MediaTimingReviewDecision>) | null,
): void { ): void {
@@ -1797,6 +1813,8 @@ export class AnkiIntegration {
...request, ...request,
audioPadding: Math.max(0, this.config.media.audioPadding ?? 0), audioPadding: Math.max(0, this.config.media.audioPadding ?? 0),
maxMediaDuration: Math.max(0, this.config.media.maxMediaDuration ?? 30), maxMediaDuration: Math.max(0, this.config.media.maxMediaDuration ?? 30),
screenshotEnabled:
this.config.media.generateImage !== false && this.config.media.imageType !== 'avif',
}); });
} }
@@ -14,7 +14,8 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({ const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
config: { config: {
fields: { fields: {
audio: 'ExpressionAudio', audio: 'SentenceAudio',
wordAudio: 'Pronunciation',
}, },
media: { media: {
imageType: 'avif', imageType: 'avif',
@@ -25,7 +26,8 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
noteInfo: { noteInfo: {
noteId: 42, noteId: 42,
fields: { fields: {
ExpressionAudio: { SentenceAudio: { value: '[sound:sentence.mp3]' },
Pronunciation: {
value: '[sound:word.mp3][sound:alt.ogg]', value: '[sound:word.mp3][sound:alt.ogg]',
}, },
}, },
@@ -121,3 +123,32 @@ test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled
assert.equal(leadInSeconds, 0); assert.equal(leadInSeconds, 0);
}); });
for (const sentenceAudio of ['', '[sound:sentence.mp3]']) {
test(`word audio defaults independently of sentence audio (${sentenceAudio ? 'existing' : 'new'} note)`, async () => {
const retrieved: string[] = [];
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
config: {
fields: { audio: 'SentenceAudio' },
media: { imageType: 'avif' },
},
noteInfo: {
noteId: 42,
fields: {
ExpressionAudio: { value: '[sound:word.mp3]' },
SentenceAudio: { value: sentenceAudio },
},
},
resolveConfiguredFieldName: (noteInfo, ...preferredNames) =>
preferredNames.find((name) => name !== undefined && name in noteInfo.fields) ?? null,
retrieveMediaFileBase64: async (filename) => {
retrieved.push(filename);
return 'd29yZA==';
},
probeAudioDurationSeconds: async (_buffer, filename) => (filename === 'word.mp3' ? 0.6 : 4),
});
assert.equal(leadInSeconds, 0.6);
assert.deepEqual(retrieved, ['word.mp3']);
});
}
+2 -2
View File
@@ -97,8 +97,8 @@ export async function resolveAnimatedImageLeadInSeconds<TNoteInfo extends NoteIn
const wordAudioFieldName = resolveConfiguredFieldName( const wordAudioFieldName = resolveConfiguredFieldName(
noteInfo, noteInfo,
config.fields?.audio, config.fields?.wordAudio,
DEFAULT_ANKI_CONNECT_CONFIG.fields.audio, DEFAULT_ANKI_CONNECT_CONFIG.fields.wordAudio,
); );
if (!wordAudioFieldName) { if (!wordAudioFieldName) {
return 0; return 0;
@@ -160,6 +160,52 @@ test('manual clipboard subtitle update replaces audio in the configured field',
); );
}); });
test('manual clipboard mining treats a zero media duration cap as unlimited', async () => {
const audioRanges: Array<{ start: number; end: number; padding: number | undefined }> = [];
const scenarios = [
{ maxMediaDuration: 0, expectedEnd: 14 },
{ maxMediaDuration: 1, expectedEnd: 13 },
];
for (const scenario of scenarios) {
const { service } = createManualUpdateService({
getConfig: () =>
({
deck: 'Mining',
fields: {
word: 'Expression',
sentence: 'Sentence',
audio: 'ExpressionAudio',
},
media: {
generateAudio: true,
generateImage: false,
audioPadding: 0.25,
maxMediaDuration: scenario.maxMediaDuration,
},
behavior: {},
ai: false,
}) as AnkiConnectConfig,
mediaGenerator: {
generateAudio: async (_path, start, end, padding) => {
audioRanges.push({ start, end, padding });
return Buffer.from('audio');
},
generateScreenshot: async () => null,
generateAnimatedImage: async () => null,
},
});
await service.updateLastAddedFromClipboard('字幕');
assert.deepEqual(audioRanges.at(-1), {
start: 12,
end: scenario.expectedEnd,
padding: 0.25,
});
}
});
test('manual clipboard word-card update uses configured fields with Lapis and Kiku enabled', async () => { test('manual clipboard word-card update uses configured fields with Lapis and Kiku enabled', async () => {
const { service, updatedFields } = createManualUpdateService({ const { service, updatedFields } = createManualUpdateService({
getConfig: () => getConfig: () =>
+20 -3
View File
@@ -290,6 +290,9 @@ test('CardCreationService keeps updating after recordCardsMinedCallback throws',
}); });
test('CardCreationService uses stream-open-filename for remote media generation', async () => { test('CardCreationService uses stream-open-filename for remote media generation', async () => {
let reviewing = false;
const audioRanges: number[][] = [];
const imageTimes: number[] = [];
const audioPaths: string[] = []; const audioPaths: string[] = [];
const imagePaths: string[] = []; const imagePaths: string[] = [];
const recordMediaPath = (mediaInput: MediaInput): string => const recordMediaPath = (mediaInput: MediaInput): string =>
@@ -319,6 +322,10 @@ test('CardCreationService uses stream-open-filename for remote media generation'
behavior: {}, behavior: {},
ai: false, ai: false,
}) as AnkiConnectConfig, }) as AnkiConnectConfig,
reviewMediaTiming: async () =>
reviewing
? { action: 'confirm', startTime: 0.2, endTime: 0.8, screenshotTime: 3.125 }
: { action: 'use-original' },
getAiConfig: () => ({}), getAiConfig: () => ({}),
getTimingTracker: () => ({}) as never, getTimingTracker: () => ({}) as never,
getMpvClient: () => getMpvClient: () =>
@@ -349,16 +356,18 @@ test('CardCreationService uses stream-open-filename for remote media generation'
], ],
updateNoteFields: async () => undefined, updateNoteFields: async () => undefined,
storeMediaFile: async () => undefined, storeMediaFile: async () => undefined,
findNotes: async () => [], findNotes: async () => [42],
retrieveMediaFile: async () => '', retrieveMediaFile: async () => '',
deleteNotes: async () => undefined, deleteNotes: async () => undefined,
}, },
mediaGenerator: { mediaGenerator: {
generateAudio: async (path) => { generateAudio: async (path, start, end, padding) => {
audioRanges.push([start, end, padding ?? -1]);
audioPaths.push(recordMediaPath(path)); audioPaths.push(recordMediaPath(path));
return Buffer.from('audio'); return Buffer.from('audio');
}, },
generateScreenshot: async (path) => { generateScreenshot: async (path, timestamp) => {
imageTimes.push(timestamp);
imagePaths.push(recordMediaPath(path)); imagePaths.push(recordMediaPath(path));
return Buffer.from('image'); return Buffer.from('image');
}, },
@@ -406,6 +415,14 @@ test('CardCreationService uses stream-open-filename for remote media generation'
assert.equal(created, true); assert.equal(created, true);
assert.deepEqual(audioPaths, [audioUrl]); assert.deepEqual(audioPaths, [audioUrl]);
assert.deepEqual(imagePaths, [videoUrl]); assert.deepEqual(imagePaths, [videoUrl]);
reviewing = true;
assert.equal(await service.createSentenceCard('テスト', 0, 1), true);
assert.deepEqual(audioRanges.at(-1), [0.2, 0.8, 0]);
assert.equal(imageTimes.at(-1), 3.125);
await service.markLastCardAsAudioCard();
assert.equal(imageTimes.length, 3);
assert.deepEqual(audioRanges.at(-1), [0.2, 0.8, 0]);
assert.equal(imageTimes.at(-1), 3.125);
}); });
test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => { test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => {
+17 -10
View File
@@ -21,6 +21,7 @@ import {
resolveAudioStreamIndexForMediaGeneration, resolveAudioStreamIndexForMediaGeneration,
type MediaGenerationInputResolverOptions, type MediaGenerationInputResolverOptions,
} from './media-source'; } from './media-source';
import { clampMediaEndTime } from './media-duration';
import { resolveWordCardKind } from './note-field-utils'; import { resolveWordCardKind } from './note-field-utils';
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media'; import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
import { resolveMpvVolumeScale } from './mpv-volume'; import { resolveMpvVolumeScale } from './mpv-volume';
@@ -233,11 +234,12 @@ export class CardCreationService {
let rangeEnd = Math.max(...timings.map((entry) => entry.endTime)); let rangeEnd = Math.max(...timings.map((entry) => entry.endTime));
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30; const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && rangeEnd - rangeStart > maxMediaDuration) { const cappedRangeEnd = clampMediaEndTime(rangeStart, rangeEnd, maxMediaDuration);
if (cappedRangeEnd !== rangeEnd) {
log.warn( log.warn(
`Media range ${(rangeEnd - rangeStart).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`, `Media range ${(rangeEnd - rangeStart).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`,
); );
rangeEnd = rangeStart + maxMediaDuration; rangeEnd = cappedRangeEnd;
} }
this.deps.showOsdNotification('Updating card from clipboard...'); this.deps.showOsdNotification('Updating card from clipboard...');
@@ -437,9 +439,7 @@ export class CardCreationService {
} }
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30; const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) { endTime = clampMediaEndTime(startTime, endTime, maxMediaDuration);
endTime = startTime + maxMediaDuration;
}
this.deps.showOsdNotification('Marking card as audio card...'); this.deps.showOsdNotification('Marking card as audio card...');
await this.deps.withUpdateProgress('Marking audio card', async () => { await this.deps.withUpdateProgress('Marking audio card', async () => {
@@ -536,6 +536,7 @@ export class CardCreationService {
endTime, endTime,
animatedLeadInSeconds, animatedLeadInSeconds,
exactReviewedRange, exactReviewedRange,
timingDecision.action === 'confirm' ? timingDecision.screenshotTime : undefined,
); );
const imageField = this.deps.getConfig().fields?.image; const imageField = this.deps.getConfig().fields?.image;
@@ -599,11 +600,12 @@ export class CardCreationService {
} }
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30; const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
if (maxMediaDuration > 0 && endTime - startTime > maxMediaDuration) { const cappedEndTime = clampMediaEndTime(startTime, endTime, maxMediaDuration);
if (cappedEndTime !== endTime) {
log.warn( log.warn(
`Sentence card media range ${(endTime - startTime).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`, `Sentence card media range ${(endTime - startTime).toFixed(1)}s exceeds cap of ${maxMediaDuration}s, clamping`,
); );
endTime = startTime + maxMediaDuration; endTime = cappedEndTime;
} }
try { try {
@@ -796,6 +798,9 @@ export class CardCreationService {
generateImage, generateImage,
volumeScale, volumeScale,
...(exactReviewedRange ? { mediaPaddingSeconds: 0 } : {}), ...(exactReviewedRange ? { mediaPaddingSeconds: 0 } : {}),
...(timingDecision.action === 'confirm' && timingDecision.screenshotTime !== undefined
? { screenshotTime: timingDecision.screenshotTime }
: {}),
}); });
await this.deps.showNotification(noteId, label, 'media queued'); await this.deps.showNotification(noteId, label, 'media queued');
return true; return true;
@@ -840,6 +845,7 @@ export class CardCreationService {
endTime, endTime,
0, 0,
exactReviewedRange, exactReviewedRange,
timingDecision.action === 'confirm' ? timingDecision.screenshotTime : undefined,
); );
const imageField = config.fields?.image; const imageField = config.fields?.image;
@@ -922,15 +928,16 @@ export class CardCreationService {
endTime: number, endTime: number,
animatedLeadInSeconds = 0, animatedLeadInSeconds = 0,
exactReviewedRange = false, exactReviewedRange = false,
screenshotTime?: number,
): Promise<Buffer | null> { ): Promise<Buffer | null> {
const mpvClient = this.deps.getMpvClient(); const mpvClient = this.deps.getMpvClient();
if (!mpvClient) { if (!mpvClient) {
return null; return null;
} }
const timestamp = exactReviewedRange const timestamp =
? startTime + (endTime - startTime) / 2 screenshotTime ??
: mpvClient.currentTimePos || 0; (exactReviewedRange ? startTime + (endTime - startTime) / 2 : mpvClient.currentTimePos || 0);
if (this.deps.getConfig().media?.imageType === 'avif') { if (this.deps.getConfig().media?.imageType === 'avif') {
let imageStart = startTime; let imageStart = startTime;
+10
View File
@@ -0,0 +1,10 @@
/** Zero or a negative cap leaves the requested end time unchanged. */
export function clampMediaEndTime(
startTime: number,
endTime: number,
maxMediaDuration: number,
): number {
return maxMediaDuration > 0 && endTime - startTime > maxMediaDuration
? startTime + maxMediaDuration
: endTime;
}
@@ -166,13 +166,14 @@ test('NoteUpdateWorkflow uses configured fields for word-card enrichment with La
test('NoteUpdateWorkflow updates sentence furigana when highlight processor changes it', async () => { test('NoteUpdateWorkflow updates sentence furigana when highlight processor changes it', async () => {
const harness = createWorkflowHarness(); const harness = createWorkflowHarness();
harness.deps.getCurrentSubtitleText = () => 'tokugi';
harness.deps.client.notesInfo = async () => harness.deps.client.notesInfo = async () =>
[ [
{ {
noteId: 42, noteId: 42,
fields: { fields: {
Expression: { value: 'tokugi' }, Expression: { value: 'tokugi' },
Sentence: { value: '' }, Sentence: { value: 'tokugi' },
SentenceFurigana: { value: '<span class="term">tokugi</span>' }, SentenceFurigana: { value: '<span class="term">tokugi</span>' },
}, },
}, },
@@ -184,7 +185,7 @@ test('NoteUpdateWorkflow updates sentence furigana when highlight processor chan
assert.equal(harness.updates.length, 1); assert.equal(harness.updates.length, 1);
assert.deepEqual(harness.updates[0]?.fields, { assert.deepEqual(harness.updates[0]?.fields, {
Sentence: 'subtitle-text', Sentence: 'tokugi',
SentenceFurigana: '<span class="term"><b>tokugi</b></span>', SentenceFurigana: '<span class="term"><b>tokugi</b></span>',
}); });
}); });
@@ -729,6 +730,7 @@ test('NoteUpdateWorkflow uses the combined review sentence for the card and medi
startTime: 2, startTime: 2,
endTime: 7, endTime: 7,
text: 'previous-line current-line next-line', text: 'previous-line current-line next-line',
screenshotTime: 8,
}); });
harness.deps.generateAudio = async (context) => { harness.deps.generateAudio = async (context) => {
audioContexts.push(context); audioContexts.push(context);
@@ -745,6 +747,7 @@ test('NoteUpdateWorkflow uses the combined review sentence for the card and medi
assert.equal(audioContexts[0]?.startTime, 2); assert.equal(audioContexts[0]?.startTime, 2);
assert.equal(audioContexts[0]?.endTime, 7); assert.equal(audioContexts[0]?.endTime, 7);
assert.equal(audioContexts[0]?.mediaPaddingSeconds, 0); assert.equal(audioContexts[0]?.mediaPaddingSeconds, 0);
assert.equal(audioContexts[0]?.screenshotTime, 8);
}); });
test('NoteUpdateWorkflow keeps cache unchanged and reports when deletion fails', async () => { test('NoteUpdateWorkflow keeps cache unchanged and reports when deletion fails', async () => {
@@ -774,3 +777,63 @@ test('NoteUpdateWorkflow keeps cache unchanged and reports when deletion fails',
assert.deepEqual(statusMessages, ['Card deletion failed: delete failed']); assert.deepEqual(statusMessages, ['Card deletion failed: delete failed']);
assert.ok(harness.warnings.length === 0); assert.ok(harness.warnings.length === 0);
}); });
for (const outcome of ['success', 'unavailable', 'throws'] as const) {
test(`NoteUpdateWorkflow regenerates expanded furigana (${outcome})`, async () => {
const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async () => [
{
noteId: 42,
fields: {
Expression: { value: '猫' },
Sentence: { value: '<b>猫</b>を見た。' },
SentenceFurigana: { value: ' 猫[ねこ]を 見[み]た。' },
},
},
];
harness.deps.captureSubtitleMediaContext = () => ({
source: 'overlay',
text: '猫を見た。',
startTime: 4,
endTime: 6,
});
harness.deps.reviewMediaTiming = async () => ({
action: 'confirm',
text: '猫を見た。犬もいた。',
startTime: 2,
endTime: 8,
});
harness.deps.generateSentenceFurigana = async (text, fields) => {
assert.equal(text, '猫を見た。犬もいた。');
assert.equal(fields.expression, '猫');
if (outcome === 'throws') throw new Error('parser unavailable');
return outcome === 'success' ? '<b> 猫[ねこ]</b>を 見[み]た。 犬[いぬ]もいた。' : null;
};
await harness.workflow.execute(42);
assert.equal(harness.updates[0]?.fields.Sentence, '猫を見た。犬もいた。');
assert.equal(
harness.updates[0]?.fields.SentenceFurigana,
outcome === 'success' ? '<b> 猫[ねこ]</b>を 見[み]た。 犬[いぬ]もいた。' : '',
);
});
}
test('NoteUpdateWorkflow preserves native furigana formatting when sentence context is unchanged', async () => {
const harness = createWorkflowHarness();
harness.deps.client.notesInfo = async () => [
{
noteId: 42,
fields: {
Expression: { value: '猫' },
Sentence: { value: '<b>猫</b>を見た。' },
SentenceFurigana: { value: '<ruby>猫<rt>ねこ</rt></ruby>を見た。' },
},
},
];
harness.deps.getCurrentSubtitleText = () => '猫を見た。';
harness.deps.generateSentenceFurigana = async () => {
assert.fail('unchanged sentence must keep native formatting');
};
await harness.workflow.execute(42);
assert.equal(harness.updates[0]?.fields.SentenceFurigana, undefined);
});
+28 -1
View File
@@ -74,6 +74,10 @@ export interface NoteUpdateWorkflowDeps {
sentenceFurigana: string, sentenceFurigana: string,
noteFields: Record<string, string>, noteFields: Record<string, string>,
) => string; ) => string;
generateSentenceFurigana?: (
text: string,
noteFields: Record<string, string>,
) => Promise<string | null>;
setCardTypeFields: ( setCardTypeFields: (
updatedFields: Record<string, string>, updatedFields: Record<string, string>,
availableFieldNames: string[], availableFieldNames: string[],
@@ -253,6 +257,9 @@ export class NoteUpdateWorkflow {
startTime: timingDecision.startTime, startTime: timingDecision.startTime,
endTime: timingDecision.endTime, endTime: timingDecision.endTime,
mediaPaddingSeconds: 0, mediaPaddingSeconds: 0,
...(timingDecision.screenshotTime !== undefined
? { screenshotTime: timingDecision.screenshotTime }
: {}),
}; };
} else if (timingDecision.action === 'skip-media') { } else if (timingDecision.action === 'skip-media') {
skipMedia = true; skipMedia = true;
@@ -279,7 +286,27 @@ export class NoteUpdateWorkflow {
const existingSentenceFurigana = sentenceFuriganaField const existingSentenceFurigana = sentenceFuriganaField
? noteInfo.fields[sentenceFuriganaField]?.value || '' ? noteInfo.fields[sentenceFuriganaField]?.value || ''
: ''; : '';
if (sentenceFuriganaField && existingSentenceFurigana && this.deps.processSentenceFurigana) { const sentenceChanged =
sentenceField &&
currentSubtitleText &&
normalizeSubtitleContextText(currentSubtitleText) !==
normalizeSubtitleContextText(noteInfo.fields[sentenceField]?.value ?? '');
if (sentenceFuriganaField && sentenceChanged) {
let furigana: string | null = null;
try {
furigana =
(await this.deps.generateSentenceFurigana?.(currentSubtitleText, fields)) ?? null;
} catch (error) {
this.deps.logWarn('Failed to regenerate sentence furigana:', error);
}
// Empty furigana lets card templates fall back to the updated Sentence field.
updatedFields[sentenceFuriganaField] = furigana ?? '';
updatePerformed = true;
} else if (
sentenceFuriganaField &&
existingSentenceFurigana &&
this.deps.processSentenceFurigana
) {
const processedSentenceFurigana = this.deps.processSentenceFurigana( const processedSentenceFurigana = this.deps.processSentenceFurigana(
existingSentenceFurigana, existingSentenceFurigana,
fields, fields,
@@ -51,6 +51,41 @@ function createDeps(
return deps; return deps;
} }
test('queued media keeps a chosen screenshot separate from the reviewed audio range', async () => {
const screenshots: number[] = [];
const audioRanges: number[][] = [];
const deps = createDeps();
deps.client.notesInfo = async () => [{ noteId: 42, fields: { Picture: { value: '' } } }];
deps.mediaGenerator.generateScreenshot = async (_media, time) => {
screenshots.push(time);
return Buffer.from('image');
};
deps.mediaGenerator.generateAudio = async (_media, start, end, padding) => {
audioRanges.push([start, end, padding ?? -1]);
return Buffer.from('audio');
};
const queue = new PendingYoutubeMediaQueue(deps);
assert.equal(
await queue.queueFromNote({
noteId: 42,
noteInfo: { noteId: 42, fields: {} },
label: 'test',
context: {
source: 'overlay',
text: '字幕',
startTime: 1,
endTime: 2,
mediaPaddingSeconds: 0,
screenshotTime: 3.125,
},
}),
true,
);
await queue.handleReady('https://youtu.be/abc123', '/cache/video.mkv');
assert.deepEqual(screenshots, [3.125]);
assert.deepEqual(audioRanges, [[1, 2, 0]]);
});
test('PendingYoutubeMediaQueue treats cache lookup failures as an immediate generation fallback', async () => { test('PendingYoutubeMediaQueue treats cache lookup failures as an immediate generation fallback', async () => {
const deps = createDeps({ const deps = createDeps({
getCachedMediaPath: async () => { getCachedMediaPath: async () => {
@@ -147,6 +147,9 @@ export class PendingYoutubeMediaQueue {
generateAudio: shouldGenerateAudio(config), generateAudio: shouldGenerateAudio(config),
generateImage: shouldGenerateImage(config), generateImage: shouldGenerateImage(config),
volumeScale, volumeScale,
...(job.context?.screenshotTime !== undefined
? { screenshotTime: job.context.screenshotTime }
: {}),
...(job.context?.mediaPaddingSeconds !== undefined ...(job.context?.mediaPaddingSeconds !== undefined
? { mediaPaddingSeconds: job.context.mediaPaddingSeconds } ? { mediaPaddingSeconds: job.context.mediaPaddingSeconds }
: {}), : {}),
@@ -320,6 +323,7 @@ export class PendingYoutubeMediaQueue {
job.endTime, job.endTime,
animatedLeadInSeconds, animatedLeadInSeconds,
job.mediaPaddingSeconds, job.mediaPaddingSeconds,
job.screenshotTime,
); );
if (imageBuffer) { if (imageBuffer) {
await this.deps.client.storeMediaFile(imageFilename, imageBuffer); await this.deps.client.storeMediaFile(imageFilename, imageBuffer);
@@ -381,6 +385,7 @@ export class PendingYoutubeMediaQueue {
endTime: number, endTime: number,
animatedLeadInSeconds = 0, animatedLeadInSeconds = 0,
mediaPaddingSeconds?: number, mediaPaddingSeconds?: number,
screenshotTime?: number,
): Promise<Buffer | null> { ): Promise<Buffer | null> {
const config = this.deps.getConfig(); const config = this.deps.getConfig();
if (config.media?.imageType === 'avif') { if (config.media?.imageType === 'avif') {
@@ -399,7 +404,7 @@ export class PendingYoutubeMediaQueue {
); );
} }
const timestamp = startTime + (endTime - startTime) / 2; const timestamp = screenshotTime ?? startTime + (endTime - startTime) / 2;
return this.deps.mediaGenerator.generateScreenshot(videoPath, timestamp, { return this.deps.mediaGenerator.generateScreenshot(videoPath, timestamp, {
format: config.media?.imageFormat as 'jpg' | 'png' | 'webp', format: config.media?.imageFormat as 'jpg' | 'png' | 'webp',
quality: config.media?.imageQuality, quality: config.media?.imageQuality,
@@ -11,6 +11,7 @@ export interface PendingYoutubeMediaUpdate {
generateImage: boolean; generateImage: boolean;
volumeScale?: number; volumeScale?: number;
mediaPaddingSeconds?: number; mediaPaddingSeconds?: number;
screenshotTime?: number;
} }
function trimToNonEmptyString(value: unknown): string | null { function trimToNonEmptyString(value: unknown): string | null {
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { formatSentenceFurigana } from './sentence-furigana';
const parsed = [
{
source: 'scanning-parser',
content: [
[{ text: '猫', reading: 'ねこ' }],
[{ text: 'を' }],
[{ text: '見', reading: 'み' }, { text: 'た' }],
[{ text: '。\n' }],
[{ text: '犬', reading: 'いぬ' }],
[{ text: 'もいた。' }],
],
},
];
test('formats the complete expanded sentence with readings and the mined word highlighted', () => {
assert.equal(
formatSentenceFurigana('猫を見た。\n犬もいた。', parsed, '猫'),
'<b> 猫[ねこ]</b>を 見[み]た。\n 犬[いぬ]もいた。',
);
});
test('rejects headword-only and malformed parse results instead of saving partial furigana', () => {
assert.equal(
formatSentenceFurigana('猫を見た。', [
{ source: 'scanning-parser', content: [[{ text: '猫', reading: 'ねこ' }]] },
]),
null,
);
assert.equal(
formatSentenceFurigana('猫', [
{ source: 'scanning-parser', content: [[{ text: '猫', reading: 42 }]] },
]),
null,
);
});
test('escapes literal markup and annotation delimiters and leaves kana unannotated', () => {
const text = '<猫> [メモ]';
assert.equal(
formatSentenceFurigana(text, [
{
source: 'scanning-parser',
content: [
[{ text: '<' }],
[{ text: '猫', reading: 'ねこ' }],
[{ text: '> [' }],
[{ text: 'メモ', reading: 'めも' }],
[{ text: ']' }],
],
},
]),
'&lt; 猫[ねこ]&gt; &#91;メモ&#93;',
);
});
test('highlights the mined word without bolding the rest of a dictionary phrase', () => {
assert.equal(
formatSentenceFurigana(
'行儀を直して',
[
{
content: [
[
{ text: '行儀', reading: 'ぎょうぎ' },
{ text: 'を' },
{ text: '直', reading: 'なお' },
{ text: 'して' },
],
],
},
],
'行儀',
),
'<b> 行儀[ぎょうぎ]</b>を 直[なお]して',
);
});
+87
View File
@@ -0,0 +1,87 @@
type Segment = { text: string; reading?: string };
function readGroups(value: unknown): Segment[][] | null {
if (!Array.isArray(value)) return null;
const groups: Segment[][] = [];
const rawGroups: unknown[] = value;
for (const group of rawGroups) {
if (!Array.isArray(group)) return null;
const segments: Segment[] = [];
const rawSegments: unknown[] = group;
for (const segment of rawSegments) {
if (
typeof segment !== 'object' ||
segment === null ||
!('text' in segment) ||
typeof segment.text !== 'string' ||
('reading' in segment &&
segment.reading !== undefined &&
typeof segment.reading !== 'string')
)
return null;
segments.push({
text: segment.text,
reading:
'reading' in segment && typeof segment.reading === 'string' ? segment.reading : undefined,
});
}
groups.push(segments);
}
return groups;
}
function escapeText(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\[/g, '&#91;')
.replace(/\]/g, '&#93;');
}
// Only accept a complete parse, so a lookup of just the headword cannot replace sentence context.
export function formatSentenceFurigana(
text: string,
results: unknown[] | null,
highlightedText?: string,
): string | null {
for (const result of results ?? []) {
if (typeof result !== 'object' || result === null || !('content' in result)) continue;
const groups = readGroups(result.content);
if (
!groups ||
groups
.flat()
.map((segment) => segment.text)
.join('') !== text
)
continue;
const highlights: number[] = [];
if (highlightedText) {
let start = text.indexOf(highlightedText);
while (start >= 0) {
highlights.push(start);
start = text.indexOf(highlightedText, start + highlightedText.length);
}
}
let offset = 0;
let bold = false;
let output = '';
for (const { text: surface, reading } of groups.flat()) {
const start = offset;
offset += surface.length;
const highlighted = highlights.some(
(position) => position < offset && position + (highlightedText?.length ?? 0) > start,
);
if (highlighted !== bold) output += highlighted ? '<b>' : '</b>';
bold = highlighted;
const escaped = escapeText(surface);
output +=
reading && reading !== surface && /[\p{Script=Han}々]/u.test(surface)
? ` ${escaped}[${escapeText(reading)}]`
: escaped;
}
return output + (bold ? '</b>' : '');
}
return null;
}
+17
View File
@@ -19,6 +19,23 @@ test('package scripts expose a sharded maintained source coverage lane with lcov
); );
}); });
test('source and coverage scripts discover the same maintained source lane', () => {
const sourceLane = packageJson.scripts['test:src']?.match(/run-test-lane\.mjs\s+([^\s]+)/)?.[1];
const coverageLane = packageJson.scripts['test:coverage:src']?.match(
/run-coverage-lane\.ts\s+([^\s]+)/,
)?.[1];
assert.equal(sourceLane, 'bun-src-full');
assert.equal(coverageLane, sourceLane);
});
test('environment suite owns launcher smoke execution', () => {
assert.match(
packageJson.scripts['test:env'] ?? '',
/^bun run test:launcher:smoke:src && bun run test:plugin:src && bun run test:immersion:sqlite:src$/,
);
});
test('ci delegates its gate instead of duplicating quality steps', () => { test('ci delegates its gate instead of duplicating quality steps', () => {
assert.match( assert.match(
ciWorkflow, ciWorkflow,
+23
View File
@@ -2818,6 +2818,29 @@ test('forces Senren off when Kiku is also enabled and validates Senren fieldGrou
); );
}); });
test('warns and falls back when isSenren.enabled is not boolean', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"ankiConnect": {
"isSenren": { "enabled": "true" }
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
assert.equal(
service.getConfig().ankiConnect.isSenren.enabled,
DEFAULT_CONFIG.ankiConnect.isSenren.enabled,
);
assert.ok(
service.getWarnings().some((warning) => warning.path === 'ankiConnect.isSenren.enabled'),
);
});
test('accepts valid ankiConnect knownWords deck object', () => { test('accepts valid ankiConnect knownWords deck object', () => {
const dir = makeTempDir(); const dir = makeTempDir();
fs.writeFileSync( fs.writeFileSync(
+3
View File
@@ -42,6 +42,7 @@ const {
ankiConnect, ankiConnect,
jimaku, jimaku,
tsukihime, tsukihime,
tmdb,
anilist, anilist,
mpv, mpv,
yomitan, yomitan,
@@ -55,6 +56,7 @@ const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
const { stats } = STATS_DEFAULT_CONFIG; const { stats } = STATS_DEFAULT_CONFIG;
export const DEFAULT_CONFIG: ResolvedConfig = { export const DEFAULT_CONFIG: ResolvedConfig = {
subtitleSelection: { enabled: false },
subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG }, subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG },
subtitlePosition, subtitlePosition,
keybindings, keybindings,
@@ -76,6 +78,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
auto_start_overlay, auto_start_overlay,
jimaku, jimaku,
tsukihime, tsukihime,
tmdb,
anilist, anilist,
mpv, mpv,
yomitan, yomitan,
+1
View File
@@ -99,6 +99,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
openRuntimeOptions: 'CommandOrControl+Shift+O', openRuntimeOptions: 'CommandOrControl+Shift+O',
openJimaku: 'Ctrl+Shift+J', openJimaku: 'Ctrl+Shift+J',
openTsukihime: 'Ctrl+Shift+T', openTsukihime: 'Ctrl+Shift+T',
openSubtitleSelection: 'g-s',
openSubtitleGeneration: 'Ctrl+Shift+G', openSubtitleGeneration: 'Ctrl+Shift+G',
openSessionHelp: 'CommandOrControl+Slash', openSessionHelp: 'CommandOrControl+Slash',
openControllerSelect: 'Alt+C', openControllerSelect: 'Alt+C',
@@ -6,6 +6,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
| 'ankiConnect' | 'ankiConnect'
| 'jimaku' | 'jimaku'
| 'tsukihime' | 'tsukihime'
| 'tmdb'
| 'anilist' | 'anilist'
| 'mpv' | 'mpv'
| 'yomitan' | 'yomitan'
@@ -29,6 +30,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
fields: { fields: {
word: 'Expression', word: 'Expression',
audio: 'ExpressionAudio', audio: 'ExpressionAudio',
wordAudio: 'ExpressionAudio',
image: 'Picture', image: 'Picture',
sentence: 'Sentence', sentence: 'Sentence',
miscInfo: 'MiscInfo', miscInfo: 'MiscInfo',
@@ -112,6 +114,10 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
apiBaseUrl: 'https://api.tsukihime.org/v1', apiBaseUrl: 'https://api.tsukihime.org/v1',
maxSearchResults: 10, maxSearchResults: 10,
}, },
tmdb: {
apiKey: '',
apiKeyCommand: '',
},
mpv: { mpv: {
executablePath: '', executablePath: '',
launchMode: 'normal', launchMode: 'normal',
+7
View File
@@ -628,6 +628,13 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.shortcuts.openSessionHelp, defaultValue: defaultConfig.shortcuts.openSessionHelp,
description: 'Accelerator that opens the session help / keybinding cheatsheet.', description: 'Accelerator that opens the session help / keybinding cheatsheet.',
}, },
{
path: 'shortcuts.openSubtitleSelection',
kind: 'string',
defaultValue: defaultConfig.shortcuts.openSubtitleSelection,
description:
'Open subtitle selection when enabled. Use g-s to press g then s. Set null to unbind.',
},
{ {
path: 'shortcuts.openSubtitleGeneration', path: 'shortcuts.openSubtitleGeneration',
kind: 'string', kind: 'string',
+22 -1
View File
@@ -82,6 +82,13 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.ankiConnect.fields.audio, defaultValue: defaultConfig.ankiConnect.fields.audio,
description: 'Card field that receives generated sentence audio.', description: 'Card field that receives generated sentence audio.',
}, },
{
path: 'ankiConnect.fields.wordAudio',
kind: 'string',
defaultValue: defaultConfig.ankiConnect.fields.wordAudio,
description:
'Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.',
},
{ {
path: 'ankiConnect.fields.image', path: 'ankiConnect.fields.image',
kind: 'string', kind: 'string',
@@ -288,7 +295,7 @@ export function buildIntegrationConfigOptionRegistry(
path: 'ankiConnect.media.maxMediaDuration', path: 'ankiConnect.media.maxMediaDuration',
kind: 'number', kind: 'number',
defaultValue: defaultConfig.ankiConnect.media.maxMediaDuration, defaultValue: defaultConfig.ankiConnect.media.maxMediaDuration,
description: 'Maximum allowed media clip duration in seconds.', description: 'Maximum allowed media clip duration in seconds. 0 disables the cap.',
}, },
{ {
path: 'ankiConnect.knownWords.matchMode', path: 'ankiConnect.knownWords.matchMode',
@@ -472,6 +479,20 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.tsukihime.maxSearchResults, defaultValue: defaultConfig.tsukihime.maxSearchResults,
description: 'Maximum TsukiHime search results returned.', description: 'Maximum TsukiHime search results returned.',
}, },
{
path: 'tmdb.apiKey',
kind: 'string',
defaultValue: defaultConfig.tmdb.apiKey,
description:
'Your own TMDB API key or read access token for live-action posters and synopses in the stats Library. Release builds bundle a project key, so set this only to use your own quota or when running from source (free under Settings > API on themoviedb.org).',
},
{
path: 'tmdb.apiKeyCommand',
kind: 'string',
defaultValue: defaultConfig.tmdb.apiKeyCommand,
description:
'Shell command that prints the TMDB API key to stdout. Used instead of apiKey to avoid storing the key in plain text.',
},
{ {
path: 'anilist.enabled', path: 'anilist.enabled',
kind: 'boolean', kind: 'boolean',
@@ -6,6 +6,13 @@ export function buildSubtitleConfigOptionRegistry(
defaultConfig: ResolvedConfig, defaultConfig: ResolvedConfig,
): ConfigOptionRegistryEntry[] { ): ConfigOptionRegistryEntry[] {
return [ return [
{
path: 'subtitleSelection.enabled',
kind: 'boolean',
defaultValue: defaultConfig.subtitleSelection.enabled,
description:
'Use the SubMiner modal to select primary and secondary subtitle tracks. When enabled, its shortcut overrides mpv subtitle selection.',
},
...( ...(
['whisperPath', 'modelPath', 'ffmpegPath', 'ffprobePath', 'vadModelPath', 'vadPath'] as const ['whisperPath', 'modelPath', 'ffmpegPath', 'ffprobePath', 'vadModelPath', 'vadPath'] as const
).map((key) => ({ ).map((key) => ({
+15 -1
View File
@@ -1,6 +1,12 @@
import { ConfigTemplateSection } from './shared'; import { ConfigTemplateSection } from './shared';
const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Subtitle Selection',
description: ['Select primary and secondary mpv subtitle tracks from the overlay.'],
notes: ['Hot-reload: enabling or disabling updates the session shortcut immediately.'],
key: 'subtitleSelection',
},
{ {
title: 'Japanese Subtitle Generation', title: 'Japanese Subtitle Generation',
description: [ description: [
@@ -144,7 +150,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
title: 'AnkiConnect Integration', title: 'AnkiConnect Integration',
description: ['Automatic Anki updates and media generation options.'], description: ['Automatic Anki updates and media generation options.'],
notes: [ notes: [
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.', 'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume/reviewTiming, knownWords, nPlusOne, fields.word/audio/wordAudio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, isSenren.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
'Shared AI provider transport settings are read from top-level ai and typically require restart.', 'Shared AI provider transport settings are read from top-level ai and typically require restart.',
'Most other AnkiConnect settings still require restart.', 'Most other AnkiConnect settings still require restart.',
], ],
@@ -164,6 +170,14 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
notes: ['Hot-reload: TsukiHime changes apply to the next TsukiHime request.'], notes: ['Hot-reload: TsukiHime changes apply to the next TsukiHime request.'],
key: 'tsukihime', key: 'tsukihime',
}, },
{
title: 'TMDB',
description: [
'TMDB (The Movie Database) metadata for live-action dramas and movies in the stats Library: posters, synopses, and grouping by show.',
],
notes: ['Hot-reload: TMDB changes apply to the next TMDB request.'],
key: 'tmdb',
},
{ {
title: 'YouTube Playback Settings', title: 'YouTube Playback Settings',
description: [ description: [
+71
View File
@@ -0,0 +1,71 @@
function pathStartsWith(path: string, prefix: string): boolean {
return path === prefix || path.startsWith(`${prefix}.`);
}
const HOT_RELOAD_ROOTS = [
'subtitleStyle',
'keybindings',
'shortcuts',
'subtitleSidebar',
'subtitleSelection',
] as const;
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'secondarySub.defaultMode',
'mpv.aniskipEnabled',
'mpv.aniskipButtonKey',
'ankiConnect.ai.enabled',
'stats.toggleKey',
'stats.markWatchedKey',
'logging.level',
'logging.rotation',
'logging.files',
'youtube.primarySubLanguages',
'ankiConnect.deck',
'ankiConnect.media.normalizeAudio',
'ankiConnect.media.mirrorMpvVolume',
'ankiConnect.media.reviewTiming',
'ankiConnect.behavior.autoUpdateNewCards',
'ankiConnect.knownWords.highlightEnabled',
'ankiConnect.knownWords.refreshMinutes',
'ankiConnect.knownWords.addMinedWordsImmediately',
'ankiConnect.knownWords.matchMode',
'ankiConnect.knownWords.decks',
'ankiConnect.nPlusOne.enabled',
'ankiConnect.nPlusOne.minSentenceWords',
'ankiConnect.fields.word',
'ankiConnect.fields.audio',
'ankiConnect.fields.wordAudio',
'ankiConnect.fields.image',
'ankiConnect.fields.sentence',
'ankiConnect.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping',
'ankiConnect.isSenren.fieldGrouping',
'ankiConnect.lapisKiku.wordCardKind',
] as const;
export function getConfigHotReloadField(path: string): string | null {
for (const root of HOT_RELOAD_ROOTS) {
if (pathStartsWith(path, root)) {
return root;
}
}
for (const hotPath of HOT_RELOAD_EXACT_OR_PREFIX_PATHS) {
if (pathStartsWith(path, hotPath)) {
return hotPath;
}
}
// These consumers read the current config when the next operation starts.
if (
['jimaku', 'tmdb', 'subsync', 'notifications', 'subtitleGeneration'].some((root) =>
pathStartsWith(path, root),
)
) {
return path;
}
return null;
}
+117
View File
@@ -32,6 +32,101 @@ test('media timing review is disabled by default and accepts a boolean override'
assert.deepEqual(enabledContext.warnings, []); assert.deepEqual(enabledContext.warnings, []);
}); });
test('invalid direct and field-grouping Anki values warn and keep defaults', () => {
const { context, warnings } = makeContext({
enabled: 'true',
url: 8765,
pollingRate: '3000',
deck: ['Mining'],
isKiku: {
enabled: 1,
fieldGrouping: 'sometimes',
deleteDuplicateInAuto: 'false',
},
isSenren: {
enabled: 'true',
fieldGrouping: false,
deleteDuplicateInAuto: 0,
},
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.enabled, DEFAULT_CONFIG.ankiConnect.enabled);
assert.equal(context.resolved.ankiConnect.url, DEFAULT_CONFIG.ankiConnect.url);
assert.equal(context.resolved.ankiConnect.pollingRate, DEFAULT_CONFIG.ankiConnect.pollingRate);
assert.equal(context.resolved.ankiConnect.deck, DEFAULT_CONFIG.ankiConnect.deck);
assert.deepEqual(context.resolved.ankiConnect.isKiku, DEFAULT_CONFIG.ankiConnect.isKiku);
assert.deepEqual(context.resolved.ankiConnect.isSenren, DEFAULT_CONFIG.ankiConnect.isSenren);
assert.deepEqual(
warnings.map((warning) => warning.path),
[
'ankiConnect.enabled',
'ankiConnect.url',
'ankiConnect.pollingRate',
'ankiConnect.deck',
'ankiConnect.isKiku.enabled',
'ankiConnect.isKiku.deleteDuplicateInAuto',
'ankiConnect.isKiku.fieldGrouping',
'ankiConnect.isSenren.enabled',
'ankiConnect.isSenren.deleteDuplicateInAuto',
'ankiConnect.isSenren.fieldGrouping',
],
);
});
test('accepts valid direct and field-grouping Anki values', () => {
const { context, warnings } = makeContext({
enabled: false,
url: 'http://127.0.0.1:9876',
pollingRate: 750,
deck: 'Mining',
isKiku: {
enabled: true,
fieldGrouping: 'manual',
deleteDuplicateInAuto: false,
},
isSenren: {
enabled: false,
fieldGrouping: 'disabled',
deleteDuplicateInAuto: false,
},
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.enabled, false);
assert.equal(context.resolved.ankiConnect.url, 'http://127.0.0.1:9876');
assert.equal(context.resolved.ankiConnect.pollingRate, 750);
assert.equal(context.resolved.ankiConnect.deck, 'Mining');
assert.deepEqual(context.resolved.ankiConnect.isKiku, {
enabled: true,
fieldGrouping: 'manual',
deleteDuplicateInAuto: false,
});
assert.deepEqual(context.resolved.ankiConnect.isSenren, {
enabled: false,
fieldGrouping: 'disabled',
deleteDuplicateInAuto: false,
});
assert.deepEqual(warnings, []);
});
test('ignores unknown Anki keys without warning or admitting them to resolved config', () => {
const { context, warnings } = makeContext({
futureOption: { enabled: true },
isKiku: { futureGroupingOption: 'future' },
isSenren: { futureGroupingOption: 'future' },
});
applyAnkiConnectResolution(context);
assert.equal(Object.hasOwn(context.resolved.ankiConnect, 'futureOption'), false);
assert.equal(Object.hasOwn(context.resolved.ankiConnect.isKiku, 'futureGroupingOption'), false);
assert.equal(Object.hasOwn(context.resolved.ankiConnect.isSenren, 'futureGroupingOption'), false);
assert.deepEqual(warnings, []);
});
test('modern media duration accepts zero as the disabled cap sentinel', () => { test('modern media duration accepts zero as the disabled cap sentinel', () => {
const disabledCap = makeContext({ media: { maxMediaDuration: 0 } }); const disabledCap = makeContext({ media: { maxMediaDuration: 0 } });
applyAnkiConnectResolution(disabledCap.context); applyAnkiConnectResolution(disabledCap.context);
@@ -290,6 +385,28 @@ test('accepts ankiConnect.media.syncAnimatedImageToWordAudio override', () => {
); );
}); });
test('word audio mapping defaults and validates independently of sentence audio', () => {
for (const wordAudio of [undefined, 'Pronunciation', 7]) {
const { context, warnings } = makeContext({
fields: {
audio: 'SentenceAudio',
...(wordAudio !== undefined ? { wordAudio } : {}),
},
});
applyAnkiConnectResolution(context);
assert.equal(context.resolved.ankiConnect.fields.audio, 'SentenceAudio');
assert.equal(
context.resolved.ankiConnect.fields.wordAudio,
typeof wordAudio === 'string' ? wordAudio : DEFAULT_CONFIG.ankiConnect.fields.wordAudio,
);
assert.deepEqual(
warnings.map((warning) => warning.path),
typeof wordAudio === 'number' ? ['ankiConnect.fields.wordAudio'] : [],
);
}
});
test('invalid modern Anki subtrees warn and keep resolved defaults', () => { test('invalid modern Anki subtrees warn and keep resolved defaults', () => {
const { context, warnings } = makeContext({ const { context, warnings } = makeContext({
fields: { word: 7 }, fields: { word: 7 },
Loaded 100 of 285 files, more files were not shown because too many files have changed in this diff. Show more