mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 01:01:34 -07:00
Compare commits
5 Commits
main
..
c1db917210
| Author | SHA1 | Date | |
|---|---|---|---|
|
c1db917210
|
|||
|
f0e1cd6548
|
|||
|
a173b108d1
|
|||
|
a39ca2cbac
|
|||
|
8a8a700ccb
|
@@ -66,7 +66,7 @@ Local stats dashboard tracking watch time, vocabulary growth, mining throughput,
|
|||||||
Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback.
|
Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs-site/public/screenshots/playlist-browser.png" width="800" alt="Playlist browser modal showing sibling episode files beside the active mpv queue">
|
<img src="docs-site/public/screenshots/playlist-browser.png" width="800" alt="Stats dashboard showing watch time, cards mined, streaks, and tracking data">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
@@ -90,14 +90,6 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
|||||||
<td><b>Jimaku</b></td>
|
<td><b>Jimaku</b></td>
|
||||||
<td>Search and download Japanese subtitles</td>
|
<td>Search and download Japanese subtitles</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td><b>TsukiHime</b></td>
|
|
||||||
<td>Search and download subtitles extracted from anime releases, with Japanese and secondary-language tabs (<code>Ctrl+Shift+T</code>) — no API key, requires <code>xz</code> on your <code>PATH</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><b>AniSkip</b></td>
|
|
||||||
<td>Automatic intro detection with chapter markers and a one-key skip (<code>TAB</code> by default)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>alass / ffsubsync</b></td>
|
<td><b>alass / ffsubsync</b></td>
|
||||||
<td>Manual subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td>
|
<td>Manual subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td>
|
||||||
@@ -118,19 +110,18 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
Only **mpv** is required to run SubMiner. Anki + AnkiConnect are required to mine cards, which is the point of the app, but everything else is optional.
|
Only **mpv** and Anki+AnkiConnect are required. Everything else is optional but enhances the experience.
|
||||||
|
|
||||||
| Dependency | Status | What it does |
|
| Dependency | Status | What it does |
|
||||||
| -------------------- | ---------------- | -------------------------------------------------------- |
|
| -------------------- | ----------- | ---------------------------------------- |
|
||||||
| mpv | Required | The video player SubMiner overlays on |
|
| mpv | Required | The video player SubMiner overlays on |
|
||||||
| Anki + AnkiConnect | Required to mine | Card creation from the Yomitan popup |
|
| Anki + AnkiConnect | Required | Card creation from the Yomitan popup |
|
||||||
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
|
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
|
||||||
| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
|
| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
|
||||||
| yt-dlp | Optional | YouTube playback |
|
| yt-dlp | Optional | YouTube playback |
|
||||||
| xz | Optional | TsukiHime subtitle downloads (not on Windows by default) |
|
| fzf / rofi | Optional | Video picker in the launcher |
|
||||||
| alass / ffsubsync | Optional | Subtitle sync |
|
| alass / ffsubsync | Optional | Subtitle sync |
|
||||||
| guessit | Optional | Better anime title and episode detection |
|
| guessit | Optional | Better anime title and episode detection |
|
||||||
| fzf / rofi | Optional | Video picker in the `subminer` launcher (Linux/macOS) |
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Platform-specific install commands</b></summary>
|
<summary><b>Platform-specific install commands</b></summary>
|
||||||
@@ -147,23 +138,9 @@ sudo pacman -S --needed mpv ffmpeg mecab mecab-ipadic
|
|||||||
brew install mpv ffmpeg mecab mecab-ipadic
|
brew install mpv ffmpeg mecab mecab-ipadic
|
||||||
```
|
```
|
||||||
|
|
||||||
**Windows:**
|
**Windows:** Install [mpv](https://mpv.io/installation/) and [ffmpeg](https://ffmpeg.org/download.html) and ensure both are on `PATH`.
|
||||||
|
|
||||||
```powershell
|
See the [full requirements list](https://docs.subminer.moe/installation#1-install-requirements) for optional dependencies.
|
||||||
winget install shinchiro.mpv
|
|
||||||
winget install Gyan.FFmpeg
|
|
||||||
```
|
|
||||||
|
|
||||||
Then reopen your terminal and check `mpv --version` and `ffmpeg -version`. winget puts `ffmpeg` on `PATH` automatically; mpv uses a regular installer that may not, so if `mpv` is not found, either add its folder (usually `%LOCALAPPDATA%\Programs\mpv`) to `PATH` or set `mpv.executablePath` during first-run setup.
|
|
||||||
|
|
||||||
[Scoop](https://scoop.sh) is the alternative if you want one package manager for everything, since it is the only one that also carries `xz`:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
scoop bucket add extras
|
|
||||||
scoop install extras/mpv main/ffmpeg main/yt-dlp main/xz
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [full requirements list](https://docs.subminer.moe/installation#_1-install-requirements) for optional dependencies.
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -189,11 +166,6 @@ paru -S subminer-bin
|
|||||||
mkdir -p ~/.local/bin
|
mkdir -p ~/.local/bin
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
|
||||||
&& chmod +x ~/.local/bin/SubMiner.AppImage
|
&& chmod +x ~/.local/bin/SubMiner.AppImage
|
||||||
```
|
|
||||||
|
|
||||||
The AppImage is all you need. The optional `subminer` command-line launcher runs on [Bun](https://bun.sh), and first-run setup can install both for you. To grab it manually instead, install Bun first, then:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
|
||||||
&& chmod +x ~/.local/bin/subminer
|
&& chmod +x ~/.local/bin/subminer
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"@xhayper/discord-rpc": "^1.3.4",
|
"@xhayper/discord-rpc": "^1.3.4",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"electron-updater": "^6.8.9",
|
"electron-updater": "^6.8.3",
|
||||||
"hono": "^4.12.28",
|
"hono": "^4.12.28",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"koffi": "^2.15.6",
|
"koffi": "^2.15.6",
|
||||||
@@ -21,9 +21,9 @@
|
|||||||
"@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.8.2",
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.4.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"undici": "7.28.0",
|
"undici": "7.28.0",
|
||||||
@@ -35,19 +35,23 @@
|
|||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@xmldom/xmldom": "0.8.13",
|
"@xmldom/xmldom": "0.8.13",
|
||||||
"app-builder-lib": "26.15.3",
|
"app-builder-lib": "26.8.2",
|
||||||
"brace-expansion": "5.0.8",
|
"brace-expansion": "5.0.7",
|
||||||
"electron-builder-squirrel-windows": "26.15.3",
|
"electron-builder-squirrel-windows": "26.8.2",
|
||||||
"form-data": "4.0.6",
|
"form-data": "4.0.6",
|
||||||
"ip-address": "10.2.0",
|
"ip-address": "10.2.0",
|
||||||
"js-yaml": "4.3.0",
|
"js-yaml": "4.3.0",
|
||||||
"lodash": "4.18.0",
|
"lodash": "4.18.0",
|
||||||
"minimatch": "10.2.5",
|
"minimatch": "10.2.3",
|
||||||
"picomatch": "4.0.4",
|
"picomatch": "4.0.4",
|
||||||
"tar": "7.5.21",
|
"tar": "7.5.21",
|
||||||
"tmp": "0.2.7",
|
"tmp": "0.2.7",
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
"7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="],
|
||||||
|
|
||||||
|
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
|
||||||
|
|
||||||
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||||
|
|
||||||
"@discordjs/rest": ["@discordjs/rest@2.6.1", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.40", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "6.27.0" } }, "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg=="],
|
"@discordjs/rest": ["@discordjs/rest@2.6.1", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.40", "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", "undici": "6.27.0" } }, "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg=="],
|
||||||
@@ -66,7 +70,7 @@
|
|||||||
|
|
||||||
"@electron/osx-sign": ["@electron/osx-sign@1.3.3", "", { "dependencies": { "compare-version": "^0.1.2", "debug": "^4.3.4", "fs-extra": "^10.0.0", "isbinaryfile": "^4.0.8", "minimist": "^1.2.6", "plist": "^3.0.5" }, "bin": { "electron-osx-flat": "bin/electron-osx-flat.js", "electron-osx-sign": "bin/electron-osx-sign.js" } }, "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg=="],
|
"@electron/osx-sign": ["@electron/osx-sign@1.3.3", "", { "dependencies": { "compare-version": "^0.1.2", "debug": "^4.3.4", "fs-extra": "^10.0.0", "isbinaryfile": "^4.0.8", "minimist": "^1.2.6", "plist": "^3.0.5" }, "bin": { "electron-osx-flat": "bin/electron-osx-flat.js", "electron-osx-sign": "bin/electron-osx-sign.js" } }, "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg=="],
|
||||||
|
|
||||||
"@electron/rebuild": ["@electron/rebuild@4.2.0", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^12.2.0", "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ=="],
|
"@electron/rebuild": ["@electron/rebuild@4.0.3", "", { "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.1.1", "detect-libc": "^2.0.1", "got": "^11.7.0", "graceful-fs": "^4.2.11", "node-abi": "^4.2.0", "node-api-version": "^0.2.1", "node-gyp": "^11.2.0", "ora": "^5.1.0", "read-binary-file-arch": "^1.0.6", "semver": "^7.3.5", "tar": "^7.5.6", "yargs": "^17.0.1" }, "bin": { "electron-rebuild": "lib/cli.js" } }, "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA=="],
|
||||||
|
|
||||||
"@electron/universal": ["@electron/universal@2.0.3", "", { "dependencies": { "@electron/asar": "^3.3.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "dir-compare": "^4.2.0", "fs-extra": "^11.1.1", "minimatch": "^9.0.3", "plist": "^3.1.0" } }, "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g=="],
|
"@electron/universal": ["@electron/universal@2.0.3", "", { "dependencies": { "@electron/asar": "^3.3.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "dir-compare": "^4.2.0", "fs-extra": "^11.1.1", "minimatch": "^9.0.3", "plist": "^3.1.0" } }, "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g=="],
|
||||||
|
|
||||||
@@ -130,13 +134,13 @@
|
|||||||
|
|
||||||
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
|
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
|
||||||
|
|
||||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="],
|
"@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="],
|
||||||
|
|
||||||
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
|
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
|
||||||
|
|
||||||
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
|
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
|
||||||
|
|
||||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
|
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
|
||||||
|
|
||||||
"@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="],
|
"@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="],
|
||||||
|
|
||||||
@@ -152,6 +156,8 @@
|
|||||||
|
|
||||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
|
||||||
|
|
||||||
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||||
|
|
||||||
"@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.5.28", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Lc/b8JXO2W2+H+5UXfw7PCHZCim1jlrB0CmLPsjfVmihMluBpdYafFImhjAHxHlWGfuZ32WzjVPUap5fGmkthw=="],
|
"@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.5.28", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Lc/b8JXO2W2+H+5UXfw7PCHZCim1jlrB0CmLPsjfVmihMluBpdYafFImhjAHxHlWGfuZ32WzjVPUap5fGmkthw=="],
|
||||||
@@ -178,15 +184,11 @@
|
|||||||
|
|
||||||
"@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=="],
|
"@npmcli/agent": ["@npmcli/agent@3.0.0", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q=="],
|
||||||
|
|
||||||
"@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=="],
|
"@npmcli/fs": ["@npmcli/fs@4.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q=="],
|
||||||
|
|
||||||
"@peculiar/json-schema": ["@peculiar/json-schema@1.1.12", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w=="],
|
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
|
||||||
|
|
||||||
"@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="],
|
|
||||||
|
|
||||||
"@peculiar/webcrypto": ["@peculiar/webcrypto@1.7.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "tslib": "^2.8.1", "webcrypto-core": "^1.9.2" } }, "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ=="],
|
|
||||||
|
|
||||||
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
|
"@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
|
||||||
|
|
||||||
@@ -216,8 +218,12 @@
|
|||||||
|
|
||||||
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
|
"@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
|
||||||
|
|
||||||
|
"@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="],
|
||||||
|
|
||||||
"@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
|
"@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
|
||||||
|
|
||||||
|
"@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="],
|
||||||
|
|
||||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||||
|
|
||||||
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
|
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
|
||||||
@@ -226,7 +232,7 @@
|
|||||||
|
|
||||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
|
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
|
||||||
|
|
||||||
"abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
|
"abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
|
||||||
|
|
||||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||||
|
|
||||||
@@ -236,15 +242,21 @@
|
|||||||
|
|
||||||
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||||
|
|
||||||
|
"ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="],
|
||||||
|
|
||||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
"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-bin": ["app-builder-bin@5.0.0-alpha.12", "", {}, "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w=="],
|
||||||
|
|
||||||
|
"app-builder-lib": ["app-builder-lib@26.8.2", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@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.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "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.8.1", "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.0.3", "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", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.2", "electron-builder-squirrel-windows": "26.8.2" } }, "sha512-z3ptLzJwNl35fyR0wxv4qWOfZuU36VysYHnbs8PDtf8S0QzIl2OWimdDFVmCxYMkIV1k/RT9CeTgcP7oUznFOw=="],
|
||||||
|
|
||||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
"asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="],
|
"assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
|
||||||
|
|
||||||
|
"astral-regex": ["astral-regex@2.0.0", "", {}, "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ=="],
|
||||||
|
|
||||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||||
|
|
||||||
@@ -254,27 +266,27 @@
|
|||||||
|
|
||||||
"at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="],
|
"at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="],
|
||||||
|
|
||||||
"aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="],
|
|
||||||
|
|
||||||
"axios": ["axios@1.18.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g=="],
|
"axios": ["axios@1.18.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g=="],
|
||||||
|
|
||||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
|
|
||||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
|
|
||||||
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
|
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||||
|
|
||||||
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
||||||
|
|
||||||
"brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="],
|
"brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
|
||||||
|
|
||||||
|
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
||||||
|
|
||||||
"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.8.1", "", { "dependencies": { "7zip-bin": "~5.2.0", "@types/debug": "^4.1.6", "app-builder-bin": "5.0.0-alpha.12", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0" } }, "sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw=="],
|
||||||
|
|
||||||
"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.5.1", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ=="],
|
||||||
|
|
||||||
"bytestreamjs": ["bytestreamjs@2.0.1", "", {}, "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ=="],
|
"cacache": ["cacache@19.0.1", "", { "dependencies": { "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^12.0.0", "tar": "^7.4.3", "unique-filename": "^4.0.0" } }, "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ=="],
|
||||||
|
|
||||||
"cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="],
|
"cacheable-lookup": ["cacheable-lookup@5.0.4", "", {}, "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="],
|
||||||
|
|
||||||
@@ -290,8 +302,16 @@
|
|||||||
|
|
||||||
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
|
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
|
||||||
|
|
||||||
|
"cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="],
|
||||||
|
|
||||||
|
"cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="],
|
||||||
|
|
||||||
|
"cli-truncate": ["cli-truncate@2.1.0", "", { "dependencies": { "slice-ansi": "^3.0.0", "string-width": "^4.2.0" } }, "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg=="],
|
||||||
|
|
||||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||||
|
|
||||||
|
"clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
|
||||||
|
|
||||||
"clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="],
|
"clone-response": ["clone-response@1.0.3", "", { "dependencies": { "mimic-response": "^1.0.0" } }, "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA=="],
|
||||||
|
|
||||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
@@ -306,6 +326,8 @@
|
|||||||
|
|
||||||
"core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
|
"core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="],
|
||||||
|
|
||||||
|
"crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="],
|
||||||
|
|
||||||
"cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="],
|
"cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="],
|
||||||
|
|
||||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
@@ -316,6 +338,8 @@
|
|||||||
|
|
||||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||||
|
|
||||||
|
"defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="],
|
||||||
|
|
||||||
"defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
|
"defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
|
||||||
|
|
||||||
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||||
@@ -332,7 +356,9 @@
|
|||||||
|
|
||||||
"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.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" }, "optionalDependencies": { "dmg-license": "^1.0.11" } }, "sha512-DaWI+p4DOqiFVZFMovdGYammBOyJAiHHFWUTQ0Z7gNc0twfdIN0LvyJ+vFsgZEDR1fjgbpCj690IVtbYIsZObQ=="],
|
||||||
|
|
||||||
|
"dmg-license": ["dmg-license@1.0.11", "", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="],
|
||||||
|
|
||||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||||
|
|
||||||
@@ -340,24 +366,26 @@
|
|||||||
|
|
||||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||||
|
|
||||||
"duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA=="],
|
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||||
|
|
||||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||||
|
|
||||||
"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.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.2", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-ieiiXPdgH3qrG6lcvy2mtnI5iEmAopmLuVRMSJ5j40weU0tgpNx0OAk9J5X5nnO0j9+KIkxHzwFZVUDk1U3aGw=="],
|
||||||
|
|
||||||
"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.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, "sha512-kXhajX6DzdIQcTlctVTKoG1oO39JhWcTG0lH7ZEJ4FzPaKJy7KFNfNJUd5BoEmLjv5GlrRZpEOYnniD+LcwNJA=="],
|
||||||
|
|
||||||
"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.8.1", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-q+jrSTIh/Cv4eGZa7oVR+grEJo/FoLMYBAnSL5GCtqwUpr1T+VgKB/dn1pnzxIxqD8S/jP1yilT9VrwCqINR4w=="],
|
||||||
|
|
||||||
"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.3", "", { "dependencies": { "builder-util-runtime": "9.5.1", "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-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ=="],
|
||||||
|
|
||||||
"electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="],
|
"electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="],
|
||||||
|
|
||||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
|
|
||||||
|
"encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="],
|
||||||
|
|
||||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||||
|
|
||||||
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||||
@@ -380,7 +408,7 @@
|
|||||||
|
|
||||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||||
|
|
||||||
"eslint": ["eslint@10.8.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ=="],
|
"eslint": ["eslint@10.4.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="],
|
||||||
|
|
||||||
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
|
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
|
||||||
|
|
||||||
@@ -398,14 +426,14 @@
|
|||||||
|
|
||||||
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
|
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
|
||||||
|
|
||||||
|
"extsprintf": ["extsprintf@1.4.1", "", {}, "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA=="],
|
||||||
|
|
||||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
||||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||||
|
|
||||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||||
|
|
||||||
"fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="],
|
|
||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||||
@@ -420,10 +448,14 @@
|
|||||||
|
|
||||||
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
|
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
|
||||||
|
|
||||||
|
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
|
||||||
|
|
||||||
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
|
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
|
||||||
|
|
||||||
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
|
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
|
||||||
|
|
||||||
|
"fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="],
|
||||||
|
|
||||||
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
@@ -472,6 +504,12 @@
|
|||||||
|
|
||||||
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
|
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
|
||||||
|
|
||||||
|
"iconv-corefoundation": ["iconv-corefoundation@1.1.7", "", { "dependencies": { "cli-truncate": "^2.1.0", "node-addon-api": "^1.6.3" }, "os": "darwin" }, "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ=="],
|
||||||
|
|
||||||
|
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||||
|
|
||||||
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
|
|
||||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||||
@@ -480,18 +518,24 @@
|
|||||||
|
|
||||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
|
"ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
|
||||||
|
|
||||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
|
|
||||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||||
|
|
||||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||||
|
|
||||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
"is-interactive": ["is-interactive@1.0.0", "", {}, "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="],
|
||||||
|
|
||||||
|
"is-unicode-supported": ["is-unicode-supported@0.1.0", "", {}, "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="],
|
||||||
|
|
||||||
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
|
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
|
||||||
|
|
||||||
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
||||||
|
|
||||||
|
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||||
|
|
||||||
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
|
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
|
||||||
|
|
||||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||||
@@ -530,12 +574,16 @@
|
|||||||
|
|
||||||
"lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
|
"lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="],
|
||||||
|
|
||||||
|
"log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="],
|
||||||
|
|
||||||
"lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
|
"lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
|
||||||
|
|
||||||
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||||
|
|
||||||
"magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="],
|
"magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="],
|
||||||
|
|
||||||
|
"make-fetch-happen": ["make-fetch-happen@14.0.3", "", { "dependencies": { "@npmcli/agent": "^3.0.0", "cacache": "^19.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", "minipass-fetch": "^4.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", "proc-log": "^5.0.0", "promise-retry": "^2.0.1", "ssri": "^12.0.0" } }, "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ=="],
|
||||||
|
|
||||||
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
|
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
|
||||||
|
|
||||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
@@ -546,14 +594,26 @@
|
|||||||
|
|
||||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
|
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||||
|
|
||||||
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||||
|
|
||||||
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
"minimatch": ["minimatch@10.2.3", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg=="],
|
||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
|
|
||||||
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||||
|
|
||||||
|
"minipass-collect": ["minipass-collect@2.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw=="],
|
||||||
|
|
||||||
|
"minipass-fetch": ["minipass-fetch@4.0.1", "", { "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", "minizlib": "^3.0.1" }, "optionalDependencies": { "encoding": "^0.1.13" } }, "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ=="],
|
||||||
|
|
||||||
|
"minipass-flush": ["minipass-flush@1.0.5", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw=="],
|
||||||
|
|
||||||
|
"minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="],
|
||||||
|
|
||||||
|
"minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="],
|
||||||
|
|
||||||
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
|
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
|
||||||
|
|
||||||
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
|
||||||
@@ -562,15 +622,17 @@
|
|||||||
|
|
||||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||||
|
|
||||||
|
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||||
|
|
||||||
"node-abi": ["node-abi@4.28.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g=="],
|
"node-abi": ["node-abi@4.28.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g=="],
|
||||||
|
|
||||||
|
"node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
|
||||||
|
|
||||||
"node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="],
|
"node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="],
|
||||||
|
|
||||||
"node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="],
|
"node-gyp": ["node-gyp@11.5.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "make-fetch-happen": "^14.0.3", "nopt": "^8.0.0", "proc-log": "^5.0.0", "semver": "^7.3.5", "tar": "^7.4.3", "tinyglobby": "^0.2.12", "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ=="],
|
||||||
|
|
||||||
"node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
|
"nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],
|
||||||
|
|
||||||
"nopt": ["nopt@9.0.0", "", { "dependencies": { "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw=="],
|
|
||||||
|
|
||||||
"normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="],
|
"normalize-url": ["normalize-url@6.1.0", "", {}, "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="],
|
||||||
|
|
||||||
@@ -578,28 +640,36 @@
|
|||||||
|
|
||||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
|
|
||||||
|
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||||
|
|
||||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||||
|
|
||||||
|
"ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="],
|
||||||
|
|
||||||
"p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
|
"p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
|
||||||
|
|
||||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||||
|
|
||||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||||
|
|
||||||
|
"p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
|
||||||
|
|
||||||
|
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||||
|
|
||||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||||
|
|
||||||
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
||||||
|
|
||||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
|
|
||||||
|
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||||
|
|
||||||
"pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="],
|
"pe-library": ["pe-library@0.4.1", "", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="],
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||||
|
|
||||||
"pkijs": ["pkijs@3.4.0", "", { "dependencies": { "@noble/hashes": "1.4.0", "asn1js": "^3.0.6", "bytestreamjs": "^2.0.1", "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw=="],
|
|
||||||
|
|
||||||
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
|
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
|
||||||
|
|
||||||
"postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="],
|
"postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="],
|
||||||
@@ -608,9 +678,7 @@
|
|||||||
|
|
||||||
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
||||||
|
|
||||||
"proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="],
|
"proc-log": ["proc-log@5.0.0", "", {}, "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ=="],
|
||||||
|
|
||||||
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
|
|
||||||
|
|
||||||
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
|
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
|
||||||
|
|
||||||
@@ -624,33 +692,31 @@
|
|||||||
|
|
||||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
|
|
||||||
|
|
||||||
"pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
|
|
||||||
|
|
||||||
"quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],
|
"quick-lru": ["quick-lru@5.1.1", "", {}, "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="],
|
||||||
|
|
||||||
"read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="],
|
"read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="],
|
||||||
|
|
||||||
"readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
|
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||||
|
|
||||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||||
|
|
||||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
|
||||||
|
|
||||||
"resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="],
|
"resedit": ["resedit@1.7.2", "", { "dependencies": { "pe-library": "^0.4.1" } }, "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA=="],
|
||||||
|
|
||||||
"resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="],
|
"resolve-alpn": ["resolve-alpn@1.2.1", "", {}, "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="],
|
||||||
|
|
||||||
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
|
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
|
||||||
|
|
||||||
|
"restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
|
||||||
|
|
||||||
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
|
||||||
|
|
||||||
"rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
|
"rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="],
|
||||||
|
|
||||||
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
|
"roarr": ["roarr@2.15.4", "", { "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", "globalthis": "^1.0.1", "json-stringify-safe": "^5.0.1", "semver-compare": "^1.0.0", "sprintf-js": "^1.1.2" } }, "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A=="],
|
||||||
|
|
||||||
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
|
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
|
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
|
|
||||||
"sanitize-filename": ["sanitize-filename@1.6.4", "", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg=="],
|
"sanitize-filename": ["sanitize-filename@1.6.4", "", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg=="],
|
||||||
|
|
||||||
@@ -670,20 +736,34 @@
|
|||||||
|
|
||||||
"simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="],
|
"simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="],
|
||||||
|
|
||||||
|
"slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
|
||||||
|
|
||||||
|
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
|
||||||
|
|
||||||
|
"socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="],
|
||||||
|
|
||||||
|
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
|
||||||
|
|
||||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
||||||
|
|
||||||
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
|
"sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
|
||||||
|
|
||||||
|
"ssri": ["ssri@12.0.0", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ=="],
|
||||||
|
|
||||||
"stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="],
|
"stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="],
|
||||||
|
|
||||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
"string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
|
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||||
|
|
||||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
|
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
"sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="],
|
"sumchecker": ["sumchecker@3.0.1", "", { "dependencies": { "debug": "^4.1.0" } }, "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg=="],
|
||||||
|
|
||||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||||
@@ -718,9 +798,11 @@
|
|||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
"unique-filename": ["unique-filename@4.0.0", "", { "dependencies": { "unique-slug": "^5.0.0" } }, "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ=="],
|
||||||
|
|
||||||
"unzipper": ["unzipper@0.12.5", "", { "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", "fs-extra": "11.3.1", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } }, "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A=="],
|
"unique-slug": ["unique-slug@5.0.0", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg=="],
|
||||||
|
|
||||||
|
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
||||||
|
|
||||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
|
|
||||||
@@ -728,7 +810,9 @@
|
|||||||
|
|
||||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
"webcrypto-core": ["webcrypto-core@1.9.2", "", { "dependencies": { "@peculiar/asn1-schema": "^2.7.0", "@peculiar/json-schema": "^1.1.12", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q=="],
|
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
|
||||||
|
|
||||||
|
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
|
||||||
|
|
||||||
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
|
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
|
||||||
|
|
||||||
@@ -736,6 +820,8 @@
|
|||||||
|
|
||||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||||
|
|
||||||
|
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||||
|
|
||||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||||
@@ -772,26 +858,42 @@
|
|||||||
|
|
||||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||||
|
|
||||||
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
|
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
|
||||||
|
|
||||||
|
"@npmcli/agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
|
|
||||||
|
"@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||||
|
|
||||||
|
"@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||||
|
|
||||||
"@types/cacheable-request/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
"@types/cacheable-request/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||||
|
|
||||||
"@types/fs-extra/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
"@types/fs-extra/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||||
|
|
||||||
"@types/keyv/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
"@types/keyv/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||||
|
|
||||||
|
"@types/plist/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||||
|
|
||||||
"@types/responselike/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
"@types/responselike/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||||
|
|
||||||
"@types/ws/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
"@types/ws/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||||
|
|
||||||
"app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
|
"app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
|
||||||
|
|
||||||
"app-builder-lib/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
|
||||||
|
|
||||||
"app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
|
"app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
|
||||||
|
|
||||||
"builder-util/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
"builder-util/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||||
|
|
||||||
|
"cacache/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||||
|
|
||||||
|
"cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||||
|
|
||||||
"clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
|
"clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
|
||||||
|
|
||||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
@@ -800,25 +902,35 @@
|
|||||||
|
|
||||||
"es-set-tostringtag/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
"es-set-tostringtag/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||||
|
|
||||||
|
"foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||||
|
|
||||||
"get-intrinsic/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
"get-intrinsic/hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||||
|
|
||||||
"http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
"http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
|
|
||||||
"lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
"lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||||
|
|
||||||
|
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||||
|
|
||||||
|
"minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||||
|
|
||||||
|
"minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
|
||||||
|
|
||||||
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||||
|
|
||||||
"node-gyp/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
|
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||||
|
|
||||||
"node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
|
|
||||||
|
|
||||||
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
|
|
||||||
|
|
||||||
"postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
|
"postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="],
|
||||||
|
|
||||||
|
"socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
|
|
||||||
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
|
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
|
||||||
|
|
||||||
"unzipper/fs-extra": ["fs-extra@11.3.1", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g=="],
|
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
|
|
||||||
|
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||||
|
|
||||||
"@types/cacheable-request/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"@types/cacheable-request/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
@@ -826,6 +938,8 @@
|
|||||||
|
|
||||||
"@types/keyv/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"@types/keyv/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
|
"@types/plist/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
"@types/responselike/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"@types/responselike/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
|
|
||||||
"@types/ws/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
"@types/ws/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||||
@@ -836,8 +950,6 @@
|
|||||||
|
|
||||||
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
"app-builder-lib/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
|
||||||
|
|
||||||
"builder-util/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
"builder-util/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||||
|
|
||||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
@@ -846,7 +958,11 @@
|
|||||||
|
|
||||||
"electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
"electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||||
|
|
||||||
"node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||||
|
|
||||||
|
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||||
|
|
||||||
|
"minipass-sized/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||||
|
|
||||||
"app-builder-lib/@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
"app-builder-lib/@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
type: added
|
type: added
|
||||||
area: overlay
|
area: overlay
|
||||||
|
|
||||||
- Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature) like asbplayer. Enable with `ankiConnect.knownWords.maturityEnabled`; the mature interval threshold (`matureThresholdDays`, default 21) and the four tier colors (`subtitleStyle.knownWordMaturityColors`) are configurable, and a runtime option toggles it in-session. The session help color legend shows the four tier colors while maturity highlighting is on.
|
- Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature) like asbplayer. Enable with `ankiConnect.knownWords.maturityEnabled`; the mature interval threshold (`matureThresholdDays`, default 21) and the four tier colors (`subtitleStyle.knownWordMaturityColors`) are configurable, and a runtime option toggles it in-session.
|
||||||
- Tiers follow Anki's own card counts: the interval tiers exclude cards in the learning/relearning queue, so a lapsed card shows the learning color instead of young (its interval is reset to at least 1 day, which previously made the learning tier unreachable). A note with a mature card alongside a relearning card still shows mature.
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: anilist
|
|
||||||
|
|
||||||
- Season 2 and later files now resolve to the right AniList entry. AniList has no numbered seasons (sequels are separate entries titled `Zoku`, `Kan`, `2nd Season`), so the old `<title> Season N` search returned nothing and silently fell back to the season 1 entry: an Oregairu S03 episode loaded the season 1 character dictionary and pushed watch progress onto season 1. SubMiner now searches the base title and walks AniList `SEQUEL` relations to reach the requested season, ordering the franchise's TV entries by air date when the relation chain is incomplete.
|
|
||||||
- When the season still cannot be located, SubMiner no longer writes progress to the season 1 entry; it skips the update and points at the manual AniList override instead. The character dictionary logs the same warning and states which entry it fell back to.
|
|
||||||
- Character dictionary overrides are now scoped by detected season as well as directory, so seasons kept in one flat folder no longer share (or overwrite) each other's override, and an override now also pins the entry used for AniList watch progress: correcting a wrong match once fixes both the dictionary and progress tracking.
|
|
||||||
- Cover art lookup uses the same season-aware resolution, so per-season art no longer falls back to the season 1 cover.
|
|
||||||
- An unresolvable season is no longer cached as if it were a normal match, so the fallback cannot silently stick for every later episode of that series.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: character dictionary
|
|
||||||
|
|
||||||
- Kept manual AniList overrides active across episodes in the same season directory when filename guesses differ.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: stats
|
|
||||||
|
|
||||||
- Stats reported 0 known words for every session after the known-word cache gained maturity tiers. The stats server carried its own copy of the cache parser that only recognized versions up to 3, so the new v4 file was read as "no cache" rather than as a format it should understand.
|
|
||||||
- The cache format, its parser, and the derived known-word set now live in one module that both the cache manager and the stats server read, and the version dispatch ends in an exhaustive check so a future format bump fails the build instead of silently reporting zero. A cache that exists but does not parse now logs a warning rather than passing for an empty one.
|
|
||||||
@@ -2,4 +2,3 @@ type: added
|
|||||||
area: launcher
|
area: launcher
|
||||||
|
|
||||||
- After a watch-history episode ends or mpv closes, the fzf or rofi launcher returns to that series with options to play the previous episode, rewatch, play the next episode, select another episode, or quit SubMiner. Previous and Next continue across season directories.
|
- After a watch-history episode ends or mpv closes, the fzf or rofi launcher returns to that series with options to play the previous episode, rewatch, play the next episode, select another episode, or quit SubMiner. Previous and Next continue across season directories.
|
||||||
- The action menu shown right after picking a series from `subminer -H` now also offers the previous episode, matching the menu shown after playback.
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
type: fixed
|
|
||||||
area: launcher
|
|
||||||
|
|
||||||
- Rofi menu prompts now keep a space between the prompt text and the input field instead of running into the search placeholder.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
type: internal
|
|
||||||
area: tokenizer
|
|
||||||
|
|
||||||
- Added `verify-known-word-highlights:electron` script: tokenizes a real subtitle file through the app's Yomitan/MeCab pipeline with the live known-word cache, prints each line in the configured tier colors, and summarizes the tier counts so highlighting can be checked outside of playback.
|
|
||||||
- Added `--audit`, which re-derives every highlighted tier from live Anki card data (`notesInfo` + `cardsInfo` intervals) and reports each token whose rendered tier disagrees, catching both stale cache entries and tier-classification bugs.
|
|
||||||
- Added `--profile-copy` so the check can run while SubMiner is open (Electron locks the Yomitan userData dir), plus `--refresh`, `--limit`, `--json`, and `--quiet`.
|
|
||||||
- Added `KnownWordCacheManager.getKnownWordMatchNoteIds`, exposing the note ids behind a known-word match so an audit can trace a rendered tier back to the exact Anki notes.
|
|
||||||
@@ -691,7 +691,7 @@
|
|||||||
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||||
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
||||||
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
|
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
|
||||||
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
"socketPath": "\\\\.\\pipe\\subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
||||||
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
|
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
|
||||||
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
|
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
|
||||||
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
|
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const outDir = optionalEnv(process.env.SUBMINER_DOCS_OUT_DIR);
|
|||||||
const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? process.cwd();
|
const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? process.cwd();
|
||||||
const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
|
const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
|
||||||
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
|
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
|
||||||
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.18.0';
|
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.14.0';
|
||||||
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
|
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
|
||||||
const versionLinkOrigin =
|
const versionLinkOrigin =
|
||||||
optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? 'production';
|
optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? 'production';
|
||||||
@@ -306,7 +306,6 @@ const sidebar: DefaultTheme.SidebarItem[] = [
|
|||||||
{ text: 'Usage', link: '/usage' },
|
{ text: 'Usage', link: '/usage' },
|
||||||
{ text: 'Mining Workflow', link: '/mining-workflow' },
|
{ text: 'Mining Workflow', link: '/mining-workflow' },
|
||||||
{ text: 'Launcher Script', link: '/launcher-script' },
|
{ text: 'Launcher Script', link: '/launcher-script' },
|
||||||
{ text: 'Feature Demos', link: '/demos' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ SubMiner monitors playback and triggers an AniList progress update when an episo
|
|||||||
The update flow:
|
The update flow:
|
||||||
|
|
||||||
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename and path. Season folders such as `Season 2` are treated as a strong season signal. SubMiner tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
|
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename and path. Season folders such as `Season 2` are treated as a strong season signal. SubMiner tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
|
||||||
2. **AniList search** -- The base title (with any `Season N` / `SN` marker stripped) is searched against the AniList GraphQL API, and SubMiner picks the best match by comparing titles (romaji, English, native, synonyms) and filtering by episode count. AniList has no notion of numbered seasons -- sequels are separate entries with their own titles (`Zoku`, `Kan`, `2nd Season`), so searching `<title> Season 3` finds nothing. For season 2 and later, SubMiner instead walks `SEQUEL` relations from the season 1 entry, preferring the TV line, and falls back to ordering the franchise's TV entries by air date when the relation chain is incomplete. If neither locates the season, SubMiner **skips the update** rather than writing progress to the season 1 entry, and tells you to pin the right entry with a [character dictionary override](/character-dictionary#correcting-anilist-matches).
|
2. **AniList search** -- The detected title is searched against the AniList GraphQL API. For season 2 and later files, SubMiner searches the season-specific title first, then falls back to the base title. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count.
|
||||||
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
|
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
|
||||||
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`, or `COMPLETED` when the watched episode is the final episode of the season (the "already at this progress" skip is bypassed for the final episode so completion still lands).
|
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`, or `COMPLETED` when the watched episode is the final episode of the season (the "already at this progress" skip is bypassed for the final episode so completion still lands).
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------ |
|
| ------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||||
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
||||||
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
|
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
|
||||||
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
|
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
|
||||||
|
|||||||
@@ -223,9 +223,7 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary
|
|||||||
subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}'
|
subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
SubMiner stores manual selections in `character-dictionaries/anilist-overrides.json`. The episode's parent directory **and detected season** define the override scope, so later episodes in the same season keep the selected AniList ID even if their filename guesses differ, while a different season never inherits the override -- including when every season sits in one flat folder. When you replace a wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
|
Manual selections are stored in `character-dictionaries/anilist-overrides.json` using a series key derived from the episode's parent directory plus the filename guess. Later episodes in the same directory use the selected AniList ID automatically, while separate season directories can keep separate overrides and character dictionaries. When the override replaces a previous wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
|
||||||
|
|
||||||
An override also pins the entry used for [AniList watch progress](/anilist-integration), so correcting a wrong match once fixes both the character dictionary and progress tracking.
|
|
||||||
|
|
||||||
## Managing Loaded Entries
|
## Managing Loaded Entries
|
||||||
|
|
||||||
|
|||||||
@@ -71,10 +71,6 @@ When both files exist, SubMiner prefers `config.jsonc` over `config.json`.
|
|||||||
|
|
||||||
See [config.example.jsonc](/config.example.jsonc) for a comprehensive example with all available options, default values, and detailed comments. Only include the options you want to customize in your config file.
|
See [config.example.jsonc](/config.example.jsonc) for a comprehensive example with all available options, default values, and detailed comments. Only include the options you want to customize in your config file.
|
||||||
|
|
||||||
::: warning One value in that file is platform-specific
|
|
||||||
The example is generated with a fixed Linux/macOS socket path so it stays reproducible, so it shows `"socketPath": "/tmp/subminer-socket"`. On Windows the real default is `\\\\.\\pipe\\subminer-socket`. Leave `mpv.socketPath` out of your config entirely unless you need a custom path, and SubMiner picks the right one for your platform.
|
|
||||||
:::
|
|
||||||
|
|
||||||
Generate a fresh default config from the centralized config registry:
|
Generate a fresh default config from the centralized config registry:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -149,13 +145,12 @@ The configuration file includes several main sections:
|
|||||||
- [**Shared AI Provider**](#shared-ai-provider) - Canonical OpenAI-compatible provider config shared by Anki and YouTube subtitle fixing
|
- [**Shared AI Provider**](#shared-ai-provider) - Canonical OpenAI-compatible provider config shared by Anki and YouTube subtitle fixing
|
||||||
- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media
|
- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media
|
||||||
- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis note types
|
- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis note types
|
||||||
- [**N+1 Word Highlighting**](#n-1-word-highlighting) - Known-word cache and single-target highlighting
|
- [**N+1 Word Highlighting**](#n1-word-highlighting) - Known-word cache and single-target highlighting
|
||||||
- [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Lapis duplicate card merging
|
- [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Lapis duplicate card merging
|
||||||
|
|
||||||
**External Integrations**
|
**External Integrations**
|
||||||
|
|
||||||
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
||||||
- [**TsukiHime**](#tsukihime) - Multi-language subtitle search and download
|
|
||||||
- [**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
|
||||||
@@ -543,8 +538,6 @@ Display a second subtitle track (e.g., English alongside Japanese) in the overla
|
|||||||
|
|
||||||
See `config.example.jsonc` for detailed configuration options.
|
See `config.example.jsonc` for detailed configuration options.
|
||||||
|
|
||||||
Secondary subtitles do **not** auto-load by default. To turn them on for local and Jellyfin playback, set `autoLoadSecondarySub` to `true` and list the language codes you want:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"secondarySub": {
|
"secondarySub": {
|
||||||
@@ -556,15 +549,11 @@ Secondary subtitles do **not** auto-load by default. To turn them on for local a
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). |
|
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match |
|
||||||
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) |
|
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
|
||||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
||||||
|
|
||||||
These two settings apply to local and Jellyfin playback only. YouTube secondary selection is fixed to English and ignores them; see [YouTube Integration](/youtube-integration#secondary-subtitle-languages). `defaultMode` still controls how the loaded secondary bar is displayed in every case.
|
|
||||||
|
|
||||||
Because the mined-card translation field is filled from the secondary subtitle when one is present, leaving `autoLoadSecondarySub` off means local-file cards fall back to AI translation (when configured) or the original sentence text.
|
|
||||||
|
|
||||||
The secondary-subtitle language list also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback.
|
The secondary-subtitle language list also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback.
|
||||||
|
|
||||||
**Display modes:**
|
**Display modes:**
|
||||||
@@ -874,8 +863,8 @@ When config hot-reload updates shortcut/keybinding/style values, close and reope
|
|||||||
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
|
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
|
||||||
|
|
||||||
Current runtime options cover automatic card updates, known-word highlighting,
|
Current runtime options cover automatic card updates, known-word highlighting,
|
||||||
known-word maturity coloring, N+1 annotation, JLPT underlines, frequency
|
N+1 annotation, JLPT underlines, frequency highlighting, known-word match mode,
|
||||||
highlighting, known-word match mode, and Kiku field grouping mode.
|
and Kiku field grouping mode.
|
||||||
|
|
||||||
Annotation toggles only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
|
Annotation toggles only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
|
||||||
|
|
||||||
@@ -1042,8 +1031,6 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
||||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
||||||
| `ankiConnect.knownWords.maturityEnabled` | `true`, `false` | Color known words by Anki card maturity (new/learning/young/mature) instead of one color. Requires `knownWords.highlightEnabled` (default: `false`). Tier colors come from `subtitleStyle.knownWordMaturityColors`. |
|
|
||||||
| `ankiConnect.knownWords.matureThresholdDays` | number | Card interval in days at which a known word counts as mature (default: `21`, matching Anki's own convention) |
|
|
||||||
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||||
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
|
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
|
||||||
@@ -1089,7 +1076,6 @@ Known-word cache policy:
|
|||||||
- `subtitleStyle.nPlusOneColor` sets the color for the single target token when exactly one eligible unknown word exists.
|
- `subtitleStyle.nPlusOneColor` sets the color for the single target token when exactly one eligible unknown word exists.
|
||||||
- The N+1 minimum sentence-word setting controls the token count required before N+1 highlighting can trigger.
|
- The N+1 minimum sentence-word setting controls the token count required before N+1 highlighting can trigger.
|
||||||
- `subtitleStyle.knownWordColor` sets the known-word highlight color for tokens already in Anki.
|
- `subtitleStyle.knownWordColor` sets the known-word highlight color for tokens already in Anki.
|
||||||
- Set `ankiConnect.knownWords.maturityEnabled` to `true` to color known words by Anki card maturity instead, using the four `subtitleStyle.knownWordMaturityColors` tiers. See [Known-Word Maturity Highlighting](/subtitle-annotations#known-word-maturity-highlighting) for how tiers are derived. Changing it or `matureThresholdDays` forces a full cache refresh.
|
|
||||||
- The known-word deck map accepts an object keyed by deck name.
|
- The known-word deck map accepts an object keyed by deck name.
|
||||||
- Prefer expression/word fields such as `Expression` or `Word`. Avoid reading-only fields unless you intentionally want homophone readings to count as known words.
|
- Prefer expression/word fields such as `Expression` or `Word`. Avoid reading-only fields unless you intentionally want homophone readings to count as known words.
|
||||||
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
|
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
|
||||||
@@ -1128,7 +1114,6 @@ When the manual merge popup opens, SubMiner pauses playback and closes any open
|
|||||||
|
|
||||||
<video controls playsinline preload="metadata" :poster="withBase('/assets/kiku-integration-poster.jpg')" style="width: 100%; max-width: 960px;">
|
<video controls playsinline preload="metadata" :poster="withBase('/assets/kiku-integration-poster.jpg')" style="width: 100%; max-width: 960px;">
|
||||||
<source :src="withBase('/assets/kiku-integration.webm')" type="video/webm" />
|
<source :src="withBase('/assets/kiku-integration.webm')" type="video/webm" />
|
||||||
<source :src="withBase('/assets/kiku-integration.mp4')" type="video/mp4" />
|
|
||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
</video>
|
</video>
|
||||||
|
|
||||||
@@ -1154,28 +1139,6 @@ Configure Jimaku API access and defaults:
|
|||||||
|
|
||||||
Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry delay from the API response.
|
Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry delay from the API response.
|
||||||
|
|
||||||
### TsukiHime
|
|
||||||
|
|
||||||
TsukiHime subtitle search works out of the box and needs no account or API key. It does require the `xz` binary on your `PATH`, because TsukiHime serves extracted subtitles xz-compressed.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"tsukihime": {
|
|
||||||
"apiBaseUrl": "https://api.tsukihime.org/v1",
|
|
||||||
"maxSearchResults": 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Option | Values | Description |
|
|
||||||
| ---------------------------- | ------------ | ---------------------------------------------------------------------------------------------------- |
|
|
||||||
| `tsukihime.apiBaseUrl` | string (URL) | Base URL of the TsukiHime API (default: `https://api.tsukihime.org/v1`). Only change it for a mirror. |
|
|
||||||
| `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) |
|
|
||||||
|
|
||||||
The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift+T`; set to `null` to disable). The older `animetosho` section and `shortcuts.openAnimetosho` are still accepted as deprecated aliases, with the current names taking precedence when both are set.
|
|
||||||
|
|
||||||
See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, language tabs, and troubleshooting.
|
|
||||||
|
|
||||||
### Subtitle Sync
|
### Subtitle Sync
|
||||||
|
|
||||||
Sync the active subtitle track from the overlay picker using `alass` or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
|
Sync the active subtitle track from the overlay picker using `alass` or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
|
||||||
@@ -1528,7 +1491,7 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
|
|||||||
"executablePath": "",
|
"executablePath": "",
|
||||||
"launchMode": "normal",
|
"launchMode": "normal",
|
||||||
"profile": "",
|
"profile": "",
|
||||||
"socketPath": "/tmp/subminer-socket",
|
"socketPath": "\\\\.\\pipe\\subminer-socket",
|
||||||
"backend": "auto",
|
"backend": "auto",
|
||||||
"autoStartSubMiner": true,
|
"autoStartSubMiner": true,
|
||||||
"pauseUntilOverlayReady": true,
|
"pauseUntilOverlayReady": true,
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner aut
|
|||||||
</a>
|
</a>
|
||||||
</video>
|
</video>
|
||||||
|
|
||||||
|
::: info VIDEO COMING SOON
|
||||||
|
:::
|
||||||
|
|
||||||
## Subtitle Download & Sync
|
## Subtitle Download & Sync
|
||||||
|
|
||||||
Search and download subtitles from Jimaku, then retime them with alass or ffsubsync - all from within SubMiner.
|
Search and download subtitles from Jimaku, then retime them with alass or ffsubsync - all from within SubMiner.
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ const ankiIntegrationContents = readFileSync(
|
|||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
const configurationContents = readFileSync(new URL('./configuration.md', import.meta.url), 'utf8');
|
const configurationContents = readFileSync(new URL('./configuration.md', import.meta.url), 'utf8');
|
||||||
const troubleshootingContents = readFileSync(
|
|
||||||
new URL('./troubleshooting.md', import.meta.url),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
function extractReleaseHeadings(content: string, count: number): string[] {
|
function extractReleaseHeadings(content: string, count: number): string[] {
|
||||||
return Array.from(content.matchAll(/^## v[^\n]+$/gm))
|
return Array.from(content.matchAll(/^## v[^\n]+$/gm))
|
||||||
@@ -62,33 +58,6 @@ test('docs reflect current launcher and release surfaces', () => {
|
|||||||
expect(changelogContents).toContain('v0.5.1 (2026-03-09)');
|
expect(changelogContents).toContain('v0.5.1 (2026-03-09)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('docs document config surfaces that are easy to miss when they ship', () => {
|
|
||||||
// Anki maturity-based known-word highlighting (#172) landed in
|
|
||||||
// subtitle-annotations.md but was missing from the config reference.
|
|
||||||
expect(configurationContents).toContain('ankiConnect.knownWords.maturityEnabled');
|
|
||||||
expect(configurationContents).toContain('ankiConnect.knownWords.matureThresholdDays');
|
|
||||||
|
|
||||||
// Every top-level config block should be reachable from the config reference.
|
|
||||||
expect(configurationContents).toContain('### TsukiHime');
|
|
||||||
expect(configurationContents).toContain('tsukihime.maxSearchResults');
|
|
||||||
|
|
||||||
// xz is a hard runtime dependency of the TsukiHime download path.
|
|
||||||
expect(installationContents).toContain('xz');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('docs state the real secondary-subtitle and Anki field-matching behavior', () => {
|
|
||||||
// secondarySub auto-load is off by default; the config example previously
|
|
||||||
// implied otherwise while youtube-integration.md documented it correctly.
|
|
||||||
expect(configurationContents).toContain('Secondary subtitles do **not** auto-load by default');
|
|
||||||
expect(configurationContents).toContain('default: `false`');
|
|
||||||
|
|
||||||
// Anki field names are matched case-insensitively (src/anki-integration.ts
|
|
||||||
// resolveFieldName: exact match first, then a lowercase comparison).
|
|
||||||
expect(usageContents).not.toContain('exactly (case-sensitive)');
|
|
||||||
expect(troubleshootingContents).not.toContain('exactly (case-sensitive)');
|
|
||||||
expect(ankiIntegrationContents).toContain('case-insensitively');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('docs dev server links version navigation to local dev routes', () => {
|
test('docs dev server links version navigation to local dev routes', () => {
|
||||||
expect(docsPackageContents).toContain('scripts/build-versioned-docs.ts');
|
expect(docsPackageContents).toContain('scripts/build-versioned-docs.ts');
|
||||||
expect(docsPackageContents).toContain(
|
expect(docsPackageContents).toContain(
|
||||||
|
|||||||
+17
-109
@@ -12,23 +12,20 @@ Three steps to get started:
|
|||||||
|
|
||||||
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
|
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
|
||||||
|
|
||||||
Several entries below exist only for the `subminer` command-line launcher, which is Linux and macOS only. On Windows you launch playback with the **SubMiner mpv** shortcut instead, so you can ignore those rows.
|
| Dependency | Status | What it does |
|
||||||
|
| -------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Dependency | Status | Platforms | What it does |
|
| mpv | Required | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
|
||||||
| -------------------- | ----------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ffmpeg | Recommended | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
|
||||||
| mpv | Required | All | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
|
| MeCab + mecab-ipadic | Recommended | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
|
||||||
| ffmpeg | Recommended | All | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
|
| yt-dlp | Optional | YouTube playback and subtitle extraction. |
|
||||||
| MeCab + mecab-ipadic | Recommended | All | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
|
| fzf | Optional | Terminal-based video picker in the launcher. |
|
||||||
| yt-dlp | Optional | All | YouTube playback and subtitle extraction. |
|
| rofi | Optional | GUI-based video picker (Linux). |
|
||||||
| xz | Optional | All | Required for TsukiHime subtitle downloads (subtitles are served xz-compressed). Preinstalled on most Linux distros; not present on Windows by default. |
|
| chafa | Optional | Thumbnail previews in fzf. |
|
||||||
| guessit | Optional | All | Better AniSkip title/season/episode parsing. |
|
| ffmpegthumbnailer | Optional | Video thumbnail generation for the picker. |
|
||||||
| alass | Optional | All | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
|
| guessit | Optional | Better AniSkip title/season/episode parsing. |
|
||||||
| ffsubsync | Optional | All | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. |
|
| alass | Optional | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
|
||||||
| fzf | Optional | Linux, macOS | Terminal-based video picker in the `subminer` launcher. |
|
| ffsubsync | Optional | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. |
|
||||||
| rofi | Optional | Linux | GUI-based video picker in the `subminer` launcher. |
|
| fuse2 | Linux only | Required to run the AppImage. |
|
||||||
| chafa | Optional | Linux, macOS | Thumbnail previews in the fzf picker. |
|
|
||||||
| ffmpegthumbnailer | Optional | Linux, macOS | Video thumbnail generation for the pickers. |
|
|
||||||
| fuse2 | Required | Linux | Needed to run the AppImage. |
|
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
@@ -112,98 +109,9 @@ pip install ffsubsync
|
|||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
Windows 10 or later. No compositor tools or window helpers are needed - native window tracking is built in.
|
Windows 10 or later. Install [`mpv`](https://mpv.io/installation/) and [`ffmpeg`](https://ffmpeg.org/download.html) and ensure both are on `PATH`. Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary.
|
||||||
|
|
||||||
You need **mpv** (required) and **ffmpeg** (strongly recommended, for card audio and screenshots), and both must be on your `PATH`.
|
No compositor tools or window helpers are needed - native window tracking is built in.
|
||||||
|
|
||||||
::: 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.
|
|
||||||
:::
|
|
||||||
|
|
||||||
You can install these with a package manager or by hand. Coverage differs, so pick based on what you need:
|
|
||||||
|
|
||||||
| Dependency | winget | Scoop |
|
|
||||||
| ---------------- | --------------- | ------------- |
|
|
||||||
| mpv (required) | `shinchiro.mpv` | `extras/mpv` |
|
|
||||||
| ffmpeg | `Gyan.FFmpeg` | `main/ffmpeg` |
|
|
||||||
| yt-dlp (YouTube) | `yt-dlp.yt-dlp` | `main/yt-dlp` |
|
|
||||||
| xz (TsukiHime) | not packaged | `main/xz` |
|
|
||||||
|
|
||||||
Use **winget** if you want Microsoft's first-party tool and don't need TsukiHime subtitle downloads. Use **Scoop** if you want one package manager to cover everything, since it is the only one that also packages `xz`.
|
|
||||||
|
|
||||||
#### Recommended: winget
|
|
||||||
|
|
||||||
[winget](https://learn.microsoft.com/windows/package-manager/winget/) is Microsoft's own package manager and ships with Windows 11 and current Windows 10 (it comes with **App Installer** from the Microsoft Store). In **PowerShell** or **Command Prompt**:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
winget install shinchiro.mpv
|
|
||||||
winget install Gyan.FFmpeg
|
|
||||||
```
|
|
||||||
|
|
||||||
Close and reopen your terminal, then check that both are found:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
mpv --version
|
|
||||||
ffmpeg -version
|
|
||||||
```
|
|
||||||
|
|
||||||
`ffmpeg` is installed as a portable package, so winget links it into a folder that is already on your `PATH` and it should work right away.
|
|
||||||
|
|
||||||
`mpv` uses a regular installer, and depending on the version it may **not** add itself to `PATH`. If `mpv --version` says `not recognized`, you have two easy options:
|
|
||||||
|
|
||||||
- Note where it installed (usually `%LOCALAPPDATA%\Programs\mpv`) and add that folder to `PATH` using the manual steps below, or
|
|
||||||
- Skip `PATH` entirely and set `mpv.executablePath` to the full path of `mpv.exe` during first-run setup.
|
|
||||||
|
|
||||||
Once `mpv --version` works, or you have the full path to `mpv.exe` ready, continue to [step 2](#_2-install-subminer).
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Alternative: Scoop (covers every dependency, no admin rights)</b></summary>
|
|
||||||
|
|
||||||
[Scoop](https://scoop.sh) installs into your user profile, needs no administrator prompt, and always puts commands on `PATH`. It is the only Windows package manager that carries all of SubMiner's optional dependencies, including `xz`, so it is the best choice if you want a single tool to manage everything.
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# One-time Scoop setup (skip if you already have it)
|
|
||||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
|
||||||
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression
|
|
||||||
|
|
||||||
# mpv lives in the "extras" bucket; everything else is in "main"
|
|
||||||
scoop bucket add extras
|
|
||||||
scoop install extras/mpv main/ffmpeg
|
|
||||||
|
|
||||||
# Optional: yt-dlp for YouTube playback, xz for TsukiHime subtitle downloads
|
|
||||||
scoop install main/yt-dlp main/xz
|
|
||||||
```
|
|
||||||
|
|
||||||
Close and reopen your terminal, then verify with `mpv --version` and `ffmpeg -version`.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Manual install (download the zips yourself)</b></summary>
|
|
||||||
|
|
||||||
1. Download mpv from [mpv.io/installation](https://mpv.io/installation/) (the Windows builds link) and ffmpeg from [ffmpeg.org/download.html](https://ffmpeg.org/download.html).
|
|
||||||
2. Unzip each one somewhere permanent, for example `C:\Tools\mpv` and `C:\Tools\ffmpeg`. Note the folder that actually contains `mpv.exe` and the one containing `ffmpeg.exe` (for ffmpeg this is usually a `bin` subfolder).
|
|
||||||
3. Press `Win`, type **Edit the system environment variables**, and open it. Click **Environment Variables…**, select **Path** under **User variables**, click **Edit…**, then use **New** to add each of those two folders. Confirm with **OK** on every dialog. Microsoft documents this in more detail under [environment variables](https://learn.microsoft.com/windows/deployment/usmt/usmt-recognized-environment-variables).
|
|
||||||
4. Close and reopen your terminal, since `PATH` changes only apply to newly opened windows. Then check:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
mpv --version
|
|
||||||
ffmpeg -version
|
|
||||||
```
|
|
||||||
|
|
||||||
If you see `not recognized as the name of a cmdlet`, the folder you added is not the one holding the `.exe`. Reopen the Path editor and double-check.
|
|
||||||
|
|
||||||
::: tip mpv can skip PATH, ffmpeg cannot
|
|
||||||
If you would rather not edit `PATH` for mpv, set `mpv.executablePath` to the full path of `mpv.exe` during first-run setup instead.
|
|
||||||
|
|
||||||
There is no equivalent setting for ffmpeg: SubMiner invokes it by bare name when generating card audio and screenshots, so ffmpeg has to be on `PATH`. Without it, cards are still created but their audio and image fields come out empty. (`subsync.ffmpeg_path` only affects subtitle sync, not card media.)
|
|
||||||
:::
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
**Optional extras:** [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary improves annotation accuracy; it is not in any package manager, so install it from that page. `xz` is needed only for [TsukiHime](/tsukihime-integration) subtitle downloads and is not packaged by winget or Chocolatey, so use `scoop install main/xz` or download [XZ Utils](https://tukaani.org/xz/) and add its folder to `PATH`.
|
|
||||||
|
|
||||||
The `subminer` command-line launcher and its picker tools (`fzf`, `rofi`, `chafa`, `ffmpegthumbnailer`) are Linux/macOS only; on Windows you use the **SubMiner mpv** shortcut instead.
|
|
||||||
|
|
||||||
## 2. Install SubMiner
|
## 2. Install SubMiner
|
||||||
|
|
||||||
@@ -370,7 +278,7 @@ Run the built-in diagnostic to confirm everything is working:
|
|||||||
subminer doctor
|
subminer doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
This checks for the app binary, mpv, ffmpeg, yt-dlp, fzf, rofi, your config file, and the mpv socket path. Only the app binary and mpv are hard failures; the rest are reported as optional. Fix any hard failures before continuing.
|
This checks for the app binary, mpv, ffmpeg, config file, and socket path. Fix any failures before continuing.
|
||||||
|
|
||||||
## Anki Setup (Recommended)
|
## Anki Setup (Recommended)
|
||||||
|
|
||||||
|
|||||||
@@ -112,5 +112,5 @@ Verify mpv is running and connected via IPC. SubMiner loads the subtitle by issu
|
|||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [Configuration Reference](/configuration#jimaku) - full config options
|
- [Configuration Reference](/configuration#jimaku) - full config options
|
||||||
- [Mining Workflow](/mining-workflow#related-features) - how Jimaku fits into the sentence mining loop
|
- [Mining Workflow](/mining-workflow#jimaku-subtitle-search) - how Jimaku fits into the sentence mining loop
|
||||||
- [Troubleshooting](/troubleshooting#jimaku) - additional error guidance
|
- [Troubleshooting](/troubleshooting#jimaku) - additional error guidance
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ subminer -R -H # rofi history browser
|
|||||||
|
|
||||||
The first menu lists every locally watched series, most recently watched first, using the parsed media title (e.g. the anime title) when available and the directory name otherwise. Selecting a series opens an action menu:
|
The first menu lists every locally watched series, most recently watched first, using the parsed media title (e.g. the anime title) when available and the directory name otherwise. Selecting a series opens an action menu:
|
||||||
|
|
||||||
- **Previous episode**: plays the episode before the last watched one and continues into the previous season directory when the season starts
|
|
||||||
- **Replay last watched**: replays the most recently watched episode
|
- **Replay last watched**: replays the most recently watched episode
|
||||||
- **Next episode**: plays the episode after the last watched one and continues into the next season directory when the season ends
|
- **Next episode**: plays the episode after the last watched one and continues into the next season directory when the season ends
|
||||||
- **Browse episodes**: lists the video files in the series directory in episode order, using the same fzf/rofi episode picker as directory browsing; if the series has multiple season directories, a season menu appears first
|
- **Browse episodes**: lists the video files in the series directory in episode order, using the same fzf/rofi episode picker as directory browsing; if the series has multiple season directories, a season menu appears first
|
||||||
@@ -177,7 +176,7 @@ Use `subminer <subcommand> -h` for command-specific help.
|
|||||||
## Options
|
## Options
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
| --------------------- | ---------------------------------------------------------------------------- |
|
| --------------------- | --------------------------------------------------------------------------- |
|
||||||
| `-d, --directory` | Video search directory (default: cwd) |
|
| `-d, --directory` | Video search directory (default: cwd) |
|
||||||
| `-r, --recursive` | Search directories recursively |
|
| `-r, --recursive` | Search directories recursively |
|
||||||
| `-R, --rofi` | Use rofi instead of fzf |
|
| `-R, --rofi` | Use rofi instead of fzf |
|
||||||
@@ -189,7 +188,7 @@ Use `subminer <subcommand> -h` for command-specific help.
|
|||||||
| `-T, --no-texthooker` | Disable texthooker server |
|
| `-T, --no-texthooker` | Disable texthooker server |
|
||||||
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
|
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
|
||||||
| `-a, --args` | Pass additional mpv arguments as a quoted string |
|
| `-a, --args` | Pass additional mpv arguments as a quoted string |
|
||||||
| `-b, --backend` | Force window backend (`auto`, `hyprland`, `sway`, `x11`, `macos`, `windows`) |
|
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) |
|
||||||
| `--settings` | Open the SubMiner settings window |
|
| `--settings` | Open the SubMiner settings window |
|
||||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||||
|
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
import { expect, test } from 'bun:test';
|
|
||||||
import { readdirSync, readFileSync } from 'node:fs';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const docsSiteDir = fileURLToPath(new URL('.', import.meta.url));
|
|
||||||
|
|
||||||
// Mirrors VitePress' heading slugifier (vitepress/dist/node, `rControl` + `rSpecial`).
|
|
||||||
// Note that a *run* of special characters collapses to a single `-`, so
|
|
||||||
// "KDE Plasma & other" becomes "kde-plasma-other", not "kde-plasma--other".
|
|
||||||
const rControl = new RegExp('[\\u0000-\\u001f]', 'g');
|
|
||||||
const rSpecial = /[\s~`!@#$%^&*()\-_+=[\]{}|\\;:"'“”‘’<>,.?/]+/g;
|
|
||||||
|
|
||||||
function slugify(heading: string): string {
|
|
||||||
return heading
|
|
||||||
.replace(rControl, '')
|
|
||||||
.replace(rSpecial, '-')
|
|
||||||
.replace(/-{2,}/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '')
|
|
||||||
.replace(/^(\d)/, '_$1')
|
|
||||||
.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
const EXCLUDED_PAGES = new Set(['README.md']);
|
|
||||||
const PUBLIC_PREFIXES = ['/assets/', '/screenshots/', '/config.example.jsonc', '/favicon'];
|
|
||||||
|
|
||||||
function loadPages(): Map<string, string> {
|
|
||||||
const pages = new Map<string, string>();
|
|
||||||
for (const file of readdirSync(docsSiteDir)) {
|
|
||||||
if (!file.endsWith('.md') || EXCLUDED_PAGES.has(file)) continue;
|
|
||||||
const route = `/${file.replace(/\.md$/, '')}`;
|
|
||||||
pages.set(route, readFileSync(`${docsSiteDir}${file}`, 'utf8'));
|
|
||||||
}
|
|
||||||
return pages;
|
|
||||||
}
|
|
||||||
|
|
||||||
function anchorsFor(contents: string): Set<string> {
|
|
||||||
const anchors = new Set<string>();
|
|
||||||
for (const match of contents.matchAll(/^#{1,6}\s+(.+?)\s*$/gm)) {
|
|
||||||
let heading = match[1]!;
|
|
||||||
const explicitId = heading.match(/\{#([^}]+)\}\s*$/);
|
|
||||||
if (explicitId) {
|
|
||||||
anchors.add(explicitId[1]!);
|
|
||||||
heading = heading.replace(/\{#[^}]+\}\s*$/, '');
|
|
||||||
}
|
|
||||||
anchors.add(slugify(heading.replace(/`/g, '')));
|
|
||||||
}
|
|
||||||
return anchors;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRoute(target: string, fromRoute: string): string {
|
|
||||||
if (target === '') return fromRoute;
|
|
||||||
if (target.startsWith('./')) return `/${target.slice(2).replace(/\.md$/, '')}`;
|
|
||||||
const normalized = target.replace(/\.md$/, '').replace(/\/$/, '');
|
|
||||||
return normalized === '' ? '/index' : normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pages = loadPages();
|
|
||||||
const anchors = new Map([...pages].map(([route, body]) => [route, anchorsFor(body)]));
|
|
||||||
|
|
||||||
test('every internal docs link resolves to an existing page', () => {
|
|
||||||
const broken: string[] = [];
|
|
||||||
|
|
||||||
for (const [route, body] of pages) {
|
|
||||||
for (const match of body.matchAll(/\]\((\/[^)\s]*|\.\/[^)\s]*)\)/g)) {
|
|
||||||
const link = match[1]!;
|
|
||||||
const target = link.split('#')[0]!;
|
|
||||||
if (PUBLIC_PREFIXES.some((prefix) => target.startsWith(prefix))) continue;
|
|
||||||
|
|
||||||
const resolved = resolveRoute(target, route);
|
|
||||||
if (resolved !== '/index' && !pages.has(resolved)) {
|
|
||||||
broken.push(`${route.slice(1)}.md -> ${link}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(broken).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('every internal docs anchor matches a real heading slug', () => {
|
|
||||||
const broken: string[] = [];
|
|
||||||
|
|
||||||
for (const [route, body] of pages) {
|
|
||||||
for (const match of body.matchAll(/\]\((\/[^)\s]*|\.\/[^)\s]*|#[^)\s]*)\)/g)) {
|
|
||||||
const link = match[1]!;
|
|
||||||
const hashIndex = link.indexOf('#');
|
|
||||||
if (hashIndex < 0) continue;
|
|
||||||
|
|
||||||
const target = link.slice(0, hashIndex);
|
|
||||||
const anchor = link.slice(hashIndex + 1);
|
|
||||||
if (PUBLIC_PREFIXES.some((prefix) => target.startsWith(prefix))) continue;
|
|
||||||
|
|
||||||
const resolved = resolveRoute(target, route);
|
|
||||||
const pageAnchors = anchors.get(resolved);
|
|
||||||
if (!pageAnchors || pageAnchors.has(anchor)) continue;
|
|
||||||
|
|
||||||
broken.push(`${route.slice(1)}.md -> ${link}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(broken).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('slugify matches the VitePress cases these docs actually rely on', () => {
|
|
||||||
// Regression guards for the anchors that were previously wrong.
|
|
||||||
expect(slugify('N+1 Word Highlighting')).toBe('n-1-word-highlighting');
|
|
||||||
expect(slugify('KDE Plasma & other Wayland compositors')).toBe(
|
|
||||||
'kde-plasma-other-wayland-compositors',
|
|
||||||
);
|
|
||||||
expect(slugify('Proxy Mode Setup (Yomitan / Texthooker)')).toBe(
|
|
||||||
'proxy-mode-setup-yomitan-texthooker',
|
|
||||||
);
|
|
||||||
expect(slugify('Kiku/Lapis Integration')).toBe('kiku-lapis-integration');
|
|
||||||
expect(slugify('Secondary Subtitles')).toBe('secondary-subtitles');
|
|
||||||
expect(slugify('2. Install SubMiner')).toBe('_2-install-subminer');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('every docs page is reachable from the sidebar', async () => {
|
|
||||||
const { default: config } = await import('./.vitepress/config');
|
|
||||||
const sidebar = config.themeConfig?.sidebar as Array<{
|
|
||||||
items?: Array<{ text: string; link?: string }>;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const linked = new Set<string>();
|
|
||||||
for (const group of sidebar) {
|
|
||||||
for (const item of group.items ?? []) {
|
|
||||||
if (item.link) linked.add(item.link === '/' ? '/index' : item.link);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const orphans = [...pages.keys()].filter((route) => !linked.has(route));
|
|
||||||
expect(orphans).toEqual([]);
|
|
||||||
});
|
|
||||||
@@ -183,7 +183,7 @@ If you want to build your own browser client, websocket consumer, or automation
|
|||||||
These features support the mining loop but have their own dedicated pages:
|
These features support the mining loop but have their own dedicated pages:
|
||||||
|
|
||||||
- **[Jimaku subtitle search](/jimaku-integration)** - search and download anime subtitle files directly from the overlay (`Ctrl+Shift+J` by default), then load them into mpv.
|
- **[Jimaku subtitle search](/jimaku-integration)** - search and download anime subtitle files directly from the overlay (`Ctrl+Shift+J` by default), then load them into mpv.
|
||||||
- **[N+1 word highlighting](/subtitle-annotations#n-1-word-highlighting)** - cross-reference your Anki decks to highlight known words, making true N+1 sentences (exactly one unknown word) easy to spot during immersion.
|
- **[N+1 word highlighting](/subtitle-annotations#n1-word-highlighting)** - cross-reference your Anki decks to highlight known words, making true N+1 sentences (exactly one unknown word) easy to spot during immersion.
|
||||||
- **[Immersion tracking](/immersion-tracking)** - log watching and mining activity to a local database and view session times, words seen, and cards mined in the built-in stats dashboard.
|
- **[Immersion tracking](/immersion-tracking)** - log watching and mining activity to a local database and view session times, words seen, and cards mined in the built-in stats dashboard.
|
||||||
|
|
||||||
Next: [Anki Integration](/anki-integration) - field mapping, media generation, and card enrichment configuration.
|
Next: [Anki Integration](/anki-integration) - field mapping, media generation, and card enrichment configuration.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"docs:dev": "SUBMINER_DOCS_VERSION_LINK_ORIGIN=local bun run ../scripts/build-versioned-docs.ts && SUBMINER_DOCS_VERSION_LINK_ORIGIN=local SUBMINER_DOCS_VERSION_MANIFEST=\"$(bun run ../scripts/print-docs-version-manifest.ts)\" VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
|
"docs:dev": "SUBMINER_DOCS_VERSION_LINK_ORIGIN=local bun run ../scripts/build-versioned-docs.ts && SUBMINER_DOCS_VERSION_LINK_ORIGIN=local SUBMINER_DOCS_VERSION_MANIFEST=\"$(bun run ../scripts/print-docs-version-manifest.ts)\" VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
|
||||||
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build",
|
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build",
|
||||||
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview --host 0.0.0.0 --port 4173 --strictPort",
|
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview --host 0.0.0.0 --port 4173 --strictPort",
|
||||||
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts links.test.ts seo.test.ts .vitepress/theme/status-line.test.ts ../scripts/docs-versioning.test.ts"
|
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts seo.test.ts .vitepress/theme/status-line.test.ts ../scripts/docs-versioning.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@catppuccin/vitepress": "^0.1.2",
|
"@catppuccin/vitepress": "^0.1.2",
|
||||||
|
|||||||
@@ -691,7 +691,7 @@
|
|||||||
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||||
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
||||||
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
|
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
|
||||||
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
"socketPath": "\\\\.\\pipe\\subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
||||||
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
|
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
|
||||||
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
|
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
|
||||||
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
|
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
|
||||||
|
|||||||
@@ -251,9 +251,7 @@ test('dev docs version links use local targets for version route testing', async
|
|||||||
delete process.env.SUBMINER_DOCS_CHANNEL;
|
delete process.env.SUBMINER_DOCS_CHANNEL;
|
||||||
delete process.env.SUBMINER_DOCS_BASE;
|
delete process.env.SUBMINER_DOCS_BASE;
|
||||||
delete process.env.SUBMINER_DOCS_VERSION;
|
delete process.env.SUBMINER_DOCS_VERSION;
|
||||||
// Set explicitly (like the sibling version-nav tests) so this assertion stays
|
delete process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||||
// pinned to the manifest under test instead of the config's fallback constant.
|
|
||||||
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
|
||||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
|
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
|
||||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
|
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
|
||||||
latestStable: 'v0.14.0',
|
latestStable: 'v0.14.0',
|
||||||
|
|||||||
@@ -49,37 +49,23 @@ Instead of one color for every known word, maturity highlighting tints each know
|
|||||||
|
|
||||||
**How it works:**
|
**How it works:**
|
||||||
|
|
||||||
1. During the known-word cache refresh, SubMiner classifies each note with Anki search filters (`prop:ivl`, `is:learn`) - no extra card data is downloaded.
|
1. During the known-word cache refresh, SubMiner classifies each note with Anki search filters (`prop:ivl`, `is:learn`, `is:new`) - no extra card data is downloaded.
|
||||||
2. Each note gets the tier of its **most mature** card: `mature` (in review, interval ≥ threshold), `young` (in review, interval below the threshold), `learning` (in the learning or relearning queue), or `new` (never studied). The buckets are disjoint, matching Anki's own card counts: a lapsed card in relearning counts as `learning`, not `young`, even though its interval is ≥ 1 day. A note with a mature card plus a relearning card still shows `mature`.
|
2. Each note gets the tier of its **most mature** card: `mature` (interval ≥ threshold), `young` (in review below the threshold), `learning` (in (re)learning), or `new` (never reviewed).
|
||||||
3. A word matched by several notes takes the most mature tier among them, with the same reading-aware matching as regular known-word highlighting.
|
3. A word matched by several notes takes the most mature tier among them, with the same reading-aware matching as regular known-word highlighting.
|
||||||
4. Known tokens render in the tier color instead of `subtitleStyle.knownWordColor`; if tier data is missing for a match, the token falls back to the single known-word color.
|
4. Known tokens render in the tier color instead of `subtitleStyle.knownWordColor`; if tier data is missing for a match, the token falls back to the single known-word color.
|
||||||
|
|
||||||
**Key settings:**
|
**Key settings:**
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| ------------------------------------------------ | --------- | --------------------------------------------------------------------- |
|
| -------------------------------------------- | --------- | ---------------------------------------------------------- |
|
||||||
| `ankiConnect.knownWords.maturityEnabled` | `false` | Color known words by card maturity (requires known-word highlighting) |
|
| `ankiConnect.knownWords.maturityEnabled` | `false` | Color known words by card maturity (requires known-word highlighting) |
|
||||||
| `ankiConnect.knownWords.matureThresholdDays` | `21` | Card interval in days at which a word counts as mature |
|
| `ankiConnect.knownWords.matureThresholdDays` | `21` | Card interval in days at which a word counts as mature |
|
||||||
| `subtitleStyle.knownWordMaturityColors.new` | `#ee99a0` | Tier color for never-reviewed cards |
|
| `subtitleStyle.knownWordMaturityColors.new` | `#ee99a0` | Tier color for never-reviewed cards |
|
||||||
| `subtitleStyle.knownWordMaturityColors.learning` | `#b7bdf8` | Tier color for cards in the learning/relearning queue |
|
| `subtitleStyle.knownWordMaturityColors.learning` | `#b7bdf8` | Tier color for (re)learning cards |
|
||||||
| `subtitleStyle.knownWordMaturityColors.young` | `#91d7e3` | Tier color for young review cards |
|
| `subtitleStyle.knownWordMaturityColors.young` | `#91d7e3` | Tier color for young review cards |
|
||||||
| `subtitleStyle.knownWordMaturityColors.mature` | `#a6da95` | Tier color for mature cards |
|
| `subtitleStyle.knownWordMaturityColors.mature` | `#a6da95` | Tier color for mature cards |
|
||||||
|
|
||||||
Changing `maturityEnabled` or the threshold triggers a full known-word cache refresh so tiers are refetched, as does upgrading to a build that revises the tier rules.
|
Changing `maturityEnabled` or the threshold triggers a full known-word cache refresh so tiers are refetched.
|
||||||
|
|
||||||
How often the `learning` color appears depends on your deck preset: with no relearning steps configured, a lapsed card returns straight to review and shows `young` instead.
|
|
||||||
|
|
||||||
While maturity highlighting is on, the session help color legend replaces its single "Known words" swatch with one row per tier (new, learning, young, mature).
|
|
||||||
|
|
||||||
**Checking the colors you actually see:**
|
|
||||||
|
|
||||||
Tiers are only as fresh as the last known-word cache refresh (`ankiConnect.knownWords.refreshMinutes`), so a card that crosses the mature threshold mid-day keeps its old color until the next refresh. To check a whole episode offline, run the verifier against its subtitle file:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
bun run verify-known-word-highlights:electron -- --input /path/to/episode.ja.srt --audit
|
|
||||||
```
|
|
||||||
|
|
||||||
It tokenizes every cue through the real Yomitan/MeCab pipeline with your live known-word cache, prints each line in your configured tier colors, and summarizes the tier counts. `--audit` re-derives each highlighted tier from live Anki card data (`notesInfo` + `cardsInfo` intervals) and lists any token whose color disagrees, with the note ids and intervals behind it. Electron locks the Yomitan profile, so quit SubMiner first or pass `--profile-copy` to run against a scratch copy. Other useful flags: `--refresh` (refresh the cache first), `--limit <n>`, `--quiet`, `--json`.
|
|
||||||
|
|
||||||
## Character-Name Highlighting
|
## Character-Name Highlighting
|
||||||
|
|
||||||
|
|||||||
+87
-104
@@ -16,6 +16,90 @@ SubMiner retries the connection automatically with increasing delays (200 ms, 50
|
|||||||
|
|
||||||
If the overlay never appears at all, see [Playback Startup Flow](./architecture#playback-startup-flow) for how a managed launch starts mpv and brings up the overlay.
|
If the overlay never appears at all, see [Playback Startup Flow](./architecture#playback-startup-flow) for how a managed launch starts mpv and brings up the overlay.
|
||||||
|
|
||||||
|
## Logging and App Mode
|
||||||
|
|
||||||
|
- Default log output is `warn`.
|
||||||
|
- Use `--log-level` for more/less output.
|
||||||
|
- Use `--dev`/`--debug` only to force app/dev mode (for example to get dev behavior from the overlay/app); they do not change log verbosity.
|
||||||
|
- You can combine both, for example `SubMiner.AppImage --start --dev --log-level debug`, when you need maximum diagnostics.
|
||||||
|
|
||||||
|
## Performance and Resource Impact
|
||||||
|
|
||||||
|
### At a glance
|
||||||
|
|
||||||
|
- Baseline: `SubMiner --start` is usually lightweight for normal playback.
|
||||||
|
- Common spikes come from:
|
||||||
|
- first subtitle parse/tokenization bursts
|
||||||
|
- media generation (`ffmpeg` audio/image and AVIF paths)
|
||||||
|
- media sync and subtitle tooling (`alass`, `ffsubsync`)
|
||||||
|
- `ankiConnect` enrichment (plus polling overhead when proxy mode is disabled)
|
||||||
|
|
||||||
|
### If playback feels sluggish
|
||||||
|
|
||||||
|
1. Reduce overlay workload:
|
||||||
|
|
||||||
|
- set secondary subtitles hidden:
|
||||||
|
- `secondarySub.defaultMode: "hidden"`
|
||||||
|
- disable optional enrichment:
|
||||||
|
- `subtitleStyle.enableJlpt: false`
|
||||||
|
- `subtitleStyle.frequencyDictionary.enabled: false`
|
||||||
|
|
||||||
|
2. Reduce rendering pressure:
|
||||||
|
|
||||||
|
- lower `subtitleStyle.css["font-size"]`
|
||||||
|
- keep overlay complexity minimal during heavy CPU periods
|
||||||
|
|
||||||
|
3. Reduce media overhead:
|
||||||
|
|
||||||
|
- keep `ankiConnect.media.imageType` set to `static` (avoid animated AVIF unless needed)
|
||||||
|
- lower `ankiConnect.media.imageQuality`
|
||||||
|
- reduce `ankiConnect.media.maxMediaDuration`
|
||||||
|
|
||||||
|
4. Lower integration cost:
|
||||||
|
|
||||||
|
- disable AI translation when not needed (`ankiConnect.ai.enabled: false`)
|
||||||
|
- if needed, run immersion telemetry with lower duration expectations (`immersionTracking.enabled: false` for constrained sessions)
|
||||||
|
- favor the default lightweight YouTube subtitle startup settings on low-resource systems
|
||||||
|
|
||||||
|
### Practical low-impact profile
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subtitleStyle": {
|
||||||
|
"css": {
|
||||||
|
"font-size": "30px"
|
||||||
|
},
|
||||||
|
"enableJlpt": false,
|
||||||
|
"frequencyDictionary": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"secondarySub": {
|
||||||
|
"defaultMode": "hidden"
|
||||||
|
},
|
||||||
|
"ankiConnect": {
|
||||||
|
"media": {
|
||||||
|
"imageType": "static",
|
||||||
|
"imageQuality": 80,
|
||||||
|
"maxMediaDuration": 12
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"immersionTracking": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### If usage is still high
|
||||||
|
|
||||||
|
- Confirm only one SubMiner instance is running.
|
||||||
|
- Check whether bottlenecks are `ffmpeg`, `yt-dlp`, or sync tooling in system monitor.
|
||||||
|
- Keep the default `warn` level for normal use; raise to `info` or `debug` only for targeted diagnosis.
|
||||||
|
- Reproduce once with `SubMiner.AppImage --start --log-level debug` and open DevTools (`y` then `d`) if freezes recur.
|
||||||
|
|
||||||
**"Failed to parse MPV message"**
|
**"Failed to parse MPV message"**
|
||||||
|
|
||||||
Logged when a malformed JSON line arrives from the mpv socket. Usually harmless - SubMiner skips the bad line and continues. If it happens constantly, check that nothing else is writing to the same socket path.
|
Logged when a malformed JSON line arrives from the mpv socket. Usually harmless - SubMiner skips the bad line and continues. If it happens constantly, check that nothing else is writing to the same socket path.
|
||||||
@@ -64,7 +148,7 @@ SubMiner retries with exponential backoff (up to 5 s) and suppresses repeated er
|
|||||||
|
|
||||||
**Cards are created but fields are empty**
|
**Cards are created but fields are empty**
|
||||||
|
|
||||||
Field names in your config must name a field that exists on your Anki note type. Matching is case-insensitive (`sentenceaudio` finds `SentenceAudio`), but the spelling must otherwise match, and unknown fields are skipped silently. Check `ankiConnect.fields` - for example, if your note type uses `SentenceAudio` but your config says `Audio`, the field will not be populated.
|
Field names in your config must match your Anki note type exactly (case-sensitive). Check `ankiConnect.fields` - for example, if your note type uses `SentenceAudio` but your config says `Audio`, the field will not be populated.
|
||||||
|
|
||||||
See [Anki Integration](/anki-integration) for the full field mapping reference.
|
See [Anki Integration](/anki-integration) for the full field mapping reference.
|
||||||
|
|
||||||
@@ -232,115 +316,17 @@ If subtitle sync fails (the error message is prefixed with the engine name):
|
|||||||
- Try running the sync tool manually to see detailed error output.
|
- Try running the sync tool manually to see detailed error output.
|
||||||
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
|
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
|
||||||
|
|
||||||
## TsukiHime
|
|
||||||
|
|
||||||
**"xz binary not found"**
|
|
||||||
|
|
||||||
TsukiHime serves extracted subtitles xz-compressed, so SubMiner shells out to `xz` to decompress them. Install it:
|
|
||||||
|
|
||||||
- **Arch Linux**: `sudo pacman -S xz`
|
|
||||||
- **Ubuntu/Debian**: `sudo apt install xz-utils`
|
|
||||||
- **Fedora**: `sudo dnf install xz`
|
|
||||||
- **macOS**: `brew install xz`
|
|
||||||
- **Windows**: neither winget nor Chocolatey packages `xz`. Use `scoop install main/xz`, or download XZ Utils from [tukaani.org/xz](https://tukaani.org/xz/) and add the folder containing `xz.exe` to your `PATH`. Restart SubMiner afterwards.
|
|
||||||
|
|
||||||
Most Linux distributions ship it already. See [TsukiHime Integration](/tsukihime-integration#troubleshooting) for the other TsukiHime error messages.
|
|
||||||
|
|
||||||
## Jimaku
|
## Jimaku
|
||||||
|
|
||||||
**"Jimaku request failed" or HTTP 429**
|
**"Jimaku request failed" or HTTP 429**
|
||||||
|
|
||||||
The Jimaku API has rate limits. If you see 429 errors, wait for the retry duration shown in the OSD message and try again. If you have a Jimaku API key, set it in `jimaku.apiKey` or `jimaku.apiKeyCommand` to get higher rate limits.
|
The Jimaku API has rate limits. If you see 429 errors, wait for the retry duration shown in the OSD message and try again. If you have a Jimaku API key, set it in `jimaku.apiKey` or `jimaku.apiKeyCommand` to get higher rate limits.
|
||||||
|
|
||||||
## Logging and App Mode
|
|
||||||
|
|
||||||
- Default log output is `warn`.
|
|
||||||
- Use `--log-level` for more/less output.
|
|
||||||
- Use `--dev`/`--debug` only to force app/dev mode (for example to get dev behavior from the overlay/app); they do not change log verbosity.
|
|
||||||
- You can combine both, for example `SubMiner.AppImage --start --dev --log-level debug`, when you need maximum diagnostics.
|
|
||||||
|
|
||||||
## Performance and Resource Impact
|
|
||||||
|
|
||||||
### At a glance
|
|
||||||
|
|
||||||
- Baseline: `SubMiner --start` is usually lightweight for normal playback.
|
|
||||||
- Common spikes come from:
|
|
||||||
- first subtitle parse/tokenization bursts
|
|
||||||
- media generation (`ffmpeg` audio/image and AVIF paths)
|
|
||||||
- media sync and subtitle tooling (`alass`, `ffsubsync`)
|
|
||||||
- `ankiConnect` enrichment (plus polling overhead when proxy mode is disabled)
|
|
||||||
|
|
||||||
### If playback feels sluggish
|
|
||||||
|
|
||||||
1. Reduce overlay workload:
|
|
||||||
|
|
||||||
- set secondary subtitles hidden:
|
|
||||||
- `secondarySub.defaultMode: "hidden"`
|
|
||||||
- disable optional enrichment:
|
|
||||||
- `subtitleStyle.enableJlpt: false`
|
|
||||||
- `subtitleStyle.frequencyDictionary.enabled: false`
|
|
||||||
|
|
||||||
2. Reduce rendering pressure:
|
|
||||||
|
|
||||||
- lower `subtitleStyle.css["font-size"]`
|
|
||||||
- keep overlay complexity minimal during heavy CPU periods
|
|
||||||
|
|
||||||
3. Reduce media overhead:
|
|
||||||
|
|
||||||
- keep `ankiConnect.media.imageType` set to `static` (avoid animated AVIF unless needed)
|
|
||||||
- lower `ankiConnect.media.imageQuality`
|
|
||||||
- reduce `ankiConnect.media.maxMediaDuration`
|
|
||||||
|
|
||||||
4. Lower integration cost:
|
|
||||||
|
|
||||||
- disable AI translation when not needed (`ankiConnect.ai.enabled: false`)
|
|
||||||
- if needed, run immersion telemetry with lower duration expectations (`immersionTracking.enabled: false` for constrained sessions)
|
|
||||||
- favor the default lightweight YouTube subtitle startup settings on low-resource systems
|
|
||||||
|
|
||||||
### Practical low-impact profile
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"subtitleStyle": {
|
|
||||||
"css": {
|
|
||||||
"font-size": "30px"
|
|
||||||
},
|
|
||||||
"enableJlpt": false,
|
|
||||||
"frequencyDictionary": {
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"secondarySub": {
|
|
||||||
"defaultMode": "hidden"
|
|
||||||
},
|
|
||||||
"ankiConnect": {
|
|
||||||
"media": {
|
|
||||||
"imageType": "static",
|
|
||||||
"imageQuality": 80,
|
|
||||||
"maxMediaDuration": 12
|
|
||||||
},
|
|
||||||
"ai": {
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"immersionTracking": {
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### If usage is still high
|
|
||||||
|
|
||||||
- Confirm only one SubMiner instance is running.
|
|
||||||
- Check whether bottlenecks are `ffmpeg`, `yt-dlp`, or sync tooling in system monitor.
|
|
||||||
- Keep the default `warn` level for normal use; raise to `info` or `debug` only for targeted diagnosis.
|
|
||||||
- Reproduce once with `SubMiner.AppImage --start --log-level debug` and open DevTools (`y` then `d`) if freezes recur.
|
|
||||||
|
|
||||||
## Platform-Specific
|
## Platform-Specific
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
- **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors (KDE Plasma, GNOME, …) are not supported natively - both mpv and SubMiner must run under X11 or Xwayland instead. On those sessions SubMiner forces XWayland automatically for itself and for every mpv it launches (see [KDE Plasma & other Wayland compositors](#kde-plasma-other-wayland-compositors)).
|
- **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors (KDE Plasma, GNOME, …) are not supported natively - both mpv and SubMiner must run under X11 or Xwayland instead. On those sessions SubMiner forces XWayland automatically for itself and for every mpv it launches (see [KDE Plasma & other Wayland compositors](#kde-plasma--other-wayland-compositors)).
|
||||||
- **X11 / Xwayland**: Requires `xdotool`, `xprop`, and `xwininfo`. If missing, the overlay cannot track the mpv window position. This is the required backend for any Wayland compositor other than Hyprland or Sway - both mpv and SubMiner must be running under X11/Xwayland for window tracking _and_ for the overlay to stay above mpv (Wayland forbids clients from controlling window stacking). SubMiner uses a managed X11 overlay while mpv is windowed, switches to an override-redirect X11 overlay while tracked mpv is fullscreen, and hides/releases that overlay when another X11/Xwayland app takes focus. The visible overlay stays hidden until SubMiner has tracked mpv geometry, so startup should not create a display-sized fallback overlay while tokenization warms up.
|
- **X11 / Xwayland**: Requires `xdotool`, `xprop`, and `xwininfo`. If missing, the overlay cannot track the mpv window position. This is the required backend for any Wayland compositor other than Hyprland or Sway - both mpv and SubMiner must be running under X11/Xwayland for window tracking _and_ for the overlay to stay above mpv (Wayland forbids clients from controlling window stacking). SubMiner uses a managed X11 overlay while mpv is windowed, switches to an override-redirect X11 overlay while tracked mpv is fullscreen, and hides/releases that overlay when another X11/Xwayland app takes focus. The visible overlay stays hidden until SubMiner has tracked mpv geometry, so startup should not create a display-sized fallback overlay while tokenization warms up.
|
||||||
- **Tray icon missing**: SubMiner creates an Electron tray icon in `--background` mode, but Linux trays require a StatusNotifier/AppIndicator host. Hyprland does not provide one by itself; enable a tray in Waybar, Hyprpanel, or another panel. If Electron cannot register the tray, SubMiner logs a warning that mentions the missing tray host.
|
- **Tray icon missing**: SubMiner creates an Electron tray icon in `--background` mode, but Linux trays require a StatusNotifier/AppIndicator host. Hyprland does not provide one by itself; enable a tray in Waybar, Hyprpanel, or another panel. If Electron cannot register the tray, SubMiner logs a warning that mentions the missing tray host.
|
||||||
- **Mouse passthrough**: On Linux X11/Xwayland, SubMiner uses `xdotool` to poll the cursor and only enables overlay input while the cursor is over subtitle or popup regions. Outside those regions, pointer input passes through to mpv. Native Wayland compositors other than Hyprland/Sway cannot provide the stacking control SubMiner needs.
|
- **Mouse passthrough**: On Linux X11/Xwayland, SubMiner uses `xdotool` to poll the cursor and only enables overlay input while the cursor is over subtitle or popup regions. Outside those regions, pointer input passes through to mpv. Native Wayland compositors other than Hyprland/Sway cannot provide the stacking control SubMiner needs.
|
||||||
@@ -442,11 +428,8 @@ Feature-specific issues are covered in each feature's own page:
|
|||||||
- [Character Dictionary](/character-dictionary) - AniList character name matching and inline portraits
|
- [Character Dictionary](/character-dictionary) - AniList character name matching and inline portraits
|
||||||
- [Jellyfin Integration](/jellyfin-integration) - remote playback and library connection
|
- [Jellyfin Integration](/jellyfin-integration) - remote playback and library connection
|
||||||
- [Jimaku Integration](/jimaku-integration) - subtitle fetching and API rate limits
|
- [Jimaku Integration](/jimaku-integration) - subtitle fetching and API rate limits
|
||||||
- [TsukiHime Integration](/tsukihime-integration) - multi-language subtitle download and `xz` decompression
|
|
||||||
- [YouTube Integration](/youtube-integration) - subtitle generation and playback
|
- [YouTube Integration](/youtube-integration) - subtitle generation and playback
|
||||||
- [Immersion Tracking](/immersion-tracking) - telemetry, session logging, and the stats dashboard
|
- [Immersion Tracking](/immersion-tracking) - telemetry and session logging
|
||||||
- [Launcher Script](/launcher-script) - `subminer` commands, pickers, watch history, and cross-machine sync
|
|
||||||
- [MPV Plugin](/mpv-plugin) - in-player chords, script-opts, and binary auto-detection
|
|
||||||
- [WebSocket / Texthooker API](/websocket-texthooker-api) - external texthooker clients
|
- [WebSocket / Texthooker API](/websocket-texthooker-api) - external texthooker clients
|
||||||
- [Subtitle Annotations](/subtitle-annotations) - N+1, frequency, JLPT, and name-match layers
|
- [Subtitle Annotations](/subtitle-annotations) - N+1, frequency, JLPT, and name-match layers
|
||||||
- [Subtitle Sidebar](/subtitle-sidebar) - sidebar navigation and behavior
|
- [Subtitle Sidebar](/subtitle-sidebar) - sidebar navigation and behavior
|
||||||
|
|||||||
+113
-92
@@ -33,7 +33,7 @@ If you want sentence, audio, and screenshot fields on your Anki cards, add this
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Field names must match a field on your Anki note type. Matching is case-insensitive (an exact match wins, then a lowercase comparison), but the spelling must otherwise match. See [Anki Integration](/anki-integration) for the full reference.
|
Field names must match your Anki note type exactly (case-sensitive). See [Anki Integration](/anki-integration) for the full reference.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
@@ -56,90 +56,126 @@ From there, subtitles render as interactive, hoverable word spans and you mine c
|
|||||||
|
|
||||||
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. On Linux, normal `subminer` playback auto-installs the launcher-managed runtime plugin copy from the bundled app if that managed copy is missing, so no separate plugin install is needed for standard launcher usage. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
|
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. On Linux, normal `subminer` playback auto-installs the launcher-managed runtime plugin copy from the bundled app if that managed copy is missing, so no separate plugin install is needed for standard launcher usage. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
|
||||||
|
|
||||||
|
## Live Config Reload
|
||||||
|
|
||||||
|
While SubMiner is running, it watches your active config file and applies safe updates automatically.
|
||||||
|
|
||||||
|
Live-updated settings include:
|
||||||
|
|
||||||
|
- `subtitleStyle`
|
||||||
|
- `keybindings`
|
||||||
|
- `shortcuts`
|
||||||
|
- `secondarySub.defaultMode`
|
||||||
|
- `subtitleSidebar`
|
||||||
|
- `notifications`
|
||||||
|
- `logging`
|
||||||
|
- `jimaku`, `subsync`
|
||||||
|
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`
|
||||||
|
- `stats.toggleKey`, `stats.markWatchedKey`
|
||||||
|
- `youtube.primarySubLanguages`
|
||||||
|
- most `ankiConnect.*` settings (including `ankiConnect.ai`)
|
||||||
|
|
||||||
|
Invalid config edits are rejected; SubMiner keeps the previous valid runtime config and shows an error notification.
|
||||||
|
For restart-required sections, SubMiner shows a restart-needed notification.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
These are the commands you will actually use day to day. The full inventory of subcommands and flags lives in [Launcher Script](/launcher-script#subcommands).
|
On Windows, replace `SubMiner.AppImage` with `SubMiner.exe` in the direct packaged-app examples below.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
subminer video.mkv # Play a specific file
|
# Browse and play videos
|
||||||
subminer # Browse the current directory (fzf picker)
|
subminer # Current directory (uses fzf)
|
||||||
subminer -R # Browse with the rofi picker instead
|
subminer -R # Use rofi instead of fzf
|
||||||
subminer -d ~/Anime -r # Browse a specific directory, recursively
|
subminer -d ~/Videos # Specific directory
|
||||||
subminer -H # Browse watch history, then replay/next/previous
|
subminer -r -d ~/Anime # Recursive search
|
||||||
|
subminer video.mkv # Play specific file (overlay auto-starts)
|
||||||
|
subminer --start video.mkv # Explicit overlay start (use when mpv.autoStartSubMiner is false in config)
|
||||||
|
subminer -S video.mkv # Also force the visible overlay on start (--start-overlay)
|
||||||
subminer https://youtu.be/... # Play a YouTube URL
|
subminer https://youtu.be/... # Play a YouTube URL
|
||||||
subminer stats # Open the immersion stats dashboard
|
subminer ytsearch:"jp news" # Play first YouTube search result
|
||||||
subminer doctor # Check dependencies, config, and the mpv socket
|
subminer -H # Browse history, then choose previous/replay/next after playback
|
||||||
subminer settings # Open the SubMiner settings window
|
subminer app --setup # Open first-run setup popup
|
||||||
subminer app --setup # Re-open first-run setup
|
subminer --version # Print the launcher's version
|
||||||
subminer -u # Check for updates
|
subminer -v # Same as above
|
||||||
```
|
subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging
|
||||||
|
subminer --log-level warn video.mkv # Set logging level explicitly
|
||||||
|
subminer --args '--fs=opengl-hq --ytdl-format=bestvideo*+bestaudio/best' video.mkv # Pass extra mpv args
|
||||||
|
|
||||||
On **Windows** there is no `subminer` launcher. Use the **SubMiner mpv** shortcut for playback (see [Windows mpv Shortcut](#windows-mpv-shortcut)), and run `SubMiner.exe` directly for everything else.
|
# Options
|
||||||
|
subminer -T video.mkv # Disable texthooker server
|
||||||
Two flags are worth knowing early:
|
subminer -b x11 video.mkv # Force X11 backend
|
||||||
|
subminer video.mkv # No mpv profile passed by default
|
||||||
- `-a/--args` passes extra arguments straight to mpv, for example `subminer --args "--ao=alsa --volume=80" video.mkv`.
|
|
||||||
- `--log-level debug` turns on verbose logging when something is not working.
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Less common launcher commands</b></summary>
|
|
||||||
|
|
||||||
```bash
|
|
||||||
subminer --start video.mkv # Explicit overlay start (when mpv.autoStartSubMiner is false)
|
|
||||||
subminer -S video.mkv # Also force the visible overlay on start
|
|
||||||
subminer -T video.mkv # Disable the texthooker server
|
|
||||||
subminer -b x11 video.mkv # Force a window backend
|
|
||||||
subminer -p gpu-hq video.mkv # Use a specific mpv profile
|
subminer -p gpu-hq video.mkv # Use a specific mpv profile
|
||||||
subminer ytsearch:"jp news" # Play the first YouTube search result
|
subminer jellyfin # Open Jellyfin setup window (subcommand form)
|
||||||
subminer texthooker # Texthooker-only mode (-o also opens the browser)
|
subminer jellyfin -l --server http://127.0.0.1:8096 --username me --password 'secret'
|
||||||
|
subminer jellyfin --logout # Clear stored Jellyfin token/session data
|
||||||
|
subminer jellyfin -p # Interactive Jellyfin library/item picker + playback
|
||||||
|
subminer jellyfin -d # Jellyfin cast-discovery mode (background tray app)
|
||||||
|
subminer app --stop # Stop background app (including Jellyfin cast broadcast)
|
||||||
|
subminer doctor # Dependency + config + socket diagnostics
|
||||||
|
subminer logs -e # Export a sanitized log ZIP and print its path
|
||||||
|
subminer config path # Print active config path
|
||||||
|
subminer config show # Print active config contents
|
||||||
|
subminer mpv socket # Print active mpv socket path
|
||||||
|
subminer mpv status # Exit 0 if socket is ready, else exit 1
|
||||||
|
subminer mpv idle # Launch detached idle mpv with SubMiner defaults
|
||||||
|
subminer sync media-box # Sync stats/watch history with an SSH host
|
||||||
|
subminer sync media-box --push # Merge this machine's stats into the host only
|
||||||
|
subminer sync media-box --pull # Merge the host's stats into this machine only
|
||||||
|
subminer sync media-box --check # Verify SSH and remote SubMiner without syncing
|
||||||
|
subminer sync media-box --json # Emit machine-readable NDJSON progress
|
||||||
|
subminer sync --ui # Open the Sync Stats & History window
|
||||||
|
subminer sync --snapshot ~/subminer-snapshot.sqlite # Write a local DB snapshot
|
||||||
|
subminer sync --merge ~/subminer-snapshot.sqlite # Merge a snapshot into the local DB
|
||||||
|
subminer sync --make-temp # Create an internal sync temp directory
|
||||||
|
subminer sync --remove-temp /tmp/subminer-sync-123 # Remove an internal sync temp directory
|
||||||
|
subminer dictionary /path/to/file-or-directory # Generate character dictionary ZIP from target (manual Yomitan import)
|
||||||
|
subminer dictionary --candidates /path/to/file.mkv
|
||||||
|
subminer dictionary --select 21355 /path/to/file.mkv
|
||||||
|
subminer texthooker # Launch texthooker-only mode
|
||||||
|
subminer texthooker -o # Launch texthooker and open it in your browser
|
||||||
|
subminer stats # Start the local stats server (see Immersion Tracking)
|
||||||
subminer stats -b # Start/reuse the background stats daemon
|
subminer stats -b # Start/reuse the background stats daemon
|
||||||
subminer stats -s # Stop the background stats daemon
|
subminer stats -s # Stop the background stats daemon
|
||||||
subminer stats cleanup # Backfill vocabulary metadata, prune stale rows
|
subminer app --anilist-setup # Pass args directly to SubMiner binary (example: AniList login flow)
|
||||||
subminer stats rebuild # Rebuild rollup data
|
|
||||||
subminer doctor --refresh-known-words # Refresh the known-word cache
|
|
||||||
subminer logs -e # Export a sanitized log ZIP and print its path
|
|
||||||
subminer config path # Print the active config path
|
|
||||||
subminer config show # Print the active config contents
|
|
||||||
subminer mpv socket # Print the active mpv socket path
|
|
||||||
subminer mpv status # Exit 0 if the socket is ready, else exit 1
|
|
||||||
subminer mpv idle # Launch a detached idle mpv with SubMiner defaults
|
|
||||||
subminer app --stop # Stop the background app
|
|
||||||
subminer --version # Print the launcher's version
|
|
||||||
```
|
|
||||||
|
|
||||||
Jellyfin, cross-machine sync, and character-dictionary commands have their own sections: [Jellyfin](/jellyfin-integration), [Sync Between Machines](/launcher-script#sync-between-machines), and [Character Dictionary](/character-dictionary).
|
# Direct packaged app control
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Direct packaged-app flags (advanced)</b></summary>
|
|
||||||
|
|
||||||
These call the app binary directly rather than going through the launcher. On Windows, replace `SubMiner.AppImage` with `SubMiner.exe`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs)
|
SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs)
|
||||||
SubMiner.AppImage --start --texthooker # Start overlay with texthooker
|
SubMiner.AppImage --start --texthooker # Start overlay with texthooker
|
||||||
SubMiner.AppImage --texthooker # Texthooker only (no overlay window)
|
SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window)
|
||||||
SubMiner.AppImage --setup # Open first-run setup
|
SubMiner.AppImage --texthooker --open-browser # Launch texthooker and open browser
|
||||||
|
SubMiner.AppImage --setup # Open first-run setup popup
|
||||||
SubMiner.AppImage --stop # Stop overlay
|
SubMiner.AppImage --stop # Stop overlay
|
||||||
SubMiner.AppImage --start --toggle # Start mpv IPC + toggle visibility
|
SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility
|
||||||
SubMiner.AppImage --show-visible-overlay # Force show the visible overlay
|
SubMiner.AppImage --show-visible-overlay # Force show visible overlay
|
||||||
SubMiner.AppImage --hide-visible-overlay # Force hide the visible overlay
|
SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay
|
||||||
SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle the primary subtitle bar
|
SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle bar visibility
|
||||||
SubMiner.AppImage --toggle-subtitle-sidebar # Toggle the subtitle sidebar
|
SubMiner.AppImage --toggle-subtitle-sidebar # Toggle the subtitle sidebar
|
||||||
SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
|
SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
|
||||||
|
SubMiner.AppImage --start --dev # Enable app/dev mode only
|
||||||
|
SubMiner.AppImage --start --debug # Alias for --dev
|
||||||
|
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
|
||||||
|
SubMiner.AppImage --playback-feedback "your feedback" # Route playback feedback through the configured feedback surface
|
||||||
SubMiner.AppImage --yomitan # Open Yomitan settings
|
SubMiner.AppImage --yomitan # Open Yomitan settings
|
||||||
SubMiner.AppImage --settings # Open the SubMiner settings window
|
SubMiner.AppImage --settings # Open SubMiner settings window
|
||||||
SubMiner.AppImage --jellyfin # Open the Jellyfin setup window
|
SubMiner.AppImage --jellyfin # Open Jellyfin setup window
|
||||||
SubMiner.AppImage --dictionary # Generate a character dictionary ZIP
|
SubMiner.AppImage --jellyfin-login --jellyfin-server http://127.0.0.1:8096 --jellyfin-username me --jellyfin-password 'secret'
|
||||||
SubMiner.AppImage --start --dev # Enable app/dev mode
|
SubMiner.AppImage --jellyfin-logout # Clear stored Jellyfin token/session data
|
||||||
SubMiner.AppImage --start --log-level debug # Verbose logging without dev mode
|
SubMiner.AppImage --jellyfin-libraries
|
||||||
|
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search anime --jellyfin-limit 20
|
||||||
|
SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID --jellyfin-audio-stream-index 1 --jellyfin-subtitle-stream-index 2 # Requires connected mpv IPC (--start)
|
||||||
|
SubMiner.AppImage --jellyfin-remote-announce # Force cast-target capability announce + visibility check
|
||||||
|
SubMiner.AppImage --sync-cli --help # Show the packaged app's headless sync help
|
||||||
|
SubMiner.AppImage --sync-cli sync media-box # Run the sync engine directly in headless mode
|
||||||
|
SubMiner.AppImage --dictionary # Generate character dictionary ZIP for current anime
|
||||||
|
SubMiner.AppImage --dictionary-candidates # List AniList candidates for current character dictionary series
|
||||||
|
SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin correct AniList media for series
|
||||||
SubMiner.AppImage --help # Show all options
|
SubMiner.AppImage --help # Show all options
|
||||||
```
|
```
|
||||||
|
|
||||||
The remaining flags are internal or scripting-only surfaces: the `--jellyfin-*` family (login, library listing, item playback, cast announce), `--sync-cli` (the app's headless sync entrypoint that `subminer sync` proxies to), `--dictionary-candidates` / `--dictionary-select`, and `--playback-feedback <text>`. Run `SubMiner.AppImage --help` for the complete list. The previous `--open-animetosho` flag is still accepted as a deprecated alias for `--open-tsukihime`.
|
`--check` performs connection and version checks without changing data. `--json` emits the NDJSON event protocol used by the sync window. `--ui` opens that window in a detached app process and returns the shell immediately; closing a standalone-launched Sync window exits that app instance. `--make-temp` and `--remove-temp` are internal remote-transfer helpers and should normally be left to SubMiner. The packaged app's `--sync-cli` flag selects its headless sync-compatible entrypoint; the `subminer sync` launcher command proxies to it automatically.
|
||||||
|
|
||||||
</details>
|
The previous `--open-animetosho` flag remains accepted as a deprecated alias for `--open-tsukihime`.
|
||||||
|
|
||||||
The tray menu includes `Export Logs`, which creates the same sanitized local-date log ZIP as `subminer logs -e` and shows the archive path when complete. Export sanitization masks common PII and secrets, including home-directory usernames, IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL query strings. The exported copy is sanitized; source log files remain unredacted on disk.
|
The tray menu includes `Export Logs`, which creates the same sanitized local-date log ZIP as `subminer logs -e` and shows the archive path when complete. Export sanitization masks common PII and secrets, including home-directory usernames, IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL query strings. The exported copy is sanitized; source log files remain unredacted on disk.
|
||||||
|
|
||||||
@@ -181,11 +217,18 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
|
|||||||
|
|
||||||
### Launcher Subcommands
|
### Launcher Subcommands
|
||||||
|
|
||||||
The launcher groups related work under subcommands: `jellyfin` (aliased `jf`), `stats`, `sync`, `dictionary` (aliased `dict`), `texthooker`, `doctor`, `settings`, `config`, `mpv`, `logs`, and `app` (aliased `bin`) for passing arguments straight to the SubMiner binary.
|
- `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
|
||||||
|
- `subminer doctor`: health checks for core dependencies and runtime paths.
|
||||||
Every subcommand has its own help page, for example `subminer jellyfin -h`. See [Launcher Script - Subcommands](/launcher-script#subcommands) for the full table, and [Sync Between Machines](/launcher-script#sync-between-machines) for the SSH stats/history sync.
|
- `subminer settings`: open the SubMiner settings window (also `subminer --settings`).
|
||||||
|
- `subminer logs -e`: export a sanitized ZIP of today's local-date logs, or the most recent logs when no current-day log exists. The exported copy masks common PII and secrets; on-disk logs are unchanged.
|
||||||
A _texthooker_ is a web page that displays the current subtitle line as selectable text, so browser-based dictionary extensions and other tools can read along with playback.
|
- `subminer config`: config file helpers (`path`, `show`).
|
||||||
|
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
|
||||||
|
- `subminer sync <host>`: sync immersion stats and watch history with another machine over SSH. The host is the SSH destination (`user@host` or an SSH config alias). Use `--push` to merge only this machine's data into the host, or `--pull` to merge only the host's data into this machine; both remain insert-only and do not make either database an exact mirror. Remote launcher checks include standard SubMiner and Bun paths even when SSH omits them from `PATH`. Use `--snapshot <file>` to write a consistent local stats DB snapshot, `--merge <file>` to merge a snapshot into the local stats DB, and `--force` to skip the running stats/mpv safety check. Advanced options: `--db <file>` overrides the local stats DB path, and `--remote-cmd <cmd>` overrides the `subminer` command used on the remote host.
|
||||||
|
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
|
||||||
|
- Use `subminer dictionary --candidates <path>` and `subminer dictionary --select <id> <path>` to correct AniList character-dictionary matches for a whole series.
|
||||||
|
- `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`). A _texthooker_ is a web page that displays the current subtitle line as selectable text, so browser-based dictionary extensions and other tools can read along with playback.
|
||||||
|
- `subminer app` / `subminer bin`: direct passthrough to the SubMiner binary/AppImage.
|
||||||
|
- Subcommand help pages are available (for example `subminer jellyfin -h`).
|
||||||
|
|
||||||
### First-Run Setup
|
### First-Run Setup
|
||||||
|
|
||||||
@@ -282,28 +325,6 @@ Notes:
|
|||||||
|
|
||||||
For local video files, SubMiner uses the same config-driven language priorities to auto-select the primary and secondary subtitle tracks from internal and external subtitle sources.
|
For local video files, SubMiner uses the same config-driven language priorities to auto-select the primary and secondary subtitle tracks from internal and external subtitle sources.
|
||||||
|
|
||||||
## Live Config Reload
|
|
||||||
|
|
||||||
While SubMiner is running, it watches your active config file and applies safe updates automatically.
|
|
||||||
|
|
||||||
Live-updated settings include:
|
|
||||||
|
|
||||||
- `subtitleStyle`
|
|
||||||
- `keybindings`
|
|
||||||
- `shortcuts`
|
|
||||||
- `secondarySub.defaultMode`
|
|
||||||
- `subtitleSidebar`
|
|
||||||
- `notifications`
|
|
||||||
- `logging`
|
|
||||||
- `jimaku`, `subsync`
|
|
||||||
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`
|
|
||||||
- `stats.toggleKey`, `stats.markWatchedKey`
|
|
||||||
- `youtube.primarySubLanguages`
|
|
||||||
- most `ankiConnect.*` settings (including `ankiConnect.ai`)
|
|
||||||
|
|
||||||
Invalid config edits are rejected; SubMiner keeps the previous valid runtime config and shows an error notification.
|
|
||||||
For restart-required sections, SubMiner shows a restart-needed notification.
|
|
||||||
|
|
||||||
## Controller Support
|
## Controller Support
|
||||||
|
|
||||||
SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled.
|
SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled.
|
||||||
|
|||||||
@@ -377,4 +377,4 @@ ws.on('message', async (raw) => {
|
|||||||
- [Mining Workflow - Texthooker](/mining-workflow#texthooker)
|
- [Mining Workflow - Texthooker](/mining-workflow#texthooker)
|
||||||
- [MPV Plugin](/mpv-plugin)
|
- [MPV Plugin](/mpv-plugin)
|
||||||
- [Launcher Script](/launcher-script)
|
- [Launcher Script](/launcher-script)
|
||||||
- [Anki Integration](/anki-integration#proxy-mode-setup-yomitan-texthooker)
|
- [Anki Integration](/anki-integration#proxy-mode-setup-yomitan--texthooker)
|
||||||
|
|||||||
@@ -153,6 +153,6 @@ These settings come from `config.jsonc` (or built-in defaults); there are no CLI
|
|||||||
|
|
||||||
- [Usage --- YouTube Playback](/usage#youtube-playback)
|
- [Usage --- YouTube Playback](/usage#youtube-playback)
|
||||||
- [Configuration --- YouTube Playback Settings](/configuration#youtube-playback-settings)
|
- [Configuration --- YouTube Playback Settings](/configuration#youtube-playback-settings)
|
||||||
- [Configuration --- Secondary Subtitles](/configuration#secondary-subtitles)
|
- [Configuration --- Secondary Subtitle](/configuration#secondary-subtitle)
|
||||||
- [Keyboard Shortcuts](/shortcuts)
|
- [Keyboard Shortcuts](/shortcuts)
|
||||||
- [Jellyfin Integration](/jellyfin-integration)
|
- [Jellyfin Integration](/jellyfin-integration)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
collectVideos,
|
collectVideos,
|
||||||
findRofiTheme,
|
findRofiTheme,
|
||||||
formatPickerLaunchError,
|
formatPickerLaunchError,
|
||||||
formatRofiPrompt,
|
|
||||||
showFzfMenu,
|
showFzfMenu,
|
||||||
showRofiMenu,
|
showRofiMenu,
|
||||||
} from '../picker.js';
|
} from '../picker.js';
|
||||||
@@ -68,34 +67,6 @@ export function buildHistorySessionActions(
|
|||||||
return actions;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildHistoryEntryActions(
|
|
||||||
lastWatchedPath: string | null,
|
|
||||||
previousEpisodePath: string | null,
|
|
||||||
nextEpisodePath: string | null,
|
|
||||||
): HistorySessionMenuAction[] {
|
|
||||||
const actions: HistorySessionMenuAction[] = [];
|
|
||||||
if (previousEpisodePath) {
|
|
||||||
actions.push({
|
|
||||||
kind: 'previous',
|
|
||||||
label: `Previous episode: ${path.basename(previousEpisodePath)}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (lastWatchedPath) {
|
|
||||||
actions.push({
|
|
||||||
kind: 'replay',
|
|
||||||
label: `Replay last watched: ${path.basename(lastWatchedPath)}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (nextEpisodePath) {
|
|
||||||
actions.push({ kind: 'next', label: `Next episode: ${path.basename(nextEpisodePath)}` });
|
|
||||||
}
|
|
||||||
actions.push(
|
|
||||||
{ kind: 'browse', label: 'Browse episodes' },
|
|
||||||
{ kind: 'quit', label: 'Quit SubMiner' },
|
|
||||||
);
|
|
||||||
return actions;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HistoryPlaybackLoopDeps {
|
interface HistoryPlaybackLoopDeps {
|
||||||
play: (videoPath: string) => Promise<void>;
|
play: (videoPath: string) => Promise<void>;
|
||||||
pickPostPlaybackAction: (input: {
|
pickPostPlaybackAction: (input: {
|
||||||
@@ -200,16 +171,7 @@ function showRofiIndexMenu(
|
|||||||
themePath: string | null,
|
themePath: string | null,
|
||||||
icons: Array<string | null> = [],
|
icons: Array<string | null> = [],
|
||||||
): number {
|
): number {
|
||||||
const rofiArgs = [
|
const rofiArgs = ['-dmenu', '-i', '-matching', 'fuzzy', '-format', 'i', '-p', prompt];
|
||||||
'-dmenu',
|
|
||||||
'-i',
|
|
||||||
'-matching',
|
|
||||||
'fuzzy',
|
|
||||||
'-format',
|
|
||||||
'i',
|
|
||||||
'-p',
|
|
||||||
formatRofiPrompt(prompt),
|
|
||||||
];
|
|
||||||
const hasIcons = icons.some(Boolean);
|
const hasIcons = icons.some(Boolean);
|
||||||
if (hasIcons) rofiArgs.push('-show-icons');
|
if (hasIcons) rofiArgs.push('-show-icons');
|
||||||
if (themePath) {
|
if (themePath) {
|
||||||
@@ -370,14 +332,17 @@ export async function runHistoryCommand(
|
|||||||
|
|
||||||
const lastPath = path.resolve(entry.lastWatched.sourcePath);
|
const lastPath = path.resolve(entry.lastWatched.sourcePath);
|
||||||
const lastExists = fs.existsSync(lastPath);
|
const lastExists = fs.existsSync(lastPath);
|
||||||
const previousEpisode = findPreviousEpisode(lastPath);
|
|
||||||
const nextEpisode = findNextEpisode(lastPath);
|
const nextEpisode = findNextEpisode(lastPath);
|
||||||
|
|
||||||
const actions = buildHistoryEntryActions(
|
const actions: HistorySessionMenuAction[] = [];
|
||||||
lastExists ? lastPath : null,
|
if (lastExists) {
|
||||||
previousEpisode,
|
actions.push({ kind: 'replay', label: `Replay last watched: ${path.basename(lastPath)}` });
|
||||||
nextEpisode,
|
}
|
||||||
);
|
if (nextEpisode) {
|
||||||
|
actions.push({ kind: 'next', label: `Next episode: ${path.basename(nextEpisode)}` });
|
||||||
|
}
|
||||||
|
actions.push({ kind: 'browse', label: 'Browse episodes' });
|
||||||
|
actions.push({ kind: 'quit', label: 'Quit SubMiner' });
|
||||||
|
|
||||||
const entryIcon = seriesIcons[seriesIdx] ?? null;
|
const entryIcon = seriesIcons[seriesIdx] ?? null;
|
||||||
const actionIdx = pickIndex(
|
const actionIdx = pickIndex(
|
||||||
@@ -392,14 +357,13 @@ export async function runHistoryCommand(
|
|||||||
switch (actions[actionIdx]!.kind) {
|
switch (actions[actionIdx]!.kind) {
|
||||||
case 'replay':
|
case 'replay':
|
||||||
return { entry, videoPath: lastPath, themePath, entryIcon };
|
return { entry, videoPath: lastPath, themePath, entryIcon };
|
||||||
case 'previous':
|
|
||||||
return previousEpisode ? { entry, videoPath: previousEpisode, themePath, entryIcon } : null;
|
|
||||||
case 'next':
|
case 'next':
|
||||||
return nextEpisode ? { entry, videoPath: nextEpisode, themePath, entryIcon } : null;
|
return nextEpisode ? { entry, videoPath: nextEpisode, themePath, entryIcon } : null;
|
||||||
case 'browse': {
|
case 'browse': {
|
||||||
const videoPath = browseEpisodes(entry, context, themePath);
|
const videoPath = browseEpisodes(entry, context, themePath);
|
||||||
return videoPath ? { entry, videoPath, themePath, entryIcon } : null;
|
return videoPath ? { entry, videoPath, themePath, entryIcon } : null;
|
||||||
}
|
}
|
||||||
|
case 'previous':
|
||||||
case 'quit':
|
case 'quit':
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import {
|
import { buildHistorySessionActions, runHistoryPlaybackLoop } from './history-command.js';
|
||||||
buildHistoryEntryActions,
|
|
||||||
buildHistorySessionActions,
|
|
||||||
runHistoryPlaybackLoop,
|
|
||||||
} from './history-command.js';
|
|
||||||
import type { HistorySeriesEntry } from '../history.js';
|
import type { HistorySeriesEntry } from '../history.js';
|
||||||
|
|
||||||
type HistoryLoop = (
|
type HistoryLoop = (
|
||||||
@@ -190,37 +186,6 @@ test('history show menu omits previous and next when the just-played episode has
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('history entry menu offers previous, replay, and next before playback starts', () => {
|
|
||||||
assert.equal(
|
|
||||||
typeof buildHistoryEntryActions,
|
|
||||||
'function',
|
|
||||||
'history entry actions not implemented',
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
buildHistoryEntryActions(
|
|
||||||
'/shows/test-show/episode-03.mkv',
|
|
||||||
'/shows/test-show/episode-02.mkv',
|
|
||||||
'/shows/test-show/episode-04.mkv',
|
|
||||||
),
|
|
||||||
[
|
|
||||||
{ kind: 'previous', label: 'Previous episode: episode-02.mkv' },
|
|
||||||
{ kind: 'replay', label: 'Replay last watched: episode-03.mkv' },
|
|
||||||
{ kind: 'next', label: 'Next episode: episode-04.mkv' },
|
|
||||||
{ kind: 'browse', label: 'Browse episodes' },
|
|
||||||
{ kind: 'quit', label: 'Quit SubMiner' },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('history entry menu omits replay when the last watched file is gone', () => {
|
|
||||||
assert.deepEqual(buildHistoryEntryActions(null, null, '/shows/test-show/episode-04.mkv'), [
|
|
||||||
{ kind: 'next', label: 'Next episode: episode-04.mkv' },
|
|
||||||
{ kind: 'browse', label: 'Browse episodes' },
|
|
||||||
{ kind: 'quit', label: 'Quit SubMiner' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('history playback loop selects previous based on the just-played path, then re-derives previous from the new current episode', async () => {
|
test('history playback loop selects previous based on the just-played path, then re-derives previous from the new current episode', async () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
typeof runHistoryPlaybackLoop,
|
typeof runHistoryPlaybackLoop,
|
||||||
|
|||||||
+1
-16
@@ -3,22 +3,7 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { findRofiTheme, formatRofiPrompt } from './picker';
|
import { findRofiTheme } from './picker';
|
||||||
|
|
||||||
// ── formatRofiPrompt: spacing between prompt and input field ──────────────────
|
|
||||||
|
|
||||||
test('formatRofiPrompt appends a single trailing space', () => {
|
|
||||||
assert.equal(formatRofiPrompt('Select Video'), 'Select Video ');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('formatRofiPrompt collapses existing trailing whitespace to one space', () => {
|
|
||||||
assert.equal(formatRofiPrompt('Watch History '), 'Watch History ');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('formatRofiPrompt leaves an empty prompt empty', () => {
|
|
||||||
assert.equal(formatRofiPrompt(''), '');
|
|
||||||
assert.equal(formatRofiPrompt(' '), '');
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── findRofiTheme: Linux packaged path discovery ──────────────────────────────
|
// ── findRofiTheme: Linux packaged path discovery ──────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+4
-13
@@ -17,22 +17,13 @@ export function escapeShellSingle(value: string): string {
|
|||||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Rofi renders the prompt flush against the input field, so keep exactly one
|
|
||||||
* trailing space to separate them.
|
|
||||||
*/
|
|
||||||
export function formatRofiPrompt(prompt: string): string {
|
|
||||||
const trimmed = prompt.trimEnd();
|
|
||||||
return trimmed ? `${trimmed} ` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function showRofiFlatMenu(
|
export function showRofiFlatMenu(
|
||||||
items: string[],
|
items: string[],
|
||||||
prompt: string,
|
prompt: string,
|
||||||
initialQuery = '',
|
initialQuery = '',
|
||||||
themePath: string | null = null,
|
themePath: string | null = null,
|
||||||
): string {
|
): string {
|
||||||
const args = ['-dmenu', '-i', '-matching', 'fuzzy', '-p', formatRofiPrompt(prompt)];
|
const args = ['-dmenu', '-i', '-matching', 'fuzzy', '-p', prompt];
|
||||||
if (themePath) {
|
if (themePath) {
|
||||||
args.push('-theme', themePath);
|
args.push('-theme', themePath);
|
||||||
} else {
|
} else {
|
||||||
@@ -119,7 +110,7 @@ export async function promptOptionalJellyfinSearch(
|
|||||||
themePath: string | null = null,
|
themePath: string | null = null,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (useRofi && commandExists('rofi')) {
|
if (useRofi && commandExists('rofi')) {
|
||||||
const rofiArgs = ['-dmenu', '-i', '-p', formatRofiPrompt('Jellyfin Search (optional)')];
|
const rofiArgs = ['-dmenu', '-i', '-p', 'Jellyfin Search (optional)'];
|
||||||
if (themePath) {
|
if (themePath) {
|
||||||
rofiArgs.push('-theme', themePath);
|
rofiArgs.push('-theme', themePath);
|
||||||
} else {
|
} else {
|
||||||
@@ -166,7 +157,7 @@ function showRofiIconMenu(
|
|||||||
themePath: string | null = null,
|
themePath: string | null = null,
|
||||||
): number {
|
): number {
|
||||||
if (entries.length === 0) return -1;
|
if (entries.length === 0) return -1;
|
||||||
const rofiArgs = ['-dmenu', '-i', '-show-icons', '-format', 'i', '-p', formatRofiPrompt(prompt)];
|
const rofiArgs = ['-dmenu', '-i', '-show-icons', '-format', 'i', '-p', prompt];
|
||||||
if (initialQuery) rofiArgs.push('-filter', initialQuery);
|
if (initialQuery) rofiArgs.push('-filter', initialQuery);
|
||||||
if (themePath) {
|
if (themePath) {
|
||||||
rofiArgs.push('-theme', themePath);
|
rofiArgs.push('-theme', themePath);
|
||||||
@@ -400,7 +391,7 @@ export function showRofiMenu(
|
|||||||
'-dmenu',
|
'-dmenu',
|
||||||
'-i',
|
'-i',
|
||||||
'-p',
|
'-p',
|
||||||
formatRofiPrompt('Select Video'),
|
'Select Video ',
|
||||||
'-show-icons',
|
'-show-icons',
|
||||||
'-theme-str',
|
'-theme-str',
|
||||||
'configuration { font: "Noto Sans CJK JP Regular 8";}',
|
'configuration { font: "Noto Sans CJK JP Regular 8";}',
|
||||||
|
|||||||
+8
-9
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.19.0-beta.4",
|
"version": "0.19.0-beta.3",
|
||||||
"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",
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
"get-frequency:electron": "bun run build:yomitan && bun build scripts/get_frequency.ts --format=cjs --target=node --outfile dist/scripts/get_frequency.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/get_frequency.js --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line",
|
"get-frequency:electron": "bun run build:yomitan && bun build scripts/get_frequency.ts --format=cjs --target=node --outfile dist/scripts/get_frequency.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/get_frequency.js --pretty --color-top-x 10000 --yomitan-user-data ~/.config/SubMiner --colorized-line",
|
||||||
"test-yomitan-parser": "bun run scripts/test-yomitan-parser.ts",
|
"test-yomitan-parser": "bun run scripts/test-yomitan-parser.ts",
|
||||||
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js",
|
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/test-yomitan-parser.js",
|
||||||
"verify-known-word-highlights:electron": "bun run build:yomitan && bun build scripts/verify-known-word-highlights.ts --format=cjs --target=node --outfile dist/scripts/verify-known-word-highlights.js --packages=external && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/verify-known-word-highlights.js",
|
|
||||||
"record-tokenizer-fixture:electron": "bun run build:yomitan && bun build scripts/record-tokenizer-fixture.ts --format=cjs --target=node --outfile dist/scripts/record-tokenizer-fixture.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/record-tokenizer-fixture.js",
|
"record-tokenizer-fixture:electron": "bun run build:yomitan && bun build scripts/record-tokenizer-fixture.ts --format=cjs --target=node --outfile dist/scripts/record-tokenizer-fixture.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/record-tokenizer-fixture.js",
|
||||||
"compare-yomitan-api:electron": "bun run build:yomitan && bun build scripts/compare-yomitan-api.ts --format=cjs --target=node --outfile dist/scripts/compare-yomitan-api.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/compare-yomitan-api.js",
|
"compare-yomitan-api:electron": "bun run build:yomitan && bun build scripts/compare-yomitan-api.ts --format=cjs --target=node --outfile dist/scripts/compare-yomitan-api.js --external electron && env -u ELECTRON_RUN_AS_NODE electron dist/scripts/compare-yomitan-api.js",
|
||||||
"build:yomitan": "bun scripts/build-yomitan.mjs",
|
"build:yomitan": "bun scripts/build-yomitan.mjs",
|
||||||
@@ -83,14 +82,14 @@
|
|||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@xmldom/xmldom": "0.8.13",
|
"@xmldom/xmldom": "0.8.13",
|
||||||
"app-builder-lib": "26.15.3",
|
"app-builder-lib": "26.8.2",
|
||||||
"brace-expansion": "5.0.8",
|
"brace-expansion": "5.0.7",
|
||||||
"electron-builder-squirrel-windows": "26.15.3",
|
"electron-builder-squirrel-windows": "26.8.2",
|
||||||
"form-data": "4.0.6",
|
"form-data": "4.0.6",
|
||||||
"ip-address": "10.2.0",
|
"ip-address": "10.2.0",
|
||||||
"js-yaml": "4.3.0",
|
"js-yaml": "4.3.0",
|
||||||
"lodash": "4.18.0",
|
"lodash": "4.18.0",
|
||||||
"minimatch": "10.2.5",
|
"minimatch": "10.2.3",
|
||||||
"picomatch": "4.0.4",
|
"picomatch": "4.0.4",
|
||||||
"tar": "7.5.21",
|
"tar": "7.5.21",
|
||||||
"tmp": "0.2.7"
|
"tmp": "0.2.7"
|
||||||
@@ -113,7 +112,7 @@
|
|||||||
"@xhayper/discord-rpc": "^1.3.4",
|
"@xhayper/discord-rpc": "^1.3.4",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"electron-updater": "^6.8.9",
|
"electron-updater": "^6.8.3",
|
||||||
"hono": "^4.12.28",
|
"hono": "^4.12.28",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"koffi": "^2.15.6",
|
"koffi": "^2.15.6",
|
||||||
@@ -124,10 +123,10 @@
|
|||||||
"@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.8.2",
|
||||||
"undici": "7.28.0",
|
"undici": "7.28.0",
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.4.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,12 +17,6 @@
|
|||||||
- **Post-Playback History Menu**
|
- **Post-Playback History Menu**
|
||||||
- After a watch-history episode ends or mpv closes, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit SubMiner.
|
- After a watch-history episode ends or mpv closes, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit SubMiner.
|
||||||
- Previous/Next continue across season directories, so you can binge a show without manually browsing folders.
|
- Previous/Next continue across season directories, so you can binge a show without manually browsing folders.
|
||||||
- The menu shown right after picking a series from `subminer -H` now offers the previous episode too, matching the post-playback menu.
|
|
||||||
|
|
||||||
- **Known-Word Highlighting by Anki Maturity**
|
|
||||||
- Subtitle highlights for known words can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Enable it with `ankiConnect.knownWords.maturityEnabled`, or toggle it live during a session.
|
|
||||||
- The mature-interval threshold and the four tier colors are configurable, and the in-session help legend shows the active tier colors while maturity highlighting is on.
|
|
||||||
- Tiers follow Anki's own card state: a lapsed card correctly shows as learning rather than young, and a note is treated as mature if any of its cards are mature. Stats and other known-word tools stay accurate with this new data.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -34,9 +28,6 @@
|
|||||||
- **Word Highlighting Accuracy**
|
- **Word Highlighting Accuracy**
|
||||||
- Fixed several incorrect word highlighting and annotation cases: inconsistent part-of-speech exclusions on merged quote-particle tokens, missing annotations for rare kanji, katakana punctuation wrongly treated as non-kana noise, and certain kanji vocabulary skipped for next-level ("N+1") highlighting.
|
- Fixed several incorrect word highlighting and annotation cases: inconsistent part-of-speech exclusions on merged quote-particle tokens, missing annotations for rare kanji, katakana punctuation wrongly treated as non-kana noise, and certain kanji vocabulary skipped for next-level ("N+1") highlighting.
|
||||||
|
|
||||||
- **Character Dictionary Season Overrides**
|
|
||||||
- Manual AniList overrides for a series now stay in effect for every episode in the same season folder, even when individual episode filenames produce different automatic guesses.
|
|
||||||
|
|
||||||
- **Startup Playback Pausing Too Early**
|
- **Startup Playback Pausing Too Early**
|
||||||
- Fixed playback resuming before subtitle processing finished warming up, which could briefly show untranslated subtitles right after opening a video.
|
- Fixed playback resuming before subtitle processing finished warming up, which could briefly show untranslated subtitles right after opening a video.
|
||||||
- Most noticeable when resuming mid-episode or when a subtitle cue starts within the first couple of seconds.
|
- Most noticeable when resuming mid-episode or when a subtitle cue starts within the first couple of seconds.
|
||||||
@@ -49,13 +40,9 @@
|
|||||||
- Fixed video playback failing to start when another process already held the configured AnkiConnect proxy port; SubMiner now shows a notification explaining how to resolve the conflict instead of crashing.
|
- Fixed video playback failing to start when another process already held the configured AnkiConnect proxy port; SubMiner now shows a notification explaining how to resolve the conflict instead of crashing.
|
||||||
|
|
||||||
- **Stats & Settings Reliability**
|
- **Stats & Settings Reliability**
|
||||||
- Fixed session stats reporting zero known words after the known-word cache gained maturity tiers.
|
|
||||||
- Hardened the stats server against malformed requests, stalled AniList lookups, media mismatches during word mining, and missing Yomitan connections.
|
- Hardened the stats server against malformed requests, stalled AniList lookups, media mismatches during word mining, and missing Yomitan connections.
|
||||||
- AnkiConnect settings validation now preserves valid custom configurations while safely falling back on invalid values instead of failing.
|
- AnkiConnect settings validation now preserves valid custom configurations while safely falling back on invalid values instead of failing.
|
||||||
|
|
||||||
- **Rofi Menu Prompt Spacing**
|
|
||||||
- Rofi menu prompts now keep a space between the prompt label and the input field instead of crowding the search placeholder text.
|
|
||||||
|
|
||||||
## What's Changed
|
## What's Changed
|
||||||
|
|
||||||
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
|
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
|
||||||
@@ -63,7 +50,6 @@
|
|||||||
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
|
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
|
||||||
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
|
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
|
||||||
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
|
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
|
||||||
- Anki maturity-based known-word highlighting by @ksyasuda in #172
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
@@ -1,593 +0,0 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import process from 'node:process';
|
|
||||||
|
|
||||||
import {
|
|
||||||
KnownWordCacheManager,
|
|
||||||
getKnownWordCacheLifecycleConfig,
|
|
||||||
} from '../src/anki-integration/known-word-cache.js';
|
|
||||||
import { getMatureIntervalThresholdDays } from '../src/anki-integration/known-word-maturity.js';
|
|
||||||
import { resolveConfigDir } from '../src/config/path-resolution.js';
|
|
||||||
import { ConfigService } from '../src/config/service.js';
|
|
||||||
import { parseSubtitleCues } from '../src/core/services/subtitle-cue-parser.js';
|
|
||||||
import { createTokenizerDepsRuntime, tokenizeSubtitle } from '../src/core/services/tokenizer.js';
|
|
||||||
import {
|
|
||||||
resolveCompleteTokenReading,
|
|
||||||
resolveKnownWordReadingForMatch,
|
|
||||||
resolveKnownWordText,
|
|
||||||
} from '../src/core/services/tokenizer/annotation-stage.js';
|
|
||||||
import { MecabTokenizer } from '../src/mecab-tokenizer.js';
|
|
||||||
import type { MergedToken } from '../src/types.js';
|
|
||||||
import type { KnownWordMaturityTier } from '../src/types/subtitle.js';
|
|
||||||
import {
|
|
||||||
createYomitanRuntimeStateWithSearch,
|
|
||||||
destroyParserWindow,
|
|
||||||
loadElectronModule,
|
|
||||||
withTimeout,
|
|
||||||
type YomitanRuntimeState,
|
|
||||||
} from './yomitan-script-runtime.js';
|
|
||||||
|
|
||||||
interface CliOptions {
|
|
||||||
input: string;
|
|
||||||
configDir?: string;
|
|
||||||
yomitanUserDataPath?: string;
|
|
||||||
yomitanExtensionPath?: string;
|
|
||||||
limit: number;
|
|
||||||
audit: boolean;
|
|
||||||
refresh: boolean;
|
|
||||||
json: boolean;
|
|
||||||
quiet: boolean;
|
|
||||||
profileCopy: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TierOrFallback = KnownWordMaturityTier | 'known-no-tier';
|
|
||||||
|
|
||||||
interface TokenReport {
|
|
||||||
cueIndex: number;
|
|
||||||
startTime: number;
|
|
||||||
surface: string;
|
|
||||||
headword: string;
|
|
||||||
reading: string;
|
|
||||||
tier: TierOrFallback;
|
|
||||||
noteIds: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuditMismatch extends TokenReport {
|
|
||||||
liveTier: KnownWordMaturityTier | 'no-notes';
|
|
||||||
intervals: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const TIERS: readonly KnownWordMaturityTier[] = ['new', 'learning', 'young', 'mature'];
|
|
||||||
const FALLBACK_TIER_COLORS: Record<KnownWordMaturityTier, string> = {
|
|
||||||
new: '#ee99a0',
|
|
||||||
learning: '#b7bdf8',
|
|
||||||
young: '#91d7e3',
|
|
||||||
mature: '#a6da95',
|
|
||||||
};
|
|
||||||
|
|
||||||
function parseCliArgs(argv: string[]): CliOptions {
|
|
||||||
const options: CliOptions = {
|
|
||||||
input: '',
|
|
||||||
limit: 0,
|
|
||||||
audit: false,
|
|
||||||
refresh: false,
|
|
||||||
json: false,
|
|
||||||
quiet: false,
|
|
||||||
profileCopy: false,
|
|
||||||
};
|
|
||||||
const rest: string[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < argv.length; i += 1) {
|
|
||||||
const arg = argv[i]!;
|
|
||||||
const takeValue = (flag: string): string => {
|
|
||||||
const next = argv[i + 1];
|
|
||||||
if (!next) {
|
|
||||||
throw new Error(`Missing value for ${flag}`);
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (arg === '--help' || arg === '-h') {
|
|
||||||
process.stdout.write(`${usage()}\n`);
|
|
||||||
process.exit(0);
|
|
||||||
} else if (arg === '--input') {
|
|
||||||
options.input = takeValue(arg);
|
|
||||||
} else if (arg === '--config-dir') {
|
|
||||||
options.configDir = takeValue(arg);
|
|
||||||
} else if (arg === '--yomitan-user-data') {
|
|
||||||
options.yomitanUserDataPath = takeValue(arg);
|
|
||||||
} else if (arg === '--yomitan-extension-path') {
|
|
||||||
options.yomitanExtensionPath = takeValue(arg);
|
|
||||||
} else if (arg === '--limit') {
|
|
||||||
options.limit = Math.max(0, Number.parseInt(takeValue(arg), 10) || 0);
|
|
||||||
} else if (arg === '--audit') {
|
|
||||||
options.audit = true;
|
|
||||||
} else if (arg === '--refresh') {
|
|
||||||
options.refresh = true;
|
|
||||||
} else if (arg === '--json') {
|
|
||||||
options.json = true;
|
|
||||||
} else if (arg === '--quiet') {
|
|
||||||
options.quiet = true;
|
|
||||||
} else if (arg === '--profile-copy') {
|
|
||||||
options.profileCopy = true;
|
|
||||||
} else if (arg === '--') {
|
|
||||||
// `bun run <script> -- --flag ...` forwards the separator too.
|
|
||||||
continue;
|
|
||||||
} else if (arg.startsWith('--')) {
|
|
||||||
throw new Error(`Unknown flag: ${arg}`);
|
|
||||||
} else {
|
|
||||||
rest.push(arg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options.input && rest.length > 0) {
|
|
||||||
options.input = rest.join(' ');
|
|
||||||
}
|
|
||||||
if (!options.input) {
|
|
||||||
throw new Error(`No subtitle file given.\n${usage()}`);
|
|
||||||
}
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
function usage(): string {
|
|
||||||
return [
|
|
||||||
'Usage: verify-known-word-highlights <subtitle.srt|.ass> [flags]',
|
|
||||||
'',
|
|
||||||
' --limit <n> Only check the first n cues (default: all)',
|
|
||||||
' --audit Re-derive every highlighted tier from live Anki card data',
|
|
||||||
' --refresh Force a known-word cache refresh before checking',
|
|
||||||
' --json Emit a machine-readable report',
|
|
||||||
' --quiet Skip the per-line colored dump',
|
|
||||||
' --profile-copy Copy the Yomitan profile to a scratch dir so this can run',
|
|
||||||
' while SubMiner is open (Electron locks the userData dir)',
|
|
||||||
' --config-dir <dir> SubMiner config dir (default: auto-detected)',
|
|
||||||
' --yomitan-user-data <dir> Electron userData dir holding the Yomitan profile',
|
|
||||||
' --yomitan-extension-path <dir>',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
const ANSI_RESET = '\u001b[0m';
|
|
||||||
|
|
||||||
function colorize(text: string, hex: string): string {
|
|
||||||
const normalized = hex.trim().replace(/^#/, '');
|
|
||||||
const expanded =
|
|
||||||
normalized.length === 3
|
|
||||||
? normalized
|
|
||||||
.split('')
|
|
||||||
.map((char) => `${char}${char}`)
|
|
||||||
.join('')
|
|
||||||
: normalized;
|
|
||||||
if (!/^[0-9a-fA-F]{6}$/.test(expanded)) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
const r = Number.parseInt(expanded.slice(0, 2), 16);
|
|
||||||
const g = Number.parseInt(expanded.slice(2, 4), 16);
|
|
||||||
const b = Number.parseInt(expanded.slice(4, 6), 16);
|
|
||||||
return `\u001b[38;2;${r};${g};${b}m${text}${ANSI_RESET}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTimestamp(seconds: number): string {
|
|
||||||
const total = Math.max(0, Math.floor(seconds));
|
|
||||||
const mm = String(Math.floor(total / 60)).padStart(2, '0');
|
|
||||||
const ss = String(total % 60).padStart(2, '0');
|
|
||||||
return `${mm}:${ss}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The user's real config dir is copied into a scratch dir so this read-only
|
|
||||||
// check can never rewrite config.jsonc (ConfigService migrates on load) or the
|
|
||||||
// live known-word cache (a --refresh persists tier data).
|
|
||||||
function createScratchState(configDir: string): { dir: string; cachePath: string } {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-highlight-verify-'));
|
|
||||||
for (const fileName of ['config.jsonc', 'config.json']) {
|
|
||||||
const source = path.join(configDir, fileName);
|
|
||||||
if (fs.existsSync(source)) {
|
|
||||||
fs.copyFileSync(source, path.join(dir, fileName));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const cachePath = path.join(dir, 'known-words-cache.json');
|
|
||||||
const liveCachePath = path.join(configDir, 'known-words-cache.json');
|
|
||||||
if (fs.existsSync(liveCachePath)) {
|
|
||||||
fs.copyFileSync(liveCachePath, cachePath);
|
|
||||||
}
|
|
||||||
return { dir, cachePath };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Electron locks a userData dir, so the Yomitan profile can't be shared with a
|
|
||||||
// running SubMiner. Copying the dictionary-bearing parts of the profile lets
|
|
||||||
// this check run mid-session (IndexedDB alone is often over 1 GB).
|
|
||||||
const YOMITAN_PROFILE_DIRS = [
|
|
||||||
'extensions',
|
|
||||||
'IndexedDB',
|
|
||||||
'Local Extension Settings',
|
|
||||||
'Local Storage',
|
|
||||||
];
|
|
||||||
|
|
||||||
function copyYomitanProfile(sourceUserDataPath: string): string {
|
|
||||||
const target = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-highlight-profile-'));
|
|
||||||
for (const name of YOMITAN_PROFILE_DIRS) {
|
|
||||||
const source = path.join(sourceUserDataPath, name);
|
|
||||||
if (fs.existsSync(source)) {
|
|
||||||
fs.cpSync(source, path.join(target, name), { recursive: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return target;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readPersistedCacheScope(cachePath: string): string | null {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as { scope?: unknown };
|
|
||||||
return typeof parsed.scope === 'string' ? parsed.scope : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ANKI_REQUEST_TIMEOUT_MS = 30_000;
|
|
||||||
|
|
||||||
function createAnkiClient(url: string) {
|
|
||||||
const request = async (action: string, params: unknown): Promise<unknown> => {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ action, version: 6, params }),
|
|
||||||
signal: AbortSignal.timeout(ANKI_REQUEST_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`AnkiConnect ${action}: HTTP ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
const payload = (await response.json()) as { result: unknown; error: string | null };
|
|
||||||
if (payload.error) {
|
|
||||||
throw new Error(`AnkiConnect ${action}: ${payload.error}`);
|
|
||||||
}
|
|
||||||
return payload.result;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
request,
|
|
||||||
findNotes: (query: string) => request('findNotes', { query }),
|
|
||||||
notesInfo: (noteIds: number[]) => request('notesInfo', { notes: noteIds }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveTokenMatch(
|
|
||||||
token: MergedToken,
|
|
||||||
cache: KnownWordCacheManager,
|
|
||||||
matchMode: 'surface' | 'headword',
|
|
||||||
): { tier: KnownWordMaturityTier | null; noteIds: Set<number> } {
|
|
||||||
const matchText = resolveKnownWordText(token.surface, token.headword, matchMode);
|
|
||||||
const matchReading = resolveKnownWordReadingForMatch(token, matchMode);
|
|
||||||
const primaryTier = matchText ? cache.getKnownWordTier(matchText, matchReading) : null;
|
|
||||||
if (primaryTier) {
|
|
||||||
return { tier: primaryTier, noteIds: cache.getKnownWordMatchNoteIds(matchText, matchReading) };
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackReading = resolveCompleteTokenReading(token);
|
|
||||||
if (!fallbackReading || fallbackReading === matchText.trim()) {
|
|
||||||
return {
|
|
||||||
tier: null,
|
|
||||||
noteIds: matchText ? cache.getKnownWordMatchNoteIds(matchText, matchReading) : new Set(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const fallbackOptions = { allowReadingOnlyMatch: false } as const;
|
|
||||||
return {
|
|
||||||
tier: cache.getKnownWordTier(fallbackReading, undefined, fallbackOptions),
|
|
||||||
noteIds: cache.getKnownWordMatchNoteIds(fallbackReading, undefined, fallbackOptions),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ground truth straight from card data, independent of the Anki search filters
|
|
||||||
// the cache refresh uses (prop:ivl / is:learn).
|
|
||||||
function classifyCardsIntoTier(
|
|
||||||
cards: Array<{ interval: number; queue: number; type: number }>,
|
|
||||||
thresholdDays: number,
|
|
||||||
): KnownWordMaturityTier {
|
|
||||||
if (cards.some((card) => card.interval >= thresholdDays)) return 'mature';
|
|
||||||
if (cards.some((card) => card.interval >= 1)) return 'young';
|
|
||||||
if (
|
|
||||||
cards.some((card) => card.type === 1 || card.type === 3 || card.queue === 1 || card.queue === 4)
|
|
||||||
)
|
|
||||||
return 'learning';
|
|
||||||
return 'new';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function auditTokens(
|
|
||||||
reports: TokenReport[],
|
|
||||||
client: ReturnType<typeof createAnkiClient>,
|
|
||||||
thresholdDays: number,
|
|
||||||
): Promise<{ mismatches: AuditMismatch[]; auditedNotes: number }> {
|
|
||||||
const noteIds = [...new Set(reports.flatMap((report) => report.noteIds))];
|
|
||||||
const cardIdsByNote = new Map<number, number[]>();
|
|
||||||
for (let i = 0; i < noteIds.length; i += 500) {
|
|
||||||
const infos = (await client.notesInfo(noteIds.slice(i, i + 500))) as Array<{
|
|
||||||
noteId: number;
|
|
||||||
cards?: number[];
|
|
||||||
}>;
|
|
||||||
for (const info of infos) {
|
|
||||||
cardIdsByNote.set(info.noteId, info.cards ?? []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const allCardIds = [...cardIdsByNote.values()].flat();
|
|
||||||
const cardById = new Map<number, { interval: number; queue: number; type: number }>();
|
|
||||||
for (let i = 0; i < allCardIds.length; i += 500) {
|
|
||||||
const infos = (await client.request('cardsInfo', {
|
|
||||||
cards: allCardIds.slice(i, i + 500),
|
|
||||||
})) as Array<{ cardId: number; interval: number; queue: number; type: number }>;
|
|
||||||
for (const info of infos) {
|
|
||||||
cardById.set(info.cardId, {
|
|
||||||
interval: info.interval,
|
|
||||||
queue: info.queue,
|
|
||||||
type: info.type,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mismatches: AuditMismatch[] = [];
|
|
||||||
for (const report of reports) {
|
|
||||||
const cards = report.noteIds
|
|
||||||
.flatMap((noteId) => cardIdsByNote.get(noteId) ?? [])
|
|
||||||
.map((cardId) => cardById.get(cardId))
|
|
||||||
.filter((card): card is { interval: number; queue: number; type: number } => Boolean(card));
|
|
||||||
const liveTier = cards.length === 0 ? 'no-notes' : classifyCardsIntoTier(cards, thresholdDays);
|
|
||||||
if (liveTier !== report.tier) {
|
|
||||||
mismatches.push({
|
|
||||||
...report,
|
|
||||||
liveTier,
|
|
||||||
intervals: cards.map((card) => card.interval),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { mismatches, auditedNotes: noteIds.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
const args = parseCliArgs(process.argv.slice(2));
|
|
||||||
let electronModule: typeof import('electron') | null = null;
|
|
||||||
let yomitanState: YomitanRuntimeState | null = null;
|
|
||||||
let scratchDir: string | null = null;
|
|
||||||
let profileCopyDir: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const configDir =
|
|
||||||
args.configDir ??
|
|
||||||
resolveConfigDir({
|
|
||||||
homeDir: os.homedir(),
|
|
||||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
|
||||||
existsSync: fs.existsSync,
|
|
||||||
});
|
|
||||||
const scratch = createScratchState(configDir);
|
|
||||||
scratchDir = scratch.dir;
|
|
||||||
const config = new ConfigService(scratch.dir).getConfig();
|
|
||||||
const ankiConfig = config.ankiConnect;
|
|
||||||
const matchMode = ankiConfig.knownWords?.matchMode === 'surface' ? 'surface' : 'headword';
|
|
||||||
const thresholdDays = getMatureIntervalThresholdDays(ankiConfig);
|
|
||||||
const client = createAnkiClient(ankiConfig.url);
|
|
||||||
const cacheScopeKey = getKnownWordCacheLifecycleConfig(ankiConfig);
|
|
||||||
|
|
||||||
const cache = new KnownWordCacheManager({
|
|
||||||
client: { findNotes: (query) => client.findNotes(query), notesInfo: client.notesInfo },
|
|
||||||
getConfig: () => ankiConfig,
|
|
||||||
knownWordCacheStatePath: scratch.cachePath,
|
|
||||||
showStatusNotification: () => {},
|
|
||||||
});
|
|
||||||
// A cache whose persisted scope key no longer matches the config is
|
|
||||||
// discarded on load, so every token would come back unknown.
|
|
||||||
if (!args.refresh && readPersistedCacheScope(scratch.cachePath) !== cacheScopeKey) {
|
|
||||||
process.stderr.write(
|
|
||||||
'warning: the persisted known-word cache was built under different settings and will be ' +
|
|
||||||
'ignored (the app refetches it on its next refresh). Re-run with --refresh to fetch tiers now.\n',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// startLifecycle loads the persisted cache; the refresh timer it arms is
|
|
||||||
// cleared before the event loop can run it.
|
|
||||||
cache.startLifecycle();
|
|
||||||
cache.stopLifecycle();
|
|
||||||
if (args.refresh) {
|
|
||||||
await cache.refresh(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cues = parseSubtitleCues(fs.readFileSync(args.input, 'utf-8'), args.input);
|
|
||||||
const selectedCues = args.limit > 0 ? cues.slice(0, args.limit) : cues;
|
|
||||||
|
|
||||||
const mecabTokenizer = new MecabTokenizer();
|
|
||||||
if (!(await mecabTokenizer.checkAvailability())) {
|
|
||||||
throw new Error('MeCab is not available; tokenization would not match the overlay.');
|
|
||||||
}
|
|
||||||
electronModule = await loadElectronModule();
|
|
||||||
const userDataPath = args.profileCopy
|
|
||||||
? copyYomitanProfile(args.yomitanUserDataPath ?? configDir)
|
|
||||||
: (args.yomitanUserDataPath ?? configDir);
|
|
||||||
profileCopyDir = args.profileCopy ? userDataPath : null;
|
|
||||||
if (electronModule?.app && typeof electronModule.app.setPath === 'function') {
|
|
||||||
electronModule.app.setPath('userData', userDataPath);
|
|
||||||
}
|
|
||||||
yomitanState = await createYomitanRuntimeStateWithSearch(
|
|
||||||
userDataPath,
|
|
||||||
args.yomitanExtensionPath,
|
|
||||||
);
|
|
||||||
if (!yomitanState.available) {
|
|
||||||
throw new Error(`Yomitan tokenizer unavailable: ${yomitanState.note ?? 'unknown reason'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const deps = createTokenizerDepsRuntime({
|
|
||||||
getYomitanExt: () => yomitanState!.yomitanExt as never,
|
|
||||||
getYomitanSession: () => yomitanState!.yomitanSession as never,
|
|
||||||
getYomitanParserWindow: () => yomitanState!.parserWindow as never,
|
|
||||||
setYomitanParserWindow: (window) => {
|
|
||||||
yomitanState!.parserWindow = window;
|
|
||||||
},
|
|
||||||
getYomitanParserReadyPromise: () => yomitanState!.parserReadyPromise as never,
|
|
||||||
setYomitanParserReadyPromise: (promise) => {
|
|
||||||
yomitanState!.parserReadyPromise = promise;
|
|
||||||
},
|
|
||||||
getYomitanParserInitPromise: () => yomitanState!.parserInitPromise as never,
|
|
||||||
setYomitanParserInitPromise: (promise) => {
|
|
||||||
yomitanState!.parserInitPromise = promise;
|
|
||||||
},
|
|
||||||
isKnownWord: (text, reading, options) => cache.isKnownWord(text, reading, options),
|
|
||||||
getKnownWordTier: (text, reading, options) => cache.getKnownWordTier(text, reading, options),
|
|
||||||
getKnownWordMatchMode: () => matchMode,
|
|
||||||
getKnownWordsEnabled: () => true,
|
|
||||||
// Other annotation layers are off so every colored token below is a
|
|
||||||
// known-word decision, not an N+1/frequency/name override.
|
|
||||||
getNPlusOneEnabled: () => false,
|
|
||||||
getNameMatchEnabled: () => false,
|
|
||||||
getJlptEnabled: () => false,
|
|
||||||
getFrequencyDictionaryEnabled: () => false,
|
|
||||||
getJlptLevel: () => null,
|
|
||||||
getMecabTokenizer: () => ({ tokenize: (text: string) => mecabTokenizer.tokenize(text) }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const styleColors = {
|
|
||||||
...FALLBACK_TIER_COLORS,
|
|
||||||
...(config.subtitleStyle?.knownWordMaturityColors ?? {}),
|
|
||||||
} as Record<KnownWordMaturityTier, string>;
|
|
||||||
const knownWordColor = config.subtitleStyle?.knownWordColor ?? '#a6da95';
|
|
||||||
|
|
||||||
const reports: TokenReport[] = [];
|
|
||||||
const tierCounts: Record<string, number> = {};
|
|
||||||
let knownTokens = 0;
|
|
||||||
let totalTokens = 0;
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
for (const [cueIndex, cue] of selectedCues.entries()) {
|
|
||||||
const { text, tokens } = await withTimeout(
|
|
||||||
tokenizeSubtitle(cue.text, deps),
|
|
||||||
20_000,
|
|
||||||
`Tokenizer (cue ${cueIndex + 1})`,
|
|
||||||
);
|
|
||||||
if (!tokens || tokens.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cursor = 0;
|
|
||||||
let rendered = '';
|
|
||||||
const ordered = [...tokens].sort((a, b) => (a.startPos ?? 0) - (b.startPos ?? 0));
|
|
||||||
for (const token of ordered) {
|
|
||||||
totalTokens += 1;
|
|
||||||
const start = Math.min(Math.max(0, token.startPos ?? 0), text.length);
|
|
||||||
const end = Math.min(Math.max(start, token.endPos ?? start), text.length);
|
|
||||||
if (start > cursor) {
|
|
||||||
rendered += text.slice(cursor, start);
|
|
||||||
}
|
|
||||||
const surfaceText = text.slice(start, end);
|
|
||||||
cursor = end;
|
|
||||||
|
|
||||||
if (!token.isKnown) {
|
|
||||||
rendered += surfaceText;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
knownTokens += 1;
|
|
||||||
const tier: TierOrFallback = token.knownMaturity ?? 'known-no-tier';
|
|
||||||
tierCounts[tier] = (tierCounts[tier] ?? 0) + 1;
|
|
||||||
rendered += colorize(
|
|
||||||
surfaceText,
|
|
||||||
tier === 'known-no-tier' ? knownWordColor : styleColors[tier],
|
|
||||||
);
|
|
||||||
reports.push({
|
|
||||||
cueIndex,
|
|
||||||
startTime: cue.startTime,
|
|
||||||
surface: token.surface,
|
|
||||||
headword: token.headword,
|
|
||||||
reading: token.reading,
|
|
||||||
tier,
|
|
||||||
noteIds: [...resolveTokenMatch(token, cache, matchMode).noteIds],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
rendered += text.slice(cursor);
|
|
||||||
lines.push(`${formatTimestamp(cue.startTime)} ${rendered}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (totalTokens === 0 && selectedCues.length > 0) {
|
|
||||||
throw new Error(
|
|
||||||
'Yomitan returned no tokens. SubMiner is probably running and holding the Electron ' +
|
|
||||||
'profile lock - quit it, or re-run with --profile-copy.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let audit: { mismatches: AuditMismatch[]; auditedNotes: number } | null = null;
|
|
||||||
if (args.audit) {
|
|
||||||
audit = await auditTokens(reports, client, thresholdDays);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.json) {
|
|
||||||
process.stdout.write(
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
input: args.input,
|
|
||||||
cues: selectedCues.length,
|
|
||||||
totalTokens,
|
|
||||||
knownTokens,
|
|
||||||
tierCounts,
|
|
||||||
matureThresholdDays: thresholdDays,
|
|
||||||
matchMode,
|
|
||||||
tokens: reports,
|
|
||||||
audit,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
)}\n`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!args.quiet) {
|
|
||||||
process.stdout.write(`${lines.join('\n')}\n\n`);
|
|
||||||
}
|
|
||||||
process.stdout.write(
|
|
||||||
[
|
|
||||||
`file : ${args.input}`,
|
|
||||||
`cues checked : ${selectedCues.length} of ${cues.length}`,
|
|
||||||
`tokens : ${totalTokens} (${knownTokens} known, ${totalTokens - knownTokens} unknown)`,
|
|
||||||
`match mode : ${matchMode} mature threshold: ${thresholdDays}d`,
|
|
||||||
'',
|
|
||||||
'known-token tiers:',
|
|
||||||
...TIERS.map(
|
|
||||||
(tier) =>
|
|
||||||
` ${colorize(tier.padEnd(9), styleColors[tier])} ${String(tierCounts[tier] ?? 0).padStart(5)}` +
|
|
||||||
` ${styleColors[tier]}`,
|
|
||||||
),
|
|
||||||
` ${colorize('no tier'.padEnd(9), knownWordColor)} ${String(tierCounts['known-no-tier'] ?? 0).padStart(5)} ${knownWordColor} (falls back to knownWordColor)`,
|
|
||||||
'',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (audit) {
|
|
||||||
process.stdout.write(
|
|
||||||
`audit: ${reports.length - audit.mismatches.length}/${reports.length} highlighted tokens agree with live Anki card data ` +
|
|
||||||
`(${audit.auditedNotes} notes)\n`,
|
|
||||||
);
|
|
||||||
for (const mismatch of audit.mismatches.slice(0, 40)) {
|
|
||||||
process.stdout.write(
|
|
||||||
` ${formatTimestamp(mismatch.startTime)} ${mismatch.surface} (${mismatch.headword}) ` +
|
|
||||||
`shown=${mismatch.tier} live=${mismatch.liveTier} ivl=[${mismatch.intervals.join(', ')}] ` +
|
|
||||||
`notes=[${mismatch.noteIds.join(', ')}]\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (audit.mismatches.length > 40) {
|
|
||||||
process.stdout.write(` ... ${audit.mismatches.length - 40} more\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
destroyParserWindow(yomitanState?.parserWindow ?? null);
|
|
||||||
for (const dir of [scratchDir, profileCopyDir]) {
|
|
||||||
if (dir) {
|
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (electronModule?.app) {
|
|
||||||
electronModule.app.quit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.then(() => {
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error(`Error: ${(error as Error).message}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import { resolveYomitanExtensionPath as resolveBuiltYomitanExtensionPath } from '../src/core/services/yomitan-extension-paths.js';
|
|
||||||
|
|
||||||
// Yomitan bootstrap for CLI scripts that need the app's real tokenizer. Mirrors
|
|
||||||
// what scripts/get_frequency.ts does inline; new scripts should import this.
|
|
||||||
export interface YomitanRuntimeState {
|
|
||||||
yomitanExt: unknown | null;
|
|
||||||
yomitanSession: unknown | null;
|
|
||||||
parserWindow: unknown | null;
|
|
||||||
parserReadyPromise: Promise<void> | null;
|
|
||||||
parserInitPromise: Promise<boolean> | null;
|
|
||||||
available: boolean;
|
|
||||||
note?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
|
||||||
}, timeoutMs);
|
|
||||||
promise
|
|
||||||
.then((value) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve(value);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function destroyParserWindow(window: unknown): void {
|
|
||||||
if (!window || typeof window !== 'object') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const candidate = window as { isDestroyed?: () => boolean; destroy?: () => void };
|
|
||||||
if (typeof candidate.isDestroyed !== 'function' || typeof candidate.destroy !== 'function') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!candidate.isDestroyed()) {
|
|
||||||
candidate.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadElectronModule(): Promise<typeof import('electron') | null> {
|
|
||||||
try {
|
|
||||||
const electronImport = await import('electron');
|
|
||||||
return (electronImport.default ?? electronImport) as typeof import('electron');
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createYomitanRuntimeState(
|
|
||||||
userDataPath: string,
|
|
||||||
extensionPath?: string,
|
|
||||||
): Promise<YomitanRuntimeState> {
|
|
||||||
const state: YomitanRuntimeState = {
|
|
||||||
yomitanExt: null,
|
|
||||||
yomitanSession: null,
|
|
||||||
parserWindow: null,
|
|
||||||
parserReadyPromise: null,
|
|
||||||
parserInitPromise: null,
|
|
||||||
available: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const electronImport = await loadElectronModule();
|
|
||||||
if (
|
|
||||||
!electronImport ||
|
|
||||||
!electronImport.app ||
|
|
||||||
typeof electronImport.app.whenReady !== 'function' ||
|
|
||||||
!electronImport.session
|
|
||||||
) {
|
|
||||||
state.note = electronImport
|
|
||||||
? 'electron runtime not available in this process'
|
|
||||||
: 'electron import failed';
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await electronImport.app.whenReady();
|
|
||||||
const loadYomitanExtension = (await import('../src/core/services/yomitan-extension-loader.js'))
|
|
||||||
.loadYomitanExtension as (options: {
|
|
||||||
userDataPath: string;
|
|
||||||
extensionPath?: string;
|
|
||||||
getYomitanParserWindow: () => unknown;
|
|
||||||
setYomitanParserWindow: (window: unknown) => void;
|
|
||||||
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
|
|
||||||
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
|
|
||||||
setYomitanExtension: (extension: unknown) => void;
|
|
||||||
setYomitanSession: (session: unknown) => void;
|
|
||||||
}) => Promise<unknown>;
|
|
||||||
|
|
||||||
const extension = await loadYomitanExtension({
|
|
||||||
userDataPath,
|
|
||||||
extensionPath,
|
|
||||||
getYomitanParserWindow: () => state.parserWindow,
|
|
||||||
setYomitanParserWindow: (window) => {
|
|
||||||
state.parserWindow = window;
|
|
||||||
},
|
|
||||||
setYomitanParserReadyPromise: (promise) => {
|
|
||||||
state.parserReadyPromise = promise;
|
|
||||||
},
|
|
||||||
setYomitanParserInitPromise: (promise) => {
|
|
||||||
state.parserInitPromise = promise;
|
|
||||||
},
|
|
||||||
setYomitanExtension: (loaded) => {
|
|
||||||
state.yomitanExt = loaded;
|
|
||||||
},
|
|
||||||
setYomitanSession: (nextSession) => {
|
|
||||||
state.yomitanSession = nextSession;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!extension) {
|
|
||||||
state.note = 'yomitan extension is not available';
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.yomitanExt = extension;
|
|
||||||
state.available = true;
|
|
||||||
return state;
|
|
||||||
} catch (error) {
|
|
||||||
state.note = error instanceof Error ? error.message : 'failed to initialize yomitan extension';
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createYomitanRuntimeStateWithSearch(
|
|
||||||
userDataPath: string,
|
|
||||||
extensionPath?: string,
|
|
||||||
): Promise<YomitanRuntimeState> {
|
|
||||||
const resolvedExtensionPath = resolveBuiltYomitanExtensionPath({
|
|
||||||
explicitPath: extensionPath,
|
|
||||||
cwd: process.cwd(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (resolvedExtensionPath) {
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(path.join(resolvedExtensionPath, 'manifest.json'))) {
|
|
||||||
const state = await createYomitanRuntimeState(userDataPath, resolvedExtensionPath);
|
|
||||||
if (!state.available && !state.note) {
|
|
||||||
state.note = `Failed to load yomitan extension at ${resolvedExtensionPath}`;
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// fall through to the unconstrained loader below
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No usable manifest at the resolved path, so let the loader search on its own.
|
|
||||||
return createYomitanRuntimeState(userDataPath);
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
|
|
||||||
import {
|
|
||||||
KnownWordCacheState,
|
|
||||||
knownWordsFromState,
|
|
||||||
parseKnownWordCacheState,
|
|
||||||
} from './known-word-cache-format';
|
|
||||||
|
|
||||||
function parseOrThrow(value: unknown): KnownWordCacheState {
|
|
||||||
const parsed = parseKnownWordCacheState(value);
|
|
||||||
assert.ok(parsed, 'expected the payload to parse');
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BASE = { refreshedAtMs: 1, scope: 'deck:test' };
|
|
||||||
|
|
||||||
test('known-word cache format reads words from every version the union covers', () => {
|
|
||||||
assert.deepEqual(
|
|
||||||
knownWordsFromState(parseOrThrow({ ...BASE, version: 1, words: ['する'] })),
|
|
||||||
new Set(['する']),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
knownWordsFromState(
|
|
||||||
parseOrThrow({ ...BASE, version: 2, words: ['する'], notes: { '1': ['する'] } }),
|
|
||||||
),
|
|
||||||
new Set(['する']),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
knownWordsFromState(
|
|
||||||
parseOrThrow({
|
|
||||||
...BASE,
|
|
||||||
version: 3,
|
|
||||||
notes: { '1': [{ word: 'する', reading: 'する' }], '2': [{ word: '猫', reading: null }] },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
new Set(['する', '猫']),
|
|
||||||
);
|
|
||||||
|
|
||||||
// v4 only adds `tiers`; the words a reader sees must not change.
|
|
||||||
assert.deepEqual(
|
|
||||||
knownWordsFromState(
|
|
||||||
parseOrThrow({
|
|
||||||
...BASE,
|
|
||||||
version: 4,
|
|
||||||
notes: { '1': [{ word: 'する', reading: 'する' }], '2': [{ word: '猫', reading: null }] },
|
|
||||||
tiers: { '1': 'mature', '2': 'young' },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
new Set(['する', '猫']),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('known-word cache format rejects payloads that are not a known cache state', () => {
|
|
||||||
const notes = { '1': [{ word: 'する', reading: null }] };
|
|
||||||
|
|
||||||
assert.equal(parseKnownWordCacheState(null), null);
|
|
||||||
assert.equal(parseKnownWordCacheState('{}'), null);
|
|
||||||
|
|
||||||
// An unknown version must not be read as an empty cache.
|
|
||||||
assert.equal(parseKnownWordCacheState({ ...BASE, version: 99, notes }), null);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
parseKnownWordCacheState({ version: 4, scope: 'deck:test', notes, tiers: {} }),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
assert.equal(parseKnownWordCacheState({ version: 4, refreshedAtMs: 1, notes, tiers: {} }), null);
|
|
||||||
|
|
||||||
// v4 without its tiers map is a v3 payload mislabelled as v4.
|
|
||||||
assert.equal(parseKnownWordCacheState({ ...BASE, version: 4, notes }), null);
|
|
||||||
|
|
||||||
// v3 carries entry objects, not the bare strings v2 used.
|
|
||||||
assert.equal(parseKnownWordCacheState({ ...BASE, version: 3, notes: { '1': ['する'] } }), null);
|
|
||||||
});
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
// On-disk shape of the known-word cache, plus the only parser for it.
|
|
||||||
//
|
|
||||||
// Two processes read this file: the cache manager (which rebuilds its indexes
|
|
||||||
// from it) and the stats server (which counts known words). They used to carry
|
|
||||||
// separate hand-written parsers, so bumping the format to v4 for maturity tiers
|
|
||||||
// left the stats server silently reporting zero known words. Everything that
|
|
||||||
// touches the format now goes through here, and the version dispatch below ends
|
|
||||||
// in assertNever so adding a V5 to the union fails the build at every consumer
|
|
||||||
// instead of degrading to an empty result at runtime.
|
|
||||||
|
|
||||||
import type { KnownWordMaturityTier } from '../types/subtitle';
|
|
||||||
import type { KnownWordEntry } from './known-word-entries';
|
|
||||||
|
|
||||||
export interface KnownWordCacheStateV1 {
|
|
||||||
readonly version: 1;
|
|
||||||
readonly refreshedAtMs: number;
|
|
||||||
readonly scope: string;
|
|
||||||
readonly words: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KnownWordCacheStateV2 {
|
|
||||||
readonly version: 2;
|
|
||||||
readonly refreshedAtMs: number;
|
|
||||||
readonly scope: string;
|
|
||||||
readonly words: string[];
|
|
||||||
readonly notes: Record<string, string[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KnownWordCacheStateV3 {
|
|
||||||
readonly version: 3;
|
|
||||||
readonly refreshedAtMs: number;
|
|
||||||
readonly scope: string;
|
|
||||||
readonly notes: Record<string, KnownWordEntry[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface KnownWordCacheStateV4 {
|
|
||||||
readonly version: 4;
|
|
||||||
readonly refreshedAtMs: number;
|
|
||||||
readonly scope: string;
|
|
||||||
readonly notes: Record<string, KnownWordEntry[]>;
|
|
||||||
readonly tiers: Record<string, KnownWordMaturityTier>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type KnownWordCacheState =
|
|
||||||
| KnownWordCacheStateV1
|
|
||||||
| KnownWordCacheStateV2
|
|
||||||
| KnownWordCacheStateV3
|
|
||||||
| KnownWordCacheStateV4;
|
|
||||||
|
|
||||||
// Version written by persistKnownWordCacheState. Readers accept every version
|
|
||||||
// in the union above; only the writer pins one.
|
|
||||||
export type CurrentKnownWordCacheState = KnownWordCacheStateV4;
|
|
||||||
|
|
||||||
// Exported so every consumer that switches on `version` can close its dispatch
|
|
||||||
// the same way: a new member of the union becomes a type error at each call
|
|
||||||
// site rather than a case that silently falls through.
|
|
||||||
export function assertNever(value: never): never {
|
|
||||||
throw new Error(`Unhandled known-word cache state: ${JSON.stringify(value)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEntryRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isKnownWordEntry(entry: unknown): boolean {
|
|
||||||
if (!isEntryRecord(entry)) return false;
|
|
||||||
const candidate = entry as Partial<KnownWordEntry>;
|
|
||||||
return (
|
|
||||||
typeof candidate.word === 'string' &&
|
|
||||||
(candidate.reading === null || typeof candidate.reading === 'string')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the narrowed state, or null when the payload is not a cache state we
|
|
||||||
// recognize. Per-entry values that are merely unusable (an unknown maturity
|
|
||||||
// tier, a non-numeric note id) are dropped by callers at load time rather than
|
|
||||||
// rejecting the whole file.
|
|
||||||
export function parseKnownWordCacheState(value: unknown): KnownWordCacheState | null {
|
|
||||||
if (!isEntryRecord(value)) return null;
|
|
||||||
const candidate = value;
|
|
||||||
if (
|
|
||||||
candidate.version !== 1 &&
|
|
||||||
candidate.version !== 2 &&
|
|
||||||
candidate.version !== 3 &&
|
|
||||||
candidate.version !== 4
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (typeof candidate.refreshedAtMs !== 'number') return null;
|
|
||||||
if (typeof candidate.scope !== 'string') return null;
|
|
||||||
|
|
||||||
if (candidate.version === 1 || candidate.version === 2) {
|
|
||||||
if (!Array.isArray(candidate.words)) return null;
|
|
||||||
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candidate.version === 4) {
|
|
||||||
// Per-tier values are sanitized entry-by-entry at load time.
|
|
||||||
if (!isEntryRecord(candidate.tiers)) return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candidate.version === 2 || candidate.version === 3 || candidate.version === 4) {
|
|
||||||
if (!isEntryRecord(candidate.notes)) return null;
|
|
||||||
const isValidNoteEntry =
|
|
||||||
candidate.version === 2
|
|
||||||
? (entry: unknown): boolean => typeof entry === 'string'
|
|
||||||
: isKnownWordEntry;
|
|
||||||
if (
|
|
||||||
!Object.values(candidate.notes).every(
|
|
||||||
(noteEntries) => Array.isArray(noteEntries) && noteEntries.every(isValidNoteEntry),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidate as unknown as KnownWordCacheState;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every word the cache considers known, flattened across notes. Consumers that
|
|
||||||
// only need membership (the stats server) use this instead of walking the
|
|
||||||
// version-specific layout themselves.
|
|
||||||
export function knownWordsFromState(state: KnownWordCacheState): Set<string> {
|
|
||||||
switch (state.version) {
|
|
||||||
case 1:
|
|
||||||
case 2:
|
|
||||||
return new Set(state.words);
|
|
||||||
case 3:
|
|
||||||
case 4: {
|
|
||||||
const words = new Set<string>();
|
|
||||||
for (const entries of Object.values(state.notes)) {
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.word) words.add(entry.word);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return words;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return assertNever(state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -69,18 +69,6 @@ function createMaturityHarness(config: AnkiConnectConfig): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// The four queries a maturity refresh issues, in one place so a query-string
|
|
||||||
// change lands in a single spot.
|
|
||||||
function setTierQueries(
|
|
||||||
clientState: { findNotesByQuery: Map<string, number[]> },
|
|
||||||
tiers: { all: number[]; mature: number[]; young: number[]; learning: number[] },
|
|
||||||
): void {
|
|
||||||
clientState.findNotesByQuery.set('deck:"Mining"', tiers.all);
|
|
||||||
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', tiers.mature);
|
|
||||||
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', tiers.young);
|
|
||||||
clientState.findNotesByQuery.set('deck:"Mining" is:learn', tiers.learning);
|
|
||||||
}
|
|
||||||
|
|
||||||
function maturityConfig(overrides: Partial<AnkiConnectConfig> = {}): AnkiConnectConfig {
|
function maturityConfig(overrides: Partial<AnkiConnectConfig> = {}): AnkiConnectConfig {
|
||||||
return {
|
return {
|
||||||
deck: 'Mining',
|
deck: 'Mining',
|
||||||
@@ -110,7 +98,7 @@ test('lifecycle config key is unchanged when maturity is disabled', () => {
|
|||||||
};
|
};
|
||||||
assert.equal(
|
assert.equal(
|
||||||
getKnownWordCacheLifecycleConfig(enabled),
|
getKnownWordCacheLifecycleConfig(enabled),
|
||||||
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21,"maturityRules":2}',
|
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21}',
|
||||||
);
|
);
|
||||||
|
|
||||||
const customThreshold: AnkiConnectConfig = {
|
const customThreshold: AnkiConnectConfig = {
|
||||||
@@ -123,19 +111,7 @@ test('lifecycle config key is unchanged when maturity is disabled', () => {
|
|||||||
};
|
};
|
||||||
assert.equal(
|
assert.equal(
|
||||||
getKnownWordCacheLifecycleConfig(customThreshold),
|
getKnownWordCacheLifecycleConfig(customThreshold),
|
||||||
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":30,"maturityRules":2}',
|
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":30}',
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a cache built under the old tier rules is invalidated', () => {
|
|
||||||
const config: AnkiConnectConfig = {
|
|
||||||
knownWords: { highlightEnabled: true, maturityEnabled: true, refreshMinutes: 60 },
|
|
||||||
};
|
|
||||||
// v1 rules put lapsed cards in young because the interval queries did not
|
|
||||||
// exclude is:learn; those persisted tiers must not be served under v2.
|
|
||||||
assert.notEqual(
|
|
||||||
getKnownWordCacheLifecycleConfig(config),
|
|
||||||
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21}',
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,7 +119,10 @@ test('refresh fetches tier sets and getKnownWordTier classifies notes', async ()
|
|||||||
const { manager, calls, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
const { manager, calls, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setTierQueries(clientState, { all: [1, 2, 3, 4], mature: [1], young: [2], learning: [3] });
|
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2, 3, 4]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [2]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [3]);
|
||||||
clientState.notesInfoResult = [
|
clientState.notesInfoResult = [
|
||||||
{ noteId: 1, fields: { Word: { value: '猫' } } },
|
{ noteId: 1, fields: { Word: { value: '猫' } } },
|
||||||
{ noteId: 2, fields: { Word: { value: '犬' } } },
|
{ noteId: 2, fields: { Word: { value: '犬' } } },
|
||||||
@@ -171,7 +150,10 @@ test('a note with cards in several tiers counts as its most mature card', async
|
|||||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setTierQueries(clientState, { all: [1], mature: [1], young: [1], learning: [1] });
|
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [1]);
|
||||||
clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];
|
clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];
|
||||||
|
|
||||||
await manager.refresh(true);
|
await manager.refresh(true);
|
||||||
@@ -186,7 +168,10 @@ test('a word matched by several notes takes the most mature note tier', async ()
|
|||||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setTierQueries(clientState, { all: [1, 2], mature: [], young: [2], learning: [1] });
|
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', []);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [2]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [1]);
|
||||||
clientState.notesInfoResult = [
|
clientState.notesInfoResult = [
|
||||||
{ noteId: 1, fields: { Word: { value: '猫' } } },
|
{ noteId: 1, fields: { Word: { value: '猫' } } },
|
||||||
{ noteId: 2, fields: { Word: { value: '猫' } } },
|
{ noteId: 2, fields: { Word: { value: '猫' } } },
|
||||||
@@ -204,7 +189,10 @@ test('tiers are reading-aware for words with several readings', async () => {
|
|||||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setTierQueries(clientState, { all: [1, 2], mature: [1], young: [], learning: [2] });
|
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [2]);
|
||||||
clientState.notesInfoResult = [
|
clientState.notesInfoResult = [
|
||||||
{ noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } },
|
{ noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } },
|
||||||
{ noteId: 2, fields: { Word: { value: '床' }, Reading: { value: 'ゆか' } } },
|
{ noteId: 2, fields: { Word: { value: '床' }, Reading: { value: 'ゆか' } } },
|
||||||
@@ -227,7 +215,10 @@ test('reading-only fallback resolves tiers unless opted out', async () => {
|
|||||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setTierQueries(clientState, { all: [1], mature: [1], young: [], learning: [] });
|
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', []);
|
||||||
clientState.notesInfoResult = [
|
clientState.notesInfoResult = [
|
||||||
{ noteId: 1, fields: { Word: { value: '警告' }, Reading: { value: 'けいこく' } } },
|
{ noteId: 1, fields: { Word: { value: '警告' }, Reading: { value: 'けいこく' } } },
|
||||||
];
|
];
|
||||||
@@ -274,7 +265,7 @@ test('refresh preserves known-word cache when maturity lookup fails', async () =
|
|||||||
infoLogs.push(args.map((value) => String(value)).join(' '));
|
infoLogs.push(args.map((value) => String(value)).join(' '));
|
||||||
};
|
};
|
||||||
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
|
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
|
||||||
clientState.failedQueries.add('deck:"Mining" prop:ivl>=21 -is:learn');
|
clientState.failedQueries.add('deck:"Mining" prop:ivl>=21');
|
||||||
clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];
|
clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];
|
||||||
|
|
||||||
await manager.refresh(true);
|
await manager.refresh(true);
|
||||||
@@ -302,7 +293,10 @@ test('tiers persist to v4 state and reload without refetching', async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
Date.now = () => 120_000;
|
Date.now = () => 120_000;
|
||||||
setTierQueries(clientState, { all: [1, 2], mature: [1], young: [], learning: [2] });
|
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [2]);
|
||||||
clientState.notesInfoResult = [
|
clientState.notesInfoResult = [
|
||||||
{ noteId: 1, fields: { Word: { value: '猫' } } },
|
{ noteId: 1, fields: { Word: { value: '猫' } } },
|
||||||
{ noteId: 2, fields: { Word: { value: '犬' } } },
|
{ noteId: 2, fields: { Word: { value: '犬' } } },
|
||||||
@@ -353,7 +347,10 @@ test('appendFromNoteInfo preserves an existing maturity tier', async () => {
|
|||||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setTierQueries(clientState, { all: [7, 8], mature: [7], young: [], learning: [8] });
|
clientState.findNotesByQuery.set('deck:"Mining"', [7, 8]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [7]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [8]);
|
||||||
clientState.notesInfoResult = [
|
clientState.notesInfoResult = [
|
||||||
{ noteId: 7, fields: { Word: { value: '猫' } } },
|
{ noteId: 7, fields: { Word: { value: '猫' } } },
|
||||||
{ noteId: 8, fields: { Word: { value: '犬' } } },
|
{ noteId: 8, fields: { Word: { value: '犬' } } },
|
||||||
@@ -375,37 +372,3 @@ test('appendFromNoteInfo preserves an existing maturity tier', async () => {
|
|||||||
cleanup();
|
cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getKnownWordMatchNoteIds reports the notes behind a tier', async () => {
|
|
||||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
|
||||||
|
|
||||||
try {
|
|
||||||
setTierQueries(clientState, { all: [1, 2, 3], mature: [1], young: [2], learning: [3] });
|
|
||||||
clientState.notesInfoResult = [
|
|
||||||
{ noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } },
|
|
||||||
{ noteId: 2, fields: { Word: { value: '床' }, Reading: { value: 'ゆか' } } },
|
|
||||||
{ noteId: 3, fields: { Word: { value: '警告' }, Reading: { value: 'けいこく' } } },
|
|
||||||
];
|
|
||||||
|
|
||||||
await manager.refresh(true);
|
|
||||||
|
|
||||||
// Same matching rules as getKnownWordTier, so an audit can re-derive the
|
|
||||||
// rendered tier from the exact notes that produced it.
|
|
||||||
assert.deepEqual([...manager.getKnownWordMatchNoteIds('床', 'とこ')], [1]);
|
|
||||||
assert.deepEqual([...manager.getKnownWordMatchNoteIds('床', 'ゆか')], [2]);
|
|
||||||
assert.deepEqual([...manager.getKnownWordMatchNoteIds('床')].sort(), [1, 2]);
|
|
||||||
assert.deepEqual([...manager.getKnownWordMatchNoteIds('床', 'しょう')], []);
|
|
||||||
assert.deepEqual([...manager.getKnownWordMatchNoteIds('けいこく')], [3]);
|
|
||||||
assert.deepEqual(
|
|
||||||
[
|
|
||||||
...manager.getKnownWordMatchNoteIds('けいこく', undefined, {
|
|
||||||
allowReadingOnlyMatch: false,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
assert.deepEqual([...manager.getKnownWordMatchNoteIds('馬')], []);
|
|
||||||
} finally {
|
|
||||||
cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { AnkiConnectConfig } from '../types/anki';
|
|||||||
import type { KnownWordMaturityTier } from '../types/subtitle';
|
import type { KnownWordMaturityTier } from '../types/subtitle';
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
import {
|
import {
|
||||||
KNOWN_WORD_MATURITY_RULES_VERSION,
|
|
||||||
classifyKnownWordNoteTier,
|
classifyKnownWordNoteTier,
|
||||||
fetchKnownWordMaturityTierSets,
|
fetchKnownWordMaturityTierSets,
|
||||||
getKnownWordMaturityEnabled,
|
getKnownWordMaturityEnabled,
|
||||||
@@ -15,11 +14,6 @@ import {
|
|||||||
maxKnownWordMaturityTier,
|
maxKnownWordMaturityTier,
|
||||||
sanitizeKnownWordMaturityTier,
|
sanitizeKnownWordMaturityTier,
|
||||||
} from './known-word-maturity';
|
} from './known-word-maturity';
|
||||||
import {
|
|
||||||
CurrentKnownWordCacheState,
|
|
||||||
assertNever,
|
|
||||||
parseKnownWordCacheState,
|
|
||||||
} from './known-word-cache-format';
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_KNOWN_WORD_READING_FIELDS,
|
DEFAULT_KNOWN_WORD_READING_FIELDS,
|
||||||
KnownWordEntry,
|
KnownWordEntry,
|
||||||
@@ -82,14 +76,10 @@ export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): str
|
|||||||
scope: getKnownWordCacheScopeForConfig(config),
|
scope: getKnownWordCacheScopeForConfig(config),
|
||||||
fieldsWord: trimToNonEmptyString(config.fields?.word) ?? '',
|
fieldsWord: trimToNonEmptyString(config.fields?.word) ?? '',
|
||||||
};
|
};
|
||||||
// The maturity fields are only added while enabled so persisted caches from
|
// The maturity field is only added while enabled so persisted caches from
|
||||||
// before the feature existed (or with it off) keep their identity.
|
// before the feature existed (or with it off) keep their identity.
|
||||||
// maturityRules is the classification-rule version: bump it whenever the tier
|
|
||||||
// queries change meaning so existing caches refetch instead of serving tiers
|
|
||||||
// computed under the old rules.
|
|
||||||
if (getKnownWordMaturityEnabled(config)) {
|
if (getKnownWordMaturityEnabled(config)) {
|
||||||
payload.maturity = getMatureIntervalThresholdDays(config);
|
payload.maturity = getMatureIntervalThresholdDays(config);
|
||||||
payload.maturityRules = KNOWN_WORD_MATURITY_RULES_VERSION;
|
|
||||||
}
|
}
|
||||||
return JSON.stringify(payload);
|
return JSON.stringify(payload);
|
||||||
}
|
}
|
||||||
@@ -99,6 +89,42 @@ export interface KnownWordCacheNoteInfo {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface KnownWordCacheStateV1 {
|
||||||
|
readonly version: 1;
|
||||||
|
readonly refreshedAtMs: number;
|
||||||
|
readonly scope: string;
|
||||||
|
readonly words: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KnownWordCacheStateV2 {
|
||||||
|
readonly version: 2;
|
||||||
|
readonly refreshedAtMs: number;
|
||||||
|
readonly scope: string;
|
||||||
|
readonly words: string[];
|
||||||
|
readonly notes: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KnownWordCacheStateV3 {
|
||||||
|
readonly version: 3;
|
||||||
|
readonly refreshedAtMs: number;
|
||||||
|
readonly scope: string;
|
||||||
|
readonly notes: Record<string, KnownWordEntry[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KnownWordCacheStateV4 {
|
||||||
|
readonly version: 4;
|
||||||
|
readonly refreshedAtMs: number;
|
||||||
|
readonly scope: string;
|
||||||
|
readonly notes: Record<string, KnownWordEntry[]>;
|
||||||
|
readonly tiers: Record<string, KnownWordMaturityTier>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type KnownWordCacheState =
|
||||||
|
| KnownWordCacheStateV1
|
||||||
|
| KnownWordCacheStateV2
|
||||||
|
| KnownWordCacheStateV3
|
||||||
|
| KnownWordCacheStateV4;
|
||||||
|
|
||||||
const NO_READING_KEY = '';
|
const NO_READING_KEY = '';
|
||||||
|
|
||||||
interface KnownWordCacheClient {
|
interface KnownWordCacheClient {
|
||||||
@@ -200,56 +226,43 @@ export class KnownWordCacheManager {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.maxTierForNotes(null, this.getKnownWordMatchNoteIds(text, reading, options));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note ids a known-word lookup matches, using the same matching rules as
|
|
||||||
// getKnownWordTier. Exposed for diagnostics (see
|
|
||||||
// scripts/verify-known-word-highlights.ts), which audits a rendered tier
|
|
||||||
// against the live card data of the notes that produced it.
|
|
||||||
getKnownWordMatchNoteIds(
|
|
||||||
text: string,
|
|
||||||
reading?: string,
|
|
||||||
options?: { allowReadingOnlyMatch?: boolean },
|
|
||||||
): Set<number> {
|
|
||||||
const matches = new Set<number>();
|
|
||||||
const normalized = this.normalizeKnownWordForLookup(text);
|
const normalized = this.normalizeKnownWordForLookup(text);
|
||||||
if (normalized.length === 0) {
|
if (normalized.length === 0) {
|
||||||
return matches;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const knownReadings = this.wordReadingNoteIds.get(normalized);
|
const knownReadings = this.wordReadingNoteIds.get(normalized);
|
||||||
if (knownReadings && knownReadings.size > 0) {
|
if (knownReadings && knownReadings.size > 0) {
|
||||||
const normalizedReading =
|
const normalizedReading =
|
||||||
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
|
typeof reading === 'string' ? normalizeKnownReadingForLookup(reading) : '';
|
||||||
|
let tier: KnownWordMaturityTier | null = null;
|
||||||
if (normalizedReading.length === 0) {
|
if (normalizedReading.length === 0) {
|
||||||
for (const noteIds of knownReadings.values()) {
|
for (const noteIds of knownReadings.values()) {
|
||||||
for (const noteId of noteIds) {
|
tier = this.maxTierForNotes(tier, noteIds);
|
||||||
matches.add(noteId);
|
|
||||||
}
|
}
|
||||||
|
return tier;
|
||||||
}
|
}
|
||||||
return matches;
|
const noReadingNotes = knownReadings.get(NO_READING_KEY);
|
||||||
|
if (noReadingNotes) {
|
||||||
|
tier = this.maxTierForNotes(tier, noReadingNotes);
|
||||||
}
|
}
|
||||||
for (const key of [NO_READING_KEY, normalizedReading]) {
|
const exactReadingNotes = knownReadings.get(normalizedReading);
|
||||||
for (const noteId of knownReadings.get(key) ?? []) {
|
if (exactReadingNotes) {
|
||||||
matches.add(noteId);
|
tier = this.maxTierForNotes(tier, exactReadingNotes);
|
||||||
}
|
}
|
||||||
}
|
return tier;
|
||||||
return matches;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.allowReadingOnlyMatch === false) {
|
if (options?.allowReadingOnlyMatch === false) {
|
||||||
return matches;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hiragana = convertKatakanaToHiragana(normalized);
|
const hiragana = convertKatakanaToHiragana(normalized);
|
||||||
if ([...hiragana].length === 1) {
|
if ([...hiragana].length === 1) {
|
||||||
return matches;
|
return null;
|
||||||
}
|
}
|
||||||
for (const noteId of this.readingNoteIds.get(hiragana) ?? []) {
|
const readingNotes = this.readingNoteIds.get(hiragana);
|
||||||
matches.add(noteId);
|
return readingNotes ? this.maxTierForNotes(null, readingNotes) : null;
|
||||||
}
|
|
||||||
return matches;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private maxTierForNotes(
|
private maxTierForNotes(
|
||||||
@@ -811,8 +824,8 @@ export class KnownWordCacheManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parseKnownWordCacheState(JSON.parse(raw) as unknown);
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
if (!parsed) {
|
if (!this.isKnownWordCacheStateValid(parsed)) {
|
||||||
this.clearInMemoryState();
|
this.clearInMemoryState();
|
||||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||||
return;
|
return;
|
||||||
@@ -825,36 +838,7 @@ export class KnownWordCacheManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.clearInMemoryState();
|
this.clearInMemoryState();
|
||||||
switch (parsed.version) {
|
if (parsed.version === 3 || parsed.version === 4) {
|
||||||
case 1:
|
|
||||||
// v1 has no per-note snapshots to convert; refetch from Anki.
|
|
||||||
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
|
||||||
return;
|
|
||||||
case 2:
|
|
||||||
// Older states have no readings; load them reading-less (fail-open,
|
|
||||||
// matching the old behavior) but leave the cache marked stale so the
|
|
||||||
// next refresh upgrades entries with readings from Anki.
|
|
||||||
for (const [noteIdKey, words] of Object.entries(parsed.notes)) {
|
|
||||||
const noteId = Number.parseInt(noteIdKey, 10);
|
|
||||||
if (!Number.isInteger(noteId) || noteId <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const normalizedEntries = normalizeKnownWordEntryList(
|
|
||||||
words.map((word) => ({
|
|
||||||
word: this.normalizeKnownWordForLookup(word),
|
|
||||||
reading: null,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
if (normalizedEntries.length === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
this.noteEntriesById.set(noteId, normalizedEntries);
|
|
||||||
this.addEntriesToIndexes(noteId, normalizedEntries);
|
|
||||||
}
|
|
||||||
this.knownWordsStateKey = parsed.scope;
|
|
||||||
return;
|
|
||||||
case 3:
|
|
||||||
case 4:
|
|
||||||
for (const [noteIdKey, entries] of Object.entries(parsed.notes)) {
|
for (const [noteIdKey, entries] of Object.entries(parsed.notes)) {
|
||||||
const noteId = Number.parseInt(noteIdKey, 10);
|
const noteId = Number.parseInt(noteIdKey, 10);
|
||||||
if (!Number.isInteger(noteId) || noteId <= 0) {
|
if (!Number.isInteger(noteId) || noteId <= 0) {
|
||||||
@@ -879,9 +863,32 @@ export class KnownWordCacheManager {
|
|||||||
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
|
this.knownWordsLastRefreshedAtMs = parsed.refreshedAtMs;
|
||||||
this.knownWordsStateKey = parsed.scope;
|
this.knownWordsStateKey = parsed.scope;
|
||||||
return;
|
return;
|
||||||
default:
|
|
||||||
assertNever(parsed);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed.version === 2) {
|
||||||
|
// Older states have no readings; load them reading-less (fail-open,
|
||||||
|
// matching the old behavior) but leave the cache marked stale so the
|
||||||
|
// next refresh upgrades entries with readings from Anki.
|
||||||
|
for (const [noteIdKey, words] of Object.entries(parsed.notes)) {
|
||||||
|
const noteId = Number.parseInt(noteIdKey, 10);
|
||||||
|
if (!Number.isInteger(noteId) || noteId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const normalizedEntries = normalizeKnownWordEntryList(
|
||||||
|
words.map((word) => ({ word: this.normalizeKnownWordForLookup(word), reading: null })),
|
||||||
|
);
|
||||||
|
if (normalizedEntries.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.noteEntriesById.set(noteId, normalizedEntries);
|
||||||
|
this.addEntriesToIndexes(noteId, normalizedEntries);
|
||||||
|
}
|
||||||
|
this.knownWordsStateKey = parsed.scope;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1 has no per-note snapshots to convert; refetch from Anki.
|
||||||
|
this.knownWordsStateKey = this.getKnownWordCacheStateKey();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn('Failed to load known-word cache state:', (error as Error).message);
|
log.warn('Failed to load known-word cache state:', (error as Error).message);
|
||||||
this.clearInMemoryState();
|
this.clearInMemoryState();
|
||||||
@@ -903,7 +910,7 @@ export class KnownWordCacheManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const state: CurrentKnownWordCacheState = {
|
const state: KnownWordCacheStateV4 = {
|
||||||
version: 4,
|
version: 4,
|
||||||
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
|
refreshedAtMs: this.knownWordsLastRefreshedAtMs,
|
||||||
scope: this.knownWordsStateKey,
|
scope: this.knownWordsStateKey,
|
||||||
@@ -916,6 +923,63 @@ export class KnownWordCacheManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isKnownWordCacheStateValid(value: unknown): value is KnownWordCacheState {
|
||||||
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
|
const candidate = value as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
candidate.version !== 1 &&
|
||||||
|
candidate.version !== 2 &&
|
||||||
|
candidate.version !== 3 &&
|
||||||
|
candidate.version !== 4
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof candidate.refreshedAtMs !== 'number') return false;
|
||||||
|
if (typeof candidate.scope !== 'string') return false;
|
||||||
|
if (candidate.version === 1 || candidate.version === 2) {
|
||||||
|
if (!Array.isArray(candidate.words)) return false;
|
||||||
|
if (!candidate.words.every((entry: unknown) => typeof entry === 'string')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidate.version === 4) {
|
||||||
|
// Per-tier values are sanitized entry-by-entry at load time.
|
||||||
|
if (
|
||||||
|
typeof candidate.tiers !== 'object' ||
|
||||||
|
candidate.tiers === null ||
|
||||||
|
Array.isArray(candidate.tiers)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (candidate.version === 2 || candidate.version === 3 || candidate.version === 4) {
|
||||||
|
if (
|
||||||
|
typeof candidate.notes !== 'object' ||
|
||||||
|
candidate.notes === null ||
|
||||||
|
Array.isArray(candidate.notes)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const isValidNoteEntry =
|
||||||
|
candidate.version === 2
|
||||||
|
? (entry: unknown): boolean => typeof entry === 'string'
|
||||||
|
: (entry: unknown): boolean =>
|
||||||
|
typeof entry === 'object' &&
|
||||||
|
entry !== null &&
|
||||||
|
typeof (entry as KnownWordEntry).word === 'string' &&
|
||||||
|
((entry as KnownWordEntry).reading === null ||
|
||||||
|
typeof (entry as KnownWordEntry).reading === 'string');
|
||||||
|
if (
|
||||||
|
!Object.values(candidate.notes as Record<string, unknown>).every(
|
||||||
|
(noteEntries) => Array.isArray(noteEntries) && noteEntries.every(isValidNoteEntry),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private extractKnownWordEntriesFromNoteInfo(
|
private extractKnownWordEntriesFromNoteInfo(
|
||||||
noteInfo: KnownWordCacheNoteInfo,
|
noteInfo: KnownWordCacheNoteInfo,
|
||||||
preferredFields = this.getConfiguredFields(),
|
preferredFields = this.getConfiguredFields(),
|
||||||
|
|||||||
@@ -46,26 +46,15 @@ test('mature threshold falls back to default for invalid values', () => {
|
|||||||
|
|
||||||
test('tier queries append Anki search props to a deck scope query', () => {
|
test('tier queries append Anki search props to a deck scope query', () => {
|
||||||
const queries = buildKnownWordMaturityTierQueries('deck:"Mining"', 21);
|
const queries = buildKnownWordMaturityTierQueries('deck:"Mining"', 21);
|
||||||
assert.equal(queries.mature, 'deck:"Mining" prop:ivl>=21 -is:learn');
|
assert.equal(queries.mature, 'deck:"Mining" prop:ivl>=21');
|
||||||
assert.equal(queries.young, 'deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn');
|
assert.equal(queries.young, 'deck:"Mining" prop:ivl>=1 prop:ivl<21');
|
||||||
assert.equal(queries.learning, 'deck:"Mining" is:learn');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('interval tiers exclude (re)learning cards so the buckets stay disjoint', () => {
|
|
||||||
const queries = buildKnownWordMaturityTierQueries('deck:"Mining"', 21);
|
|
||||||
// A lapsed card keeps an interval of at least the lapse minInt (>= 1), so
|
|
||||||
// without the exclusion the young query would claim every relearning card
|
|
||||||
// and the learning tier could only ever match brand-new cards mid-step.
|
|
||||||
for (const intervalQuery of [queries.mature, queries.young]) {
|
|
||||||
assert.ok(intervalQuery.includes('-is:learn'));
|
|
||||||
}
|
|
||||||
assert.equal(queries.learning, 'deck:"Mining" is:learn');
|
assert.equal(queries.learning, 'deck:"Mining" is:learn');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tier queries with an empty scope query have no leading space', () => {
|
test('tier queries with an empty scope query have no leading space', () => {
|
||||||
const queries = buildKnownWordMaturityTierQueries('', 30);
|
const queries = buildKnownWordMaturityTierQueries('', 30);
|
||||||
assert.equal(queries.mature, 'prop:ivl>=30 -is:learn');
|
assert.equal(queries.mature, 'prop:ivl>=30');
|
||||||
assert.equal(queries.young, 'prop:ivl>=1 prop:ivl<30 -is:learn');
|
assert.equal(queries.young, 'prop:ivl>=1 prop:ivl<30');
|
||||||
assert.equal(queries.learning, 'is:learn');
|
assert.equal(queries.learning, 'is:learn');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ import type { KnownWordMaturityTier } from '../types/subtitle';
|
|||||||
|
|
||||||
export const DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS = 21;
|
export const DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS = 21;
|
||||||
|
|
||||||
// Version of the tier classification rules; part of the known-word cache
|
|
||||||
// identity so a rule change invalidates caches built under the old rules.
|
|
||||||
export const KNOWN_WORD_MATURITY_RULES_VERSION = 2;
|
|
||||||
|
|
||||||
// Ascending maturity; index order backs tier comparison.
|
// Ascending maturity; index order backs tier comparison.
|
||||||
const TIER_ORDER: readonly KnownWordMaturityTier[] = ['new', 'learning', 'young', 'mature'];
|
const TIER_ORDER: readonly KnownWordMaturityTier[] = ['new', 'learning', 'young', 'mature'];
|
||||||
|
|
||||||
@@ -41,20 +37,14 @@ export function getMatureIntervalThresholdDays(config: AnkiConnectConfig): numbe
|
|||||||
// Anki search props classify notes server-side: a note matches a tier query
|
// Anki search props classify notes server-side: a note matches a tier query
|
||||||
// when ANY of its cards matches, which implements most-mature-card-wins for
|
// when ANY of its cards matches, which implements most-mature-card-wins for
|
||||||
// free once tiers are checked in mature > young > learning order.
|
// free once tiers are checked in mature > young > learning order.
|
||||||
//
|
|
||||||
// The interval tiers exclude is:learn so the per-card buckets stay disjoint and
|
|
||||||
// match Anki's own card counts, where (re)learning is its own bucket rather
|
|
||||||
// than part of young/mature. Without the exclusion a lapsed card - whose
|
|
||||||
// interval is reset to at least lapse minInt, so >= 1 - is caught by the young
|
|
||||||
// query first and the learning tier becomes unreachable in practice.
|
|
||||||
export function buildKnownWordMaturityTierQueries(
|
export function buildKnownWordMaturityTierQueries(
|
||||||
scopeQuery: string,
|
scopeQuery: string,
|
||||||
thresholdDays: number,
|
thresholdDays: number,
|
||||||
): KnownWordMaturityTierQueries {
|
): KnownWordMaturityTierQueries {
|
||||||
const prefix = scopeQuery.trim().length > 0 ? `${scopeQuery.trim()} ` : '';
|
const prefix = scopeQuery.trim().length > 0 ? `${scopeQuery.trim()} ` : '';
|
||||||
return {
|
return {
|
||||||
mature: `${prefix}prop:ivl>=${thresholdDays} -is:learn`,
|
mature: `${prefix}prop:ivl>=${thresholdDays}`,
|
||||||
young: `${prefix}prop:ivl>=1 prop:ivl<${thresholdDays} -is:learn`,
|
young: `${prefix}prop:ivl>=1 prop:ivl<${thresholdDays}`,
|
||||||
learning: `${prefix}is:learn`,
|
learning: `${prefix}is:learn`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -328,54 +328,3 @@ test('warns and falls back for invalid proxy settings', () => {
|
|||||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.proxy.port'));
|
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.proxy.port'));
|
||||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.proxy.upstreamUrl'));
|
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.proxy.upstreamUrl'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolves knownWords maturity settings from config', () => {
|
|
||||||
const { context, warnings } = makeContext({
|
|
||||||
knownWords: { highlightEnabled: true, maturityEnabled: true, matureThresholdDays: 30 },
|
|
||||||
});
|
|
||||||
|
|
||||||
applyAnkiConnectResolution(context);
|
|
||||||
|
|
||||||
assert.equal(context.resolved.ankiConnect.knownWords.maturityEnabled, true);
|
|
||||||
assert.equal(context.resolved.ankiConnect.knownWords.matureThresholdDays, 30);
|
|
||||||
assert.deepEqual(warnings, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('warns and falls back for invalid knownWords maturity settings', () => {
|
|
||||||
const { context, warnings } = makeContext({
|
|
||||||
knownWords: { maturityEnabled: 'yes', matureThresholdDays: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
applyAnkiConnectResolution(context);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
context.resolved.ankiConnect.knownWords.maturityEnabled,
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.maturityEnabled,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
context.resolved.ankiConnect.knownWords.matureThresholdDays,
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.matureThresholdDays,
|
|
||||||
);
|
|
||||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.maturityEnabled'));
|
|
||||||
assert.ok(
|
|
||||||
warnings.some((warning) => warning.path === 'ankiConnect.knownWords.matureThresholdDays'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('omitted knownWords maturity settings fall back to defaults', () => {
|
|
||||||
const { context, warnings } = makeContext({
|
|
||||||
knownWords: { highlightEnabled: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
applyAnkiConnectResolution(context);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
context.resolved.ankiConnect.knownWords.maturityEnabled,
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.maturityEnabled,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
context.resolved.ankiConnect.knownWords.matureThresholdDays,
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.matureThresholdDays,
|
|
||||||
);
|
|
||||||
assert.deepEqual(warnings, []);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -49,46 +49,6 @@ export function applyAnkiKnownWordsResolution(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const knownWordsMaturityEnabled = asBoolean(knownWordsConfig.maturityEnabled);
|
|
||||||
if (knownWordsMaturityEnabled !== undefined) {
|
|
||||||
context.resolved.ankiConnect.knownWords.maturityEnabled = knownWordsMaturityEnabled;
|
|
||||||
} else if (hasOwn(knownWordsConfig, 'maturityEnabled')) {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.knownWords.maturityEnabled',
|
|
||||||
knownWordsConfig.maturityEnabled,
|
|
||||||
context.resolved.ankiConnect.knownWords.maturityEnabled,
|
|
||||||
'Expected boolean.',
|
|
||||||
);
|
|
||||||
context.resolved.ankiConnect.knownWords.maturityEnabled =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.maturityEnabled;
|
|
||||||
} else {
|
|
||||||
context.resolved.ankiConnect.knownWords.maturityEnabled =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.maturityEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
const knownWordsMatureThresholdDays = asNumber(knownWordsConfig.matureThresholdDays);
|
|
||||||
const hasValidMatureThresholdDays =
|
|
||||||
knownWordsMatureThresholdDays !== undefined &&
|
|
||||||
Number.isInteger(knownWordsMatureThresholdDays) &&
|
|
||||||
knownWordsMatureThresholdDays >= 1;
|
|
||||||
if (hasOwn(knownWordsConfig, 'matureThresholdDays')) {
|
|
||||||
if (hasValidMatureThresholdDays) {
|
|
||||||
context.resolved.ankiConnect.knownWords.matureThresholdDays = knownWordsMatureThresholdDays;
|
|
||||||
} else {
|
|
||||||
context.warn(
|
|
||||||
'ankiConnect.knownWords.matureThresholdDays',
|
|
||||||
knownWordsConfig.matureThresholdDays,
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.matureThresholdDays,
|
|
||||||
'Expected an integer of at least 1.',
|
|
||||||
);
|
|
||||||
context.resolved.ankiConnect.knownWords.matureThresholdDays =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.matureThresholdDays;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
context.resolved.ankiConnect.knownWords.matureThresholdDays =
|
|
||||||
DEFAULT_CONFIG.ankiConnect.knownWords.matureThresholdDays;
|
|
||||||
}
|
|
||||||
|
|
||||||
const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes);
|
const knownWordsRefreshMinutes = asNumber(knownWordsConfig.refreshMinutes);
|
||||||
const hasValidKnownWordsRefreshMinutes =
|
const hasValidKnownWordsRefreshMinutes =
|
||||||
knownWordsRefreshMinutes !== undefined &&
|
knownWordsRefreshMinutes !== undefined &&
|
||||||
|
|||||||
@@ -497,8 +497,6 @@ describe('stats server API routes', () => {
|
|||||||
cachePath,
|
cachePath,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 1,
|
version: 1,
|
||||||
refreshedAtMs: 1,
|
|
||||||
scope: 'deck:test',
|
|
||||||
words: ['する'],
|
words: ['する'],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -563,69 +561,6 @@ describe('stats server API routes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('GET /api/stats/sessions enriches known-word metrics from a v4 maturity cache', async () => {
|
|
||||||
await withTempDir(async (dir) => {
|
|
||||||
const cachePath = path.join(dir, 'known-words.json');
|
|
||||||
fs.writeFileSync(
|
|
||||||
cachePath,
|
|
||||||
JSON.stringify({
|
|
||||||
version: 4,
|
|
||||||
refreshedAtMs: 1,
|
|
||||||
scope: 'deck:test',
|
|
||||||
notes: {
|
|
||||||
'101': [{ word: 'する', reading: 'する' }],
|
|
||||||
'102': [{ word: '猫', reading: null }],
|
|
||||||
},
|
|
||||||
tiers: { '101': 'mature', '102': 'young' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const app = createStatsApp(
|
|
||||||
createMockTracker({
|
|
||||||
getSessionWordsByLine: async (sessionId: number) =>
|
|
||||||
sessionId === 1
|
|
||||||
? [
|
|
||||||
{ lineIndex: 1, headword: 'する', occurrenceCount: 2 },
|
|
||||||
{ lineIndex: 2, headword: '未知', occurrenceCount: 1 },
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
}),
|
|
||||||
{ knownWordCachePath: cachePath },
|
|
||||||
);
|
|
||||||
|
|
||||||
const res = await app.request('/api/stats/sessions?limit=5');
|
|
||||||
assert.equal(res.status, 200);
|
|
||||||
const body = await res.json();
|
|
||||||
const first = body[0];
|
|
||||||
assert.equal(first.knownWordsSeen, 2);
|
|
||||||
assert.equal(first.knownWordRate, 66.7);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/stats/sessions reports no known words when the cache format is unrecognized', async () => {
|
|
||||||
await withTempDir(async (dir) => {
|
|
||||||
const cachePath = path.join(dir, 'known-words.json');
|
|
||||||
fs.writeFileSync(
|
|
||||||
cachePath,
|
|
||||||
JSON.stringify({ version: 99, refreshedAtMs: 1, scope: 'deck:test', notes: {} }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const app = createStatsApp(
|
|
||||||
createMockTracker({
|
|
||||||
getSessionWordsByLine: async () => [
|
|
||||||
{ lineIndex: 1, headword: 'する', occurrenceCount: 2 },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
{ knownWordCachePath: cachePath },
|
|
||||||
);
|
|
||||||
|
|
||||||
const res = await app.request('/api/stats/sessions?limit=5');
|
|
||||||
assert.equal(res.status, 200);
|
|
||||||
const body = await res.json();
|
|
||||||
assert.equal(body[0].knownWordsSeen, 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/stats/sessions/:id/events forwards event type filters to the tracker', async () => {
|
it('GET /api/stats/sessions/:id/events forwards event type filters to the tracker', async () => {
|
||||||
let seenSessionId = 0;
|
let seenSessionId = 0;
|
||||||
let seenLimit = 0;
|
let seenLimit = 0;
|
||||||
@@ -674,8 +609,6 @@ describe('stats server API routes', () => {
|
|||||||
cachePath,
|
cachePath,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
version: 1,
|
version: 1,
|
||||||
refreshedAtMs: 1,
|
|
||||||
scope: 'deck:test',
|
|
||||||
words: ['知る', '猫'],
|
words: ['知る', '猫'],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -143,50 +143,3 @@ test('anilist update queue persists and reloads from disk', () => {
|
|||||||
});
|
});
|
||||||
assert.equal(queueB.nextReady(Number.MAX_SAFE_INTEGER)?.title, 'Persist Demo');
|
assert.equal(queueB.nextReady(Number.MAX_SAFE_INTEGER)?.title, 'Persist Demo');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('drops queued items whose persisted mediaId is not a positive integer', () => {
|
|
||||||
const filePath = path.join(
|
|
||||||
fs.mkdtempSync(path.join(os.tmpdir(), 'anilist-queue-mediaid-')),
|
|
||||||
'queue.json',
|
|
||||||
);
|
|
||||||
const base = {
|
|
||||||
episode: 1,
|
|
||||||
createdAt: 1,
|
|
||||||
attemptCount: 0,
|
|
||||||
nextAttemptAt: 0,
|
|
||||||
lastError: null,
|
|
||||||
};
|
|
||||||
fs.writeFileSync(
|
|
||||||
filePath,
|
|
||||||
JSON.stringify({
|
|
||||||
pending: [
|
|
||||||
{ ...base, key: 'ok-absent', title: 'A' },
|
|
||||||
{ ...base, key: 'ok-null', title: 'B', mediaId: null },
|
|
||||||
{ ...base, key: 'ok-valid', title: 'C', mediaId: 108489 },
|
|
||||||
{ ...base, key: 'bad-string', title: 'D', mediaId: '108489' },
|
|
||||||
{ ...base, key: 'bad-zero', title: 'E', mediaId: 0 },
|
|
||||||
{ ...base, key: 'bad-negative', title: 'F', mediaId: -3 },
|
|
||||||
{ ...base, key: 'bad-float', title: 'G', mediaId: 1.5 },
|
|
||||||
{ ...base, key: 'bad-object', title: 'H', mediaId: { id: 1 } },
|
|
||||||
],
|
|
||||||
deadLetter: [],
|
|
||||||
}),
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
|
|
||||||
const queue = createAnilistUpdateQueue(filePath, {
|
|
||||||
info: () => {},
|
|
||||||
warn: () => {},
|
|
||||||
error: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const keptKeys: string[] = [];
|
|
||||||
for (let i = 0; i < 8; i += 1) {
|
|
||||||
const next = queue.nextReady(1000);
|
|
||||||
if (!next) break;
|
|
||||||
keptKeys.push(next.key);
|
|
||||||
queue.markSuccess(next.key);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.deepEqual(keptKeys, ['ok-absent', 'ok-null', 'ok-valid']);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ export interface AnilistQueuedUpdate {
|
|||||||
key: string;
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
season?: number | null;
|
season?: number | null;
|
||||||
/** Pinned AniList media id from a manual override, when one applied at enqueue time. */
|
|
||||||
mediaId?: number | null;
|
|
||||||
episode: number;
|
episode: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
attemptCount: number;
|
attemptCount: number;
|
||||||
@@ -31,31 +29,13 @@ export interface AnilistRetryQueueSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AnilistUpdateQueue {
|
export interface AnilistUpdateQueue {
|
||||||
enqueue: (
|
enqueue: (key: string, title: string, episode: number, season?: number | null) => void;
|
||||||
key: string,
|
|
||||||
title: string,
|
|
||||||
episode: number,
|
|
||||||
season?: number | null,
|
|
||||||
mediaId?: number | null,
|
|
||||||
) => void;
|
|
||||||
nextReady: (nowMs?: number) => AnilistQueuedUpdate | null;
|
nextReady: (nowMs?: number) => AnilistQueuedUpdate | null;
|
||||||
markSuccess: (key: string) => void;
|
markSuccess: (key: string) => void;
|
||||||
markFailure: (key: string, reason: string, nowMs?: number) => void;
|
markFailure: (key: string, reason: string, nowMs?: number) => void;
|
||||||
getSnapshot: (nowMs?: number) => AnilistRetryQueueSnapshot;
|
getSnapshot: (nowMs?: number) => AnilistRetryQueueSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A persisted mediaId pins the update to a specific AniList entry, so a corrupted or
|
|
||||||
* hand-edited queue file must not be able to feed a bogus id straight to the mutation.
|
|
||||||
*/
|
|
||||||
function isValidPersistedMediaId(value: unknown): boolean {
|
|
||||||
return (
|
|
||||||
value === undefined ||
|
|
||||||
value === null ||
|
|
||||||
(typeof value === 'number' && Number.isInteger(value) && value > 0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampBackoffMs(attemptCount: number): number {
|
function clampBackoffMs(attemptCount: number): number {
|
||||||
const computed = INITIAL_BACKOFF_MS * Math.pow(2, Math.max(0, attemptCount - 1));
|
const computed = INITIAL_BACKOFF_MS * Math.pow(2, Math.max(0, attemptCount - 1));
|
||||||
return Math.min(MAX_BACKOFF_MS, computed);
|
return Math.min(MAX_BACKOFF_MS, computed);
|
||||||
@@ -102,7 +82,6 @@ export function createAnilistUpdateQueue(
|
|||||||
typeof item.createdAt === 'number' &&
|
typeof item.createdAt === 'number' &&
|
||||||
typeof item.attemptCount === 'number' &&
|
typeof item.attemptCount === 'number' &&
|
||||||
typeof item.nextAttemptAt === 'number' &&
|
typeof item.nextAttemptAt === 'number' &&
|
||||||
isValidPersistedMediaId(item.mediaId) &&
|
|
||||||
(typeof item.lastError === 'string' || item.lastError === null),
|
(typeof item.lastError === 'string' || item.lastError === null),
|
||||||
)
|
)
|
||||||
.slice(0, MAX_ITEMS);
|
.slice(0, MAX_ITEMS);
|
||||||
@@ -117,7 +96,6 @@ export function createAnilistUpdateQueue(
|
|||||||
typeof item.createdAt === 'number' &&
|
typeof item.createdAt === 'number' &&
|
||||||
typeof item.attemptCount === 'number' &&
|
typeof item.attemptCount === 'number' &&
|
||||||
typeof item.nextAttemptAt === 'number' &&
|
typeof item.nextAttemptAt === 'number' &&
|
||||||
isValidPersistedMediaId(item.mediaId) &&
|
|
||||||
(typeof item.lastError === 'string' || item.lastError === null),
|
(typeof item.lastError === 'string' || item.lastError === null),
|
||||||
)
|
)
|
||||||
.slice(0, MAX_ITEMS);
|
.slice(0, MAX_ITEMS);
|
||||||
@@ -129,13 +107,7 @@ export function createAnilistUpdateQueue(
|
|||||||
load();
|
load();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enqueue(
|
enqueue(key: string, title: string, episode: number, season: number | null = null): void {
|
||||||
key: string,
|
|
||||||
title: string,
|
|
||||||
episode: number,
|
|
||||||
season: number | null = null,
|
|
||||||
mediaId: number | null = null,
|
|
||||||
): void {
|
|
||||||
const existing =
|
const existing =
|
||||||
pending.find((item) => item.key === key) || deadLetter.find((item) => item.key === key);
|
pending.find((item) => item.key === key) || deadLetter.find((item) => item.key === key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -148,7 +120,6 @@ export function createAnilistUpdateQueue(
|
|||||||
key,
|
key,
|
||||||
title,
|
title,
|
||||||
season,
|
season,
|
||||||
mediaId,
|
|
||||||
episode,
|
episode,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
attemptCount: 0,
|
attemptCount: 0,
|
||||||
|
|||||||
@@ -376,7 +376,7 @@ test('updateAnilistPostWatchProgress returns non-retryable error when media is n
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('updateAnilistPostWatchProgress resolves later seasons through the sequel chain', async () => {
|
test('updateAnilistPostWatchProgress prefers season-specific AniList matches', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const searchTerms: string[] = [];
|
const searchTerms: string[] = [];
|
||||||
let call = 0;
|
let call = 0;
|
||||||
@@ -389,45 +389,21 @@ test('updateAnilistPostWatchProgress resolves later seasons through the sequel c
|
|||||||
data: {
|
data: {
|
||||||
Page: {
|
Page: {
|
||||||
media: [
|
media: [
|
||||||
{ id: 101, episodes: 12, format: 'TV', title: { english: 'Demo Show' } },
|
{ id: 202, episodes: 12, title: { english: 'Demo Show Season 2' } },
|
||||||
{ id: 202, episodes: 12, format: 'TV', title: { english: 'Demo Show Sequel' } },
|
{ id: 101, episodes: 12, title: { english: 'Demo Show' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (call === 2) {
|
if (call === 2) {
|
||||||
assert.equal(body.variables?.id, 101);
|
|
||||||
return createJsonResponse({
|
|
||||||
data: {
|
|
||||||
Media: {
|
|
||||||
relations: {
|
|
||||||
edges: [
|
|
||||||
{
|
|
||||||
relationType: 'SEQUEL',
|
|
||||||
node: {
|
|
||||||
id: 202,
|
|
||||||
type: 'ANIME',
|
|
||||||
episodes: 12,
|
|
||||||
format: 'TV',
|
|
||||||
title: { english: 'Demo Show Sequel' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (call === 3) {
|
|
||||||
assert.equal(body.variables?.mediaId, 202);
|
assert.equal(body.variables?.mediaId, 202);
|
||||||
return createJsonResponse({
|
return createJsonResponse({
|
||||||
data: {
|
data: {
|
||||||
Media: { id: 202, mediaListEntry: { progress: 1, status: 'CURRENT' } },
|
Media: { id: 202, mediaListEntry: null },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
assert.equal(body.variables?.mediaId, 202);
|
|
||||||
return createJsonResponse({
|
return createJsonResponse({
|
||||||
data: {
|
data: {
|
||||||
SaveMediaListEntry: { progress: 2, status: 'CURRENT' },
|
SaveMediaListEntry: { progress: 2, status: 'CURRENT' },
|
||||||
@@ -439,118 +415,16 @@ test('updateAnilistPostWatchProgress resolves later seasons through the sequel c
|
|||||||
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 2, {
|
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 2, {
|
||||||
season: 2,
|
season: 2,
|
||||||
});
|
});
|
||||||
// Searches the bare title: AniList has no "Demo Show Season 2" entry to find.
|
assert.deepEqual(searchTerms, ['Demo Show Season 2']);
|
||||||
assert.deepEqual(searchTerms, ['Demo Show']);
|
|
||||||
assert.equal(result.status, 'updated');
|
|
||||||
assert.match(result.message, /Demo Show Sequel/);
|
|
||||||
assert.equal(call, 4);
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updateAnilistPostWatchProgress refuses to update season 1 when the season is unresolvable', async () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
let call = 0;
|
|
||||||
globalThis.fetch = (async () => {
|
|
||||||
call += 1;
|
|
||||||
if (call === 1) {
|
|
||||||
return createJsonResponse({
|
|
||||||
data: {
|
|
||||||
Page: {
|
|
||||||
media: [{ id: 101, episodes: 12, format: 'TV', title: { english: 'Demo Show' } }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return createJsonResponse({ data: { Media: { relations: { edges: [] } } } });
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 2, { season: 3 });
|
|
||||||
assert.equal(result.status, 'error');
|
assert.equal(result.status, 'error');
|
||||||
assert.equal(result.retryable, false);
|
assert.equal(result.retryable, false);
|
||||||
assert.match(result.message, /could not find season 3/i);
|
assert.match(result.message, /not in your AniList Planning or Watching list/i);
|
||||||
// Stops after search + relation lookup: no entry lookup, no save.
|
|
||||||
assert.equal(call, 2);
|
assert.equal(call, 2);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('updateAnilistPostWatchProgress uses a pinned media id without searching', async () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
let call = 0;
|
|
||||||
globalThis.fetch = (async (_input, init) => {
|
|
||||||
call += 1;
|
|
||||||
const body = JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> };
|
|
||||||
if (call === 1) {
|
|
||||||
assert.equal(body.variables?.mediaId, 108489);
|
|
||||||
return createJsonResponse({
|
|
||||||
data: {
|
|
||||||
Media: {
|
|
||||||
id: 108489,
|
|
||||||
episodes: 12,
|
|
||||||
title: { english: 'Pinned Show' },
|
|
||||||
mediaListEntry: { progress: 4, status: 'CURRENT' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
assert.equal(body.variables?.mediaId, 108489);
|
|
||||||
return createJsonResponse({
|
|
||||||
data: { SaveMediaListEntry: { progress: 5, status: 'CURRENT' } },
|
|
||||||
});
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await updateAnilistPostWatchProgress('token', 'Wrong Guess', 5, {
|
|
||||||
season: 3,
|
|
||||||
mediaId: 108489,
|
|
||||||
});
|
|
||||||
assert.equal(result.status, 'updated');
|
|
||||||
assert.match(result.message, /Pinned Show/);
|
|
||||||
assert.equal(call, 2);
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updateAnilistPostWatchProgress marks the final episode completed for a pinned media id', async () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
let call = 0;
|
|
||||||
globalThis.fetch = (async (_input, init) => {
|
|
||||||
call += 1;
|
|
||||||
const body = JSON.parse(String(init?.body)) as { variables?: Record<string, unknown> };
|
|
||||||
if (call === 1) {
|
|
||||||
return createJsonResponse({
|
|
||||||
data: {
|
|
||||||
Media: {
|
|
||||||
id: 108489,
|
|
||||||
episodes: 12,
|
|
||||||
title: { english: 'Pinned Show' },
|
|
||||||
mediaListEntry: { progress: 11, status: 'CURRENT' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
assert.equal(body.variables?.status, 'COMPLETED');
|
|
||||||
return createJsonResponse({
|
|
||||||
data: { SaveMediaListEntry: { progress: 12, status: 'COMPLETED' } },
|
|
||||||
});
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await updateAnilistPostWatchProgress('token', 'Pinned Show', 12, {
|
|
||||||
mediaId: 108489,
|
|
||||||
});
|
|
||||||
assert.equal(result.status, 'updated');
|
|
||||||
assert.match(result.message, /completed/i);
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updateAnilistPostWatchProgress does not update rewatching entries', async () => {
|
test('updateAnilistPostWatchProgress does not update rewatching entries', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
let call = 0;
|
let call = 0;
|
||||||
@@ -605,33 +479,3 @@ test('updateAnilistPostWatchProgress returns error when search fails', async ()
|
|||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('updateAnilistPostWatchProgress does not requeue a search that matched nothing', async () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
globalThis.fetch = (async () =>
|
|
||||||
createJsonResponse({ data: { Page: { media: [] } } })) as typeof fetch;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await updateAnilistPostWatchProgress('token', 'Unknown Show', 3);
|
|
||||||
assert.equal(result.status, 'error');
|
|
||||||
assert.equal(result.retryable, false);
|
|
||||||
assert.match(result.message, /no matches/i);
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updateAnilistPostWatchProgress still allows retry when the search itself fails', async () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
globalThis.fetch = (async () =>
|
|
||||||
createJsonResponse({ errors: [{ message: 'upstream exploded' }] })) as typeof fetch;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await updateAnilistPostWatchProgress('token', 'Demo Show', 3);
|
|
||||||
assert.equal(result.status, 'error');
|
|
||||||
assert.notEqual(result.retryable, false);
|
|
||||||
assert.match(result.message, /search failed/i);
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import * as path from 'path';
|
|||||||
|
|
||||||
import { parseMediaInfo } from '../../../jimaku/utils';
|
import { parseMediaInfo } from '../../../jimaku/utils';
|
||||||
import type { AnilistRateLimiter } from './rate-limiter';
|
import type { AnilistRateLimiter } from './rate-limiter';
|
||||||
import { resolveAnilistSeasonMedia } from './season-resolver';
|
|
||||||
|
|
||||||
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||||
|
|
||||||
@@ -25,12 +24,6 @@ export interface AnilistPostWatchUpdateResult {
|
|||||||
export interface AnilistPostWatchUpdateOptions {
|
export interface AnilistPostWatchUpdateOptions {
|
||||||
rateLimiter?: AnilistRateLimiter;
|
rateLimiter?: AnilistRateLimiter;
|
||||||
season?: number | null;
|
season?: number | null;
|
||||||
/**
|
|
||||||
* Pinned AniList media id (from a character dictionary manual override). When set,
|
|
||||||
* the search/season resolution is skipped entirely.
|
|
||||||
*/
|
|
||||||
mediaId?: number | null;
|
|
||||||
logInfo?: (message: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AnilistGraphQlError {
|
interface AnilistGraphQlError {
|
||||||
@@ -42,15 +35,23 @@ interface AnilistGraphQlResponse<T> {
|
|||||||
errors?: AnilistGraphQlError[];
|
errors?: AnilistGraphQlError[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AnilistMediaEntryData {
|
interface AnilistSearchData {
|
||||||
Media?: {
|
Page?: {
|
||||||
|
media?: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
episodes?: number | null;
|
episodes: number | null;
|
||||||
title?: {
|
title?: {
|
||||||
romaji?: string | null;
|
romaji?: string | null;
|
||||||
english?: string | null;
|
english?: string | null;
|
||||||
native?: string | null;
|
native?: string | null;
|
||||||
} | null;
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnilistMediaEntryData {
|
||||||
|
Media?: {
|
||||||
|
id: number;
|
||||||
mediaListEntry?: {
|
mediaListEntry?: {
|
||||||
progress?: number | null;
|
progress?: number | null;
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
@@ -153,6 +154,32 @@ function buildGuessitTitle(title: string, alternativeTitle: string | null): stri
|
|||||||
return title;
|
return title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeTitle(text: string): string {
|
||||||
|
return text.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleMentionsSeason(title: string, season: number): boolean {
|
||||||
|
const normalized = normalizeTitle(title);
|
||||||
|
return (
|
||||||
|
normalized.includes(`season ${season}`) ||
|
||||||
|
normalized.includes(`s${String(season).padStart(2, '0')}`) ||
|
||||||
|
normalized.includes(`s${season}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchCandidates(title: string, season: number | null | undefined): string[] {
|
||||||
|
const trimmed = title.trim();
|
||||||
|
if (!trimmed) return [];
|
||||||
|
const candidates =
|
||||||
|
typeof season === 'number' &&
|
||||||
|
Number.isInteger(season) &&
|
||||||
|
season > 1 &&
|
||||||
|
!titleMentionsSeason(trimmed, season)
|
||||||
|
? [`${trimmed} Season ${season}`, trimmed]
|
||||||
|
: [trimmed];
|
||||||
|
return candidates.filter((candidate, index, all) => all.indexOf(candidate) === index);
|
||||||
|
}
|
||||||
|
|
||||||
async function anilistGraphQl<T>(
|
async function anilistGraphQl<T>(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
query: string,
|
query: string,
|
||||||
@@ -189,22 +216,38 @@ function firstErrorMessage<T>(response: AnilistGraphQlResponse<T>): string | nul
|
|||||||
return firstError?.message ?? null;
|
return firstError?.message ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The season resolver signals failure by throwing; anilistGraphQl reports it in-band. */
|
function pickBestSearchResult(
|
||||||
function createAnilistSeasonQueryExecutor(
|
title: string,
|
||||||
accessToken: string,
|
episode: number,
|
||||||
options: AnilistPostWatchUpdateOptions,
|
media: Array<{
|
||||||
) {
|
id: number;
|
||||||
return async <T>(query: string, variables: Record<string, unknown>): Promise<T> => {
|
episodes: number | null;
|
||||||
const response = await anilistGraphQl<T>(accessToken, query, variables, options);
|
title?: {
|
||||||
const error = firstErrorMessage(response);
|
romaji?: string | null;
|
||||||
if (error) {
|
english?: string | null;
|
||||||
throw new Error(error);
|
native?: string | null;
|
||||||
}
|
|
||||||
if (!response.data) {
|
|
||||||
throw new Error('AniList response missing data');
|
|
||||||
}
|
|
||||||
return response.data;
|
|
||||||
};
|
};
|
||||||
|
}>,
|
||||||
|
): { id: number; title: string; episodes: number | null } | null {
|
||||||
|
const filtered = media.filter((item) => {
|
||||||
|
const totalEpisodes = item.episodes;
|
||||||
|
return totalEpisodes === null || totalEpisodes >= episode;
|
||||||
|
});
|
||||||
|
const candidates = filtered.length > 0 ? filtered : media;
|
||||||
|
if (candidates.length === 0) return null;
|
||||||
|
|
||||||
|
const normalizedTarget = normalizeTitle(title);
|
||||||
|
const exact = candidates.find((item) => {
|
||||||
|
const titles = [item.title?.romaji, item.title?.english, item.title?.native]
|
||||||
|
.filter((value): value is string => typeof value === 'string')
|
||||||
|
.map((value) => normalizeTitle(value));
|
||||||
|
return titles.includes(normalizedTarget);
|
||||||
|
});
|
||||||
|
|
||||||
|
const selected = exact ?? candidates[0]!;
|
||||||
|
const selectedTitle =
|
||||||
|
selected.title?.english || selected.title?.romaji || selected.title?.native || title;
|
||||||
|
return { id: selected.id, title: selectedTitle, episodes: selected.episodes };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUpdateableListStatus(status: string | null | undefined): boolean {
|
function isUpdateableListStatus(status: string | null | undefined): boolean {
|
||||||
@@ -278,53 +321,52 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
episode: number,
|
episode: number,
|
||||||
options: AnilistPostWatchUpdateOptions = {},
|
options: AnilistPostWatchUpdateOptions = {},
|
||||||
): Promise<AnilistPostWatchUpdateResult> {
|
): Promise<AnilistPostWatchUpdateResult> {
|
||||||
const pinnedMediaId =
|
let media: NonNullable<NonNullable<AnilistSearchData['Page']>['media']> = [];
|
||||||
typeof options.mediaId === 'number' && Number.isInteger(options.mediaId) && options.mediaId > 0
|
let searchError: string | null = null;
|
||||||
? options.mediaId
|
let pickTitle = title;
|
||||||
: null;
|
const searchCandidates = buildSearchCandidates(title, options.season);
|
||||||
|
for (const search of searchCandidates) {
|
||||||
let mediaId = pinnedMediaId;
|
const searchResponse = await anilistGraphQl<AnilistSearchData>(
|
||||||
let resolvedTitle: string | null = null;
|
accessToken,
|
||||||
let resolvedEpisodes: number | null = null;
|
`
|
||||||
|
query ($search: String!) {
|
||||||
if (mediaId === null) {
|
Page(perPage: 5) {
|
||||||
let resolution: Awaited<ReturnType<typeof resolveAnilistSeasonMedia>>;
|
media(search: $search, type: ANIME) {
|
||||||
try {
|
id
|
||||||
resolution = await resolveAnilistSeasonMedia(
|
episodes
|
||||||
{ title, season: options.season, episode },
|
title {
|
||||||
{
|
romaji
|
||||||
execute: createAnilistSeasonQueryExecutor(accessToken, options),
|
english
|
||||||
logInfo: options.logInfo,
|
native
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{ search },
|
||||||
|
options,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
searchError = firstErrorMessage(searchResponse);
|
||||||
|
if (searchError) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
media = searchResponse.data?.Page?.media ?? [];
|
||||||
|
if (media.length > 0) {
|
||||||
|
pickTitle = search;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchError) {
|
||||||
return {
|
return {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
message: `AniList search failed: ${error instanceof Error ? error.message : String(error)}`,
|
message: `AniList search failed: ${searchError}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resolution) {
|
const picked = pickBestSearchResult(pickTitle, episode, media);
|
||||||
// A well-formed search that matched nothing is deterministic for this title, so
|
if (!picked) {
|
||||||
// requeueing it just burns rate limit. Transient failures throw and are caught above.
|
return { status: 'error', message: 'AniList search returned no matches.' };
|
||||||
return {
|
|
||||||
status: 'error',
|
|
||||||
retryable: false,
|
|
||||||
message: 'AniList search returned no matches.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!resolution.seasonResolved) {
|
|
||||||
// Updating the season 1 entry here is worse than not updating at all.
|
|
||||||
return {
|
|
||||||
status: 'error',
|
|
||||||
retryable: false,
|
|
||||||
message: `AniList update skipped: could not find season ${resolution.requestedSeason} of "${title}" (only matched "${resolution.title}"). Pick the right entry with the character dictionary AniList override.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaId = resolution.id;
|
|
||||||
resolvedTitle = resolution.title;
|
|
||||||
resolvedEpisodes = resolution.episodes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const entryResponse = await anilistGraphQl<AnilistMediaEntryData>(
|
const entryResponse = await anilistGraphQl<AnilistMediaEntryData>(
|
||||||
@@ -333,12 +375,6 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
query ($mediaId: Int!) {
|
query ($mediaId: Int!) {
|
||||||
Media(id: $mediaId, type: ANIME) {
|
Media(id: $mediaId, type: ANIME) {
|
||||||
id
|
id
|
||||||
episodes
|
|
||||||
title {
|
|
||||||
romaji
|
|
||||||
english
|
|
||||||
native
|
|
||||||
}
|
|
||||||
mediaListEntry {
|
mediaListEntry {
|
||||||
progress
|
progress
|
||||||
status
|
status
|
||||||
@@ -346,7 +382,7 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
{ mediaId },
|
{ mediaId: picked.id },
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
const entryError = firstErrorMessage(entryResponse);
|
const entryError = firstErrorMessage(entryResponse);
|
||||||
@@ -357,34 +393,21 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const entryMedia = entryResponse.data?.Media ?? null;
|
const entry = entryResponse.data?.Media?.mediaListEntry ?? null;
|
||||||
const pickedTitle =
|
|
||||||
resolvedTitle ||
|
|
||||||
entryMedia?.title?.english?.trim() ||
|
|
||||||
entryMedia?.title?.romaji?.trim() ||
|
|
||||||
entryMedia?.title?.native?.trim() ||
|
|
||||||
title;
|
|
||||||
const pickedEpisodes =
|
|
||||||
resolvedEpisodes ??
|
|
||||||
(typeof entryMedia?.episodes === 'number' && entryMedia.episodes > 0
|
|
||||||
? entryMedia.episodes
|
|
||||||
: null);
|
|
||||||
|
|
||||||
const entry = entryMedia?.mediaListEntry ?? null;
|
|
||||||
if (!entry || !isUpdateableListStatus(entry.status)) {
|
if (!entry || !isUpdateableListStatus(entry.status)) {
|
||||||
return {
|
return {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
retryable: false,
|
retryable: false,
|
||||||
message: `AniList update not possible: "${pickedTitle}" is ${formatListStatus(entry?.status)}. Add it to Planning or Watching, then mark watched again.`,
|
message: `AniList update not possible: "${picked.title}" is ${formatListStatus(entry?.status)}. Add it to Planning or Watching, then mark watched again.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentProgress = entry.progress ?? 0;
|
const currentProgress = entry.progress ?? 0;
|
||||||
const shouldMarkCompleted = isKnownFinalEpisode(pickedEpisodes, episode);
|
const shouldMarkCompleted = isKnownFinalEpisode(picked.episodes, episode);
|
||||||
if (typeof currentProgress === 'number' && currentProgress >= episode && !shouldMarkCompleted) {
|
if (typeof currentProgress === 'number' && currentProgress >= episode && !shouldMarkCompleted) {
|
||||||
return {
|
return {
|
||||||
status: 'skipped',
|
status: 'skipped',
|
||||||
message: `AniList already at episode ${currentProgress} (${pickedTitle}).`,
|
message: `AniList already at episode ${currentProgress} (${picked.title}).`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,7 +422,7 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
{
|
{
|
||||||
mediaId,
|
mediaId: picked.id,
|
||||||
progress: episode,
|
progress: episode,
|
||||||
status: shouldMarkCompleted ? 'COMPLETED' : 'CURRENT',
|
status: shouldMarkCompleted ? 'COMPLETED' : 'CURRENT',
|
||||||
},
|
},
|
||||||
@@ -413,7 +436,7 @@ export async function updateAnilistPostWatchProgress(
|
|||||||
return {
|
return {
|
||||||
status: 'updated',
|
status: 'updated',
|
||||||
message: shouldMarkCompleted
|
message: shouldMarkCompleted
|
||||||
? `AniList updated "${pickedTitle}" to episode ${episode} and marked it completed.`
|
? `AniList updated "${picked.title}" to episode ${episode} and marked it completed.`
|
||||||
: `AniList updated "${pickedTitle}" to episode ${episode}.`,
|
: `AniList updated "${picked.title}" to episode ${episode}.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,45 +201,17 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
|
|||||||
});
|
});
|
||||||
|
|
||||||
const searchCalls: Array<{ search: string }> = [];
|
const searchCalls: Array<{ search: string }> = [];
|
||||||
const relationCalls: number[] = [];
|
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const raw = (init?.body as string | undefined) ?? '';
|
const raw = (init?.body as string | undefined) ?? '';
|
||||||
const payload = JSON.parse(raw) as { variables: { search?: string; id?: number } };
|
const payload = JSON.parse(raw) as { variables: { search: string } };
|
||||||
|
const search = payload.variables.search;
|
||||||
|
searchCalls.push({ search });
|
||||||
|
|
||||||
if (typeof payload.variables.id === 'number') {
|
if (search.includes('Season 2')) {
|
||||||
relationCalls.push(payload.variables.id);
|
return Promise.resolve(createJsonResponse({ data: { Page: { media: [] } } }));
|
||||||
return Promise.resolve(
|
|
||||||
createJsonResponse({
|
|
||||||
data: {
|
|
||||||
Media: {
|
|
||||||
relations: {
|
|
||||||
edges: [
|
|
||||||
{
|
|
||||||
relationType: 'SEQUEL',
|
|
||||||
node: {
|
|
||||||
id: 20,
|
|
||||||
type: 'ANIME',
|
|
||||||
episodes: 25,
|
|
||||||
format: 'TV',
|
|
||||||
seasonYear: 2017,
|
|
||||||
coverImage: { large: 'https://images.test/cover-s2.jpg', medium: null },
|
|
||||||
title: {
|
|
||||||
romaji: 'Little Witch Academia 2',
|
|
||||||
english: 'Little Witch Academia 2',
|
|
||||||
native: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
searchCalls.push({ search: String(payload.variables.search) });
|
|
||||||
return Promise.resolve(
|
return Promise.resolve(
|
||||||
createJsonResponse({
|
createJsonResponse({
|
||||||
data: {
|
data: {
|
||||||
@@ -248,8 +220,6 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
|
|||||||
{
|
{
|
||||||
id: 19,
|
id: 19,
|
||||||
episodes: 24,
|
episodes: 24,
|
||||||
format: 'TV',
|
|
||||||
seasonYear: 2013,
|
|
||||||
coverImage: { large: 'https://images.test/cover.jpg', medium: null },
|
coverImage: { large: 'https://images.test/cover.jpg', medium: null },
|
||||||
title: {
|
title: {
|
||||||
romaji: 'Little Witch Academia',
|
romaji: 'Little Witch Academia',
|
||||||
@@ -281,11 +251,9 @@ test('fetchIfMissing uses guessit primary title and season when available', asyn
|
|||||||
const stored = getCoverArt(db, videoId);
|
const stored = getCoverArt(db, videoId);
|
||||||
|
|
||||||
assert.equal(fetched, true);
|
assert.equal(fetched, true);
|
||||||
// One search on the bare title, then a sequel hop to reach season 2.
|
assert.equal(searchCalls.length, 2);
|
||||||
assert.equal(searchCalls.length, 1);
|
assert.equal(searchCalls[0]!.search, 'Little Witch Academia Season 2');
|
||||||
assert.equal(searchCalls[0]!.search, 'Little Witch Academia');
|
assert.equal(stored?.anilistId, 19);
|
||||||
assert.deepEqual(relationCalls, [19]);
|
|
||||||
assert.equal(stored?.anilistId, 20);
|
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
db.close();
|
db.close();
|
||||||
@@ -356,187 +324,3 @@ test('fetchIfMissing falls back to internal parser when guessit throws', async (
|
|||||||
cleanupDbPath(dbPath);
|
cleanupDbPath(dbPath);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fetchIfMissing caches a no-match when the season cannot be resolved', async () => {
|
|
||||||
const dbPath = makeDbPath();
|
|
||||||
const db = new Database(dbPath);
|
|
||||||
ensureSchema(db);
|
|
||||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-unresolved.mkv', {
|
|
||||||
canonicalTitle: 'Unresolved Show (2013) - S03E01 - Something [1080p].mkv',
|
|
||||||
sourcePath: '/tmp/cover-fetcher-unresolved.mkv',
|
|
||||||
sourceType: SOURCE_TYPE_LOCAL,
|
|
||||||
sourceUrl: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
||||||
if (!url.includes('graphql')) {
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(Buffer.from('01020304'), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'content-type': 'image/png' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = JSON.parse(String(init?.body ?? '{}')) as {
|
|
||||||
variables?: { search?: string; id?: number };
|
|
||||||
};
|
|
||||||
if (typeof payload.variables?.id === 'number') {
|
|
||||||
// No sequel edges, so season 3 cannot be reached from the season 1 anchor.
|
|
||||||
return Promise.resolve(createJsonResponse({ data: { Media: { relations: { edges: [] } } } }));
|
|
||||||
}
|
|
||||||
return Promise.resolve(
|
|
||||||
createJsonResponse({
|
|
||||||
data: {
|
|
||||||
Page: {
|
|
||||||
media: [
|
|
||||||
{
|
|
||||||
id: 55,
|
|
||||||
episodes: 13,
|
|
||||||
format: 'TV',
|
|
||||||
seasonYear: 2013,
|
|
||||||
coverImage: { large: 'https://images.test/s1.jpg', medium: null },
|
|
||||||
title: { romaji: 'Unresolved Show', english: 'Unresolved Show', native: null },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fetcher = createCoverArtFetcher(
|
|
||||||
{ acquire: async () => {}, recordResponse: () => {} },
|
|
||||||
console,
|
|
||||||
{
|
|
||||||
runGuessit: async () =>
|
|
||||||
JSON.stringify({ title: 'Unresolved Show', season: 3, episode: 1, year: 2013 }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const fetched = await fetcher.fetchIfMissing(db, videoId, 'Unresolved Show');
|
|
||||||
const stored = getCoverArt(db, videoId);
|
|
||||||
|
|
||||||
assert.equal(fetched, false);
|
|
||||||
// Storing the season 1 artwork would leave a blob with no AniList id, which the
|
|
||||||
// `existing.coverBlob` early return serves forever - the season could then never
|
|
||||||
// re-resolve. A plain no-match keeps the existing retry window in play instead.
|
|
||||||
assert.equal(stored?.coverBlob, null);
|
|
||||||
assert.equal(stored?.coverUrl, null);
|
|
||||||
assert.equal(stored?.anilistId, null);
|
|
||||||
assert.equal(stored?.episodesTotal, null);
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
db.close();
|
|
||||||
cleanupDbPath(dbPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('fetchIfMissing re-resolves an unresolved season once AniList publishes the relation', async () => {
|
|
||||||
const dbPath = makeDbPath();
|
|
||||||
const db = new Database(dbPath);
|
|
||||||
ensureSchema(db);
|
|
||||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/cover-fetcher-recovers.mkv', {
|
|
||||||
canonicalTitle: 'Recovering Show (2013) - S02E01 - Something [1080p].mkv',
|
|
||||||
sourcePath: '/tmp/cover-fetcher-recovers.mkv',
|
|
||||||
sourceType: SOURCE_TYPE_LOCAL,
|
|
||||||
sourceUrl: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
let sequelPublished = false;
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
const originalNow = Date.now;
|
|
||||||
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
||||||
if (!url.includes('graphql')) {
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(Buffer.from('01020304'), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'content-type': 'image/png' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = JSON.parse(String(init?.body ?? '{}')) as {
|
|
||||||
variables?: { search?: string; id?: number };
|
|
||||||
};
|
|
||||||
if (typeof payload.variables?.id === 'number') {
|
|
||||||
return Promise.resolve(
|
|
||||||
createJsonResponse({
|
|
||||||
data: {
|
|
||||||
Media: {
|
|
||||||
relations: {
|
|
||||||
edges: sequelPublished
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
relationType: 'SEQUEL',
|
|
||||||
node: {
|
|
||||||
id: 66,
|
|
||||||
type: 'ANIME',
|
|
||||||
episodes: 12,
|
|
||||||
format: 'TV',
|
|
||||||
seasonYear: 2015,
|
|
||||||
coverImage: { large: 'https://images.test/s2.jpg', medium: null },
|
|
||||||
title: { romaji: 'Recovering Show 2', english: null, native: null },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Promise.resolve(
|
|
||||||
createJsonResponse({
|
|
||||||
data: {
|
|
||||||
Page: {
|
|
||||||
media: [
|
|
||||||
{
|
|
||||||
id: 65,
|
|
||||||
episodes: 13,
|
|
||||||
format: 'TV',
|
|
||||||
seasonYear: 2013,
|
|
||||||
coverImage: { large: 'https://images.test/s1.jpg', medium: null },
|
|
||||||
title: { romaji: 'Recovering Show', english: null, native: null },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}) as typeof fetch;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fetcher = createCoverArtFetcher(
|
|
||||||
{ acquire: async () => {}, recordResponse: () => {} },
|
|
||||||
console,
|
|
||||||
{
|
|
||||||
runGuessit: async () =>
|
|
||||||
JSON.stringify({ title: 'Recovering Show', season: 2, episode: 1, year: 2013 }),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Recovering Show'), false);
|
|
||||||
assert.equal(getCoverArt(db, videoId)?.anilistId, null);
|
|
||||||
|
|
||||||
// AniList publishes the sequel relation, and the no-match retry window elapses.
|
|
||||||
sequelPublished = true;
|
|
||||||
const base = originalNow();
|
|
||||||
Date.now = () => base + 10 * 60 * 1000;
|
|
||||||
|
|
||||||
assert.equal(await fetcher.fetchIfMissing(db, videoId, 'Recovering Show'), true);
|
|
||||||
const recovered = getCoverArt(db, videoId);
|
|
||||||
assert.equal(recovered?.anilistId, 66);
|
|
||||||
assert.equal(recovered?.episodesTotal, 12);
|
|
||||||
} finally {
|
|
||||||
Date.now = originalNow;
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
db.close();
|
|
||||||
cleanupDbPath(dbPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -11,15 +11,43 @@ import {
|
|||||||
runGuessit,
|
runGuessit,
|
||||||
type GuessAnilistMediaInfoDeps,
|
type GuessAnilistMediaInfoDeps,
|
||||||
} from './anilist-updater';
|
} from './anilist-updater';
|
||||||
import {
|
|
||||||
resolveAnilistSeasonMedia,
|
|
||||||
type AnilistQueryExecutor,
|
|
||||||
type AnilistSeasonResolution,
|
|
||||||
} from './season-resolver';
|
|
||||||
|
|
||||||
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||||
const NO_MATCH_RETRY_MS = 5 * 60 * 1000;
|
const NO_MATCH_RETRY_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
const SEARCH_QUERY = `
|
||||||
|
query ($search: String!) {
|
||||||
|
Page(perPage: 5) {
|
||||||
|
media(search: $search, type: ANIME) {
|
||||||
|
id
|
||||||
|
episodes
|
||||||
|
season
|
||||||
|
seasonYear
|
||||||
|
coverImage { large medium }
|
||||||
|
title { romaji english native }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface AnilistMedia {
|
||||||
|
id: number;
|
||||||
|
episodes: number | null;
|
||||||
|
season: string | null;
|
||||||
|
seasonYear: number | null;
|
||||||
|
coverImage: { large: string | null; medium: string | null } | null;
|
||||||
|
title: { romaji: string | null; english: string | null; native: string | null } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnilistSearchResponse {
|
||||||
|
data?: {
|
||||||
|
Page?: {
|
||||||
|
media?: AnilistMedia[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
errors?: Array<{ message?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CoverArtFetcher {
|
export interface CoverArtFetcher {
|
||||||
fetchIfMissing(db: DatabaseSync, videoId: number, canonicalTitle: string): Promise<boolean>;
|
fetchIfMissing(db: DatabaseSync, videoId: number, canonicalTitle: string): Promise<boolean>;
|
||||||
}
|
}
|
||||||
@@ -71,45 +99,151 @@ export function stripFilenameTags(raw: string): string {
|
|||||||
return title.trim().replace(/\s{2,}/g, ' ');
|
return title.trim().replace(/\s{2,}/g, ' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
class AnilistRateLimitedError extends Error {
|
function removeSeasonHint(title: string): string {
|
||||||
constructor() {
|
return title
|
||||||
super('Anilist rate limit reached');
|
.replace(/\bseason\s*\d+\b/gi, '')
|
||||||
this.name = 'AnilistRateLimitedError';
|
.replace(/\s{2,}/g, ' ')
|
||||||
}
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeAnilistQuery<T>(
|
function normalizeTitle(text: string): string {
|
||||||
|
return text.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractCandidateSeasonHints(text: string): Set<number> {
|
||||||
|
const normalized = normalizeTitle(text);
|
||||||
|
const matches = [
|
||||||
|
...normalized.matchAll(/\bseason\s*(\d{1,2})\b/gi),
|
||||||
|
...normalized.matchAll(/\bs(\d{1,2})(?:\b|\D)/gi),
|
||||||
|
];
|
||||||
|
const values = new Set<number>();
|
||||||
|
for (const match of matches) {
|
||||||
|
const value = Number.parseInt(match[1]!, 10);
|
||||||
|
if (Number.isInteger(value)) {
|
||||||
|
values.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSeasonMentioned(titles: string[], season: number | null): boolean {
|
||||||
|
if (!season) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const hints = titles.flatMap((title) => [...extractCandidateSeasonHints(title)]);
|
||||||
|
return hints.includes(season);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBestSearchResult(
|
||||||
|
title: string,
|
||||||
|
episode: number | null,
|
||||||
|
season: number | null,
|
||||||
|
media: AnilistMedia[],
|
||||||
|
): { id: number; title: string } | null {
|
||||||
|
const cleanedTitle = removeSeasonHint(title);
|
||||||
|
const targets = [title, cleanedTitle]
|
||||||
|
.map(normalizeTitle)
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter((value, index, all) => value.length > 0 && all.indexOf(value) === index);
|
||||||
|
|
||||||
|
const filtered =
|
||||||
|
episode === null
|
||||||
|
? media
|
||||||
|
: media.filter((item) => {
|
||||||
|
const total = item.episodes;
|
||||||
|
return total === null || total >= episode;
|
||||||
|
});
|
||||||
|
const candidates = filtered.length > 0 ? filtered : media;
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scored = candidates.map((item) => {
|
||||||
|
const candidateTitles = [item.title?.romaji, item.title?.english, item.title?.native]
|
||||||
|
.filter((value): value is string => typeof value === 'string')
|
||||||
|
.map((value) => normalizeTitle(value));
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
if (candidateTitles.includes(target)) {
|
||||||
|
score += 120;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (candidateTitles.some((itemTitle) => itemTitle.includes(target))) {
|
||||||
|
score += 30;
|
||||||
|
}
|
||||||
|
if (candidateTitles.some((itemTitle) => target.includes(itemTitle))) {
|
||||||
|
score += 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (episode !== null && item.episodes === episode) {
|
||||||
|
score += 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (season !== null && isSeasonMentioned(candidateTitles, season)) {
|
||||||
|
score += 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { item, score };
|
||||||
|
});
|
||||||
|
|
||||||
|
scored.sort((a, b) => {
|
||||||
|
if (b.score !== a.score) return b.score - a.score;
|
||||||
|
return b.item.id - a.item.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selected = scored[0]!;
|
||||||
|
const selectedTitle =
|
||||||
|
selected.item.title?.english ??
|
||||||
|
selected.item.title?.romaji ??
|
||||||
|
selected.item.title?.native ??
|
||||||
|
title;
|
||||||
|
return { id: selected.item.id, title: selectedTitle };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchCandidates(parsed: CoverArtCandidate): string[] {
|
||||||
|
const candidateTitles = [
|
||||||
|
...(parsed.source === 'guessit' && parsed.season !== null && parsed.season > 1
|
||||||
|
? [`${parsed.title} Season ${parsed.season}`]
|
||||||
|
: []),
|
||||||
|
parsed.title,
|
||||||
|
];
|
||||||
|
return candidateTitles
|
||||||
|
.map((title) => title.trim())
|
||||||
|
.filter((title, index, all) => title.length > 0 && all.indexOf(title) === index);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchAnilist(
|
||||||
rateLimiter: AnilistRateLimiter,
|
rateLimiter: AnilistRateLimiter,
|
||||||
query: string,
|
title: string,
|
||||||
variables: Record<string, unknown>,
|
): Promise<{ media: AnilistMedia[]; rateLimited: boolean }> {
|
||||||
): Promise<T> {
|
|
||||||
await rateLimiter.acquire();
|
await rateLimiter.acquire();
|
||||||
|
|
||||||
const res = await fetch(ANILIST_GRAPHQL_URL, {
|
const res = await fetch(ANILIST_GRAPHQL_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
body: JSON.stringify({ query, variables }),
|
body: JSON.stringify({ query: SEARCH_QUERY, variables: { search: title } }),
|
||||||
});
|
});
|
||||||
|
|
||||||
rateLimiter.recordResponse(res.headers);
|
rateLimiter.recordResponse(res.headers);
|
||||||
|
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
throw new AnilistRateLimitedError();
|
return { media: [], rateLimited: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Anilist search failed: ${res.status} ${res.statusText}`);
|
throw new Error(`Anilist search failed: ${res.status} ${res.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = (await res.json()) as { data?: T; errors?: Array<{ message?: string }> };
|
const json = (await res.json()) as AnilistSearchResponse;
|
||||||
const firstError = json.errors?.find((entry) => Boolean(entry?.message));
|
const mediaList = json.data?.Page?.media;
|
||||||
if (firstError?.message) {
|
if (!mediaList || mediaList.length === 0) {
|
||||||
throw new Error(firstError.message);
|
return { media: [], rateLimited: false };
|
||||||
}
|
}
|
||||||
if (!json.data) {
|
|
||||||
throw new Error('Anilist response missing data');
|
return { media: mediaList, rateLimited: false };
|
||||||
}
|
|
||||||
return json.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadImage(url: string): Promise<Buffer | null> {
|
async function downloadImage(url: string): Promise<Buffer | null> {
|
||||||
@@ -242,57 +376,49 @@ export function createCoverArtFetcher(
|
|||||||
|
|
||||||
const parsedInfo = await resolveMediaInfo(db, videoId, canonicalTitle);
|
const parsedInfo = await resolveMediaInfo(db, videoId, canonicalTitle);
|
||||||
const searchBase = parsedInfo?.title ?? cleaned;
|
const searchBase = parsedInfo?.title ?? cleaned;
|
||||||
const searchTitles = searchBase === cleaned ? [searchBase] : ([searchBase, cleaned] as const);
|
const searchCandidates = parsedInfo ? buildSearchCandidates(parsedInfo) : [cleaned];
|
||||||
|
|
||||||
const execute: AnilistQueryExecutor = (query, variables) =>
|
const effectiveCandidates = searchCandidates.includes(cleaned)
|
||||||
executeAnilistQuery(rateLimiter, query, variables);
|
? searchCandidates
|
||||||
|
: [...searchCandidates, cleaned];
|
||||||
|
|
||||||
|
let selected: AnilistMedia | null = null;
|
||||||
|
let rateLimited = false;
|
||||||
|
|
||||||
|
for (const candidate of effectiveCandidates) {
|
||||||
|
logger.info('cover-art: searching Anilist for "%s" (videoId=%d)', candidate, videoId);
|
||||||
|
|
||||||
let resolution: AnilistSeasonResolution | null = null;
|
|
||||||
try {
|
try {
|
||||||
for (const searchTitle of searchTitles) {
|
const result = await searchAnilist(rateLimiter, candidate);
|
||||||
logger.info('cover-art: searching Anilist for "%s" (videoId=%d)', searchTitle, videoId);
|
rateLimited = result.rateLimited;
|
||||||
resolution = await resolveAnilistSeasonMedia(
|
if (result.media.length === 0) {
|
||||||
{
|
continue;
|
||||||
title: searchTitle,
|
}
|
||||||
season: parsedInfo?.season ?? null,
|
|
||||||
episode: parsedInfo?.episode ?? null,
|
const picked = pickBestSearchResult(
|
||||||
},
|
searchBase,
|
||||||
{ execute, logInfo: (message) => logger.info('%s', message) },
|
parsedInfo?.episode ?? null,
|
||||||
|
parsedInfo?.season ?? null,
|
||||||
|
result.media,
|
||||||
);
|
);
|
||||||
if (resolution) break;
|
if (picked) {
|
||||||
|
const match = result.media.find((media) => media.id === picked.id);
|
||||||
|
if (match) {
|
||||||
|
selected = match;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AnilistRateLimitedError) {
|
logger.error('cover-art: Anilist search error for "%s": %s', candidate, err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rateLimited) {
|
||||||
logger.warn('cover-art: rate-limited by Anilist, skipping videoId=%d', videoId);
|
logger.warn('cover-art: rate-limited by Anilist, skipping videoId=%d', videoId);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
logger.error('cover-art: Anilist search error for "%s": %s', searchBase, err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolution && !resolution.seasonResolved) {
|
|
||||||
// Only the season 1 entry was found. Storing its artwork would leave a cover with
|
|
||||||
// no AniList id, which the `existing.coverBlob` early return above serves forever,
|
|
||||||
// so the season could never re-resolve once AniList publishes the relation.
|
|
||||||
// Caching a plain no-match instead reuses the NO_MATCH_RETRY_MS retry window.
|
|
||||||
logger.warn(
|
|
||||||
'cover-art: could not find season %d of "%s" (only matched "%s"), caching no-match',
|
|
||||||
resolution.requestedSeason,
|
|
||||||
searchBase,
|
|
||||||
resolution.title,
|
|
||||||
);
|
|
||||||
upsertCoverArt(db, videoId, {
|
|
||||||
anilistId: null,
|
|
||||||
coverUrl: null,
|
|
||||||
coverBlob: null,
|
|
||||||
titleRomaji: null,
|
|
||||||
titleEnglish: null,
|
|
||||||
episodesTotal: null,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selected = resolution?.media ?? null;
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
logger.info('cover-art: no Anilist results for "%s", caching no-match', searchBase);
|
logger.info('cover-art: no Anilist results for "%s", caching no-match', searchBase);
|
||||||
upsertCoverArt(db, videoId, {
|
upsertCoverArt(db, videoId, {
|
||||||
|
|||||||
@@ -1,307 +0,0 @@
|
|||||||
import test from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
|
|
||||||
import {
|
|
||||||
ANILIST_SEASON_RELATIONS_QUERY,
|
|
||||||
ANILIST_SEASON_SEARCH_QUERY,
|
|
||||||
pickByAirOrder,
|
|
||||||
resolveAnilistSeasonMedia,
|
|
||||||
stripSeasonSuffix,
|
|
||||||
type AnilistSeasonMedia,
|
|
||||||
} from './season-resolver';
|
|
||||||
|
|
||||||
/** Real AniList payloads for "My Teen Romantic Comedy SNAFU", trimmed to used fields. */
|
|
||||||
const OREGAIRU_SEARCH: AnilistSeasonMedia[] = [
|
|
||||||
{
|
|
||||||
id: 14813,
|
|
||||||
episodes: 13,
|
|
||||||
format: 'TV',
|
|
||||||
seasonYear: 2013,
|
|
||||||
title: {
|
|
||||||
romaji: 'Yahari Ore no Seishun Love Come wa Machigatteiru.',
|
|
||||||
english: 'My Teen Romantic Comedy SNAFU',
|
|
||||||
native: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 18753,
|
|
||||||
episodes: 1,
|
|
||||||
format: 'OVA',
|
|
||||||
seasonYear: 2013,
|
|
||||||
title: { romaji: null, english: 'My Teen Romantic Comedy SNAFU OVA', native: null },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 108489,
|
|
||||||
episodes: 12,
|
|
||||||
format: 'TV',
|
|
||||||
seasonYear: 2020,
|
|
||||||
title: {
|
|
||||||
romaji: 'Yahari Ore no Seishun Love Come wa Machigatteiru. Kan',
|
|
||||||
english: 'My Teen Romantic Comedy SNAFU Climax!',
|
|
||||||
native: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 20698,
|
|
||||||
episodes: 13,
|
|
||||||
format: 'TV',
|
|
||||||
seasonYear: 2015,
|
|
||||||
title: {
|
|
||||||
romaji: 'Yahari Ore no Seishun Love Come wa Machigatteiru. Zoku',
|
|
||||||
english: 'My Teen Romantic Comedy SNAFU TOO!',
|
|
||||||
native: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const OREGAIRU_RELATIONS: Record<
|
|
||||||
number,
|
|
||||||
Array<{ relationType: string; node: AnilistSeasonMedia }>
|
|
||||||
> = {
|
|
||||||
14813: [
|
|
||||||
{
|
|
||||||
relationType: 'SIDE_STORY',
|
|
||||||
node: OREGAIRU_SEARCH[1]!,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
relationType: 'SEQUEL',
|
|
||||||
node: OREGAIRU_SEARCH[3]!,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
20698: [
|
|
||||||
{
|
|
||||||
relationType: 'PREQUEL',
|
|
||||||
node: OREGAIRU_SEARCH[0]!,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
relationType: 'SEQUEL',
|
|
||||||
node: OREGAIRU_SEARCH[2]!,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
108489: [
|
|
||||||
{
|
|
||||||
relationType: 'PREQUEL',
|
|
||||||
node: OREGAIRU_SEARCH[3]!,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
function createExecutor(
|
|
||||||
search: AnilistSeasonMedia[],
|
|
||||||
relations: Record<number, Array<{ relationType: string; node: AnilistSeasonMedia }>> = {},
|
|
||||||
) {
|
|
||||||
const searches: string[] = [];
|
|
||||||
const relationLookups: number[] = [];
|
|
||||||
const execute = async <T>(query: string, variables: Record<string, unknown>): Promise<T> => {
|
|
||||||
if (query === ANILIST_SEASON_SEARCH_QUERY) {
|
|
||||||
searches.push(String(variables.search));
|
|
||||||
return { Page: { media: search } } as T;
|
|
||||||
}
|
|
||||||
if (query === ANILIST_SEASON_RELATIONS_QUERY) {
|
|
||||||
const id = Number(variables.id);
|
|
||||||
relationLookups.push(id);
|
|
||||||
return {
|
|
||||||
Media: {
|
|
||||||
relations: {
|
|
||||||
edges: (relations[id] ?? []).map((edge) => ({
|
|
||||||
relationType: edge.relationType,
|
|
||||||
node: { ...edge.node, type: 'ANIME' },
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as T;
|
|
||||||
}
|
|
||||||
throw new Error(`unexpected query: ${query}`);
|
|
||||||
};
|
|
||||||
return { execute, searches, relationLookups };
|
|
||||||
}
|
|
||||||
|
|
||||||
test('stripSeasonSuffix drops release-name season markers', () => {
|
|
||||||
assert.equal(stripSeasonSuffix('Some Show Season 3'), 'Some Show');
|
|
||||||
assert.equal(stripSeasonSuffix('Some Show S3'), 'Some Show');
|
|
||||||
assert.equal(stripSeasonSuffix('Some Show 2nd Season'), 'Some Show');
|
|
||||||
assert.equal(stripSeasonSuffix('Some Show'), 'Some Show');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('resolves season 3 by walking sequel relations', async () => {
|
|
||||||
const { execute, searches, relationLookups } = createExecutor(
|
|
||||||
OREGAIRU_SEARCH,
|
|
||||||
OREGAIRU_RELATIONS,
|
|
||||||
);
|
|
||||||
const result = await resolveAnilistSeasonMedia(
|
|
||||||
{ title: 'My Teen Romantic Comedy SNAFU', season: 3, episode: 1 },
|
|
||||||
{ execute },
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(result?.id, 108489);
|
|
||||||
assert.equal(result?.title, 'My Teen Romantic Comedy SNAFU Climax!');
|
|
||||||
assert.equal(result?.episodes, 12);
|
|
||||||
assert.equal(result?.seasonResolved, true);
|
|
||||||
assert.equal(result?.via, 'sequel-chain');
|
|
||||||
// Searches the bare title only; "Season 3" returns nothing on AniList.
|
|
||||||
assert.deepEqual(searches, ['My Teen Romantic Comedy SNAFU']);
|
|
||||||
assert.deepEqual(relationLookups, [14813, 20698]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('season 1 resolves to the anchor without relation lookups', async () => {
|
|
||||||
const { execute, relationLookups } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
|
|
||||||
const result = await resolveAnilistSeasonMedia(
|
|
||||||
{ title: 'My Teen Romantic Comedy SNAFU', season: 1, episode: 5 },
|
|
||||||
{ execute },
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(result?.id, 14813);
|
|
||||||
assert.equal(result?.seasonResolved, true);
|
|
||||||
assert.equal(result?.via, 'anchor');
|
|
||||||
assert.deepEqual(relationLookups, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('strips a season marker already present in the parsed title', async () => {
|
|
||||||
const { execute, searches } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
|
|
||||||
const result = await resolveAnilistSeasonMedia(
|
|
||||||
{ title: 'My Teen Romantic Comedy SNAFU Season 2', season: 2, episode: 3 },
|
|
||||||
{ execute },
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(searches, ['My Teen Romantic Comedy SNAFU']);
|
|
||||||
assert.equal(result?.id, 20698);
|
|
||||||
assert.equal(result?.seasonResolved, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('falls back to air order when the sequel chain is broken', async () => {
|
|
||||||
const { execute } = createExecutor(OREGAIRU_SEARCH, {});
|
|
||||||
const result = await resolveAnilistSeasonMedia(
|
|
||||||
{ title: 'My Teen Romantic Comedy SNAFU', season: 3, episode: 1 },
|
|
||||||
{ execute },
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(result?.id, 108489);
|
|
||||||
assert.equal(result?.via, 'air-order');
|
|
||||||
assert.equal(result?.seasonResolved, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('reports seasonResolved false rather than silently using season 1', async () => {
|
|
||||||
const { execute } = createExecutor([OREGAIRU_SEARCH[0]!], {});
|
|
||||||
const result = await resolveAnilistSeasonMedia(
|
|
||||||
{ title: 'My Teen Romantic Comedy SNAFU', season: 3, episode: 1 },
|
|
||||||
{ execute },
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(result?.id, 14813);
|
|
||||||
assert.equal(result?.seasonResolved, false);
|
|
||||||
assert.equal(result?.via, 'anchor');
|
|
||||||
assert.equal(result?.requestedSeason, 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not filter the anchor by an episode count the season 1 entry cannot have', async () => {
|
|
||||||
const { execute } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
|
|
||||||
// Season 2 episode 13 exceeds nothing, but season 3 episode 13 exceeds Climax!'s 12.
|
|
||||||
const result = await resolveAnilistSeasonMedia(
|
|
||||||
{ title: 'My Teen Romantic Comedy SNAFU', season: 2, episode: 13 },
|
|
||||||
{ execute },
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(result?.id, 20698);
|
|
||||||
assert.equal(result?.seasonResolved, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('prefers the TV sequel when a franchise branches into other formats', async () => {
|
|
||||||
const branching: Record<number, Array<{ relationType: string; node: AnilistSeasonMedia }>> = {
|
|
||||||
1: [
|
|
||||||
{
|
|
||||||
relationType: 'SEQUEL',
|
|
||||||
node: {
|
|
||||||
id: 3,
|
|
||||||
episodes: 6,
|
|
||||||
format: 'ONA',
|
|
||||||
seasonYear: 2019,
|
|
||||||
title: { english: 'Spinoff' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
relationType: 'SEQUEL',
|
|
||||||
node: { id: 2, episodes: 12, format: 'TV', seasonYear: 2020, title: { english: 'Show 2' } },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const { execute } = createExecutor(
|
|
||||||
[{ id: 1, episodes: 12, format: 'TV', seasonYear: 2018, title: { english: 'Show' } }],
|
|
||||||
branching,
|
|
||||||
);
|
|
||||||
const result = await resolveAnilistSeasonMedia({ title: 'Show', season: 2 }, { execute });
|
|
||||||
|
|
||||||
assert.equal(result?.id, 2);
|
|
||||||
assert.equal(result?.via, 'sequel-chain');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('air-order fallback refuses when the anchor is not the earliest entry', () => {
|
|
||||||
const anchor: AnilistSeasonMedia = {
|
|
||||||
id: 2,
|
|
||||||
format: 'TV',
|
|
||||||
seasonYear: 2020,
|
|
||||||
title: { english: 'Show Later' },
|
|
||||||
};
|
|
||||||
const picked = pickByAirOrder(anchor, 2, [
|
|
||||||
anchor,
|
|
||||||
{ id: 1, format: 'TV', seasonYear: 2015, title: { english: 'Show Earlier' } },
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(picked, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns null when the search yields nothing', async () => {
|
|
||||||
const { execute } = createExecutor([]);
|
|
||||||
const result = await resolveAnilistSeasonMedia({ title: 'Nothing', season: 2 }, { execute });
|
|
||||||
|
|
||||||
assert.equal(result, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('refuses a season beyond the sequel-hop cap instead of returning a partial walk', async () => {
|
|
||||||
// A 30-entry sequel chain: hopping the capped number of times would land on the wrong
|
|
||||||
// season and report it resolved, so the walk must decline outright.
|
|
||||||
const chain: Record<number, Array<{ relationType: string; node: AnilistSeasonMedia }>> = {};
|
|
||||||
for (let id = 1; id < 30; id += 1) {
|
|
||||||
chain[id] = [
|
|
||||||
{
|
|
||||||
relationType: 'SEQUEL',
|
|
||||||
node: {
|
|
||||||
id: id + 1,
|
|
||||||
episodes: 12,
|
|
||||||
format: 'TV',
|
|
||||||
seasonYear: 2000 + id,
|
|
||||||
title: { english: `Show ${id + 1}` },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
const { execute, relationLookups } = createExecutor(
|
|
||||||
[{ id: 1, episodes: 12, format: 'TV', seasonYear: 2000, title: { english: 'Show' } }],
|
|
||||||
chain,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await resolveAnilistSeasonMedia({ title: 'Show', season: 25 }, { execute });
|
|
||||||
|
|
||||||
assert.equal(result?.id, 1);
|
|
||||||
assert.equal(result?.seasonResolved, false);
|
|
||||||
assert.equal(result?.via, 'anchor');
|
|
||||||
// Declines before spending any relation requests.
|
|
||||||
assert.deepEqual(relationLookups, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('air-order fallback declines when a franchise entry has no air year', async () => {
|
|
||||||
// The middle season has no year, so ordering it would shift every later season by one.
|
|
||||||
const { execute } = createExecutor(
|
|
||||||
[
|
|
||||||
{ id: 1, episodes: 12, format: 'TV', seasonYear: 2013, title: { english: 'Show' } },
|
|
||||||
{ id: 2, episodes: 12, format: 'TV', title: { english: 'Show Zoku' } },
|
|
||||||
{ id: 3, episodes: 12, format: 'TV', seasonYear: 2020, title: { english: 'Show Kan' } },
|
|
||||||
],
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await resolveAnilistSeasonMedia({ title: 'Show', season: 2 }, { execute });
|
|
||||||
|
|
||||||
assert.equal(result?.id, 1);
|
|
||||||
assert.equal(result?.seasonResolved, false);
|
|
||||||
assert.equal(result?.via, 'anchor');
|
|
||||||
});
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
/**
|
|
||||||
* AniList has no concept of "season N": sequels are separate media with their own
|
|
||||||
* titles (Zoku, Kan, 2nd Season, ...). Searching "<title> Season 3" therefore returns
|
|
||||||
* nothing and callers silently fall back to the season 1 entry.
|
|
||||||
*
|
|
||||||
* This module resolves a parsed (title, season) pair to the right media by locating a
|
|
||||||
* season 1 anchor and then walking AniList SEQUEL relations, with an air-order fallback
|
|
||||||
* when the relation chain is incomplete. When the seasonal entry cannot be located it
|
|
||||||
* reports `seasonResolved: false` so callers can refuse to act instead of guessing.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface AnilistSeasonMediaTitle {
|
|
||||||
romaji?: string | null;
|
|
||||||
english?: string | null;
|
|
||||||
native?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AnilistSeasonMedia {
|
|
||||||
id: number;
|
|
||||||
episodes?: number | null;
|
|
||||||
format?: string | null;
|
|
||||||
seasonYear?: number | null;
|
|
||||||
startDate?: { year?: number | null } | null;
|
|
||||||
synonyms?: Array<string | null> | null;
|
|
||||||
coverImage?: { large?: string | null; medium?: string | null } | null;
|
|
||||||
title?: AnilistSeasonMediaTitle | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AnilistQueryExecutor = <T>(
|
|
||||||
query: string,
|
|
||||||
variables: Record<string, unknown>,
|
|
||||||
) => Promise<T>;
|
|
||||||
|
|
||||||
export type AnilistSeasonResolutionVia = 'anchor' | 'sequel-chain' | 'air-order';
|
|
||||||
|
|
||||||
export interface AnilistSeasonResolution {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
episodes: number | null;
|
|
||||||
media: AnilistSeasonMedia;
|
|
||||||
/** False when a season >= 2 was requested but no seasonal entry could be located. */
|
|
||||||
seasonResolved: boolean;
|
|
||||||
requestedSeason: number | null;
|
|
||||||
via: AnilistSeasonResolutionVia;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResolveAnilistSeasonMediaInput {
|
|
||||||
title: string;
|
|
||||||
season?: number | null;
|
|
||||||
episode?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResolveAnilistSeasonMediaDeps {
|
|
||||||
execute: AnilistQueryExecutor;
|
|
||||||
logInfo?: (message: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MEDIA_FIELDS = `
|
|
||||||
id
|
|
||||||
episodes
|
|
||||||
format
|
|
||||||
seasonYear
|
|
||||||
startDate { year }
|
|
||||||
synonyms
|
|
||||||
coverImage { large medium }
|
|
||||||
title { romaji english native }
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ANILIST_SEASON_SEARCH_QUERY = `
|
|
||||||
query ($search: String!) {
|
|
||||||
Page(perPage: 10) {
|
|
||||||
media(search: $search, type: ANIME, sort: [SEARCH_MATCH, POPULARITY_DESC]) {
|
|
||||||
${MEDIA_FIELDS}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const ANILIST_SEASON_RELATIONS_QUERY = `
|
|
||||||
query ($id: Int!) {
|
|
||||||
Media(id: $id, type: ANIME) {
|
|
||||||
id
|
|
||||||
relations {
|
|
||||||
edges {
|
|
||||||
relationType
|
|
||||||
node {
|
|
||||||
type
|
|
||||||
${MEDIA_FIELDS}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
interface AnilistSeasonSearchResponse {
|
|
||||||
Page?: {
|
|
||||||
media?: AnilistSeasonMedia[] | null;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AnilistSeasonRelationsResponse {
|
|
||||||
Media?: {
|
|
||||||
relations?: {
|
|
||||||
edges?: Array<{
|
|
||||||
relationType?: string | null;
|
|
||||||
node?: (AnilistSeasonMedia & { type?: string | null }) | null;
|
|
||||||
} | null> | null;
|
|
||||||
} | null;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Formats that can carry a numbered TV season, best first. */
|
|
||||||
const SEASONAL_FORMAT_PRIORITY = ['TV', 'TV_SHORT', 'ONA'];
|
|
||||||
|
|
||||||
const MAX_SEQUEL_HOPS = 12;
|
|
||||||
|
|
||||||
function normalizeTitle(value: string): string {
|
|
||||||
return value.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drops season markers a release name carries but AniList titles never do,
|
|
||||||
* so "Some Show Season 3" and "Some Show S3" both search as "Some Show".
|
|
||||||
*/
|
|
||||||
export function stripSeasonSuffix(title: string): string {
|
|
||||||
return title
|
|
||||||
.replace(/\bseason\s*\d{1,2}\b/gi, ' ')
|
|
||||||
.replace(/\b\d{1,2}(?:st|nd|rd|th)\s+season\b/gi, ' ')
|
|
||||||
.replace(/\bs\d{1,2}\b/gi, ' ')
|
|
||||||
.replace(/\s{2,}/g, ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function mediaTitles(media: AnilistSeasonMedia): string[] {
|
|
||||||
const synonyms = Array.isArray(media.synonyms) ? media.synonyms : [];
|
|
||||||
return [media.title?.english, media.title?.romaji, media.title?.native, ...synonyms]
|
|
||||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
|
||||||
.map((value) => normalizeTitle(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayTitle(media: AnilistSeasonMedia, fallback: string): string {
|
|
||||||
return (
|
|
||||||
media.title?.english?.trim() ||
|
|
||||||
media.title?.romaji?.trim() ||
|
|
||||||
media.title?.native?.trim() ||
|
|
||||||
fallback.trim()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function episodeCount(media: AnilistSeasonMedia): number | null {
|
|
||||||
return typeof media.episodes === 'number' && media.episodes > 0 ? media.episodes : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function airYear(media: AnilistSeasonMedia): number | null {
|
|
||||||
if (typeof media.seasonYear === 'number' && media.seasonYear > 0) {
|
|
||||||
return media.seasonYear;
|
|
||||||
}
|
|
||||||
const startYear = media.startDate?.year;
|
|
||||||
return typeof startYear === 'number' && startYear > 0 ? startYear : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSeasonalFormat(media: AnilistSeasonMedia): boolean {
|
|
||||||
const format = (media.format || '').toUpperCase();
|
|
||||||
return SEASONAL_FORMAT_PRIORITY.includes(format);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRank(media: AnilistSeasonMedia): number {
|
|
||||||
const index = SEASONAL_FORMAT_PRIORITY.indexOf((media.format || '').toUpperCase());
|
|
||||||
return index < 0 ? SEASONAL_FORMAT_PRIORITY.length : index;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toResolution(
|
|
||||||
media: AnilistSeasonMedia,
|
|
||||||
fallbackTitle: string,
|
|
||||||
season: number | null,
|
|
||||||
via: AnilistSeasonResolutionVia,
|
|
||||||
seasonResolved: boolean,
|
|
||||||
): AnilistSeasonResolution {
|
|
||||||
return {
|
|
||||||
id: media.id,
|
|
||||||
title: displayTitle(media, fallbackTitle),
|
|
||||||
episodes: episodeCount(media),
|
|
||||||
media,
|
|
||||||
seasonResolved,
|
|
||||||
requestedSeason: season,
|
|
||||||
via,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Picks the franchise anchor (season 1 entry) for a search result set. The anchor is
|
|
||||||
* matched on title alone - season is handled by walking relations from here.
|
|
||||||
*/
|
|
||||||
export function pickAnchorMedia(
|
|
||||||
title: string,
|
|
||||||
media: AnilistSeasonMedia[],
|
|
||||||
options: { episode?: number | null } = {},
|
|
||||||
): AnilistSeasonMedia | null {
|
|
||||||
if (media.length === 0) return null;
|
|
||||||
|
|
||||||
const episode = options.episode;
|
|
||||||
const episodeFiltered =
|
|
||||||
typeof episode === 'number' && episode > 0
|
|
||||||
? media.filter((entry) => {
|
|
||||||
const total = episodeCount(entry);
|
|
||||||
return total === null || total >= episode;
|
|
||||||
})
|
|
||||||
: media;
|
|
||||||
const pool = episodeFiltered.length > 0 ? episodeFiltered : media;
|
|
||||||
|
|
||||||
const targets = [normalizeTitle(title), normalizeTitle(stripSeasonSuffix(title))].filter(
|
|
||||||
(value, index, all) => value.length > 0 && all.indexOf(value) === index,
|
|
||||||
);
|
|
||||||
|
|
||||||
const scored = pool.map((entry, index) => {
|
|
||||||
const candidateTitles = mediaTitles(entry);
|
|
||||||
let score = 0;
|
|
||||||
for (const target of targets) {
|
|
||||||
if (candidateTitles.includes(target)) {
|
|
||||||
score += 120;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (candidateTitles.some((candidate) => candidate.startsWith(target))) {
|
|
||||||
score += 40;
|
|
||||||
} else if (candidateTitles.some((candidate) => candidate.includes(target))) {
|
|
||||||
score += 25;
|
|
||||||
}
|
|
||||||
if (candidateTitles.some((candidate) => target.includes(candidate))) {
|
|
||||||
score += 10;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isSeasonalFormat(entry)) {
|
|
||||||
score += 30;
|
|
||||||
}
|
|
||||||
return { entry, score, index };
|
|
||||||
});
|
|
||||||
|
|
||||||
scored.sort((a, b) => {
|
|
||||||
if (b.score !== a.score) return b.score - a.score;
|
|
||||||
if (a.index !== b.index) return a.index - b.index;
|
|
||||||
return a.entry.id - b.entry.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
return scored[0]?.entry ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function walkSequelChain(
|
|
||||||
anchor: AnilistSeasonMedia,
|
|
||||||
season: number,
|
|
||||||
deps: ResolveAnilistSeasonMediaDeps,
|
|
||||||
): Promise<AnilistSeasonMedia | null> {
|
|
||||||
// Walking fewer hops than requested would land on the wrong season and report it as
|
|
||||||
// resolved, so refuse instead and let the caller fall through to its guarded fallback.
|
|
||||||
const hops = season - 1;
|
|
||||||
if (hops > MAX_SEQUEL_HOPS) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const visited = new Set<number>([anchor.id]);
|
|
||||||
let current = anchor;
|
|
||||||
|
|
||||||
for (let hop = 0; hop < hops; hop += 1) {
|
|
||||||
// Transport errors propagate: a failed hop must not be mistaken for "no sequel exists".
|
|
||||||
const response = await deps.execute<AnilistSeasonRelationsResponse>(
|
|
||||||
ANILIST_SEASON_RELATIONS_QUERY,
|
|
||||||
{ id: current.id },
|
|
||||||
);
|
|
||||||
|
|
||||||
const sequels = (response.Media?.relations?.edges ?? [])
|
|
||||||
.map((edge) => edge ?? null)
|
|
||||||
.filter(
|
|
||||||
(
|
|
||||||
edge,
|
|
||||||
): edge is {
|
|
||||||
relationType?: string | null;
|
|
||||||
node: AnilistSeasonMedia & { type?: string | null };
|
|
||||||
} =>
|
|
||||||
Boolean(edge?.node) &&
|
|
||||||
(edge?.relationType || '').toUpperCase() === 'SEQUEL' &&
|
|
||||||
(edge?.node?.type || 'ANIME').toUpperCase() === 'ANIME',
|
|
||||||
)
|
|
||||||
.map((edge) => edge.node)
|
|
||||||
.filter((node) => !visited.has(node.id));
|
|
||||||
|
|
||||||
if (sequels.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A franchise can branch (a TV sequel plus an ONA spinoff); prefer the TV line.
|
|
||||||
sequels.sort((a, b) => {
|
|
||||||
const rankDelta = formatRank(a) - formatRank(b);
|
|
||||||
if (rankDelta !== 0) return rankDelta;
|
|
||||||
const yearDelta =
|
|
||||||
(airYear(a) ?? Number.MAX_SAFE_INTEGER) - (airYear(b) ?? Number.MAX_SAFE_INTEGER);
|
|
||||||
if (yearDelta !== 0) return yearDelta;
|
|
||||||
return a.id - b.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
current = sequels[0]!;
|
|
||||||
visited.add(current.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return current.id === anchor.id ? null : current;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fallback for franchises whose SEQUEL edges are missing or route through a format we
|
|
||||||
* skipped: order the franchise's seasonal entries by air date and index by season.
|
|
||||||
*/
|
|
||||||
export function pickByAirOrder(
|
|
||||||
anchor: AnilistSeasonMedia,
|
|
||||||
season: number,
|
|
||||||
media: AnilistSeasonMedia[],
|
|
||||||
): AnilistSeasonMedia | null {
|
|
||||||
const anchorTitles = mediaTitles(anchor).map((value) => stripSeasonSuffix(value));
|
|
||||||
const anchorBase = anchorTitles.sort((a, b) => a.length - b.length)[0];
|
|
||||||
if (!anchorBase) return null;
|
|
||||||
|
|
||||||
const franchise = media.filter((entry) => {
|
|
||||||
if (!isSeasonalFormat(entry)) return false;
|
|
||||||
if (entry.id === anchor.id) return true;
|
|
||||||
return mediaTitles(entry).some((candidate) =>
|
|
||||||
stripSeasonSuffix(candidate).includes(anchorBase),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
if (franchise.length < season) return null;
|
|
||||||
|
|
||||||
// Ordering by air date is only meaningful when every entry has one: an unknown year
|
|
||||||
// would sort last and shift the season index while still reporting a resolved match.
|
|
||||||
if (franchise.some((entry) => airYear(entry) === null)) return null;
|
|
||||||
|
|
||||||
const ordered = [...franchise].sort((a, b) => {
|
|
||||||
const yearDelta = (airYear(a) ?? 0) - (airYear(b) ?? 0);
|
|
||||||
if (yearDelta !== 0) return yearDelta;
|
|
||||||
return a.id - b.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
// The anchor has to be the first entry, otherwise this ordering is not a season list.
|
|
||||||
if (ordered[0]?.id !== anchor.id) return null;
|
|
||||||
|
|
||||||
return ordered[season - 1] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveAnilistSeasonMedia(
|
|
||||||
input: ResolveAnilistSeasonMediaInput,
|
|
||||||
deps: ResolveAnilistSeasonMediaDeps,
|
|
||||||
): Promise<AnilistSeasonResolution | null> {
|
|
||||||
const searchTitle = stripSeasonSuffix(input.title).trim() || input.title.trim();
|
|
||||||
if (!searchTitle) return null;
|
|
||||||
|
|
||||||
const season =
|
|
||||||
typeof input.season === 'number' && Number.isInteger(input.season) && input.season > 0
|
|
||||||
? input.season
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const response = await deps.execute<AnilistSeasonSearchResponse>(ANILIST_SEASON_SEARCH_QUERY, {
|
|
||||||
search: searchTitle,
|
|
||||||
});
|
|
||||||
const media = (response.Page?.media ?? []).filter(
|
|
||||||
(entry): entry is AnilistSeasonMedia => Boolean(entry) && typeof entry.id === 'number',
|
|
||||||
);
|
|
||||||
if (media.length === 0) return null;
|
|
||||||
|
|
||||||
// Season 1 (or unknown) resolves against the episode count; later seasons must not,
|
|
||||||
// because the anchor is season 1 and may be shorter than the requested episode.
|
|
||||||
const anchor = pickAnchorMedia(searchTitle, media, {
|
|
||||||
episode: season === null || season <= 1 ? input.episode : null,
|
|
||||||
});
|
|
||||||
if (!anchor) return null;
|
|
||||||
|
|
||||||
if (season === null || season <= 1) {
|
|
||||||
return toResolution(anchor, searchTitle, season, 'anchor', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
let chainError: unknown = null;
|
|
||||||
let viaChain: AnilistSeasonMedia | null = null;
|
|
||||||
try {
|
|
||||||
viaChain = await walkSequelChain(anchor, season, deps);
|
|
||||||
} catch (error) {
|
|
||||||
chainError = error;
|
|
||||||
}
|
|
||||||
if (viaChain) {
|
|
||||||
deps.logInfo?.(
|
|
||||||
`[anilist] season ${season} of "${searchTitle}" resolved via sequel chain: ${displayTitle(viaChain, searchTitle)} (${viaChain.id})`,
|
|
||||||
);
|
|
||||||
return toResolution(viaChain, searchTitle, season, 'sequel-chain', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const viaAirOrder = pickByAirOrder(anchor, season, media);
|
|
||||||
if (viaAirOrder) {
|
|
||||||
deps.logInfo?.(
|
|
||||||
`[anilist] season ${season} of "${searchTitle}" resolved via air order: ${displayTitle(viaAirOrder, searchTitle)} (${viaAirOrder.id})`,
|
|
||||||
);
|
|
||||||
return toResolution(viaAirOrder, searchTitle, season, 'air-order', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The chain failed for transport reasons rather than because the season is absent;
|
|
||||||
// surface that so callers retry instead of reporting an unresolvable season.
|
|
||||||
if (chainError) {
|
|
||||||
throw chainError;
|
|
||||||
}
|
|
||||||
|
|
||||||
deps.logInfo?.(
|
|
||||||
`[anilist] could not resolve season ${season} of "${searchTitle}"; falling back to ${displayTitle(anchor, searchTitle)} (${anchor.id})`,
|
|
||||||
);
|
|
||||||
return toResolution(anchor, searchTitle, season, 'anchor', false);
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import path from 'node:path';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { Database } from '../sqlite.js';
|
import { Database } from '../sqlite.js';
|
||||||
import {
|
import {
|
||||||
applyPragmas,
|
|
||||||
createTrackerPreparedStatements,
|
createTrackerPreparedStatements,
|
||||||
ensureSchema,
|
ensureSchema,
|
||||||
getOrCreateAnimeRecord,
|
getOrCreateAnimeRecord,
|
||||||
@@ -58,16 +57,6 @@ function makeDbPath(): string {
|
|||||||
return path.join(dir, 'immersion.sqlite');
|
return path.join(dir, 'immersion.sqlite');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match the runtime's SQLite tuning (WAL + synchronous=NORMAL). Without it these
|
|
||||||
// tests run at SQLite's defaults, where every statement is its own fsync-ing
|
|
||||||
// transaction; on slow CI disks that is enough to push insert-heavy cases past
|
|
||||||
// the 5s per-test timeout.
|
|
||||||
function openTestDb(dbPath: string) {
|
|
||||||
const db = new Database(dbPath);
|
|
||||||
applyPragmas(db);
|
|
||||||
return db;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupDbPath(dbPath: string): void {
|
function cleanupDbPath(dbPath: string): void {
|
||||||
const dir = path.dirname(dbPath);
|
const dir = path.dirname(dbPath);
|
||||||
if (!fs.existsSync(dir)) {
|
if (!fs.existsSync(dir)) {
|
||||||
@@ -183,7 +172,7 @@ function withMockNowMs<T>(fixedDateMs: string | number, run: () => T): T {
|
|||||||
|
|
||||||
test('getSessionSummaries returns sessionId and canonicalTitle', () => {
|
test('getSessionSummaries returns sessionId and canonicalTitle', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -242,7 +231,7 @@ test('getSessionSummaries returns sessionId and canonicalTitle', () => {
|
|||||||
|
|
||||||
test('getAnimeEpisodes prefers the latest session media position when the latest session is still active', () => {
|
test('getAnimeEpisodes prefers the latest session media position when the latest session is still active', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -310,7 +299,7 @@ test('getAnimeEpisodes prefers the latest session media position when the latest
|
|||||||
|
|
||||||
test('getAnimeEpisodes includes unwatched episodes for the anime', () => {
|
test('getAnimeEpisodes includes unwatched episodes for the anime', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -408,7 +397,7 @@ test('getAnimeEpisodes includes unwatched episodes for the anime', () => {
|
|||||||
|
|
||||||
test('getAnimeEpisodes falls back to the latest subtitle segment end when session progress checkpoints are missing', () => {
|
test('getAnimeEpisodes falls back to the latest subtitle segment end when session progress checkpoints are missing', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -480,7 +469,7 @@ test('getAnimeEpisodes falls back to the latest subtitle segment end when sessio
|
|||||||
|
|
||||||
test('getAnimeEpisodes ignores zero-valued session checkpoints and falls back to subtitle progress', () => {
|
test('getAnimeEpisodes ignores zero-valued session checkpoints and falls back to subtitle progress', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -552,7 +541,7 @@ test('getAnimeEpisodes ignores zero-valued session checkpoints and falls back to
|
|||||||
|
|
||||||
test('getSessionTimeline returns the full session when no limit is provided', () => {
|
test('getSessionTimeline returns the full session when no limit is provided', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -603,7 +592,7 @@ test('getSessionTimeline returns the full session when no limit is provided', ()
|
|||||||
|
|
||||||
test('getDailyRollups limits by distinct days (not rows)', () => {
|
test('getDailyRollups limits by distinct days (not rows)', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -636,7 +625,7 @@ test('getDailyRollups limits by distinct days (not rows)', () => {
|
|||||||
|
|
||||||
test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -790,7 +779,7 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
|||||||
|
|
||||||
test('getTrendsDashboard redacts legacy Jellyfin stream titles', () => {
|
test('getTrendsDashboard redacts legacy Jellyfin stream titles', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -880,7 +869,7 @@ test('getTrendsDashboard redacts legacy Jellyfin stream titles', () => {
|
|||||||
|
|
||||||
test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1016,7 +1005,7 @@ test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
|||||||
|
|
||||||
test('getTrendsDashboard 30d day range zero-fills empty calendar days', () => {
|
test('getTrendsDashboard 30d day range zero-fills empty calendar days', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
withMockNowMs('1772395200000', () => {
|
withMockNowMs('1772395200000', () => {
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1080,7 +1069,7 @@ test('getTrendsDashboard 30d day range zero-fills empty calendar days', () => {
|
|||||||
|
|
||||||
test('getTrendsDashboard skips empty calendar days when zero-fill is disabled', () => {
|
test('getTrendsDashboard skips empty calendar days when zero-fill is disabled', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
withMockNowMs('1772395200000', () => {
|
withMockNowMs('1772395200000', () => {
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1134,7 +1123,7 @@ test(
|
|||||||
{ timeout: 20_000 },
|
{ timeout: 20_000 },
|
||||||
() => {
|
() => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
withMockNowMs('1772395200000', () => {
|
withMockNowMs('1772395200000', () => {
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1194,7 +1183,7 @@ test(
|
|||||||
|
|
||||||
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
|
test('getTrendsDashboard month grouping spans every touched calendar month and keeps progress monthly', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
withMockNowMs('1772395200000', () => {
|
withMockNowMs('1772395200000', () => {
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1417,7 +1406,7 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
|
|||||||
|
|
||||||
test('getQueryHints reads all-time totals from lifetime summary', () => {
|
test('getQueryHints reads all-time totals from lifetime summary', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1487,7 +1476,7 @@ test('getQueryHints reads all-time totals from lifetime summary', () => {
|
|||||||
|
|
||||||
test('getQueryHints computes weekly new-word cutoff from calendar midnights', () => {
|
test('getQueryHints computes weekly new-word cutoff from calendar midnights', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
withMockNowMs('1773601200000', () => {
|
withMockNowMs('1773601200000', () => {
|
||||||
try {
|
try {
|
||||||
@@ -1539,7 +1528,7 @@ test('getQueryHints computes weekly new-word cutoff from calendar midnights', ()
|
|||||||
|
|
||||||
test('word-count read models use filtered persisted occurrences with raw fallback', () => {
|
test('word-count read models use filtered persisted occurrences with raw fallback', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1622,7 +1611,7 @@ test('word-count read models use filtered persisted occurrences with raw fallbac
|
|||||||
|
|
||||||
test('rollups keep persisted totals when retained-session word counts are partial', () => {
|
test('rollups keep persisted totals when retained-session word counts are partial', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1683,7 +1672,7 @@ test('rollups keep persisted totals when retained-session word counts are partia
|
|||||||
|
|
||||||
test('getQueryHints counts new words by distinct headword first-seen time', () => {
|
test('getQueryHints counts new words by distinct headword first-seen time', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1748,7 +1737,7 @@ test('getQueryHints counts new words by distinct headword first-seen time', () =
|
|||||||
|
|
||||||
test('getSessionSummaries with no telemetry returns zero aggregates', () => {
|
test('getSessionSummaries with no telemetry returns zero aggregates', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1782,7 +1771,7 @@ test('getSessionSummaries with no telemetry returns zero aggregates', () => {
|
|||||||
|
|
||||||
test('getSessionSummaries uses denormalized session metrics for ended sessions without telemetry', () => {
|
test('getSessionSummaries uses denormalized session metrics for ended sessions without telemetry', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1834,7 +1823,7 @@ test('getSessionSummaries uses denormalized session metrics for ended sessions w
|
|||||||
|
|
||||||
test('getVocabularyStats returns rows ordered by frequency descending', () => {
|
test('getVocabularyStats returns rows ordered by frequency descending', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1877,7 +1866,7 @@ test('getVocabularyStats returns rows ordered by frequency descending', () => {
|
|||||||
|
|
||||||
test('getVocabularyStats filters rows that fail tokenizer vocabulary rules', () => {
|
test('getVocabularyStats filters rows that fail tokenizer vocabulary rules', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1932,7 +1921,7 @@ test('getVocabularyStats filters rows that fail tokenizer vocabulary rules', ()
|
|||||||
|
|
||||||
test('getVocabularyStats pages past hidden rows until enough visible rows are collected', () => {
|
test('getVocabularyStats pages past hidden rows until enough visible rows are collected', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1967,7 +1956,7 @@ test('getVocabularyStats pages past hidden rows until enough visible rows are co
|
|||||||
|
|
||||||
test('getVocabularyStats returns empty array when no words exist', () => {
|
test('getVocabularyStats returns empty array when no words exist', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -1981,7 +1970,7 @@ test('getVocabularyStats returns empty array when no words exist', () => {
|
|||||||
|
|
||||||
test('cleanupVocabularyStats repairs stored POS metadata and removes excluded imm_words rows', async () => {
|
test('cleanupVocabularyStats repairs stored POS metadata and removes excluded imm_words rows', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2111,7 +2100,7 @@ test('cleanupVocabularyStats repairs stored POS metadata and removes excluded im
|
|||||||
|
|
||||||
test('getDailyRollups returns all rows for the most recent rollup days', () => {
|
test('getDailyRollups returns all rows for the most recent rollup days', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
const insertRollup = db.prepare(
|
const insertRollup = db.prepare(
|
||||||
@@ -2147,7 +2136,7 @@ test('getDailyRollups returns all rows for the most recent rollup days', () => {
|
|||||||
|
|
||||||
test('getMonthlyRollups returns all rows for the most recent rollup months', () => {
|
test('getMonthlyRollups returns all rows for the most recent rollup months', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
const insertRollup = db.prepare(
|
const insertRollup = db.prepare(
|
||||||
@@ -2178,7 +2167,7 @@ test('getMonthlyRollups returns all rows for the most recent rollup months', ()
|
|||||||
|
|
||||||
test('getMonthlyRollups derives rate metrics from stored monthly totals', () => {
|
test('getMonthlyRollups derives rate metrics from stored monthly totals', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
const insertRollup = db.prepare(
|
const insertRollup = db.prepare(
|
||||||
@@ -2209,7 +2198,7 @@ test('getMonthlyRollups derives rate metrics from stored monthly totals', () =>
|
|||||||
|
|
||||||
test('getAnimeDailyRollups returns all rows for the most recent rollup days', () => {
|
test('getAnimeDailyRollups returns all rows for the most recent rollup days', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
const insertRollup = db.prepare(
|
const insertRollup = db.prepare(
|
||||||
@@ -2265,7 +2254,7 @@ test('getAnimeDailyRollups returns all rows for the most recent rollup days', ()
|
|||||||
|
|
||||||
test('cleanupVocabularyStats merges repaired duplicates instead of violating the imm_words unique key', async () => {
|
test('cleanupVocabularyStats merges repaired duplicates instead of violating the imm_words unique key', async () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2378,7 +2367,7 @@ test('cleanupVocabularyStats merges repaired duplicates instead of violating the
|
|||||||
|
|
||||||
test('getKanjiStats returns rows ordered by frequency descending', () => {
|
test('getKanjiStats returns rows ordered by frequency descending', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2416,7 +2405,7 @@ test('getKanjiStats returns rows ordered by frequency descending', () => {
|
|||||||
|
|
||||||
test('getKanjiStats returns empty array when no kanji exist', () => {
|
test('getKanjiStats returns empty array when no kanji exist', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2430,7 +2419,7 @@ test('getKanjiStats returns empty array when no kanji exist', () => {
|
|||||||
|
|
||||||
test('getSessionEvents returns events ordered by ts_ms ascending', () => {
|
test('getSessionEvents returns events ordered by ts_ms ascending', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2491,7 +2480,7 @@ test('getSessionEvents returns events ordered by ts_ms ascending', () => {
|
|||||||
|
|
||||||
test('getSessionEvents round-trips wall-clock timestamps written through event inserts', () => {
|
test('getSessionEvents round-trips wall-clock timestamps written through event inserts', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2535,7 +2524,7 @@ test('getSessionEvents round-trips wall-clock timestamps written through event i
|
|||||||
|
|
||||||
test('getSessionEvents returns empty array for session with no events', () => {
|
test('getSessionEvents returns empty array for session with no events', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2558,7 +2547,7 @@ test('getSessionEvents returns empty array for session with no events', () => {
|
|||||||
|
|
||||||
test('getSessionEvents filters events to the requested session id', () => {
|
test('getSessionEvents filters events to the requested session id', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2624,7 +2613,7 @@ test('getSessionEvents filters events to the requested session id', () => {
|
|||||||
|
|
||||||
test('getSessionEvents respects limit parameter', () => {
|
test('getSessionEvents respects limit parameter', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2667,7 +2656,7 @@ test('getSessionEvents respects limit parameter', () => {
|
|||||||
|
|
||||||
test('getSessionEvents filters by event type before applying limit', () => {
|
test('getSessionEvents filters by event type before applying limit', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2739,7 +2728,7 @@ test('getSessionEvents filters by event type before applying limit', () => {
|
|||||||
|
|
||||||
test('getSessionWordsByLine joins word occurrences through imm_words.id', () => {
|
test('getSessionWordsByLine joins word occurrences through imm_words.id', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -2814,7 +2803,7 @@ test('getSessionWordsByLine joins word occurrences through imm_words.id', () =>
|
|||||||
|
|
||||||
test('anime-level queries group by anime_id and preserve episode-level rows', () => {
|
test('anime-level queries group by anime_id and preserve episode-level rows', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -3094,7 +3083,7 @@ test('anime-level queries group by anime_id and preserve episode-level rows', ()
|
|||||||
|
|
||||||
test('anime library and detail still return lifetime rows without retained sessions', () => {
|
test('anime library and detail still return lifetime rows without retained sessions', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -3191,7 +3180,7 @@ test('anime library and detail still return lifetime rows without retained sessi
|
|||||||
|
|
||||||
test('anime and media detail prefer lifetime totals over partial retained sessions', () => {
|
test('anime and media detail prefer lifetime totals over partial retained sessions', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -3281,7 +3270,7 @@ test('anime and media detail prefer lifetime totals over partial retained sessio
|
|||||||
|
|
||||||
test('media detail resolves retained sessions before lifetime summary exists', () => {
|
test('media detail resolves retained sessions before lifetime summary exists', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -3334,7 +3323,7 @@ test('media detail resolves retained sessions before lifetime summary exists', (
|
|||||||
|
|
||||||
test('media library and detail queries read lifetime totals', () => {
|
test('media library and detail queries read lifetime totals', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -3426,7 +3415,7 @@ test('media library and detail queries read lifetime totals', () => {
|
|||||||
|
|
||||||
test('media library and detail queries include joined youtube metadata when present', () => {
|
test('media library and detail queries include joined youtube metadata when present', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -3523,7 +3512,7 @@ test('media library and detail queries include joined youtube metadata when pres
|
|||||||
|
|
||||||
test('cover art queries reuse a shared blob across duplicate anime art rows', () => {
|
test('cover art queries reuse a shared blob across duplicate anime art rows', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -3650,7 +3639,7 @@ test('cover art queries reuse a shared blob across duplicate anime art rows', ()
|
|||||||
|
|
||||||
test('upsertCoverArt prefers freshly fetched bytes over a reused shared hash', () => {
|
test('upsertCoverArt prefers freshly fetched bytes over a reused shared hash', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -3696,7 +3685,7 @@ test('upsertCoverArt prefers freshly fetched bytes over a reused shared hash', (
|
|||||||
|
|
||||||
test('anime/media detail and episode queries use ended-session metrics when telemetry rows are absent', () => {
|
test('anime/media detail and episode queries use ended-session metrics when telemetry rows are absent', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -3838,7 +3827,7 @@ test('anime/media detail and episode queries use ended-session metrics when tele
|
|||||||
|
|
||||||
test('getWordOccurrences maps a normalized word back to anime, video, and subtitle line context', () => {
|
test('getWordOccurrences maps a normalized word back to anime, video, and subtitle line context', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -3913,7 +3902,7 @@ test('getWordOccurrences maps a normalized word back to anime, video, and subtit
|
|||||||
|
|
||||||
test('searchSubtitleSentences searches known subtitle lines and returns media context', () => {
|
test('searchSubtitleSentences searches known subtitle lines and returns media context', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -4010,7 +3999,7 @@ test('searchSubtitleSentences searches known subtitle lines and returns media co
|
|||||||
|
|
||||||
test('searchSubtitleSentences searches subtitle lines by resolved headword candidates', () => {
|
test('searchSubtitleSentences searches subtitle lines by resolved headword candidates', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -4094,7 +4083,7 @@ test('searchSubtitleSentences searches subtitle lines by resolved headword candi
|
|||||||
|
|
||||||
test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line context', () => {
|
test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line context', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -4169,7 +4158,7 @@ test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line c
|
|||||||
|
|
||||||
test('deleteSession removes the session and all associated session-scoped rows', () => {
|
test('deleteSession removes the session and all associated session-scoped rows', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -4317,7 +4306,7 @@ test('deleteSession removes the session and all associated session-scoped rows',
|
|||||||
|
|
||||||
test('deleteSession rebuilds word and kanji aggregates from retained subtitle lines', () => {
|
test('deleteSession rebuilds word and kanji aggregates from retained subtitle lines', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -4473,7 +4462,7 @@ test('deleteSession rebuilds word and kanji aggregates from retained subtitle li
|
|||||||
|
|
||||||
test('deleteSession removes zero-session media from library and trends', () => {
|
test('deleteSession removes zero-session media from library and trends', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -4678,7 +4667,7 @@ test('deleteSession removes zero-session media from library and trends', () => {
|
|||||||
|
|
||||||
test('getTrendsDashboard builds librarySummary with per-title aggregates', () => {
|
test('getTrendsDashboard builds librarySummary with per-title aggregates', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -4785,7 +4774,7 @@ test('getTrendsDashboard builds librarySummary with per-title aggregates', () =>
|
|||||||
|
|
||||||
test('getTrendsDashboard librarySummary returns null lookupsPerHundred when words is zero', () => {
|
test('getTrendsDashboard librarySummary returns null lookupsPerHundred when words is zero', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -4867,7 +4856,7 @@ test('getTrendsDashboard librarySummary returns null lookupsPerHundred when word
|
|||||||
|
|
||||||
test('getTrendsDashboard rollup word metrics keep persisted totals over partial session counts', () => {
|
test('getTrendsDashboard rollup word metrics keep persisted totals over partial session counts', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
@@ -4982,7 +4971,7 @@ test('getTrendsDashboard rollup word metrics keep persisted totals over partial
|
|||||||
|
|
||||||
test('getTrendsDashboard librarySummary is empty when no rollups exist', () => {
|
test('getTrendsDashboard librarySummary is empty when no rollups exist', () => {
|
||||||
const dbPath = makeDbPath();
|
const dbPath = makeDbPath();
|
||||||
const db = openTestDb(dbPath);
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ensureSchema(db);
|
ensureSchema(db);
|
||||||
|
|||||||
@@ -6,18 +6,11 @@ import {
|
|||||||
getConfiguredWordFieldName,
|
getConfiguredWordFieldName,
|
||||||
getPreferredNoteFieldValue,
|
getPreferredNoteFieldValue,
|
||||||
} from '../../../anki-field-config.js';
|
} from '../../../anki-field-config.js';
|
||||||
import {
|
|
||||||
knownWordsFromState,
|
|
||||||
parseKnownWordCacheState,
|
|
||||||
} from '../../../anki-integration/known-word-cache-format.js';
|
|
||||||
import { createLogger } from '../../../logger.js';
|
|
||||||
import type { AnkiConnectConfig } from '../../../types.js';
|
import type { AnkiConnectConfig } from '../../../types.js';
|
||||||
import type { StatsExcludedWord } from '../../../types/stats-wire.js';
|
import type { StatsExcludedWord } from '../../../types/stats-wire.js';
|
||||||
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
|
import type { ImmersionTrackerService } from '../immersion-tracker-service.js';
|
||||||
import { splitSentenceSearchTerms } from '../immersion-tracker/query-lexical.js';
|
import { splitSentenceSearchTerms } from '../immersion-tracker/query-lexical.js';
|
||||||
|
|
||||||
const statsKnownWordsLogger = createLogger('stats:known-words');
|
|
||||||
|
|
||||||
const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
|
const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
|
||||||
'.css': 'text/css; charset=utf-8',
|
'.css': 'text/css; charset=utf-8',
|
||||||
'.gif': 'image/gif',
|
'.gif': 'image/gif',
|
||||||
@@ -91,14 +84,24 @@ export function parseExcludedWordsBody(body: unknown): StatsExcludedWord[] | nul
|
|||||||
export function loadKnownWordsSet(cachePath: string | undefined): Set<string> | null {
|
export function loadKnownWordsSet(cachePath: string | undefined): Set<string> | null {
|
||||||
if (!cachePath || !existsSync(cachePath)) return null;
|
if (!cachePath || !existsSync(cachePath)) return null;
|
||||||
try {
|
try {
|
||||||
const state = parseKnownWordCacheState(JSON.parse(readFileSync(cachePath, 'utf-8')) as unknown);
|
const raw = JSON.parse(readFileSync(cachePath, 'utf-8')) as {
|
||||||
if (!state) {
|
version?: number;
|
||||||
// A cache that exists but does not parse is a format mismatch, not an
|
words?: string[];
|
||||||
// empty collection; say so instead of reporting zero known words.
|
notes?: Record<string, Array<{ word?: unknown; reading?: unknown }>>;
|
||||||
statsKnownWordsLogger.warn(`Unrecognized known-word cache format at ${cachePath}`);
|
};
|
||||||
return null;
|
if ((raw.version === 1 || raw.version === 2) && Array.isArray(raw.words)) {
|
||||||
|
return new Set(raw.words);
|
||||||
|
}
|
||||||
|
if (raw.version === 3 && raw.notes && typeof raw.notes === 'object') {
|
||||||
|
const words = new Set<string>();
|
||||||
|
for (const entries of Object.values(raw.notes)) {
|
||||||
|
if (!Array.isArray(entries)) continue;
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry && typeof entry.word === 'string' && entry.word) words.add(entry.word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return words;
|
||||||
}
|
}
|
||||||
return knownWordsFromState(state);
|
|
||||||
} catch {
|
} catch {
|
||||||
// Treat an unreadable cache as unavailable.
|
// Treat an unreadable cache as unavailable.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export interface AnnotationStageOptions {
|
|||||||
sourceText?: string;
|
sourceText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveKnownWordText(
|
function resolveKnownWordText(
|
||||||
surface: string,
|
surface: string,
|
||||||
headword: string,
|
headword: string,
|
||||||
matchMode: NPlusOneMatchMode,
|
matchMode: NPlusOneMatchMode,
|
||||||
@@ -571,7 +571,7 @@ function isCompleteReadingForSurface(surface: string, reading: string): boolean
|
|||||||
// (see isCompleteReadingForSurface); undefined otherwise. Shared so the
|
// (see isCompleteReadingForSurface); undefined otherwise. Shared so the
|
||||||
// known-word reading disambiguation and the reading fallback stay in sync if the
|
// known-word reading disambiguation and the reading fallback stay in sync if the
|
||||||
// validity rule changes.
|
// validity rule changes.
|
||||||
export function resolveCompleteTokenReading(token: MergedToken): string | undefined {
|
function resolveCompleteTokenReading(token: MergedToken): string | undefined {
|
||||||
const normalizedReading = token.reading.trim();
|
const normalizedReading = token.reading.trim();
|
||||||
if (!normalizedReading || !isCompleteReadingForSurface(token.surface, normalizedReading)) {
|
if (!normalizedReading || !isCompleteReadingForSurface(token.surface, normalizedReading)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -584,7 +584,7 @@ export function resolveCompleteTokenReading(token: MergedToken): string | undefi
|
|||||||
// inflected surface's reading does not match the dictionary form's reading,
|
// inflected surface's reading does not match the dictionary form's reading,
|
||||||
// and partial furigana readings (see isCompleteReadingForSurface) would cause
|
// and partial furigana readings (see isCompleteReadingForSurface) would cause
|
||||||
// false negatives. Undefined falls back to text-only matching (fail-open).
|
// false negatives. Undefined falls back to text-only matching (fail-open).
|
||||||
export function resolveKnownWordReadingForMatch(
|
function resolveKnownWordReadingForMatch(
|
||||||
token: MergedToken,
|
token: MergedToken,
|
||||||
knownWordMatchMode: NPlusOneMatchMode,
|
knownWordMatchMode: NPlusOneMatchMode,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
|
|||||||
@@ -85,5 +85,5 @@ test('writeConfigExampleArtifacts creates parent directories for resolved output
|
|||||||
test('generateConfigExampleTemplate uses the canonical example socket path', () => {
|
test('generateConfigExampleTemplate uses the canonical example socket path', () => {
|
||||||
const template = generateConfigExampleTemplate();
|
const template = generateConfigExampleTemplate();
|
||||||
|
|
||||||
assert.match(template, /"socketPath": "\/tmp\/subminer-socket"/);
|
assert.match(template, /"socketPath": "\\\\\\\\.\\\\pipe\\\\subminer-socket"/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as path from 'path';
|
|||||||
import { DEFAULT_CONFIG, deepCloneConfig, generateConfigTemplate } from './config';
|
import { DEFAULT_CONFIG, deepCloneConfig, generateConfigTemplate } from './config';
|
||||||
import { getDefaultMpvSocketPath } from './shared/mpv-socket-path';
|
import { getDefaultMpvSocketPath } from './shared/mpv-socket-path';
|
||||||
|
|
||||||
const CONFIG_EXAMPLE_PLATFORM: NodeJS.Platform = 'linux';
|
const CONFIG_EXAMPLE_PLATFORM: NodeJS.Platform = 'win32';
|
||||||
|
|
||||||
export function generateConfigExampleTemplate(): string {
|
export function generateConfigExampleTemplate(): string {
|
||||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||||
|
|||||||
+4
-11
@@ -3616,12 +3616,10 @@ const {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
|
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
|
||||||
updateAnilistPostWatchProgress: (accessToken, title, episode, season, mediaId) =>
|
updateAnilistPostWatchProgress: (accessToken, title, episode, season) =>
|
||||||
updateAnilistPostWatchProgress(accessToken, title, episode, {
|
updateAnilistPostWatchProgress(accessToken, title, episode, {
|
||||||
rateLimiter: anilistRateLimiter,
|
rateLimiter: anilistRateLimiter,
|
||||||
season,
|
season,
|
||||||
mediaId,
|
|
||||||
logInfo: (message) => logger.info(message),
|
|
||||||
}),
|
}),
|
||||||
markSuccess: (key) => {
|
markSuccess: (key) => {
|
||||||
anilistUpdateQueue.markSuccess(key);
|
anilistUpdateQueue.markSuccess(key);
|
||||||
@@ -3657,12 +3655,9 @@ const {
|
|||||||
hasAttemptedUpdateKey: (key) => anilistAttemptedUpdateKeys.has(key),
|
hasAttemptedUpdateKey: (key) => anilistAttemptedUpdateKeys.has(key),
|
||||||
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
|
processNextAnilistRetryUpdate: () => processNextAnilistRetryUpdate(),
|
||||||
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
|
refreshAnilistClientSecretState: () => refreshAnilistClientSecretState(),
|
||||||
enqueueRetry: (key, title, episode, season, mediaId) => {
|
enqueueRetry: (key, title, episode, season) => {
|
||||||
anilistUpdateQueue.enqueue(key, title, episode, season, mediaId);
|
anilistUpdateQueue.enqueue(key, title, episode, season);
|
||||||
},
|
},
|
||||||
getCurrentMediaTitle: () => appState.currentMediaTitle,
|
|
||||||
resolvePinnedAnilistMediaId: ({ mediaPath, mediaTitle, guess }) =>
|
|
||||||
characterDictionaryRuntime.resolvePinnedMediaId({ mediaPath, mediaTitle, guess }),
|
|
||||||
markRetryFailure: (key, message) => {
|
markRetryFailure: (key, message) => {
|
||||||
anilistUpdateQueue.markFailure(key, message);
|
anilistUpdateQueue.markFailure(key, message);
|
||||||
},
|
},
|
||||||
@@ -3670,12 +3665,10 @@ const {
|
|||||||
anilistUpdateQueue.markSuccess(key);
|
anilistUpdateQueue.markSuccess(key);
|
||||||
},
|
},
|
||||||
refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(),
|
refreshRetryQueueState: () => anilistStateRuntime.refreshRetryQueueState(),
|
||||||
updateAnilistPostWatchProgress: (accessToken, title, episode, season, mediaId) =>
|
updateAnilistPostWatchProgress: (accessToken, title, episode, season) =>
|
||||||
updateAnilistPostWatchProgress(accessToken, title, episode, {
|
updateAnilistPostWatchProgress(accessToken, title, episode, {
|
||||||
rateLimiter: anilistRateLimiter,
|
rateLimiter: anilistRateLimiter,
|
||||||
season,
|
season,
|
||||||
mediaId,
|
|
||||||
logInfo: (message) => logger.info(message),
|
|
||||||
}),
|
}),
|
||||||
rememberAttemptedUpdateKey: (key) => {
|
rememberAttemptedUpdateKey: (key) => {
|
||||||
rememberAnilistAttemptedUpdate(key);
|
rememberAnilistAttemptedUpdate(key);
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildCharacterDictionarySeriesKey,
|
buildCharacterDictionarySeriesKey,
|
||||||
createCharacterDictionaryManualSelectionStore,
|
createCharacterDictionaryManualSelectionStore,
|
||||||
type CharacterDictionarySeriesKeyGuess,
|
|
||||||
} from './character-dictionary-runtime/manual-selection';
|
} from './character-dictionary-runtime/manual-selection';
|
||||||
import { snapshotHasCharacterNameImages } from './character-dictionary-runtime/image-lookup';
|
import { snapshotHasCharacterNameImages } from './character-dictionary-runtime/image-lookup';
|
||||||
import { resolveJapaneseNameSplits } from './character-dictionary-runtime/name-split-resolver';
|
import { resolveJapaneseNameSplits } from './character-dictionary-runtime/name-split-resolver';
|
||||||
@@ -169,11 +168,6 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
targetPath?: string,
|
targetPath?: string,
|
||||||
options?: CharacterDictionaryGenerateOptions,
|
options?: CharacterDictionaryGenerateOptions,
|
||||||
) => Promise<CharacterDictionaryBuildResult>;
|
) => Promise<CharacterDictionaryBuildResult>;
|
||||||
resolvePinnedMediaId: (input: {
|
|
||||||
mediaPath: string | null;
|
|
||||||
mediaTitle: string | null;
|
|
||||||
guess: CharacterDictionarySeriesKeyGuess | null;
|
|
||||||
}) => Promise<number | null>;
|
|
||||||
} {
|
} {
|
||||||
const outputDir = path.join(deps.userDataPath, 'character-dictionaries');
|
const outputDir = path.join(deps.userDataPath, 'character-dictionaries');
|
||||||
const sleepMs = deps.sleep ?? sleep;
|
const sleepMs = deps.sleep ?? sleep;
|
||||||
@@ -278,7 +272,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
: ''
|
: ''
|
||||||
}`,
|
}`,
|
||||||
);
|
);
|
||||||
const override = await manualSelectionStore.getOverride(seriesKey, guessed.season);
|
const override = await manualSelectionStore.getOverride(seriesKey);
|
||||||
if (override) {
|
if (override) {
|
||||||
deps.logInfo?.(
|
deps.logInfo?.(
|
||||||
`[dictionary] manual AniList override: ${override.mediaTitle} -> AniList ${override.mediaId}`,
|
`[dictionary] manual AniList override: ${override.mediaTitle} -> AniList ${override.mediaId}`,
|
||||||
@@ -320,17 +314,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest, (message) =>
|
const resolved = await resolveAniListMediaIdFromGuess(guessed, beforeRequest);
|
||||||
deps.logInfo?.(message),
|
|
||||||
);
|
|
||||||
if (resolved.seasonResolved === false) {
|
|
||||||
deps.logWarn?.(
|
|
||||||
`[dictionary] could not find season ${resolved.requestedSeason} of "${guessed.title}"; using ${resolved.title} (AniList ${resolved.id}). Set a manual AniList match to correct it.`,
|
|
||||||
);
|
|
||||||
// Caching the fallback would make the wrong season stick silently for every later
|
|
||||||
// episode, so leave it uncached and re-resolve (and re-warn) until it is corrected.
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
writeCachedMediaResolution(outputDir, {
|
writeCachedMediaResolution(outputDir, {
|
||||||
seriesKey,
|
seriesKey,
|
||||||
mediaId: resolved.id,
|
mediaId: resolved.id,
|
||||||
@@ -525,7 +509,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
? await searchAniListMediaCandidates(candidateSearchTitle, waitForAniListRequestSlot)
|
? await searchAniListMediaCandidates(candidateSearchTitle, waitForAniListRequestSlot)
|
||||||
: [];
|
: [];
|
||||||
const [override, current] = await Promise.all([
|
const [override, current] = await Promise.all([
|
||||||
manualSelectionStore.getOverride(seriesKey, guessed.season),
|
manualSelectionStore.getOverride(seriesKey),
|
||||||
shouldUseExplicitSearch
|
shouldUseExplicitSearch
|
||||||
? Promise.resolve(null)
|
? Promise.resolve(null)
|
||||||
: resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot)
|
: resolveAniListMediaIdFromGuess(guessed, waitForAniListRequestSlot)
|
||||||
@@ -569,7 +553,6 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
mediaId: selected.id,
|
mediaId: selected.id,
|
||||||
mediaTitle: selected.title,
|
mediaTitle: selected.title,
|
||||||
staleMediaIds,
|
staleMediaIds,
|
||||||
season: guessed.season,
|
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -622,14 +605,5 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
revision,
|
revision,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
resolvePinnedMediaId: async ({ mediaPath, mediaTitle, guess }) => {
|
|
||||||
const seriesKey = buildCharacterDictionarySeriesKey({
|
|
||||||
mediaPath: deps.resolveMediaPathForJimaku(mediaPath),
|
|
||||||
mediaTitle,
|
|
||||||
guess,
|
|
||||||
});
|
|
||||||
const override = await manualSelectionStore.getOverride(seriesKey, guess?.season ?? null);
|
|
||||||
return override?.mediaId ?? null;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
|
import type { AnilistMediaGuess } from '../../core/services/anilist/anilist-updater';
|
||||||
import { resolveAnilistSeasonMedia } from '../../core/services/anilist/season-resolver';
|
|
||||||
import { ANILIST_GRAPHQL_URL } from './constants';
|
import { ANILIST_GRAPHQL_URL } from './constants';
|
||||||
import type {
|
import type {
|
||||||
AniListMediaCandidate,
|
AniListMediaCandidate,
|
||||||
@@ -74,6 +73,57 @@ type AniListCharacterPageResponse = {
|
|||||||
} | null;
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function normalizeTitle(value: string): string {
|
||||||
|
return value.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAniListSearchResult(
|
||||||
|
title: string,
|
||||||
|
episode: number | null,
|
||||||
|
media: Array<{
|
||||||
|
id: number;
|
||||||
|
episodes?: number | null;
|
||||||
|
title?: {
|
||||||
|
romaji?: string | null;
|
||||||
|
english?: string | null;
|
||||||
|
native?: string | null;
|
||||||
|
};
|
||||||
|
}>,
|
||||||
|
): ResolvedAniListMedia | null {
|
||||||
|
if (media.length === 0) return null;
|
||||||
|
|
||||||
|
const episodeFiltered =
|
||||||
|
episode && episode > 0
|
||||||
|
? media.filter((entry) => {
|
||||||
|
const totalEpisodes = entry.episodes;
|
||||||
|
return (
|
||||||
|
typeof totalEpisodes !== 'number' || totalEpisodes <= 0 || episode <= totalEpisodes
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: media;
|
||||||
|
const candidates = episodeFiltered.length > 0 ? episodeFiltered : media;
|
||||||
|
const normalizedTitle = normalizeTitle(title);
|
||||||
|
|
||||||
|
const exact = candidates.find((entry) => {
|
||||||
|
const titles = [entry.title?.english, entry.title?.romaji, entry.title?.native]
|
||||||
|
.filter((value): value is string => typeof value === 'string')
|
||||||
|
.map((value) => normalizeTitle(value));
|
||||||
|
return titles.includes(normalizedTitle);
|
||||||
|
});
|
||||||
|
const selected = exact ?? candidates[0] ?? media[0];
|
||||||
|
if (!selected) return null;
|
||||||
|
|
||||||
|
const selectedTitle =
|
||||||
|
selected.title?.english?.trim() ||
|
||||||
|
selected.title?.romaji?.trim() ||
|
||||||
|
selected.title?.native?.trim() ||
|
||||||
|
title.trim();
|
||||||
|
return {
|
||||||
|
id: selected.id,
|
||||||
|
title: selectedTitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function toAniListMediaCandidate(
|
function toAniListMediaCandidate(
|
||||||
entry: {
|
entry: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -192,24 +242,35 @@ function inferImageExt(contentType: string | null, bytes: Buffer): string {
|
|||||||
export async function resolveAniListMediaIdFromGuess(
|
export async function resolveAniListMediaIdFromGuess(
|
||||||
guess: AnilistMediaGuess,
|
guess: AnilistMediaGuess,
|
||||||
beforeRequest?: () => Promise<void>,
|
beforeRequest?: () => Promise<void>,
|
||||||
logInfo?: (message: string) => void,
|
|
||||||
): Promise<ResolvedAniListMedia> {
|
): Promise<ResolvedAniListMedia> {
|
||||||
const resolution = await resolveAnilistSeasonMedia(
|
const data = await fetchAniList<AniListSearchResponse>(
|
||||||
{ title: guess.title, season: guess.season, episode: guess.episode },
|
`
|
||||||
|
query($search: String!) {
|
||||||
|
Page(perPage: 10) {
|
||||||
|
media(search: $search, type: ANIME, sort: [SEARCH_MATCH, POPULARITY_DESC]) {
|
||||||
|
id
|
||||||
|
episodes
|
||||||
|
title {
|
||||||
|
romaji
|
||||||
|
english
|
||||||
|
native
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
{
|
{
|
||||||
execute: (query, variables) => fetchAniList(query, variables, beforeRequest),
|
search: guess.title,
|
||||||
logInfo,
|
|
||||||
},
|
},
|
||||||
|
beforeRequest,
|
||||||
);
|
);
|
||||||
if (!resolution) {
|
|
||||||
|
const media = data.Page?.media ?? [];
|
||||||
|
const resolved = pickAniListSearchResult(guess.title, guess.episode, media);
|
||||||
|
if (!resolved) {
|
||||||
throw new Error(`No AniList media match found for "${guess.title}".`);
|
throw new Error(`No AniList media match found for "${guess.title}".`);
|
||||||
}
|
}
|
||||||
return {
|
return resolved;
|
||||||
id: resolution.id,
|
|
||||||
title: resolution.title,
|
|
||||||
seasonResolved: resolution.seasonResolved,
|
|
||||||
requestedSeason: resolution.requestedSeason,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchAniListMediaCandidates(
|
export async function searchAniListMediaCandidates(
|
||||||
|
|||||||
@@ -160,73 +160,3 @@ test('getManualSelectionSnapshot hydrates override episode count from searched c
|
|||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolvePinnedMediaId returns the manual override for the current season', async () => {
|
|
||||||
const userDataPath = makeTempDir();
|
|
||||||
const seasonThreePath = '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv';
|
|
||||||
const guess = {
|
|
||||||
title: 'My Teen Romantic Comedy SNAFU',
|
|
||||||
year: 2013,
|
|
||||||
season: 3,
|
|
||||||
episode: 1,
|
|
||||||
source: 'guessit' as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
const runtime = createCharacterDictionaryRuntimeService({
|
|
||||||
userDataPath,
|
|
||||||
getCurrentMediaPath: () => seasonThreePath,
|
|
||||||
getCurrentMediaTitle: () => null,
|
|
||||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
|
||||||
guessAnilistMediaInfo: async () => guess,
|
|
||||||
now: () => 1_700_000_000_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
await runtime.resolvePinnedMediaId({
|
|
||||||
mediaPath: seasonThreePath,
|
|
||||||
mediaTitle: null,
|
|
||||||
guess,
|
|
||||||
}),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const overridesPath = path.join(userDataPath, 'character-dictionaries', 'anilist-overrides.json');
|
|
||||||
fs.mkdirSync(path.dirname(overridesPath), { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
overridesPath,
|
|
||||||
JSON.stringify({
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
seriesKey: buildCharacterDictionarySeriesKey({
|
|
||||||
mediaPath: seasonThreePath,
|
|
||||||
mediaTitle: null,
|
|
||||||
guess,
|
|
||||||
}),
|
|
||||||
mediaId: 108489,
|
|
||||||
mediaTitle: 'My Teen Romantic Comedy SNAFU Climax!',
|
|
||||||
staleMediaIds: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
await runtime.resolvePinnedMediaId({
|
|
||||||
mediaPath: seasonThreePath,
|
|
||||||
mediaTitle: null,
|
|
||||||
guess,
|
|
||||||
}),
|
|
||||||
108489,
|
|
||||||
);
|
|
||||||
|
|
||||||
// The season 1 file in the same folder must not inherit the season 3 override.
|
|
||||||
assert.equal(
|
|
||||||
await runtime.resolvePinnedMediaId({
|
|
||||||
mediaPath: '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S01E01.mkv',
|
|
||||||
mediaTitle: null,
|
|
||||||
guess: { ...guess, season: 1 },
|
|
||||||
}),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -82,73 +82,6 @@ test('manual selection store persists overrides and matches later episodes in th
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('manual selection store applies a season-directory override when later episode guesses differ', async () => {
|
|
||||||
const userDataPath = makeTempDir();
|
|
||||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
|
||||||
const directory = '/Volumes/jellyfin/anime/toaru-kagaku-no-railgun/Season-2';
|
|
||||||
const selectedKey = buildCharacterDictionarySeriesKey({
|
|
||||||
mediaPath: `${directory}/A Certain Scientific Railgun S - 10 - Critical.mkv`,
|
|
||||||
mediaTitle: 'A Certain Scientific Railgun S - 10 - Critical.mkv',
|
|
||||||
guess: {
|
|
||||||
title: 'Critical',
|
|
||||||
season: null,
|
|
||||||
episode: 10,
|
|
||||||
source: 'guessit',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await store.setOverride({
|
|
||||||
seriesKey: selectedKey,
|
|
||||||
mediaId: 16049,
|
|
||||||
mediaTitle: 'A Certain Scientific Railgun S',
|
|
||||||
staleMediaIds: [1057],
|
|
||||||
});
|
|
||||||
|
|
||||||
const laterEpisodeKey = buildCharacterDictionarySeriesKey({
|
|
||||||
mediaPath: `${directory}/A Certain Scientific Railgun S - 16 - Sisters.mkv`,
|
|
||||||
mediaTitle: 'A Certain Scientific Railgun S - 16 - Sisters.mkv',
|
|
||||||
guess: {
|
|
||||||
title: 'Sisters',
|
|
||||||
season: null,
|
|
||||||
episode: 16,
|
|
||||||
source: 'guessit',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(await store.getOverride(laterEpisodeKey), {
|
|
||||||
seriesKey: selectedKey,
|
|
||||||
mediaId: 16049,
|
|
||||||
mediaTitle: 'A Certain Scientific Railgun S',
|
|
||||||
staleMediaIds: [1057],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('manual selection store replaces older guessed keys for the same season directory', async () => {
|
|
||||||
const userDataPath = makeTempDir();
|
|
||||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
|
||||||
const directoryKey = 'volumes-jellyfin-anime-toaru-kagaku-no-railgun-season-2';
|
|
||||||
const firstKey = `${directoryKey}--critical`;
|
|
||||||
const laterKey = `${directoryKey}--sisters`;
|
|
||||||
await store.setOverride({
|
|
||||||
seriesKey: firstKey,
|
|
||||||
mediaId: 1057,
|
|
||||||
mediaTitle: 'Ippatsu Kiki Musume',
|
|
||||||
staleMediaIds: [],
|
|
||||||
});
|
|
||||||
await store.setOverride({
|
|
||||||
seriesKey: laterKey,
|
|
||||||
mediaId: 16049,
|
|
||||||
mediaTitle: 'A Certain Scientific Railgun S',
|
|
||||||
staleMediaIds: [1057],
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(await store.getOverride(firstKey), {
|
|
||||||
seriesKey: laterKey,
|
|
||||||
mediaId: 16049,
|
|
||||||
mediaTitle: 'A Certain Scientific Railgun S',
|
|
||||||
staleMediaIds: [1057],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('manual selection store resolves legacy unscoped override keys', async () => {
|
test('manual selection store resolves legacy unscoped override keys', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const overrideDir = path.join(userDataPath, 'character-dictionaries');
|
const overrideDir = path.join(userDataPath, 'character-dictionaries');
|
||||||
@@ -238,42 +171,6 @@ test('manual selection store prefers exact scoped override over legacy fallback'
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('manual selection store prefers same-directory override over legacy fallback', async () => {
|
|
||||||
const userDataPath = makeTempDir();
|
|
||||||
const overrideDir = path.join(userDataPath, 'character-dictionaries');
|
|
||||||
fs.mkdirSync(overrideDir, { recursive: true });
|
|
||||||
const directoryScope = 'volumes-jellyfin-anime-toaru-kagaku-no-railgun-season-2';
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(overrideDir, 'anilist-overrides.json'),
|
|
||||||
JSON.stringify({
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
seriesKey: 'sisters',
|
|
||||||
mediaId: 1057,
|
|
||||||
mediaTitle: 'Legacy fallback',
|
|
||||||
staleMediaIds: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
seriesKey: `${directoryScope}--critical`,
|
|
||||||
mediaId: 16049,
|
|
||||||
mediaTitle: 'A Certain Scientific Railgun S',
|
|
||||||
staleMediaIds: [1057],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
|
||||||
|
|
||||||
assert.deepEqual(await store.getOverride(`${directoryScope}--sisters`), {
|
|
||||||
seriesKey: `${directoryScope}--critical`,
|
|
||||||
mediaId: 16049,
|
|
||||||
mediaTitle: 'A Certain Scientific Railgun S',
|
|
||||||
staleMediaIds: [1057],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('manual selection store keeps overrides separate for different season directories', async () => {
|
test('manual selection store keeps overrides separate for different season directories', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
||||||
@@ -312,131 +209,3 @@ test('manual selection store keeps overrides separate for different season direc
|
|||||||
assert.notEqual(secondSeasonKey, firstSeasonKey);
|
assert.notEqual(secondSeasonKey, firstSeasonKey);
|
||||||
assert.equal(await store.getOverride(secondSeasonKey), null);
|
assert.equal(await store.getOverride(secondSeasonKey), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildCharacterDictionarySeriesKey tags seasons past the first', () => {
|
|
||||||
const base = {
|
|
||||||
title: 'My Teen Romantic Comedy SNAFU',
|
|
||||||
year: 2013,
|
|
||||||
episode: 1,
|
|
||||||
source: 'guessit' as const,
|
|
||||||
};
|
|
||||||
const seasonOne = buildCharacterDictionarySeriesKey({
|
|
||||||
mediaPath: '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S01E01.mkv',
|
|
||||||
mediaTitle: null,
|
|
||||||
guess: { ...base, season: 1 },
|
|
||||||
});
|
|
||||||
const seasonThree = buildCharacterDictionarySeriesKey({
|
|
||||||
mediaPath: '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv',
|
|
||||||
mediaTitle: null,
|
|
||||||
guess: { ...base, season: 3 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Season 1 keeps the pre-existing key shape so cached snapshots stay valid.
|
|
||||||
assert.equal(seasonOne, 'anime-oregairu--my-teen-romantic-comedy-snafu-2013');
|
|
||||||
assert.equal(seasonThree, 'anime-oregairu--my-teen-romantic-comedy-snafu-s3-2013');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('manual selection store keeps seasons apart inside one flat directory', async () => {
|
|
||||||
const userDataPath = makeTempDir();
|
|
||||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
|
||||||
const base = {
|
|
||||||
title: 'My Teen Romantic Comedy SNAFU',
|
|
||||||
year: 2013,
|
|
||||||
episode: 1,
|
|
||||||
source: 'guessit' as const,
|
|
||||||
};
|
|
||||||
const seasonOneKey = buildCharacterDictionarySeriesKey({
|
|
||||||
mediaPath: '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S01E01.mkv',
|
|
||||||
mediaTitle: null,
|
|
||||||
guess: { ...base, season: 1 },
|
|
||||||
});
|
|
||||||
const seasonThreeKey = buildCharacterDictionarySeriesKey({
|
|
||||||
mediaPath: '/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv',
|
|
||||||
mediaTitle: null,
|
|
||||||
guess: { ...base, season: 3 },
|
|
||||||
});
|
|
||||||
|
|
||||||
await store.setOverride({
|
|
||||||
seriesKey: seasonOneKey,
|
|
||||||
mediaId: 14813,
|
|
||||||
mediaTitle: 'My Teen Romantic Comedy SNAFU',
|
|
||||||
staleMediaIds: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Same directory, different season: the season 1 override must not leak across.
|
|
||||||
assert.equal(await store.getOverride(seasonThreeKey), null);
|
|
||||||
|
|
||||||
await store.setOverride({
|
|
||||||
seriesKey: seasonThreeKey,
|
|
||||||
mediaId: 108489,
|
|
||||||
mediaTitle: 'My Teen Romantic Comedy SNAFU Climax!',
|
|
||||||
staleMediaIds: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal((await store.getOverride(seasonOneKey))?.mediaId, 14813);
|
|
||||||
assert.equal((await store.getOverride(seasonThreeKey))?.mediaId, 108489);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('override scope uses the recorded season, not season-like text in the title', async () => {
|
|
||||||
const userDataPath = makeTempDir();
|
|
||||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
|
||||||
// A title that itself normalizes to a trailing "-s2" would be misparsed as season 2.
|
|
||||||
const trickyTitleKey = buildCharacterDictionarySeriesKey({
|
|
||||||
mediaPath: '/anime/Mixed/Some Show S2 - S01E01.mkv',
|
|
||||||
mediaTitle: null,
|
|
||||||
guess: { title: 'Some Show S2', season: 1, episode: 1, source: 'guessit' },
|
|
||||||
});
|
|
||||||
const realSeasonTwoKey = buildCharacterDictionarySeriesKey({
|
|
||||||
mediaPath: '/anime/Mixed/Other Show - S02E01.mkv',
|
|
||||||
mediaTitle: null,
|
|
||||||
guess: { title: 'Other Show', season: 2, episode: 1, source: 'guessit' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await store.setOverride({
|
|
||||||
seriesKey: trickyTitleKey,
|
|
||||||
mediaId: 111,
|
|
||||||
mediaTitle: 'Some Show S2',
|
|
||||||
staleMediaIds: [],
|
|
||||||
season: 1,
|
|
||||||
});
|
|
||||||
await store.setOverride({
|
|
||||||
seriesKey: realSeasonTwoKey,
|
|
||||||
mediaId: 222,
|
|
||||||
mediaTitle: 'Other Show 2',
|
|
||||||
staleMediaIds: [],
|
|
||||||
season: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Same directory, genuinely different seasons: neither override may replace the other.
|
|
||||||
assert.equal((await store.getOverride(trickyTitleKey, 1))?.mediaId, 111);
|
|
||||||
assert.equal((await store.getOverride(realSeasonTwoKey, 2))?.mediaId, 222);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('override records without a stored season still resolve via the key', async () => {
|
|
||||||
const userDataPath = makeTempDir();
|
|
||||||
const store = createCharacterDictionaryManualSelectionStore({ userDataPath });
|
|
||||||
const legacyKey = buildCharacterDictionarySeriesKey({
|
|
||||||
mediaPath: REZERO_EP1,
|
|
||||||
mediaTitle: null,
|
|
||||||
guess: {
|
|
||||||
title: 'Re ZERO, Starting Life in Another World',
|
|
||||||
year: 2016,
|
|
||||||
season: 1,
|
|
||||||
episode: 1,
|
|
||||||
source: 'guessit',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const overridesPath = path.join(userDataPath, 'character-dictionaries', 'anilist-overrides.json');
|
|
||||||
fs.mkdirSync(path.dirname(overridesPath), { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
overridesPath,
|
|
||||||
JSON.stringify({
|
|
||||||
overrides: [
|
|
||||||
{ seriesKey: legacyKey, mediaId: 21355, mediaTitle: 'Re:ZERO', staleMediaIds: [] },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal((await store.getOverride(legacyKey, 1))?.mediaId, 21355);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -8,11 +8,6 @@ export type CharacterDictionaryManualSelection = {
|
|||||||
mediaId: number;
|
mediaId: number;
|
||||||
mediaTitle: string;
|
mediaTitle: string;
|
||||||
staleMediaIds: number[];
|
staleMediaIds: number[];
|
||||||
/**
|
|
||||||
* Season this override was saved for. Recorded explicitly because inferring it from the
|
|
||||||
* key text is ambiguous for titles that themselves end in a season-like token.
|
|
||||||
*/
|
|
||||||
season?: number | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ManualSelectionStoreFile = {
|
type ManualSelectionStoreFile = {
|
||||||
@@ -25,11 +20,6 @@ function normalizeManualMediaId(value: unknown): number | null {
|
|||||||
return mediaId > 0 ? mediaId : null;
|
return mediaId > 0 ? mediaId : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSeason(value: unknown): number | null {
|
|
||||||
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) return null;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSeriesKeyPart(value: string): string {
|
function normalizeSeriesKeyPart(value: string): string {
|
||||||
return value
|
return value
|
||||||
.normalize('NFKD')
|
.normalize('NFKD')
|
||||||
@@ -83,13 +73,11 @@ function normalizeOverride(value: unknown): CharacterDictionaryManualSelection |
|
|||||||
const mediaId = normalizeManualMediaId(raw.mediaId);
|
const mediaId = normalizeManualMediaId(raw.mediaId);
|
||||||
const mediaTitle = typeof raw.mediaTitle === 'string' ? raw.mediaTitle.trim() : '';
|
const mediaTitle = typeof raw.mediaTitle === 'string' ? raw.mediaTitle.trim() : '';
|
||||||
if (!seriesKey || mediaId === null || !mediaTitle) return null;
|
if (!seriesKey || mediaId === null || !mediaTitle) return null;
|
||||||
const season = normalizeSeason(raw.season);
|
|
||||||
return {
|
return {
|
||||||
seriesKey,
|
seriesKey,
|
||||||
mediaId,
|
mediaId,
|
||||||
mediaTitle,
|
mediaTitle,
|
||||||
staleMediaIds: dedupeNumbers(Array.isArray(raw.staleMediaIds) ? raw.staleMediaIds : []),
|
staleMediaIds: dedupeNumbers(Array.isArray(raw.staleMediaIds) ? raw.staleMediaIds : []),
|
||||||
...(season === null ? {} : { season }),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,34 +107,10 @@ function getLegacySeriesKeyCandidates(seriesKey: string): string[] {
|
|||||||
return [seriesKey, seriesKey.slice(scopedSeparatorIndex + 2)];
|
return [seriesKey, seriesKey.slice(scopedSeparatorIndex + 2)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDirectoryScope(seriesKey: string): string | null {
|
|
||||||
const scopedSeparatorIndex = seriesKey.indexOf('--');
|
|
||||||
return scopedSeparatorIndex < 0 ? null : seriesKey.slice(0, scopedSeparatorIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fallback season for override records saved before the season was stored explicitly:
|
|
||||||
* reads the "-s<N>" segment emitted by buildCharacterDictionarySeriesKey, which sits just
|
|
||||||
* before the optional trailing year. Season 1 keys carry no segment and parse as 1.
|
|
||||||
*/
|
|
||||||
function getSeasonScopeFromKey(seriesKey: string): number {
|
|
||||||
const match = /-s(\d{1,2})(?:-\d{4})?$/.exec(seriesKey);
|
|
||||||
if (!match) return 1;
|
|
||||||
const season = Number.parseInt(match[1]!, 10);
|
|
||||||
return Number.isInteger(season) && season > 0 ? season : 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The subset of a parsed guess the series key is built from. Kept structural so callers
|
|
||||||
* holding a narrower guess (the AniList post-watch runtime) can build the same key.
|
|
||||||
*/
|
|
||||||
export type CharacterDictionarySeriesKeyGuess = Pick<AnilistMediaGuess, 'title' | 'season'> &
|
|
||||||
Partial<Omit<AnilistMediaGuess, 'title' | 'season'>>;
|
|
||||||
|
|
||||||
export function buildCharacterDictionarySeriesKey(input: {
|
export function buildCharacterDictionarySeriesKey(input: {
|
||||||
mediaPath: string | null;
|
mediaPath: string | null;
|
||||||
mediaTitle: string | null;
|
mediaTitle: string | null;
|
||||||
guess: CharacterDictionarySeriesKeyGuess | null;
|
guess: AnilistMediaGuess | null;
|
||||||
}): string {
|
}): string {
|
||||||
const guessedTitle = input.guess?.title.trim() || input.guess?.alternativeTitle?.trim() || '';
|
const guessedTitle = input.guess?.title.trim() || input.guess?.alternativeTitle?.trim() || '';
|
||||||
const sourceTitle =
|
const sourceTitle =
|
||||||
@@ -161,51 +125,17 @@ export function buildCharacterDictionarySeriesKey(input: {
|
|||||||
const base = normalizeSeriesKeyPart(withoutEpisode) || 'unknown';
|
const base = normalizeSeriesKeyPart(withoutEpisode) || 'unknown';
|
||||||
const directoryKey = getMediaDirectoryKey(input.mediaPath);
|
const directoryKey = getMediaDirectoryKey(input.mediaPath);
|
||||||
const scopedBase = directoryKey ? `${directoryKey}--${base}` : base;
|
const scopedBase = directoryKey ? `${directoryKey}--${base}` : base;
|
||||||
// Season 1 stays unsuffixed so existing keys, snapshots and overrides keep matching.
|
return input.guess?.year ? `${scopedBase}-${input.guess.year}` : scopedBase;
|
||||||
const season = input.guess?.season;
|
|
||||||
const seasonSegment =
|
|
||||||
typeof season === 'number' && Number.isInteger(season) && season > 1 ? `-s${season}` : '';
|
|
||||||
const withSeason = `${scopedBase}${seasonSegment}`;
|
|
||||||
return input.guess?.year ? `${withSeason}-${input.guess.year}` : withSeason;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Season an override applies to: the recorded value, else parsed from its key. */
|
|
||||||
function getOverrideSeason(entry: CharacterDictionaryManualSelection): number {
|
|
||||||
return normalizeSeason(entry.season) ?? getSeasonScopeFromKey(entry.seriesKey);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCharacterDictionaryManualSelectionStore(deps: { userDataPath: string }) {
|
export function createCharacterDictionaryManualSelectionStore(deps: { userDataPath: string }) {
|
||||||
const filePath = path.join(deps.userDataPath, 'character-dictionaries', 'anilist-overrides.json');
|
const filePath = path.join(deps.userDataPath, 'character-dictionaries', 'anilist-overrides.json');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getOverride: async (
|
getOverride: async (seriesKey: string): Promise<CharacterDictionaryManualSelection | null> => {
|
||||||
seriesKey: string,
|
|
||||||
season?: number | null,
|
|
||||||
): Promise<CharacterDictionaryManualSelection | null> => {
|
|
||||||
const candidates = getLegacySeriesKeyCandidates(seriesKey);
|
const candidates = getLegacySeriesKeyCandidates(seriesKey);
|
||||||
const overrides = readOverrides(filePath);
|
const overrides = readOverrides(filePath);
|
||||||
const exactMatch = overrides.find((entry) => entry.seriesKey === candidates[0]);
|
for (const candidate of candidates) {
|
||||||
if (exactMatch) return exactMatch;
|
|
||||||
const directoryScope = getDirectoryScope(seriesKey);
|
|
||||||
if (directoryScope) {
|
|
||||||
// Same folder is only evidence of the same show when it is also the same season;
|
|
||||||
// a flat multi-season folder would otherwise spread one override across all of them.
|
|
||||||
const seasonScope = normalizeSeason(season) ?? getSeasonScopeFromKey(seriesKey);
|
|
||||||
const scopedMatches = overrides.filter(
|
|
||||||
(entry) =>
|
|
||||||
getDirectoryScope(entry.seriesKey) === directoryScope &&
|
|
||||||
getOverrideSeason(entry) === seasonScope,
|
|
||||||
);
|
|
||||||
const selectedMediaIds = new Set(scopedMatches.map((entry) => entry.mediaId));
|
|
||||||
if (scopedMatches.length > 0 && selectedMediaIds.size === 1) {
|
|
||||||
const latest = scopedMatches.at(-1)!;
|
|
||||||
return {
|
|
||||||
...latest,
|
|
||||||
staleMediaIds: dedupeNumbers(scopedMatches.flatMap((entry) => entry.staleMediaIds)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const candidate of candidates.slice(1)) {
|
|
||||||
const match = overrides.find((entry) => entry.seriesKey === candidate);
|
const match = overrides.find((entry) => entry.seriesKey === candidate);
|
||||||
if (match) return match;
|
if (match) return match;
|
||||||
}
|
}
|
||||||
@@ -216,13 +146,8 @@ export function createCharacterDictionaryManualSelectionStore(deps: { userDataPa
|
|||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
throw new Error('Invalid character dictionary manual selection.');
|
throw new Error('Invalid character dictionary manual selection.');
|
||||||
}
|
}
|
||||||
const directoryScope = getDirectoryScope(normalized.seriesKey);
|
const remaining = readOverrides(filePath).filter(
|
||||||
const seasonScope = getOverrideSeason(normalized);
|
(entry) => entry.seriesKey !== normalized.seriesKey,
|
||||||
const remaining = readOverrides(filePath).filter((entry) =>
|
|
||||||
directoryScope
|
|
||||||
? getDirectoryScope(entry.seriesKey) !== directoryScope ||
|
|
||||||
getOverrideSeason(entry) !== seasonScope
|
|
||||||
: entry.seriesKey !== normalized.seriesKey,
|
|
||||||
);
|
);
|
||||||
writeOverrides(filePath, [...remaining, normalized]);
|
writeOverrides(filePath, [...remaining, normalized]);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -324,102 +324,3 @@ test('generateForCurrentMedia keeps same-version snapshots without images when i
|
|||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an unresolvable season is not cached as a normal AniList match', async () => {
|
|
||||||
const userDataPath = makeTempDir();
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
let searchCalls = 0;
|
|
||||||
|
|
||||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
||||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
||||||
if (url !== GRAPHQL_URL) {
|
|
||||||
return new Response(PNG_1X1, { status: 200, headers: { 'content-type': 'image/png' } });
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
|
||||||
query?: string;
|
|
||||||
variables?: { search?: string; id?: number };
|
|
||||||
};
|
|
||||||
|
|
||||||
if (body.query?.includes('characters(page: $page')) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
data: {
|
|
||||||
Media: {
|
|
||||||
title: { english: 'My Teen Romantic Comedy SNAFU' },
|
|
||||||
characters: {
|
|
||||||
pageInfo: { hasNextPage: false },
|
|
||||||
edges: [
|
|
||||||
{
|
|
||||||
role: 'MAIN',
|
|
||||||
node: { id: 1, name: { full: 'Hachiman Hikigaya', native: '比企谷八幡' } },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof body.variables?.id === 'number') {
|
|
||||||
// No sequel edges: season 3 is unreachable from the season 1 anchor.
|
|
||||||
return new Response(JSON.stringify({ data: { Media: { relations: { edges: [] } } } }), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
searchCalls += 1;
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
data: {
|
|
||||||
Page: {
|
|
||||||
media: [
|
|
||||||
{
|
|
||||||
id: 14813,
|
|
||||||
episodes: 13,
|
|
||||||
format: 'TV',
|
|
||||||
seasonYear: 2013,
|
|
||||||
title: { romaji: null, english: 'My Teen Romantic Comedy SNAFU', native: null },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
||||||
);
|
|
||||||
}) as typeof globalThis.fetch;
|
|
||||||
|
|
||||||
const warnings: string[] = [];
|
|
||||||
try {
|
|
||||||
const runtime = createCharacterDictionaryRuntimeService({
|
|
||||||
userDataPath,
|
|
||||||
getCurrentMediaPath: () =>
|
|
||||||
'/anime/Oregairu/My Teen Romantic Comedy SNAFU (2013) - S03E01.mkv',
|
|
||||||
getCurrentMediaTitle: () => null,
|
|
||||||
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
|
||||||
guessAnilistMediaInfo: async () => ({
|
|
||||||
title: 'My Teen Romantic Comedy SNAFU',
|
|
||||||
year: 2013,
|
|
||||||
season: 3,
|
|
||||||
episode: 1,
|
|
||||||
source: 'guessit',
|
|
||||||
}),
|
|
||||||
now: () => 1_700_000_000_000,
|
|
||||||
sleep: async () => {},
|
|
||||||
logWarn: (message) => warnings.push(message),
|
|
||||||
});
|
|
||||||
|
|
||||||
await runtime.getOrCreateCurrentSnapshot();
|
|
||||||
const searchesAfterFirst = searchCalls;
|
|
||||||
await runtime.getOrCreateCurrentSnapshot();
|
|
||||||
|
|
||||||
// Re-resolved rather than served from the resolution cache, and warned both times.
|
|
||||||
assert.ok(searchCalls > searchesAfterFirst);
|
|
||||||
assert.equal(warnings.filter((m) => /could not find season 3/i.test(m)).length, 2);
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -181,7 +181,4 @@ export type ResolvedAniListMedia = {
|
|||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
staleMediaIds?: number[];
|
staleMediaIds?: number[];
|
||||||
/** False when a season >= 2 was requested but only the season 1 entry could be found. */
|
|
||||||
seasonResolved?: boolean;
|
|
||||||
requestedSeason?: number | null;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ export function createBuildProcessNextAnilistRetryUpdateMainDepsHandler(
|
|||||||
title: string,
|
title: string,
|
||||||
episode: number,
|
episode: number,
|
||||||
season?: number | null,
|
season?: number | null,
|
||||||
mediaId?: number | null,
|
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season),
|
||||||
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season, mediaId),
|
|
||||||
markSuccess: (key: string) => deps.markSuccess(key),
|
markSuccess: (key: string) => deps.markSuccess(key),
|
||||||
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
|
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
|
||||||
markFailure: (key: string, message: string) => deps.markFailure(key, message),
|
markFailure: (key: string, message: string) => deps.markFailure(key, message),
|
||||||
@@ -53,19 +52,8 @@ export function createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler(
|
|||||||
hasAttemptedUpdateKey: (key: string) => deps.hasAttemptedUpdateKey(key),
|
hasAttemptedUpdateKey: (key: string) => deps.hasAttemptedUpdateKey(key),
|
||||||
processNextAnilistRetryUpdate: () => deps.processNextAnilistRetryUpdate(),
|
processNextAnilistRetryUpdate: () => deps.processNextAnilistRetryUpdate(),
|
||||||
refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(),
|
refreshAnilistClientSecretState: () => deps.refreshAnilistClientSecretState(),
|
||||||
enqueueRetry: (
|
enqueueRetry: (key: string, title: string, episode: number, season?: number | null) =>
|
||||||
key: string,
|
deps.enqueueRetry(key, title, episode, season),
|
||||||
title: string,
|
|
||||||
episode: number,
|
|
||||||
season?: number | null,
|
|
||||||
mediaId?: number | null,
|
|
||||||
) => deps.enqueueRetry(key, title, episode, season, mediaId),
|
|
||||||
getCurrentMediaTitle: deps.getCurrentMediaTitle
|
|
||||||
? () => deps.getCurrentMediaTitle!()
|
|
||||||
: undefined,
|
|
||||||
resolvePinnedAnilistMediaId: deps.resolvePinnedAnilistMediaId
|
|
||||||
? (input) => deps.resolvePinnedAnilistMediaId!(input)
|
|
||||||
: undefined,
|
|
||||||
markRetryFailure: (key: string, message: string) => deps.markRetryFailure(key, message),
|
markRetryFailure: (key: string, message: string) => deps.markRetryFailure(key, message),
|
||||||
markRetrySuccess: (key: string) => deps.markRetrySuccess(key),
|
markRetrySuccess: (key: string) => deps.markRetrySuccess(key),
|
||||||
refreshRetryQueueState: () => deps.refreshRetryQueueState(),
|
refreshRetryQueueState: () => deps.refreshRetryQueueState(),
|
||||||
@@ -74,8 +62,7 @@ export function createBuildMaybeRunAnilistPostWatchUpdateMainDepsHandler(
|
|||||||
title: string,
|
title: string,
|
||||||
episode: number,
|
episode: number,
|
||||||
season?: number | null,
|
season?: number | null,
|
||||||
mediaId?: number | null,
|
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season),
|
||||||
) => deps.updateAnilistPostWatchProgress(accessToken, title, episode, season, mediaId),
|
|
||||||
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
|
rememberAttemptedUpdateKey: (key: string) => deps.rememberAttemptedUpdateKey(key),
|
||||||
showMpvOsd: (message: string) => deps.showMpvOsd(message),
|
showMpvOsd: (message: string) => deps.showMpvOsd(message),
|
||||||
logInfo: (message: string) => deps.logInfo(message),
|
logInfo: (message: string) => deps.logInfo(message),
|
||||||
|
|||||||
@@ -380,155 +380,3 @@ test('createMaybeRunAnilistPostWatchUpdateHandler notifies when retry already ha
|
|||||||
assert.equal(calls.includes('mark-failure'), false);
|
assert.equal(calls.includes('mark-failure'), false);
|
||||||
assert.deepEqual(calls, ['inflight:true', 'process-retry', 'osd:retry ok', 'inflight:false']);
|
assert.deepEqual(calls, ['inflight:true', 'process-retry', 'osd:retry ok', 'inflight:false']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('createMaybeRunAnilistPostWatchUpdateHandler passes the pinned override media id', async () => {
|
|
||||||
const updateArgs: Array<number | null | undefined> = [];
|
|
||||||
const pinInputs: Array<{ mediaPath: string | null; mediaTitle: string | null; guess: unknown }> =
|
|
||||||
[];
|
|
||||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
|
||||||
getInFlight: () => false,
|
|
||||||
setInFlight: () => {},
|
|
||||||
getResolvedConfig: () => ({}),
|
|
||||||
isAnilistTrackingEnabled: () => true,
|
|
||||||
getCurrentMediaKey: () => '/tmp/video.mkv',
|
|
||||||
hasMpvClient: () => true,
|
|
||||||
getTrackedMediaKey: () => '/tmp/video.mkv',
|
|
||||||
resetTrackedMedia: () => {},
|
|
||||||
getWatchedSeconds: () => 1000,
|
|
||||||
maybeProbeAnilistDuration: async () => 1000,
|
|
||||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 3, episode: 1 }),
|
|
||||||
hasAttemptedUpdateKey: () => false,
|
|
||||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
|
||||||
refreshAnilistClientSecretState: async () => 'token',
|
|
||||||
enqueueRetry: () => {},
|
|
||||||
getCurrentMediaTitle: () => 'Show S03E01.mkv',
|
|
||||||
resolvePinnedAnilistMediaId: async (input) => {
|
|
||||||
pinInputs.push(input);
|
|
||||||
return 108489;
|
|
||||||
},
|
|
||||||
markRetryFailure: () => {},
|
|
||||||
markRetrySuccess: () => {},
|
|
||||||
refreshRetryQueueState: () => {},
|
|
||||||
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, _season, mediaId) => {
|
|
||||||
updateArgs.push(mediaId);
|
|
||||||
return { status: 'updated', message: 'ok' };
|
|
||||||
},
|
|
||||||
rememberAttemptedUpdateKey: () => {},
|
|
||||||
showMpvOsd: () => {},
|
|
||||||
logInfo: () => {},
|
|
||||||
logWarn: () => {},
|
|
||||||
minWatchSeconds: 600,
|
|
||||||
minWatchRatio: 0.85,
|
|
||||||
});
|
|
||||||
|
|
||||||
await handler();
|
|
||||||
assert.deepEqual(updateArgs, [108489]);
|
|
||||||
// Resolved against the media this run captured, not whatever is playing now.
|
|
||||||
assert.equal(pinInputs.length, 1);
|
|
||||||
assert.equal(pinInputs[0]!.mediaPath, '/tmp/video.mkv');
|
|
||||||
assert.equal(pinInputs[0]!.mediaTitle, 'Show S03E01.mkv');
|
|
||||||
assert.deepEqual(pinInputs[0]!.guess, { title: 'Show', season: 3, episode: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('createMaybeRunAnilistPostWatchUpdateHandler queues the pinned media id for retry', async () => {
|
|
||||||
const enqueued: Array<number | null | undefined> = [];
|
|
||||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
|
||||||
getInFlight: () => false,
|
|
||||||
setInFlight: () => {},
|
|
||||||
getResolvedConfig: () => ({}),
|
|
||||||
isAnilistTrackingEnabled: () => true,
|
|
||||||
getCurrentMediaKey: () => '/tmp/video.mkv',
|
|
||||||
hasMpvClient: () => true,
|
|
||||||
getTrackedMediaKey: () => '/tmp/video.mkv',
|
|
||||||
resetTrackedMedia: () => {},
|
|
||||||
getWatchedSeconds: () => 1000,
|
|
||||||
maybeProbeAnilistDuration: async () => 1000,
|
|
||||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 3, episode: 1 }),
|
|
||||||
hasAttemptedUpdateKey: () => false,
|
|
||||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
|
||||||
refreshAnilistClientSecretState: async () => null,
|
|
||||||
enqueueRetry: (_key, _title, _episode, _season, mediaId) => {
|
|
||||||
enqueued.push(mediaId);
|
|
||||||
},
|
|
||||||
resolvePinnedAnilistMediaId: async () => 108489,
|
|
||||||
markRetryFailure: () => {},
|
|
||||||
markRetrySuccess: () => {},
|
|
||||||
refreshRetryQueueState: () => {},
|
|
||||||
updateAnilistPostWatchProgress: async () => ({ status: 'updated', message: 'ok' }),
|
|
||||||
rememberAttemptedUpdateKey: () => {},
|
|
||||||
showMpvOsd: () => {},
|
|
||||||
logInfo: () => {},
|
|
||||||
logWarn: () => {},
|
|
||||||
minWatchSeconds: 600,
|
|
||||||
minWatchRatio: 0.85,
|
|
||||||
});
|
|
||||||
|
|
||||||
await handler();
|
|
||||||
assert.deepEqual(enqueued, [108489]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('createMaybeRunAnilistPostWatchUpdateHandler still updates when the override lookup throws', async () => {
|
|
||||||
const updateArgs: Array<number | null | undefined> = [];
|
|
||||||
const warnings: string[] = [];
|
|
||||||
const handler = createMaybeRunAnilistPostWatchUpdateHandler({
|
|
||||||
getInFlight: () => false,
|
|
||||||
setInFlight: () => {},
|
|
||||||
getResolvedConfig: () => ({}),
|
|
||||||
isAnilistTrackingEnabled: () => true,
|
|
||||||
getCurrentMediaKey: () => '/tmp/video.mkv',
|
|
||||||
hasMpvClient: () => true,
|
|
||||||
getTrackedMediaKey: () => '/tmp/video.mkv',
|
|
||||||
resetTrackedMedia: () => {},
|
|
||||||
getWatchedSeconds: () => 1000,
|
|
||||||
maybeProbeAnilistDuration: async () => 1000,
|
|
||||||
ensureAnilistMediaGuess: async () => ({ title: 'Show', season: 3, episode: 1 }),
|
|
||||||
hasAttemptedUpdateKey: () => false,
|
|
||||||
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'noop' }),
|
|
||||||
refreshAnilistClientSecretState: async () => 'token',
|
|
||||||
enqueueRetry: () => {},
|
|
||||||
resolvePinnedAnilistMediaId: async () => {
|
|
||||||
throw new Error('store unreadable');
|
|
||||||
},
|
|
||||||
markRetryFailure: () => {},
|
|
||||||
markRetrySuccess: () => {},
|
|
||||||
refreshRetryQueueState: () => {},
|
|
||||||
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, _season, mediaId) => {
|
|
||||||
updateArgs.push(mediaId);
|
|
||||||
return { status: 'updated', message: 'ok' };
|
|
||||||
},
|
|
||||||
rememberAttemptedUpdateKey: () => {},
|
|
||||||
showMpvOsd: () => {},
|
|
||||||
logInfo: () => {},
|
|
||||||
logWarn: (message) => warnings.push(message),
|
|
||||||
minWatchSeconds: 600,
|
|
||||||
minWatchRatio: 0.85,
|
|
||||||
});
|
|
||||||
|
|
||||||
await handler();
|
|
||||||
assert.deepEqual(updateArgs, [null]);
|
|
||||||
assert.equal(warnings.length, 1);
|
|
||||||
assert.match(warnings[0]!, /override lookup failed/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('createProcessNextAnilistRetryUpdateHandler forwards the queued media id', async () => {
|
|
||||||
const received: Array<number | null | undefined> = [];
|
|
||||||
const handler = createProcessNextAnilistRetryUpdateHandler({
|
|
||||||
nextReady: () => ({ key: 'k1', title: 'Show', season: 3, mediaId: 108489, episode: 1 }),
|
|
||||||
refreshRetryQueueState: () => {},
|
|
||||||
setLastAttemptAt: () => {},
|
|
||||||
setLastError: () => {},
|
|
||||||
refreshAnilistClientSecretState: async () => 'token',
|
|
||||||
updateAnilistPostWatchProgress: async (_accessToken, _title, _episode, _season, mediaId) => {
|
|
||||||
received.push(mediaId);
|
|
||||||
return { status: 'updated', message: 'ok' };
|
|
||||||
},
|
|
||||||
markSuccess: () => {},
|
|
||||||
rememberAttemptedUpdateKey: () => {},
|
|
||||||
markFailure: () => {},
|
|
||||||
logInfo: () => {},
|
|
||||||
now: () => 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
await handler();
|
|
||||||
assert.deepEqual(received, [108489]);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ type AnilistGuess = {
|
|||||||
title: string;
|
title: string;
|
||||||
season: number | null;
|
season: number | null;
|
||||||
episode: number | null;
|
episode: number | null;
|
||||||
alternativeTitle?: string;
|
|
||||||
year?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type AnilistUpdateResult = {
|
type AnilistUpdateResult = {
|
||||||
@@ -18,7 +16,6 @@ type RetryQueueItem = {
|
|||||||
key: string;
|
key: string;
|
||||||
title: string;
|
title: string;
|
||||||
season?: number | null;
|
season?: number | null;
|
||||||
mediaId?: number | null;
|
|
||||||
episode: number;
|
episode: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,7 +58,6 @@ export function createProcessNextAnilistRetryUpdateHandler(deps: {
|
|||||||
title: string,
|
title: string,
|
||||||
episode: number,
|
episode: number,
|
||||||
season?: number | null,
|
season?: number | null,
|
||||||
mediaId?: number | null,
|
|
||||||
) => Promise<AnilistUpdateResult>;
|
) => Promise<AnilistUpdateResult>;
|
||||||
markSuccess: (key: string) => void;
|
markSuccess: (key: string) => void;
|
||||||
rememberAttemptedUpdateKey: (key: string) => void;
|
rememberAttemptedUpdateKey: (key: string) => void;
|
||||||
@@ -88,7 +84,6 @@ export function createProcessNextAnilistRetryUpdateHandler(deps: {
|
|||||||
queued.title,
|
queued.title,
|
||||||
queued.episode,
|
queued.episode,
|
||||||
queued.season ?? null,
|
queued.season ?? null,
|
||||||
queued.mediaId ?? null,
|
|
||||||
);
|
);
|
||||||
if (result.status === 'updated' || result.status === 'skipped') {
|
if (result.status === 'updated' || result.status === 'skipped') {
|
||||||
deps.markSuccess(queued.key);
|
deps.markSuccess(queued.key);
|
||||||
@@ -124,23 +119,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
|||||||
hasAttemptedUpdateKey: (key: string) => boolean;
|
hasAttemptedUpdateKey: (key: string) => boolean;
|
||||||
processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>;
|
processNextAnilistRetryUpdate: () => Promise<{ ok: boolean; message: string }>;
|
||||||
refreshAnilistClientSecretState: () => Promise<string | null>;
|
refreshAnilistClientSecretState: () => Promise<string | null>;
|
||||||
enqueueRetry: (
|
enqueueRetry: (key: string, title: string, episode: number, season?: number | null) => void;
|
||||||
key: string,
|
|
||||||
title: string,
|
|
||||||
episode: number,
|
|
||||||
season?: number | null,
|
|
||||||
mediaId?: number | null,
|
|
||||||
) => void;
|
|
||||||
getCurrentMediaTitle?: () => string | null;
|
|
||||||
/**
|
|
||||||
* Pinned AniList media id from a manual dictionary override. Takes the media captured
|
|
||||||
* by this run, so a mid-run file change cannot pin the update to a different show.
|
|
||||||
*/
|
|
||||||
resolvePinnedAnilistMediaId?: (input: {
|
|
||||||
mediaPath: string | null;
|
|
||||||
mediaTitle: string | null;
|
|
||||||
guess: AnilistGuess;
|
|
||||||
}) => Promise<number | null>;
|
|
||||||
markRetryFailure: (key: string, message: string) => void;
|
markRetryFailure: (key: string, message: string) => void;
|
||||||
markRetrySuccess: (key: string) => void;
|
markRetrySuccess: (key: string) => void;
|
||||||
refreshRetryQueueState: () => void;
|
refreshRetryQueueState: () => void;
|
||||||
@@ -149,7 +128,6 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
|||||||
title: string,
|
title: string,
|
||||||
episode: number,
|
episode: number,
|
||||||
season?: number | null,
|
season?: number | null,
|
||||||
mediaId?: number | null,
|
|
||||||
) => Promise<AnilistUpdateResult>;
|
) => Promise<AnilistUpdateResult>;
|
||||||
rememberAttemptedUpdateKey: (key: string) => void;
|
rememberAttemptedUpdateKey: (key: string) => void;
|
||||||
showMpvOsd: (message: string) => void;
|
showMpvOsd: (message: string) => void;
|
||||||
@@ -179,8 +157,6 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
|||||||
if (deps.getTrackedMediaKey() !== mediaKey) {
|
if (deps.getTrackedMediaKey() !== mediaKey) {
|
||||||
deps.resetTrackedMedia(mediaKey);
|
deps.resetTrackedMedia(mediaKey);
|
||||||
}
|
}
|
||||||
// Captured before any await: playback can advance while the update is in flight.
|
|
||||||
const mediaTitle = deps.getCurrentMediaTitle?.() ?? null;
|
|
||||||
|
|
||||||
let watchedSeconds = 0;
|
let watchedSeconds = 0;
|
||||||
if (!force) {
|
if (!force) {
|
||||||
@@ -226,23 +202,9 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A manual dictionary override is the user's explicit answer to "which AniList
|
|
||||||
// entry is this?", so it wins over anything the title search would resolve.
|
|
||||||
let pinnedMediaId: number | null = null;
|
|
||||||
try {
|
|
||||||
pinnedMediaId =
|
|
||||||
(await deps.resolvePinnedAnilistMediaId?.({
|
|
||||||
mediaPath: mediaKey,
|
|
||||||
mediaTitle,
|
|
||||||
guess,
|
|
||||||
})) ?? null;
|
|
||||||
} catch (error) {
|
|
||||||
deps.logWarn(`AniList override lookup failed: ${String(error)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessToken = await deps.refreshAnilistClientSecretState();
|
const accessToken = await deps.refreshAnilistClientSecretState();
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season, pinnedMediaId);
|
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season);
|
||||||
deps.markRetryFailure(attemptKey, 'cannot authenticate without anilist.accessToken');
|
deps.markRetryFailure(attemptKey, 'cannot authenticate without anilist.accessToken');
|
||||||
deps.refreshRetryQueueState();
|
deps.refreshRetryQueueState();
|
||||||
deps.showMpvOsd('AniList: access token not configured');
|
deps.showMpvOsd('AniList: access token not configured');
|
||||||
@@ -254,7 +216,6 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
|||||||
guess.title,
|
guess.title,
|
||||||
guess.episode,
|
guess.episode,
|
||||||
guess.season,
|
guess.season,
|
||||||
pinnedMediaId,
|
|
||||||
);
|
);
|
||||||
if (result.status === 'updated') {
|
if (result.status === 'updated') {
|
||||||
deps.rememberAttemptedUpdateKey(attemptKey);
|
deps.rememberAttemptedUpdateKey(attemptKey);
|
||||||
@@ -280,7 +241,7 @@ export function createMaybeRunAnilistPostWatchUpdateHandler(deps: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season, pinnedMediaId);
|
deps.enqueueRetry(attemptKey, guess.title, guess.episode, guess.season);
|
||||||
deps.markRetryFailure(attemptKey, result.message);
|
deps.markRetryFailure(attemptKey, result.message);
|
||||||
deps.refreshRetryQueueState();
|
deps.refreshRetryQueueState();
|
||||||
deps.showMpvOsd(`AniList: ${result.message}`);
|
deps.showMpvOsd(`AniList: ${result.message}`);
|
||||||
|
|||||||
@@ -2,12 +2,6 @@ import type { SessionHelpSection } from './session-help-sections';
|
|||||||
|
|
||||||
export type SessionHelpSubtitleStyle = {
|
export type SessionHelpSubtitleStyle = {
|
||||||
knownWordColor?: unknown;
|
knownWordColor?: unknown;
|
||||||
knownWordMaturityColors?: {
|
|
||||||
new?: unknown;
|
|
||||||
learning?: unknown;
|
|
||||||
young?: unknown;
|
|
||||||
mature?: unknown;
|
|
||||||
};
|
|
||||||
nPlusOneColor?: unknown;
|
nPlusOneColor?: unknown;
|
||||||
nameMatchColor?: unknown;
|
nameMatchColor?: unknown;
|
||||||
jlptColors?: {
|
jlptColors?: {
|
||||||
@@ -19,19 +13,10 @@ export type SessionHelpSubtitleStyle = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SessionHelpColorOptions = {
|
|
||||||
/** When true, known words are colored per Anki card maturity instead of one flat color. */
|
|
||||||
knownWordMaturityEnabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||||
|
|
||||||
const FALLBACK_COLORS = {
|
const FALLBACK_COLORS = {
|
||||||
knownWordColor: '#a6da95',
|
knownWordColor: '#a6da95',
|
||||||
knownWordMaturityNewColor: '#ee99a0',
|
|
||||||
knownWordMaturityLearningColor: '#b7bdf8',
|
|
||||||
knownWordMaturityYoungColor: '#91d7e3',
|
|
||||||
knownWordMaturityMatureColor: '#a6da95',
|
|
||||||
nPlusOneColor: '#c6a0f6',
|
nPlusOneColor: '#c6a0f6',
|
||||||
nameMatchColor: '#f5bde6',
|
nameMatchColor: '#f5bde6',
|
||||||
jlptN1Color: '#ed8796',
|
jlptN1Color: '#ed8796',
|
||||||
@@ -47,53 +32,15 @@ function normalizeColor(value: unknown, fallback: string): string {
|
|||||||
return HEX_COLOR_RE.test(next) ? next : fallback;
|
return HEX_COLOR_RE.test(next) ? next : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildKnownWordRows(
|
export function buildColorSection(style: SessionHelpSubtitleStyle): SessionHelpSection {
|
||||||
style: SessionHelpSubtitleStyle,
|
|
||||||
options: SessionHelpColorOptions,
|
|
||||||
): SessionHelpSection['rows'] {
|
|
||||||
if (!options.knownWordMaturityEnabled) {
|
|
||||||
const knownWordColor = normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor);
|
|
||||||
return [{ shortcut: 'Known words', action: knownWordColor, color: knownWordColor }];
|
|
||||||
}
|
|
||||||
|
|
||||||
const maturityColors = style.knownWordMaturityColors;
|
|
||||||
const tiers: Array<{ label: string; value: unknown; fallback: string }> = [
|
|
||||||
{
|
|
||||||
label: 'Known words (new)',
|
|
||||||
value: maturityColors?.new,
|
|
||||||
fallback: FALLBACK_COLORS.knownWordMaturityNewColor,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Known words (learning)',
|
|
||||||
value: maturityColors?.learning,
|
|
||||||
fallback: FALLBACK_COLORS.knownWordMaturityLearningColor,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Known words (young)',
|
|
||||||
value: maturityColors?.young,
|
|
||||||
fallback: FALLBACK_COLORS.knownWordMaturityYoungColor,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Known words (mature)',
|
|
||||||
value: maturityColors?.mature,
|
|
||||||
fallback: FALLBACK_COLORS.knownWordMaturityMatureColor,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return tiers.map((tier) => {
|
|
||||||
const color = normalizeColor(tier.value, tier.fallback);
|
|
||||||
return { shortcut: tier.label, action: color, color };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildColorSection(
|
|
||||||
style: SessionHelpSubtitleStyle,
|
|
||||||
options: SessionHelpColorOptions = {},
|
|
||||||
): SessionHelpSection {
|
|
||||||
return {
|
return {
|
||||||
title: 'Color legend',
|
title: 'Color legend',
|
||||||
rows: [
|
rows: [
|
||||||
...buildKnownWordRows(style, options),
|
{
|
||||||
|
shortcut: 'Known words',
|
||||||
|
action: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
|
||||||
|
color: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
shortcut: 'N+1 words',
|
shortcut: 'N+1 words',
|
||||||
action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
|
action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
|
||||||
|
|||||||
@@ -403,7 +403,6 @@ export function buildSessionHelpSections(input: {
|
|||||||
markWatchedKey?: string | null;
|
markWatchedKey?: string | null;
|
||||||
subtitleSidebarToggleKey?: string | null;
|
subtitleSidebarToggleKey?: string | null;
|
||||||
subtitleStyle: SessionHelpSubtitleStyle | null | undefined;
|
subtitleStyle: SessionHelpSubtitleStyle | null | undefined;
|
||||||
knownWordMaturityEnabled?: boolean;
|
|
||||||
}): SessionHelpSection[] {
|
}): SessionHelpSection[] {
|
||||||
const sessionBindings = input.sessionBindings.filter((binding) => {
|
const sessionBindings = input.sessionBindings.filter((binding) => {
|
||||||
if (binding.actionType !== 'session-action') return true;
|
if (binding.actionType !== 'session-action') return true;
|
||||||
@@ -421,9 +420,7 @@ export function buildSessionHelpSections(input: {
|
|||||||
subtitleSidebarToggleKey: input.subtitleSidebarToggleKey,
|
subtitleSidebarToggleKey: input.subtitleSidebarToggleKey,
|
||||||
}),
|
}),
|
||||||
...buildFixedOverlaySections(),
|
...buildFixedOverlaySections(),
|
||||||
buildColorSection(input.subtitleStyle ?? {}, {
|
buildColorSection(input.subtitleStyle ?? {}),
|
||||||
knownWordMaturityEnabled: input.knownWordMaturityEnabled,
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ import {
|
|||||||
createSessionHelpModal,
|
createSessionHelpModal,
|
||||||
describeSessionHelpCommand,
|
describeSessionHelpCommand,
|
||||||
formatSessionHelpKeybinding,
|
formatSessionHelpKeybinding,
|
||||||
isKnownWordMaturityLegendEnabled,
|
|
||||||
} from './session-help.js';
|
} from './session-help.js';
|
||||||
import type { RuntimeOptionId, RuntimeOptionState } from '../../types/runtime-options.js';
|
|
||||||
|
|
||||||
test('session help describes sub-seek commands as subtitle-line navigation', () => {
|
test('session help describes sub-seek commands as subtitle-line navigation', () => {
|
||||||
assert.equal(describeSessionHelpCommand(['sub-seek', 1]), 'Jump to next subtitle');
|
assert.equal(describeSessionHelpCommand(['sub-seek', 1]), 'Jump to next subtitle');
|
||||||
@@ -106,101 +104,6 @@ test('session help builds rows from canonical session bindings and fixed overlay
|
|||||||
assert.ok(rows.some((row) => row.shortcut === 'Y then D' && row.action === 'Toggle DevTools'));
|
assert.ok(rows.some((row) => row.shortcut === 'Y then D' && row.action === 'Toggle DevTools'));
|
||||||
});
|
});
|
||||||
|
|
||||||
function booleanRuntimeOption(id: RuntimeOptionId, value: boolean): RuntimeOptionState {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
label: id,
|
|
||||||
scope: 'subtitle',
|
|
||||||
valueType: 'boolean',
|
|
||||||
value,
|
|
||||||
allowedValues: [true, false],
|
|
||||||
requiresRestart: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('maturity legend requires both known-word highlighting and maturity coloring', () => {
|
|
||||||
const highlightOn = booleanRuntimeOption('subtitle.annotation.knownWords.highlightEnabled', true);
|
|
||||||
const highlightOff = booleanRuntimeOption(
|
|
||||||
'subtitle.annotation.knownWords.highlightEnabled',
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
const maturityOn = booleanRuntimeOption('subtitle.annotation.knownWords.maturityEnabled', true);
|
|
||||||
const maturityOff = booleanRuntimeOption('subtitle.annotation.knownWords.maturityEnabled', false);
|
|
||||||
|
|
||||||
assert.equal(isKnownWordMaturityLegendEnabled([highlightOn, maturityOn]), true);
|
|
||||||
assert.equal(isKnownWordMaturityLegendEnabled([highlightOff, maturityOn]), false);
|
|
||||||
assert.equal(isKnownWordMaturityLegendEnabled([highlightOn, maturityOff]), false);
|
|
||||||
assert.equal(isKnownWordMaturityLegendEnabled([maturityOn]), false);
|
|
||||||
assert.equal(isKnownWordMaturityLegendEnabled([]), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
function colorLegendRows(input: Parameters<typeof buildSessionHelpSections>[0]) {
|
|
||||||
const sections = buildSessionHelpSections(input);
|
|
||||||
return sections.find((section) => section.title === 'Color legend')?.rows ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
test('color legend shows the flat known-word color when maturity coloring is off', () => {
|
|
||||||
const rows = colorLegendRows({
|
|
||||||
sessionBindings: [],
|
|
||||||
subtitleStyle: {
|
|
||||||
knownWordColor: '#a6da95',
|
|
||||||
knownWordMaturityColors: {
|
|
||||||
new: '#ee99a0',
|
|
||||||
learning: '#b7bdf8',
|
|
||||||
young: '#91d7e3',
|
|
||||||
mature: '#a6da95',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
rows.filter((row) => row.shortcut.startsWith('Known words')),
|
|
||||||
[{ shortcut: 'Known words', action: '#a6da95', color: '#a6da95' }],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('color legend swaps in maturity tiers when maturity coloring is on', () => {
|
|
||||||
const rows = colorLegendRows({
|
|
||||||
sessionBindings: [],
|
|
||||||
subtitleStyle: {
|
|
||||||
knownWordColor: '#a6da95',
|
|
||||||
knownWordMaturityColors: {
|
|
||||||
new: '#ee99a0',
|
|
||||||
learning: '#b7bdf8',
|
|
||||||
young: '#91d7e3',
|
|
||||||
mature: '#f0c6c6',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
knownWordMaturityEnabled: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
rows.filter((row) => row.shortcut.startsWith('Known words')),
|
|
||||||
[
|
|
||||||
{ shortcut: 'Known words (new)', action: '#ee99a0', color: '#ee99a0' },
|
|
||||||
{ shortcut: 'Known words (learning)', action: '#b7bdf8', color: '#b7bdf8' },
|
|
||||||
{ shortcut: 'Known words (young)', action: '#91d7e3', color: '#91d7e3' },
|
|
||||||
{ shortcut: 'Known words (mature)', action: '#f0c6c6', color: '#f0c6c6' },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
assert.ok(rows.some((row) => row.shortcut === 'N+1 words'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('color legend falls back to default maturity colors when overrides are invalid', () => {
|
|
||||||
const rows = colorLegendRows({
|
|
||||||
sessionBindings: [],
|
|
||||||
subtitleStyle: {
|
|
||||||
knownWordMaturityColors: { new: 'not-a-color', learning: 42 },
|
|
||||||
},
|
|
||||||
knownWordMaturityEnabled: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
rows.filter((row) => row.shortcut.startsWith('Known words')).map((row) => row.color),
|
|
||||||
['#ee99a0', '#b7bdf8', '#91d7e3', '#a6da95'],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function createClassList(initialTokens: string[] = []) {
|
function createClassList(initialTokens: string[] = []) {
|
||||||
const tokens = new Set(initialTokens);
|
const tokens = new Set(initialTokens);
|
||||||
return {
|
return {
|
||||||
@@ -273,7 +176,6 @@ test('modal-layer session help does not focus hidden main overlay and still clos
|
|||||||
getSubtitleSidebarSnapshot: async () => ({
|
getSubtitleSidebarSnapshot: async () => ({
|
||||||
config: { toggleKey: 'Backslash' },
|
config: { toggleKey: 'Backslash' },
|
||||||
}),
|
}),
|
||||||
getRuntimeOptions: async () => [],
|
|
||||||
},
|
},
|
||||||
focus: () => {},
|
focus: () => {},
|
||||||
addEventListener: () => {},
|
addEventListener: () => {},
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { ModalStateReader, RendererContext } from '../context';
|
import type { ModalStateReader, RendererContext } from '../context';
|
||||||
import type { RuntimeOptionId, RuntimeOptionState } from '../../types/runtime-options';
|
|
||||||
import {
|
import {
|
||||||
buildSessionHelpSections,
|
buildSessionHelpSections,
|
||||||
type SessionHelpSection,
|
type SessionHelpSection,
|
||||||
@@ -20,32 +19,6 @@ type SessionHelpBindingInfo = {
|
|||||||
fallbackUnavailable: boolean;
|
fallbackUnavailable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Tiers only render when known-word highlighting is also on, matching
|
|
||||||
* getKnownWordMaturityEnabled in anki-integration/known-word-maturity.
|
|
||||||
*/
|
|
||||||
export function isKnownWordMaturityLegendEnabled(runtimeOptions: RuntimeOptionState[]): boolean {
|
|
||||||
const isOn = (id: RuntimeOptionId): boolean =>
|
|
||||||
runtimeOptions.some((option) => option.id === id && option.value === true);
|
|
||||||
return (
|
|
||||||
isOn('subtitle.annotation.knownWords.highlightEnabled') &&
|
|
||||||
isOn('subtitle.annotation.knownWords.maturityEnabled')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maturity coloring is a live runtime toggle, so the color legend reads it from
|
|
||||||
* runtime options instead of the resolved subtitle style. A missing or failing
|
|
||||||
* runtime-options call falls back to the flat known-word color.
|
|
||||||
*/
|
|
||||||
async function readKnownWordMaturityEnabled(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
return isKnownWordMaturityLegendEnabled(await window.electronAPI.getRuntimeOptions());
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBindingHint(info: SessionHelpBindingInfo): string {
|
function formatBindingHint(info: SessionHelpBindingInfo): string {
|
||||||
if (info.bindingKey === 'KeyK' && info.fallbackUsed) {
|
if (info.bindingKey === 'KeyK' && info.fallbackUsed) {
|
||||||
return info.fallbackUnavailable ? 'Y-K (fallback and conflict noted)' : 'Y-K (fallback)';
|
return info.fallbackUnavailable ? 'Y-K (fallback and conflict noted)' : 'Y-K (fallback)';
|
||||||
@@ -246,13 +219,8 @@ export function createSessionHelpModal(
|
|||||||
|
|
||||||
async function render(): Promise<boolean> {
|
async function render(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const [
|
const [sessionBindings, styleConfig, markWatchedKey, subtitleSidebarToggleKey] =
|
||||||
sessionBindings,
|
await Promise.all([
|
||||||
styleConfig,
|
|
||||||
markWatchedKey,
|
|
||||||
subtitleSidebarToggleKey,
|
|
||||||
knownWordMaturityEnabled,
|
|
||||||
] = await Promise.all([
|
|
||||||
window.electronAPI.getSessionBindings(),
|
window.electronAPI.getSessionBindings(),
|
||||||
window.electronAPI.getSubtitleStyle(),
|
window.electronAPI.getSubtitleStyle(),
|
||||||
window.electronAPI.getMarkWatchedKey(),
|
window.electronAPI.getMarkWatchedKey(),
|
||||||
@@ -260,7 +228,6 @@ export function createSessionHelpModal(
|
|||||||
.getSubtitleSidebarSnapshot()
|
.getSubtitleSidebarSnapshot()
|
||||||
.then((snapshot) => snapshot.config.toggleKey)
|
.then((snapshot) => snapshot.config.toggleKey)
|
||||||
.catch(() => undefined),
|
.catch(() => undefined),
|
||||||
readKnownWordMaturityEnabled(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
helpSections = buildSessionHelpSections({
|
helpSections = buildSessionHelpSections({
|
||||||
@@ -268,7 +235,6 @@ export function createSessionHelpModal(
|
|||||||
markWatchedKey,
|
markWatchedKey,
|
||||||
subtitleSidebarToggleKey,
|
subtitleSidebarToggleKey,
|
||||||
subtitleStyle: styleConfig ?? {},
|
subtitleStyle: styleConfig ?? {},
|
||||||
knownWordMaturityEnabled,
|
|
||||||
});
|
});
|
||||||
applyFilterAndRender();
|
applyFilterAndRender();
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
Reference in New Issue
Block a user