mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 07:21:33 -07:00
Compare commits
9 Commits
v0.15.2
...
c09d009a3e
| Author | SHA1 | Date | |
|---|---|---|---|
|
c09d009a3e
|
|||
| 2007e28be8 | |||
| d5bfdcae7b | |||
| 311f1e8ee5 | |||
| e6a16a069b | |||
| af67c53dd6 | |||
|
ea79e331fa
|
|||
| ee89b0c8a9 | |||
| f2fd58cd2b |
@@ -31,6 +31,6 @@ If docs-site/ changed, also: bun run docs:test && bun run docs:build
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Added a changelog fragment, or this PR is labeled `skip-changelog` (see [`changes/README.md`](../changes/README.md))
|
||||
- [ ] Reconciled current-outcome changelog fragment(s), or this PR is labeled `skip-changelog` (see [`changes/README.md`](../changes/README.md))
|
||||
- [ ] Docs updated in the same PR if behavior, defaults, flags, shortcuts, ports, or APIs changed
|
||||
- [ ] Relevant checks pass locally (typecheck, tests, build)
|
||||
|
||||
@@ -68,7 +68,7 @@ Start here, then leave this file.
|
||||
|
||||
## Release / PR Notes
|
||||
|
||||
- User-visible PRs need one fragment in `changes/*.md` — format and rules in [`changes/README.md`](./changes/README.md) (`type` + `area` keys required; apply the `skip-changelog` label to opt out)
|
||||
- User-visible PRs need reconciled current-outcome fragment(s) in `changes/*.md` — format and rules in [`changes/README.md`](./changes/README.md) (`type` + `area` keys required; inspect existing same-PR fragments, then update/remove stale bullets or add only genuinely separate outcomes; apply the `skip-changelog` label to opt out)
|
||||
- User-visible docs changes get a `type: docs` fragment
|
||||
- CI enforces `bun run changelog:lint` and `bun run changelog:pr-check`
|
||||
- PR review helpers:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop docs-test docs-build docs-build-versioned docs-dev
|
||||
.PHONY: help submodules deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop docs-test docs-build docs-build-versioned docs-dev
|
||||
|
||||
APP_NAME := subminer
|
||||
THEME_SOURCE := assets/themes/subminer.rasi
|
||||
@@ -72,7 +72,8 @@ help:
|
||||
" generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \
|
||||
"" \
|
||||
"Other targets:" \
|
||||
" deps Install JS dependencies (root + stats + texthooker-ui)" \
|
||||
" submodules Initialize/update git submodules" \
|
||||
" deps Initialize submodules and install JS dependencies (root + stats + texthooker-ui)" \
|
||||
" uninstall-linux Remove Linux install artifacts" \
|
||||
" uninstall-macos Remove macOS install artifacts" \
|
||||
" uninstall-windows Remove Windows mpv plugin artifacts" \
|
||||
@@ -105,8 +106,10 @@ print-dirs:
|
||||
"MACOS_APP_SRC=$(MACOS_APP_SRC)" \
|
||||
"MACOS_ZIP_SRC=$(MACOS_ZIP_SRC)"
|
||||
|
||||
deps:
|
||||
@$(MAKE) --no-print-directory ensure-bun
|
||||
submodules:
|
||||
@git submodule update --init --recursive
|
||||
|
||||
deps: submodules ensure-bun
|
||||
@bun install
|
||||
@cd stats && bun install --frozen-lockfile
|
||||
@cd vendor/texthooker-ui && bun install --frozen-lockfile
|
||||
|
||||
@@ -121,6 +121,7 @@ Only **mpv** and Anki+AnkiConnect are required. Everything else is optional but
|
||||
| yt-dlp | Optional | YouTube playback |
|
||||
| fzf / rofi | Optional | Video picker in the launcher |
|
||||
| alass / ffsubsync | Optional | Subtitle sync |
|
||||
| guessit | Optional | Better anime title and episode detection |
|
||||
|
||||
<details>
|
||||
<summary><b>Platform-specific install commands</b></summary>
|
||||
@@ -228,6 +229,7 @@ SubMiner builds on the work of these open-source projects:
|
||||
|
||||
| Project | Role |
|
||||
| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| [ani-skip](https://github.com/synacktraa/ani-skip) | AniSkip API client for anime intro/outro skip timestamps |
|
||||
| [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script) | Inspiration for the mining workflow |
|
||||
| [asbplayer](https://github.com/killergerbah/asbplayer) | Inspiration for subtitle sidebar and logic for YouTube subtitle parsing |
|
||||
| [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) | Character name recognition in subtitles |
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: release
|
||||
|
||||
- Release notes now credit contributors with a `What's Changed` list (`by @author in #pr`) and a `New Contributors` section for first-time authors, resolved from changelog fragments via git and the GitHub API.
|
||||
@@ -31,6 +31,13 @@ Rules:
|
||||
- `README.md` is ignored by the generator
|
||||
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment
|
||||
|
||||
PR branch workflow:
|
||||
|
||||
- Before adding a fragment or bullet, inspect the `changes/*.md` files already changed in the PR
|
||||
- If the new work fixes, modifies, renames, or supersedes behavior introduced or referenced by that fragment, edit or remove the stale bullet instead of adding follow-up churn
|
||||
- Add a new bullet only when it describes a truly separate user-visible outcome
|
||||
- Multiple fragment files are allowed when one PR has genuinely separate release-note outcomes, but keep them minimized and current
|
||||
|
||||
How fragments turn into a release:
|
||||
|
||||
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: anilist
|
||||
|
||||
- Marked AniList entries completed when a post-watch update reaches the final known episode of the season.
|
||||
@@ -0,0 +1,6 @@
|
||||
type: changed
|
||||
area: playback
|
||||
|
||||
- AniSkip intro detection now runs in the SubMiner app instead of the mpv plugin: lookups cover every local file loaded during an mpv session (including playlist advances), and the plugin no longer performs any network calls.
|
||||
- `mpv.aniskipEnabled` and `mpv.aniskipButtonKey` now hot-reload without restarting playback.
|
||||
- AniSkip now requires the SubMiner app to be connected to mpv; plugin-only mpv sessions without the app no longer fetch skip windows.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: playback
|
||||
|
||||
- Fixed AniSkip intro markers disappearing after same-media mpv reloads.
|
||||
- Fixed AniSkip metadata detection for intros that start at `0` seconds and common release-group filenames without `guessit`.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: release
|
||||
|
||||
- Changed PR changelog guidance to preserve multiple fragments for genuinely separate outcomes while directing contributors to update, remove, or merge same-PR fragment notes before adding follow-up churn.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Restarted the Jellyfin remote session after successful setup login so websocket reconnects use the freshly saved credentials.
|
||||
- Stopped the Jellyfin remote session on setup logout.
|
||||
@@ -0,0 +1,6 @@
|
||||
type: changed
|
||||
area: stats
|
||||
|
||||
- Split local and Jellyfin library entries by detected season, using season folders first and filename parsing as fallback.
|
||||
- Repaired older combined-series stats rows by moving parsed episodes into season-specific library entries, rebuilding summaries, and deleting now-empty legacy rows.
|
||||
- Refresh anime detail and library cover art immediately after manually changing an AniList entry.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: build
|
||||
|
||||
- Updated `make deps` so a fresh source checkout initializes submodules before installing root, stats, and texthooker-ui dependencies.
|
||||
@@ -0,0 +1,9 @@
|
||||
type: changed
|
||||
area: stats
|
||||
|
||||
- Added the Stats Search tab for realtime subtitle sentence search with media context, headword matching, and mining actions for source-backed sentence cards or exact-match word/audio cards.
|
||||
- Improved Stats mining from Search and vocabulary examples: empty `ankiConnect.deck` can use Yomitan's mining deck, sentence cards are created before slow media generation finishes, stored/requested secondary subtitles are preserved before falling back to sidecar files or temporary alass-retimed English sidecars for sentence Selection Text, invalid stored timings are blocked before FFmpeg runs, future out-of-order subtitle timing pairs are skipped until valid timings arrive, and partial media failures are shown.
|
||||
- Fixed Stats mining field/audio behavior so sentence clips update `SentenceAudio`, word audio uses the configured Yomitan sources, English subtitle text is not written onto word cards, and secondary subtitle auto-selection prefers regular English tracks over Signs/Songs tracks.
|
||||
- Improved vocabulary review with remembered Hide Known/Hide Kana filters, cross-title Hide Kana filtering, duplicate-collapsed exclusions across token variants, and Related Seen Words matching based on shared readings or kanji.
|
||||
- Reorganized the Stats Trends tab into clearer Activity, Cumulative Totals, Efficiency, Patterns, and Library sections, disambiguated per-period vs cumulative charts, and added Words/Min and Cards/Hour efficiency charts.
|
||||
- Improved Stats browsing reliability by remembering library card size, retrying stored cover art without extra AniList lookups, preserving PNG/WebP cover MIME types, honoring custom AnkiConnect URLs for Browse, showing progress during session deletes, and making session deletes refresh faster.
|
||||
@@ -496,7 +496,7 @@
|
||||
"tags": [
|
||||
"SubMiner"
|
||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.
|
||||
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.
|
||||
"fields": {
|
||||
"word": "Expression", // Card field for the mined word or expression text.
|
||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||
@@ -634,8 +634,8 @@
|
||||
"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
|
||||
"subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.
|
||||
"aniskipEnabled": true, // Enable AniSkip intro detection and skip markers in the bundled mpv plugin. Values: true | false
|
||||
"aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible.
|
||||
"aniskipEnabled": true, // Enable AniSkip intro detection, chapter markers, and the skip-intro key. Values: true | false
|
||||
"aniskipButtonKey": "TAB" // mpv key used to skip the detected intro while the skip prompt is visible.
|
||||
}, // SubMiner-managed mpv launch and bundled plugin options.
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -328,6 +328,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
|
||||
{ text: 'YouTube', link: '/youtube-integration' },
|
||||
{ text: 'Jimaku', link: '/jimaku-integration' },
|
||||
{ text: 'AniList', link: '/anilist-integration' },
|
||||
{ text: 'AniSkip', link: '/aniskip-integration' },
|
||||
{ text: 'Character Dictionary', link: '/character-dictionary' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@ SubMiner monitors playback and triggers an AniList progress update when an episo
|
||||
|
||||
The update flow:
|
||||
|
||||
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename. It 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 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.
|
||||
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# AniSkip Integration
|
||||
|
||||
SubMiner integrates with [AniSkip](https://aniskip.com) to automatically detect anime intro intervals and let you skip them with a single key press.
|
||||
|
||||
Intro detection runs in the SubMiner app over the mpv IPC socket. It is available whenever the overlay is connected to mpv - not just at launch - and covers every local file loaded during an mpv session, including playlist advances.
|
||||
|
||||
## Setup
|
||||
|
||||
AniSkip is opt-in. Enable it in your config:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mpv": {
|
||||
"aniskipEnabled": true,
|
||||
"aniskipButtonKey": "TAB",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Both settings hot-reload: changing them in your config takes effect immediately without restarting playback or mpv.
|
||||
|
||||
For best title and episode detection, install [`guessit`](https://github.com/guessit-io/guessit):
|
||||
|
||||
```bash
|
||||
python3 -m pip install --user guessit
|
||||
```
|
||||
|
||||
Without `guessit`, SubMiner falls back to an internal filename parser which handles most common naming conventions but may miss unusual formats.
|
||||
|
||||
## How It Works
|
||||
|
||||
On each local file load:
|
||||
|
||||
1. SubMiner infers the anime title, season, and episode number from the filename and path (using `guessit` if available, otherwise the built-in parser). Remote URLs are skipped entirely.
|
||||
2. The title is matched against MyAnimeList to resolve a MAL id.
|
||||
3. SubMiner queries the AniSkip API for an OP skip interval for that MAL id and episode.
|
||||
4. If an interval is found, SubMiner adds `AniSkip Intro Start` and `AniSkip Intro End` chapter markers to the current file and binds the skip key (`mpv.aniskipButtonKey`, default `TAB`).
|
||||
5. At the start of the intro, an OSD prompt appears for 3 seconds: `You can skip by pressing TAB` (reflects your configured key). Pressing the key at any point during the intro seeks to the intro end.
|
||||
|
||||
Results are cached per file for the app session. Reload detection is also handled: if mpv reloads the same file, SubMiner re-applies the chapter markers without a new API lookup.
|
||||
|
||||
## Triggering from mpv
|
||||
|
||||
You can trigger AniSkip actions from mpv script-messages:
|
||||
|
||||
| Command | Effect |
|
||||
| ------- | ------ |
|
||||
| `script-message subminer-skip-intro` | Skip to the intro end immediately (same as pressing the key) |
|
||||
| `script-message subminer-aniskip-refresh` | Force a fresh lookup for the current file, discarding any cached result |
|
||||
|
||||
These are handled by the SubMiner app over the IPC socket.
|
||||
@@ -4,11 +4,12 @@ SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-
|
||||
This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) note types, including sentence-card and field-grouping behavior.
|
||||
|
||||
::: tip New to these terms?
|
||||
|
||||
- **Anki** is the flashcard app where your study cards live.
|
||||
- **AnkiConnect** is a free add-on that lets other programs (like SubMiner) talk to Anki over a local connection. SubMiner needs it installed to add or edit cards.
|
||||
- A **note type** (also called a "model") is the template that defines what a card looks like - for example the Kiku or Lapis templates many Japanese learners use.
|
||||
- A **field** is one labeled slot in that template, such as `Sentence`, `Expression`, or `Picture`. SubMiner fills these fields when it mines a card.
|
||||
:::
|
||||
:::
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -22,7 +23,7 @@ AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the po
|
||||
|
||||
When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, image, and translation fields automatically. Two detection methods are available:
|
||||
|
||||
**Proxy mode** (default) - SubMiner runs a local *proxy*: a small middleman server that sits between Yomitan and Anki. Yomitan sends new cards to SubMiner, SubMiner enriches them, then passes them along to Anki. This makes enrichment instant.
|
||||
**Proxy mode** (default) - SubMiner runs a local _proxy_: a small middleman server that sits between Yomitan and Anki. Yomitan sends new cards to SubMiner, SubMiner enriches them, then passes them along to Anki. This makes enrichment instant.
|
||||
|
||||
**Polling mode** (fallback, when the proxy is disabled) - SubMiner asks AnkiConnect every few seconds whether any new cards were added, then enriches them. Simpler setup, but with a short delay (~3 seconds).
|
||||
|
||||
@@ -36,7 +37,7 @@ In both modes, the enrichment workflow is the same:
|
||||
4. Fills the translation field from the secondary subtitle or AI.
|
||||
5. Writes metadata to the miscInfo field.
|
||||
|
||||
Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks. In Settings, the AnkiConnect deck dropdown auto-fills from Yomitan's current mining deck when available, then falls back to the decks reported by AnkiConnect.
|
||||
Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it uses Yomitan's current mining deck when available; otherwise it searches all decks. In Settings, the AnkiConnect deck dropdown auto-fills and persists Yomitan's current mining deck when available, then falls back to the decks reported by AnkiConnect.
|
||||
Known-word sync scope is controlled by `ankiConnect.knownWords.decks`.
|
||||
|
||||
### Proxy Mode Setup (Yomitan / Texthooker)
|
||||
|
||||
@@ -30,7 +30,7 @@ launcher/ # Standalone CLI launcher wrapper and mpv helpers
|
||||
plugin/
|
||||
subminer/ # Modular mpv plugin (init · main · bootstrap · lifecycle · process
|
||||
# state · messages · hover · ui · options · environment · log
|
||||
# binary · aniskip · aniskip_match)
|
||||
# binary)
|
||||
src/
|
||||
ai/ # AI translation provider utilities (client, config)
|
||||
main-entry.ts # Background-mode bootstrap wrapper before loading main.js
|
||||
@@ -130,7 +130,7 @@ src/renderer/
|
||||
### Launcher + Plugin Runtimes
|
||||
|
||||
- `launcher/main.ts` dispatches commands through `launcher/commands/*` and shared config readers in `launcher/config/*`. It handles mpv startup, app passthrough, Jellyfin helper commands, and playback handoff.
|
||||
- `plugin/subminer/init.lua` runs inside mpv and loads modular Lua files: `main.lua` (orchestration), `bootstrap.lua` (startup), `lifecycle.lua` (connect/disconnect), `process.lua` (process management), `state.lua` (shared state), `messages.lua` (IPC), `hover.lua` (hover-token highlight rendering), `ui.lua` (OSD rendering), `options.lua` (config), `environment.lua` (detection), `log.lua` (logging), `binary.lua` (path resolution), `aniskip.lua` + `aniskip_match.lua` (intro-skip UX).
|
||||
- `plugin/subminer/init.lua` runs inside mpv and loads modular Lua files: `main.lua` (orchestration), `bootstrap.lua` (startup), `lifecycle.lua` (connect/disconnect), `process.lua` (process management), `state.lua` (shared state), `messages.lua` (IPC), `hover.lua` (hover-token highlight rendering), `ui.lua` (OSD rendering), `options.lua` (config), `environment.lua` (detection), `log.lua` (logging), `binary.lua` (path resolution). AniSkip intro detection lives in the SubMiner app (`src/main/runtime/aniskip-runtime.ts`), which drives mpv chapters and the skip key over the IPC socket.
|
||||
|
||||
## Flow Diagram
|
||||
|
||||
|
||||
+76
-74
@@ -52,7 +52,7 @@ The Settings window groups options by workflow instead of mirroring the raw conf
|
||||
- Tracking & App
|
||||
- Advanced
|
||||
|
||||
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names. The AnkiConnect deck field also reads Yomitan's current mining deck and auto-fills an empty setting when one is found. Keybinding fields use click-to-learn controls instead of raw text boxes.
|
||||
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names. The AnkiConnect deck field also reads Yomitan's current mining deck and persists it into an empty setting when one is found. Stats mining also uses Yomitan's current mining deck when `ankiConnect.deck` is empty. Keybinding fields use click-to-learn controls instead of raw text boxes.
|
||||
|
||||
The Settings window preserves existing JSONC comments, trailing commas, and unrelated keys. Resetting a field removes the explicit config path so the built-in default applies.
|
||||
|
||||
@@ -360,7 +360,7 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
|
||||
| Option | Values | Description |
|
||||
| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
|
||||
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
|
||||
| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
|
||||
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
|
||||
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
||||
@@ -516,11 +516,11 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ----------------------- | ---------------------------------- | ------------------------------------------------------ |
|
||||
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`) |
|
||||
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
|
||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
||||
| Option | Values | Description |
|
||||
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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 matching secondary subtitle track |
|
||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
||||
|
||||
The secondary-subtitle language list also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback.
|
||||
|
||||
@@ -943,57 +943,57 @@ This example is intentionally compact. The option table below documents availabl
|
||||
|
||||
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
|
||||
|
||||
| Option | Values | Description |
|
||||
| ------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
|
||||
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
||||
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
|
||||
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
|
||||
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
|
||||
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
|
||||
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
|
||||
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
||||
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks. In Settings, this dropdown auto-fills from Yomitan's current mining deck when available. |
|
||||
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
||||
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
|
||||
| `fields.image` | string | Card field for images (default: `Picture`) |
|
||||
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
|
||||
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
|
||||
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
|
||||
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
|
||||
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
||||
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
|
||||
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
|
||||
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
|
||||
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
|
||||
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
|
||||
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
|
||||
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
|
||||
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
|
||||
| `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. |
|
||||
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
|
||||
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
||||
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
|
||||
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) |
|
||||
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
|
||||
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
||||
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
|
||||
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
|
||||
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
||||
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
|
||||
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
|
||||
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
|
||||
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
|
||||
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
|
||||
| Option | Values | Description |
|
||||
| ------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ankiConnect.enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
|
||||
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
|
||||
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
|
||||
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
|
||||
| `proxy.host` | string | Bind host for local AnkiConnect proxy (default: `127.0.0.1`) |
|
||||
| `proxy.port` | number | Bind port for local AnkiConnect proxy (default: `8766`) |
|
||||
| `proxy.upstreamUrl` | string (URL) | Upstream AnkiConnect URL that proxy forwards to (default: `http://127.0.0.1:8765`) |
|
||||
| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). |
|
||||
| `ankiConnect.deck` | string | Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available. In Settings, this dropdown auto-fills and persists Yomitan's current mining deck when available. |
|
||||
| `fields.word` | string | Card field for mined word / expression text (default: `Expression`) |
|
||||
| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) |
|
||||
| `fields.image` | string | Card field for images (default: `Picture`) |
|
||||
| `fields.sentence` | string | Card field for sentences (default: `Sentence`) |
|
||||
| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) |
|
||||
| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) |
|
||||
| `ankiConnect.ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. |
|
||||
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
||||
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||
| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) |
|
||||
| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. |
|
||||
| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. |
|
||||
| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) |
|
||||
| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) |
|
||||
| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. |
|
||||
| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) |
|
||||
| `media.syncAnimatedImageToWordAudio` | `true`, `false` | Whether animated AVIF includes an opening frame synced to sentence word-audio timing (default: `true`). |
|
||||
| `media.audioPadding` | number (seconds) | Optional padding around generated sentence media timing (default: `0`). Animated AVIF clips include the same padded source range as sentence audio. |
|
||||
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
|
||||
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
|
||||
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended using the configured media insert mode; manual clipboard updates always replace generated sentence audio (default: `true`) |
|
||||
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended using the configured media insert mode (default: `true`) |
|
||||
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
|
||||
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
||||
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
|
||||
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
|
||||
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
||||
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
|
||||
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
|
||||
| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time |
|
||||
| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. |
|
||||
| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) |
|
||||
|
||||
`ankiConnect.ai` only controls feature-local enablement plus optional `model` / `systemPrompt` overrides.
|
||||
API key resolution, base URL, and timeout live under the shared top-level [`ai`](#shared-ai-provider) config.
|
||||
@@ -1122,6 +1122,8 @@ Sync the active subtitle track from the overlay picker using `alass` or `ffsubsy
|
||||
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
|
||||
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
|
||||
|
||||
Stats dashboard sentence mining also uses `alass_path` when available to align a local English sidecar against the local Japanese sidecar before filling the card translation field. This stats-only retime writes a temporary cached copy and never edits the original subtitle files.
|
||||
|
||||
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
||||
Customize it there, or set it to `null` to disable.
|
||||
|
||||
@@ -1380,9 +1382,9 @@ Enable or disable local immersion analytics stored in SQLite for mined subtitles
|
||||
| `retention.dailyRollupsDays` | integer (`0`-`36500`) | Daily rollup retention window. Default `0` (keep all). |
|
||||
| `retention.monthlyRollupsDays` | integer (`0`-`36500`) | Monthly rollup retention window. Default `0` (keep all). |
|
||||
| `retention.vacuumIntervalDays` | integer (`0`-`3650`) | Minimum spacing between `VACUUM` passes. `0` disables vacuum. Default `0` (disabled). |
|
||||
| `lifetimeSummaries.global` | `true`, `false` | Maintain global lifetime stats rows (default: `true`). |
|
||||
| `lifetimeSummaries.anime` | `true`, `false` | Maintain per-anime lifetime stats rows (default: `true`). |
|
||||
| `lifetimeSummaries.media` | `true`, `false` | Maintain per-media lifetime stats rows (default: `true`). |
|
||||
| `lifetimeSummaries.global` | `true`, `false` | Maintain global lifetime stats rows (default: `true`). |
|
||||
| `lifetimeSummaries.anime` | `true`, `false` | Maintain per-anime lifetime stats rows (default: `true`). |
|
||||
| `lifetimeSummaries.media` | `true`, `false` | Maintain per-media lifetime stats rows (default: `true`). |
|
||||
|
||||
You can also disable immersion tracking for a single session using:
|
||||
|
||||
@@ -1433,7 +1435,7 @@ Usage notes:
|
||||
- The browser UI is served at `http://127.0.0.1:<serverPort>`.
|
||||
- The overlay toggle is local to the focused visible overlay window; it is not registered as a global OS shortcut.
|
||||
- The dashboard reads from the same immersion-tracking database, so keep `immersionTracking.enabled` on if you want data to appear.
|
||||
- The UI includes Overview, Library, Trends, Vocabulary, and Sessions tabs.
|
||||
- The UI includes Overview, Library, Trends, Vocabulary, Search, and Sessions tabs.
|
||||
|
||||
### MPV Launcher
|
||||
|
||||
@@ -1456,18 +1458,18 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ----------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
||||
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
||||
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
||||
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (default: `\\\\.\\pipe\\subminer-socket`) |
|
||||
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
|
||||
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
|
||||
| `pauseUntilOverlayReady`| `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness (default: `true`) |
|
||||
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
|
||||
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection and skip markers in the bundled mpv plugin (default: `true`) |
|
||||
| `aniskipButtonKey` | string | mpv key used to trigger the AniSkip button while the skip marker is visible (default: `"TAB"`) |
|
||||
| Option | Values | Description |
|
||||
| ------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
||||
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
||||
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
||||
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (default: `\\\\.\\pipe\\subminer-socket`) |
|
||||
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
|
||||
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
|
||||
| `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness (default: `true`) |
|
||||
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
|
||||
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection, chapter markers, and the skip-intro key (default: `true`) |
|
||||
| `aniskipButtonKey` | string | mpv key used to skip the detected intro while the skip prompt is visible (default: `"TAB"`) |
|
||||
|
||||
If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list.
|
||||
|
||||
|
||||
@@ -11,15 +11,10 @@ For internal architecture/workflow guidance, use `docs/README.md` at the repo ro
|
||||
```bash
|
||||
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
|
||||
cd SubMiner
|
||||
# if you cloned without --recurse-submodules:
|
||||
git submodule update --init --recursive
|
||||
|
||||
bun install
|
||||
(cd stats && bun install --frozen-lockfile)
|
||||
(cd vendor/texthooker-ui && bun install --frozen-lockfile)
|
||||
make deps
|
||||
```
|
||||
|
||||
`make deps` is still available as a convenience wrapper around the same dependency install flow.
|
||||
`make deps` initializes submodules and installs root, `stats/`, and `vendor/texthooker-ui` dependencies. The Yomitan submodule installs its own dependencies on demand during `bun run build`.
|
||||
|
||||
## Building
|
||||
|
||||
@@ -216,7 +211,7 @@ Run `make help` for a full list of targets. Key ones:
|
||||
| `make build` | Build platform package for detected OS |
|
||||
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
|
||||
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) |
|
||||
| `make deps` | Install JS dependencies (root + stats + texthooker-ui) |
|
||||
| `make deps` | Init submodules and install root/stats/texthooker-ui deps |
|
||||
| `make pretty` | Run scoped Prettier formatting for maintained source/config files |
|
||||
| `make generate-config` | Generate default config from centralized registry |
|
||||
| `make build-linux` | Convenience wrapper for Linux packaging |
|
||||
|
||||
@@ -18,8 +18,8 @@ Episode completion for local `watched` state uses the shared `DEFAULT_MIN_WATCH_
|
||||
{
|
||||
"immersionTracking": {
|
||||
"enabled": true,
|
||||
"dbPath": ""
|
||||
}
|
||||
"dbPath": "",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -48,13 +48,19 @@ Recent sessions, streak calendar, watch-time history, and a tracking snapshot wi
|
||||
|
||||
Cover-art library with search and sorting, per-series progress, episode drill-down, and direct links into mined cards.
|
||||
|
||||
Local files and Jellyfin items with detected season numbers are split into season-specific library entries, so `Season 1` and `Season 2` folders do not merge into one show card.
|
||||
|
||||
When older stats already grouped multiple seasons under one series entry, SubMiner moves parsed episodes into the season-specific entries on startup and rebuilds the affected summaries.
|
||||
|
||||
Jellyfin stream URLs are normalized to stable item links before stats titles are shown, so playback query parameters are not displayed in the dashboard.
|
||||
|
||||
When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section.
|
||||
|
||||

|
||||
|
||||
#### Trends
|
||||
|
||||
Watch time, sessions, words seen, and per-anime progress/pattern charts with configurable date ranges and grouping.
|
||||
Grouped into Activity (per-day/month watch time, cards, words, sessions), Cumulative Totals (running totals incl. new words seen and episodes), Efficiency (words/min, cards/hour, lookups per 100 words), Patterns (watch time by day of week and hour), and per-anime Library charts — all with configurable date ranges and grouping.
|
||||
|
||||

|
||||
|
||||
@@ -66,10 +72,14 @@ Expandable session history with new-word activity, cumulative totals, and pause/
|
||||
|
||||
#### Vocabulary
|
||||
|
||||
Top repeated words (click a bar to open the word), new-word timeline, frequency rank table with full readings, kanji breakdown, word exclusion list, and click-through occurrence drilldown with Mine Word / Mine Sentence / Mine Audio buttons.
|
||||
Top repeated words (click a bar to open the word), new-word timeline, cross-title and frequency rank tables with Hide Known / Hide Kana filters, kanji breakdown, word exclusion list, and click-through occurrence drilldown with Mine Word / Mine Sentence / Mine Audio buttons.
|
||||
|
||||

|
||||
|
||||
#### Search
|
||||
|
||||
Realtime search across tracked primary subtitle lines and media titles. Results show the source media, session, line number, timing, and sentence text. Secondary subtitle text is not shown or searched here because separate subtitle tracks may not line up sentence-for-sentence. Sentence cards can be mined from any result with a valid local source and timing. Word and audio card buttons appear only when the searched word exactly appears in the primary sentence text; matching text is highlighted in the result.
|
||||
|
||||
Stats server config lives under `stats`:
|
||||
|
||||
```jsonc
|
||||
@@ -78,8 +88,8 @@ Stats server config lives under `stats`:
|
||||
"toggleKey": "Backquote",
|
||||
"serverPort": 6969,
|
||||
"autoStartServer": true,
|
||||
"autoOpenBrowser": false
|
||||
}
|
||||
"autoOpenBrowser": false,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -96,15 +106,15 @@ Stats server config lives under `stats`:
|
||||
|
||||
## Mining Cards from the Stats Page
|
||||
|
||||
The Vocabulary tab's word detail panel shows example lines from your viewing history. Each example line with a valid source file offers three mining buttons:
|
||||
The Search tab and the Vocabulary tab's word detail panel both mine from subtitle lines in your viewing history. Search matches sentence text and media titles, and **Search by headword** is enabled by default so dictionary-form searches such as `知らない` can find tracked subtitle lines with inflected variants. Turn that toggle off for exact text/title matching only. Each line with a valid source file offers sentence-card mining; word/audio mining is available when the selected word or searched word appears in the sentence:
|
||||
|
||||
- **Mine Word** - performs a full Yomitan dictionary lookup for the word (definition, reading, pitch accent, etc.) via a short-lived hidden helper, then enriches the card with sentence audio, a screenshot or animated AVIF clip, the highlighted sentence, and metadata extracted from the source video file. Requires Anki and Yomitan dictionaries to be loaded.
|
||||
- **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio, image, and translation from the secondary subtitle if available.
|
||||
- **Mine Sentence** - creates a sentence card directly with the `IsSentenceCard` flag set (for Lapis/Kiku workflows), along with audio and image from the source video.
|
||||
- **Mine Audio** - creates an audio-only card with the `IsAudioCard` flag, attaching only the sentence audio clip.
|
||||
|
||||
All three modes respect your `ankiConnect` config: deck, model, field mappings, media settings (static vs AVIF, quality, dimensions), audio padding, metadata pattern, and tags. Media generation runs in parallel for faster card creation.
|
||||
|
||||
Secondary subtitle text (typically English translations) is stored alongside primary subtitles during playback and used as the translation field when mining from the stats page.
|
||||
Secondary subtitle text (typically English translations) is stored alongside primary subtitles during playback and can be used as the translation field when mining sentence cards from Search or vocabulary occurrences. The Search tab does not use that text for display or matching.
|
||||
|
||||
### Word Exclusion List
|
||||
|
||||
@@ -114,12 +124,12 @@ The Vocabulary tab toolbar includes an **Exclusions** button for hiding words fr
|
||||
|
||||
By default, SubMiner keeps all retention tables and raw data (`0` means keep all) while continuing daily/monthly rollup maintenance:
|
||||
|
||||
| Data type | Retention |
|
||||
| -------------- | --------- |
|
||||
| Raw events | 0 (keep all) |
|
||||
| Telemetry | 0 (keep all) |
|
||||
| Sessions | 0 (keep all) |
|
||||
| Daily rollups | 0 (keep all) |
|
||||
| Data type | Retention |
|
||||
| --------------- | ------------ |
|
||||
| Raw events | 0 (keep all) |
|
||||
| Telemetry | 0 (keep all) |
|
||||
| Sessions | 0 (keep all) |
|
||||
| Daily rollups | 0 (keep all) |
|
||||
| Monthly rollups | 0 (keep all) |
|
||||
|
||||
Maintenance runs on startup and every 24 hours. Vacuum runs only when `retention.vacuumIntervalDays` is non-zero.
|
||||
@@ -146,24 +156,24 @@ The tracker is optimized for "keep everything" defaults:
|
||||
|
||||
All policy options live under `immersionTracking` in your config:
|
||||
|
||||
| Option | Description |
|
||||
| ------ | ----------- |
|
||||
| `batchSize` | Writes per flush batch |
|
||||
| `flushIntervalMs` | Max delay between flushes (default: 500ms) |
|
||||
| `queueCap` | Max queued writes before oldest are dropped |
|
||||
| `payloadCapBytes` | Max payload size per write |
|
||||
| `maintenanceIntervalMs` | How often maintenance runs |
|
||||
| `retention.eventsDays` | Raw event retention |
|
||||
| `retention.telemetryDays` | Telemetry retention |
|
||||
| `retention.sessionsDays` | Session retention |
|
||||
| `retention.dailyRollupsDays` | Daily rollup retention |
|
||||
| `retention.monthlyRollupsDays` | Monthly rollup retention |
|
||||
| `retention.vacuumIntervalDays` | Minimum spacing between vacuums |
|
||||
| `retentionMode` | `preset` or `advanced` |
|
||||
| `retentionPreset` | `minimal`, `balanced`, or `deep-history` (used by `retentionMode`) |
|
||||
| `lifetimeSummaries.global` | Maintain global lifetime totals |
|
||||
| `lifetimeSummaries.anime` | Maintain per-anime lifetime totals |
|
||||
| `lifetimeSummaries.media` | Maintain per-media lifetime totals |
|
||||
| Option | Description |
|
||||
| ------------------------------ | ------------------------------------------------------------------ |
|
||||
| `batchSize` | Writes per flush batch |
|
||||
| `flushIntervalMs` | Max delay between flushes (default: 500ms) |
|
||||
| `queueCap` | Max queued writes before oldest are dropped |
|
||||
| `payloadCapBytes` | Max payload size per write |
|
||||
| `maintenanceIntervalMs` | How often maintenance runs |
|
||||
| `retention.eventsDays` | Raw event retention |
|
||||
| `retention.telemetryDays` | Telemetry retention |
|
||||
| `retention.sessionsDays` | Session retention |
|
||||
| `retention.dailyRollupsDays` | Daily rollup retention |
|
||||
| `retention.monthlyRollupsDays` | Monthly rollup retention |
|
||||
| `retention.vacuumIntervalDays` | Minimum spacing between vacuums |
|
||||
| `retentionMode` | `preset` or `advanced` |
|
||||
| `retentionPreset` | `minimal`, `balanced`, or `deep-history` (used by `retentionMode`) |
|
||||
| `lifetimeSummaries.global` | Maintain global lifetime totals |
|
||||
| `lifetimeSummaries.anime` | Maintain per-anime lifetime totals |
|
||||
| `lifetimeSummaries.media` | Maintain per-media lifetime totals |
|
||||
|
||||
## Query Templates
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ Make sure `mpv.exe` is on your `PATH`, or set `mpv.executablePath` in the config
|
||||
```bash
|
||||
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
|
||||
cd SubMiner
|
||||
bun install
|
||||
make deps
|
||||
bun run build
|
||||
|
||||
# Optional: build AppImage
|
||||
@@ -202,7 +202,7 @@ Bundled Yomitan is built during `bun run build`.
|
||||
```bash
|
||||
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
|
||||
cd SubMiner
|
||||
git submodule update --init --recursive
|
||||
make deps
|
||||
make build-macos
|
||||
```
|
||||
|
||||
@@ -216,14 +216,14 @@ The built app will be in the `release` directory (`.dmg` and `.zip`). For unsign
|
||||
```powershell
|
||||
git clone https://github.com/ksyasuda/SubMiner.git
|
||||
cd SubMiner
|
||||
git submodule update --init --recursive
|
||||
bun install
|
||||
|
||||
# Windows requires building texthooker-ui manually before the main build
|
||||
Set-Location vendor/texthooker-ui
|
||||
Set-Location stats
|
||||
bun install --frozen-lockfile
|
||||
Set-Location ../vendor/texthooker-ui
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
Set-Location ../..
|
||||
|
||||
bun run build:win
|
||||
```
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ This guide walks through the sentence mining loop - from watching a video to cre
|
||||
|
||||
## Overview
|
||||
|
||||
*Sentence mining* means turning real sentences you encounter while watching native video into Anki flashcards, so you learn vocabulary in the context where you actually met it. SubMiner automates the tedious parts of that loop.
|
||||
_Sentence mining_ means turning real sentences you encounter while watching native video into Anki flashcards, so you learn vocabulary in the context where you actually met it. SubMiner automates the tedious parts of that loop.
|
||||
|
||||
SubMiner runs as a transparent overlay on top of mpv (the video player). As subtitles play, the overlay displays them as interactive text. You hover a word, trigger a Yomitan dictionary lookup with your configured lookup key/modifier, then create an Anki card with a single action. SubMiner automatically attaches the sentence, an audio clip, and a screenshot to that card - no manual copy-pasting or screen capturing.
|
||||
|
||||
@@ -122,10 +122,10 @@ By default the **primary** bar is `visible` (`subtitleStyle.primaryDefaultMode`)
|
||||
|
||||
Cycle each bar's mode at runtime with its own shortcut:
|
||||
|
||||
| Shortcut | Action | Config key |
|
||||
| -------------------- | -------------------------------------------------------- | ------------------------------ |
|
||||
| `V` | Cycle primary subtitle mode (hidden → visible → hover) | overlay-local |
|
||||
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
||||
| Shortcut | Action | Config key |
|
||||
| ------------------ | -------------------------------------------------------- | ------------------------------ |
|
||||
| `V` | Cycle primary subtitle mode (hidden → visible → hover) | overlay-local |
|
||||
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
||||
|
||||
### Modal Surfaces
|
||||
|
||||
@@ -166,6 +166,8 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize
|
||||
|
||||
For remote streams, including Jellyfin playback, the modal only offers alass. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync needs direct access to the local media file and is unavailable for stream URLs.
|
||||
|
||||
When you mine a sentence card from the stats dashboard, SubMiner can also use `alass` automatically to align a local English sidecar against the matching local Japanese sidecar before filling the card translation field. The source subtitle files are not modified; SubMiner writes a temporary retimed copy and reuses it while the stats server is running.
|
||||
|
||||
Install the sync tools separately - see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found.
|
||||
|
||||
## Texthooker
|
||||
|
||||
+4
-19
@@ -42,7 +42,7 @@ Most plugin actions use a `y` chord prefix - press `y`, then the second key (a "
|
||||
| `v` | Toggle primary subtitle bar visibility |
|
||||
| `TAB` (default) | Skip intro (AniSkip) |
|
||||
|
||||
The AniSkip key is **not** a `y` chord. It defaults to `TAB` and is configurable via `mpv.aniskipButtonKey`. The legacy `y-k` chord still works as a fallback unless you remap the AniSkip key onto it.
|
||||
The AniSkip key is **not** a `y` chord and is not bound by the plugin: the SubMiner app binds it over the mpv IPC socket while it is connected. It defaults to `TAB` and is configurable via `mpv.aniskipButtonKey`. The legacy `y-k` chord still works as a fallback unless you remap the AniSkip key onto it. See [AniSkip Integration](/aniskip-integration) for setup and details.
|
||||
|
||||
The bare `v` binding is a forced mpv binding. It overrides mpv's default primary subtitle visibility toggle and routes the action to SubMiner's primary subtitle bar instead.
|
||||
|
||||
@@ -133,10 +133,10 @@ script-message subminer-options
|
||||
script-message subminer-restart
|
||||
script-message subminer-status
|
||||
script-message subminer-autoplay-ready
|
||||
script-message subminer-aniskip-refresh
|
||||
script-message subminer-skip-intro
|
||||
```
|
||||
|
||||
The AniSkip messages (`subminer-skip-intro`, `subminer-aniskip-refresh`) still exist, but they are handled by the SubMiner app over the IPC socket rather than by the plugin - see [AniSkip Integration](/aniskip-integration#triggering-from-mpv).
|
||||
|
||||
The `subminer-start` message accepts overrides:
|
||||
|
||||
```
|
||||
@@ -146,26 +146,11 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
|
||||
`log-level` here controls only logging verbosity passed to SubMiner.
|
||||
`--debug` is a separate app/dev-mode flag in the main CLI and should not be used here for logging.
|
||||
|
||||
## AniSkip Intro Skip
|
||||
|
||||
- AniSkip lookups are gated. The plugin only runs lookup when:
|
||||
- SubMiner launcher metadata is present, or
|
||||
- SubMiner app process is already running, or
|
||||
- You explicitly call `script-message subminer-aniskip-refresh`.
|
||||
- Lookups are asynchronous (no blocking `ps`/`curl` on `file-loaded`).
|
||||
- MAL/title resolution is cached for the current mpv session.
|
||||
- When launched via `subminer`, launcher can pass `aniskip_payload` (pre-fetched AniSkip `skip-times` payload) and the plugin applies it directly without making API calls.
|
||||
- If the payload is absent or invalid, lookup falls back to title/MAL-based async fetch.
|
||||
- Install `guessit` for best detection quality (`python3 -m pip install --user guessit`).
|
||||
- If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters.
|
||||
- At intro start, plugin shows an OSD hint for the first 3 seconds (`You can skip by pressing TAB` by default; the key reflects `mpv.aniskipButtonKey`).
|
||||
- Use `script-message subminer-aniskip-refresh` after changing media metadata/options to retry lookup.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
For how the plugin's auto-start fits into the full launch sequence - including when the launcher starts the overlay instead of the plugin - see [Playback Startup Flow](./architecture#playback-startup-flow).
|
||||
|
||||
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay, then defers AniSkip lookup until after startup delay.
|
||||
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay.
|
||||
- **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused and the plugin resumes playback after SubMiner reports tokenization-ready (with timeout fallback).
|
||||
- **Duplicate auto-start events**: Repeated `file-loaded` hooks while overlay is already running are ignored for auto-start triggers (prevents duplicate start attempts).
|
||||
- **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server.
|
||||
|
||||
@@ -496,7 +496,7 @@
|
||||
"tags": [
|
||||
"SubMiner"
|
||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.
|
||||
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.
|
||||
"fields": {
|
||||
"word": "Expression", // Card field for the mined word or expression text.
|
||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||
@@ -634,8 +634,8 @@
|
||||
"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
|
||||
"subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.
|
||||
"aniskipEnabled": true, // Enable AniSkip intro detection and skip markers in the bundled mpv plugin. Values: true | false
|
||||
"aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible.
|
||||
"aniskipEnabled": true, // Enable AniSkip intro detection, chapter markers, and the skip-intro key. Values: true | false
|
||||
"aniskipButtonKey": "TAB" // mpv key used to skip the detected intro while the skip prompt is visible.
|
||||
}, // SubMiner-managed mpv launch and bundled plugin options.
|
||||
|
||||
// ==========================================
|
||||
|
||||
+1
-1
@@ -287,7 +287,7 @@ Notes:
|
||||
- For YouTube URLs, `subminer` probes available YouTube subtitle tracks, reuses existing authoritative tracks when available, and downloads only missing sides.
|
||||
- Native mpv secondary subtitle rendering stays hidden so the overlay remains the visible secondary subtitle surface.
|
||||
- Primary subtitle target languages come from `youtube.primarySubLanguages` (defaults to `["ja","jpn"]`).
|
||||
- Secondary target languages come from `secondarySub.secondarySubLanguages` (empty by default; when empty, no language-based secondary track is auto-selected, though mpv's `--slang` list above still prefers English variants).
|
||||
- Secondary target languages come from `secondarySub.secondarySubLanguages` (empty by default; when empty, no language-based secondary track is auto-selected, though mpv's `--slang` list above still prefers English variants). When multiple matching secondary tracks exist, SubMiner prefers a non-Signs/Songs track.
|
||||
- Configure defaults in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`) under `youtube` and `secondarySub`.
|
||||
|
||||
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.
|
||||
|
||||
@@ -265,10 +265,10 @@ script-message subminer-options
|
||||
script-message subminer-restart
|
||||
script-message subminer-status
|
||||
script-message subminer-autoplay-ready
|
||||
script-message subminer-aniskip-refresh
|
||||
script-message subminer-skip-intro
|
||||
```
|
||||
|
||||
The AniSkip messages (`subminer-skip-intro`, `subminer-aniskip-refresh`) are handled by the SubMiner app over the mpv IPC socket while it is connected.
|
||||
|
||||
The start command also accepts inline overrides:
|
||||
|
||||
```text
|
||||
@@ -283,7 +283,7 @@ Examples:
|
||||
|
||||
- send `subminer-start` after your own media-selection script chooses a file
|
||||
- send `subminer-status` before running follow-up automation
|
||||
- send `subminer-aniskip-refresh` after you update title/episode metadata
|
||||
- send `subminer-aniskip-refresh` after you update title/episode metadata (handled by the SubMiner app)
|
||||
|
||||
#### Build a launcher wrapper
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ Notes:
|
||||
- `changelog:check` now rejects tag/package version mismatches.
|
||||
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
|
||||
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
|
||||
- `release/release-notes.md` (and `release/prerelease-notes.md`) end with GitHub-style attribution: a `## What’s Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
|
||||
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
|
||||
- Do not tag while `changes/*.md` fragments still exist.
|
||||
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
|
||||
|
||||
@@ -46,8 +46,6 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
},
|
||||
appPath: '/tmp/subminer.app',
|
||||
launcherJellyfinConfig: {},
|
||||
|
||||
@@ -83,8 +83,6 @@ function createContext(): LauncherCommandContext {
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
},
|
||||
appPath: '/tmp/SubMiner.AppImage',
|
||||
launcherJellyfinConfig: {},
|
||||
@@ -210,8 +208,6 @@ test('plugin auto-start playback leaves app lifetime to managed-playback owner',
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
};
|
||||
const appPath = context.appPath ?? '';
|
||||
state.appPath = appPath;
|
||||
@@ -273,8 +269,6 @@ test('plugin auto-start playback attaches a warm background app through the laun
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: true,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
};
|
||||
const calls: string[] = [];
|
||||
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
||||
@@ -342,8 +336,6 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: true,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
};
|
||||
let availabilityConfigDir: string | undefined;
|
||||
let overlayConfigDir: string | undefined;
|
||||
@@ -404,8 +396,6 @@ test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: true,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
};
|
||||
const calls: string[] = [];
|
||||
|
||||
|
||||
@@ -91,8 +91,6 @@ test('parseLauncherMpvConfig reads launch mode preference', () => {
|
||||
autoStartSubMiner: false,
|
||||
pauseUntilOverlayReady: false,
|
||||
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -102,8 +100,6 @@ test('parseLauncherMpvConfig reads launch mode preference', () => {
|
||||
assert.equal(parsed.autoStartSubMiner, false);
|
||||
assert.equal(parsed.pauseUntilOverlayReady, false);
|
||||
assert.equal(parsed.subminerBinaryPath, '/opt/SubMiner/SubMiner.AppImage');
|
||||
assert.equal(parsed.aniskipEnabled, false);
|
||||
assert.equal(parsed.aniskipButtonKey, 'F8');
|
||||
});
|
||||
|
||||
test('parseLauncherMpvConfig ignores blank subminer binary paths', () => {
|
||||
@@ -138,8 +134,6 @@ test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugi
|
||||
autoStartSubMiner: true,
|
||||
pauseUntilOverlayReady: true,
|
||||
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -150,8 +144,6 @@ test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugi
|
||||
assert.equal(parsed.autoStartPauseUntilReady, true);
|
||||
assert.equal(parsed.binaryPath, '/opt/SubMiner/SubMiner.AppImage');
|
||||
assert.equal(parsed.texthookerEnabled, false);
|
||||
assert.equal(parsed.aniskipEnabled, false);
|
||||
assert.equal(parsed.aniskipButtonKey, 'F8');
|
||||
});
|
||||
|
||||
test('parsePluginRuntimeConfigFromMainConfig defaults to background-only managed startup', () => {
|
||||
@@ -161,8 +153,6 @@ test('parsePluginRuntimeConfigFromMainConfig defaults to background-only managed
|
||||
assert.equal(parsed.autoStartVisibleOverlay, false);
|
||||
assert.equal(parsed.autoStartPauseUntilReady, true);
|
||||
assert.equal(parsed.texthookerEnabled, false);
|
||||
assert.equal(parsed.aniskipEnabled, true);
|
||||
assert.equal(parsed.aniskipButtonKey, 'TAB');
|
||||
});
|
||||
|
||||
test('buildPluginRuntimeScriptOptParts emits config values that override plugin defaults', () => {
|
||||
@@ -176,8 +166,6 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8',
|
||||
},
|
||||
'/fallback/SubMiner.AppImage',
|
||||
),
|
||||
@@ -189,8 +177,6 @@ test('buildPluginRuntimeScriptOptParts emits config values that override plugin
|
||||
'subminer-auto_start_visible_overlay=no',
|
||||
'subminer-auto_start_pause_until_ready=yes',
|
||||
'subminer-texthooker_enabled=no',
|
||||
'subminer-aniskip_enabled=no',
|
||||
'subminer-aniskip_button_key=F8',
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -206,8 +192,6 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8,\nF9',
|
||||
},
|
||||
'/fallback/SubMiner.AppImage',
|
||||
),
|
||||
@@ -219,8 +203,6 @@ test('buildPluginRuntimeScriptOptParts strips script-option delimiters from stri
|
||||
'subminer-auto_start_visible_overlay=no',
|
||||
'subminer-auto_start_pause_until_ready=yes',
|
||||
'subminer-texthooker_enabled=no',
|
||||
'subminer-aniskip_enabled=no',
|
||||
'subminer-aniskip_button_key=F8 F9',
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -244,8 +226,6 @@ test('parseLauncherMpvConfig reads configured mpv profile', () => {
|
||||
pauseUntilOverlayReady: undefined,
|
||||
subminerBinaryPath: undefined,
|
||||
profile: 'anime',
|
||||
aniskipEnabled: undefined,
|
||||
aniskipButtonKey: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -39,7 +39,5 @@ export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherM
|
||||
pauseUntilOverlayReady:
|
||||
typeof mpv.pauseUntilOverlayReady === 'boolean' ? mpv.pauseUntilOverlayReady : undefined,
|
||||
subminerBinaryPath: parseNonEmptyString(mpv.subminerBinaryPath),
|
||||
aniskipEnabled: typeof mpv.aniskipEnabled === 'boolean' ? mpv.aniskipEnabled : undefined,
|
||||
aniskipButtonKey: parseNonEmptyString(mpv.aniskipButtonKey),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,8 +54,6 @@ export function parsePluginRuntimeConfigFromMainConfig(
|
||||
autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false),
|
||||
autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true),
|
||||
texthookerEnabled: booleanOrDefault(texthooker.launchAtStartup, false),
|
||||
aniskipEnabled: booleanOrDefault(mpvConfig.aniskipEnabled, true),
|
||||
aniskipButtonKey: nonEmptyStringOrDefault(mpvConfig.aniskipButtonKey, 'TAB'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,7 +70,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
|
||||
log(
|
||||
'debug',
|
||||
logLevel,
|
||||
`Using mpv plugin settings from SubMiner config: socket_path=${parsed.socketPath}, backend=${parsed.backend}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}, texthooker_enabled=${parsed.texthookerEnabled}, aniskip_enabled=${parsed.aniskipEnabled}, aniskip_button_key=${parsed.aniskipButtonKey}`,
|
||||
`Using mpv plugin settings from SubMiner config: socket_path=${parsed.socketPath}, backend=${parsed.backend}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}, texthooker_enabled=${parsed.texthookerEnabled}`,
|
||||
);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -23,8 +23,6 @@ import {
|
||||
runAppCommandCaptureOutput,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
resolveLauncherRuntimePluginPlan,
|
||||
shouldResolveAniSkipMetadataForLaunch,
|
||||
shouldResolveAniSkipMetadata,
|
||||
stopOverlay,
|
||||
startOverlay,
|
||||
state,
|
||||
@@ -388,31 +386,12 @@ test('buildRuntimeExtraScriptOptParts marks launcher-owned startup pause gate',
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
},
|
||||
}),
|
||||
['subminer-auto_start_pause_until_ready_owns_initial_pause=yes'],
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldResolveAniSkipMetadataForLaunch respects disabled runtime plugin AniSkip', () => {
|
||||
assert.equal(
|
||||
shouldResolveAniSkipMetadataForLaunch('/tmp/video.mkv', 'file', undefined, {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'TAB',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
|
||||
const error = withProcessExitIntercept(() => {
|
||||
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
|
||||
@@ -565,20 +544,6 @@ test('waitForUnixSocketReady returns true when socket becomes connectable before
|
||||
}
|
||||
});
|
||||
|
||||
test('shouldResolveAniSkipMetadata skips URL and YouTube-preloaded playback', () => {
|
||||
assert.equal(shouldResolveAniSkipMetadata('/media/show.mkv', 'file'), true);
|
||||
assert.equal(
|
||||
shouldResolveAniSkipMetadata('https://www.youtube.com/watch?v=test123', 'url'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldResolveAniSkipMetadata('/tmp/video123.webm', 'file', {
|
||||
primaryPath: '/tmp/video123.ja.srt',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
return {
|
||||
backend: 'x11',
|
||||
|
||||
+3
-50
@@ -27,7 +27,7 @@ import {
|
||||
shouldForwardLogLevel,
|
||||
} from './types.js';
|
||||
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
|
||||
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
|
||||
import { buildSubminerScriptOpts } from './script-opts.js';
|
||||
import { buildPluginRuntimeScriptOptParts } from './config/plugin-runtime-config.js';
|
||||
import { nowMs } from './time.js';
|
||||
import {
|
||||
@@ -823,20 +823,6 @@ export async function loadSubtitleIntoMpv(
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldResolveAniSkipMetadata(
|
||||
target: string,
|
||||
targetKind: 'file' | 'url',
|
||||
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
|
||||
): boolean {
|
||||
if (targetKind !== 'file') {
|
||||
return false;
|
||||
}
|
||||
if (preloadedSubtitles?.primaryPath || preloadedSubtitles?.secondaryPath) {
|
||||
return false;
|
||||
}
|
||||
return !isYoutubeTarget(target);
|
||||
}
|
||||
|
||||
type StartMpvOptions = {
|
||||
startPaused?: boolean;
|
||||
disableYoutubeSubtitleAutoLoad?: boolean;
|
||||
@@ -844,18 +830,6 @@ type StartMpvOptions = {
|
||||
runtimePluginConfig?: PluginRuntimeConfig;
|
||||
};
|
||||
|
||||
export function shouldResolveAniSkipMetadataForLaunch(
|
||||
target: string,
|
||||
targetKind: 'file' | 'url',
|
||||
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
|
||||
runtimePluginConfig?: PluginRuntimeConfig,
|
||||
): boolean {
|
||||
if (runtimePluginConfig?.aniskipEnabled === false) {
|
||||
return false;
|
||||
}
|
||||
return shouldResolveAniSkipMetadata(target, targetKind, preloadedSubtitles);
|
||||
}
|
||||
|
||||
export function buildRuntimeExtraScriptOptParts(
|
||||
target: string,
|
||||
targetKind: 'file' | 'url',
|
||||
@@ -946,29 +920,14 @@ export async function startMpv(
|
||||
if (options?.startPaused) {
|
||||
mpvArgs.push('--pause=yes');
|
||||
}
|
||||
const aniSkipMetadata = shouldResolveAniSkipMetadataForLaunch(
|
||||
target,
|
||||
targetKind,
|
||||
preloadedSubtitles,
|
||||
options?.runtimePluginConfig,
|
||||
)
|
||||
? await resolveAniSkipMetadataForFile(target)
|
||||
: null;
|
||||
const extraScriptOpts = buildRuntimeExtraScriptOptParts(target, targetKind, options);
|
||||
const runtimeScriptOpts = options?.runtimePluginConfig
|
||||
? buildPluginRuntimeScriptOptParts(options.runtimePluginConfig, appPath)
|
||||
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
|
||||
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata, args.logLevel, [
|
||||
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, [
|
||||
...runtimeScriptOpts,
|
||||
...extraScriptOpts,
|
||||
]);
|
||||
if (aniSkipMetadata) {
|
||||
log(
|
||||
'debug',
|
||||
args.logLevel,
|
||||
`AniSkip metadata (${aniSkipMetadata.source}): title="${aniSkipMetadata.title}" season=${aniSkipMetadata.season ?? '-'} episode=${aniSkipMetadata.episode ?? '-'}`,
|
||||
);
|
||||
}
|
||||
mpvArgs.push(`--script-opts=${scriptOpts}`);
|
||||
mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
|
||||
|
||||
@@ -1701,13 +1660,7 @@ export function launchMpvIdleDetached(
|
||||
? buildPluginRuntimeScriptOptParts(runtimePluginConfig, appPath)
|
||||
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
|
||||
mpvArgs.push(
|
||||
`--script-opts=${buildSubminerScriptOpts(
|
||||
appPath,
|
||||
socketPath,
|
||||
null,
|
||||
args.logLevel,
|
||||
runtimeScriptOpts,
|
||||
)}`,
|
||||
`--script-opts=${buildSubminerScriptOpts(appPath, socketPath, runtimeScriptOpts)}`,
|
||||
);
|
||||
mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
|
||||
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildSubminerScriptOpts } from './script-opts';
|
||||
|
||||
test('buildSubminerScriptOpts preserves app and socket paths verbatim', () => {
|
||||
const scriptOpts = buildSubminerScriptOpts(
|
||||
'/Applications/SubMiner Beta.app/Contents/MacOS/SubMiner',
|
||||
'/tmp/subminer socket.sock',
|
||||
['subminer-backend=x11'],
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
scriptOpts,
|
||||
'subminer-binary_path=/Applications/SubMiner Beta.app/Contents/MacOS/SubMiner,subminer-socket_path=/tmp/subminer socket.sock,subminer-backend=x11',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildSubminerScriptOpts rejects delimiter-bearing default paths', () => {
|
||||
assert.throws(
|
||||
() => buildSubminerScriptOpts('/tmp/SubMiner,canary', '/tmp/subminer.sock'),
|
||||
/subminer-binary_path contains unsupported script option delimiter/,
|
||||
);
|
||||
assert.throws(
|
||||
() => buildSubminerScriptOpts('/tmp/SubMiner', '/tmp/subminer\nsocket.sock'),
|
||||
/subminer-socket_path contains unsupported script option delimiter/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
function sanitizeScriptOptValue(value: string): string {
|
||||
return value
|
||||
.replace(/,/g, ' ')
|
||||
.replace(/[\r\n]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function assertScriptOptPathValue(name: string, value: string): void {
|
||||
if (/[,\r\n]/.test(value)) {
|
||||
throw new Error(`${name} contains unsupported script option delimiter`);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSubminerScriptOpts(
|
||||
appPath: string,
|
||||
socketPath: string,
|
||||
extraParts: string[] = [],
|
||||
): string {
|
||||
const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path='));
|
||||
const hasSocketPath = extraParts.some((part) => part.startsWith('subminer-socket_path='));
|
||||
if (!hasBinaryPath) {
|
||||
assertScriptOptPathValue('subminer-binary_path', appPath);
|
||||
}
|
||||
if (!hasSocketPath) {
|
||||
assertScriptOptPathValue('subminer-socket_path', socketPath);
|
||||
}
|
||||
const parts = [
|
||||
...(hasBinaryPath ? [] : [`subminer-binary_path=${appPath}`]),
|
||||
...(hasSocketPath ? [] : [`subminer-socket_path=${socketPath}`]),
|
||||
...extraParts.map(sanitizeScriptOptValue),
|
||||
];
|
||||
return parts.join(',');
|
||||
}
|
||||
@@ -559,7 +559,6 @@ test(
|
||||
socketPath: smokeCase.socketPath,
|
||||
autoStartSubMiner: true,
|
||||
pauseUntilOverlayReady: true,
|
||||
aniskipEnabled: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -191,8 +191,6 @@ export interface LauncherMpvConfig {
|
||||
autoStartSubMiner?: boolean;
|
||||
pauseUntilOverlayReady?: boolean;
|
||||
subminerBinaryPath?: string;
|
||||
aniskipEnabled?: boolean;
|
||||
aniskipButtonKey?: string;
|
||||
}
|
||||
|
||||
export interface LauncherLoggingConfig {
|
||||
@@ -210,8 +208,6 @@ export interface PluginRuntimeConfig {
|
||||
autoStartVisibleOverlay: boolean;
|
||||
autoStartPauseUntilReady: boolean;
|
||||
texthookerEnabled: boolean;
|
||||
aniskipEnabled: boolean;
|
||||
aniskipButtonKey: string;
|
||||
}
|
||||
|
||||
export interface CommandExecOptions {
|
||||
|
||||
@@ -1,758 +0,0 @@
|
||||
local M = {}
|
||||
local matcher = require("aniskip_match")
|
||||
local DEFAULT_ANISKIP_BUTTON_KEY = "TAB"
|
||||
|
||||
function M.create(ctx)
|
||||
local mp = ctx.mp
|
||||
local utils = ctx.utils
|
||||
local opts = ctx.opts
|
||||
local state = ctx.state
|
||||
local environment = ctx.environment
|
||||
local subminer_log = ctx.log.subminer_log
|
||||
local show_osd = ctx.log.show_osd
|
||||
local request_generation = 0
|
||||
local mal_lookup_cache = {}
|
||||
local payload_cache = {}
|
||||
local title_context_cache = {}
|
||||
local base64_reverse = {}
|
||||
local base64_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
|
||||
for i = 1, #base64_chars do
|
||||
base64_reverse[base64_chars:sub(i, i)] = i - 1
|
||||
end
|
||||
|
||||
local function url_encode(text)
|
||||
if type(text) ~= "string" then
|
||||
return ""
|
||||
end
|
||||
local encoded = text:gsub("\n", " ")
|
||||
encoded = encoded:gsub("([^%w%-_%.~ ])", function(char)
|
||||
return string.format("%%%02X", string.byte(char))
|
||||
end)
|
||||
return encoded:gsub(" ", "%%20")
|
||||
end
|
||||
|
||||
local function is_remote_media_path()
|
||||
local media_path = mp.get_property("path")
|
||||
if type(media_path) ~= "string" then
|
||||
return false
|
||||
end
|
||||
local trimmed = media_path:match("^%s*(.-)%s*$") or ""
|
||||
if trimmed == "" then
|
||||
return false
|
||||
end
|
||||
return trimmed:match("^%a[%w+.-]*://") ~= nil
|
||||
end
|
||||
|
||||
local function parse_json_payload(text)
|
||||
if type(text) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local parsed, parse_error = utils.parse_json(text)
|
||||
if type(parsed) == "table" then
|
||||
return parsed
|
||||
end
|
||||
return nil, parse_error
|
||||
end
|
||||
|
||||
local function decode_base64(input)
|
||||
if type(input) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local cleaned = input:gsub("%s", ""):gsub("-", "+"):gsub("_", "/")
|
||||
cleaned = cleaned:match("^%s*(.-)%s*$") or ""
|
||||
if cleaned == "" then
|
||||
return nil
|
||||
end
|
||||
if #cleaned % 4 == 1 then
|
||||
return nil
|
||||
end
|
||||
if #cleaned % 4 ~= 0 then
|
||||
cleaned = cleaned .. string.rep("=", 4 - (#cleaned % 4))
|
||||
end
|
||||
if not cleaned:match("^[A-Za-z0-9+/%=]+$") then
|
||||
return nil
|
||||
end
|
||||
local out = {}
|
||||
local out_len = 0
|
||||
for index = 1, #cleaned, 4 do
|
||||
local c1 = cleaned:sub(index, index)
|
||||
local c2 = cleaned:sub(index + 1, index + 1)
|
||||
local c3 = cleaned:sub(index + 2, index + 2)
|
||||
local c4 = cleaned:sub(index + 3, index + 3)
|
||||
local v1 = base64_reverse[c1]
|
||||
local v2 = base64_reverse[c2]
|
||||
if not v1 or not v2 then
|
||||
return nil
|
||||
end
|
||||
local v3 = c3 == "=" and 0 or base64_reverse[c3]
|
||||
local v4 = c4 == "=" and 0 or base64_reverse[c4]
|
||||
if (c3 ~= "=" and not v3) or (c4 ~= "=" and not v4) then
|
||||
return nil
|
||||
end
|
||||
local n = (((v1 * 64 + v2) * 64 + v3) * 64 + v4)
|
||||
local b1 = math.floor(n / 65536)
|
||||
local remaining = n % 65536
|
||||
local b2 = math.floor(remaining / 256)
|
||||
local b3 = remaining % 256
|
||||
out_len = out_len + 1
|
||||
out[out_len] = string.char(b1)
|
||||
if c3 ~= "=" then
|
||||
out_len = out_len + 1
|
||||
out[out_len] = string.char(b2)
|
||||
end
|
||||
if c4 ~= "=" then
|
||||
out_len = out_len + 1
|
||||
out[out_len] = string.char(b3)
|
||||
end
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
local function resolve_launcher_payload()
|
||||
local raw_payload = type(opts.aniskip_payload) == "string" and opts.aniskip_payload or ""
|
||||
local trimmed = raw_payload:match("^%s*(.-)%s*$") or ""
|
||||
if trimmed == "" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local parsed, parse_error = parse_json_payload(trimmed)
|
||||
if type(parsed) == "table" then
|
||||
return parsed
|
||||
end
|
||||
|
||||
local url_decoded = trimmed:gsub("%%(%x%x)", function(hex)
|
||||
local value = tonumber(hex, 16)
|
||||
if value then
|
||||
return string.char(value)
|
||||
end
|
||||
return "%"
|
||||
end)
|
||||
if url_decoded ~= trimmed then
|
||||
parsed, parse_error = parse_json_payload(url_decoded)
|
||||
if type(parsed) == "table" then
|
||||
return parsed
|
||||
end
|
||||
end
|
||||
|
||||
local b64_decoded = decode_base64(trimmed)
|
||||
if type(b64_decoded) == "string" and b64_decoded ~= "" then
|
||||
parsed, parse_error = parse_json_payload(b64_decoded)
|
||||
if type(parsed) == "table" then
|
||||
return parsed
|
||||
end
|
||||
end
|
||||
|
||||
subminer_log("warn", "aniskip", "Invalid launcher AniSkip payload: " .. tostring(parse_error or "unparseable"))
|
||||
return nil
|
||||
end
|
||||
|
||||
local function run_json_curl_async(url, callback)
|
||||
mp.command_native_async({
|
||||
name = "subprocess",
|
||||
args = { "curl", "-sL", "--connect-timeout", "5", "-A", "SubMiner-mpv/ani-skip", url },
|
||||
playback_only = false,
|
||||
capture_stdout = true,
|
||||
capture_stderr = true,
|
||||
}, function(success, result, error)
|
||||
if not success or not result or result.status ~= 0 or type(result.stdout) ~= "string" or result.stdout == "" then
|
||||
local detail = error or (result and result.stderr) or "curl failed"
|
||||
callback(nil, detail)
|
||||
return
|
||||
end
|
||||
local parsed, parse_error = utils.parse_json(result.stdout)
|
||||
if type(parsed) ~= "table" then
|
||||
callback(nil, parse_error or "invalid json")
|
||||
return
|
||||
end
|
||||
callback(parsed, nil)
|
||||
end)
|
||||
end
|
||||
|
||||
local function parse_episode_hint(text)
|
||||
if type(text) ~= "string" or text == "" then
|
||||
return nil
|
||||
end
|
||||
local patterns = {
|
||||
"[Ss]%d+[Ee](%d+)",
|
||||
"[Ee][Pp]?[%s%._%-]*(%d+)",
|
||||
"[%s%._%-]+(%d+)[%s%._%-]+",
|
||||
}
|
||||
for _, pattern in ipairs(patterns) do
|
||||
local token = text:match(pattern)
|
||||
if token then
|
||||
local episode = tonumber(token)
|
||||
if episode and episode > 0 and episode < 10000 then
|
||||
return episode
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function cleanup_title(raw)
|
||||
if type(raw) ~= "string" then
|
||||
return nil
|
||||
end
|
||||
local cleaned = raw
|
||||
cleaned = cleaned:gsub("%b[]", " ")
|
||||
cleaned = cleaned:gsub("%b()", " ")
|
||||
cleaned = cleaned:gsub("[Ss]%d+[Ee]%d+", " ")
|
||||
cleaned = cleaned:gsub("[Ee][Pp]?[%s%._%-]*%d+", " ")
|
||||
cleaned = cleaned:gsub("[%._%-]+", " ")
|
||||
cleaned = cleaned:gsub("%s+", " ")
|
||||
cleaned = cleaned:match("^%s*(.-)%s*$") or ""
|
||||
if cleaned == "" then
|
||||
return nil
|
||||
end
|
||||
return cleaned
|
||||
end
|
||||
|
||||
local function extract_show_title_from_path(media_path)
|
||||
if type(media_path) ~= "string" or media_path == "" then
|
||||
return nil
|
||||
end
|
||||
local normalized = media_path:gsub("\\", "/")
|
||||
local segments = {}
|
||||
for segment in normalized:gmatch("[^/]+") do
|
||||
segments[#segments + 1] = segment
|
||||
end
|
||||
for index = 1, #segments do
|
||||
local segment = segments[index] or ""
|
||||
if segment:match("^[Ss]eason[%s%._%-]*%d+$") or segment:match("^[Ss][%s%._%-]*%d+$") then
|
||||
local prior = segments[index - 1]
|
||||
local cleaned = cleanup_title(prior or "")
|
||||
if cleaned and cleaned ~= "" then
|
||||
return cleaned
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function resolve_title_and_episode()
|
||||
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
|
||||
local forced_season = tonumber(opts.aniskip_season)
|
||||
local forced_episode = tonumber(opts.aniskip_episode)
|
||||
local media_title = mp.get_property("media-title")
|
||||
local filename = mp.get_property("filename/no-ext") or mp.get_property("filename") or ""
|
||||
local path = mp.get_property("path") or ""
|
||||
local cache_key = table.concat({
|
||||
tostring(forced_title or ""),
|
||||
tostring(forced_season or ""),
|
||||
tostring(forced_episode or ""),
|
||||
tostring(media_title or ""),
|
||||
tostring(filename or ""),
|
||||
tostring(path or ""),
|
||||
}, "\31")
|
||||
local cached = title_context_cache[cache_key]
|
||||
if type(cached) == "table" then
|
||||
return cached.title, cached.episode, cached.season
|
||||
end
|
||||
local path_show_title = extract_show_title_from_path(path)
|
||||
local candidate_title = nil
|
||||
if path_show_title and path_show_title ~= "" then
|
||||
candidate_title = path_show_title
|
||||
elseif forced_title ~= "" then
|
||||
candidate_title = forced_title
|
||||
else
|
||||
candidate_title = cleanup_title(media_title) or cleanup_title(filename) or cleanup_title(path)
|
||||
end
|
||||
local episode = forced_episode or parse_episode_hint(media_title) or parse_episode_hint(filename) or parse_episode_hint(path) or 1
|
||||
title_context_cache[cache_key] = {
|
||||
title = candidate_title,
|
||||
episode = episode,
|
||||
season = forced_season,
|
||||
}
|
||||
return candidate_title, episode, forced_season
|
||||
end
|
||||
|
||||
local function select_best_mal_item(items, title, season)
|
||||
if type(items) ~= "table" then
|
||||
return nil
|
||||
end
|
||||
local best_item = nil
|
||||
local best_score = -math.huge
|
||||
for _, item in ipairs(items) do
|
||||
if type(item) == "table" and tonumber(item.id) then
|
||||
local candidate_name = tostring(item.name or "")
|
||||
local score = matcher.title_overlap_score(title, candidate_name) + matcher.season_signal_score(season, candidate_name)
|
||||
if score > best_score then
|
||||
best_score = score
|
||||
best_item = item
|
||||
end
|
||||
end
|
||||
end
|
||||
return best_item
|
||||
end
|
||||
|
||||
local function resolve_mal_id_async(title, season, request_id, callback)
|
||||
local forced_mal_id = tonumber(opts.aniskip_mal_id)
|
||||
if forced_mal_id and forced_mal_id > 0 then
|
||||
callback(forced_mal_id, "(forced-mal-id)")
|
||||
return
|
||||
end
|
||||
if type(title) == "string" and title:match("^%d+$") then
|
||||
local numeric = tonumber(title)
|
||||
if numeric and numeric > 0 then
|
||||
callback(numeric, title)
|
||||
return
|
||||
end
|
||||
end
|
||||
if type(title) ~= "string" or title == "" then
|
||||
callback(nil, nil)
|
||||
return
|
||||
end
|
||||
|
||||
local lookup = title
|
||||
if season and season > 1 then
|
||||
lookup = string.format("%s Season %d", lookup, season)
|
||||
end
|
||||
local cache_key = string.format("%s|%s", lookup:lower(), tostring(season or "-"))
|
||||
local cached = mal_lookup_cache[cache_key]
|
||||
if cached ~= nil then
|
||||
if cached == false then
|
||||
callback(nil, lookup)
|
||||
else
|
||||
callback(cached, lookup)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local mal_url = "https://myanimelist.net/search/prefix.json?type=anime&keyword=" .. url_encode(lookup)
|
||||
run_json_curl_async(mal_url, function(mal_json, mal_error)
|
||||
if request_id ~= request_generation then
|
||||
return
|
||||
end
|
||||
if not mal_json then
|
||||
subminer_log("warn", "aniskip", "MAL lookup failed: " .. tostring(mal_error))
|
||||
callback(nil, lookup)
|
||||
return
|
||||
end
|
||||
local categories = mal_json.categories
|
||||
if type(categories) ~= "table" then
|
||||
mal_lookup_cache[cache_key] = false
|
||||
callback(nil, lookup)
|
||||
return
|
||||
end
|
||||
|
||||
local all_items = {}
|
||||
for _, category in ipairs(categories) do
|
||||
if type(category) == "table" and type(category.items) == "table" then
|
||||
for _, item in ipairs(category.items) do
|
||||
all_items[#all_items + 1] = item
|
||||
end
|
||||
end
|
||||
end
|
||||
local best_item = select_best_mal_item(all_items, title, season)
|
||||
if best_item and tonumber(best_item.id) then
|
||||
local matched_id = tonumber(best_item.id)
|
||||
mal_lookup_cache[cache_key] = matched_id
|
||||
subminer_log(
|
||||
"info",
|
||||
"aniskip",
|
||||
string.format(
|
||||
'MAL candidate selected (score-based): id=%s name="%s" season_hint=%s',
|
||||
tostring(best_item.id),
|
||||
tostring(best_item.name or ""),
|
||||
tostring(season or "-")
|
||||
)
|
||||
)
|
||||
callback(matched_id, lookup)
|
||||
return
|
||||
end
|
||||
mal_lookup_cache[cache_key] = false
|
||||
callback(nil, lookup)
|
||||
end)
|
||||
end
|
||||
|
||||
local function set_intro_chapters(intro_start, intro_end)
|
||||
if type(intro_start) ~= "number" or type(intro_end) ~= "number" then
|
||||
return
|
||||
end
|
||||
local current = mp.get_property_native("chapter-list")
|
||||
local chapters = {}
|
||||
if type(current) == "table" then
|
||||
for _, chapter in ipairs(current) do
|
||||
local title = type(chapter) == "table" and chapter.title or nil
|
||||
if type(title) ~= "string" or not title:match("^AniSkip ") then
|
||||
chapters[#chapters + 1] = chapter
|
||||
end
|
||||
end
|
||||
end
|
||||
chapters[#chapters + 1] = { time = intro_start, title = "AniSkip Intro Start" }
|
||||
chapters[#chapters + 1] = { time = intro_end, title = "AniSkip Intro End" }
|
||||
table.sort(chapters, function(a, b)
|
||||
local a_time = type(a) == "table" and tonumber(a.time) or 0
|
||||
local b_time = type(b) == "table" and tonumber(b.time) or 0
|
||||
return a_time < b_time
|
||||
end)
|
||||
mp.set_property_native("chapter-list", chapters)
|
||||
end
|
||||
|
||||
local function remove_aniskip_chapters()
|
||||
local current = mp.get_property_native("chapter-list")
|
||||
if type(current) ~= "table" then
|
||||
return
|
||||
end
|
||||
local chapters = {}
|
||||
local changed = false
|
||||
for _, chapter in ipairs(current) do
|
||||
local title = type(chapter) == "table" and chapter.title or nil
|
||||
if type(title) == "string" and title:match("^AniSkip ") then
|
||||
changed = true
|
||||
else
|
||||
chapters[#chapters + 1] = chapter
|
||||
end
|
||||
end
|
||||
if changed then
|
||||
mp.set_property_native("chapter-list", chapters)
|
||||
end
|
||||
end
|
||||
|
||||
local function reset_aniskip_fields()
|
||||
state.aniskip.prompt_shown = false
|
||||
state.aniskip.found = false
|
||||
state.aniskip.mal_id = nil
|
||||
state.aniskip.title = nil
|
||||
state.aniskip.episode = nil
|
||||
state.aniskip.intro_start = nil
|
||||
state.aniskip.intro_end = nil
|
||||
state.aniskip.payload = nil
|
||||
state.aniskip.payload_source = nil
|
||||
remove_aniskip_chapters()
|
||||
end
|
||||
|
||||
local function clear_aniskip_state()
|
||||
request_generation = request_generation + 1
|
||||
reset_aniskip_fields()
|
||||
end
|
||||
|
||||
local function skip_intro_now()
|
||||
if not state.aniskip.found then
|
||||
show_osd("Intro skip unavailable")
|
||||
return
|
||||
end
|
||||
local intro_start = state.aniskip.intro_start
|
||||
local intro_end = state.aniskip.intro_end
|
||||
if type(intro_start) ~= "number" or type(intro_end) ~= "number" then
|
||||
show_osd("Intro markers missing")
|
||||
return
|
||||
end
|
||||
local now = mp.get_property_number("time-pos")
|
||||
if type(now) ~= "number" then
|
||||
show_osd("Skip unavailable")
|
||||
return
|
||||
end
|
||||
local epsilon = 0.35
|
||||
if now < (intro_start - epsilon) or now > (intro_end + epsilon) then
|
||||
show_osd("Skip intro only during intro")
|
||||
return
|
||||
end
|
||||
mp.set_property_number("time-pos", intro_end)
|
||||
show_osd("Skipped intro")
|
||||
end
|
||||
|
||||
local function update_intro_button_visibility()
|
||||
if not opts.aniskip_enabled or not opts.aniskip_show_button or not state.aniskip.found then
|
||||
return
|
||||
end
|
||||
local now = mp.get_property_number("time-pos")
|
||||
if type(now) ~= "number" then
|
||||
return
|
||||
end
|
||||
local in_intro = now >= (state.aniskip.intro_start or -1) and now < (state.aniskip.intro_end or -1)
|
||||
local intro_start = state.aniskip.intro_start or -1
|
||||
local hint_window_end = intro_start + 3
|
||||
if in_intro and not state.aniskip.prompt_shown and now >= intro_start and now < hint_window_end then
|
||||
local key = opts.aniskip_button_key ~= "" and opts.aniskip_button_key or DEFAULT_ANISKIP_BUTTON_KEY
|
||||
local message = string.format(opts.aniskip_button_text, key)
|
||||
mp.osd_message(message, tonumber(opts.aniskip_button_duration) or 3)
|
||||
state.aniskip.prompt_shown = true
|
||||
end
|
||||
end
|
||||
|
||||
local function apply_aniskip_payload(mal_id, title, episode, payload)
|
||||
local results = payload and payload.results
|
||||
if type(results) ~= "table" then
|
||||
return false
|
||||
end
|
||||
for _, item in ipairs(results) do
|
||||
if type(item) == "table" and item.skip_type == "op" and type(item.interval) == "table" then
|
||||
local intro_start = tonumber(item.interval.start_time)
|
||||
local intro_end = tonumber(item.interval.end_time)
|
||||
if intro_start and intro_end and intro_end > intro_start then
|
||||
state.aniskip.found = true
|
||||
state.aniskip.mal_id = mal_id
|
||||
state.aniskip.title = title
|
||||
state.aniskip.episode = episode
|
||||
state.aniskip.intro_start = intro_start
|
||||
state.aniskip.intro_end = intro_end
|
||||
state.aniskip.prompt_shown = false
|
||||
set_intro_chapters(intro_start, intro_end)
|
||||
subminer_log(
|
||||
"info",
|
||||
"aniskip",
|
||||
string.format(
|
||||
"Intro window %.3f -> %.3f (MAL %s, ep %s)",
|
||||
intro_start,
|
||||
intro_end,
|
||||
tostring(mal_id or "-"),
|
||||
tostring(episode or "-")
|
||||
)
|
||||
)
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function has_launcher_payload()
|
||||
return type(opts.aniskip_payload) == "string" and opts.aniskip_payload:match("%S") ~= nil
|
||||
end
|
||||
|
||||
local function is_launcher_context()
|
||||
local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or ""
|
||||
if forced_title ~= "" then
|
||||
return true
|
||||
end
|
||||
local forced_mal_id = tonumber(opts.aniskip_mal_id)
|
||||
if forced_mal_id and forced_mal_id > 0 then
|
||||
return true
|
||||
end
|
||||
local forced_episode = tonumber(opts.aniskip_episode)
|
||||
if forced_episode and forced_episode > 0 then
|
||||
return true
|
||||
end
|
||||
local forced_season = tonumber(opts.aniskip_season)
|
||||
if forced_season and forced_season > 0 then
|
||||
return true
|
||||
end
|
||||
if has_launcher_payload() then
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function should_fetch_aniskip_async(trigger_source, callback)
|
||||
if is_remote_media_path() then
|
||||
callback(false, "remote-url")
|
||||
return
|
||||
end
|
||||
if trigger_source == "script-message" or trigger_source == "overlay-start" then
|
||||
callback(true, trigger_source)
|
||||
return
|
||||
end
|
||||
if is_launcher_context() then
|
||||
callback(true, "launcher-context")
|
||||
return
|
||||
end
|
||||
if type(environment.is_subminer_app_running_async) == "function" then
|
||||
environment.is_subminer_app_running_async(function(running)
|
||||
if running then
|
||||
callback(true, "subminer-app-running")
|
||||
else
|
||||
callback(false, "subminer-context-missing")
|
||||
end
|
||||
end)
|
||||
return
|
||||
end
|
||||
if environment.is_subminer_app_running() then
|
||||
callback(true, "subminer-app-running")
|
||||
return
|
||||
end
|
||||
callback(false, "subminer-context-missing")
|
||||
end
|
||||
|
||||
local function resolve_lookup_titles(primary_title)
|
||||
local media_title_fallback = cleanup_title(mp.get_property("media-title"))
|
||||
local filename_fallback = cleanup_title(mp.get_property("filename/no-ext") or mp.get_property("filename") or "")
|
||||
local path_fallback = cleanup_title(mp.get_property("path") or "")
|
||||
local lookup_titles = {}
|
||||
local seen_titles = {}
|
||||
local function push_lookup_title(candidate)
|
||||
if type(candidate) ~= "string" then
|
||||
return
|
||||
end
|
||||
local trimmed = candidate:match("^%s*(.-)%s*$") or ""
|
||||
if trimmed == "" then
|
||||
return
|
||||
end
|
||||
local key = trimmed:lower()
|
||||
if seen_titles[key] then
|
||||
return
|
||||
end
|
||||
seen_titles[key] = true
|
||||
lookup_titles[#lookup_titles + 1] = trimmed
|
||||
end
|
||||
push_lookup_title(primary_title)
|
||||
push_lookup_title(media_title_fallback)
|
||||
push_lookup_title(filename_fallback)
|
||||
push_lookup_title(path_fallback)
|
||||
return lookup_titles
|
||||
end
|
||||
|
||||
local function resolve_mal_from_candidates_async(lookup_titles, season, request_id, callback, index, last_lookup)
|
||||
local current_index = index or 1
|
||||
local current_lookup = last_lookup
|
||||
if current_index > #lookup_titles then
|
||||
callback(nil, current_lookup)
|
||||
return
|
||||
end
|
||||
local lookup_title = lookup_titles[current_index]
|
||||
subminer_log("info", "aniskip", string.format('MAL lookup attempt %d/%d using title="%s"', current_index, #lookup_titles, lookup_title))
|
||||
resolve_mal_id_async(lookup_title, season, request_id, function(mal_id, lookup)
|
||||
if request_id ~= request_generation then
|
||||
return
|
||||
end
|
||||
if mal_id then
|
||||
callback(mal_id, lookup)
|
||||
return
|
||||
end
|
||||
resolve_mal_from_candidates_async(lookup_titles, season, request_id, callback, current_index + 1, lookup or current_lookup)
|
||||
end)
|
||||
end
|
||||
|
||||
local function fetch_payload_for_episode_async(mal_id, episode, request_id, callback)
|
||||
local payload_cache_key = string.format("%d:%d", mal_id, episode)
|
||||
local cached_payload = payload_cache[payload_cache_key]
|
||||
if cached_payload ~= nil then
|
||||
if cached_payload == false then
|
||||
callback(nil, nil, true)
|
||||
else
|
||||
callback(cached_payload, nil, true)
|
||||
end
|
||||
return
|
||||
end
|
||||
local url = string.format("https://api.aniskip.com/v1/skip-times/%d/%d?types=op&types=ed", mal_id, episode)
|
||||
subminer_log("info", "aniskip", string.format("AniSkip URL=%s", url))
|
||||
run_json_curl_async(url, function(payload, fetch_error)
|
||||
if request_id ~= request_generation then
|
||||
return
|
||||
end
|
||||
if not payload then
|
||||
callback(nil, fetch_error, false)
|
||||
return
|
||||
end
|
||||
if payload.found ~= true then
|
||||
payload_cache[payload_cache_key] = false
|
||||
callback(nil, nil, false)
|
||||
return
|
||||
end
|
||||
payload_cache[payload_cache_key] = payload
|
||||
callback(payload, nil, false)
|
||||
end)
|
||||
end
|
||||
|
||||
local function fetch_payload_from_launcher(payload, mal_id, title, episode)
|
||||
if not payload then
|
||||
return false
|
||||
end
|
||||
state.aniskip.payload = payload
|
||||
state.aniskip.payload_source = "launcher"
|
||||
state.aniskip.mal_id = mal_id
|
||||
state.aniskip.title = title
|
||||
state.aniskip.episode = episode
|
||||
return apply_aniskip_payload(mal_id, title, episode, payload)
|
||||
end
|
||||
|
||||
local function fetch_aniskip_for_current_media(trigger_source)
|
||||
local trigger = type(trigger_source) == "string" and trigger_source or "manual"
|
||||
if not opts.aniskip_enabled then
|
||||
clear_aniskip_state()
|
||||
return
|
||||
end
|
||||
|
||||
should_fetch_aniskip_async(trigger, function(allowed, reason)
|
||||
if not allowed then
|
||||
subminer_log("debug", "aniskip", "Skipping lookup: " .. tostring(reason))
|
||||
return
|
||||
end
|
||||
|
||||
request_generation = request_generation + 1
|
||||
local request_id = request_generation
|
||||
reset_aniskip_fields()
|
||||
local title, episode, season = resolve_title_and_episode()
|
||||
local lookup_titles = resolve_lookup_titles(title)
|
||||
local launcher_payload = resolve_launcher_payload()
|
||||
if launcher_payload then
|
||||
local launcher_mal_id = tonumber(opts.aniskip_mal_id)
|
||||
if not launcher_mal_id then
|
||||
launcher_mal_id = nil
|
||||
end
|
||||
if fetch_payload_from_launcher(launcher_payload, launcher_mal_id, title, episode) then
|
||||
subminer_log(
|
||||
"info",
|
||||
"aniskip",
|
||||
string.format(
|
||||
"Using launcher-provided AniSkip payload (title=%s, season=%s, episode=%s)",
|
||||
tostring(title or ""),
|
||||
tostring(season or "-"),
|
||||
tostring(episode or "-")
|
||||
)
|
||||
)
|
||||
return
|
||||
end
|
||||
subminer_log("info", "aniskip", "Launcher payload present but no OP interval was available")
|
||||
return
|
||||
end
|
||||
|
||||
subminer_log(
|
||||
"info",
|
||||
"aniskip",
|
||||
string.format(
|
||||
'Query context: trigger=%s reason=%s title="%s" season=%s episode=%s (opts: title="%s" season=%s episode=%s mal_id=%s; fallback_titles=%d)',
|
||||
tostring(trigger),
|
||||
tostring(reason or "-"),
|
||||
tostring(title or ""),
|
||||
tostring(season or "-"),
|
||||
tostring(episode or "-"),
|
||||
tostring(opts.aniskip_title or ""),
|
||||
tostring(opts.aniskip_season or "-"),
|
||||
tostring(opts.aniskip_episode or "-"),
|
||||
tostring(opts.aniskip_mal_id or "-"),
|
||||
#lookup_titles
|
||||
)
|
||||
)
|
||||
|
||||
resolve_mal_from_candidates_async(lookup_titles, season, request_id, function(mal_id, mal_lookup)
|
||||
if request_id ~= request_generation then
|
||||
return
|
||||
end
|
||||
if not mal_id then
|
||||
subminer_log("info", "aniskip", string.format('Skipped: MAL id unavailable for query="%s"', tostring(mal_lookup or "")))
|
||||
return
|
||||
end
|
||||
subminer_log("info", "aniskip", string.format('Resolved MAL id=%d using query="%s"', mal_id, tostring(mal_lookup or "")))
|
||||
fetch_payload_for_episode_async(mal_id, episode, request_id, function(payload, fetch_error)
|
||||
if request_id ~= request_generation then
|
||||
return
|
||||
end
|
||||
if not payload then
|
||||
if fetch_error then
|
||||
subminer_log("warn", "aniskip", "AniSkip fetch failed: " .. tostring(fetch_error))
|
||||
else
|
||||
subminer_log("info", "aniskip", "AniSkip: no skip windows found")
|
||||
end
|
||||
return
|
||||
end
|
||||
state.aniskip.payload = payload
|
||||
state.aniskip.payload_source = "remote"
|
||||
if not apply_aniskip_payload(mal_id, title, episode, payload) then
|
||||
subminer_log("info", "aniskip", "AniSkip payload did not include OP interval")
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
return {
|
||||
clear_aniskip_state = clear_aniskip_state,
|
||||
skip_intro_now = skip_intro_now,
|
||||
update_intro_button_visibility = update_intro_button_visibility,
|
||||
fetch_aniskip_for_current_media = fetch_aniskip_for_current_media,
|
||||
}
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -1,150 +0,0 @@
|
||||
local M = {}
|
||||
|
||||
local function normalize_for_match(value)
|
||||
if type(value) ~= "string" then
|
||||
return ""
|
||||
end
|
||||
return value:lower():gsub("[^%w]+", " "):gsub("%s+", " "):match("^%s*(.-)%s*$") or ""
|
||||
end
|
||||
|
||||
local MATCH_STOPWORDS = {
|
||||
the = true,
|
||||
this = true,
|
||||
that = true,
|
||||
world = true,
|
||||
animated = true,
|
||||
series = true,
|
||||
season = true,
|
||||
no = true,
|
||||
on = true,
|
||||
["and"] = true,
|
||||
}
|
||||
|
||||
local function tokenize_match_words(value)
|
||||
local normalized = normalize_for_match(value)
|
||||
local tokens = {}
|
||||
for token in normalized:gmatch("%S+") do
|
||||
if #token >= 3 and not MATCH_STOPWORDS[token] then
|
||||
tokens[#tokens + 1] = token
|
||||
end
|
||||
end
|
||||
return tokens
|
||||
end
|
||||
|
||||
local function token_set(tokens)
|
||||
local set = {}
|
||||
for _, token in ipairs(tokens) do
|
||||
set[token] = true
|
||||
end
|
||||
return set
|
||||
end
|
||||
|
||||
function M.title_overlap_score(expected_title, candidate_title)
|
||||
local expected = normalize_for_match(expected_title)
|
||||
local candidate = normalize_for_match(candidate_title)
|
||||
if expected == "" or candidate == "" then
|
||||
return 0
|
||||
end
|
||||
if candidate:find(expected, 1, true) then
|
||||
return 120
|
||||
end
|
||||
local expected_tokens = tokenize_match_words(expected_title)
|
||||
local candidate_tokens = token_set(tokenize_match_words(candidate_title))
|
||||
if #expected_tokens == 0 then
|
||||
return 0
|
||||
end
|
||||
local score = 0
|
||||
local matched = 0
|
||||
for _, token in ipairs(expected_tokens) do
|
||||
if candidate_tokens[token] then
|
||||
score = score + 30
|
||||
matched = matched + 1
|
||||
else
|
||||
score = score - 20
|
||||
end
|
||||
end
|
||||
if matched == 0 then
|
||||
score = score - 80
|
||||
end
|
||||
local coverage = matched / #expected_tokens
|
||||
if #expected_tokens >= 2 then
|
||||
if coverage >= 0.8 then
|
||||
score = score + 30
|
||||
elseif coverage >= 0.6 then
|
||||
score = score + 10
|
||||
else
|
||||
score = score - 50
|
||||
end
|
||||
elseif coverage >= 1 then
|
||||
score = score + 10
|
||||
end
|
||||
return score
|
||||
end
|
||||
|
||||
local function has_any_sequel_marker(candidate_title)
|
||||
local normalized = normalize_for_match(candidate_title)
|
||||
if normalized == "" then
|
||||
return false
|
||||
end
|
||||
local markers = {
|
||||
"season 2",
|
||||
"season 3",
|
||||
"season 4",
|
||||
"2nd season",
|
||||
"3rd season",
|
||||
"4th season",
|
||||
"second season",
|
||||
"third season",
|
||||
"fourth season",
|
||||
" ii ",
|
||||
" iii ",
|
||||
" iv ",
|
||||
}
|
||||
local padded = " " .. normalized .. " "
|
||||
for _, marker in ipairs(markers) do
|
||||
if padded:find(marker, 1, true) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function M.season_signal_score(requested_season, candidate_title)
|
||||
local season = tonumber(requested_season)
|
||||
if not season or season < 1 then
|
||||
return 0
|
||||
end
|
||||
local normalized = " " .. normalize_for_match(candidate_title) .. " "
|
||||
if normalized == " " then
|
||||
return 0
|
||||
end
|
||||
|
||||
if season == 1 then
|
||||
return has_any_sequel_marker(candidate_title) and -60 or 20
|
||||
end
|
||||
|
||||
local numeric_marker = string.format(" season %d ", season)
|
||||
local ordinal_marker = string.format(" %dth season ", season)
|
||||
local roman_markers = {
|
||||
[2] = { " ii ", " second season ", " 2nd season " },
|
||||
[3] = { " iii ", " third season ", " 3rd season " },
|
||||
[4] = { " iv ", " fourth season ", " 4th season " },
|
||||
[5] = { " v ", " fifth season ", " 5th season " },
|
||||
}
|
||||
|
||||
if normalized:find(numeric_marker, 1, true) or normalized:find(ordinal_marker, 1, true) then
|
||||
return 40
|
||||
end
|
||||
local aliases = roman_markers[season] or {}
|
||||
for _, marker in ipairs(aliases) do
|
||||
if normalized:find(marker, 1, true) then
|
||||
return 40
|
||||
end
|
||||
end
|
||||
if has_any_sequel_marker(candidate_title) then
|
||||
return -20
|
||||
end
|
||||
return 5
|
||||
end
|
||||
|
||||
return M
|
||||
@@ -56,9 +56,6 @@ function M.init()
|
||||
ctx.binary = make_lazy_proxy("binary", function()
|
||||
return require("binary").create(ctx)
|
||||
end)
|
||||
ctx.aniskip = make_lazy_proxy("aniskip", function()
|
||||
return require("aniskip").create(ctx)
|
||||
end)
|
||||
ctx.hover = make_lazy_proxy("hover", function()
|
||||
return require("hover").create(ctx)
|
||||
end)
|
||||
|
||||
@@ -10,7 +10,6 @@ function M.create(ctx)
|
||||
local state = ctx.state
|
||||
local options_helper = ctx.options_helper
|
||||
local process = ctx.process
|
||||
local aniskip = ctx.aniskip
|
||||
local hover = ctx.hover
|
||||
local subminer_log = ctx.log.subminer_log
|
||||
local show_osd = ctx.log.show_osd
|
||||
@@ -52,13 +51,6 @@ function M.create(ctx)
|
||||
return reason == "reload" or reason == "redirect"
|
||||
end
|
||||
|
||||
local function schedule_aniskip_fetch(trigger_source, delay_seconds)
|
||||
local delay = tonumber(delay_seconds) or 0
|
||||
mp.add_timeout(delay, function()
|
||||
aniskip.fetch_aniskip_for_current_media(trigger_source)
|
||||
end)
|
||||
end
|
||||
|
||||
local function clear_pending_visible_overlay_hide()
|
||||
local timer = state.pending_visible_overlay_hide_timer
|
||||
if timer and timer.kill then
|
||||
@@ -159,7 +151,6 @@ function M.create(ctx)
|
||||
return
|
||||
end
|
||||
if not resolve_auto_start_enabled() then
|
||||
schedule_aniskip_fetch("file-loaded", 0)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -178,7 +169,6 @@ function M.create(ctx)
|
||||
.. process.describe_mpv_ipc_socket_match(opts.socket_path)
|
||||
.. ")"
|
||||
)
|
||||
schedule_aniskip_fetch("file-loaded", 0)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -187,8 +177,6 @@ function M.create(ctx)
|
||||
socket_path = opts.socket_path,
|
||||
rearm_pause_until_ready = should_rearm_pause_until_ready(same_media_loaded),
|
||||
})
|
||||
-- Give the overlay process a moment to initialize before querying AniSkip.
|
||||
schedule_aniskip_fetch("overlay-start", 0.8)
|
||||
end
|
||||
|
||||
local function on_start_file()
|
||||
@@ -267,7 +255,6 @@ function M.create(ctx)
|
||||
local preserve_active_auto_start_gate = (
|
||||
state.overlay_running and state.auto_play_ready_gate_armed and should_auto_start and has_matching_socket
|
||||
)
|
||||
aniskip.clear_aniskip_state()
|
||||
if not preserve_active_auto_start_gate then
|
||||
process.disarm_auto_play_ready_gate()
|
||||
end
|
||||
@@ -283,12 +270,10 @@ function M.create(ctx)
|
||||
end
|
||||
|
||||
refresh_managed_subtitle_autoloading()
|
||||
schedule_aniskip_fetch("file-loaded", 0)
|
||||
end
|
||||
|
||||
local function on_shutdown()
|
||||
next_auto_start_retry_generation()
|
||||
aniskip.clear_aniskip_state()
|
||||
hover.clear_hover_overlay()
|
||||
process.disarm_auto_play_ready_gate()
|
||||
clear_pending_visible_overlay_hide()
|
||||
@@ -334,22 +319,12 @@ function M.create(ctx)
|
||||
mp.register_event("shutdown", function()
|
||||
hover.clear_hover_overlay()
|
||||
end)
|
||||
mp.register_event("end-file", function()
|
||||
aniskip.clear_aniskip_state()
|
||||
end)
|
||||
mp.register_event("shutdown", function()
|
||||
aniskip.clear_aniskip_state()
|
||||
end)
|
||||
mp.add_hook("on_unload", 10, function()
|
||||
hover.clear_hover_overlay()
|
||||
aniskip.clear_aniskip_state()
|
||||
end)
|
||||
mp.observe_property("sub-start", "native", function()
|
||||
hover.clear_hover_overlay()
|
||||
end)
|
||||
mp.observe_property("time-pos", "number", function()
|
||||
aniskip.update_intro_button_visibility()
|
||||
end)
|
||||
end
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,7 +3,6 @@ local M = {}
|
||||
function M.create(ctx)
|
||||
local mp = ctx.mp
|
||||
local process = ctx.process
|
||||
local aniskip = ctx.aniskip
|
||||
local hover = ctx.hover
|
||||
local ui = ctx.ui
|
||||
local state = ctx.state
|
||||
@@ -43,12 +42,6 @@ function M.create(ctx)
|
||||
mp.register_script_message("subminer-autoplay-ready", function()
|
||||
process.notify_auto_play_ready()
|
||||
end)
|
||||
mp.register_script_message("subminer-aniskip-refresh", function()
|
||||
aniskip.fetch_aniskip_for_current_media("script-message")
|
||||
end)
|
||||
mp.register_script_message("subminer-skip-intro", function()
|
||||
aniskip.skip_intro_now()
|
||||
end)
|
||||
mp.register_script_message(hover.HOVER_MESSAGE_NAME, function(payload_json)
|
||||
hover.handle_hover_message(payload_json)
|
||||
end)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
local M = {}
|
||||
local DEFAULT_ANISKIP_BUTTON_KEY = "TAB"
|
||||
|
||||
local function normalize_socket_path_option(socket_path, default_socket_path)
|
||||
if type(default_socket_path) ~= "string" then
|
||||
@@ -37,16 +36,6 @@ function M.load(options_lib, default_socket_path)
|
||||
auto_start_pause_until_ready_timeout_seconds = 15,
|
||||
osd_messages = true,
|
||||
log_level = "info",
|
||||
aniskip_enabled = false,
|
||||
aniskip_title = "",
|
||||
aniskip_season = "",
|
||||
aniskip_mal_id = "",
|
||||
aniskip_episode = "",
|
||||
aniskip_payload = "",
|
||||
aniskip_show_button = true,
|
||||
aniskip_button_text = "You can skip by pressing %s",
|
||||
aniskip_button_key = DEFAULT_ANISKIP_BUTTON_KEY,
|
||||
aniskip_button_duration = 3,
|
||||
}
|
||||
|
||||
options_lib.read_options(opts, "subminer")
|
||||
|
||||
@@ -18,17 +18,6 @@ function M.new()
|
||||
clear_timer = nil,
|
||||
last_hover_update_ts = 0,
|
||||
},
|
||||
aniskip = {
|
||||
mal_id = nil,
|
||||
title = nil,
|
||||
episode = nil,
|
||||
intro_start = nil,
|
||||
intro_end = nil,
|
||||
payload = nil,
|
||||
payload_source = nil,
|
||||
found = false,
|
||||
prompt_shown = false,
|
||||
},
|
||||
auto_play_ready_gate_armed = false,
|
||||
auto_play_ready_should_resume_playback = false,
|
||||
auto_play_ready_timeout = nil,
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
local M = {}
|
||||
local DEFAULT_ANISKIP_BUTTON_KEY = "TAB"
|
||||
local LEGACY_ANISKIP_BUTTON_KEY = "y-k"
|
||||
|
||||
function M.create(ctx)
|
||||
local mp = ctx.mp
|
||||
local input = ctx.input
|
||||
local opts = ctx.opts
|
||||
local process = ctx.process
|
||||
local aniskip = ctx.aniskip
|
||||
local subminer_log = ctx.log.subminer_log
|
||||
local show_osd = ctx.log.show_osd
|
||||
|
||||
@@ -99,19 +95,6 @@ function M.create(ctx)
|
||||
end
|
||||
process.run_control_command_async("open-session-help")
|
||||
end)
|
||||
if type(opts.aniskip_button_key) == "string" and opts.aniskip_button_key ~= "" then
|
||||
mp.add_key_binding(opts.aniskip_button_key, "subminer-skip-intro", function()
|
||||
aniskip.skip_intro_now()
|
||||
end)
|
||||
end
|
||||
if
|
||||
opts.aniskip_button_key ~= LEGACY_ANISKIP_BUTTON_KEY
|
||||
and opts.aniskip_button_key ~= DEFAULT_ANISKIP_BUTTON_KEY
|
||||
then
|
||||
mp.add_key_binding(LEGACY_ANISKIP_BUTTON_KEY, "subminer-skip-intro-fallback", function()
|
||||
aniskip.skip_intro_now()
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
## Highlights
|
||||
### Changed
|
||||
|
||||
- **Yomitan**: Updated the bundled Yomitan to the latest revision.
|
||||
- Picks up the newest lookup improvements and fixes from the SubMiner Yomitan fork.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Anki / Animated AVIF**: Clips with word audio no longer start or end early.
|
||||
- Clip boundaries are now snapped to the nearest AVIF frame edge, keeping audio lead-in and playback in sync.
|
||||
|
||||
- **macOS Overlay**: Resolved several interactivity and focus issues triggered by autoplay and modal windows.
|
||||
- After autoplay starts with "wait for overlay to be ready" enabled, subtitles are immediately hoverable and Yomitan lookups work - no longer require an extra click to activate.
|
||||
- After any modal closes (Settings, Stats, sidebar, etc.), the overlay and subtitles reappear automatically and mpv keyboard shortcuts (pause, seek, etc.) are restored to mpv right away, including in native fullscreen.
|
||||
|
||||
- **Hyprland Fullscreen Overlay**: Fixed overlay alignment when mpv is fullscreen on Hyprland.
|
||||
- Compositor client bounds are now verified before positioning, so the stats panel, modals, and subtitle sidebar no longer shift below the mpv window.
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||
|
||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||
@@ -488,7 +488,7 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
|
||||
changedEntries: [{ path: 'src/main-entry.ts', status: 'M' }],
|
||||
changedLabels: [],
|
||||
}),
|
||||
/requires a changelog fragment/,
|
||||
/requires a reconciled changelog fragment/,
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
@@ -514,7 +514,7 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
|
||||
],
|
||||
changedLabels: [],
|
||||
}),
|
||||
/requires a changelog fragment/,
|
||||
/requires a reconciled changelog fragment/,
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
@@ -526,6 +526,27 @@ test('verifyPullRequestChangelog requires fragments for user-facing changes and
|
||||
changedLabels: [],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
verifyPullRequestChangelog({
|
||||
changedEntries: [
|
||||
{ path: 'src/main-entry.ts', status: 'M' },
|
||||
{ path: 'changes/001.md', status: 'M' },
|
||||
],
|
||||
changedLabels: [],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
verifyPullRequestChangelog({
|
||||
changedEntries: [
|
||||
{ path: 'src/main-entry.ts', status: 'M' },
|
||||
{ path: 'changes/001.md', status: 'A' },
|
||||
{ path: 'changes/002.md', status: 'A' },
|
||||
],
|
||||
changedLabels: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('writePrereleaseNotesForVersion writes cumulative beta notes without mutating stable changelog artifacts', async () => {
|
||||
@@ -1044,6 +1065,85 @@ test('writeChangelogArtifacts filters internal fragments from the release-notes
|
||||
}
|
||||
});
|
||||
|
||||
test('writeChangelogArtifacts appends contributor attribution and a new-contributors section to release notes', async () => {
|
||||
const { writeChangelogArtifacts } = await loadModule();
|
||||
const workspace = createWorkspace('release-notes-contributors');
|
||||
const projectRoot = path.join(workspace, 'SubMiner');
|
||||
|
||||
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'changes', '001.md'),
|
||||
['type: added', 'area: overlay', '', '- Added a feature.'].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'changes', '002.md'),
|
||||
['type: fixed', 'area: jellyfin', '', '- Fixed a bug.'].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
try {
|
||||
const stub = defaultStubClaude();
|
||||
const resolveContributionsCalls: string[][] = [];
|
||||
writeChangelogArtifacts({
|
||||
cwd: projectRoot,
|
||||
version: '0.6.0',
|
||||
date: '2026-05-06',
|
||||
deps: {
|
||||
runClaude: stub.runClaude,
|
||||
resolveContributions: (fragmentPaths) => {
|
||||
resolveContributionsCalls.push(fragmentPaths);
|
||||
return [
|
||||
{
|
||||
prNumber: 110,
|
||||
login: 'ksyasuda',
|
||||
title: 'feat(overlay): add a feature',
|
||||
isFirstContribution: false,
|
||||
},
|
||||
{
|
||||
prNumber: 112,
|
||||
login: 'bee-san',
|
||||
title: 'fix(jellyfin): restart remote session',
|
||||
isFirstContribution: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(resolveContributionsCalls.length, 1, 'resolves contributions once per release');
|
||||
assert.deepEqual(resolveContributionsCalls[0], [
|
||||
path.join(projectRoot, 'changes', '001.md'),
|
||||
path.join(projectRoot, 'changes', '002.md'),
|
||||
]);
|
||||
|
||||
const releaseNotes = fs.readFileSync(
|
||||
path.join(projectRoot, 'release', 'release-notes.md'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(releaseNotes, /## What’s Changed\n\n/);
|
||||
assert.match(releaseNotes, /- feat\(overlay\): add a feature by @ksyasuda in #110\n/);
|
||||
assert.match(releaseNotes, /- fix\(jellyfin\): restart remote session by @bee-san in #112\n/);
|
||||
assert.match(
|
||||
releaseNotes,
|
||||
/## New Contributors\n\n- @bee-san made their first contribution in #112/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
releaseNotes,
|
||||
/ksyasuda made their first contribution/,
|
||||
'returning contributors are not listed under New Contributors',
|
||||
);
|
||||
|
||||
// Attribution is a release-notes concern only; the CHANGELOG stays clean.
|
||||
const changelog = fs.readFileSync(path.join(projectRoot, 'CHANGELOG.md'), 'utf8');
|
||||
assert.doesNotMatch(changelog, /What’s Changed/);
|
||||
assert.doesNotMatch(changelog, /New Contributors/);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('writeChangelogArtifacts strips <details> blocks from release notes when reusing an existing CHANGELOG section', async () => {
|
||||
const { writeChangelogArtifacts } = await loadModule();
|
||||
const workspace = createWorkspace('reuse-existing-section');
|
||||
|
||||
+174
-3
@@ -4,6 +4,20 @@ import { execFileSync } from 'node:child_process';
|
||||
|
||||
type RunClaude = (input: string, args: string[]) => string;
|
||||
|
||||
// A single PR's contribution, resolved from the fragment files released in this
|
||||
// cycle. Used to append GitHub-style attribution to the release notes.
|
||||
type Contribution = {
|
||||
prNumber: number;
|
||||
login: string;
|
||||
title: string;
|
||||
isFirstContribution: boolean;
|
||||
};
|
||||
|
||||
// Resolves the contributions behind a set of changelog fragment paths. Injected
|
||||
// in tests so we never hit git/gh; the default implementation walks git history
|
||||
// and the GitHub API.
|
||||
type ResolveContributions = (fragmentPaths: string[], cwd: string) => Contribution[];
|
||||
|
||||
type ChangelogFsDeps = {
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
mkdirSync?: (candidate: string, options: { recursive: true }) => void;
|
||||
@@ -13,6 +27,7 @@ type ChangelogFsDeps = {
|
||||
writeFileSync?: (candidate: string, content: string, encoding: BufferEncoding) => void;
|
||||
log?: (message: string) => void;
|
||||
runClaude?: RunClaude;
|
||||
resolveContributions?: ResolveContributions;
|
||||
};
|
||||
|
||||
type PolishMode = 'changelog' | 'release-notes';
|
||||
@@ -296,6 +311,152 @@ function defaultRunClaude(input: string, args: string[]): string {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFragmentRelativePath(fragmentPath: string, cwd: string): string {
|
||||
return path.relative(cwd, fragmentPath).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
// Walks git history + the GitHub API to attribute each released fragment to the
|
||||
// PR (and author) that introduced it. One git call and one gh call per fragment,
|
||||
// plus one gh call per unique author for the first-contribution check. Best
|
||||
// effort: if gh is unavailable/unauthenticated or any lookup fails, we warn and
|
||||
// drop attribution rather than failing the release.
|
||||
function defaultResolveContributions(fragmentPaths: string[], cwd: string): Contribution[] {
|
||||
if (fragmentPaths.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const slug = execFileSync(
|
||||
'gh',
|
||||
['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'],
|
||||
{
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
},
|
||||
).trim();
|
||||
if (!slug) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const byPr = new Map<number, Contribution>();
|
||||
for (const fragmentPath of fragmentPaths) {
|
||||
const relativePath = resolveFragmentRelativePath(fragmentPath, cwd);
|
||||
// git log lists newest first, so the commit that *added* the file is the
|
||||
// last line of the --diff-filter=A history.
|
||||
const addingSha = execFileSync(
|
||||
'git',
|
||||
['log', '--diff-filter=A', '--follow', '--format=%H', '--', relativePath],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
)
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.pop();
|
||||
if (!addingSha) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const prRaw = execFileSync(
|
||||
'gh',
|
||||
[
|
||||
'api',
|
||||
`repos/${slug}/commits/${addingSha}/pulls`,
|
||||
'--jq',
|
||||
'.[0] // empty | {number, login: .user.login, title}',
|
||||
],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
).trim();
|
||||
if (!prRaw) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pr = JSON.parse(prRaw) as { number?: number; login?: string; title?: string };
|
||||
if (typeof pr.number !== 'number' || !pr.login || !pr.title) {
|
||||
continue;
|
||||
}
|
||||
if (!byPr.has(pr.number)) {
|
||||
byPr.set(pr.number, {
|
||||
prNumber: pr.number,
|
||||
login: pr.login,
|
||||
title: pr.title,
|
||||
isFirstContribution: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const firstPrByAuthor = new Map<string, number | null>();
|
||||
for (const contribution of byPr.values()) {
|
||||
if (!firstPrByAuthor.has(contribution.login)) {
|
||||
const firstRaw = execFileSync(
|
||||
'gh',
|
||||
[
|
||||
'api',
|
||||
'-X',
|
||||
'GET',
|
||||
'search/issues',
|
||||
'-f',
|
||||
`q=repo:${slug} is:pr is:merged author:${contribution.login}`,
|
||||
'-f',
|
||||
'sort=created',
|
||||
'-f',
|
||||
'order=asc',
|
||||
'-f',
|
||||
'per_page=1',
|
||||
'--jq',
|
||||
'.items[0].number // empty',
|
||||
],
|
||||
{ cwd, encoding: 'utf8' },
|
||||
).trim();
|
||||
firstPrByAuthor.set(contribution.login, firstRaw ? Number.parseInt(firstRaw, 10) : null);
|
||||
}
|
||||
const firstPr = firstPrByAuthor.get(contribution.login) ?? null;
|
||||
contribution.isFirstContribution = firstPr !== null && firstPr === contribution.prNumber;
|
||||
}
|
||||
|
||||
return [...byPr.values()].sort((a, b) => a.prNumber - b.prNumber);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`Skipping contributor attribution: ${message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function resolveContributionsForFragments(
|
||||
fragments: ChangeFragment[],
|
||||
cwd: string,
|
||||
deps?: ChangelogFsDeps,
|
||||
): Contribution[] {
|
||||
const resolve = deps?.resolveContributions ?? defaultResolveContributions;
|
||||
return resolve(
|
||||
fragments.filter((fragment) => fragment.type !== 'internal').map((fragment) => fragment.path),
|
||||
cwd,
|
||||
);
|
||||
}
|
||||
|
||||
function renderContributorsSections(contributions: Contribution[]): string[] {
|
||||
if (contributions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines: string[] = ['## What’s Changed', ''];
|
||||
for (const contribution of contributions) {
|
||||
lines.push(`- ${contribution.title} by @${contribution.login} in #${contribution.prNumber}`);
|
||||
}
|
||||
|
||||
const firstTimers = contributions.filter((contribution) => contribution.isFirstContribution);
|
||||
if (firstTimers.length > 0) {
|
||||
lines.push('', '## New Contributors', '');
|
||||
for (const contribution of firstTimers) {
|
||||
lines.push(
|
||||
`- @${contribution.login} made their first contribution in #${contribution.prNumber}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
return lines;
|
||||
}
|
||||
|
||||
function serializeFragmentsForPrompt(
|
||||
fragments: ChangeFragment[],
|
||||
mode: PolishMode,
|
||||
@@ -473,6 +634,7 @@ function renderReleaseNotes(
|
||||
changes: string,
|
||||
options?: {
|
||||
disclaimer?: string;
|
||||
contributions?: Contribution[];
|
||||
},
|
||||
): string {
|
||||
const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
|
||||
@@ -494,6 +656,7 @@ function renderReleaseNotes(
|
||||
'',
|
||||
'Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.',
|
||||
'',
|
||||
...renderContributorsSections(options?.contributions ?? []),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -504,6 +667,7 @@ function writeReleaseNotesFile(
|
||||
options?: {
|
||||
disclaimer?: string;
|
||||
outputPath?: string;
|
||||
contributions?: Contribution[];
|
||||
},
|
||||
): string {
|
||||
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
|
||||
@@ -530,6 +694,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
|
||||
const version = resolveVersion(options ?? {});
|
||||
const date = resolveDate(options?.date);
|
||||
const fragments = readChangeFragments(cwd, options?.deps);
|
||||
const contributions = resolveContributionsForFragments(fragments, cwd, options?.deps);
|
||||
const existingChangelogPath = path.join(cwd, 'CHANGELOG.md');
|
||||
const existingChangelog = existsSync(existingChangelogPath)
|
||||
? readFileSync(existingChangelogPath, 'utf8')
|
||||
@@ -547,6 +712,7 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
|
||||
cwd,
|
||||
stripDetailsBlocks(existingReleaseSection),
|
||||
options?.deps,
|
||||
{ contributions },
|
||||
);
|
||||
log(`Generated ${releaseNotesPath}`);
|
||||
|
||||
@@ -572,7 +738,9 @@ export function writeChangelogArtifacts(options?: ChangelogOptions): {
|
||||
date,
|
||||
deps: options?.deps,
|
||||
});
|
||||
const releaseNotesPath = writeReleaseNotesFile(cwd, releaseNotesBody, options?.deps);
|
||||
const releaseNotesPath = writeReleaseNotesFile(cwd, releaseNotesBody, options?.deps, {
|
||||
contributions,
|
||||
});
|
||||
log(`Generated ${releaseNotesPath}`);
|
||||
|
||||
for (const fragment of fragments) {
|
||||
@@ -661,14 +829,15 @@ export function verifyPullRequestChangelog(options: PullRequestChangelogOptions)
|
||||
return;
|
||||
}
|
||||
|
||||
const hasFragment = normalizedEntries.some(
|
||||
const fragmentEntries = normalizedEntries.filter(
|
||||
(entry) => entry.status !== 'D' && isFragmentPath(entry.path),
|
||||
);
|
||||
const hasFragment = fragmentEntries.length > 0;
|
||||
const requiresFragment = normalizedEntries.some((entry) => !isIgnoredPullRequestPath(entry.path));
|
||||
|
||||
if (requiresFragment && !hasFragment) {
|
||||
throw new Error(
|
||||
`This pull request changes release-relevant files and requires a changelog fragment under changes/ or the ${SKIP_CHANGELOG_LABEL} label.`,
|
||||
`This pull request changes release-relevant files and requires a reconciled changelog fragment under changes/ or the ${SKIP_CHANGELOG_LABEL} label. Before adding a new fragment, update the existing PR fragment when the new work modifies, fixes, or supersedes behavior already described there.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -832,10 +1001,12 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
|
||||
existingReleaseNotes,
|
||||
deps: options?.deps,
|
||||
});
|
||||
const contributions = resolveContributionsForFragments(fragments, cwd, options?.deps);
|
||||
return writeReleaseNotesFile(cwd, changes, options?.deps, {
|
||||
disclaimer:
|
||||
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
||||
outputPath: PRERELEASE_NOTES_PATH,
|
||||
contributions,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -87,13 +87,6 @@ local function run_plugin_scenario(config)
|
||||
}
|
||||
end
|
||||
if args[1] == "curl" then
|
||||
local url = args[#args] or ""
|
||||
if type(url) == "string" and url:find("myanimelist", 1, true) then
|
||||
return { status = 0, stdout = config.mal_lookup_stdout or "{}", stderr = "" }
|
||||
end
|
||||
if type(url) == "string" and url:find("api.aniskip.com", 1, true) then
|
||||
return { status = 0, stdout = config.aniskip_stdout or "{}", stderr = "" }
|
||||
end
|
||||
return { status = 0, stdout = "{}", stderr = "" }
|
||||
end
|
||||
return { status = 0, stdout = "", stderr = "" }
|
||||
@@ -108,15 +101,8 @@ local function run_plugin_scenario(config)
|
||||
return
|
||||
end
|
||||
if args[1] == "curl" then
|
||||
local url = args[#args] or ""
|
||||
if type(url) == "string" and url:find("myanimelist", 1, true) then
|
||||
callback(true, { status = 0, stdout = config.mal_lookup_stdout or "{}", stderr = "" }, nil)
|
||||
return
|
||||
end
|
||||
if type(url) == "string" and url:find("api.aniskip.com", 1, true) then
|
||||
callback(true, { status = 0, stdout = config.aniskip_stdout or "{}", stderr = "" }, nil)
|
||||
return
|
||||
end
|
||||
callback(true, { status = 0, stdout = "{}", stderr = "" }, nil)
|
||||
return
|
||||
end
|
||||
for _, value in ipairs(args) do
|
||||
if value == "--app-ping" then
|
||||
@@ -263,34 +249,6 @@ local function run_plugin_scenario(config)
|
||||
amount = 125,
|
||||
}, nil
|
||||
end
|
||||
if json == "__MAL_FOUND__" then
|
||||
return {
|
||||
categories = {
|
||||
{
|
||||
items = {
|
||||
{
|
||||
id = 99,
|
||||
name = "Sample Show",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
end
|
||||
if json == "__ANISKIP_FOUND__" then
|
||||
return {
|
||||
found = true,
|
||||
results = {
|
||||
{
|
||||
skip_type = "op",
|
||||
interval = {
|
||||
start_time = 12.3,
|
||||
end_time = 45.6,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
end
|
||||
return {}, nil
|
||||
end
|
||||
|
||||
@@ -311,7 +269,6 @@ local function run_plugin_scenario(config)
|
||||
package.loaded["process"] = nil
|
||||
package.loaded["state"] = nil
|
||||
package.loaded["ui"] = nil
|
||||
package.loaded["aniskip"] = nil
|
||||
_G.__subminer_plugin_bootstrapped = nil
|
||||
local original_package_config = package.config
|
||||
if config.platform == "windows" then
|
||||
@@ -505,33 +462,6 @@ local function has_async_command(async_calls, executable)
|
||||
return false
|
||||
end
|
||||
|
||||
local function has_async_curl_for(async_calls, needle)
|
||||
for _, call in ipairs(async_calls) do
|
||||
local args = call.args or {}
|
||||
if args[1] == "curl" then
|
||||
local url = args[#args] or ""
|
||||
if type(url) == "string" and url:find(needle, 1, true) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function count_async_curl_for(async_calls, needle)
|
||||
local count = 0
|
||||
for _, call in ipairs(async_calls) do
|
||||
local args = call.args or {}
|
||||
if args[1] == "curl" then
|
||||
local url = args[#args] or ""
|
||||
if type(url) == "string" and url:find(needle, 1, true) then
|
||||
count = count + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
return count
|
||||
end
|
||||
|
||||
local function has_property_set(property_sets, name, value)
|
||||
for _, call in ipairs(property_sets) do
|
||||
if call.name == name and call.value == value then
|
||||
@@ -631,15 +561,6 @@ local function fire_observer(recorded, name, value)
|
||||
end
|
||||
end
|
||||
|
||||
local function has_key_binding(recorded, keys, name)
|
||||
for _, binding in ipairs(recorded.key_bindings or {}) do
|
||||
if binding.keys == keys and binding.name == name then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local binary_path = "/tmp/subminer-binary"
|
||||
local appimage_path = "/tmp/SubMiner.AppImage"
|
||||
|
||||
@@ -1325,7 +1246,6 @@ do
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "yes",
|
||||
aniskip_enabled = "yes",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
@@ -1367,7 +1287,6 @@ do
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "no",
|
||||
aniskip_enabled = "yes",
|
||||
},
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
@@ -1404,14 +1323,11 @@ do
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "yes",
|
||||
aniskip_enabled = "yes",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
path = media_path,
|
||||
media_title = "Sample Show S01E01",
|
||||
mal_lookup_stdout = "__MAL_FOUND__",
|
||||
aniskip_stdout = "__ANISKIP_FOUND__",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
@@ -1429,10 +1345,6 @@ do
|
||||
count_property_set(recorded.property_sets, "pause", true) == 1,
|
||||
"same-media reload should not re-arm pause-until-ready"
|
||||
)
|
||||
assert_true(
|
||||
count_async_curl_for(recorded.async_calls, "api.aniskip.com") == 1,
|
||||
"same-media reload should not repeat AniSkip lookup"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
@@ -1535,7 +1447,6 @@ do
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "no",
|
||||
aniskip_enabled = "yes",
|
||||
},
|
||||
media_title = "Random Movie",
|
||||
files = {
|
||||
@@ -1545,14 +1456,10 @@ do
|
||||
assert_true(recorded ~= nil, "plugin failed to load for non-subminer file-load scenario: " .. tostring(err))
|
||||
fire_event(recorded, "file-loaded")
|
||||
assert_true(not has_sync_command(recorded.sync_calls, "ps"), "file-loaded should avoid synchronous process checks")
|
||||
assert_true(not has_sync_command(recorded.sync_calls, "curl"), "file-loaded should avoid synchronous AniSkip network calls")
|
||||
assert_true(not has_sync_command(recorded.sync_calls, "curl"), "file-loaded should not perform synchronous network calls")
|
||||
assert_true(
|
||||
not has_async_curl_for(recorded.async_calls, "myanimelist.net/search/prefix.json"),
|
||||
"file-loaded without SubMiner context should skip AniSkip MAL lookup"
|
||||
)
|
||||
assert_true(
|
||||
not has_async_curl_for(recorded.async_calls, "api.aniskip.com"),
|
||||
"file-loaded without SubMiner context should skip AniSkip API lookup"
|
||||
not has_async_command(recorded.async_calls, "curl"),
|
||||
"file-loaded should not perform plugin-side AniSkip lookups (AniSkip now lives in the app)"
|
||||
)
|
||||
end
|
||||
|
||||
@@ -1574,75 +1481,12 @@ do
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for URL overlay-start AniSkip scenario: " .. tostring(err))
|
||||
assert_true(recorded ~= nil, "plugin failed to load for URL overlay-start scenario: " .. tostring(err))
|
||||
fire_event(recorded, "file-loaded")
|
||||
assert_true(find_start_call(recorded.async_calls) ~= nil, "URL auto-start should still invoke --start command")
|
||||
assert_true(
|
||||
not has_async_curl_for(recorded.async_calls, "myanimelist.net/search/prefix.json"),
|
||||
"URL playback should skip AniSkip MAL lookup even after overlay-start"
|
||||
)
|
||||
assert_true(
|
||||
not has_async_curl_for(recorded.async_calls, "api.aniskip.com"),
|
||||
"URL playback should skip AniSkip API lookup even after overlay-start"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "no",
|
||||
aniskip_enabled = "yes",
|
||||
},
|
||||
media_title = "Sample Show S01E01",
|
||||
mal_lookup_stdout = "__MAL_FOUND__",
|
||||
aniskip_stdout = "__ANISKIP_FOUND__",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for script-message AniSkip scenario: " .. tostring(err))
|
||||
assert_true(recorded.script_messages["subminer-aniskip-refresh"] ~= nil, "subminer-aniskip-refresh script message not registered")
|
||||
recorded.script_messages["subminer-aniskip-refresh"]()
|
||||
assert_true(not has_sync_command(recorded.sync_calls, "curl"), "AniSkip refresh should not perform synchronous curl calls")
|
||||
assert_true(has_async_command(recorded.async_calls, "curl"), "AniSkip refresh should perform async curl calls")
|
||||
assert_true(
|
||||
has_async_curl_for(recorded.async_calls, "myanimelist.net/search/prefix.json"),
|
||||
"AniSkip refresh should perform MAL lookup even when app is not running"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "no",
|
||||
aniskip_enabled = "yes",
|
||||
},
|
||||
media_title = "Sample Show S01E01",
|
||||
time_pos = 13,
|
||||
mal_lookup_stdout = "__MAL_FOUND__",
|
||||
aniskip_stdout = "__ANISKIP_FOUND__",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for default AniSkip keybinding scenario: " .. tostring(err))
|
||||
assert_true(
|
||||
has_key_binding(recorded, "TAB", "subminer-skip-intro"),
|
||||
"default AniSkip keybinding should register TAB"
|
||||
)
|
||||
assert_true(
|
||||
not has_key_binding(recorded, "y-k", "subminer-skip-intro-fallback"),
|
||||
"default AniSkip keybinding should not also register legacy y-k fallback"
|
||||
)
|
||||
recorded.script_messages["subminer-aniskip-refresh"]()
|
||||
fire_observer(recorded, "time-pos", 13)
|
||||
assert_true(
|
||||
has_osd_message(recorded.osd, "You can skip by pressing TAB"),
|
||||
"default AniSkip prompt should mention TAB"
|
||||
not has_async_command(recorded.async_calls, "curl"),
|
||||
"URL playback should not trigger plugin-side network lookups"
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -156,6 +156,22 @@ export class AnkiConnectClient {
|
||||
return (result as number[]) || [];
|
||||
}
|
||||
|
||||
async findCards(query: string, options?: { maxRetries?: number }): Promise<number[]> {
|
||||
const result = await this.invoke('findCards', { query }, options);
|
||||
return (result as number[]) || [];
|
||||
}
|
||||
|
||||
async changeDeck(cardIds: number[], deckName: string): Promise<void> {
|
||||
if (cardIds.length === 0 || !deckName.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.invoke('changeDeck', {
|
||||
cards: cardIds,
|
||||
deck: deckName,
|
||||
});
|
||||
}
|
||||
|
||||
async deckNames(): Promise<string[]> {
|
||||
const result = await this.invoke('deckNames');
|
||||
return Array.isArray(result)
|
||||
|
||||
@@ -63,7 +63,7 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.ankiConnect.deck,
|
||||
description:
|
||||
'Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to search all decks.',
|
||||
'Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.fields.word',
|
||||
@@ -496,13 +496,13 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
path: 'mpv.aniskipEnabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.mpv.aniskipEnabled,
|
||||
description: 'Enable AniSkip intro detection and skip markers in the bundled mpv plugin.',
|
||||
description: 'Enable AniSkip intro detection, chapter markers, and the skip-intro key.',
|
||||
},
|
||||
{
|
||||
path: 'mpv.aniskipButtonKey',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.mpv.aniskipButtonKey,
|
||||
description: 'mpv key used to trigger the AniSkip button while the skip marker is visible.',
|
||||
description: 'mpv key used to skip the detected intro while the skip prompt is visible.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.enabled',
|
||||
|
||||
@@ -680,6 +680,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
path === 'ankiConnect.fields.miscInfo' ||
|
||||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
|
||||
path === 'ankiConnect.isKiku.fieldGrouping' ||
|
||||
path === 'mpv.aniskipEnabled' ||
|
||||
path === 'mpv.aniskipButtonKey' ||
|
||||
path === 'stats.toggleKey' ||
|
||||
path === 'stats.markWatchedKey' ||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,13 +34,13 @@ test('guessAnilistMediaInfo fills missing guessit episode from filename parser',
|
||||
});
|
||||
});
|
||||
|
||||
test('guessAnilistMediaInfo ignores low-confidence parser details when guessit omits them', async () => {
|
||||
test('guessAnilistMediaInfo keeps season directory scope when guessit omits details', async () => {
|
||||
const result = await guessAnilistMediaInfo('/tmp/Season 2/Guessit Title.mkv', null, {
|
||||
runGuessit: async () => JSON.stringify({ title: 'Guessit Title' }),
|
||||
});
|
||||
assert.deepEqual(result, {
|
||||
title: 'Guessit Title',
|
||||
season: null,
|
||||
season: 2,
|
||||
episode: null,
|
||||
source: 'guessit',
|
||||
});
|
||||
@@ -235,6 +235,86 @@ test('updateAnilistPostWatchProgress uses the configured AniList rate limiter',
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress marks the final season episode completed', 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: {
|
||||
Page: {
|
||||
media: [{ id: 12, episodes: 12, title: { english: 'Final Show' } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (call === 2) {
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Media: { id: 12, mediaListEntry: { progress: 11, status: 'CURRENT' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
assert.equal(body.variables?.progress, 12);
|
||||
assert.equal(body.variables?.status, 'COMPLETED');
|
||||
return createJsonResponse({
|
||||
data: { SaveMediaListEntry: { progress: 12, status: 'COMPLETED' } },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await updateAnilistPostWatchProgress('token', 'Final Show', 12);
|
||||
assert.equal(result.status, 'updated');
|
||||
assert.match(result.message, /completed/i);
|
||||
assert.equal(call, 3);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress marks an already watched final season episode completed', 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: {
|
||||
Page: {
|
||||
media: [{ id: 12, episodes: 12, title: { english: 'Final Show' } }],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (call === 2) {
|
||||
return createJsonResponse({
|
||||
data: {
|
||||
Media: { id: 12, mediaListEntry: { progress: 12, status: 'CURRENT' } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
assert.equal(body.variables?.progress, 12);
|
||||
assert.equal(body.variables?.status, 'COMPLETED');
|
||||
return createJsonResponse({
|
||||
data: { SaveMediaListEntry: { progress: 12, status: 'COMPLETED' } },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await updateAnilistPostWatchProgress('token', 'Final Show', 12);
|
||||
assert.equal(result.status, 'updated');
|
||||
assert.match(result.message, /completed/i);
|
||||
assert.equal(call, 3);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnilistPostWatchProgress skips when progress already reached', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let call = 0;
|
||||
|
||||
@@ -228,7 +228,7 @@ function pickBestSearchResult(
|
||||
native?: string | null;
|
||||
};
|
||||
}>,
|
||||
): { id: number; title: string } | null {
|
||||
): { id: number; title: string; episodes: number | null } | null {
|
||||
const filtered = media.filter((item) => {
|
||||
const totalEpisodes = item.episodes;
|
||||
return totalEpisodes === null || totalEpisodes >= episode;
|
||||
@@ -247,7 +247,7 @@ function pickBestSearchResult(
|
||||
const selected = exact ?? candidates[0]!;
|
||||
const selectedTitle =
|
||||
selected.title?.english || selected.title?.romaji || selected.title?.native || title;
|
||||
return { id: selected.id, title: selectedTitle };
|
||||
return { id: selected.id, title: selectedTitle, episodes: selected.episodes };
|
||||
}
|
||||
|
||||
function isUpdateableListStatus(status: string | null | undefined): boolean {
|
||||
@@ -259,6 +259,15 @@ function formatListStatus(status: string | null | undefined): string {
|
||||
return `marked ${status.toLowerCase().replace(/_/g, ' ')} on AniList`;
|
||||
}
|
||||
|
||||
function isKnownFinalEpisode(totalEpisodes: number | null, episode: number): boolean {
|
||||
return (
|
||||
typeof totalEpisodes === 'number' &&
|
||||
Number.isInteger(totalEpisodes) &&
|
||||
totalEpisodes > 0 &&
|
||||
episode === totalEpisodes
|
||||
);
|
||||
}
|
||||
|
||||
export async function guessAnilistMediaInfo(
|
||||
mediaPath: string | null,
|
||||
mediaTitle: string | null,
|
||||
@@ -283,7 +292,7 @@ export async function guessAnilistMediaInfo(
|
||||
title: buildGuessitTitle(title, alternativeTitle),
|
||||
...(alternativeTitle ? { alternativeTitle } : {}),
|
||||
...(year ? { year } : {}),
|
||||
season: season ?? (canUseFallbackDetails ? fallback.season : null),
|
||||
season: season ?? fallback.season,
|
||||
episode: episode ?? (canUseFallbackDetails ? fallback.episode : null),
|
||||
source: 'guessit',
|
||||
};
|
||||
@@ -394,7 +403,8 @@ export async function updateAnilistPostWatchProgress(
|
||||
}
|
||||
|
||||
const currentProgress = entry.progress ?? 0;
|
||||
if (typeof currentProgress === 'number' && currentProgress >= episode) {
|
||||
const shouldMarkCompleted = isKnownFinalEpisode(picked.episodes, episode);
|
||||
if (typeof currentProgress === 'number' && currentProgress >= episode && !shouldMarkCompleted) {
|
||||
return {
|
||||
status: 'skipped',
|
||||
message: `AniList already at episode ${currentProgress} (${picked.title}).`,
|
||||
@@ -404,14 +414,18 @@ export async function updateAnilistPostWatchProgress(
|
||||
const saveResponse = await anilistGraphQl<AnilistSaveEntryData>(
|
||||
accessToken,
|
||||
`
|
||||
mutation ($mediaId: Int!, $progress: Int!) {
|
||||
SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: CURRENT) {
|
||||
mutation ($mediaId: Int!, $progress: Int!, $status: MediaListStatus!) {
|
||||
SaveMediaListEntry(mediaId: $mediaId, progress: $progress, status: $status) {
|
||||
progress
|
||||
status
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ mediaId: picked.id, progress: episode },
|
||||
{
|
||||
mediaId: picked.id,
|
||||
progress: episode,
|
||||
status: shouldMarkCompleted ? 'COMPLETED' : 'CURRENT',
|
||||
},
|
||||
options,
|
||||
);
|
||||
const saveError = firstErrorMessage(saveResponse);
|
||||
@@ -421,6 +435,8 @@ export async function updateAnilistPostWatchProgress(
|
||||
|
||||
return {
|
||||
status: 'updated',
|
||||
message: `AniList updated "${picked.title}" to episode ${episode}.`,
|
||||
message: shouldMarkCompleted
|
||||
? `AniList updated "${picked.title}" to episode ${episode} and marked it completed.`
|
||||
: `AniList updated "${picked.title}" to episode ${episode}.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitle
|
||||
|
||||
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||
'secondarySub.defaultMode',
|
||||
'mpv.aniskipEnabled',
|
||||
'mpv.aniskipButtonKey',
|
||||
'ankiConnect.ai.enabled',
|
||||
'stats.toggleKey',
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from 'node:path';
|
||||
import { toMonthKey } from './immersion-tracker/maintenance';
|
||||
import { enqueueWrite } from './immersion-tracker/queue';
|
||||
import { toDbTimestamp } from './immersion-tracker/query-shared';
|
||||
import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair';
|
||||
import { Database, type DatabaseSync } from './immersion-tracker/sqlite';
|
||||
import { nowMs as trackerNowMs } from './immersion-tracker/time';
|
||||
import {
|
||||
@@ -1164,6 +1165,54 @@ test('recordSubtitleLine leaves session token counts at zero when tokenization i
|
||||
}
|
||||
});
|
||||
|
||||
test('recordSubtitleLine skips invalid cue timing and still stores the later valid cue', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
|
||||
tracker.handleMediaChange('/tmp/timing.mkv', 'Timing');
|
||||
tracker.recordSubtitleLine('same subtitle', 953.991, 953.891);
|
||||
tracker.recordSubtitleLine('same subtitle', 953.991, 956.56);
|
||||
|
||||
const privateApi = tracker as unknown as {
|
||||
flushTelemetry: (force?: boolean) => void;
|
||||
flushNow: () => void;
|
||||
};
|
||||
privateApi.flushTelemetry(true);
|
||||
privateApi.flushNow();
|
||||
|
||||
const db = new Database(dbPath);
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT line_index, segment_start_ms, segment_end_ms, text
|
||||
FROM imm_subtitle_lines
|
||||
ORDER BY line_id ASC`,
|
||||
)
|
||||
.all() as Array<{
|
||||
line_index: number;
|
||||
segment_start_ms: number | null;
|
||||
segment_end_ms: number | null;
|
||||
text: string;
|
||||
}>;
|
||||
db.close();
|
||||
|
||||
assert.deepEqual(rows, [
|
||||
{
|
||||
line_index: 1,
|
||||
segment_start_ms: 953991,
|
||||
segment_end_ms: 956560,
|
||||
text: 'same subtitle',
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('subtitle-line event payload omits duplicated subtitle text', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
@@ -1470,7 +1519,7 @@ test('handleMediaChange links parsed anime metadata on the active video row', as
|
||||
assert.equal(row?.parsed_season, 2);
|
||||
assert.equal(row?.parsed_episode, 5);
|
||||
assert.ok(row?.parser_source === 'guessit' || row?.parser_source === 'fallback');
|
||||
assert.equal(row?.anime_title, 'Little Witch Academia');
|
||||
assert.equal(row?.anime_title, 'Little Witch Academia Season 2');
|
||||
assert.equal(row?.anilist_id, null);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
@@ -1535,13 +1584,13 @@ test('handleMediaChange reuses the same provisional anime row across matching fi
|
||||
{
|
||||
sourcePath: '/tmp/Little Witch Academia S02E05.mkv',
|
||||
parsedEpisode: 5,
|
||||
animeTitle: 'Little Witch Academia',
|
||||
animeTitle: 'Little Witch Academia Season 2',
|
||||
anilistId: null,
|
||||
},
|
||||
{
|
||||
sourcePath: '/tmp/Little Witch Academia S02E06.mkv',
|
||||
parsedEpisode: 6,
|
||||
animeTitle: 'Little Witch Academia',
|
||||
animeTitle: 'Little Witch Academia Season 2',
|
||||
anilistId: null,
|
||||
},
|
||||
],
|
||||
@@ -1552,6 +1601,351 @@ test('handleMediaChange reuses the same provisional anime row across matching fi
|
||||
}
|
||||
});
|
||||
|
||||
test('handleMediaChange splits matching parsed titles across distinct seasons', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
|
||||
tracker.handleMediaChange('/tmp/KonoSuba/Season 1/KonoSuba S01E05.mkv', 'Episode 5');
|
||||
await waitForPendingAnimeMetadata(tracker);
|
||||
|
||||
tracker.handleMediaChange('/tmp/KonoSuba/Season 2/KonoSuba S02E05.mkv', 'Episode 5');
|
||||
await waitForPendingAnimeMetadata(tracker);
|
||||
|
||||
const privateApi = tracker as unknown as {
|
||||
db: DatabaseSync;
|
||||
};
|
||||
const rows = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
v.source_path,
|
||||
v.anime_id,
|
||||
v.parsed_season,
|
||||
a.canonical_title AS anime_title,
|
||||
a.normalized_title_key
|
||||
FROM imm_videos v
|
||||
LEFT JOIN imm_anime a ON a.anime_id = v.anime_id
|
||||
WHERE v.source_path IN (?, ?)
|
||||
ORDER BY v.source_path
|
||||
`,
|
||||
)
|
||||
.all(
|
||||
'/tmp/KonoSuba/Season 1/KonoSuba S01E05.mkv',
|
||||
'/tmp/KonoSuba/Season 2/KonoSuba S02E05.mkv',
|
||||
) as Array<{
|
||||
source_path: string | null;
|
||||
anime_id: number | null;
|
||||
parsed_season: number | null;
|
||||
anime_title: string | null;
|
||||
normalized_title_key: string | null;
|
||||
}>;
|
||||
|
||||
assert.equal(rows.length, 2);
|
||||
assert.ok(rows[0]?.anime_id);
|
||||
assert.ok(rows[1]?.anime_id);
|
||||
assert.notEqual(rows[0]?.anime_id, rows[1]?.anime_id);
|
||||
assert.deepEqual(
|
||||
rows.map((row) => ({
|
||||
sourcePath: row.source_path,
|
||||
parsedSeason: row.parsed_season,
|
||||
animeTitle: row.anime_title,
|
||||
normalizedTitleKey: row.normalized_title_key,
|
||||
})),
|
||||
[
|
||||
{
|
||||
sourcePath: '/tmp/KonoSuba/Season 1/KonoSuba S01E05.mkv',
|
||||
parsedSeason: 1,
|
||||
animeTitle: 'KonoSuba Season 1',
|
||||
normalizedTitleKey: 'konosuba season 1',
|
||||
},
|
||||
{
|
||||
sourcePath: '/tmp/KonoSuba/Season 2/KonoSuba S02E05.mkv',
|
||||
parsedSeason: 2,
|
||||
animeTitle: 'KonoSuba Season 2',
|
||||
normalizedTitleKey: 'konosuba season 2',
|
||||
},
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('startup redistributes legacy combined anime rows across parsed seasons', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||
|
||||
privateApi.db.exec(`
|
||||
INSERT INTO imm_anime (
|
||||
anime_id,
|
||||
normalized_title_key,
|
||||
canonical_title,
|
||||
anilist_id,
|
||||
title_romaji,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1,
|
||||
'frieren',
|
||||
'Frieren',
|
||||
154587,
|
||||
'Sousou no Frieren',
|
||||
1000,
|
||||
1000
|
||||
);
|
||||
|
||||
INSERT INTO imm_videos (
|
||||
video_id,
|
||||
video_key,
|
||||
canonical_title,
|
||||
anime_id,
|
||||
source_type,
|
||||
source_path,
|
||||
parsed_basename,
|
||||
parsed_title,
|
||||
parsed_season,
|
||||
parsed_episode,
|
||||
parser_source,
|
||||
parser_confidence,
|
||||
watched,
|
||||
duration_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(
|
||||
1,
|
||||
'local:/tmp/Frieren S01E01.mkv',
|
||||
'Frieren S01E01',
|
||||
1,
|
||||
1,
|
||||
'/tmp/Frieren S01E01.mkv',
|
||||
'Frieren S01E01.mkv',
|
||||
'Frieren',
|
||||
1,
|
||||
1,
|
||||
'fallback',
|
||||
0.9,
|
||||
1,
|
||||
0,
|
||||
1000,
|
||||
1000
|
||||
),
|
||||
(
|
||||
2,
|
||||
'local:/tmp/Frieren S02E01.mkv',
|
||||
'Frieren S02E01',
|
||||
1,
|
||||
1,
|
||||
'/tmp/Frieren S02E01.mkv',
|
||||
'Frieren S02E01.mkv',
|
||||
'Frieren',
|
||||
2,
|
||||
1,
|
||||
'fallback',
|
||||
0.9,
|
||||
1,
|
||||
0,
|
||||
1000,
|
||||
1000
|
||||
);
|
||||
|
||||
INSERT INTO imm_sessions (
|
||||
session_id,
|
||||
session_uuid,
|
||||
video_id,
|
||||
started_at_ms,
|
||||
ended_at_ms,
|
||||
status,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(1, 'season-repair-session-1', 1, 1000, 2000, 2, 1000, 2000),
|
||||
(2, 'season-repair-session-2', 2, 3000, 4000, 2, 3000, 4000);
|
||||
|
||||
INSERT INTO imm_session_telemetry (
|
||||
session_id,
|
||||
sample_ms,
|
||||
total_watched_ms,
|
||||
active_watched_ms,
|
||||
lines_seen,
|
||||
tokens_seen,
|
||||
cards_mined,
|
||||
lookup_count,
|
||||
lookup_hits,
|
||||
pause_count,
|
||||
pause_ms,
|
||||
seek_forward_count,
|
||||
seek_backward_count,
|
||||
media_buffer_events
|
||||
) VALUES
|
||||
(1, 2000, 1000, 1000, 1, 10, 1, 0, 0, 0, 0, 0, 0, 0),
|
||||
(2, 4000, 2000, 2000, 2, 20, 2, 0, 0, 0, 0, 0, 0, 0);
|
||||
`);
|
||||
|
||||
tracker.destroy();
|
||||
tracker = new Ctor({ dbPath });
|
||||
const repairedApi = tracker as unknown as { db: DatabaseSync };
|
||||
|
||||
const rows = repairedApi.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
a.canonical_title AS canonicalTitle,
|
||||
a.normalized_title_key AS normalizedTitleKey,
|
||||
a.anilist_id AS anilistId,
|
||||
COUNT(v.video_id) AS videoCount,
|
||||
COALESCE(lm.total_active_ms, 0) AS totalActiveMs
|
||||
FROM imm_anime a
|
||||
LEFT JOIN imm_videos v ON v.anime_id = a.anime_id
|
||||
LEFT JOIN imm_lifetime_anime lm ON lm.anime_id = a.anime_id
|
||||
GROUP BY a.anime_id
|
||||
ORDER BY a.canonical_title ASC
|
||||
`,
|
||||
)
|
||||
.all() as Array<{
|
||||
canonicalTitle: string;
|
||||
normalizedTitleKey: string;
|
||||
anilistId: number | null;
|
||||
videoCount: number;
|
||||
totalActiveMs: number;
|
||||
}>;
|
||||
|
||||
assert.deepEqual(rows, [
|
||||
{
|
||||
canonicalTitle: 'Frieren Season 1',
|
||||
normalizedTitleKey: 'frieren season 1',
|
||||
anilistId: 154587,
|
||||
videoCount: 1,
|
||||
totalActiveMs: 1000,
|
||||
},
|
||||
{
|
||||
canonicalTitle: 'Frieren Season 2',
|
||||
normalizedTitleKey: 'frieren season 2',
|
||||
anilistId: null,
|
||||
videoCount: 1,
|
||||
totalActiveMs: 2000,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('startup skips single-season anime rows during legacy season repair', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||
|
||||
privateApi.db.exec(`
|
||||
INSERT INTO imm_anime (
|
||||
anime_id,
|
||||
normalized_title_key,
|
||||
canonical_title,
|
||||
anilist_id,
|
||||
title_romaji,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1,
|
||||
'frieren',
|
||||
'Frieren',
|
||||
154587,
|
||||
'Sousou no Frieren',
|
||||
1000,
|
||||
1000
|
||||
);
|
||||
|
||||
INSERT INTO imm_videos (
|
||||
video_id,
|
||||
video_key,
|
||||
canonical_title,
|
||||
anime_id,
|
||||
source_type,
|
||||
source_path,
|
||||
parsed_basename,
|
||||
parsed_title,
|
||||
parsed_season,
|
||||
parsed_episode,
|
||||
parser_source,
|
||||
parser_confidence,
|
||||
watched,
|
||||
duration_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES (
|
||||
1,
|
||||
'local:/tmp/Frieren S01E01.mkv',
|
||||
'Frieren S01E01',
|
||||
1,
|
||||
1,
|
||||
'/tmp/Frieren S01E01.mkv',
|
||||
'Frieren S01E01.mkv',
|
||||
'Frieren',
|
||||
1,
|
||||
1,
|
||||
'fallback',
|
||||
0.9,
|
||||
1,
|
||||
0,
|
||||
1000,
|
||||
1000
|
||||
);
|
||||
`);
|
||||
|
||||
tracker.destroy();
|
||||
tracker = new Ctor({ dbPath });
|
||||
const repairedApi = tracker as unknown as { db: DatabaseSync };
|
||||
|
||||
const rows = repairedApi.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
a.canonical_title AS canonicalTitle,
|
||||
a.normalized_title_key AS normalizedTitleKey,
|
||||
a.anilist_id AS anilistId,
|
||||
COUNT(v.video_id) AS videoCount
|
||||
FROM imm_anime a
|
||||
LEFT JOIN imm_videos v ON v.anime_id = a.anime_id
|
||||
GROUP BY a.anime_id
|
||||
ORDER BY a.anime_id ASC
|
||||
`,
|
||||
)
|
||||
.all() as Array<{
|
||||
canonicalTitle: string;
|
||||
normalizedTitleKey: string;
|
||||
anilistId: number | null;
|
||||
videoCount: number;
|
||||
}>;
|
||||
|
||||
assert.deepEqual(rows, [
|
||||
{
|
||||
canonicalTitle: 'Frieren',
|
||||
normalizedTitleKey: 'frieren',
|
||||
anilistId: 154587,
|
||||
videoCount: 1,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('Jellyfin playback metadata links stream videos to existing series title', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
@@ -1595,8 +1989,41 @@ test('Jellyfin playback metadata links stream videos to existing series title',
|
||||
'http://jellyfin.local/Videos/item-2/stream?static=true&api_key=token&MediaSourceId=ms-1&StartTimeTicks=12000000',
|
||||
'The Beginning After the End S02E02 The Princess Begins Adventuring',
|
||||
);
|
||||
tracker.handleMediaChange(null, null);
|
||||
tracker.recordJellyfinPlaybackMetadata({
|
||||
mediaPath:
|
||||
'http://jellyfin.local/Videos/item-3/stream?static=true&api_key=token&MediaSourceId=ms-2',
|
||||
displayTitle: 'The Beginning After the End S02E03 Dragon Has Left the Building',
|
||||
itemTitle: 'Dragon Has Left the Building',
|
||||
seriesTitle: 'The Beginning After the End',
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 3,
|
||||
itemId: 'item-3',
|
||||
});
|
||||
tracker.handleMediaChange(
|
||||
'http://jellyfin.local/Videos/item-3/stream?static=true&api_key=token&MediaSourceId=ms-2&AudioStreamIndex=3&SubtitleStreamIndex=4',
|
||||
'The Beginning After the End S02E03 Dragon Has Left the Building',
|
||||
);
|
||||
await waitForPendingAnimeMetadata(tracker);
|
||||
|
||||
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||
const videoRows = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT source_url, canonical_title AS video_title
|
||||
FROM imm_videos
|
||||
ORDER BY video_id
|
||||
`,
|
||||
)
|
||||
.all() as Array<{ source_url: string | null; video_title: string }>;
|
||||
assert.equal(videoRows.length, 3);
|
||||
assert.equal(
|
||||
videoRows.some(
|
||||
(row) => row.source_url?.includes('api_key=') || row.video_title.includes('api_key='),
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
const rows = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
@@ -1623,7 +2050,7 @@ test('Jellyfin playback metadata links stream videos to existing series title',
|
||||
anime_title: string;
|
||||
}>;
|
||||
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows.length, 3);
|
||||
assert.equal(new Set(rows.map((row) => row.anime_title)).size, 1);
|
||||
const jellyfinRow = rows.find(
|
||||
(row) => row.source_url === 'jellyfin://jellyfin.local/item/item-2',
|
||||
@@ -1637,7 +2064,250 @@ test('Jellyfin playback metadata links stream videos to existing series title',
|
||||
assert.equal(jellyfinRow.parsed_season, 2);
|
||||
assert.equal(jellyfinRow.parsed_episode, 2);
|
||||
assert.equal(jellyfinRow.parser_source, 'jellyfin');
|
||||
assert.equal(jellyfinRow.anime_title, 'The Beginning After the End');
|
||||
assert.equal(jellyfinRow.anime_title, 'The Beginning After the End Season 2');
|
||||
const streamVariantRow = rows.find(
|
||||
(row) => row.source_url === 'jellyfin://jellyfin.local/item/item-3',
|
||||
);
|
||||
assert.ok(streamVariantRow);
|
||||
assert.equal(
|
||||
streamVariantRow.video_title,
|
||||
'The Beginning After the End S02E03 Dragon Has Left the Building',
|
||||
);
|
||||
assert.equal(streamVariantRow.source_url?.includes('api_key='), false);
|
||||
assert.equal(streamVariantRow.video_title.includes('api_key='), false);
|
||||
assert.equal(streamVariantRow.video_title.includes('stream?'), false);
|
||||
assert.equal(streamVariantRow.parsed_title, 'The Beginning After the End');
|
||||
assert.equal(streamVariantRow.parsed_season, 2);
|
||||
assert.equal(streamVariantRow.parsed_episode, 3);
|
||||
assert.equal(streamVariantRow.parser_source, 'jellyfin');
|
||||
assert.equal(streamVariantRow.anime_title, 'The Beginning After the End Season 2');
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('startup repairs existing Jellyfin stream video links to metadata rows', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
const streamUrl =
|
||||
'http://jellyfin.local/Videos/item-9/stream?static=true&api_key=secret-token&MediaSourceId=ms-1&AudioStreamIndex=3&SubtitleStreamIndex=4';
|
||||
tracker.handleMediaChange(
|
||||
streamUrl,
|
||||
'stream?static=true&api_key=secret-token&MediaSourceId=ms-1&AudioStreamIndex=3&SubtitleStreamIndex=4',
|
||||
);
|
||||
tracker.handleMediaChange(null, null);
|
||||
const titledStreamUrl =
|
||||
'http://jellyfin.local/Videos/item-10/stream?static=true&api_key=secret-token&MediaSourceId=ms-2';
|
||||
tracker.handleMediaChange(titledStreamUrl, 'KonoSuba S01E06 Decision! Class Rep');
|
||||
tracker.handleMediaChange(null, null);
|
||||
tracker.recordJellyfinPlaybackMetadata({
|
||||
mediaPath: 'http://jellyfin.local/Videos/item-9/stream?static=true&api_key=secret-token',
|
||||
displayTitle: 'Frieren S01E09 Aura the Guillotine',
|
||||
itemTitle: 'Aura the Guillotine',
|
||||
seriesTitle: 'Frieren',
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 9,
|
||||
itemId: 'item-9',
|
||||
});
|
||||
tracker.destroy();
|
||||
tracker = null;
|
||||
|
||||
tracker = new Ctor({ dbPath });
|
||||
|
||||
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||
const videoRows = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
video_id,
|
||||
video_key,
|
||||
source_url,
|
||||
canonical_title,
|
||||
parser_source,
|
||||
parsed_basename,
|
||||
parsed_title,
|
||||
parse_metadata_json
|
||||
FROM imm_videos
|
||||
ORDER BY video_id
|
||||
`,
|
||||
)
|
||||
.all() as Array<{
|
||||
video_id: number;
|
||||
video_key: string;
|
||||
source_url: string | null;
|
||||
canonical_title: string;
|
||||
parser_source: string | null;
|
||||
parsed_basename: string | null;
|
||||
parsed_title: string | null;
|
||||
parse_metadata_json: string | null;
|
||||
}>;
|
||||
assert.equal(videoRows.length, 3);
|
||||
const frierenRows = videoRows.filter(
|
||||
(row) => row.source_url === 'jellyfin://jellyfin.local/item/item-9',
|
||||
);
|
||||
assert.equal(frierenRows.length, 2);
|
||||
for (const row of frierenRows) {
|
||||
assert.equal(row.source_url, 'jellyfin://jellyfin.local/item/item-9');
|
||||
assert.equal(row.canonical_title, 'Frieren S01E09 Aura the Guillotine');
|
||||
assert.equal(row.parser_source, 'jellyfin');
|
||||
assert.equal(row.video_key.includes('api_key='), false);
|
||||
assert.equal(row.source_url?.includes('api_key='), false);
|
||||
assert.equal(row.canonical_title.includes('api_key='), false);
|
||||
}
|
||||
const titledRow = videoRows.find(
|
||||
(row) => row.source_url === 'jellyfin://jellyfin.local/item/item-10',
|
||||
);
|
||||
assert.ok(titledRow);
|
||||
assert.equal(titledRow.canonical_title, 'KonoSuba S01E06 Decision! Class Rep');
|
||||
assert.equal(titledRow.video_key.includes('api_key='), false);
|
||||
assert.equal(titledRow.source_url?.includes('api_key='), false);
|
||||
assert.equal(JSON.stringify(videoRows).includes('api_key='), false);
|
||||
assert.equal(JSON.stringify(videoRows).includes('secret-token'), false);
|
||||
|
||||
const animeRows = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT canonical_title, normalized_title_key
|
||||
FROM imm_anime
|
||||
ORDER BY anime_id
|
||||
`,
|
||||
)
|
||||
.all() as Array<{ canonical_title: string; normalized_title_key: string }>;
|
||||
assert.equal(JSON.stringify(animeRows).includes('api_key='), false);
|
||||
assert.equal(JSON.stringify(animeRows).includes('api key'), false);
|
||||
assert.equal(JSON.stringify(animeRows).includes('secret-token'), false);
|
||||
|
||||
const sessionRows = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT v.source_url, v.canonical_title
|
||||
FROM imm_sessions s
|
||||
JOIN imm_videos v ON v.video_id = s.video_id
|
||||
ORDER BY s.session_id
|
||||
`,
|
||||
)
|
||||
.all() as Array<{ source_url: string | null; canonical_title: string }>;
|
||||
assert.deepEqual(
|
||||
sessionRows.map((row) => row.canonical_title),
|
||||
['Frieren S01E09 Aura the Guillotine', 'KonoSuba S01E06 Decision! Class Rep'],
|
||||
);
|
||||
assert.equal(
|
||||
sessionRows.some((row) => row.source_url?.includes('api_key=')),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('Jellyfin link repair removes merged leaked anime rows and sanitizes orphan video titles', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||
const db = privateApi.db;
|
||||
const timestamp = toDbTimestamp(trackerNowMs());
|
||||
const leakedTitle =
|
||||
'http://jellyfin.local/Videos/item-20/stream?static=true&api_key=secret-token&MediaSourceId=ms-1';
|
||||
const orphanLeakedTitle =
|
||||
'http://jellyfin.local/Videos/item-21/stream?static=true&api_key=secret-token&MediaSourceId=ms-2&AudioStreamIndex=3';
|
||||
|
||||
const existingAnime = db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO imm_anime (
|
||||
normalized_title_key,
|
||||
canonical_title,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
)
|
||||
VALUES ('frieren', 'Frieren', ?, ?)
|
||||
RETURNING anime_id
|
||||
`,
|
||||
)
|
||||
.get(timestamp, timestamp) as { anime_id: number };
|
||||
const leakedAnime = db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO imm_anime (
|
||||
normalized_title_key,
|
||||
canonical_title,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
)
|
||||
VALUES ('http jellyfin local videos item 20 stream static true api key secret token mediasourceid ms 1', ?, ?, ?)
|
||||
RETURNING anime_id
|
||||
`,
|
||||
)
|
||||
.get(leakedTitle, timestamp, timestamp) as { anime_id: number };
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_videos (
|
||||
video_key,
|
||||
anime_id,
|
||||
canonical_title,
|
||||
source_type,
|
||||
source_url,
|
||||
duration_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
)
|
||||
VALUES (?, ?, 'Frieren', 2, ?, 0, ?, ?)
|
||||
`,
|
||||
).run(`remote:${leakedTitle}`, leakedAnime.anime_id, leakedTitle, timestamp, timestamp);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_videos (
|
||||
video_key,
|
||||
anime_id,
|
||||
canonical_title,
|
||||
source_type,
|
||||
source_url,
|
||||
duration_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
)
|
||||
VALUES (?, NULL, ?, 2, ?, 0, ?, ?)
|
||||
`,
|
||||
).run(
|
||||
`remote:${orphanLeakedTitle}`,
|
||||
orphanLeakedTitle,
|
||||
orphanLeakedTitle,
|
||||
timestamp,
|
||||
timestamp,
|
||||
);
|
||||
|
||||
const summary = repairJellyfinStreamVideoLinks(db);
|
||||
|
||||
assert.equal(summary.repaired, 3);
|
||||
const leakedAnimeRow = db
|
||||
.prepare('SELECT anime_id FROM imm_anime WHERE anime_id = ?')
|
||||
.get(leakedAnime.anime_id);
|
||||
assert.equal(leakedAnimeRow, undefined);
|
||||
const reparentedCount = db
|
||||
.prepare('SELECT COUNT(*) AS count FROM imm_videos WHERE anime_id = ?')
|
||||
.get(existingAnime.anime_id) as { count: number };
|
||||
assert.equal(reparentedCount.count, 1);
|
||||
const orphanVideo = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT canonical_title
|
||||
FROM imm_videos
|
||||
WHERE source_url = 'jellyfin://jellyfin.local/item/item-21'
|
||||
`,
|
||||
)
|
||||
.get() as { canonical_title: string };
|
||||
assert.equal(orphanVideo.canonical_title, 'Jellyfin Video');
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
@@ -2447,6 +3117,216 @@ test('reassignAnimeAnilist preserves existing description when description is om
|
||||
}
|
||||
});
|
||||
|
||||
test('reassignAnimeAnilist redistributes conflicting legacy combined row before assigning AniList id', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||
|
||||
privateApi.db.exec(`
|
||||
INSERT INTO imm_anime (
|
||||
anime_id,
|
||||
normalized_title_key,
|
||||
canonical_title,
|
||||
anilist_id,
|
||||
title_romaji,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(
|
||||
1,
|
||||
'konosuba',
|
||||
'KonoSuba',
|
||||
21202,
|
||||
'Kono Subarashii Sekai ni Shukufuku wo!',
|
||||
1000,
|
||||
1000
|
||||
),
|
||||
(
|
||||
2,
|
||||
'konosuba season 1',
|
||||
'KonoSuba Season 1',
|
||||
NULL,
|
||||
NULL,
|
||||
1000,
|
||||
1000
|
||||
);
|
||||
|
||||
INSERT INTO imm_videos (
|
||||
video_id,
|
||||
video_key,
|
||||
canonical_title,
|
||||
anime_id,
|
||||
source_type,
|
||||
source_path,
|
||||
parsed_basename,
|
||||
parsed_title,
|
||||
parsed_season,
|
||||
parsed_episode,
|
||||
parser_source,
|
||||
parser_confidence,
|
||||
watched,
|
||||
duration_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(
|
||||
1,
|
||||
'local:/tmp/KonoSuba S01E01.mkv',
|
||||
'KonoSuba S01E01',
|
||||
1,
|
||||
1,
|
||||
'/tmp/KonoSuba S01E01.mkv',
|
||||
'KonoSuba S01E01.mkv',
|
||||
'KonoSuba',
|
||||
1,
|
||||
1,
|
||||
'fallback',
|
||||
0.9,
|
||||
1,
|
||||
0,
|
||||
1000,
|
||||
1000
|
||||
),
|
||||
(
|
||||
2,
|
||||
'local:/tmp/KonoSuba S02E01.mkv',
|
||||
'KonoSuba S02E01',
|
||||
1,
|
||||
1,
|
||||
'/tmp/KonoSuba S02E01.mkv',
|
||||
'KonoSuba S02E01.mkv',
|
||||
'KonoSuba',
|
||||
2,
|
||||
1,
|
||||
'fallback',
|
||||
0.9,
|
||||
1,
|
||||
0,
|
||||
1000,
|
||||
1000
|
||||
),
|
||||
(
|
||||
3,
|
||||
'local:/tmp/KonoSuba S01E02.mkv',
|
||||
'KonoSuba S01E02',
|
||||
2,
|
||||
1,
|
||||
'/tmp/KonoSuba S01E02.mkv',
|
||||
'KonoSuba S01E02.mkv',
|
||||
'KonoSuba',
|
||||
1,
|
||||
2,
|
||||
'fallback',
|
||||
0.9,
|
||||
1,
|
||||
0,
|
||||
1000,
|
||||
1000
|
||||
);
|
||||
|
||||
INSERT INTO imm_sessions (
|
||||
session_id,
|
||||
session_uuid,
|
||||
video_id,
|
||||
started_at_ms,
|
||||
ended_at_ms,
|
||||
status,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(1, 'anilist-conflict-session-1', 1, 1000, 2000, 2, 1000, 2000),
|
||||
(2, 'anilist-conflict-session-2', 2, 3000, 4000, 2, 3000, 4000),
|
||||
(3, 'anilist-conflict-session-3', 3, 5000, 6000, 2, 5000, 6000);
|
||||
|
||||
INSERT INTO imm_subtitle_lines (
|
||||
session_id,
|
||||
video_id,
|
||||
anime_id,
|
||||
line_index,
|
||||
text,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
) VALUES
|
||||
(1, 1, 1, 0, 'season one legacy line', 1000, 1000),
|
||||
(2, 2, 1, 0, 'season two legacy line', 1000, 1000);
|
||||
|
||||
INSERT INTO imm_session_telemetry (
|
||||
session_id,
|
||||
sample_ms,
|
||||
total_watched_ms,
|
||||
active_watched_ms,
|
||||
lines_seen,
|
||||
tokens_seen,
|
||||
cards_mined,
|
||||
lookup_count,
|
||||
lookup_hits,
|
||||
pause_count,
|
||||
pause_ms,
|
||||
seek_forward_count,
|
||||
seek_backward_count,
|
||||
media_buffer_events
|
||||
) VALUES
|
||||
(1, 2000, 1000, 1000, 1, 10, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
(2, 4000, 2000, 2000, 2, 20, 0, 0, 0, 0, 0, 0, 0, 0),
|
||||
(3, 6000, 3000, 3000, 3, 30, 0, 0, 0, 0, 0, 0, 0, 0);
|
||||
`);
|
||||
|
||||
await tracker.reassignAnimeAnilist(2, {
|
||||
anilistId: 21202,
|
||||
titleRomaji: 'Kono Subarashii Sekai ni Shukufuku wo!',
|
||||
});
|
||||
|
||||
const rows = privateApi.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
a.canonical_title AS canonicalTitle,
|
||||
a.anilist_id AS anilistId,
|
||||
COUNT(DISTINCT v.video_id) AS videoCount,
|
||||
COUNT(DISTINCT sl.line_id) AS subtitleLineCount,
|
||||
COALESCE(lm.total_active_ms, 0) AS totalActiveMs
|
||||
FROM imm_anime a
|
||||
LEFT JOIN imm_videos v ON v.anime_id = a.anime_id
|
||||
LEFT JOIN imm_subtitle_lines sl ON sl.anime_id = a.anime_id
|
||||
LEFT JOIN imm_lifetime_anime lm ON lm.anime_id = a.anime_id
|
||||
GROUP BY a.anime_id
|
||||
ORDER BY a.canonical_title ASC
|
||||
`,
|
||||
)
|
||||
.all() as Array<{
|
||||
canonicalTitle: string;
|
||||
anilistId: number | null;
|
||||
videoCount: number;
|
||||
subtitleLineCount: number;
|
||||
totalActiveMs: number;
|
||||
}>;
|
||||
|
||||
assert.deepEqual(rows, [
|
||||
{
|
||||
canonicalTitle: 'KonoSuba Season 1',
|
||||
anilistId: 21202,
|
||||
videoCount: 2,
|
||||
subtitleLineCount: 1,
|
||||
totalActiveMs: 4000,
|
||||
},
|
||||
{
|
||||
canonicalTitle: 'KonoSuba Season 2',
|
||||
anilistId: null,
|
||||
videoCount: 1,
|
||||
subtitleLineCount: 1,
|
||||
totalActiveMs: 2000,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('handleMediaChange stores youtube metadata for new youtube sessions', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
getStatsExcludedWords,
|
||||
getVocabularyStats,
|
||||
replaceStatsExcludedWords,
|
||||
searchSubtitleSentences,
|
||||
getWordAnimeAppearances,
|
||||
getWordDetail,
|
||||
getWordOccurrences,
|
||||
@@ -89,6 +90,11 @@ import {
|
||||
markVideoWatched,
|
||||
upsertCoverArt,
|
||||
} from './immersion-tracker/query-maintenance';
|
||||
import { repairJellyfinStreamVideoLinks } from './immersion-tracker/jellyfin-link-repair';
|
||||
import {
|
||||
repairLegacySeasonlessAnimeRows,
|
||||
resolveAnimeAnilistConflict,
|
||||
} from './immersion-tracker/anime-season-repair';
|
||||
import {
|
||||
buildVideoKey,
|
||||
deriveCanonicalTitle,
|
||||
@@ -148,6 +154,8 @@ import {
|
||||
type MediaLibraryRow,
|
||||
type NewAnimePerDayRow,
|
||||
type QueuedWrite,
|
||||
type SentenceSearchOptions,
|
||||
type SentenceSearchResultRow,
|
||||
type SessionEventRow,
|
||||
type SessionState,
|
||||
type SessionSummaryQueryRow,
|
||||
@@ -328,6 +336,34 @@ function buildJellyfinStatsMediaPath(mediaPath: string, itemId: string): string
|
||||
}
|
||||
}
|
||||
|
||||
const JELLYFIN_MEDIA_ALIAS_QUERY_KEYS = [
|
||||
'api_key',
|
||||
'StartTimeTicks',
|
||||
'AudioStreamIndex',
|
||||
'SubtitleStreamIndex',
|
||||
];
|
||||
|
||||
function deleteSearchParamsCaseInsensitive(searchParams: URLSearchParams, names: string[]): void {
|
||||
const loweredNames = new Set(names.map((name) => name.toLowerCase()));
|
||||
for (const key of [...searchParams.keys()]) {
|
||||
if (loweredNames.has(key.toLowerCase())) {
|
||||
searchParams.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildJellyfinMediaPathAliasCandidates(mediaPath: string): string[] {
|
||||
const candidates = new Set<string>([mediaPath]);
|
||||
try {
|
||||
const parsed = new URL(mediaPath);
|
||||
deleteSearchParamsCaseInsensitive(parsed.searchParams, JELLYFIN_MEDIA_ALIAS_QUERY_KEYS);
|
||||
candidates.add(parsed.toString());
|
||||
} catch {
|
||||
// Non-URL fallback paths are already represented by the raw candidate.
|
||||
}
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
export class ImmersionTrackerService {
|
||||
private readonly logger = createLogger('main:immersion-tracker');
|
||||
private readonly db: DatabaseSync;
|
||||
@@ -437,6 +473,19 @@ export class ImmersionTrackerService {
|
||||
`Recovered stale active sessions on startup: reconciledSessions=${reconciledSessions}`,
|
||||
);
|
||||
}
|
||||
const jellyfinRepair = repairJellyfinStreamVideoLinks(this.db);
|
||||
if (jellyfinRepair.repaired > 0) {
|
||||
this.logger.info(
|
||||
`Repaired Jellyfin stats links on startup: scanned=${jellyfinRepair.scanned} repaired=${jellyfinRepair.repaired}`,
|
||||
);
|
||||
}
|
||||
const seasonRepair = repairLegacySeasonlessAnimeRows(this.db);
|
||||
if (seasonRepair.movedVideos > 0 || seasonRepair.deletedAnimeRows > 0) {
|
||||
this.logger.info(
|
||||
`Repaired season-scoped stats links on startup: scanned=${seasonRepair.scanned} movedVideos=${seasonRepair.movedVideos} deletedAnimeRows=${seasonRepair.deletedAnimeRows}`,
|
||||
);
|
||||
rebuildLifetimeSummaryTables(this.db);
|
||||
}
|
||||
if (shouldBackfillLifetimeSummaries(this.db)) {
|
||||
const result = rebuildLifetimeSummaryTables(this.db);
|
||||
if (result.appliedSessions > 0) {
|
||||
@@ -568,6 +617,14 @@ export class ImmersionTrackerService {
|
||||
return getKanjiOccurrences(this.db, kanji, limit, offset);
|
||||
}
|
||||
|
||||
async searchSubtitleSentences(
|
||||
query: string,
|
||||
limit = 50,
|
||||
options?: SentenceSearchOptions,
|
||||
): Promise<SentenceSearchResultRow[]> {
|
||||
return searchSubtitleSentences(this.db, query, limit, options);
|
||||
}
|
||||
|
||||
async getSessionEvents(
|
||||
sessionId: number,
|
||||
limit = 500,
|
||||
@@ -687,6 +744,7 @@ export class ImmersionTrackerService {
|
||||
coverUrl?: string | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
const repair = resolveAnimeAnilistConflict(this.db, animeId, info.anilistId);
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
@@ -712,6 +770,9 @@ export class ImmersionTrackerService {
|
||||
nowMs(),
|
||||
animeId,
|
||||
);
|
||||
if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) {
|
||||
rebuildLifetimeSummaryTables(this.db);
|
||||
}
|
||||
|
||||
// Update cover art for all videos in this anime
|
||||
if (info.coverUrl) {
|
||||
@@ -1149,7 +1210,9 @@ export class ImmersionTrackerService {
|
||||
return;
|
||||
}
|
||||
const normalizedPath = buildJellyfinStatsMediaPath(rawPath, metadata.itemId);
|
||||
this.mediaPathAliases.set(rawPath, normalizedPath);
|
||||
for (const alias of buildJellyfinMediaPathAliasCandidates(rawPath)) {
|
||||
this.mediaPathAliases.set(alias, normalizedPath);
|
||||
}
|
||||
|
||||
const displayTitle =
|
||||
normalizeText(metadata.displayTitle) ||
|
||||
@@ -1158,6 +1221,8 @@ export class ImmersionTrackerService {
|
||||
const itemTitle = normalizeText(metadata.itemTitle) || displayTitle;
|
||||
const seriesTitle = normalizeText(metadata.seriesTitle);
|
||||
const libraryTitle = seriesTitle || itemTitle;
|
||||
const seasonNumber = normalizeMetadataInt(metadata.seasonNumber);
|
||||
const episodeNumber = normalizeMetadataInt(metadata.episodeNumber);
|
||||
if (!libraryTitle) {
|
||||
return;
|
||||
}
|
||||
@@ -1181,12 +1246,13 @@ export class ImmersionTrackerService {
|
||||
itemTitle,
|
||||
seriesTitle: seriesTitle || null,
|
||||
displayTitle,
|
||||
seasonNumber: normalizeMetadataInt(metadata.seasonNumber),
|
||||
episodeNumber: normalizeMetadataInt(metadata.episodeNumber),
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
});
|
||||
const animeId = getOrCreateAnimeRecord(this.db, {
|
||||
parsedTitle: libraryTitle,
|
||||
canonicalTitle: libraryTitle,
|
||||
seasonScope: seasonNumber,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
@@ -1197,8 +1263,8 @@ export class ImmersionTrackerService {
|
||||
animeId,
|
||||
parsedBasename: null,
|
||||
parsedTitle: libraryTitle,
|
||||
parsedSeason: normalizeMetadataInt(metadata.seasonNumber),
|
||||
parsedEpisode: normalizeMetadataInt(metadata.episodeNumber),
|
||||
parsedSeason: seasonNumber,
|
||||
parsedEpisode: episodeNumber,
|
||||
parserSource: 'jellyfin',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: metadataJson,
|
||||
@@ -1221,7 +1287,10 @@ export class ImmersionTrackerService {
|
||||
|
||||
handleMediaChange(mediaPath: string | null, mediaTitle: string | null): void {
|
||||
const rawPath = normalizeMediaPath(mediaPath);
|
||||
const normalizedPath = this.mediaPathAliases.get(rawPath) ?? rawPath;
|
||||
const normalizedPath =
|
||||
buildJellyfinMediaPathAliasCandidates(rawPath)
|
||||
.map((alias) => this.mediaPathAliases.get(alias))
|
||||
.find((alias): alias is string => Boolean(alias)) ?? rawPath;
|
||||
const normalizedTitle = normalizeText(mediaTitle);
|
||||
this.logger.info(
|
||||
`handleMediaChange called with path=${normalizedPath || '<empty>'} title=${normalizedTitle || '<empty>'}`,
|
||||
@@ -1294,7 +1363,7 @@ export class ImmersionTrackerService {
|
||||
const cleaned = normalizeText(text);
|
||||
if (!cleaned) return;
|
||||
|
||||
if (!endSec || endSec <= 0) {
|
||||
if (!Number.isFinite(startSec) || !Number.isFinite(endSec) || endSec <= startSec) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1826,6 +1895,7 @@ export class ImmersionTrackerService {
|
||||
const animeId = getOrCreateAnimeRecord(this.db, {
|
||||
parsedTitle: parsed.parsedTitle,
|
||||
canonicalTitle: parsed.parsedTitle,
|
||||
seasonScope: parsed.parsedSeason,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
test('getRollupGroupsForSessions uses only localtime rollup keys', () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(process.cwd(), 'src/core/services/immersion-tracker/maintenance.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const start = source.indexOf('export function getRollupGroupsForSessions');
|
||||
const end = source.indexOf('export function refreshRollupsForGroupsInTransaction');
|
||||
const functionSource = source.slice(start, end);
|
||||
|
||||
assert.match(functionSource, /'unixepoch', 'localtime'/);
|
||||
assert.doesNotMatch(functionSource, /UNION/);
|
||||
assert.doesNotMatch(functionSource, /86400000/);
|
||||
});
|
||||
@@ -356,6 +356,81 @@ test('split session and lexical helpers return distinct-headword, detail, appear
|
||||
}
|
||||
});
|
||||
|
||||
test('similar words use same reading and shared kanji without kana suffix noise', () => {
|
||||
const { db, dbPath, stmts } = createDb();
|
||||
|
||||
try {
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'Similar Words Anime',
|
||||
canonicalTitle: 'Similar Words Anime',
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/similar-words.mkv', {
|
||||
canonicalTitle: 'Similar Words Episode',
|
||||
sourcePath: '/tmp/similar-words.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const sessionId = startSessionRecord(db, videoId, 1_000_000).sessionId;
|
||||
|
||||
const araiId = insertWordOccurrence(db, stmts, {
|
||||
sessionId,
|
||||
videoId,
|
||||
animeId,
|
||||
lineIndex: 1,
|
||||
text: '荒い息',
|
||||
word: { headword: '荒い', word: '荒い', reading: 'あらい' },
|
||||
});
|
||||
insertWordOccurrence(db, stmts, {
|
||||
sessionId,
|
||||
videoId,
|
||||
animeId,
|
||||
lineIndex: 2,
|
||||
text: '洗い物',
|
||||
word: { headword: '洗い', word: '洗い', reading: 'あらい' },
|
||||
});
|
||||
insertWordOccurrence(db, stmts, {
|
||||
sessionId,
|
||||
videoId,
|
||||
animeId,
|
||||
lineIndex: 3,
|
||||
text: '荒波',
|
||||
word: { headword: '荒波', word: '荒波', reading: 'あらなみ' },
|
||||
});
|
||||
|
||||
for (let lineIndex = 4; lineIndex < 9; lineIndex++) {
|
||||
insertWordOccurrence(db, stmts, {
|
||||
sessionId,
|
||||
videoId,
|
||||
animeId,
|
||||
lineIndex,
|
||||
text: '良い',
|
||||
word: { headword: '良い', word: '良い', reading: 'よい' },
|
||||
});
|
||||
}
|
||||
insertWordOccurrence(db, stmts, {
|
||||
sessionId,
|
||||
videoId,
|
||||
animeId,
|
||||
lineIndex: 9,
|
||||
text: 'お構いなく',
|
||||
word: { headword: 'お構いなく', word: 'お構いなく', reading: 'おかまいなく' },
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
getSimilarWords(db, araiId, 10).map((row) => row.headword),
|
||||
['洗い', '荒波'],
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('split library helpers return anime/media session and analytics rows', () => {
|
||||
const { db, dbPath, stmts } = createDb();
|
||||
|
||||
@@ -605,6 +680,189 @@ test('split maintenance helpers update anime metadata and watched state', () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('updateAnimeAnilistInfo redistributes legacy combined row before assigning duplicate AniList id', () => {
|
||||
const { db, dbPath } = createDb();
|
||||
|
||||
try {
|
||||
const legacyAnimeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'KonoSuba',
|
||||
canonicalTitle: 'KonoSuba',
|
||||
anilistId: 21202,
|
||||
titleRomaji: 'Kono Subarashii Sekai ni Shukufuku wo!',
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
const seasonAnimeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'KonoSuba',
|
||||
canonicalTitle: 'KonoSuba',
|
||||
seasonScope: 1,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
const legacySeasonOneVideoId = getOrCreateVideoRecord(db, 'local:/tmp/konosuba-s01e01.mkv', {
|
||||
canonicalTitle: 'KonoSuba S01E01',
|
||||
sourcePath: '/tmp/konosuba-s01e01.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const legacySeasonTwoVideoId = getOrCreateVideoRecord(db, 'local:/tmp/konosuba-s02e01.mkv', {
|
||||
canonicalTitle: 'KonoSuba S02E01',
|
||||
sourcePath: '/tmp/konosuba-s02e01.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const targetVideoId = getOrCreateVideoRecord(db, 'local:/tmp/konosuba-s01e02.mkv', {
|
||||
canonicalTitle: 'KonoSuba S01E02',
|
||||
sourcePath: '/tmp/konosuba-s01e02.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
|
||||
linkVideoToAnimeRecord(db, legacySeasonOneVideoId, {
|
||||
animeId: legacyAnimeId,
|
||||
parsedBasename: 'konosuba-s01e01.mkv',
|
||||
parsedTitle: 'KonoSuba',
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: 1,
|
||||
parserSource: 'test',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: null,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, legacySeasonTwoVideoId, {
|
||||
animeId: legacyAnimeId,
|
||||
parsedBasename: 'konosuba-s02e01.mkv',
|
||||
parsedTitle: 'KonoSuba',
|
||||
parsedSeason: 2,
|
||||
parsedEpisode: 1,
|
||||
parserSource: 'test',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: null,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, targetVideoId, {
|
||||
animeId: seasonAnimeId,
|
||||
parsedBasename: 'konosuba-s01e02.mkv',
|
||||
parsedTitle: 'KonoSuba',
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: 2,
|
||||
parserSource: 'test',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: null,
|
||||
});
|
||||
|
||||
updateAnimeAnilistInfo(db, targetVideoId, {
|
||||
anilistId: 21202,
|
||||
titleRomaji: 'Kono Subarashii Sekai ni Shukufuku wo!',
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
episodesTotal: 10,
|
||||
});
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
a.canonical_title AS canonicalTitle,
|
||||
a.anilist_id AS anilistId,
|
||||
COUNT(v.video_id) AS videoCount
|
||||
FROM imm_anime a
|
||||
LEFT JOIN imm_videos v ON v.anime_id = a.anime_id
|
||||
GROUP BY a.anime_id
|
||||
ORDER BY a.canonical_title ASC
|
||||
`,
|
||||
)
|
||||
.all() as Array<{
|
||||
canonicalTitle: string;
|
||||
anilistId: number | null;
|
||||
videoCount: number;
|
||||
}>;
|
||||
|
||||
assert.deepEqual(rows, [
|
||||
{ canonicalTitle: 'KonoSuba Season 1', anilistId: 21202, videoCount: 2 },
|
||||
{ canonicalTitle: 'KonoSuba Season 2', anilistId: null, videoCount: 1 },
|
||||
]);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('deleteSessions refreshes only rollups affected by deleted sessions', () => {
|
||||
const { db, dbPath } = createDb();
|
||||
|
||||
try {
|
||||
const keepVideoId = getOrCreateVideoRecord(db, 'local:/tmp/rollup-keep.mkv', {
|
||||
canonicalTitle: 'Rollup Keep',
|
||||
sourcePath: '/tmp/rollup-keep.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const dropVideoId = getOrCreateVideoRecord(db, 'local:/tmp/rollup-drop.mkv', {
|
||||
canonicalTitle: 'Rollup Drop',
|
||||
sourcePath: '/tmp/rollup-drop.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
|
||||
const keepStartedAtMs = 1_700_000_000_000;
|
||||
const dropStartedAtMs = 1_700_086_400_000;
|
||||
const keepSessionId = startSessionRecord(db, keepVideoId, keepStartedAtMs).sessionId;
|
||||
const dropSessionId = startSessionRecord(db, dropVideoId, dropStartedAtMs).sessionId;
|
||||
finalizeSessionMetrics(db, keepSessionId, keepStartedAtMs, {
|
||||
activeWatchedMs: 30_000,
|
||||
cardsMined: 1,
|
||||
});
|
||||
finalizeSessionMetrics(db, dropSessionId, dropStartedAtMs, {
|
||||
activeWatchedMs: 60_000,
|
||||
cardsMined: 2,
|
||||
});
|
||||
|
||||
const keepDay = getLocalEpochDay(db, keepStartedAtMs);
|
||||
const dropDay = getLocalEpochDay(db, dropStartedAtMs);
|
||||
const keepMonth = 202311;
|
||||
const dropMonth = 202311;
|
||||
|
||||
const insertDaily = db.prepare(`
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const insertMonthly = db.prepare(`
|
||||
INSERT INTO imm_monthly_rollups (
|
||||
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
insertDaily.run(keepDay, keepVideoId, 1, 0.5, 3, 6, 1, keepStartedAtMs, keepStartedAtMs);
|
||||
insertDaily.run(dropDay, dropVideoId, 1, 1, 3, 6, 2, dropStartedAtMs, dropStartedAtMs);
|
||||
insertMonthly.run(keepMonth, keepVideoId, 1, 0.5, 3, 6, 1, keepStartedAtMs, keepStartedAtMs);
|
||||
insertMonthly.run(dropMonth, dropVideoId, 1, 1, 3, 6, 2, dropStartedAtMs, dropStartedAtMs);
|
||||
|
||||
deleteSessions(db, [dropSessionId]);
|
||||
|
||||
const dailyRows = db
|
||||
.prepare('SELECT rollup_day, video_id, total_cards FROM imm_daily_rollups ORDER BY video_id')
|
||||
.all() as Array<{ rollup_day: number; video_id: number; total_cards: number }>;
|
||||
const monthlyRows = db
|
||||
.prepare(
|
||||
'SELECT rollup_month, video_id, total_cards FROM imm_monthly_rollups ORDER BY video_id',
|
||||
)
|
||||
.all() as Array<{ rollup_month: number; video_id: number; total_cards: number }>;
|
||||
|
||||
assert.deepEqual(dailyRows, [{ rollup_day: keepDay, video_id: keepVideoId, total_cards: 1 }]);
|
||||
assert.deepEqual(monthlyRows, [
|
||||
{ rollup_month: keepMonth, video_id: keepVideoId, total_cards: 1 },
|
||||
]);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('split maintenance helpers delete multiple sessions and whole videos with dependent rows', () => {
|
||||
const { db, dbPath, stmts } = createDb();
|
||||
|
||||
|
||||
@@ -35,9 +35,11 @@ import {
|
||||
getSessionTimeline,
|
||||
getSessionWordsByLine,
|
||||
getWordOccurrences,
|
||||
searchSubtitleSentences,
|
||||
upsertCoverArt,
|
||||
} from '../query.js';
|
||||
import {
|
||||
getLocalEpochDay,
|
||||
getShiftedLocalDaySec,
|
||||
getStartOfLocalDayTimestamp,
|
||||
toDbTimestamp,
|
||||
@@ -759,6 +761,10 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
||||
assert.equal(dashboard.progress.watchTime[1]?.value, 75);
|
||||
assert.equal(dashboard.progress.lookups[1]?.value, 18);
|
||||
assert.equal(dashboard.ratios.lookupsPerHundred[0]?.value, +((8 / 120) * 100).toFixed(1));
|
||||
assert.equal(dashboard.ratios.cardsPerHour[0]?.value, +(2 / (30 / 60)).toFixed(1));
|
||||
assert.equal(dashboard.ratios.cardsPerHour[1]?.value, +(3 / (45 / 60)).toFixed(1));
|
||||
assert.equal(dashboard.ratios.readingSpeed[0]?.value, +(120 / 30).toFixed(1));
|
||||
assert.equal(dashboard.ratios.readingSpeed[1]?.value, +(140 / 45).toFixed(1));
|
||||
assert.equal(dashboard.librarySummary[0]?.title, 'Trend Dashboard Anime');
|
||||
assert.equal(dashboard.animeCumulative.watchTime[1]?.value, 75);
|
||||
assert.equal(
|
||||
@@ -771,6 +777,84 @@ test('getTrendsDashboard returns chart-ready aggregated series', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('getTrendsDashboard redacts legacy Jellyfin stream titles', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const rawStreamTitle =
|
||||
'stream?static true&api key secret-token&MediaSourceId ms-1&AudioStreamIndex 3&SubtitleStreamIndex 4';
|
||||
const videoId = getOrCreateVideoRecord(
|
||||
db,
|
||||
'remote:http://jellyfin.local/Videos/item-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1&AudioStreamIndex=3&SubtitleStreamIndex=4',
|
||||
{
|
||||
canonicalTitle: rawStreamTitle,
|
||||
sourcePath: null,
|
||||
sourceUrl:
|
||||
'http://jellyfin.local/Videos/item-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1&AudioStreamIndex=3&SubtitleStreamIndex=4',
|
||||
sourceType: SOURCE_TYPE_REMOTE,
|
||||
},
|
||||
);
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: rawStreamTitle,
|
||||
canonicalTitle: rawStreamTitle,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, videoId, {
|
||||
animeId,
|
||||
parsedBasename:
|
||||
'stream?static=true&api_key=secret-token&MediaSourceId=ms-1&AudioStreamIndex=3&SubtitleStreamIndex=4',
|
||||
parsedTitle: rawStreamTitle,
|
||||
parsedSeason: null,
|
||||
parsedEpisode: null,
|
||||
parserSource: 'guessit',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: null,
|
||||
});
|
||||
const startedAtMs = 1_700_000_000_000;
|
||||
const session = startSessionRecord(db, videoId, startedAtMs);
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_sessions
|
||||
SET
|
||||
ended_at_ms = ?,
|
||||
total_watched_ms = ?,
|
||||
active_watched_ms = ?,
|
||||
tokens_seen = ?
|
||||
WHERE session_id = ?
|
||||
`,
|
||||
).run(`${startedAtMs + 30 * 60_000}`, 30 * 60_000, 30 * 60_000, 120, session.sessionId);
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
).run(Math.floor(startedAtMs / 86_400_000), videoId, 1, 30, 10, 120, 0);
|
||||
|
||||
const dashboard = getTrendsDashboard(db, 'all', 'day');
|
||||
const titles = [
|
||||
...dashboard.animeCumulative.watchTime.map((point) => point.animeTitle),
|
||||
...dashboard.librarySummary.map((row) => row.title),
|
||||
];
|
||||
|
||||
assert.deepEqual([...new Set(titles)], ['Jellyfin Video']);
|
||||
assert.equal(titles.some((title) => title.includes('api_key=')), false);
|
||||
assert.equal(titles.some((title) => title.includes('api key')), false);
|
||||
assert.equal(titles.some((title) => title.includes('secret-token')), false);
|
||||
assert.equal(titles.some((title) => title.includes('stream?')), false);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('getTrendsDashboard keeps local-midnight session buckets separate', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
@@ -3686,6 +3770,187 @@ test('getWordOccurrences maps a normalized word back to anime, video, and subtit
|
||||
}
|
||||
});
|
||||
|
||||
test('searchSubtitleSentences searches known subtitle lines and returns media context', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'Dungeon Meshi',
|
||||
canonicalTitle: 'Dungeon Meshi',
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: '{"source":"test"}',
|
||||
});
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/dungeon-meshi-01.mkv', {
|
||||
canonicalTitle: 'Episode 1',
|
||||
sourcePath: '/tmp/Dungeon Meshi 01.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: 'Dungeon Meshi 01.mkv',
|
||||
parsedTitle: 'Dungeon Meshi',
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: 1,
|
||||
parserSource: 'fallback',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: '{"episode":1}',
|
||||
});
|
||||
const { sessionId } = startSessionRecord(db, videoId, 3_000_000);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO imm_subtitle_lines (
|
||||
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms,
|
||||
text, secondary_text, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
sessionId,
|
||||
null,
|
||||
videoId,
|
||||
animeId,
|
||||
7,
|
||||
4_000,
|
||||
5_500,
|
||||
'魔物を食べるなんて信じられない',
|
||||
'I cannot believe we are eating monsters',
|
||||
3_000,
|
||||
3_000,
|
||||
);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_subtitle_lines (
|
||||
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms,
|
||||
text, secondary_text, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
sessionId,
|
||||
null,
|
||||
videoId,
|
||||
animeId,
|
||||
8,
|
||||
6_000,
|
||||
7_000,
|
||||
'これは別の行です',
|
||||
'Another line',
|
||||
2_000,
|
||||
2_000,
|
||||
);
|
||||
|
||||
const rows = searchSubtitleSentences(db, '魔物 食べる', 10);
|
||||
|
||||
assert.deepEqual(rows, [
|
||||
{
|
||||
animeId,
|
||||
animeTitle: 'Dungeon Meshi',
|
||||
sourcePath: '/tmp/Dungeon Meshi 01.mkv',
|
||||
secondaryText: 'I cannot believe we are eating monsters',
|
||||
videoId,
|
||||
videoTitle: 'Episode 1',
|
||||
sessionId,
|
||||
lineIndex: 7,
|
||||
segmentStartMs: 4_000,
|
||||
segmentEndMs: 5_500,
|
||||
text: '魔物を食べるなんて信じられない',
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepEqual(searchSubtitleSentences(db, 'monsters', 10), []);
|
||||
assert.doesNotThrow(() => searchSubtitleSentences(db, '魔物', Number.POSITIVE_INFINITY));
|
||||
assert.equal(searchSubtitleSentences(db, '魔物', -1).length, 1);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('searchSubtitleSentences searches subtitle lines by resolved headword candidates', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: 'Little Witch Academia',
|
||||
canonicalTitle: 'Little Witch Academia',
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: '{"source":"test"}',
|
||||
});
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/lwa-05.mkv', {
|
||||
canonicalTitle: 'Episode 5',
|
||||
sourcePath: '/tmp/Little Witch Academia S01E05.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
linkVideoToAnimeRecord(db, videoId, {
|
||||
animeId,
|
||||
parsedBasename: 'Little Witch Academia S01E05.mkv',
|
||||
parsedTitle: 'Little Witch Academia',
|
||||
parsedSeason: 1,
|
||||
parsedEpisode: 5,
|
||||
parserSource: 'fallback',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: '{"episode":5}',
|
||||
});
|
||||
const { sessionId } = startSessionRecord(db, videoId, 4_000_000);
|
||||
const lineResult = db
|
||||
.prepare(
|
||||
`INSERT INTO imm_subtitle_lines (
|
||||
session_id, event_id, video_id, anime_id, line_index, segment_start_ms, segment_end_ms,
|
||||
text, secondary_text, CREATED_DATE, LAST_UPDATE_DATE
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
sessionId,
|
||||
null,
|
||||
videoId,
|
||||
animeId,
|
||||
20,
|
||||
247_000,
|
||||
250_000,
|
||||
'ああ、名無しが何だか知らねえが',
|
||||
null,
|
||||
4_000,
|
||||
4_000,
|
||||
);
|
||||
const wordResult = db
|
||||
.prepare(
|
||||
`INSERT INTO imm_words (
|
||||
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run('知る', '知らねえ', 'しらねえ', 'verb', '動詞', '自立', '', 4_000, 4_000, 1);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count)
|
||||
VALUES (?, ?, ?)`,
|
||||
).run(Number(lineResult.lastInsertRowid), Number(wordResult.lastInsertRowid), 1);
|
||||
|
||||
assert.deepEqual(searchSubtitleSentences(db, '知らない', 10), []);
|
||||
|
||||
const rows = searchSubtitleSentences(db, '知らない', 10, {
|
||||
headwordTerms: [{ term: '知らない', headwords: ['知る'] }],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.text),
|
||||
['ああ、名無しが何だか知らねえが'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
searchSubtitleSentences(db, '知らねえ', 10).map((row) => row.text),
|
||||
['ああ、名無しが何だか知らねえが'],
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('getKanjiOccurrences maps a kanji back to anime, video, and subtitle line context', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
@@ -4100,8 +4365,14 @@ test('deleteSession removes zero-session media from library and trends', () => {
|
||||
|
||||
const startedAtMs = 9_000_000;
|
||||
const endedAtMs = startedAtMs + 120_000;
|
||||
const rollupDay = Math.floor(startedAtMs / 86_400_000);
|
||||
const rollupMonth = 197001;
|
||||
const rollupDay = getLocalEpochDay(db, startedAtMs);
|
||||
const rollupMonth = (
|
||||
db
|
||||
.prepare(
|
||||
"SELECT CAST(strftime('%Y%m', CAST(? AS REAL) / 1000, 'unixepoch', 'localtime') AS INTEGER) AS rollupMonth",
|
||||
)
|
||||
.get(startedAtMs) as { rollupMonth: number }
|
||||
).rollupMonth;
|
||||
const { sessionId } = startSessionRecord(db, videoId, startedAtMs);
|
||||
|
||||
db.prepare(
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { getOrCreateAnimeRecord } from './storage';
|
||||
import { toDbTimestamp } from './query-shared';
|
||||
import { nowMs } from './time';
|
||||
|
||||
export interface AnimeSeasonRepairSummary {
|
||||
scanned: number;
|
||||
repaired: number;
|
||||
movedVideos: number;
|
||||
deletedAnimeRows: number;
|
||||
}
|
||||
|
||||
interface AnimeRow {
|
||||
anime_id: number;
|
||||
anilist_id: number | null;
|
||||
title_romaji: string | null;
|
||||
title_english: string | null;
|
||||
title_native: string | null;
|
||||
episodes_total: number | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
interface ParsedVideoRow {
|
||||
video_id: number;
|
||||
parsed_title: string | null;
|
||||
parsed_season: number | null;
|
||||
}
|
||||
|
||||
interface RedistributeOptions {
|
||||
transferAnilistToAnimeId?: number | null;
|
||||
transferLegacyAnilist?: boolean;
|
||||
overwriteTargetAnilist?: boolean;
|
||||
}
|
||||
|
||||
function emptySummary(scanned = 0): AnimeSeasonRepairSummary {
|
||||
return {
|
||||
scanned,
|
||||
repaired: 0,
|
||||
movedVideos: 0,
|
||||
deletedAnimeRows: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeSummary(
|
||||
target: AnimeSeasonRepairSummary,
|
||||
source: AnimeSeasonRepairSummary,
|
||||
): AnimeSeasonRepairSummary {
|
||||
target.scanned += source.scanned;
|
||||
target.repaired += source.repaired;
|
||||
target.movedVideos += source.movedVideos;
|
||||
target.deletedAnimeRows += source.deletedAnimeRows;
|
||||
return target;
|
||||
}
|
||||
|
||||
function runInTransaction<T>(db: DatabaseSync, work: () => T): T {
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
const result = work();
|
||||
db.exec('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSeason(value: number | null): number | null {
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getAnimeRow(db: DatabaseSync, animeId: number): AnimeRow | null {
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
anime_id,
|
||||
anilist_id,
|
||||
title_romaji,
|
||||
title_english,
|
||||
title_native,
|
||||
episodes_total,
|
||||
description
|
||||
FROM imm_anime
|
||||
WHERE anime_id = ?
|
||||
`,
|
||||
)
|
||||
.get(animeId) as AnimeRow | null;
|
||||
}
|
||||
|
||||
function getParsedVideos(db: DatabaseSync, animeId: number): ParsedVideoRow[] {
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT video_id, parsed_title, parsed_season
|
||||
FROM imm_videos
|
||||
WHERE anime_id = ?
|
||||
ORDER BY video_id ASC
|
||||
`,
|
||||
)
|
||||
.all(animeId) as ParsedVideoRow[];
|
||||
}
|
||||
|
||||
function hasAnimeReferences(db: DatabaseSync, animeId: number): boolean {
|
||||
const row = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT 1 AS found
|
||||
WHERE EXISTS (SELECT 1 FROM imm_videos WHERE anime_id = ?)
|
||||
OR EXISTS (SELECT 1 FROM imm_subtitle_lines WHERE anime_id = ?)
|
||||
`,
|
||||
)
|
||||
.get(animeId, animeId) as { found: number } | null;
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
function assignAnilistToTarget(
|
||||
db: DatabaseSync,
|
||||
source: AnimeRow,
|
||||
targetAnimeId: number,
|
||||
overwriteTarget: boolean,
|
||||
updatedAt: string,
|
||||
): boolean {
|
||||
if (source.anilist_id === null || targetAnimeId === source.anime_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = getAnimeRow(db, targetAnimeId);
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
if (!overwriteTarget && target.anilist_id !== null && target.anilist_id !== source.anilist_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_anime
|
||||
SET anilist_id = NULL,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE anime_id = ?
|
||||
`,
|
||||
).run(updatedAt, source.anime_id);
|
||||
|
||||
const updated = db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE imm_anime
|
||||
SET
|
||||
anilist_id = ?,
|
||||
title_romaji = COALESCE(?, title_romaji),
|
||||
title_english = COALESCE(?, title_english),
|
||||
title_native = COALESCE(?, title_native),
|
||||
episodes_total = COALESCE(?, episodes_total),
|
||||
description = COALESCE(?, description),
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE anime_id = ?
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
source.anilist_id,
|
||||
source.title_romaji,
|
||||
source.title_english,
|
||||
source.title_native,
|
||||
source.episodes_total,
|
||||
source.description,
|
||||
updatedAt,
|
||||
targetAnimeId,
|
||||
) as { changes: number };
|
||||
return updated.changes > 0;
|
||||
}
|
||||
|
||||
function redistributeAnimeRowByParsedSeasonsInTransaction(
|
||||
db: DatabaseSync,
|
||||
animeId: number,
|
||||
options: RedistributeOptions = {},
|
||||
): AnimeSeasonRepairSummary {
|
||||
const source = getAnimeRow(db, animeId);
|
||||
if (!source) {
|
||||
return emptySummary(1);
|
||||
}
|
||||
|
||||
const videos = getParsedVideos(db, animeId);
|
||||
const summary = emptySummary(1);
|
||||
const updatedAt = toDbTimestamp(nowMs());
|
||||
const targetBySeason = new Map<number, number>();
|
||||
|
||||
for (const video of videos) {
|
||||
const parsedTitle = video.parsed_title?.trim();
|
||||
const season = normalizeSeason(video.parsed_season);
|
||||
if (!parsedTitle || season === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetAnimeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle,
|
||||
canonicalTitle: parsedTitle,
|
||||
seasonScope: season,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
titleNative: null,
|
||||
metadataJson: null,
|
||||
});
|
||||
targetBySeason.set(season, targetAnimeId);
|
||||
|
||||
if (targetAnimeId === animeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const videoUpdate = db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE imm_videos
|
||||
SET anime_id = ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE video_id = ?
|
||||
`,
|
||||
)
|
||||
.run(targetAnimeId, updatedAt, video.video_id) as { changes: number };
|
||||
const lineUpdate = db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE imm_subtitle_lines
|
||||
SET anime_id = ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE video_id = ?
|
||||
`,
|
||||
)
|
||||
.run(targetAnimeId, updatedAt, video.video_id) as { changes: number };
|
||||
|
||||
if (videoUpdate.changes > 0 || lineUpdate.changes > 0) {
|
||||
summary.movedVideos += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const transferTarget =
|
||||
options.transferAnilistToAnimeId ??
|
||||
(options.transferLegacyAnilist
|
||||
? (targetBySeason.get(1) ??
|
||||
(targetBySeason.size === 1 ? [...targetBySeason.values()][0] : null))
|
||||
: null);
|
||||
if (transferTarget) {
|
||||
const transferred = assignAnilistToTarget(
|
||||
db,
|
||||
source,
|
||||
transferTarget,
|
||||
options.overwriteTargetAnilist ?? false,
|
||||
updatedAt,
|
||||
);
|
||||
if (transferred) {
|
||||
summary.repaired += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAnimeReferences(db, animeId)) {
|
||||
const deleted = db.prepare('DELETE FROM imm_anime WHERE anime_id = ?').run(animeId) as {
|
||||
changes: number;
|
||||
};
|
||||
if (deleted.changes > 0) {
|
||||
summary.deletedAnimeRows += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (summary.movedVideos > 0 || summary.deletedAnimeRows > 0) {
|
||||
summary.repaired += 1;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function repairLegacySeasonlessAnimeRows(db: DatabaseSync): AnimeSeasonRepairSummary {
|
||||
return runInTransaction(db, () => {
|
||||
const candidates = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT a.anime_id AS animeId
|
||||
FROM imm_anime a
|
||||
JOIN imm_videos v ON v.anime_id = a.anime_id
|
||||
WHERE v.parsed_title IS NOT NULL
|
||||
AND TRIM(v.parsed_title) != ''
|
||||
AND v.parsed_season IS NOT NULL
|
||||
AND v.parsed_season > 0
|
||||
GROUP BY a.anime_id
|
||||
HAVING COUNT(DISTINCT v.parsed_season) > 1
|
||||
ORDER BY a.anime_id ASC
|
||||
`,
|
||||
)
|
||||
.all() as Array<{ animeId: number }>;
|
||||
const summary = emptySummary();
|
||||
for (const candidate of candidates) {
|
||||
mergeSummary(
|
||||
summary,
|
||||
redistributeAnimeRowByParsedSeasonsInTransaction(db, candidate.animeId, {
|
||||
transferLegacyAnilist: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return summary;
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveAnimeAnilistConflict(
|
||||
db: DatabaseSync,
|
||||
targetAnimeId: number,
|
||||
anilistId: number,
|
||||
): AnimeSeasonRepairSummary {
|
||||
const conflict = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT anime_id AS animeId
|
||||
FROM imm_anime
|
||||
WHERE anilist_id = ?
|
||||
AND anime_id != ?
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(anilistId, targetAnimeId) as { animeId: number } | null;
|
||||
if (!conflict) {
|
||||
return emptySummary();
|
||||
}
|
||||
|
||||
return runInTransaction(db, () =>
|
||||
redistributeAnimeRowByParsedSeasonsInTransaction(db, conflict.animeId, {
|
||||
transferAnilistToAnimeId: targetAnimeId,
|
||||
overwriteTargetAnilist: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { normalizeText } from './reducer';
|
||||
import { normalizeAnimeIdentityKey } from './storage';
|
||||
import { nowMs } from './time';
|
||||
import { toDbTimestamp } from './query-shared';
|
||||
import type { JellyfinLinkRepairSummary } from './types';
|
||||
|
||||
type LegacyJellyfinVideoRow = {
|
||||
video_id: number;
|
||||
video_key: string;
|
||||
source_url: string | null;
|
||||
canonical_title: string;
|
||||
};
|
||||
|
||||
type JellyfinTargetVideoRow = {
|
||||
video_id: number;
|
||||
anime_id: number | null;
|
||||
canonical_title: string;
|
||||
parsed_basename: string | null;
|
||||
parsed_title: string | null;
|
||||
parsed_season: number | null;
|
||||
parsed_episode: number | null;
|
||||
parser_source: string | null;
|
||||
parser_confidence: number | null;
|
||||
parse_metadata_json: string | null;
|
||||
};
|
||||
|
||||
type LeakedAnimeTitleRow = {
|
||||
anime_id: number;
|
||||
canonical_title: string;
|
||||
normalized_title_key: string;
|
||||
title_romaji: string | null;
|
||||
title_english: string | null;
|
||||
title_native: string | null;
|
||||
linked_video_title: string | null;
|
||||
};
|
||||
|
||||
function looksLikeLeakedJellyfinTitle(value: string | null): boolean {
|
||||
if (!value) return false;
|
||||
const lowered = value.toLowerCase();
|
||||
const hasApiKey = /api[\s_-]*key(?:\s|=|$)/i.test(value);
|
||||
return (
|
||||
hasApiKey &&
|
||||
(lowered.includes('stream?') ||
|
||||
lowered.includes('/stream?') ||
|
||||
lowered.includes('/videos/') ||
|
||||
lowered.includes('mediasourceid'))
|
||||
);
|
||||
}
|
||||
|
||||
function chooseSafeAnimeTitle(row: LeakedAnimeTitleRow): string | null {
|
||||
const candidates = [
|
||||
row.title_english,
|
||||
row.title_romaji,
|
||||
row.title_native,
|
||||
row.linked_video_title?.replace(/^\[Jellyfin\/direct]\s*/i, ''),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const normalized = candidate?.trim();
|
||||
if (normalized && !looksLikeLeakedJellyfinTitle(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseLegacyJellyfinStreamUrl(value: string | null): URL | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
const urlText = trimmed.startsWith('remote:') ? trimmed.slice('remote:'.length) : trimmed;
|
||||
try {
|
||||
const url = new URL(urlText);
|
||||
const pathSegments = url.pathname.split('/').filter(Boolean);
|
||||
const videosIndex = pathSegments.findIndex((segment) => segment.toLowerCase() === 'videos');
|
||||
if (
|
||||
videosIndex < 0 ||
|
||||
pathSegments[videosIndex + 1] === undefined ||
|
||||
pathSegments[videosIndex + 2]?.toLowerCase() !== 'stream'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (!url.searchParams.has('api_key')) {
|
||||
return null;
|
||||
}
|
||||
return url;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildJellyfinStatsUrlFromLegacyStream(url: URL): string | null {
|
||||
const pathSegments = url.pathname.split('/').filter(Boolean);
|
||||
const videosIndex = pathSegments.findIndex((segment) => segment.toLowerCase() === 'videos');
|
||||
const itemId = normalizeText(pathSegments[videosIndex + 1]);
|
||||
if (!itemId) return null;
|
||||
return `jellyfin://${url.host}/item/${encodeURIComponent(itemId)}`;
|
||||
}
|
||||
|
||||
function buildSanitizedJellyfinVideoKey(
|
||||
db: DatabaseSync,
|
||||
videoId: number,
|
||||
statsUrl: string,
|
||||
): string {
|
||||
const baseKey = `remote:${statsUrl}`;
|
||||
const existing = db
|
||||
.prepare('SELECT video_id FROM imm_videos WHERE video_key = ?')
|
||||
.get(baseKey) as { video_id: number } | null;
|
||||
if (!existing || existing.video_id === videoId) {
|
||||
return baseKey;
|
||||
}
|
||||
return `${baseKey}#legacy-${videoId}`;
|
||||
}
|
||||
|
||||
function repairLeakedJellyfinAnimeTitles(db: DatabaseSync, currentTimestamp: string): number {
|
||||
const candidates = (
|
||||
db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
a.anime_id,
|
||||
a.normalized_title_key,
|
||||
a.canonical_title,
|
||||
a.title_romaji,
|
||||
a.title_english,
|
||||
a.title_native,
|
||||
(
|
||||
SELECT v.canonical_title
|
||||
FROM imm_videos v
|
||||
WHERE v.anime_id = a.anime_id
|
||||
AND v.canonical_title NOT LIKE '%api_key=%'
|
||||
AND lower(v.canonical_title) NOT LIKE '%api key%'
|
||||
ORDER BY v.LAST_UPDATE_DATE DESC, v.video_id DESC
|
||||
LIMIT 1
|
||||
) AS linked_video_title
|
||||
FROM imm_anime a
|
||||
WHERE a.canonical_title LIKE '%api_key=%'
|
||||
OR lower(a.canonical_title) LIKE '%api key%'
|
||||
OR lower(a.normalized_title_key) LIKE '%api key%'
|
||||
`,
|
||||
)
|
||||
.all() as LeakedAnimeTitleRow[]
|
||||
).filter(
|
||||
(row) =>
|
||||
looksLikeLeakedJellyfinTitle(row.canonical_title) ||
|
||||
looksLikeLeakedJellyfinTitle(row.normalized_title_key),
|
||||
);
|
||||
|
||||
let repaired = 0;
|
||||
for (const candidate of candidates) {
|
||||
const replacementTitle = chooseSafeAnimeTitle(candidate);
|
||||
if (!replacementTitle) {
|
||||
continue;
|
||||
}
|
||||
const replacementKey = normalizeAnimeIdentityKey(replacementTitle);
|
||||
if (!replacementKey) {
|
||||
continue;
|
||||
}
|
||||
const existing = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT anime_id
|
||||
FROM imm_anime
|
||||
WHERE normalized_title_key = ?
|
||||
AND anime_id != ?
|
||||
`,
|
||||
)
|
||||
.get(replacementKey, candidate.anime_id) as { anime_id: number } | null;
|
||||
if (existing) {
|
||||
const videoUpdate = db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE imm_videos
|
||||
SET anime_id = ?, LAST_UPDATE_DATE = ?
|
||||
WHERE anime_id = ?
|
||||
`,
|
||||
)
|
||||
.run(existing.anime_id, currentTimestamp, candidate.anime_id) as { changes: number };
|
||||
const subtitleUpdate = db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE imm_subtitle_lines
|
||||
SET anime_id = ?, LAST_UPDATE_DATE = ?
|
||||
WHERE anime_id = ?
|
||||
`,
|
||||
)
|
||||
.run(existing.anime_id, currentTimestamp, candidate.anime_id) as { changes: number };
|
||||
const animeDelete = db
|
||||
.prepare(
|
||||
`
|
||||
DELETE FROM imm_anime
|
||||
WHERE anime_id = ?
|
||||
AND NOT EXISTS (SELECT 1 FROM imm_videos WHERE anime_id = ?)
|
||||
AND NOT EXISTS (SELECT 1 FROM imm_subtitle_lines WHERE anime_id = ?)
|
||||
`,
|
||||
)
|
||||
.run(candidate.anime_id, candidate.anime_id, candidate.anime_id) as { changes: number };
|
||||
if (videoUpdate.changes > 0 || subtitleUpdate.changes > 0) {
|
||||
repaired += 1;
|
||||
} else if (animeDelete.changes > 0) {
|
||||
repaired += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const updated = db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE imm_anime
|
||||
SET
|
||||
normalized_title_key = ?,
|
||||
canonical_title = ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE anime_id = ?
|
||||
`,
|
||||
)
|
||||
.run(replacementKey, replacementTitle, currentTimestamp, candidate.anime_id) as {
|
||||
changes: number;
|
||||
};
|
||||
if (updated.changes > 0) {
|
||||
repaired += 1;
|
||||
}
|
||||
}
|
||||
return repaired;
|
||||
}
|
||||
|
||||
function repairLeakedJellyfinVideoParseMetadata(
|
||||
db: DatabaseSync,
|
||||
currentTimestamp: string,
|
||||
): number {
|
||||
const updated = db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE imm_videos
|
||||
SET
|
||||
parsed_basename = NULL,
|
||||
parsed_title = NULL,
|
||||
parse_metadata_json = NULL,
|
||||
parser_source = CASE
|
||||
WHEN parser_source = 'guessit' THEN 'jellyfin'
|
||||
ELSE parser_source
|
||||
END,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE source_type = 2
|
||||
AND (
|
||||
parsed_basename LIKE '%api_key=%'
|
||||
OR lower(parsed_basename) LIKE '%api key%'
|
||||
OR parsed_title LIKE '%api_key=%'
|
||||
OR lower(parsed_title) LIKE '%api key%'
|
||||
OR parse_metadata_json LIKE '%api_key=%'
|
||||
OR lower(parse_metadata_json) LIKE '%api key%'
|
||||
)
|
||||
`,
|
||||
)
|
||||
.run(currentTimestamp) as { changes: number };
|
||||
return updated.changes;
|
||||
}
|
||||
|
||||
export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRepairSummary {
|
||||
const candidates = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT video_id, video_key, source_url, canonical_title
|
||||
FROM imm_videos
|
||||
WHERE source_type = 2
|
||||
AND (
|
||||
video_key LIKE '%api_key=%'
|
||||
OR lower(video_key) LIKE '%api key%'
|
||||
OR source_url LIKE '%api_key=%'
|
||||
OR lower(source_url) LIKE '%api key%'
|
||||
OR canonical_title LIKE '%api_key=%'
|
||||
OR lower(canonical_title) LIKE '%api key%'
|
||||
)
|
||||
`,
|
||||
)
|
||||
.all() as LegacyJellyfinVideoRow[];
|
||||
|
||||
const summary: JellyfinLinkRepairSummary = {
|
||||
scanned: candidates.length,
|
||||
repaired: 0,
|
||||
};
|
||||
if (candidates.length === 0) {
|
||||
const currentTimestamp = toDbTimestamp(nowMs());
|
||||
const repaired =
|
||||
repairLeakedJellyfinAnimeTitles(db, currentTimestamp) +
|
||||
repairLeakedJellyfinVideoParseMetadata(db, currentTimestamp);
|
||||
summary.repaired += repaired;
|
||||
return summary;
|
||||
}
|
||||
|
||||
const currentTimestamp = toDbTimestamp(nowMs());
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
for (const candidate of candidates) {
|
||||
const legacyUrl =
|
||||
parseLegacyJellyfinStreamUrl(candidate.source_url) ??
|
||||
parseLegacyJellyfinStreamUrl(candidate.video_key);
|
||||
if (!legacyUrl) {
|
||||
continue;
|
||||
}
|
||||
const statsUrl = buildJellyfinStatsUrlFromLegacyStream(legacyUrl);
|
||||
if (!statsUrl) {
|
||||
continue;
|
||||
}
|
||||
const sanitizedVideoKey = buildSanitizedJellyfinVideoKey(db, candidate.video_id, statsUrl);
|
||||
const sanitizedCanonicalTitle = looksLikeLeakedJellyfinTitle(candidate.canonical_title)
|
||||
? 'Jellyfin Video'
|
||||
: candidate.canonical_title;
|
||||
const target = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
video_id,
|
||||
anime_id,
|
||||
canonical_title,
|
||||
parsed_basename,
|
||||
parsed_title,
|
||||
parsed_season,
|
||||
parsed_episode,
|
||||
parser_source,
|
||||
parser_confidence,
|
||||
parse_metadata_json
|
||||
FROM imm_videos
|
||||
WHERE video_id != ?
|
||||
AND (video_key = ? OR source_url = ?)
|
||||
ORDER BY parser_source = 'jellyfin' DESC, video_id DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(candidate.video_id, `remote:${statsUrl}`, statsUrl) as JellyfinTargetVideoRow | null;
|
||||
if (!target) {
|
||||
const updated = db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE imm_videos
|
||||
SET
|
||||
video_key = ?,
|
||||
source_url = ?,
|
||||
canonical_title = ?,
|
||||
parser_source = COALESCE(parser_source, 'jellyfin'),
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE video_id = ?
|
||||
AND (video_key != ? OR source_url != ? OR canonical_title != ?)
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
sanitizedVideoKey,
|
||||
statsUrl,
|
||||
sanitizedCanonicalTitle,
|
||||
currentTimestamp,
|
||||
candidate.video_id,
|
||||
sanitizedVideoKey,
|
||||
statsUrl,
|
||||
sanitizedCanonicalTitle,
|
||||
) as { changes: number };
|
||||
if (updated.changes > 0) {
|
||||
summary.repaired += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_videos
|
||||
SET
|
||||
video_key = ?,
|
||||
anime_id = ?,
|
||||
canonical_title = ?,
|
||||
source_url = ?,
|
||||
parsed_basename = ?,
|
||||
parsed_title = ?,
|
||||
parsed_season = ?,
|
||||
parsed_episode = ?,
|
||||
parser_source = ?,
|
||||
parser_confidence = ?,
|
||||
parse_metadata_json = ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE video_id = ?
|
||||
`,
|
||||
).run(
|
||||
sanitizedVideoKey,
|
||||
target.anime_id,
|
||||
target.canonical_title,
|
||||
statsUrl,
|
||||
target.parsed_basename,
|
||||
target.parsed_title,
|
||||
target.parsed_season,
|
||||
target.parsed_episode,
|
||||
target.parser_source,
|
||||
target.parser_confidence,
|
||||
target.parse_metadata_json,
|
||||
currentTimestamp,
|
||||
candidate.video_id,
|
||||
);
|
||||
if (target.anime_id !== null) {
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_subtitle_lines
|
||||
SET anime_id = ?, LAST_UPDATE_DATE = ?
|
||||
WHERE video_id = ?
|
||||
`,
|
||||
).run(target.anime_id, currentTimestamp, candidate.video_id);
|
||||
}
|
||||
summary.repaired += 1;
|
||||
}
|
||||
summary.repaired += repairLeakedJellyfinAnimeTitles(db, currentTimestamp);
|
||||
summary.repaired += repairLeakedJellyfinVideoParseMetadata(db, currentTimestamp);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
@@ -60,6 +60,34 @@ interface RetainedSessionRow {
|
||||
mediaBufferEvents: number;
|
||||
}
|
||||
|
||||
const RETAINED_SESSION_METRICS_CTE = `
|
||||
retained_sessions AS (
|
||||
SELECT
|
||||
s.session_id,
|
||||
s.video_id,
|
||||
v.anime_id,
|
||||
s.started_at_ms,
|
||||
s.ended_at_ms,
|
||||
MAX(COALESCE(t.active_watched_ms, s.active_watched_ms, 0), 0) AS active_ms,
|
||||
MAX(COALESCE(t.cards_mined, s.cards_mined, 0), 0) AS cards_mined,
|
||||
MAX(COALESCE(t.lines_seen, s.lines_seen, 0), 0) AS lines_seen,
|
||||
MAX(COALESCE(t.tokens_seen, s.tokens_seen, 0), 0) AS tokens_seen,
|
||||
CASE WHEN v.watched > 0 THEN 1 ELSE 0 END AS completed
|
||||
FROM imm_sessions s
|
||||
JOIN imm_videos v
|
||||
ON v.video_id = s.video_id
|
||||
LEFT JOIN imm_session_telemetry t
|
||||
ON t.telemetry_id = (
|
||||
SELECT telemetry_id
|
||||
FROM imm_session_telemetry
|
||||
WHERE session_id = s.session_id
|
||||
ORDER BY sample_ms DESC, telemetry_id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE s.ended_at_ms IS NOT NULL
|
||||
)
|
||||
`;
|
||||
|
||||
function hasRetainedPriorSession(
|
||||
db: DatabaseSync,
|
||||
videoId: number,
|
||||
@@ -154,54 +182,150 @@ function rebuildLifetimeSummariesInternal(
|
||||
db: DatabaseSync,
|
||||
rebuiltAtMs: number,
|
||||
): LifetimeRebuildSummary {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
session_id AS sessionId,
|
||||
video_id AS videoId,
|
||||
started_at_ms AS startedAtMs,
|
||||
ended_at_ms AS endedAtMs,
|
||||
ended_media_ms AS lastMediaMs,
|
||||
total_watched_ms AS totalWatchedMs,
|
||||
active_watched_ms AS activeWatchedMs,
|
||||
lines_seen AS linesSeen,
|
||||
tokens_seen AS tokensSeen,
|
||||
cards_mined AS cardsMined,
|
||||
lookup_count AS lookupCount,
|
||||
lookup_hits AS lookupHits,
|
||||
yomitan_lookup_count AS yomitanLookupCount,
|
||||
pause_count AS pauseCount,
|
||||
pause_ms AS pauseMs,
|
||||
seek_forward_count AS seekForwardCount,
|
||||
seek_backward_count AS seekBackwardCount,
|
||||
media_buffer_events AS mediaBufferEvents
|
||||
FROM imm_sessions
|
||||
WHERE ended_at_ms IS NOT NULL
|
||||
ORDER BY started_at_ms ASC, session_id ASC
|
||||
`,
|
||||
)
|
||||
.all() as Array<
|
||||
Omit<RetainedSessionRow, 'startedAtMs' | 'endedAtMs' | 'lastMediaMs'> & {
|
||||
startedAtMs: number | string;
|
||||
endedAtMs: number | string;
|
||||
lastMediaMs: number | string | null;
|
||||
}
|
||||
>;
|
||||
const sessions = rows.map((row) => ({
|
||||
...row,
|
||||
startedAtMs: row.startedAtMs,
|
||||
endedAtMs: row.endedAtMs,
|
||||
lastMediaMs: row.lastMediaMs === null ? null : Number(row.lastMediaMs),
|
||||
})) as RetainedSessionRow[];
|
||||
const rebuiltAtDbMs = toDbTimestamp(rebuiltAtMs);
|
||||
const appliedSessions = Number(
|
||||
(
|
||||
db
|
||||
.prepare('SELECT COUNT(*) AS total FROM imm_sessions WHERE ended_at_ms IS NOT NULL')
|
||||
.get() as { total: number }
|
||||
).total,
|
||||
);
|
||||
|
||||
resetLifetimeSummaries(db, rebuiltAtMs);
|
||||
for (const session of sessions) {
|
||||
applySessionLifetimeSummary(db, toRebuildSessionState(session), session.endedAtMs);
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO imm_lifetime_applied_sessions (
|
||||
session_id,
|
||||
applied_at_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
)
|
||||
SELECT
|
||||
session_id,
|
||||
ended_at_ms,
|
||||
?,
|
||||
?
|
||||
FROM imm_sessions
|
||||
WHERE ended_at_ms IS NOT NULL
|
||||
`,
|
||||
).run(rebuiltAtDbMs, rebuiltAtDbMs);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
WITH ${RETAINED_SESSION_METRICS_CTE}
|
||||
INSERT INTO imm_lifetime_media (
|
||||
video_id,
|
||||
total_sessions,
|
||||
total_active_ms,
|
||||
total_cards,
|
||||
total_lines_seen,
|
||||
total_tokens_seen,
|
||||
completed,
|
||||
first_watched_ms,
|
||||
last_watched_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
)
|
||||
SELECT
|
||||
video_id,
|
||||
COUNT(*) AS total_sessions,
|
||||
COALESCE(SUM(active_ms), 0) AS total_active_ms,
|
||||
COALESCE(SUM(cards_mined), 0) AS total_cards,
|
||||
COALESCE(SUM(lines_seen), 0) AS total_lines_seen,
|
||||
COALESCE(SUM(tokens_seen), 0) AS total_tokens_seen,
|
||||
MAX(completed) AS completed,
|
||||
MIN(started_at_ms) AS first_watched_ms,
|
||||
MAX(ended_at_ms) AS last_watched_ms,
|
||||
? AS CREATED_DATE,
|
||||
? AS LAST_UPDATE_DATE
|
||||
FROM retained_sessions
|
||||
GROUP BY video_id
|
||||
`,
|
||||
).run(rebuiltAtDbMs, rebuiltAtDbMs);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
WITH ${RETAINED_SESSION_METRICS_CTE}
|
||||
INSERT INTO imm_lifetime_anime (
|
||||
anime_id,
|
||||
total_sessions,
|
||||
total_active_ms,
|
||||
total_cards,
|
||||
total_lines_seen,
|
||||
total_tokens_seen,
|
||||
episodes_started,
|
||||
episodes_completed,
|
||||
first_watched_ms,
|
||||
last_watched_ms,
|
||||
CREATED_DATE,
|
||||
LAST_UPDATE_DATE
|
||||
)
|
||||
SELECT
|
||||
anime_id,
|
||||
COUNT(*) AS total_sessions,
|
||||
COALESCE(SUM(active_ms), 0) AS total_active_ms,
|
||||
COALESCE(SUM(cards_mined), 0) AS total_cards,
|
||||
COALESCE(SUM(lines_seen), 0) AS total_lines_seen,
|
||||
COALESCE(SUM(tokens_seen), 0) AS total_tokens_seen,
|
||||
COUNT(DISTINCT video_id) AS episodes_started,
|
||||
COUNT(DISTINCT CASE WHEN completed > 0 THEN video_id END) AS episodes_completed,
|
||||
MIN(started_at_ms) AS first_watched_ms,
|
||||
MAX(ended_at_ms) AS last_watched_ms,
|
||||
? AS CREATED_DATE,
|
||||
? AS LAST_UPDATE_DATE
|
||||
FROM retained_sessions
|
||||
WHERE anime_id IS NOT NULL
|
||||
GROUP BY anime_id
|
||||
`,
|
||||
).run(rebuiltAtDbMs, rebuiltAtDbMs);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
WITH ${RETAINED_SESSION_METRICS_CTE},
|
||||
anime_completion AS (
|
||||
SELECT
|
||||
rs.anime_id,
|
||||
MAX(a.episodes_total) AS episodes_total,
|
||||
COUNT(DISTINCT CASE WHEN rs.completed > 0 THEN rs.video_id END) AS completed_videos
|
||||
FROM retained_sessions rs
|
||||
JOIN imm_anime a
|
||||
ON a.anime_id = rs.anime_id
|
||||
WHERE rs.anime_id IS NOT NULL
|
||||
GROUP BY rs.anime_id
|
||||
)
|
||||
UPDATE imm_lifetime_global
|
||||
SET
|
||||
total_sessions = (SELECT COUNT(*) FROM retained_sessions),
|
||||
total_active_ms = (SELECT COALESCE(SUM(active_ms), 0) FROM retained_sessions),
|
||||
total_cards = (SELECT COALESCE(SUM(cards_mined), 0) FROM retained_sessions),
|
||||
active_days = (
|
||||
SELECT COUNT(DISTINCT CAST(
|
||||
julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5
|
||||
AS INTEGER
|
||||
))
|
||||
FROM retained_sessions
|
||||
),
|
||||
episodes_started = (SELECT COUNT(DISTINCT video_id) FROM retained_sessions),
|
||||
episodes_completed = (
|
||||
SELECT COUNT(DISTINCT CASE WHEN completed > 0 THEN video_id END)
|
||||
FROM retained_sessions
|
||||
),
|
||||
anime_completed = (
|
||||
SELECT COUNT(*)
|
||||
FROM anime_completion
|
||||
WHERE episodes_total IS NOT NULL
|
||||
AND episodes_total > 0
|
||||
AND completed_videos >= episodes_total
|
||||
),
|
||||
last_rebuilt_ms = ?,
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE global_id = 1
|
||||
`,
|
||||
).run(rebuiltAtDbMs, rebuiltAtDbMs);
|
||||
|
||||
return {
|
||||
appliedSessions: sessions.length,
|
||||
appliedSessions,
|
||||
rebuiltAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { nowMs } from './time';
|
||||
import { subtractDbTimestamp, toDbTimestamp } from './query-shared';
|
||||
import { makePlaceholders, subtractDbTimestamp, toDbTimestamp } from './query-shared';
|
||||
|
||||
const ROLLUP_STATE_KEY = 'last_rollup_sample_ms';
|
||||
const DAILY_MS = 86_400_000;
|
||||
@@ -20,6 +20,12 @@ interface RollupTelemetryResult {
|
||||
maxSampleMs: number | null;
|
||||
}
|
||||
|
||||
export interface RollupGroup {
|
||||
rollupDay: number;
|
||||
rollupMonth: number;
|
||||
videoId: number;
|
||||
}
|
||||
|
||||
interface RawRetentionResult {
|
||||
deletedSessionEvents: number;
|
||||
deletedTelemetryRows: number;
|
||||
@@ -164,6 +170,26 @@ function upsertDailyRollupsForGroups(
|
||||
}
|
||||
|
||||
const upsertStmt = db.prepare(`
|
||||
WITH matching_sessions AS (
|
||||
SELECT *
|
||||
FROM imm_sessions
|
||||
WHERE CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) = ?
|
||||
AND video_id = ?
|
||||
),
|
||||
session_metrics AS (
|
||||
SELECT
|
||||
t.session_id,
|
||||
MAX(t.active_watched_ms) AS max_active_ms,
|
||||
MAX(t.lines_seen) AS max_lines,
|
||||
MAX(t.tokens_seen) AS max_tokens,
|
||||
MAX(t.cards_mined) AS max_cards,
|
||||
MAX(t.lookup_count) AS max_lookups,
|
||||
MAX(t.lookup_hits) AS max_hits
|
||||
FROM imm_session_telemetry t
|
||||
JOIN matching_sessions s
|
||||
ON s.session_id = t.session_id
|
||||
GROUP BY t.session_id
|
||||
)
|
||||
INSERT INTO imm_daily_rollups (
|
||||
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, cards_per_hour,
|
||||
@@ -197,20 +223,8 @@ function upsertDailyRollupsForGroups(
|
||||
END AS lookup_hit_rate,
|
||||
? AS CREATED_DATE,
|
||||
? AS LAST_UPDATE_DATE
|
||||
FROM imm_sessions s
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
t.session_id,
|
||||
MAX(t.active_watched_ms) AS max_active_ms,
|
||||
MAX(t.lines_seen) AS max_lines,
|
||||
MAX(t.tokens_seen) AS max_tokens,
|
||||
MAX(t.cards_mined) AS max_cards,
|
||||
MAX(t.lookup_count) AS max_lookups,
|
||||
MAX(t.lookup_hits) AS max_hits
|
||||
FROM imm_session_telemetry t
|
||||
GROUP BY t.session_id
|
||||
) sm ON s.session_id = sm.session_id
|
||||
WHERE CAST(julianday(s.started_at_ms / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) = ? AND s.video_id = ?
|
||||
FROM matching_sessions s
|
||||
LEFT JOIN session_metrics sm ON s.session_id = sm.session_id
|
||||
GROUP BY rollup_day, s.video_id
|
||||
ON CONFLICT (rollup_day, video_id) DO UPDATE SET
|
||||
total_sessions = excluded.total_sessions,
|
||||
@@ -226,7 +240,7 @@ function upsertDailyRollupsForGroups(
|
||||
`);
|
||||
|
||||
for (const { rollupDay, videoId } of groups) {
|
||||
upsertStmt.run(rollupNowMs, rollupNowMs, rollupDay, videoId);
|
||||
upsertStmt.run(rollupDay, videoId, rollupNowMs, rollupNowMs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +254,24 @@ function upsertMonthlyRollupsForGroups(
|
||||
}
|
||||
|
||||
const upsertStmt = db.prepare(`
|
||||
WITH matching_sessions AS (
|
||||
SELECT *
|
||||
FROM imm_sessions
|
||||
WHERE CAST(strftime('%Y%m', CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') AS INTEGER) = ?
|
||||
AND video_id = ?
|
||||
),
|
||||
session_metrics AS (
|
||||
SELECT
|
||||
t.session_id,
|
||||
MAX(t.active_watched_ms) AS max_active_ms,
|
||||
MAX(t.lines_seen) AS max_lines,
|
||||
MAX(t.tokens_seen) AS max_tokens,
|
||||
MAX(t.cards_mined) AS max_cards
|
||||
FROM imm_session_telemetry t
|
||||
JOIN matching_sessions s
|
||||
ON s.session_id = t.session_id
|
||||
GROUP BY t.session_id
|
||||
)
|
||||
INSERT INTO imm_monthly_rollups (
|
||||
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
|
||||
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
|
||||
@@ -254,18 +286,8 @@ function upsertMonthlyRollupsForGroups(
|
||||
COALESCE(SUM(COALESCE(sm.max_cards, s.cards_mined)), 0) AS total_cards,
|
||||
? AS CREATED_DATE,
|
||||
? AS LAST_UPDATE_DATE
|
||||
FROM imm_sessions s
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
t.session_id,
|
||||
MAX(t.active_watched_ms) AS max_active_ms,
|
||||
MAX(t.lines_seen) AS max_lines,
|
||||
MAX(t.tokens_seen) AS max_tokens,
|
||||
MAX(t.cards_mined) AS max_cards
|
||||
FROM imm_session_telemetry t
|
||||
GROUP BY t.session_id
|
||||
) sm ON s.session_id = sm.session_id
|
||||
WHERE CAST(strftime('%Y%m', s.started_at_ms / 1000, 'unixepoch', 'localtime') AS INTEGER) = ? AND s.video_id = ?
|
||||
FROM matching_sessions s
|
||||
LEFT JOIN session_metrics sm ON s.session_id = sm.session_id
|
||||
GROUP BY rollup_month, s.video_id
|
||||
ON CONFLICT (rollup_month, video_id) DO UPDATE SET
|
||||
total_sessions = excluded.total_sessions,
|
||||
@@ -278,10 +300,75 @@ function upsertMonthlyRollupsForGroups(
|
||||
`);
|
||||
|
||||
for (const { rollupMonth, videoId } of groups) {
|
||||
upsertStmt.run(rollupNowMs, rollupNowMs, rollupMonth, videoId);
|
||||
upsertStmt.run(rollupMonth, videoId, rollupNowMs, rollupNowMs);
|
||||
}
|
||||
}
|
||||
|
||||
export function getRollupGroupsForSessions(db: DatabaseSync, sessionIds: number[]): RollupGroup[] {
|
||||
if (sessionIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const placeholders = makePlaceholders(sessionIds);
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT DISTINCT
|
||||
CAST(julianday(CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') - 2440587.5 AS INTEGER) AS rollup_day,
|
||||
CAST(strftime('%Y%m', CAST(started_at_ms AS REAL) / 1000, 'unixepoch', 'localtime') AS INTEGER) AS rollup_month,
|
||||
video_id
|
||||
FROM imm_sessions
|
||||
WHERE session_id IN (${placeholders})
|
||||
`,
|
||||
)
|
||||
.all(...sessionIds) as RollupGroupRow[];
|
||||
|
||||
return rows.map((row) => ({
|
||||
rollupDay: row.rollup_day,
|
||||
rollupMonth: row.rollup_month,
|
||||
videoId: row.video_id,
|
||||
}));
|
||||
}
|
||||
|
||||
export function refreshRollupsForGroupsInTransaction(
|
||||
db: DatabaseSync,
|
||||
groups: RollupGroup[],
|
||||
): void {
|
||||
if (groups.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rollupNowMs = toDbTimestamp(nowMs());
|
||||
const dailyGroups = dedupeGroups(
|
||||
groups.map((group) => ({
|
||||
rollupDay: group.rollupDay,
|
||||
videoId: group.videoId,
|
||||
})),
|
||||
);
|
||||
const monthlyGroups = dedupeGroups(
|
||||
groups.map((group) => ({
|
||||
rollupMonth: group.rollupMonth,
|
||||
videoId: group.videoId,
|
||||
})),
|
||||
);
|
||||
const deleteDailyStmt = db.prepare(
|
||||
'DELETE FROM imm_daily_rollups WHERE rollup_day = ? AND video_id = ?',
|
||||
);
|
||||
const deleteMonthlyStmt = db.prepare(
|
||||
'DELETE FROM imm_monthly_rollups WHERE rollup_month = ? AND video_id = ?',
|
||||
);
|
||||
|
||||
for (const { rollupDay, videoId } of dailyGroups) {
|
||||
deleteDailyStmt.run(rollupDay, videoId);
|
||||
}
|
||||
for (const { rollupMonth, videoId } of monthlyGroups) {
|
||||
deleteMonthlyStmt.run(rollupMonth, videoId);
|
||||
}
|
||||
|
||||
upsertDailyRollupsForGroups(db, dailyGroups, rollupNowMs);
|
||||
upsertMonthlyRollupsForGroups(db, monthlyGroups, rollupNowMs);
|
||||
}
|
||||
|
||||
function getAffectedRollupGroups(
|
||||
db: DatabaseSync,
|
||||
lastRollupSampleMs: number | string,
|
||||
|
||||
@@ -179,6 +179,32 @@ test('guessAnimeVideoMetadata uses guessit basename output first when available'
|
||||
});
|
||||
});
|
||||
|
||||
test('guessAnimeVideoMetadata keeps season directory scope when guessit omits season', async () => {
|
||||
const parsed = await guessAnimeVideoMetadata(
|
||||
'/tmp/KonoSuba/Season 2/KonoSuba - 05.mkv',
|
||||
'Episode 5',
|
||||
{
|
||||
runGuessit: async () =>
|
||||
JSON.stringify({
|
||||
title: 'KonoSuba',
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(parsed, {
|
||||
parsedBasename: 'KonoSuba - 05.mkv',
|
||||
parsedTitle: 'KonoSuba',
|
||||
parsedSeason: 2,
|
||||
parsedEpisode: null,
|
||||
parserSource: 'guessit',
|
||||
parserConfidence: 1,
|
||||
parseMetadataJson: JSON.stringify({
|
||||
filename: 'KonoSuba - 05.mkv',
|
||||
source: 'guessit',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test('guessAnimeVideoMetadata falls back to parser when guessit throws', async () => {
|
||||
const parsed = await guessAnimeVideoMetadata(
|
||||
'/tmp/Little Witch Academia S02E05.mkv',
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
KanjiOccurrenceRow,
|
||||
KanjiStatsRow,
|
||||
KanjiWordRow,
|
||||
SentenceSearchOptions,
|
||||
SentenceSearchResultRow,
|
||||
SessionEventRow,
|
||||
SimilarWordRow,
|
||||
StatsExcludedWordRow,
|
||||
@@ -20,6 +22,56 @@ import { nowMs } from './time';
|
||||
|
||||
const VOCABULARY_STATS_FILTER_OVERSAMPLE_FACTOR = 4;
|
||||
const VOCABULARY_STATS_FILTER_OVERSAMPLE_MIN = 100;
|
||||
const SENTENCE_SEARCH_DEFAULT_LIMIT = 50;
|
||||
const SENTENCE_SEARCH_MAX_LIMIT = 100;
|
||||
const KANJI_PATTERN = /\p{Script=Han}/gu;
|
||||
|
||||
function resolveSentenceSearchLimit(limit: number): number {
|
||||
if (!Number.isFinite(limit)) return SENTENCE_SEARCH_DEFAULT_LIMIT;
|
||||
const normalized = Math.floor(limit);
|
||||
if (normalized <= 0) return SENTENCE_SEARCH_DEFAULT_LIMIT;
|
||||
return Math.min(normalized, SENTENCE_SEARCH_MAX_LIMIT);
|
||||
}
|
||||
|
||||
export function splitSentenceSearchTerms(query: string): string[] {
|
||||
return query
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((term) => term.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
function escapeLikeTerm(term: string): string {
|
||||
return term.replace(/[\\%_]/g, (match) => `\\${match}`);
|
||||
}
|
||||
|
||||
function uniqueNonEmptyTerms(values: readonly string[] | undefined): string[] {
|
||||
const seen = new Set<string>();
|
||||
const terms: string[] = [];
|
||||
for (const value of values ?? []) {
|
||||
const term = value.trim();
|
||||
if (!term || seen.has(term)) continue;
|
||||
seen.add(term);
|
||||
terms.push(term);
|
||||
}
|
||||
return terms;
|
||||
}
|
||||
|
||||
function getHeadwordCandidatesForSentenceSearchTerm(
|
||||
term: string,
|
||||
options: SentenceSearchOptions | undefined,
|
||||
): string[] {
|
||||
const headwords =
|
||||
options?.headwordTerms
|
||||
?.filter((entry) => entry.term === term)
|
||||
.flatMap((entry) => entry.headwords) ?? [];
|
||||
return uniqueNonEmptyTerms(headwords);
|
||||
}
|
||||
|
||||
function uniqueKanji(text: string): string[] {
|
||||
return Array.from(new Set(text.match(KANJI_PATTERN) ?? []));
|
||||
}
|
||||
|
||||
function toVocabularyToken(row: VocabularyStatsRow): MergedToken {
|
||||
const partOfSpeech =
|
||||
@@ -211,6 +263,70 @@ export function getKanjiOccurrences(
|
||||
.all(kanji, limit, offset) as unknown as KanjiOccurrenceRow[];
|
||||
}
|
||||
|
||||
export function searchSubtitleSentences(
|
||||
db: DatabaseSync,
|
||||
query: string,
|
||||
limit = SENTENCE_SEARCH_DEFAULT_LIMIT,
|
||||
options?: SentenceSearchOptions,
|
||||
): SentenceSearchResultRow[] {
|
||||
const terms = splitSentenceSearchTerms(query);
|
||||
if (terms.length === 0) return [];
|
||||
const resolvedLimit = resolveSentenceSearchLimit(limit);
|
||||
|
||||
const clauses: string[] = [];
|
||||
const params: string[] = [];
|
||||
for (const term of terms) {
|
||||
const likeTerm = `%${escapeLikeTerm(term)}%`;
|
||||
const headwords = getHeadwordCandidatesForSentenceSearchTerm(term, options);
|
||||
const headwordClause =
|
||||
headwords.length > 0
|
||||
? `
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM imm_word_line_occurrences o
|
||||
JOIN imm_words w ON w.id = o.word_id
|
||||
WHERE o.line_id = l.line_id
|
||||
AND w.headword IN (${headwords.map(() => '?').join(', ')})
|
||||
)
|
||||
`
|
||||
: '';
|
||||
clauses.push(`
|
||||
(
|
||||
l.text LIKE ? ESCAPE '\\'
|
||||
OR v.canonical_title LIKE ? ESCAPE '\\'
|
||||
OR COALESCE(a.canonical_title, '') LIKE ? ESCAPE '\\'
|
||||
${headwordClause}
|
||||
)
|
||||
`);
|
||||
params.push(likeTerm, likeTerm, likeTerm, ...headwords);
|
||||
}
|
||||
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
l.anime_id AS animeId,
|
||||
a.canonical_title AS animeTitle,
|
||||
l.video_id AS videoId,
|
||||
v.canonical_title AS videoTitle,
|
||||
v.source_path AS sourcePath,
|
||||
l.secondary_text AS secondaryText,
|
||||
l.session_id AS sessionId,
|
||||
l.line_index AS lineIndex,
|
||||
l.segment_start_ms AS segmentStartMs,
|
||||
l.segment_end_ms AS segmentEndMs,
|
||||
l.text AS text
|
||||
FROM imm_subtitle_lines l
|
||||
JOIN imm_videos v ON v.video_id = l.video_id
|
||||
LEFT JOIN imm_anime a ON a.anime_id = l.anime_id
|
||||
WHERE ${clauses.join(' AND ')}
|
||||
ORDER BY l.CREATED_DATE DESC, l.line_id DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(...params, resolvedLimit) as unknown as SentenceSearchResultRow[];
|
||||
}
|
||||
|
||||
export function getSessionEvents(
|
||||
db: DatabaseSync,
|
||||
sessionId: number,
|
||||
@@ -287,24 +403,38 @@ export function getSimilarWords(db: DatabaseSync, wordId: number, limit = 10): S
|
||||
reading: string;
|
||||
} | null;
|
||||
if (!word || word.headword.trim() === '') return [];
|
||||
|
||||
const clauses: string[] = [];
|
||||
const params: string[] = [];
|
||||
const reading = word.reading.trim();
|
||||
if (reading !== '') {
|
||||
clauses.push('reading = ?');
|
||||
params.push(word.reading);
|
||||
}
|
||||
|
||||
for (const kanji of uniqueKanji(word.headword)) {
|
||||
clauses.push("headword LIKE ? ESCAPE '\\'");
|
||||
params.push(`%${escapeLikeTerm(kanji)}%`);
|
||||
}
|
||||
|
||||
if (clauses.length === 0) return [];
|
||||
|
||||
const orderBy =
|
||||
reading !== '' ? 'CASE WHEN reading = ? THEN 0 ELSE 1 END, frequency DESC' : 'frequency DESC';
|
||||
const orderParams = reading !== '' ? [word.reading] : [];
|
||||
|
||||
return db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id AS wordId, headword, word, reading, frequency
|
||||
FROM imm_words
|
||||
WHERE id != ?
|
||||
AND (reading = ? OR headword LIKE ? OR headword LIKE ?)
|
||||
ORDER BY frequency DESC
|
||||
AND (${clauses.join(' OR ')})
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT ?
|
||||
`,
|
||||
)
|
||||
.all(
|
||||
wordId,
|
||||
word.reading,
|
||||
`%${word.headword.charAt(0)}%`,
|
||||
`%${word.headword.charAt(word.headword.length - 1)}%`,
|
||||
limit,
|
||||
) as SimilarWordRow[];
|
||||
.all(wordId, ...params, ...orderParams, limit) as SimilarWordRow[];
|
||||
}
|
||||
|
||||
export function getKanjiDetail(db: DatabaseSync, kanjiId: number): KanjiDetailRow | null {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { DatabaseSync } from './sqlite';
|
||||
import { buildCoverBlobReference, normalizeCoverBlobBytes } from './storage';
|
||||
import { rebuildLifetimeSummariesInTransaction } from './lifetime';
|
||||
import { rebuildRollupsInTransaction } from './maintenance';
|
||||
import { rebuildLifetimeSummaries, rebuildLifetimeSummariesInTransaction } from './lifetime';
|
||||
import { getRollupGroupsForSessions, refreshRollupsForGroupsInTransaction } from './maintenance';
|
||||
import { nowMs } from './time';
|
||||
import { resolveAnimeAnilistConflict } from './anime-season-repair';
|
||||
import { PartOfSpeech, type MergedToken } from '../../../types';
|
||||
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
|
||||
import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech';
|
||||
@@ -425,6 +426,14 @@ export function updateAnimeAnilistInfo(
|
||||
} | null;
|
||||
if (!row?.anime_id) return;
|
||||
|
||||
const repair = resolveAnimeAnilistConflict(db, row.anime_id, info.anilistId);
|
||||
const targetRow = db
|
||||
.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?')
|
||||
.get(videoId) as {
|
||||
anime_id: number | null;
|
||||
} | null;
|
||||
if (!targetRow?.anime_id) return;
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE imm_anime
|
||||
@@ -444,8 +453,11 @@ export function updateAnimeAnilistInfo(
|
||||
info.titleNative,
|
||||
info.episodesTotal,
|
||||
toDbTimestamp(nowMs()),
|
||||
row.anime_id,
|
||||
targetRow.anime_id,
|
||||
);
|
||||
if (repair.movedVideos > 0 || repair.deletedAnimeRows > 0) {
|
||||
rebuildLifetimeSummaries(db);
|
||||
}
|
||||
}
|
||||
|
||||
export function markVideoWatched(db: DatabaseSync, videoId: number, watched: boolean): void {
|
||||
@@ -474,13 +486,14 @@ export function deleteSession(db: DatabaseSync, sessionId: number): void {
|
||||
const sessionIds = [sessionId];
|
||||
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
|
||||
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
|
||||
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
|
||||
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
deleteSessionsByIds(db, sessionIds);
|
||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||
rebuildLifetimeSummariesInTransaction(db);
|
||||
rebuildRollupsInTransaction(db);
|
||||
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
@@ -492,13 +505,14 @@ export function deleteSessions(db: DatabaseSync, sessionIds: number[]): void {
|
||||
if (sessionIds.length === 0) return;
|
||||
const affectedWordIds = getAffectedWordIdsForSessions(db, sessionIds);
|
||||
const affectedKanjiIds = getAffectedKanjiIdsForSessions(db, sessionIds);
|
||||
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
|
||||
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
deleteSessionsByIds(db, sessionIds);
|
||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||
rebuildLifetimeSummariesInTransaction(db);
|
||||
rebuildRollupsInTransaction(db);
|
||||
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
@@ -536,7 +550,6 @@ export function deleteVideo(db: DatabaseSync, videoId: number): void {
|
||||
db.prepare('DELETE FROM imm_videos WHERE video_id = ?').run(videoId);
|
||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||
rebuildLifetimeSummariesInTransaction(db);
|
||||
rebuildRollupsInTransaction(db);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
|
||||
@@ -74,6 +74,8 @@ export interface TrendsDashboardQueryResult {
|
||||
};
|
||||
ratios: {
|
||||
lookupsPerHundred: TrendChartPoint[];
|
||||
cardsPerHour: TrendChartPoint[];
|
||||
readingSpeed: TrendChartPoint[];
|
||||
};
|
||||
animeCumulative: {
|
||||
watchTime: TrendPerAnimePoint[];
|
||||
@@ -176,11 +178,31 @@ function getTrendSessionWordCount(session: Pick<TrendSessionMetricRow, 'tokensSe
|
||||
return session.tokensSeen;
|
||||
}
|
||||
|
||||
function looksLikeJellyfinStreamTitle(title: string): boolean {
|
||||
const lowered = title.toLowerCase();
|
||||
const hasApiKey = /api[\s_-]*key(?:\s|=|$)/i.test(title);
|
||||
return (
|
||||
hasApiKey &&
|
||||
(lowered.includes('stream?') ||
|
||||
lowered.includes('/stream?') ||
|
||||
lowered.includes('/videos/') ||
|
||||
lowered.includes('mediasourceid'))
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeTrendTitle(title: string): string {
|
||||
const normalized = title.trim();
|
||||
if (!normalized) {
|
||||
return 'Unknown';
|
||||
}
|
||||
return looksLikeJellyfinStreamTitle(normalized) ? 'Jellyfin Video' : normalized;
|
||||
}
|
||||
|
||||
function resolveTrendAnimeTitle(value: {
|
||||
animeTitle: string | null;
|
||||
canonicalTitle: string | null;
|
||||
}): string {
|
||||
return value.animeTitle ?? value.canonicalTitle ?? 'Unknown';
|
||||
return sanitizeTrendTitle(value.animeTitle ?? value.canonicalTitle ?? 'Unknown');
|
||||
}
|
||||
|
||||
function accumulatePoints(points: TrendChartPoint[]): TrendChartPoint[] {
|
||||
@@ -225,6 +247,26 @@ function buildAggregatedTrendRows(rollups: ImmersionSessionRollupRow[]) {
|
||||
}));
|
||||
}
|
||||
|
||||
function buildEfficiencyRates(rows: ReturnType<typeof buildAggregatedTrendRows>): {
|
||||
cardsPerHour: TrendChartPoint[];
|
||||
readingSpeed: TrendChartPoint[];
|
||||
} {
|
||||
const cardsPerHour: TrendChartPoint[] = [];
|
||||
const readingSpeed: TrendChartPoint[] = [];
|
||||
for (const row of rows) {
|
||||
const hours = row.activeMin / 60;
|
||||
cardsPerHour.push({
|
||||
label: row.label,
|
||||
value: hours > 0 ? +(row.cards / hours).toFixed(1) : 0,
|
||||
});
|
||||
readingSpeed.push({
|
||||
label: row.label,
|
||||
value: row.activeMin > 0 ? +(row.words / row.activeMin).toFixed(1) : 0,
|
||||
});
|
||||
}
|
||||
return { cardsPerHour, readingSpeed };
|
||||
}
|
||||
|
||||
function buildWatchTimeByDayOfWeek(sessions: TrendSessionMetricRow[]): TrendChartPoint[] {
|
||||
const totals = new Array(7).fill(0);
|
||||
for (const session of sessions) {
|
||||
@@ -449,7 +491,7 @@ function getVideoAnimeTitleMap(
|
||||
)
|
||||
.all(...uniqueIds) as Array<{ videoId: number; animeTitle: string }>;
|
||||
|
||||
return new Map(rows.map((row) => [row.videoId, row.animeTitle]));
|
||||
return new Map(rows.map((row) => [row.videoId, sanitizeTrendTitle(row.animeTitle)]));
|
||||
}
|
||||
|
||||
function resolveVideoAnimeTitle(
|
||||
@@ -675,6 +717,7 @@ export function getTrendsDashboard(
|
||||
);
|
||||
|
||||
const aggregatedRows = buildAggregatedTrendRows(chartRollups);
|
||||
const efficiency = buildEfficiencyRates(aggregatedRows);
|
||||
const activity = {
|
||||
watchTime: aggregatedRows.map((row) => ({ label: row.label, value: row.activeMin })),
|
||||
cards: aggregatedRows.map((row) => ({ label: row.label, value: row.cards })),
|
||||
@@ -724,6 +767,8 @@ export function getTrendsDashboard(
|
||||
},
|
||||
ratios: {
|
||||
lookupsPerHundred: buildLookupsPerHundredWords(sessions, groupBy),
|
||||
cardsPerHour: efficiency.cardsPerHour,
|
||||
readingSpeed: efficiency.readingSpeed,
|
||||
},
|
||||
animeCumulative: {
|
||||
watchTime: buildCumulativePerAnime(animePerDay.watchTime),
|
||||
|
||||
@@ -813,7 +813,7 @@ test('ensureSchema migrates legacy videos and backfills anime metadata from file
|
||||
.all() as Array<{ canonical_title: string }>;
|
||||
assert.deepEqual(
|
||||
animeRows.map((row) => row.canonical_title),
|
||||
['Frieren', 'Little Witch Academia'],
|
||||
['Frieren', 'Little Witch Academia Season 2'],
|
||||
);
|
||||
|
||||
const littleWitchRows = db
|
||||
@@ -855,7 +855,7 @@ test('ensureSchema migrates legacy videos and backfills anime metadata from file
|
||||
})),
|
||||
[
|
||||
{
|
||||
animeTitle: 'Little Witch Academia',
|
||||
animeTitle: 'Little Witch Academia Season 2',
|
||||
parsedTitle: 'Little Witch Academia',
|
||||
parsedBasename: 'Little Witch Academia S02E05.mkv',
|
||||
parsedSeason: 2,
|
||||
@@ -863,7 +863,7 @@ test('ensureSchema migrates legacy videos and backfills anime metadata from file
|
||||
parserSource: 'fallback',
|
||||
},
|
||||
{
|
||||
animeTitle: 'Little Witch Academia',
|
||||
animeTitle: 'Little Witch Academia Season 2',
|
||||
parsedTitle: 'Little Witch Academia',
|
||||
parsedBasename: 'Little Witch Academia S02E06.mkv',
|
||||
parsedSeason: 2,
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface TrackerPreparedStatements {
|
||||
export interface AnimeRecordInput {
|
||||
parsedTitle: string;
|
||||
canonicalTitle: string;
|
||||
seasonScope?: number | null;
|
||||
anilistId: number | null;
|
||||
titleRomaji: string | null;
|
||||
titleEnglish: string | null;
|
||||
@@ -300,6 +301,31 @@ export function normalizeAnimeIdentityKey(title: string): string {
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function normalizeSeasonScope(value: number | null | undefined): number | null {
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function titleAlreadyHasSeasonScope(title: string, season: number): boolean {
|
||||
const normalized = title.normalize('NFKC').toLowerCase();
|
||||
const padded = String(season).padStart(2, '0');
|
||||
return (
|
||||
new RegExp(`\\bseason\\s*0?${season}\\b`, 'i').test(normalized) ||
|
||||
new RegExp(`\\bs0?${season}\\b`, 'i').test(normalized) ||
|
||||
new RegExp(`\\bs${padded}\\b`, 'i').test(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
function buildSeasonScopedAnimeTitle(title: string, season: number | null): string {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed || season === null || titleAlreadyHasSeasonScope(trimmed, season)) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed} Season ${season}`;
|
||||
}
|
||||
|
||||
function looksLikeEpisodeOnlyTitle(title: string): boolean {
|
||||
const normalized = title.normalize('NFKC').toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
return /^(episode|ep)\s*\d{1,3}$/.test(normalized) || /^第\s*\d{1,3}\s*話$/.test(normalized);
|
||||
@@ -478,7 +504,12 @@ function ensureStatsExcludedWordsTable(db: DatabaseSync): void {
|
||||
}
|
||||
|
||||
export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput): number {
|
||||
const normalizedTitleKey = normalizeAnimeIdentityKey(input.parsedTitle);
|
||||
const seasonScope = normalizeSeasonScope(input.seasonScope);
|
||||
const identityTitle = buildSeasonScopedAnimeTitle(input.parsedTitle, seasonScope);
|
||||
const canonicalTitle =
|
||||
buildSeasonScopedAnimeTitle(input.canonicalTitle || input.parsedTitle, seasonScope) ||
|
||||
identityTitle;
|
||||
const normalizedTitleKey = normalizeAnimeIdentityKey(identityTitle);
|
||||
if (!normalizedTitleKey) {
|
||||
throw new Error('parsedTitle is required to create or update an anime record');
|
||||
}
|
||||
@@ -508,7 +539,7 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
|
||||
WHERE anime_id = ?
|
||||
`,
|
||||
).run(
|
||||
input.canonicalTitle,
|
||||
canonicalTitle,
|
||||
input.anilistId,
|
||||
input.titleRomaji,
|
||||
input.titleEnglish,
|
||||
@@ -539,7 +570,7 @@ export function getOrCreateAnimeRecord(db: DatabaseSync, input: AnimeRecordInput
|
||||
)
|
||||
.run(
|
||||
normalizedTitleKey,
|
||||
input.canonicalTitle,
|
||||
canonicalTitle,
|
||||
input.anilistId,
|
||||
input.titleRomaji,
|
||||
input.titleEnglish,
|
||||
@@ -648,6 +679,7 @@ function migrateLegacyAnimeMetadata(db: DatabaseSync): void {
|
||||
const animeId = getOrCreateAnimeRecord(db, {
|
||||
parsedTitle: parsed.title,
|
||||
canonicalTitle: parsed.title,
|
||||
seasonScope: parsed.season,
|
||||
anilistId: null,
|
||||
titleRomaji: null,
|
||||
titleEnglish: null,
|
||||
|
||||
@@ -52,6 +52,11 @@ export interface ImmersionTrackerPolicy {
|
||||
};
|
||||
}
|
||||
|
||||
export interface JellyfinLinkRepairSummary {
|
||||
scanned: number;
|
||||
repaired: number;
|
||||
}
|
||||
|
||||
export interface TelemetryAccumulator {
|
||||
totalWatchedMs: number;
|
||||
activeWatchedMs: number;
|
||||
@@ -367,6 +372,29 @@ export interface KanjiOccurrenceRow {
|
||||
occurrenceCount: number;
|
||||
}
|
||||
|
||||
export interface SentenceSearchResultRow {
|
||||
animeId: number | null;
|
||||
animeTitle: string | null;
|
||||
videoId: number;
|
||||
videoTitle: string;
|
||||
sourcePath: string | null;
|
||||
secondaryText: string | null;
|
||||
sessionId: number;
|
||||
lineIndex: number;
|
||||
segmentStartMs: number | null;
|
||||
segmentEndMs: number | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SentenceSearchHeadwordTerm {
|
||||
term: string;
|
||||
headwords: string[];
|
||||
}
|
||||
|
||||
export interface SentenceSearchOptions {
|
||||
headwordTerms?: SentenceSearchHeadwordTerm[];
|
||||
}
|
||||
|
||||
export interface SessionEventRow {
|
||||
eventType: number;
|
||||
tsMs: number;
|
||||
|
||||
@@ -235,6 +235,27 @@ test('dispatchMpvProtocolMessage prefers the already selected matching secondary
|
||||
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 3] }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage skips signs and songs when choosing secondary subtitles', async () => {
|
||||
const { deps, state } = createDeps({
|
||||
getResolvedConfig: () => ({
|
||||
secondarySub: { secondarySubLanguages: ['eng', 'en'] },
|
||||
}),
|
||||
});
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{
|
||||
request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
||||
data: [
|
||||
{ type: 'sub', id: 2, lang: 'eng', title: 'English Signs & Songs' },
|
||||
{ type: 'sub', id: 3, lang: 'eng', title: 'English Dialogue' },
|
||||
],
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(state.commands, [{ command: ['set_property', 'secondary-sid', 3] }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage restores secondary visibility on shutdown', async () => {
|
||||
const { deps, state } = createDeps();
|
||||
|
||||
@@ -329,3 +350,21 @@ test('visibility and boolean parsers handle text values', () => {
|
||||
assert.equal(asBoolean('yes', false), true);
|
||||
assert.equal(asBoolean('0', true), false);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage emits client-message string args', async () => {
|
||||
const received: Array<{ args: string[] }> = [];
|
||||
const { deps } = createDeps({
|
||||
emitClientMessage: (payload) => {
|
||||
received.push(payload);
|
||||
},
|
||||
});
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'client-message', args: ['subminer-skip-intro', 42, 'extra'] },
|
||||
deps,
|
||||
);
|
||||
await dispatchMpvProtocolMessage({ event: 'client-message', args: [] }, deps);
|
||||
await dispatchMpvProtocolMessage({ event: 'client-message' }, deps);
|
||||
|
||||
assert.deepEqual(received, [{ args: ['subminer-skip-intro', 'extra'] }]);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ export type MpvMessage = {
|
||||
event?: string;
|
||||
name?: string;
|
||||
data?: unknown;
|
||||
args?: unknown;
|
||||
request_id?: number;
|
||||
error?: string;
|
||||
};
|
||||
@@ -94,6 +95,7 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
restorePreviousSecondarySubVisibility: () => void;
|
||||
shouldQuitOnMpvShutdown: () => boolean;
|
||||
requestAppQuit: () => void;
|
||||
emitClientMessage?: (payload: { args: string[] }) => void;
|
||||
}
|
||||
|
||||
type SubtitleTrackCandidate = {
|
||||
@@ -149,6 +151,11 @@ function getSubtitleTrackIdentity(track: SubtitleTrackCandidate): string {
|
||||
return `id:${track.id}`;
|
||||
}
|
||||
|
||||
function isSignsOrSongsSubtitleTrack(track: SubtitleTrackCandidate): boolean {
|
||||
const label = `${track.title} ${track.externalFilename ?? ''}`.toLowerCase();
|
||||
return /\b(signs?|songs?)\b/.test(label);
|
||||
}
|
||||
|
||||
function pickSecondarySubtitleTrackId(
|
||||
tracks: Array<Record<string, unknown>>,
|
||||
preferredLanguages: string[],
|
||||
@@ -177,12 +184,19 @@ function pickSecondarySubtitleTrackId(
|
||||
const uniqueTracks = [...dedupedTracks.values()];
|
||||
|
||||
for (const language of normalizedLanguages) {
|
||||
const selectedMatch = uniqueTracks.find((track) => track.selected && track.lang === language);
|
||||
const languageTracks = uniqueTracks.filter((track) => track.lang === language);
|
||||
if (languageTracks.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const cleanTracks = languageTracks.filter((track) => !isSignsOrSongsSubtitleTrack(track));
|
||||
const candidateTracks = cleanTracks.length > 0 ? cleanTracks : languageTracks;
|
||||
|
||||
const selectedMatch = candidateTracks.find((track) => track.selected);
|
||||
if (selectedMatch) {
|
||||
return selectedMatch.id;
|
||||
}
|
||||
|
||||
const match = uniqueTracks.find((track) => track.lang === language);
|
||||
const match = candidateTracks[0];
|
||||
if (match) {
|
||||
return match.id;
|
||||
}
|
||||
@@ -364,6 +378,13 @@ export async function dispatchMpvProtocolMessage(
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (msg.event === 'client-message') {
|
||||
const args = Array.isArray(msg.args)
|
||||
? msg.args.filter((arg): arg is string => typeof arg === 'string')
|
||||
: [];
|
||||
if (args.length > 0) {
|
||||
deps.emitClientMessage?.({ args });
|
||||
}
|
||||
} else if (msg.event === 'shutdown') {
|
||||
deps.restorePreviousSecondarySubVisibility();
|
||||
if (deps.shouldQuitOnMpvShutdown()) {
|
||||
|
||||
@@ -129,6 +129,7 @@ export interface MpvIpcClientEventMap {
|
||||
'media-title-change': { title: string | null };
|
||||
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
|
||||
'secondary-subtitle-visibility': { visible: boolean };
|
||||
'client-message': { args: string[] };
|
||||
}
|
||||
|
||||
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
|
||||
@@ -491,6 +492,9 @@ export class MpvIpcClient implements MpvClient {
|
||||
},
|
||||
shouldQuitOnMpvShutdown: () => this.deps.shouldQuitOnMpvShutdown?.() ?? false,
|
||||
requestAppQuit: () => this.deps.requestAppQuit?.(),
|
||||
emitClientMessage: (payload) => {
|
||||
this.emit('client-message', payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { runCommand, type CommandResult } from '../../subsync/utils';
|
||||
import { parseSubtitleCues, type SubtitleCue } from './subtitle-cue-parser.js';
|
||||
import { isEnglishYoutubeLang, normalizeYoutubeLangCode } from './youtube/labels.js';
|
||||
|
||||
const DEFAULT_SECONDARY_SUBTITLE_LANGUAGES = ['en', 'eng', 'english', 'en-us', 'enus'];
|
||||
const DEFAULT_PRIMARY_SUBTITLE_LANGUAGES = ['ja', 'jpn', 'jp', 'japanese'];
|
||||
const SUPPORTED_SUBTITLE_EXTENSIONS = new Set(['.srt', '.vtt', '.ass', '.ssa']);
|
||||
const TIMING_TOLERANCE_SECONDS = 0.25;
|
||||
const SAME_TIMING_EPSILON_SECONDS = 0.001;
|
||||
const RETIMED_SUBTITLE_TIMEOUT_MS = 30_000;
|
||||
const FALLBACK_ALASS_PATHS = [
|
||||
'/opt/homebrew/bin/alass-cli',
|
||||
'/opt/homebrew/bin/alass',
|
||||
'/usr/local/bin/alass-cli',
|
||||
'/usr/local/bin/alass',
|
||||
'/usr/bin/alass',
|
||||
];
|
||||
|
||||
type SidecarCandidate = {
|
||||
path: string;
|
||||
languageRank: number;
|
||||
extensionRank: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type RetimedSubtitleCacheEntry = {
|
||||
path: string;
|
||||
cleanupDir: string;
|
||||
promise?: Promise<string>;
|
||||
};
|
||||
|
||||
export type RetimedSubtitleCommandRunner = (
|
||||
alassPath: string,
|
||||
referencePath: string,
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
) => Promise<CommandResult>;
|
||||
|
||||
export type RetimedSecondarySubtitleInput = {
|
||||
sourcePath: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
languages?: readonly string[];
|
||||
primaryLanguages?: readonly string[];
|
||||
alassPath?: string | null;
|
||||
runAlass?: RetimedSubtitleCommandRunner;
|
||||
};
|
||||
|
||||
const retimedSubtitleCache = new Map<string, RetimedSubtitleCacheEntry>();
|
||||
let retimedSubtitleCleanupRegistered = false;
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return values.filter((value, index) => value.length > 0 && values.indexOf(value) === index);
|
||||
}
|
||||
|
||||
function expandPreferredLanguages(
|
||||
languages: readonly string[] | undefined,
|
||||
fallback: readonly string[],
|
||||
): string[] {
|
||||
const normalized = unique(
|
||||
(languages ?? []).map((language) => normalizeYoutubeLangCode(language)).filter(Boolean),
|
||||
);
|
||||
const base = normalized.length > 0 ? normalized : [...fallback];
|
||||
const expanded: string[] = [];
|
||||
for (const language of base) {
|
||||
expanded.push(language);
|
||||
if (isEnglishYoutubeLang(language)) {
|
||||
expanded.push(...DEFAULT_SECONDARY_SUBTITLE_LANGUAGES);
|
||||
}
|
||||
}
|
||||
return unique(expanded);
|
||||
}
|
||||
|
||||
function isExecutableFile(filePath: string): boolean {
|
||||
try {
|
||||
return statSync(filePath).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function pathEntries(): string[] {
|
||||
const entries = (process.env.PATH ?? '')
|
||||
.split(path.delimiter)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
return unique([...entries, ...FALLBACK_ALASS_PATHS.map((candidate) => path.dirname(candidate))]);
|
||||
}
|
||||
|
||||
function executableNames(name: string): string[] {
|
||||
if (process.platform !== 'win32') return [name];
|
||||
const extensions = (process.env.PATHEXT ?? '.EXE;.CMD;.BAT')
|
||||
.split(';')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
if (path.extname(name)) return [name];
|
||||
return [name, ...extensions.map((extension) => `${name}${extension}`)];
|
||||
}
|
||||
|
||||
function findExecutable(names: readonly string[]): string {
|
||||
for (const name of names) {
|
||||
if (path.dirname(name) !== '.') {
|
||||
return isExecutableFile(name) ? name : '';
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of pathEntries()) {
|
||||
for (const name of names) {
|
||||
for (const executableName of executableNames(name)) {
|
||||
const candidate = path.join(dir, executableName);
|
||||
if (isExecutableFile(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of FALLBACK_ALASS_PATHS) {
|
||||
if (isExecutableFile(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function resolveAlassPath(configuredPath: string | null | undefined): string {
|
||||
const trimmed = configuredPath?.trim() ?? '';
|
||||
if (trimmed) {
|
||||
return findExecutable([trimmed]);
|
||||
}
|
||||
return findExecutable(['alass', 'alass-cli']);
|
||||
}
|
||||
|
||||
function fileSignature(filePath: string): string | null {
|
||||
try {
|
||||
const stats = statSync(filePath);
|
||||
if (!stats.isFile()) return null;
|
||||
return `${stats.size}:${stats.mtimeMs}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function retimedCacheKey(
|
||||
alassPath: string,
|
||||
primaryPath: string,
|
||||
secondaryPath: string,
|
||||
): string | null {
|
||||
const primarySignature = fileSignature(primaryPath);
|
||||
const secondarySignature = fileSignature(secondaryPath);
|
||||
if (!primarySignature || !secondarySignature) return null;
|
||||
return [alassPath, primaryPath, primarySignature, secondaryPath, secondarySignature].join('\0');
|
||||
}
|
||||
|
||||
function cleanupRetimedSubtitleCache(): void {
|
||||
for (const entry of retimedSubtitleCache.values()) {
|
||||
try {
|
||||
rmSync(entry.cleanupDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort temp cleanup.
|
||||
}
|
||||
}
|
||||
retimedSubtitleCache.clear();
|
||||
}
|
||||
|
||||
function registerRetimedSubtitleCleanup(): void {
|
||||
if (retimedSubtitleCleanupRegistered) return;
|
||||
retimedSubtitleCleanupRegistered = true;
|
||||
process.once('exit', cleanupRetimedSubtitleCache);
|
||||
}
|
||||
|
||||
export function clearRetimedSecondarySubtitleCache(): void {
|
||||
cleanupRetimedSubtitleCache();
|
||||
}
|
||||
|
||||
function splitLanguageSuffix(value: string): string[] {
|
||||
const normalizedWhole = normalizeYoutubeLangCode(value);
|
||||
const tokens = value
|
||||
.split(/[^A-Za-z0-9-]+/g)
|
||||
.map((token) => normalizeYoutubeLangCode(token))
|
||||
.filter(Boolean);
|
||||
return unique([normalizedWhole, ...tokens]);
|
||||
}
|
||||
|
||||
function languageTokenMatches(token: string, preferredLanguage: string): boolean {
|
||||
if (token === preferredLanguage) {
|
||||
return true;
|
||||
}
|
||||
if (token.startsWith(`${preferredLanguage}-`) || preferredLanguage.startsWith(`${token}-`)) {
|
||||
return true;
|
||||
}
|
||||
return isEnglishYoutubeLang(token) && isEnglishYoutubeLang(preferredLanguage);
|
||||
}
|
||||
|
||||
function resolveLanguageRank(suffix: string, preferredLanguages: string[]): number {
|
||||
const tokens = splitLanguageSuffix(suffix);
|
||||
for (let index = 0; index < preferredLanguages.length; index += 1) {
|
||||
const preferredLanguage = preferredLanguages[index]!;
|
||||
if (tokens.some((token) => languageTokenMatches(token, preferredLanguage))) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function extensionRank(ext: string): number {
|
||||
if (ext === '.srt') return 0;
|
||||
if (ext === '.vtt') return 1;
|
||||
if (ext === '.ass') return 2;
|
||||
if (ext === '.ssa') return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
function findSidecarSubtitleCandidates(
|
||||
sourcePath: string,
|
||||
preferredLanguages: string[],
|
||||
): SidecarCandidate[] {
|
||||
const source = path.parse(sourcePath);
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(source.dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const prefix = `${source.name}.`;
|
||||
return entries
|
||||
.map((entry) => {
|
||||
const parsed = path.parse(entry);
|
||||
const ext = parsed.ext.toLowerCase();
|
||||
if (!SUPPORTED_SUBTITLE_EXTENSIONS.has(ext) || !parsed.name.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
const suffix = parsed.name.slice(prefix.length);
|
||||
const languageRank = resolveLanguageRank(suffix, preferredLanguages);
|
||||
if (!Number.isFinite(languageRank)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
path: path.join(source.dir, entry),
|
||||
languageRank,
|
||||
extensionRank: extensionRank(ext),
|
||||
name: entry,
|
||||
};
|
||||
})
|
||||
.filter((candidate): candidate is SidecarCandidate => candidate !== null)
|
||||
.sort((left, right) => {
|
||||
if (left.languageRank !== right.languageRank) return left.languageRank - right.languageRank;
|
||||
if (left.extensionRank !== right.extensionRank)
|
||||
return left.extensionRank - right.extensionRank;
|
||||
return left.name.localeCompare(right.name);
|
||||
});
|
||||
}
|
||||
|
||||
function combineCueText(cues: SubtitleCue[]): string {
|
||||
return unique(cues.map((cue) => cue.text.trim()).filter(Boolean))
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function overlapSeconds(cue: SubtitleCue, startSeconds: number, endSeconds: number): number {
|
||||
return (
|
||||
Math.min(cue.endTime, endSeconds + TIMING_TOLERANCE_SECONDS) -
|
||||
Math.max(cue.startTime, startSeconds - TIMING_TOLERANCE_SECONDS)
|
||||
);
|
||||
}
|
||||
|
||||
function isSameCueTiming(left: SubtitleCue, right: SubtitleCue): boolean {
|
||||
return (
|
||||
Math.abs(left.startTime - right.startTime) <= SAME_TIMING_EPSILON_SECONDS &&
|
||||
Math.abs(left.endTime - right.endTime) <= SAME_TIMING_EPSILON_SECONDS
|
||||
);
|
||||
}
|
||||
|
||||
function compareCueTimingMatch(
|
||||
startSeconds: number,
|
||||
endSeconds: number,
|
||||
left: { cue: SubtitleCue; overlap: number },
|
||||
right: { cue: SubtitleCue; overlap: number },
|
||||
): number {
|
||||
if (left.overlap !== right.overlap) {
|
||||
return right.overlap - left.overlap;
|
||||
}
|
||||
|
||||
const leftStartDistance = Math.abs(left.cue.startTime - startSeconds);
|
||||
const rightStartDistance = Math.abs(right.cue.startTime - startSeconds);
|
||||
if (leftStartDistance !== rightStartDistance) {
|
||||
return leftStartDistance - rightStartDistance;
|
||||
}
|
||||
|
||||
const leftEndDistance = Math.abs(left.cue.endTime - endSeconds);
|
||||
const rightEndDistance = Math.abs(right.cue.endTime - endSeconds);
|
||||
if (leftEndDistance !== rightEndDistance) {
|
||||
return leftEndDistance - rightEndDistance;
|
||||
}
|
||||
|
||||
return left.cue.startTime - right.cue.startTime;
|
||||
}
|
||||
|
||||
function findCueTextAtTiming(cues: SubtitleCue[], startMs: number, endMs: number): string {
|
||||
const startSeconds = startMs / 1000;
|
||||
const endSeconds = endMs / 1000;
|
||||
const midpointSeconds = (startSeconds + endSeconds) / 2;
|
||||
|
||||
const midpointMatches = cues
|
||||
.filter(
|
||||
(cue) =>
|
||||
cue.startTime - TIMING_TOLERANCE_SECONDS <= midpointSeconds &&
|
||||
cue.endTime + TIMING_TOLERANCE_SECONDS >= midpointSeconds,
|
||||
)
|
||||
.map((cue) => ({ cue, overlap: overlapSeconds(cue, startSeconds, endSeconds) }))
|
||||
.sort((left, right) => compareCueTimingMatch(startSeconds, endSeconds, left, right));
|
||||
const [bestMidpointMatch] = midpointMatches;
|
||||
const midpointText = bestMidpointMatch
|
||||
? combineCueText(
|
||||
midpointMatches
|
||||
.filter((match) => isSameCueTiming(match.cue, bestMidpointMatch.cue))
|
||||
.map((match) => match.cue),
|
||||
)
|
||||
: '';
|
||||
if (midpointText) {
|
||||
return midpointText;
|
||||
}
|
||||
|
||||
const [bestOverlap] = cues
|
||||
.map((cue) => ({ cue, overlap: overlapSeconds(cue, startSeconds, endSeconds) }))
|
||||
.filter((entry) => entry.overlap > 0)
|
||||
.sort((left, right) => compareCueTimingMatch(startSeconds, endSeconds, left, right));
|
||||
return bestOverlap ? bestOverlap.cue.text.trim() : '';
|
||||
}
|
||||
|
||||
function readCueTextAtTiming(filePath: string, startMs: number, endMs: number): string {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const cues = parseSubtitleCues(content, filePath);
|
||||
return findCueTextAtTiming(cues, startMs, endMs);
|
||||
}
|
||||
|
||||
async function defaultRunAlass(
|
||||
alassPath: string,
|
||||
referencePath: string,
|
||||
inputPath: string,
|
||||
outputPath: string,
|
||||
): Promise<CommandResult> {
|
||||
return runCommand(alassPath, [referencePath, inputPath, outputPath], RETIMED_SUBTITLE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
async function retimeSecondarySubtitle(input: {
|
||||
alassPath: string;
|
||||
primaryPath: string;
|
||||
secondaryPath: string;
|
||||
runAlass: RetimedSubtitleCommandRunner;
|
||||
}): Promise<string> {
|
||||
const key = retimedCacheKey(input.alassPath, input.primaryPath, input.secondaryPath);
|
||||
if (!key) return '';
|
||||
|
||||
const cached = retimedSubtitleCache.get(key);
|
||||
if (cached?.promise) {
|
||||
return cached.promise;
|
||||
}
|
||||
if (cached && existsSync(cached.path)) {
|
||||
return cached.path;
|
||||
}
|
||||
if (cached) {
|
||||
retimedSubtitleCache.delete(key);
|
||||
try {
|
||||
rmSync(cached.cleanupDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
registerRetimedSubtitleCleanup();
|
||||
const cleanupDir = mkdtempSync(path.join(os.tmpdir(), 'subminer-retimed-secondary-'));
|
||||
const parsedSecondary = path.parse(input.secondaryPath);
|
||||
const outputPath = path.join(
|
||||
cleanupDir,
|
||||
`${parsedSecondary.name}.retimed${parsedSecondary.ext || '.srt'}`,
|
||||
);
|
||||
|
||||
const entry: RetimedSubtitleCacheEntry = { path: outputPath, cleanupDir };
|
||||
entry.promise = input
|
||||
.runAlass(input.alassPath, input.primaryPath, input.secondaryPath, outputPath)
|
||||
.then((result) => {
|
||||
if (!result.ok || !existsSync(outputPath)) {
|
||||
rmSync(cleanupDir, { recursive: true, force: true });
|
||||
retimedSubtitleCache.delete(key);
|
||||
return '';
|
||||
}
|
||||
entry.promise = undefined;
|
||||
return outputPath;
|
||||
})
|
||||
.catch(() => {
|
||||
rmSync(cleanupDir, { recursive: true, force: true });
|
||||
retimedSubtitleCache.delete(key);
|
||||
return '';
|
||||
});
|
||||
retimedSubtitleCache.set(key, entry);
|
||||
return entry.promise;
|
||||
}
|
||||
|
||||
export function resolveSecondarySubtitleTextFromSidecar(input: {
|
||||
sourcePath: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
languages?: readonly string[];
|
||||
}): string {
|
||||
if (!input.sourcePath || !existsSync(input.sourcePath)) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
if (!statSync(input.sourcePath).isFile()) {
|
||||
return '';
|
||||
}
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
|
||||
const preferredLanguages = expandPreferredLanguages(
|
||||
input.languages,
|
||||
DEFAULT_SECONDARY_SUBTITLE_LANGUAGES,
|
||||
);
|
||||
const candidates = findSidecarSubtitleCandidates(input.sourcePath, preferredLanguages);
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const text = readCueTextAtTiming(candidate.path, input.startMs, input.endMs);
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
} catch {
|
||||
// Try the next matching sidecar.
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export async function resolveRetimedSecondarySubtitleTextFromSidecar(
|
||||
input: RetimedSecondarySubtitleInput,
|
||||
): Promise<string> {
|
||||
if (!input.sourcePath || !existsSync(input.sourcePath)) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
if (!statSync(input.sourcePath).isFile()) {
|
||||
return '';
|
||||
}
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
|
||||
const alassPath = resolveAlassPath(input.alassPath);
|
||||
if (!alassPath) return '';
|
||||
|
||||
const primaryLanguages = expandPreferredLanguages(
|
||||
input.primaryLanguages,
|
||||
DEFAULT_PRIMARY_SUBTITLE_LANGUAGES,
|
||||
);
|
||||
const secondaryLanguages = expandPreferredLanguages(
|
||||
input.languages,
|
||||
DEFAULT_SECONDARY_SUBTITLE_LANGUAGES,
|
||||
);
|
||||
const primaryCandidates = findSidecarSubtitleCandidates(input.sourcePath, primaryLanguages);
|
||||
const secondaryCandidates = findSidecarSubtitleCandidates(input.sourcePath, secondaryLanguages);
|
||||
const runAlass = input.runAlass ?? defaultRunAlass;
|
||||
|
||||
for (const primary of primaryCandidates) {
|
||||
for (const secondary of secondaryCandidates) {
|
||||
if (primary.path === secondary.path) continue;
|
||||
try {
|
||||
const retimedPath = await retimeSecondarySubtitle({
|
||||
alassPath,
|
||||
primaryPath: primary.path,
|
||||
secondaryPath: secondary.path,
|
||||
runAlass,
|
||||
});
|
||||
if (!retimedPath) continue;
|
||||
const text = readCueTextAtTiming(retimedPath, input.startMs, input.endMs);
|
||||
if (text) return text;
|
||||
} catch {
|
||||
// Try the next sidecar pair.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { ImmersionTrackerService } from './immersion-tracker-service.js';
|
||||
import { splitSentenceSearchTerms } from './immersion-tracker/query-lexical.js';
|
||||
import http, { type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { basename, extname, resolve, sep } from 'node:path';
|
||||
import { readFileSync, existsSync, statSync } from 'node:fs';
|
||||
@@ -7,6 +8,7 @@ import { Readable } from 'node:stream';
|
||||
import { MediaGenerator } from '../../media-generator.js';
|
||||
import { AnkiConnectClient } from '../../anki-connect.js';
|
||||
import type { AnkiConnectConfig } from '../../types.js';
|
||||
import { createLogger } from '../../logger.js';
|
||||
import {
|
||||
getConfiguredSentenceFieldName,
|
||||
getConfiguredTranslationFieldName,
|
||||
@@ -15,18 +17,50 @@ import {
|
||||
} from '../../anki-field-config.js';
|
||||
import { resolveAnimatedImageLeadInSeconds } from '../../anki-integration/animated-image-sync.js';
|
||||
import type { AnilistRateLimiter } from './anilist/rate-limiter.js';
|
||||
import {
|
||||
resolveRetimedSecondarySubtitleTextFromSidecar,
|
||||
resolveSecondarySubtitleTextFromSidecar,
|
||||
type RetimedSecondarySubtitleInput,
|
||||
} from './secondary-subtitle-sidecar.js';
|
||||
|
||||
type StatsServerNoteInfo = {
|
||||
noteId: number;
|
||||
fields: Record<string, { value: string }>;
|
||||
};
|
||||
|
||||
type StatsServerMediaGenerator = {
|
||||
generateAudio: (...args: Parameters<MediaGenerator['generateAudio']>) => Promise<Buffer | null>;
|
||||
generateScreenshot: (
|
||||
...args: Parameters<MediaGenerator['generateScreenshot']>
|
||||
) => Promise<Buffer | null>;
|
||||
generateAnimatedImage: (
|
||||
...args: Parameters<MediaGenerator['generateAnimatedImage']>
|
||||
) => Promise<Buffer | null>;
|
||||
};
|
||||
|
||||
export type StatsMiningTimingEvent = {
|
||||
mode: 'word' | 'sentence' | 'audio';
|
||||
phase: string;
|
||||
elapsedMs: number;
|
||||
noteId?: number;
|
||||
};
|
||||
|
||||
type StatsExcludedWordPayload = {
|
||||
headword: string;
|
||||
word: string;
|
||||
reading: string;
|
||||
};
|
||||
|
||||
type StatsCoverImagePayload = {
|
||||
contentType: string;
|
||||
dataUrl: string;
|
||||
} | null;
|
||||
|
||||
type StatsCoverBatchBody = {
|
||||
animeIds?: unknown;
|
||||
videoIds?: unknown;
|
||||
};
|
||||
|
||||
function parseIntQuery(raw: string | undefined, fallback: number, maxLimit?: number): number {
|
||||
if (raw === undefined) return fallback;
|
||||
const n = Number(raw);
|
||||
@@ -73,6 +107,62 @@ function parseExcludedWordsBody(body: unknown): StatsExcludedWordPayload[] | nul
|
||||
return words;
|
||||
}
|
||||
|
||||
function parsePositiveIdList(raw: unknown, maxItems = 100): number[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
const ids = new Set<number>();
|
||||
for (const rawId of raw) {
|
||||
const id = typeof rawId === 'number' ? rawId : typeof rawId === 'string' ? Number(rawId) : NaN;
|
||||
if (Number.isFinite(id) && id > 0) {
|
||||
ids.add(Math.floor(id));
|
||||
if (ids.size >= maxItems) break;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ids).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function coverImagePayload(
|
||||
art: { coverBlob?: Uint8Array | null } | null | undefined,
|
||||
): StatsCoverImagePayload {
|
||||
if (!art?.coverBlob) return null;
|
||||
const bytes = new Uint8Array(art.coverBlob);
|
||||
const contentType = detectImageContentType(bytes);
|
||||
return {
|
||||
contentType,
|
||||
dataUrl: `data:${contentType};base64,${Buffer.from(bytes).toString('base64')}`,
|
||||
};
|
||||
}
|
||||
|
||||
function detectImageContentType(bytes: Uint8Array): string {
|
||||
if (
|
||||
bytes.length >= 8 &&
|
||||
bytes[0] === 0x89 &&
|
||||
bytes[1] === 0x50 &&
|
||||
bytes[2] === 0x4e &&
|
||||
bytes[3] === 0x47
|
||||
) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (
|
||||
bytes.length >= 12 &&
|
||||
bytes[0] === 0x52 &&
|
||||
bytes[1] === 0x49 &&
|
||||
bytes[2] === 0x46 &&
|
||||
bytes[3] === 0x46 &&
|
||||
bytes[8] === 0x57 &&
|
||||
bytes[9] === 0x45 &&
|
||||
bytes[10] === 0x42 &&
|
||||
bytes[11] === 0x50
|
||||
) {
|
||||
return 'image/webp';
|
||||
}
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function resolveStatsNoteFieldName(
|
||||
noteInfo: StatsServerNoteInfo,
|
||||
...preferredNames: (string | undefined)[]
|
||||
@@ -87,6 +177,57 @@ function resolveStatsNoteFieldName(
|
||||
return null;
|
||||
}
|
||||
|
||||
function uniqueFieldNames(...fieldNames: (string | null | undefined)[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const fieldName of fieldNames) {
|
||||
const normalized = fieldName?.trim();
|
||||
if (!normalized) continue;
|
||||
const key = normalized.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getStatsWordMiningAudioFieldName(
|
||||
ankiConfig: AnkiConnectConfig,
|
||||
noteInfo: StatsServerNoteInfo | null,
|
||||
): string {
|
||||
return (
|
||||
(noteInfo
|
||||
? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', ankiConfig.fields?.audio)
|
||||
: null) ??
|
||||
ankiConfig.fields?.audio ??
|
||||
'ExpressionAudio'
|
||||
);
|
||||
}
|
||||
|
||||
function getStatsDirectMiningAudioFieldNames(
|
||||
ankiConfig: AnkiConnectConfig,
|
||||
noteInfo: StatsServerNoteInfo | null,
|
||||
mode: 'sentence' | 'audio',
|
||||
): string[] {
|
||||
const configuredAudioField = ankiConfig.fields?.audio ?? 'ExpressionAudio';
|
||||
if (!ankiConfig.isLapis?.enabled && !ankiConfig.isKiku?.enabled) {
|
||||
return [configuredAudioField];
|
||||
}
|
||||
|
||||
const sentenceAudioField = noteInfo
|
||||
? resolveStatsNoteFieldName(noteInfo, 'SentenceAudio', configuredAudioField)
|
||||
: 'SentenceAudio';
|
||||
const expressionAudioField = noteInfo
|
||||
? resolveStatsNoteFieldName(noteInfo, configuredAudioField)
|
||||
: configuredAudioField;
|
||||
|
||||
if (mode === 'sentence') {
|
||||
return uniqueFieldNames(sentenceAudioField);
|
||||
}
|
||||
|
||||
return uniqueFieldNames(sentenceAudioField, expressionAudioField);
|
||||
}
|
||||
|
||||
function toFetchHeaders(headers: IncomingMessage['headers']): Headers {
|
||||
const fetchHeaders = new Headers();
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
@@ -256,9 +397,19 @@ export interface StatsServerConfig {
|
||||
knownWordCachePath?: string;
|
||||
mpvSocketPath?: string;
|
||||
ankiConnectConfig?: AnkiConnectConfig;
|
||||
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
|
||||
getYomitanAnkiDeckName?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
secondarySubtitleLanguages?: string[];
|
||||
getSecondarySubtitleLanguages?: () => string[] | undefined;
|
||||
statsMiningAlassPath?: string;
|
||||
getStatsMiningAlassPath?: () => string | null | undefined;
|
||||
resolveRetimedSecondarySubtitleText?: (
|
||||
input: RetimedSecondarySubtitleInput,
|
||||
) => Promise<string> | string;
|
||||
anilistRateLimiter?: AnilistRateLimiter;
|
||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||
resolveAnkiNoteId?: (noteId: number) => number;
|
||||
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
|
||||
}
|
||||
|
||||
const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
|
||||
@@ -279,6 +430,52 @@ const STATS_STATIC_CONTENT_TYPES: Record<string, string> = {
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
const ANKI_CONNECT_FETCH_TIMEOUT_MS = 3_000;
|
||||
const statsMiningLogger = createLogger('stats:mining');
|
||||
|
||||
function defaultNowMs(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function parseBooleanQuery(raw: string | undefined, fallback: boolean): boolean {
|
||||
if (raw === undefined) return fallback;
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (!normalized) return fallback;
|
||||
return !['0', 'false', 'no', 'off'].includes(normalized);
|
||||
}
|
||||
|
||||
function uniqueNonEmptyStrings(values: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const value of values) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
result.push(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function buildSentenceSearchOptions(
|
||||
query: string,
|
||||
searchByHeadword: boolean,
|
||||
resolveSentenceSearchHeadwords: ((term: string) => Promise<string[]> | string[]) | undefined,
|
||||
): Promise<{ headwordTerms: Array<{ term: string; headwords: string[] }> } | undefined> {
|
||||
if (!searchByHeadword) return undefined;
|
||||
|
||||
const terms = splitSentenceSearchTerms(query);
|
||||
const headwordTerms: Array<{ term: string; headwords: string[] }> = [];
|
||||
for (const term of terms) {
|
||||
const resolved = resolveSentenceSearchHeadwords
|
||||
? await resolveSentenceSearchHeadwords(term)
|
||||
: [term];
|
||||
const headwords = uniqueNonEmptyStrings(resolved);
|
||||
if (headwords.length > 0) {
|
||||
headwordTerms.push({ term, headwords });
|
||||
}
|
||||
}
|
||||
|
||||
return headwordTerms.length > 0 ? { headwordTerms } : undefined;
|
||||
}
|
||||
|
||||
function buildAnkiNotePreview(
|
||||
fields: Record<string, { value: string }>,
|
||||
@@ -340,12 +537,81 @@ export function createStatsApp(
|
||||
knownWordCachePath?: string;
|
||||
mpvSocketPath?: string;
|
||||
ankiConnectConfig?: AnkiConnectConfig;
|
||||
getAnkiConnectConfig?: () => AnkiConnectConfig | undefined;
|
||||
getYomitanAnkiDeckName?: () => Promise<string | null | undefined> | string | null | undefined;
|
||||
secondarySubtitleLanguages?: string[];
|
||||
getSecondarySubtitleLanguages?: () => string[] | undefined;
|
||||
statsMiningAlassPath?: string;
|
||||
getStatsMiningAlassPath?: () => string | null | undefined;
|
||||
resolveRetimedSecondarySubtitleText?: (
|
||||
input: RetimedSecondarySubtitleInput,
|
||||
) => Promise<string> | string;
|
||||
anilistRateLimiter?: AnilistRateLimiter;
|
||||
addYomitanNote?: (word: string) => Promise<number | null>;
|
||||
resolveAnkiNoteId?: (noteId: number) => number;
|
||||
resolveSentenceSearchHeadwords?: (term: string) => Promise<string[]> | string[];
|
||||
createMediaGenerator?: () => StatsServerMediaGenerator;
|
||||
onMiningTiming?: (event: StatsMiningTimingEvent) => void;
|
||||
nowMs?: () => number;
|
||||
},
|
||||
) {
|
||||
const app = new Hono();
|
||||
const nowMs = options?.nowMs ?? defaultNowMs;
|
||||
const getAnkiConnectConfig = (): AnkiConnectConfig | undefined =>
|
||||
options?.getAnkiConnectConfig?.() ?? options?.ankiConnectConfig;
|
||||
const getSecondarySubtitleLanguages = (): string[] =>
|
||||
options?.getSecondarySubtitleLanguages?.() ?? options?.secondarySubtitleLanguages ?? [];
|
||||
const getStatsMiningAlassPath = (): string | null | undefined =>
|
||||
options?.getStatsMiningAlassPath?.() ?? options?.statsMiningAlassPath;
|
||||
const getEffectiveMiningDeckName = async (ankiConfig: AnkiConnectConfig): Promise<string> => {
|
||||
const configuredDeckName = ankiConfig.deck?.trim() ?? '';
|
||||
if (configuredDeckName) return configuredDeckName;
|
||||
|
||||
try {
|
||||
const yomitanDeckName = await options?.getYomitanAnkiDeckName?.();
|
||||
return typeof yomitanDeckName === 'string' ? yomitanDeckName.trim() : '';
|
||||
} catch (error) {
|
||||
statsMiningLogger.warn(
|
||||
'Failed to resolve Yomitan Anki deck for stats mining:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const recordMiningTiming = (event: StatsMiningTimingEvent): void => {
|
||||
options?.onMiningTiming?.(event);
|
||||
statsMiningLogger.debug(
|
||||
`[stats:mining] ${event.mode} ${event.phase} ${Math.round(event.elapsedMs)}ms`,
|
||||
event,
|
||||
);
|
||||
};
|
||||
|
||||
const timeMiningPhase = async <T>(
|
||||
mode: StatsMiningTimingEvent['mode'],
|
||||
phase: string,
|
||||
fn: () => Promise<T>,
|
||||
details?: (value: T) => Partial<StatsMiningTimingEvent>,
|
||||
): Promise<T> => {
|
||||
const startedAtMs = nowMs();
|
||||
try {
|
||||
const value = await fn();
|
||||
recordMiningTiming({
|
||||
mode,
|
||||
phase,
|
||||
elapsedMs: nowMs() - startedAtMs,
|
||||
...details?.(value),
|
||||
});
|
||||
return value;
|
||||
} catch (err) {
|
||||
recordMiningTiming({
|
||||
mode,
|
||||
phase,
|
||||
elapsedMs: nowMs() - startedAtMs,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/api/stats/overview', async (c) => {
|
||||
const [rawSessions, rollups, hints] = await Promise.all([
|
||||
@@ -509,6 +775,20 @@ export function createStatsApp(
|
||||
return c.json(occurrences);
|
||||
});
|
||||
|
||||
app.get('/api/stats/sentences/search', async (c) => {
|
||||
const query = (c.req.query('q') ?? '').trim();
|
||||
if (!query) return c.json([]);
|
||||
const limit = parseIntQuery(c.req.query('limit'), 50, 100);
|
||||
const searchByHeadword = parseBooleanQuery(c.req.query('headword'), true);
|
||||
const searchOptions = await buildSentenceSearchOptions(
|
||||
query,
|
||||
searchByHeadword,
|
||||
options?.resolveSentenceSearchHeadwords,
|
||||
);
|
||||
const rows = await tracker.searchSubtitleSentences(query, limit, searchOptions);
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
app.get('/api/stats/kanji', async (c) => {
|
||||
const limit = parseIntQuery(c.req.query('limit'), 100, 500);
|
||||
const kanji = await tracker.getKanjiStats(limit);
|
||||
@@ -707,14 +987,36 @@ export function createStatsApp(
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/stats/covers', async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as StatsCoverBatchBody | null;
|
||||
const animeIds = parsePositiveIdList(body?.animeIds);
|
||||
const videoIds = parsePositiveIdList(body?.videoIds);
|
||||
const anime: Record<number, StatsCoverImagePayload> = {};
|
||||
const media: Record<number, StatsCoverImagePayload> = {};
|
||||
|
||||
await Promise.all(
|
||||
animeIds.map(async (animeId) => {
|
||||
anime[animeId] = coverImagePayload(await tracker.getAnimeCoverArt(animeId));
|
||||
}),
|
||||
);
|
||||
await Promise.all(
|
||||
videoIds.map(async (videoId) => {
|
||||
media[videoId] = coverImagePayload(await tracker.getCoverArt(videoId));
|
||||
}),
|
||||
);
|
||||
|
||||
return c.json({ anime, media });
|
||||
});
|
||||
|
||||
app.get('/api/stats/anime/:animeId/cover', async (c) => {
|
||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||
if (animeId <= 0) return c.body(null, 404);
|
||||
const art = await tracker.getAnimeCoverArt(animeId);
|
||||
if (!art?.coverBlob) return c.body(null, 404);
|
||||
return new Response(new Uint8Array(art.coverBlob), {
|
||||
const bytes = new Uint8Array(art.coverBlob);
|
||||
return new Response(bytes, {
|
||||
headers: {
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Content-Type': detectImageContentType(bytes),
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
},
|
||||
});
|
||||
@@ -729,9 +1031,10 @@ export function createStatsApp(
|
||||
art = await tracker.getCoverArt(videoId);
|
||||
}
|
||||
if (!art?.coverBlob) return c.body(null, 404);
|
||||
return new Response(new Uint8Array(art.coverBlob), {
|
||||
const bytes = new Uint8Array(art.coverBlob);
|
||||
return new Response(bytes, {
|
||||
headers: {
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Content-Type': detectImageContentType(bytes),
|
||||
'Cache-Control': 'public, max-age=604800',
|
||||
},
|
||||
});
|
||||
@@ -754,8 +1057,9 @@ export function createStatsApp(
|
||||
app.post('/api/stats/anki/browse', async (c) => {
|
||||
const noteId = parseIntQuery(c.req.query('noteId'), 0);
|
||||
if (noteId <= 0) return c.body(null, 400);
|
||||
const ankiConfig = getAnkiConnectConfig();
|
||||
try {
|
||||
const response = await fetch('http://127.0.0.1:8765', {
|
||||
const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
|
||||
@@ -791,7 +1095,8 @@ export function createStatsApp(
|
||||
),
|
||||
);
|
||||
try {
|
||||
const response = await fetch('http://127.0.0.1:8765', {
|
||||
const ankiConfig = getAnkiConnectConfig();
|
||||
const response = await fetch(ankiConfig?.url ?? 'http://127.0.0.1:8765', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(ANKI_CONNECT_FETCH_TIMEOUT_MS),
|
||||
@@ -807,7 +1112,7 @@ export function createStatsApp(
|
||||
return c.json(
|
||||
(result.result ?? []).map((note) => ({
|
||||
...note,
|
||||
preview: buildAnkiNotePreview(note.fields, options?.ankiConnectConfig),
|
||||
preview: buildAnkiNotePreview(note.fields, ankiConfig),
|
||||
})),
|
||||
);
|
||||
} catch {
|
||||
@@ -822,7 +1127,8 @@ export function createStatsApp(
|
||||
const endMs = typeof body?.endMs === 'number' ? body.endMs : NaN;
|
||||
const sentence = typeof body?.sentence === 'string' ? body.sentence.trim() : '';
|
||||
const word = typeof body?.word === 'string' ? body.word.trim() : '';
|
||||
const secondaryText = typeof body?.secondaryText === 'string' ? body.secondaryText.trim() : '';
|
||||
const bodySecondaryText =
|
||||
typeof body?.secondaryText === 'string' ? body.secondaryText.trim() : '';
|
||||
const videoTitle = typeof body?.videoTitle === 'string' ? body.videoTitle.trim() : '';
|
||||
const rawMode = c.req.query('mode');
|
||||
const mode = rawMode === 'audio' ? 'audio' : rawMode === 'word' ? 'word' : 'sentence';
|
||||
@@ -830,18 +1136,51 @@ export function createStatsApp(
|
||||
if (!sourcePath || !sentence || !Number.isFinite(startMs) || !Number.isFinite(endMs)) {
|
||||
return c.json({ error: 'sourcePath, sentence, startMs, and endMs are required' }, 400);
|
||||
}
|
||||
if (endMs <= startMs) {
|
||||
return c.json({ error: 'endMs must be greater than startMs' }, 400);
|
||||
}
|
||||
|
||||
if (!existsSync(sourcePath)) {
|
||||
return c.json({ error: 'File not found' }, 404);
|
||||
}
|
||||
|
||||
const ankiConfig = options?.ankiConnectConfig;
|
||||
const ankiConfig = getAnkiConnectConfig();
|
||||
if (!ankiConfig) {
|
||||
return c.json({ error: 'AnkiConnect is not configured' }, 500);
|
||||
}
|
||||
const secondarySubtitleLanguages = getSecondarySubtitleLanguages();
|
||||
let retimedSecondaryText = '';
|
||||
if (mode === 'sentence' && !bodySecondaryText) {
|
||||
try {
|
||||
retimedSecondaryText = await (
|
||||
options?.resolveRetimedSecondarySubtitleText ??
|
||||
resolveRetimedSecondarySubtitleTextFromSidecar
|
||||
)({
|
||||
sourcePath,
|
||||
startMs,
|
||||
endMs,
|
||||
languages: secondarySubtitleLanguages,
|
||||
alassPath: getStatsMiningAlassPath(),
|
||||
});
|
||||
} catch (error) {
|
||||
statsMiningLogger.warn(
|
||||
'Failed to resolve retimed secondary subtitle for stats mining:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
const secondaryText =
|
||||
bodySecondaryText ||
|
||||
retimedSecondaryText ||
|
||||
resolveSecondarySubtitleTextFromSidecar({
|
||||
sourcePath,
|
||||
startMs,
|
||||
endMs,
|
||||
languages: secondarySubtitleLanguages,
|
||||
});
|
||||
|
||||
const client = new AnkiConnectClient(ankiConfig.url ?? 'http://127.0.0.1:8765');
|
||||
const mediaGen = new MediaGenerator();
|
||||
const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator();
|
||||
|
||||
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
|
||||
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
|
||||
@@ -865,7 +1204,9 @@ export function createStatsApp(
|
||||
imageType === 'avif' && ankiConfig.media?.syncAnimatedImageToWordAudio !== false;
|
||||
|
||||
const audioPromise = generateAudio
|
||||
? mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding)
|
||||
? timeMiningPhase(mode, 'generateAudio', () =>
|
||||
mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding),
|
||||
)
|
||||
: Promise.resolve(null);
|
||||
|
||||
const createImagePromise = (animatedLeadInSeconds = 0): Promise<Buffer | null> => {
|
||||
@@ -874,22 +1215,26 @@ export function createStatsApp(
|
||||
}
|
||||
|
||||
if (imageType === 'avif') {
|
||||
return mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, {
|
||||
fps: ankiConfig.media?.animatedFps ?? 10,
|
||||
maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640,
|
||||
maxHeight: ankiConfig.media?.animatedMaxHeight,
|
||||
crf: ankiConfig.media?.animatedCrf ?? 35,
|
||||
leadingStillDuration: animatedLeadInSeconds,
|
||||
});
|
||||
return timeMiningPhase(mode, 'generateAnimatedImage', () =>
|
||||
mediaGen.generateAnimatedImage(sourcePath, startSec, clampedEndSec, audioPadding, {
|
||||
fps: ankiConfig.media?.animatedFps ?? 10,
|
||||
maxWidth: ankiConfig.media?.animatedMaxWidth ?? 640,
|
||||
maxHeight: ankiConfig.media?.animatedMaxHeight,
|
||||
crf: ankiConfig.media?.animatedCrf ?? 35,
|
||||
leadingStillDuration: animatedLeadInSeconds,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const midpointSec = (startSec + clampedEndSec) / 2;
|
||||
return mediaGen.generateScreenshot(sourcePath, midpointSec, {
|
||||
format: ankiConfig.media?.imageFormat ?? 'jpg',
|
||||
quality: ankiConfig.media?.imageQuality ?? 92,
|
||||
maxWidth: ankiConfig.media?.imageMaxWidth,
|
||||
maxHeight: ankiConfig.media?.imageMaxHeight,
|
||||
});
|
||||
return timeMiningPhase(mode, 'generateScreenshot', () =>
|
||||
mediaGen.generateScreenshot(sourcePath, midpointSec, {
|
||||
format: ankiConfig.media?.imageFormat ?? 'jpg',
|
||||
quality: ankiConfig.media?.imageQuality ?? 92,
|
||||
maxWidth: ankiConfig.media?.imageMaxWidth,
|
||||
maxHeight: ankiConfig.media?.imageMaxHeight,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const imagePromise =
|
||||
@@ -899,6 +1244,25 @@ export function createStatsApp(
|
||||
|
||||
const errors: string[] = [];
|
||||
let noteId: number;
|
||||
let effectiveDeckNamePromise: Promise<string> | null = null;
|
||||
const getEffectiveDeckNameForRequest = (): Promise<string> => {
|
||||
effectiveDeckNamePromise ??= getEffectiveMiningDeckName(ankiConfig);
|
||||
return effectiveDeckNamePromise;
|
||||
};
|
||||
const moveNoteToConfiguredDeck = async (id: number): Promise<void> => {
|
||||
const deckName = await getEffectiveDeckNameForRequest();
|
||||
if (!deckName) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cardIds = await timeMiningPhase(mode, 'findCards', () =>
|
||||
client.findCards(`nid:${id}`),
|
||||
);
|
||||
await timeMiningPhase(mode, 'changeDeck', () => client.changeDeck(cardIds, deckName));
|
||||
} catch (err) {
|
||||
errors.push(`deck: ${(err as Error).message}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (mode === 'word') {
|
||||
if (!options?.addYomitanNote) {
|
||||
@@ -906,7 +1270,12 @@ export function createStatsApp(
|
||||
}
|
||||
|
||||
const [yomitanResult, audioResult, imageResult] = await Promise.allSettled([
|
||||
options.addYomitanNote(word),
|
||||
timeMiningPhase(
|
||||
'word',
|
||||
'addYomitanNote',
|
||||
() => options.addYomitanNote!(word),
|
||||
(noteId) => (typeof noteId === 'number' ? { noteId } : {}),
|
||||
),
|
||||
audioPromise,
|
||||
imagePromise,
|
||||
]);
|
||||
@@ -921,6 +1290,7 @@ export function createStatsApp(
|
||||
}
|
||||
|
||||
noteId = yomitanResult.value;
|
||||
await moveNoteToConfiguredDeck(noteId);
|
||||
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
|
||||
if (audioResult.status === 'rejected')
|
||||
errors.push(`audio: ${(audioResult.reason as Error).message}`);
|
||||
@@ -928,10 +1298,19 @@ export function createStatsApp(
|
||||
errors.push(`image: ${(imageResult.reason as Error).message}`);
|
||||
|
||||
let imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
||||
if (syncAnimatedImageToWordAudio && generateImage) {
|
||||
let noteInfo: StatsServerNoteInfo | null = null;
|
||||
if (audioBuffer || (syncAnimatedImageToWordAudio && generateImage)) {
|
||||
try {
|
||||
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
||||
const noteInfo = noteInfoResult[0] ?? null;
|
||||
noteInfo = noteInfoResult[0] ?? null;
|
||||
} catch (err) {
|
||||
if (syncAnimatedImageToWordAudio && generateImage) {
|
||||
errors.push(`image: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (syncAnimatedImageToWordAudio && generateImage) {
|
||||
try {
|
||||
const animatedLeadInSeconds = noteInfo
|
||||
? await resolveAnimatedImageLeadInSeconds({
|
||||
config: ankiConfig,
|
||||
@@ -946,22 +1325,27 @@ export function createStatsApp(
|
||||
errors.push(`image: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (generateAudio && !audioBuffer && audioResult.status === 'fulfilled') {
|
||||
errors.push('audio: no audio generated');
|
||||
}
|
||||
if (generateImage && !imageBuffer) {
|
||||
errors.push('image: no image generated');
|
||||
}
|
||||
|
||||
const mediaFields: Record<string, string> = {};
|
||||
const timestamp = Date.now();
|
||||
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
|
||||
const audioFieldName = ankiConfig.fields?.audio ?? 'ExpressionAudio';
|
||||
const audioFieldName = getStatsWordMiningAudioFieldName(ankiConfig, noteInfo);
|
||||
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
||||
|
||||
mediaFields[sentenceFieldName] = highlightedSentence;
|
||||
if (secondaryText) {
|
||||
mediaFields[ankiConfig.fields?.translation ?? 'SelectionText'] = secondaryText;
|
||||
}
|
||||
|
||||
if (audioBuffer) {
|
||||
const audioFilename = `subminer_audio_${timestamp}.mp3`;
|
||||
try {
|
||||
await client.storeMediaFile(audioFilename, audioBuffer);
|
||||
await timeMiningPhase('word', 'uploadAudio', () =>
|
||||
client.storeMediaFile(audioFilename, audioBuffer),
|
||||
);
|
||||
mediaFields[audioFieldName] = `[sound:${audioFilename}]`;
|
||||
} catch (err) {
|
||||
errors.push(`audio upload: ${(err as Error).message}`);
|
||||
@@ -972,7 +1356,9 @@ export function createStatsApp(
|
||||
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
|
||||
const imageFilename = `subminer_image_${timestamp}.${imageExt}`;
|
||||
try {
|
||||
await client.storeMediaFile(imageFilename, imageBuffer);
|
||||
await timeMiningPhase('word', 'uploadImage', () =>
|
||||
client.storeMediaFile(imageFilename, imageBuffer),
|
||||
);
|
||||
mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
|
||||
} catch (err) {
|
||||
errors.push(`image upload: ${(err as Error).message}`);
|
||||
@@ -1000,7 +1386,9 @@ export function createStatsApp(
|
||||
|
||||
if (Object.keys(mediaFields).length > 0) {
|
||||
try {
|
||||
await client.updateNoteFields(noteId, mediaFields);
|
||||
await timeMiningPhase('word', 'updateNoteFields', () =>
|
||||
client.updateNoteFields(noteId, mediaFields),
|
||||
);
|
||||
} catch (err) {
|
||||
errors.push(`update fields: ${(err as Error).message}`);
|
||||
}
|
||||
@@ -1009,32 +1397,24 @@ export function createStatsApp(
|
||||
return c.json({ noteId, ...(errors.length > 0 ? { errors } : {}) });
|
||||
}
|
||||
|
||||
const [audioResult, imageResult] = await Promise.allSettled([audioPromise, imagePromise]);
|
||||
|
||||
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
|
||||
const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
||||
if (audioResult.status === 'rejected')
|
||||
errors.push(`audio: ${(audioResult.reason as Error).message}`);
|
||||
if (imageResult.status === 'rejected')
|
||||
errors.push(`image: ${(imageResult.reason as Error).message}`);
|
||||
|
||||
const wordFieldName = getConfiguredWordFieldName(ankiConfig);
|
||||
const sentenceFieldName = ankiConfig.fields?.sentence ?? 'Sentence';
|
||||
const translationFieldName = ankiConfig.fields?.translation ?? 'SelectionText';
|
||||
const audioFieldName = ankiConfig.fields?.audio ?? 'ExpressionAudio';
|
||||
const imageFieldName = ankiConfig.fields?.image ?? 'Picture';
|
||||
const miscInfoFieldName = ankiConfig.fields?.miscInfo ?? '';
|
||||
|
||||
const fields: Record<string, string> = {
|
||||
[sentenceFieldName]: highlightedSentence,
|
||||
[sentenceFieldName]: mode === 'sentence' ? sentence : highlightedSentence,
|
||||
};
|
||||
|
||||
if (secondaryText) {
|
||||
if (mode === 'sentence' && secondaryText) {
|
||||
fields[translationFieldName] = secondaryText;
|
||||
}
|
||||
|
||||
if (ankiConfig.isLapis?.enabled || ankiConfig.isKiku?.enabled) {
|
||||
if (word) {
|
||||
if (mode === 'sentence') {
|
||||
fields[wordFieldName] = sentence;
|
||||
} else if (word) {
|
||||
fields[wordFieldName] = word;
|
||||
}
|
||||
if (mode === 'sentence') {
|
||||
@@ -1045,23 +1425,62 @@ export function createStatsApp(
|
||||
}
|
||||
|
||||
const model = ankiConfig.isLapis?.sentenceCardModel || 'Basic';
|
||||
const deck = ankiConfig.deck ?? 'Default';
|
||||
const tags = ankiConfig.tags ?? ['SubMiner'];
|
||||
|
||||
try {
|
||||
noteId = await client.addNote(deck, model, fields, tags);
|
||||
} catch (err) {
|
||||
return c.json({ error: `Failed to add note: ${(err as Error).message}` }, 502);
|
||||
const addNotePromise = timeMiningPhase(
|
||||
mode,
|
||||
'addNote',
|
||||
async () =>
|
||||
client.addNote((await getEffectiveDeckNameForRequest()) || 'Default', model, fields, tags),
|
||||
(id) => ({
|
||||
noteId: id,
|
||||
}),
|
||||
);
|
||||
|
||||
const [audioResult, imageResult, addNoteResult] = await Promise.allSettled([
|
||||
audioPromise,
|
||||
imagePromise,
|
||||
addNotePromise,
|
||||
]);
|
||||
|
||||
const audioBuffer = audioResult.status === 'fulfilled' ? audioResult.value : null;
|
||||
const imageBuffer = imageResult.status === 'fulfilled' ? imageResult.value : null;
|
||||
if (audioResult.status === 'rejected')
|
||||
errors.push(`audio: ${(audioResult.reason as Error).message}`);
|
||||
if (imageResult.status === 'rejected')
|
||||
errors.push(`image: ${(imageResult.reason as Error).message}`);
|
||||
|
||||
if (addNoteResult.status === 'rejected') {
|
||||
return c.json(
|
||||
{ error: `Failed to add note: ${(addNoteResult.reason as Error).message}` },
|
||||
502,
|
||||
);
|
||||
}
|
||||
noteId = addNoteResult.value;
|
||||
await moveNoteToConfiguredDeck(noteId);
|
||||
|
||||
const mediaFields: Record<string, string> = {};
|
||||
const timestamp = Date.now();
|
||||
let noteInfo: StatsServerNoteInfo | null = null;
|
||||
if (audioBuffer) {
|
||||
try {
|
||||
const noteInfoResult = (await client.notesInfo([noteId])) as StatsServerNoteInfo[];
|
||||
noteInfo = noteInfoResult[0] ?? null;
|
||||
} catch {
|
||||
noteInfo = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (audioBuffer) {
|
||||
const audioFilename = `subminer_audio_${timestamp}.mp3`;
|
||||
try {
|
||||
await client.storeMediaFile(audioFilename, audioBuffer);
|
||||
mediaFields[audioFieldName] = `[sound:${audioFilename}]`;
|
||||
await timeMiningPhase(mode, 'uploadAudio', () =>
|
||||
client.storeMediaFile(audioFilename, audioBuffer),
|
||||
);
|
||||
const audioValue = `[sound:${audioFilename}]`;
|
||||
for (const fieldName of getStatsDirectMiningAudioFieldNames(ankiConfig, noteInfo, mode)) {
|
||||
mediaFields[fieldName] = audioValue;
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`audio upload: ${(err as Error).message}`);
|
||||
}
|
||||
@@ -1071,7 +1490,9 @@ export function createStatsApp(
|
||||
const imageExt = imageType === 'avif' ? 'avif' : (ankiConfig.media?.imageFormat ?? 'jpg');
|
||||
const imageFilename = `subminer_image_${timestamp}.${imageExt}`;
|
||||
try {
|
||||
await client.storeMediaFile(imageFilename, imageBuffer);
|
||||
await timeMiningPhase(mode, 'uploadImage', () =>
|
||||
client.storeMediaFile(imageFilename, imageBuffer),
|
||||
);
|
||||
mediaFields[imageFieldName] = `<img src="${imageFilename}">`;
|
||||
} catch (err) {
|
||||
errors.push(`image upload: ${(err as Error).message}`);
|
||||
@@ -1099,7 +1520,9 @@ export function createStatsApp(
|
||||
|
||||
if (Object.keys(mediaFields).length > 0) {
|
||||
try {
|
||||
await client.updateNoteFields(noteId, mediaFields);
|
||||
await timeMiningPhase(mode, 'updateNoteFields', () =>
|
||||
client.updateNoteFields(noteId, mediaFields),
|
||||
);
|
||||
} catch (err) {
|
||||
errors.push(`update fields: ${(err as Error).message}`);
|
||||
}
|
||||
@@ -1139,9 +1562,17 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
|
||||
knownWordCachePath: config.knownWordCachePath,
|
||||
mpvSocketPath: config.mpvSocketPath,
|
||||
ankiConnectConfig: config.ankiConnectConfig,
|
||||
getAnkiConnectConfig: config.getAnkiConnectConfig,
|
||||
getYomitanAnkiDeckName: config.getYomitanAnkiDeckName,
|
||||
secondarySubtitleLanguages: config.secondarySubtitleLanguages,
|
||||
getSecondarySubtitleLanguages: config.getSecondarySubtitleLanguages,
|
||||
statsMiningAlassPath: config.statsMiningAlassPath,
|
||||
getStatsMiningAlassPath: config.getStatsMiningAlassPath,
|
||||
resolveRetimedSecondarySubtitleText: config.resolveRetimedSecondarySubtitleText,
|
||||
anilistRateLimiter: config.anilistRateLimiter,
|
||||
addYomitanNote: config.addYomitanNote,
|
||||
resolveAnkiNoteId: config.resolveAnkiNoteId,
|
||||
resolveSentenceSearchHeadwords: config.resolveSentenceSearchHeadwords,
|
||||
});
|
||||
|
||||
const bunRuntime = globalThis as typeof globalThis & {
|
||||
|
||||
@@ -151,6 +151,56 @@ test('syncYomitanDefaultAnkiServer injects force override when enabled', async (
|
||||
assert.match(scriptValue, /forceOverride = true/);
|
||||
});
|
||||
|
||||
test('syncYomitanDefaultAnkiServer updates the active profile Anki deck', async () => {
|
||||
const optionsFull = {
|
||||
profileCurrent: 0,
|
||||
profiles: [
|
||||
{
|
||||
options: {
|
||||
anki: {
|
||||
server: 'http://127.0.0.1:8766',
|
||||
cardFormats: [
|
||||
{ type: 'term', deck: 'Default', model: 'Mining Note', fields: {} },
|
||||
{ type: 'kanji', deck: 'Kanji', model: 'Kanji Note', fields: {} },
|
||||
],
|
||||
terms: { deck: 'Default', model: 'Legacy Note', fields: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
let savedOptions: typeof optionsFull | null = null;
|
||||
const deps = createDeps((script) =>
|
||||
runInjectedYomitanScript(script, (action, params) => {
|
||||
if (action === 'optionsGetFull') {
|
||||
return JSON.parse(JSON.stringify(optionsFull));
|
||||
}
|
||||
if (action === 'setAllSettings') {
|
||||
savedOptions = (params as { value: typeof optionsFull }).value;
|
||||
return true;
|
||||
}
|
||||
throw new Error(`Unexpected action: ${action}`);
|
||||
}),
|
||||
);
|
||||
|
||||
const synced = await syncYomitanDefaultAnkiServer(
|
||||
'http://127.0.0.1:8766',
|
||||
deps,
|
||||
{
|
||||
error: () => undefined,
|
||||
info: () => undefined,
|
||||
},
|
||||
{ deck: 'Minecraft', forceOverride: true },
|
||||
);
|
||||
|
||||
assert.equal(synced, true);
|
||||
assert.ok(savedOptions);
|
||||
const saved = savedOptions as typeof optionsFull;
|
||||
assert.equal(saved.profiles[0]?.options.anki.cardFormats[0]?.deck, 'Minecraft');
|
||||
assert.equal(saved.profiles[0]?.options.anki.cardFormats[1]?.deck, 'Kanji');
|
||||
assert.equal(saved.profiles[0]?.options.anki.terms.deck, 'Minecraft');
|
||||
});
|
||||
|
||||
test('syncYomitanDefaultAnkiServer logs and returns false on script failure', async () => {
|
||||
const deps = createDeps(async () => {
|
||||
throw new Error('execute failed');
|
||||
|
||||
@@ -1783,6 +1783,7 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
logger: LoggerLike,
|
||||
options?: {
|
||||
forceOverride?: boolean;
|
||||
deck?: string;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const normalizedTargetServer = serverUrl.trim();
|
||||
@@ -1790,6 +1791,7 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
return false;
|
||||
}
|
||||
const forceOverride = options?.forceOverride === true;
|
||||
const normalizedTargetDeck = options?.deck?.trim() ?? '';
|
||||
|
||||
const isReady = await ensureYomitanParserWindow(deps, logger);
|
||||
const parserWindow = deps.getYomitanParserWindow();
|
||||
@@ -1819,6 +1821,7 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
});
|
||||
|
||||
const targetServer = ${JSON.stringify(normalizedTargetServer)};
|
||||
const targetDeck = ${JSON.stringify(normalizedTargetDeck)};
|
||||
const forceOverride = ${forceOverride ? 'true' : 'false'};
|
||||
const optionsFull = await invoke("optionsGetFull", undefined);
|
||||
const profiles = Array.isArray(optionsFull.profiles) ? optionsFull.profiles : [];
|
||||
@@ -1843,18 +1846,54 @@ export async function syncYomitanDefaultAnkiServer(
|
||||
|
||||
const currentServerRaw = targetProfile.options.anki.server;
|
||||
const currentServer = typeof currentServerRaw === "string" ? currentServerRaw.trim() : "";
|
||||
if (currentServer === targetServer) {
|
||||
return { updated: false, matched: true, reason: "already-target", currentServer, targetServer };
|
||||
}
|
||||
const canReplaceCurrent =
|
||||
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765";
|
||||
if (!canReplaceCurrent) {
|
||||
return { updated: false, matched: false, reason: "blocked-existing-server", currentServer, targetServer };
|
||||
let changed = false;
|
||||
if (currentServer !== targetServer) {
|
||||
const canReplaceCurrent =
|
||||
forceOverride || currentServer.length === 0 || currentServer === "http://127.0.0.1:8765";
|
||||
if (!canReplaceCurrent) {
|
||||
return { updated: false, matched: false, reason: "blocked-existing-server", currentServer, targetServer };
|
||||
}
|
||||
|
||||
targetProfile.options.anki.server = targetServer;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (targetDeck) {
|
||||
const cardFormats = Array.isArray(targetProfile.options.anki.cardFormats)
|
||||
? targetProfile.options.anki.cardFormats
|
||||
: [];
|
||||
for (const cardFormat of cardFormats) {
|
||||
if (
|
||||
!cardFormat ||
|
||||
typeof cardFormat !== "object" ||
|
||||
cardFormat.type !== "term" ||
|
||||
cardFormat.enabled === false
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const currentDeck = typeof cardFormat.deck === "string" ? cardFormat.deck.trim() : "";
|
||||
if (currentDeck !== targetDeck) {
|
||||
cardFormat.deck = targetDeck;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const terms = targetProfile.options.anki.terms;
|
||||
if (terms && typeof terms === "object") {
|
||||
const currentTermDeck = typeof terms.deck === "string" ? terms.deck.trim() : "";
|
||||
if (currentTermDeck !== targetDeck) {
|
||||
terms.deck = targetDeck;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { updated: false, matched: true, reason: "already-target", currentServer, targetServer, targetDeck };
|
||||
}
|
||||
|
||||
targetProfile.options.anki.server = targetServer;
|
||||
await invoke("setAllSettings", { value: optionsFull, source: "subminer" });
|
||||
return { updated: true, matched: true, currentServer, targetServer };
|
||||
return { updated: true, matched: true, currentServer, targetServer, targetDeck };
|
||||
})();
|
||||
`;
|
||||
|
||||
|
||||
@@ -28,8 +28,6 @@ export function buildWindowsMpvPluginRuntimeConfig(
|
||||
autoStartVisibleOverlay: config.auto_start_overlay,
|
||||
autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady,
|
||||
texthookerEnabled: config.texthooker.launchAtStartup,
|
||||
aniskipEnabled: config.mpv.aniskipEnabled,
|
||||
aniskipButtonKey: config.mpv.aniskipButtonKey,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -326,8 +326,6 @@ test('readConfiguredWindowsMpvLaunch includes defaults for runtime plugin script
|
||||
autoStartVisibleOverlay: DEFAULT_CONFIG.auto_start_overlay,
|
||||
autoStartPauseUntilReady: DEFAULT_CONFIG.mpv.pauseUntilOverlayReady,
|
||||
texthookerEnabled: DEFAULT_CONFIG.texthooker.launchAtStartup,
|
||||
aniskipEnabled: DEFAULT_CONFIG.mpv.aniskipEnabled,
|
||||
aniskipButtonKey: DEFAULT_CONFIG.mpv.aniskipButtonKey,
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
@@ -359,8 +357,6 @@ test('readConfiguredWindowsMpvLaunch preserves configured runtime plugin script
|
||||
autoStartSubMiner: false,
|
||||
pauseUntilOverlayReady: false,
|
||||
subminerBinaryPath: 'C:\\SubMiner\\Custom.exe',
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8',
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -382,8 +378,6 @@ test('readConfiguredWindowsMpvLaunch preserves configured runtime plugin script
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
texthookerEnabled: true,
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8',
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
|
||||
+91
-20
@@ -33,6 +33,8 @@ import {
|
||||
} from 'electron';
|
||||
import { applyControllerConfigUpdate } from './main/controller-config-update.js';
|
||||
import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtime/playlist-browser-open';
|
||||
import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
|
||||
import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata';
|
||||
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
||||
import { startAppControlServer } from './main/runtime/app-control-server';
|
||||
import {
|
||||
@@ -1468,8 +1470,6 @@ function getMpvPluginRuntimeConfig() {
|
||||
autoStartVisibleOverlay: config.auto_start_overlay,
|
||||
autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady,
|
||||
texthookerEnabled: config.texthooker.launchAtStartup,
|
||||
aniskipEnabled: config.mpv.aniskipEnabled,
|
||||
aniskipButtonKey: config.mpv.aniskipButtonKey,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1833,6 +1833,31 @@ function getCurrentAutoplaySubtitlePayload(): SubtitleData | null {
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function resolveSentenceSearchHeadwords(term: string): Promise<string[]> {
|
||||
const fallback = term.trim() ? [term.trim()] : [];
|
||||
try {
|
||||
const tokenized = tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(term) : null;
|
||||
const tokens = tokenized?.tokens ?? [];
|
||||
if (tokens.length === 0) return fallback;
|
||||
|
||||
const seen = new Set<string>();
|
||||
const headwords: string[] = [];
|
||||
for (const token of tokens) {
|
||||
const headword = (token.headword || token.surface).trim();
|
||||
if (!headword || seen.has(headword)) continue;
|
||||
seen.add(headword);
|
||||
headwords.push(headword);
|
||||
}
|
||||
return headwords.length > 0 ? headwords : fallback;
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
'Failed to resolve sentence-search headwords:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function signalCurrentSubtitleAutoplayReady(): void {
|
||||
autoplayReadyGate.flushPendingAutoplayReadySignal();
|
||||
const payload = getCurrentAutoplaySubtitlePayload();
|
||||
@@ -2206,6 +2231,9 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
|
||||
setLogFileToggles: (files) => {
|
||||
setLogFileToggles(files);
|
||||
},
|
||||
applyAniSkipConfig: () => {
|
||||
aniSkipRuntime.applyConfigChange();
|
||||
},
|
||||
},
|
||||
);
|
||||
const applyConfigHotReloadDiff = createConfigHotReloadAppliedHandler(
|
||||
@@ -2240,6 +2268,18 @@ const configHotReloadRuntime = createConfigHotReloadRuntime(
|
||||
buildConfigHotReloadRuntimeMainDepsHandler(),
|
||||
);
|
||||
|
||||
async function getCurrentYomitanAnkiDeckNameForRuntime(): Promise<string> {
|
||||
await yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
|
||||
return getYomitanCurrentAnkiDeckNameCore(getYomitanParserRuntimeDeps(), {
|
||||
error: (message, ...args) => {
|
||||
logger.error(message, ...args);
|
||||
},
|
||||
info: (message, ...args) => {
|
||||
logger.info(message, ...args);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const configSettingsRuntime = createConfigSettingsRuntime({
|
||||
fields: configSettingsFields,
|
||||
getConfigPath: () => configService.getConfigPath(),
|
||||
@@ -2250,17 +2290,7 @@ const configSettingsRuntime = createConfigSettingsRuntime({
|
||||
onHotReloadApplied: applyConfigHotReloadDiff,
|
||||
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
|
||||
createAnkiClient: (url) => new AnkiConnectClient(url),
|
||||
getYomitanAnkiDeckName: async () => {
|
||||
await yomitanExtensionRuntime.ensureYomitanExtensionLoaded();
|
||||
return getYomitanCurrentAnkiDeckNameCore(getYomitanParserRuntimeDeps(), {
|
||||
error: (message, ...args) => {
|
||||
logger.error(message, ...args);
|
||||
},
|
||||
info: (message, ...args) => {
|
||||
logger.info(message, ...args);
|
||||
},
|
||||
});
|
||||
},
|
||||
getYomitanAnkiDeckName: getCurrentYomitanAnkiDeckNameForRuntime,
|
||||
getSettingsWindow: () => appState.configSettingsWindow,
|
||||
setSettingsWindow: (window) => {
|
||||
appState.configSettingsWindow = window as BrowserWindow | null;
|
||||
@@ -4441,14 +4471,20 @@ const startLocalStatsServer = (): void => {
|
||||
tracker,
|
||||
knownWordCachePath: path.join(USER_DATA_PATH, 'known-words-cache.json'),
|
||||
mpvSocketPath: appState.mpvSocketPath,
|
||||
ankiConnectConfig: getResolvedConfig().ankiConnect,
|
||||
getAnkiConnectConfig: () => getResolvedConfig().ankiConnect,
|
||||
getYomitanAnkiDeckName: getCurrentYomitanAnkiDeckNameForRuntime,
|
||||
getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages,
|
||||
getStatsMiningAlassPath: () => getResolvedConfig().subsync.alass_path,
|
||||
anilistRateLimiter,
|
||||
resolveAnkiNoteId: (noteId: number) =>
|
||||
appState.ankiIntegration?.resolveCurrentNoteId(noteId) ?? noteId,
|
||||
resolveSentenceSearchHeadwords,
|
||||
addYomitanNote: async (word: string) => {
|
||||
const ankiUrl = getResolvedConfig().ankiConnect.url || 'http://127.0.0.1:8765';
|
||||
const ankiConnectConfig = getResolvedConfig().ankiConnect;
|
||||
const ankiUrl = ankiConnectConfig.url || 'http://127.0.0.1:8765';
|
||||
await syncYomitanDefaultAnkiServerCore(ankiUrl, yomitanDeps, yomitanLogger, {
|
||||
forceOverride: true,
|
||||
forceOverride: shouldForceOverrideYomitanAnkiServer(ankiConnectConfig),
|
||||
deck: ankiConnectConfig.deck,
|
||||
});
|
||||
const result = await addYomitanNoteViaSearch(word, yomitanDeps, yomitanLogger);
|
||||
if (result.noteId && result.duplicateNoteIds.length > 0) {
|
||||
@@ -5368,6 +5404,31 @@ signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease(
|
||||
});
|
||||
tokenizeSubtitleDeferred = tokenizeSubtitle;
|
||||
|
||||
const aniSkipRuntime = createAniSkipRuntime({
|
||||
getAniSkipConfig: () => ({
|
||||
aniskipEnabled: getResolvedConfig().mpv.aniskipEnabled,
|
||||
aniskipButtonKey: getResolvedConfig().mpv.aniskipButtonKey,
|
||||
}),
|
||||
resolveMetadataForFile: (mediaPath) => resolveAniSkipMetadataForFile(mediaPath),
|
||||
sendMpvCommand: (command) => {
|
||||
appState.mpvClient?.send({ command });
|
||||
},
|
||||
requestMpvProperty: (name) => {
|
||||
if (!appState.mpvClient) {
|
||||
return Promise.reject(new Error('MPV not connected'));
|
||||
}
|
||||
return appState.mpvClient.requestProperty(name);
|
||||
},
|
||||
isMpvConnected: () => appState.mpvClient?.connected === true,
|
||||
getCurrentTimePos: () => appState.mpvClient?.currentTimePos ?? Number.NaN,
|
||||
showMpvOsd: (text, durationMs) => {
|
||||
appState.mpvClient?.send({ command: ['show-text', text, durationMs] });
|
||||
},
|
||||
logInfo: (message) => logger.info(message),
|
||||
logWarn: (message, error) => logger.warn(message, error),
|
||||
logDebug: (message) => logger.debug(message),
|
||||
});
|
||||
|
||||
function createMpvClientRuntimeService(): MpvIpcClient {
|
||||
const client = createMpvClientRuntimeServiceHandler() as MpvIpcClient;
|
||||
client.on('connection-change', ({ connected }) => {
|
||||
@@ -5381,6 +5442,10 @@ function createMpvClientRuntimeService(): MpvIpcClient {
|
||||
broadcastToOverlayWindows(IPC_CHANNELS.event.youtubePickerCancel, null);
|
||||
overlayModalRuntime.handleOverlayModalClosed('youtube-track-picker');
|
||||
});
|
||||
client.on('connection-change', aniSkipRuntime.handleConnectionChange);
|
||||
client.on('media-path-change', aniSkipRuntime.handleMediaPathChange);
|
||||
client.on('time-pos-change', aniSkipRuntime.handleTimePosChange);
|
||||
client.on('client-message', aniSkipRuntime.handleClientMessage);
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -5640,7 +5705,7 @@ async function ensureYomitanExtensionLoaded(): Promise<Extension | null> {
|
||||
return extension;
|
||||
}
|
||||
|
||||
let lastSyncedYomitanAnkiServer: string | null = null;
|
||||
let lastSyncedYomitanAnkiSettingsKey: string | null = null;
|
||||
|
||||
function getPreferredYomitanAnkiServerUrl(): string {
|
||||
return getPreferredYomitanAnkiServerUrlRuntime(getResolvedConfig().ankiConnect);
|
||||
@@ -5671,7 +5736,10 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
|
||||
}
|
||||
|
||||
const targetUrl = getPreferredYomitanAnkiServerUrl().trim();
|
||||
if (!targetUrl || targetUrl === lastSyncedYomitanAnkiServer) {
|
||||
const ankiConnectConfig = getResolvedConfig().ankiConnect;
|
||||
const targetDeck = ankiConnectConfig?.deck?.trim() ?? '';
|
||||
const targetSettingsKey = `${targetUrl}\n${targetDeck}`;
|
||||
if (!targetUrl || targetSettingsKey === lastSyncedYomitanAnkiSettingsKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5687,12 +5755,15 @@ async function syncYomitanDefaultProfileAnkiServer(): Promise<void> {
|
||||
},
|
||||
},
|
||||
{
|
||||
forceOverride: shouldForceOverrideYomitanAnkiServer(getResolvedConfig().ankiConnect),
|
||||
forceOverride: ankiConnectConfig
|
||||
? shouldForceOverrideYomitanAnkiServer(ankiConnectConfig)
|
||||
: false,
|
||||
deck: targetDeck,
|
||||
},
|
||||
);
|
||||
|
||||
if (synced) {
|
||||
lastSyncedYomitanAnkiServer = targetUrl;
|
||||
lastSyncedYomitanAnkiSettingsKey = targetSettingsKey;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -237,6 +237,21 @@ test('warm tokenization release reuses current subtitle payload instead of synth
|
||||
assert.match(currentPayloadBlock, /payload\.text !== appState\.currentSubText/);
|
||||
});
|
||||
|
||||
test('stats server Yomitan note creation honors configured Anki server override policy', () => {
|
||||
const source = readMainSource();
|
||||
const startStatsServerBlock = source.match(
|
||||
/statsServer = startStatsServer\(\{(?<body>[\s\S]*?)\n \}\);/,
|
||||
)?.groups?.body;
|
||||
const addYomitanNoteBlock = startStatsServerBlock?.match(
|
||||
/addYomitanNote:\s*async\s*\(word: string\)\s*=>\s*\{(?<body>[\s\S]*?)\n \},/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(addYomitanNoteBlock);
|
||||
assert.match(addYomitanNoteBlock, /const ankiConnectConfig = getResolvedConfig\(\)\.ankiConnect;/);
|
||||
assert.match(addYomitanNoteBlock, /shouldForceOverrideYomitanAnkiServer\(ankiConnectConfig\)/);
|
||||
assert.doesNotMatch(addYomitanNoteBlock, /forceOverride:\s*true/);
|
||||
});
|
||||
|
||||
test('Linux visible overlay recreation clears stale input state before creating replacement window', () => {
|
||||
const source = readMainSource();
|
||||
const actionBlock = source.match(
|
||||
|
||||
@@ -2,7 +2,6 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
inferAniSkipMetadataForFile,
|
||||
buildSubminerScriptOpts,
|
||||
parseAniSkipGuessitJson,
|
||||
resolveAniSkipMetadataForFile,
|
||||
} from './aniskip-metadata';
|
||||
@@ -98,6 +97,20 @@ test('inferAniSkipMetadataForFile falls back to anime directory title when filen
|
||||
assert.equal(parsed.source, 'fallback');
|
||||
});
|
||||
|
||||
test('inferAniSkipMetadataForFile handles release-group filename with trailing quality tags', () => {
|
||||
const parsed = inferAniSkipMetadataForFile(
|
||||
'/media/anime/Solo Leveling/Season 1/[SubsPlease] Solo Leveling - 01 (1080p) [ABCDEF12].mkv',
|
||||
{
|
||||
commandExists: () => false,
|
||||
runGuessit: () => null,
|
||||
},
|
||||
);
|
||||
assert.equal(parsed.title, 'Solo Leveling');
|
||||
assert.equal(parsed.season, 1);
|
||||
assert.equal(parsed.episode, 1);
|
||||
assert.equal(parsed.source, 'fallback');
|
||||
});
|
||||
|
||||
test('resolveAniSkipMetadataForFile resolves MAL id and intro payload', async () => {
|
||||
await withMockFetch(
|
||||
async (input) => {
|
||||
@@ -133,6 +146,33 @@ test('resolveAniSkipMetadataForFile resolves MAL id and intro payload', async ()
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveAniSkipMetadataForFile accepts intro payload starting at zero', async () => {
|
||||
await withMockFetch(
|
||||
async (input) => {
|
||||
const url = normalizeFetchInput(input);
|
||||
if (url.includes('myanimelist.net/search/prefix.json')) {
|
||||
return makeMockResponse({
|
||||
categories: [{ items: [{ id: '1234', name: 'My Show' }] }],
|
||||
});
|
||||
}
|
||||
if (url.includes('api.aniskip.com/v1/skip-times/1234/1')) {
|
||||
return makeMockResponse({
|
||||
found: true,
|
||||
results: [{ skip_type: 'op', interval: { start_time: 0, end_time: 89.5 } }],
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected url: ${url}`);
|
||||
},
|
||||
async () => {
|
||||
const resolved = await resolveAniSkipMetadataForFile('/media/My.Show.S01E01.mkv');
|
||||
assert.equal(resolved.malId, 1234);
|
||||
assert.equal(resolved.introStart, 0);
|
||||
assert.equal(resolved.introEnd, 89.5);
|
||||
assert.equal(resolved.lookupStatus, 'ready');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveAniSkipMetadataForFile emits missing_mal_id when MAL search misses', async () => {
|
||||
await withMockFetch(
|
||||
async () => makeMockResponse({ categories: [] }),
|
||||
@@ -143,43 +183,3 @@ test('resolveAniSkipMetadataForFile emits missing_mal_id when MAL search misses'
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('buildSubminerScriptOpts includes aniskip payload fields', () => {
|
||||
const opts = buildSubminerScriptOpts(
|
||||
'/tmp/SubMiner.AppImage',
|
||||
'/tmp/subminer.sock',
|
||||
{
|
||||
title: "Frieren: Beyond Journey's End",
|
||||
season: 1,
|
||||
episode: 5,
|
||||
source: 'guessit',
|
||||
malId: 1234,
|
||||
introStart: 30.5,
|
||||
introEnd: 62,
|
||||
lookupStatus: 'ready',
|
||||
},
|
||||
'debug',
|
||||
);
|
||||
const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/);
|
||||
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
|
||||
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/);
|
||||
assert.doesNotMatch(opts, /subminer-log_level=/);
|
||||
assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/);
|
||||
assert.match(opts, /subminer-aniskip_season=1/);
|
||||
assert.match(opts, /subminer-aniskip_episode=5/);
|
||||
assert.match(opts, /subminer-aniskip_mal_id=1234/);
|
||||
assert.match(opts, /subminer-aniskip_intro_start=30.5/);
|
||||
assert.match(opts, /subminer-aniskip_intro_end=62/);
|
||||
assert.match(opts, /subminer-aniskip_lookup_status=ready/);
|
||||
assert.ok(payloadMatch !== null);
|
||||
const encodedPayload = payloadMatch[1];
|
||||
assert.ok(encodedPayload !== undefined);
|
||||
assert.equal(encodedPayload.includes('%'), false);
|
||||
const payloadJson = Buffer.from(encodedPayload, 'base64url').toString('utf-8');
|
||||
const payload = JSON.parse(payloadJson);
|
||||
assert.equal(payload.found, true);
|
||||
const first = payload.results?.[0];
|
||||
assert.equal(first.skip_type, 'op');
|
||||
assert.equal(first.interval.start_time, 30.5);
|
||||
assert.equal(first.interval.end_time, 62);
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import type { LogLevel } from './types.js';
|
||||
import { commandExists } from './util.js';
|
||||
|
||||
export type AniSkipLookupStatus =
|
||||
| 'ready'
|
||||
@@ -63,7 +62,8 @@ const ROMAN_SEASON_ALIASES: Record<number, readonly string[]> = {
|
||||
|
||||
const MAL_PREFIX_API = 'https://myanimelist.net/search/prefix.json?type=anime&keyword=';
|
||||
const ANISKIP_PAYLOAD_API = 'https://api.aniskip.com/v1/skip-times/';
|
||||
const MAL_USER_AGENT = 'SubMiner-launcher/ani-skip';
|
||||
const ANISKIP_USER_AGENT = 'SubMiner/ani-skip';
|
||||
const ANISKIP_FETCH_TIMEOUT_MS = 8000;
|
||||
const MAL_MATCH_STOPWORDS = new Set([
|
||||
'the',
|
||||
'this',
|
||||
@@ -77,6 +77,27 @@ const MAL_MATCH_STOPWORDS = new Set([
|
||||
'and',
|
||||
]);
|
||||
|
||||
function commandExistsOnPath(command: string): boolean {
|
||||
const pathEnv = process.env.PATH || '';
|
||||
const extensions =
|
||||
process.platform === 'win32' ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';') : [''];
|
||||
for (const dir of pathEnv.split(path.delimiter)) {
|
||||
if (!dir) continue;
|
||||
for (const extension of extensions) {
|
||||
const candidate = path.join(dir, `${command}${extension.toLowerCase()}`);
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.X_OK);
|
||||
if (fs.statSync(candidate).isFile()) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// keep scanning PATH entries
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function toPositiveInt(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
|
||||
return Math.floor(value);
|
||||
@@ -103,6 +124,19 @@ function toPositiveNumber(value: unknown): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function toNonNegativeNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeForMatch(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
@@ -227,10 +261,6 @@ function toMalSearchItems(payload: unknown): MalSearchResult[] {
|
||||
return items;
|
||||
}
|
||||
|
||||
function normalizeEpisodePayload(value: unknown): number | null {
|
||||
return toPositiveNumber(value);
|
||||
}
|
||||
|
||||
function parseAniSkipPayload(payload: unknown): { start: number; end: number } | null {
|
||||
const parsed = payload as AniSkipPayloadResponse;
|
||||
const results = Array.isArray(parsed?.results) ? parsed.results : null;
|
||||
@@ -246,8 +276,8 @@ function parseAniSkipPayload(payload: unknown): { start: number; end: number } |
|
||||
continue;
|
||||
}
|
||||
const interval = result.interval as AniSkipIntervalPayload;
|
||||
const start = normalizeEpisodePayload(interval?.start_time);
|
||||
const end = normalizeEpisodePayload(interval?.end_time);
|
||||
const start = toNonNegativeNumber(interval?.start_time);
|
||||
const end = toPositiveNumber(interval?.end_time);
|
||||
if (start !== null && end !== null && end > start) {
|
||||
return { start, end };
|
||||
}
|
||||
@@ -259,8 +289,9 @@ function parseAniSkipPayload(payload: unknown): { start: number; end: number } |
|
||||
async function fetchJson<T>(url: string): Promise<T | null> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': MAL_USER_AGENT,
|
||||
'User-Agent': ANISKIP_USER_AGENT,
|
||||
},
|
||||
signal: AbortSignal.timeout(ANISKIP_FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
try {
|
||||
@@ -311,13 +342,17 @@ function detectEpisodeFromName(baseName: string): number | null {
|
||||
const patterns = [
|
||||
/[Ss]\d+[Ee](\d{1,3})/,
|
||||
/(?:^|[\s._-])[Ee][Pp]?[\s._-]*(\d{1,3})(?:$|[\s._-])/,
|
||||
/(?:^|[\s._-])(\d{1,3})(?:$|[\s._-])/,
|
||||
/[-\s](\d{1,3})$/,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = baseName.match(pattern);
|
||||
if (!match || !match[1]) continue;
|
||||
const parsed = Number.parseInt(match[1], 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
const groupStrippedName = baseName.replace(/\[[^\]]+\]/g, ' ').replace(/\([^)]+\)/g, ' ');
|
||||
for (const candidate of [baseName, groupStrippedName]) {
|
||||
for (const pattern of patterns) {
|
||||
const match = candidate.match(pattern);
|
||||
if (!match || !match[1]) continue;
|
||||
const parsed = Number.parseInt(match[1], 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -379,6 +414,7 @@ function cleanupTitle(value: string): string {
|
||||
.replace(/\([^)]+\)/g, ' ')
|
||||
.replace(/[Ss]\d+[Ee]\d+/g, ' ')
|
||||
.replace(/[Ee][Pp]?[\s._-]*\d+/g, ' ')
|
||||
.replace(/(?:^|[\s._-])\d{1,3}\s*$/g, ' ')
|
||||
.replace(/[_\-.]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
@@ -443,7 +479,7 @@ function defaultRunGuessit(mediaPath: string): string | null {
|
||||
|
||||
export function inferAniSkipMetadataForFile(
|
||||
mediaPath: string,
|
||||
deps: InferAniSkipDeps = { commandExists, runGuessit: defaultRunGuessit },
|
||||
deps: InferAniSkipDeps = { commandExists: commandExistsOnPath, runGuessit: defaultRunGuessit },
|
||||
): AniSkipMetadata {
|
||||
if (deps.commandExists('guessit')) {
|
||||
const stdout = deps.runGuessit(mediaPath);
|
||||
@@ -527,79 +563,3 @@ export async function resolveAniSkipMetadataForFile(mediaPath: string): Promise<
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeScriptOptValue(value: string): string {
|
||||
return value
|
||||
.replace(/,/g, ' ')
|
||||
.replace(/[\r\n]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function buildLauncherAniSkipPayload(aniSkipMetadata: AniSkipMetadata): string | null {
|
||||
if (!aniSkipMetadata.malId || !aniSkipMetadata.introStart || !aniSkipMetadata.introEnd) {
|
||||
return null;
|
||||
}
|
||||
if (aniSkipMetadata.introEnd <= aniSkipMetadata.introStart) {
|
||||
return null;
|
||||
}
|
||||
const payload = {
|
||||
found: true,
|
||||
results: [
|
||||
{
|
||||
skip_type: 'op',
|
||||
interval: {
|
||||
start_time: aniSkipMetadata.introStart,
|
||||
end_time: aniSkipMetadata.introEnd,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
// mpv --script-opts treats `%` as an escape prefix, so URL-encoding can break parsing.
|
||||
// Base64url stays script-opts-safe and is decoded by the plugin launcher payload parser.
|
||||
return Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url');
|
||||
}
|
||||
|
||||
export function buildSubminerScriptOpts(
|
||||
appPath: string,
|
||||
socketPath: string,
|
||||
aniSkipMetadata: AniSkipMetadata | null,
|
||||
_logLevel: LogLevel = 'info',
|
||||
extraParts: string[] = [],
|
||||
): string {
|
||||
const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path='));
|
||||
const hasSocketPath = extraParts.some((part) => part.startsWith('subminer-socket_path='));
|
||||
const parts = [
|
||||
...(hasBinaryPath ? [] : [`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`]),
|
||||
...(hasSocketPath ? [] : [`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`]),
|
||||
...extraParts.map(sanitizeScriptOptValue),
|
||||
];
|
||||
if (aniSkipMetadata && aniSkipMetadata.title) {
|
||||
parts.push(`subminer-aniskip_title=${sanitizeScriptOptValue(aniSkipMetadata.title)}`);
|
||||
}
|
||||
if (aniSkipMetadata && aniSkipMetadata.season && aniSkipMetadata.season > 0) {
|
||||
parts.push(`subminer-aniskip_season=${aniSkipMetadata.season}`);
|
||||
}
|
||||
if (aniSkipMetadata && aniSkipMetadata.episode && aniSkipMetadata.episode > 0) {
|
||||
parts.push(`subminer-aniskip_episode=${aniSkipMetadata.episode}`);
|
||||
}
|
||||
if (aniSkipMetadata && aniSkipMetadata.malId && aniSkipMetadata.malId > 0) {
|
||||
parts.push(`subminer-aniskip_mal_id=${aniSkipMetadata.malId}`);
|
||||
}
|
||||
if (aniSkipMetadata && aniSkipMetadata.introStart !== null && aniSkipMetadata.introStart > 0) {
|
||||
parts.push(`subminer-aniskip_intro_start=${aniSkipMetadata.introStart}`);
|
||||
}
|
||||
if (aniSkipMetadata && aniSkipMetadata.introEnd !== null && aniSkipMetadata.introEnd > 0) {
|
||||
parts.push(`subminer-aniskip_intro_end=${aniSkipMetadata.introEnd}`);
|
||||
}
|
||||
if (aniSkipMetadata?.lookupStatus) {
|
||||
parts.push(
|
||||
`subminer-aniskip_lookup_status=${sanitizeScriptOptValue(aniSkipMetadata.lookupStatus)}`,
|
||||
);
|
||||
}
|
||||
const aniskipPayload = aniSkipMetadata ? buildLauncherAniSkipPayload(aniSkipMetadata) : null;
|
||||
if (aniskipPayload) {
|
||||
parts.push(`subminer-aniskip_payload=${sanitizeScriptOptValue(aniskipPayload)}`);
|
||||
}
|
||||
return parts.join(',');
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAniSkipRuntime, isRemoteMediaPath, AniSkipRuntimeDeps } from './aniskip-runtime';
|
||||
import type { AniSkipMetadata } from './aniskip-metadata';
|
||||
|
||||
function readyMetadata(overrides: Partial<AniSkipMetadata> = {}): AniSkipMetadata {
|
||||
return {
|
||||
title: 'My Show',
|
||||
season: 1,
|
||||
episode: 1,
|
||||
source: 'fallback',
|
||||
malId: 1234,
|
||||
introStart: 10,
|
||||
introEnd: 95.5,
|
||||
lookupStatus: 'ready',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(options?: {
|
||||
enabled?: boolean;
|
||||
buttonKey?: string;
|
||||
metadata?: AniSkipMetadata | (() => Promise<AniSkipMetadata>);
|
||||
chapterList?: unknown;
|
||||
}) {
|
||||
const state = {
|
||||
enabled: options?.enabled ?? true,
|
||||
buttonKey: options?.buttonKey ?? 'TAB',
|
||||
commands: [] as unknown[][],
|
||||
osd: [] as string[],
|
||||
resolveCalls: [] as string[],
|
||||
connected: true,
|
||||
timePos: 0,
|
||||
chapterList: options?.chapterList ?? [],
|
||||
};
|
||||
|
||||
const deps: AniSkipRuntimeDeps = {
|
||||
getAniSkipConfig: () => ({
|
||||
aniskipEnabled: state.enabled,
|
||||
aniskipButtonKey: state.buttonKey,
|
||||
}),
|
||||
resolveMetadataForFile: async (mediaPath) => {
|
||||
state.resolveCalls.push(mediaPath);
|
||||
const metadata = options?.metadata;
|
||||
if (typeof metadata === 'function') return metadata();
|
||||
return metadata ?? readyMetadata();
|
||||
},
|
||||
sendMpvCommand: (command) => {
|
||||
state.commands.push(command);
|
||||
},
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'chapter-list') return state.chapterList;
|
||||
return null;
|
||||
},
|
||||
isMpvConnected: () => state.connected,
|
||||
getCurrentTimePos: () => state.timePos,
|
||||
showMpvOsd: (text) => {
|
||||
state.osd.push(text);
|
||||
},
|
||||
logInfo: () => {},
|
||||
logWarn: () => {},
|
||||
logDebug: () => {},
|
||||
};
|
||||
|
||||
return { runtime: createAniSkipRuntime(deps), state };
|
||||
}
|
||||
|
||||
function chapterListCommands(commands: unknown[][]): unknown[][] {
|
||||
return commands.filter(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'chapter-list',
|
||||
);
|
||||
}
|
||||
|
||||
async function flushAsync(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
test('isRemoteMediaPath detects URLs but not local paths', () => {
|
||||
assert.equal(isRemoteMediaPath('https://example.com/stream.mkv'), true);
|
||||
assert.equal(isRemoteMediaPath('rtmp://example.com/live'), true);
|
||||
assert.equal(isRemoteMediaPath('/media/anime/show.mkv'), false);
|
||||
assert.equal(isRemoteMediaPath('C:\\media\\show.mkv'), false);
|
||||
assert.equal(isRemoteMediaPath(''), false);
|
||||
});
|
||||
|
||||
test('media path change resolves metadata and sets AniSkip chapters', async () => {
|
||||
const { runtime, state } = createHarness({
|
||||
chapterList: [{ time: 0, title: 'Prologue' }],
|
||||
});
|
||||
|
||||
runtime.handleMediaPathChange({ path: '/media/anime/My Show/ep1.mkv' });
|
||||
await flushAsync();
|
||||
|
||||
assert.deepEqual(state.resolveCalls, ['/media/anime/My Show/ep1.mkv']);
|
||||
const chapterCommands = chapterListCommands(state.commands);
|
||||
assert.equal(chapterCommands.length, 1);
|
||||
const chapters = chapterCommands[0]![2] as Array<{ time: number; title: string }>;
|
||||
assert.deepEqual(chapters, [
|
||||
{ time: 0, title: 'Prologue' },
|
||||
{ time: 10, title: 'AniSkip Intro Start' },
|
||||
{ time: 95.5, title: 'AniSkip Intro End' },
|
||||
]);
|
||||
assert.deepEqual(runtime.getIntroWindow(), { start: 10, end: 95.5, malId: 1234 });
|
||||
});
|
||||
|
||||
test('remote media paths and disabled config never resolve', async () => {
|
||||
const remote = createHarness();
|
||||
remote.runtime.handleMediaPathChange({ path: 'https://example.com/video.mkv' });
|
||||
await flushAsync();
|
||||
assert.deepEqual(remote.state.resolveCalls, []);
|
||||
|
||||
const disabled = createHarness({ enabled: false });
|
||||
disabled.runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||
await flushAsync();
|
||||
assert.deepEqual(disabled.state.resolveCalls, []);
|
||||
});
|
||||
|
||||
test('skip intro seeks to intro end only inside the intro window', async () => {
|
||||
const { runtime, state } = createHarness();
|
||||
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||
await flushAsync();
|
||||
|
||||
state.timePos = 200;
|
||||
runtime.handleClientMessage({ args: ['subminer-skip-intro'] });
|
||||
assert.deepEqual(state.osd, ['Skip intro only during intro']);
|
||||
|
||||
state.timePos = 30;
|
||||
runtime.handleClientMessage({ args: ['subminer-skip-intro'] });
|
||||
assert.deepEqual(state.osd, ['Skip intro only during intro', 'Skipped intro']);
|
||||
const seek = state.commands.find(
|
||||
(command) => command[0] === 'set_property' && command[1] === 'time-pos',
|
||||
);
|
||||
assert.deepEqual(seek, ['set_property', 'time-pos', 95.5]);
|
||||
});
|
||||
|
||||
test('skip intro reports unavailable when no window was found', () => {
|
||||
const { runtime, state } = createHarness();
|
||||
runtime.handleClientMessage({ args: ['subminer-skip-intro'] });
|
||||
assert.deepEqual(state.osd, ['Intro skip unavailable']);
|
||||
});
|
||||
|
||||
test('time-pos prompt shows once near intro start', async () => {
|
||||
const { runtime, state } = createHarness({ buttonKey: 'TAB' });
|
||||
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||
await flushAsync();
|
||||
|
||||
runtime.handleTimePosChange({ time: 5 });
|
||||
assert.deepEqual(state.osd, []);
|
||||
|
||||
runtime.handleTimePosChange({ time: 10.5 });
|
||||
runtime.handleTimePosChange({ time: 11 });
|
||||
assert.deepEqual(state.osd, ['You can skip by pressing TAB']);
|
||||
});
|
||||
|
||||
test('connection change binds skip key and legacy fallback for custom keys', () => {
|
||||
const { runtime, state } = createHarness({ buttonKey: 'F6' });
|
||||
runtime.handleConnectionChange({ connected: true });
|
||||
assert.deepEqual(state.commands, [
|
||||
['keybind', 'F6', 'script-message subminer-skip-intro'],
|
||||
['keybind', 'y-k', 'script-message subminer-skip-intro'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('default key binds without duplicate legacy fallback', () => {
|
||||
const { runtime, state } = createHarness({ buttonKey: 'TAB' });
|
||||
runtime.handleConnectionChange({ connected: true });
|
||||
assert.deepEqual(state.commands, [['keybind', 'TAB', 'script-message subminer-skip-intro']]);
|
||||
});
|
||||
|
||||
test('config change rebinds key and disabling unbinds and clears chapters', async () => {
|
||||
const { runtime, state } = createHarness({ buttonKey: 'TAB' });
|
||||
runtime.handleConnectionChange({ connected: true });
|
||||
|
||||
state.buttonKey = 'F6';
|
||||
runtime.applyConfigChange();
|
||||
assert.deepEqual(state.commands.slice(1), [
|
||||
['keybind', 'TAB', ''],
|
||||
['keybind', 'F6', 'script-message subminer-skip-intro'],
|
||||
['keybind', 'y-k', 'script-message subminer-skip-intro'],
|
||||
]);
|
||||
|
||||
state.commands.length = 0;
|
||||
state.enabled = false;
|
||||
state.chapterList = [
|
||||
{ time: 0, title: 'Prologue' },
|
||||
{ time: 10, title: 'AniSkip Intro Start' },
|
||||
{ time: 95.5, title: 'AniSkip Intro End' },
|
||||
];
|
||||
runtime.applyConfigChange();
|
||||
await flushAsync();
|
||||
assert.deepEqual(state.commands, [
|
||||
['keybind', 'F6', ''],
|
||||
['keybind', 'y-k', ''],
|
||||
['set_property', 'chapter-list', [{ time: 0, title: 'Prologue' }]],
|
||||
]);
|
||||
});
|
||||
|
||||
test('same-media reload re-applies chapters without a new lookup', async () => {
|
||||
const { runtime, state } = createHarness();
|
||||
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||
await flushAsync();
|
||||
assert.equal(state.resolveCalls.length, 1);
|
||||
|
||||
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||
await flushAsync();
|
||||
assert.equal(state.resolveCalls.length, 1);
|
||||
assert.equal(chapterListCommands(state.commands).length, 2);
|
||||
});
|
||||
|
||||
test('aniskip refresh forces a fresh lookup for the current media', async () => {
|
||||
const { runtime, state } = createHarness();
|
||||
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||
await flushAsync();
|
||||
assert.equal(state.resolveCalls.length, 1);
|
||||
|
||||
runtime.handleClientMessage({ args: ['subminer-aniskip-refresh'] });
|
||||
await flushAsync();
|
||||
assert.equal(state.resolveCalls.length, 2);
|
||||
});
|
||||
|
||||
test('media without an intro window is cached and never re-resolved on reload of another file', async () => {
|
||||
const { runtime, state } = createHarness({
|
||||
metadata: readyMetadata({
|
||||
malId: 1234,
|
||||
introStart: null,
|
||||
introEnd: null,
|
||||
lookupStatus: 'missing_payload',
|
||||
}),
|
||||
});
|
||||
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||
await flushAsync();
|
||||
assert.equal(runtime.getIntroWindow(), null);
|
||||
assert.equal(chapterListCommands(state.commands).length, 0);
|
||||
|
||||
runtime.handleMediaPathChange({ path: '/media/other.mkv' });
|
||||
await flushAsync();
|
||||
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||
await flushAsync();
|
||||
assert.deepEqual(state.resolveCalls, ['/media/show.mkv', '/media/other.mkv']);
|
||||
});
|
||||
|
||||
test('transient lookup failures are retried on the next media load', async () => {
|
||||
let failures = 0;
|
||||
const { runtime, state } = createHarness({
|
||||
metadata: async () => {
|
||||
failures += 1;
|
||||
return readyMetadata(
|
||||
failures === 1 ? { introStart: null, introEnd: null, lookupStatus: 'lookup_failed' } : {},
|
||||
);
|
||||
},
|
||||
});
|
||||
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||
await flushAsync();
|
||||
assert.equal(runtime.getIntroWindow(), null);
|
||||
|
||||
runtime.handleMediaPathChange({ path: '' });
|
||||
runtime.handleMediaPathChange({ path: '/media/show.mkv' });
|
||||
await flushAsync();
|
||||
assert.equal(state.resolveCalls.length, 2);
|
||||
assert.deepEqual(runtime.getIntroWindow(), { start: 10, end: 95.5, malId: 1234 });
|
||||
});
|
||||
|
||||
test('disconnect clears bindings so reconnect rebinds the skip key', () => {
|
||||
const { runtime, state } = createHarness();
|
||||
runtime.handleConnectionChange({ connected: true });
|
||||
runtime.handleConnectionChange({ connected: false });
|
||||
runtime.handleConnectionChange({ connected: true });
|
||||
assert.deepEqual(state.commands, [
|
||||
['keybind', 'TAB', 'script-message subminer-skip-intro'],
|
||||
['keybind', 'TAB', 'script-message subminer-skip-intro'],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
import type { AniSkipMetadata } from './aniskip-metadata';
|
||||
|
||||
export const ANISKIP_SKIP_INTRO_MESSAGE = 'subminer-skip-intro';
|
||||
export const ANISKIP_REFRESH_MESSAGE = 'subminer-aniskip-refresh';
|
||||
|
||||
const DEFAULT_ANISKIP_BUTTON_KEY = 'TAB';
|
||||
const LEGACY_ANISKIP_BUTTON_KEY = 'y-k';
|
||||
const ANISKIP_CHAPTER_PREFIX = 'AniSkip ';
|
||||
const SKIP_WINDOW_EPSILON_SECONDS = 0.35;
|
||||
const PROMPT_WINDOW_SECONDS = 3;
|
||||
const PROMPT_OSD_DURATION_MS = 3000;
|
||||
export interface AniSkipRuntimeConfig {
|
||||
aniskipEnabled: boolean;
|
||||
aniskipButtonKey: string;
|
||||
}
|
||||
|
||||
export interface AniSkipRuntimeDeps {
|
||||
getAniSkipConfig: () => AniSkipRuntimeConfig;
|
||||
resolveMetadataForFile: (mediaPath: string) => Promise<AniSkipMetadata>;
|
||||
sendMpvCommand: (command: unknown[]) => void;
|
||||
requestMpvProperty: (name: string) => Promise<unknown>;
|
||||
isMpvConnected: () => boolean;
|
||||
getCurrentTimePos: () => number;
|
||||
showMpvOsd: (text: string, durationMs: number) => void;
|
||||
logInfo: (message: string) => void;
|
||||
logWarn: (message: string, error?: unknown) => void;
|
||||
logDebug: (message: string) => void;
|
||||
}
|
||||
|
||||
interface AniSkipIntroWindow {
|
||||
start: number;
|
||||
end: number;
|
||||
malId: number | null;
|
||||
}
|
||||
|
||||
type MpvChapter = { time?: unknown; title?: unknown };
|
||||
|
||||
export function isRemoteMediaPath(mediaPath: string): boolean {
|
||||
return /^[a-zA-Z][\w+.-]*:\/\//.test(mediaPath.trim());
|
||||
}
|
||||
|
||||
export function createAniSkipRuntime(deps: AniSkipRuntimeDeps) {
|
||||
let requestGeneration = 0;
|
||||
let currentMediaPath = '';
|
||||
let introWindow: AniSkipIntroWindow | null = null;
|
||||
let promptShown = false;
|
||||
let boundButtonKey: string | null = null;
|
||||
let legacyFallbackBound = false;
|
||||
const introWindowCache = new Map<string, AniSkipIntroWindow | null>();
|
||||
|
||||
function resolveButtonKey(): string {
|
||||
const key = deps.getAniSkipConfig().aniskipButtonKey.trim();
|
||||
return key || DEFAULT_ANISKIP_BUTTON_KEY;
|
||||
}
|
||||
|
||||
function bindSkipKeys(): void {
|
||||
if (!deps.isMpvConnected()) return;
|
||||
const enabled = deps.getAniSkipConfig().aniskipEnabled;
|
||||
const key = resolveButtonKey();
|
||||
const wantLegacyFallback =
|
||||
enabled && key !== LEGACY_ANISKIP_BUTTON_KEY && key !== DEFAULT_ANISKIP_BUTTON_KEY;
|
||||
|
||||
if (boundButtonKey && (!enabled || boundButtonKey !== key)) {
|
||||
deps.sendMpvCommand(['keybind', boundButtonKey, '']);
|
||||
boundButtonKey = null;
|
||||
}
|
||||
if (legacyFallbackBound && !wantLegacyFallback) {
|
||||
deps.sendMpvCommand(['keybind', LEGACY_ANISKIP_BUTTON_KEY, '']);
|
||||
legacyFallbackBound = false;
|
||||
}
|
||||
if (!enabled) return;
|
||||
|
||||
if (boundButtonKey !== key) {
|
||||
deps.sendMpvCommand(['keybind', key, `script-message ${ANISKIP_SKIP_INTRO_MESSAGE}`]);
|
||||
boundButtonKey = key;
|
||||
}
|
||||
if (wantLegacyFallback && !legacyFallbackBound) {
|
||||
deps.sendMpvCommand([
|
||||
'keybind',
|
||||
LEGACY_ANISKIP_BUTTON_KEY,
|
||||
`script-message ${ANISKIP_SKIP_INTRO_MESSAGE}`,
|
||||
]);
|
||||
legacyFallbackBound = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function setIntroChapters(introStart: number, introEnd: number): Promise<void> {
|
||||
let existing: MpvChapter[] = [];
|
||||
try {
|
||||
const chapterList = await deps.requestMpvProperty('chapter-list');
|
||||
if (Array.isArray(chapterList)) {
|
||||
existing = chapterList as MpvChapter[];
|
||||
}
|
||||
} catch {
|
||||
// chapter-list may be unavailable mid-load; fall back to AniSkip chapters only
|
||||
}
|
||||
const chapters = existing.filter(
|
||||
(chapter) =>
|
||||
typeof chapter?.title !== 'string' || !chapter.title.startsWith(ANISKIP_CHAPTER_PREFIX),
|
||||
);
|
||||
chapters.push({ time: introStart, title: 'AniSkip Intro Start' });
|
||||
chapters.push({ time: introEnd, title: 'AniSkip Intro End' });
|
||||
chapters.sort((a, b) => {
|
||||
const aTime = typeof a.time === 'number' ? a.time : 0;
|
||||
const bTime = typeof b.time === 'number' ? b.time : 0;
|
||||
return aTime - bTime;
|
||||
});
|
||||
deps.sendMpvCommand(['set_property', 'chapter-list', chapters]);
|
||||
}
|
||||
|
||||
async function removeIntroChapters(): Promise<void> {
|
||||
if (!deps.isMpvConnected()) return;
|
||||
let existing: MpvChapter[] = [];
|
||||
try {
|
||||
const chapterList = await deps.requestMpvProperty('chapter-list');
|
||||
if (Array.isArray(chapterList)) {
|
||||
existing = chapterList as MpvChapter[];
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const filtered = existing.filter(
|
||||
(chapter) =>
|
||||
typeof chapter?.title !== 'string' || !chapter.title.startsWith(ANISKIP_CHAPTER_PREFIX),
|
||||
);
|
||||
if (filtered.length !== existing.length) {
|
||||
deps.sendMpvCommand(['set_property', 'chapter-list', filtered]);
|
||||
}
|
||||
}
|
||||
|
||||
function clearState(): void {
|
||||
requestGeneration += 1;
|
||||
introWindow = null;
|
||||
promptShown = false;
|
||||
}
|
||||
|
||||
async function applyIntroWindow(window: AniSkipIntroWindow): Promise<void> {
|
||||
introWindow = window;
|
||||
promptShown = false;
|
||||
await setIntroChapters(window.start, window.end);
|
||||
deps.logInfo(
|
||||
`AniSkip intro window ${window.start.toFixed(3)} -> ${window.end.toFixed(3)} (MAL ${
|
||||
window.malId ?? '-'
|
||||
})`,
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveForMedia(mediaPath: string, options?: { force?: boolean }): Promise<void> {
|
||||
if (!deps.getAniSkipConfig().aniskipEnabled) return;
|
||||
if (!mediaPath || isRemoteMediaPath(mediaPath)) {
|
||||
deps.logDebug('AniSkip lookup skipped: no local media path');
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.force) {
|
||||
introWindowCache.delete(mediaPath);
|
||||
}
|
||||
const generation = requestGeneration;
|
||||
const cached = introWindowCache.get(mediaPath);
|
||||
if (cached !== undefined) {
|
||||
if (cached) {
|
||||
await applyIntroWindow(cached);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let metadata: AniSkipMetadata;
|
||||
try {
|
||||
metadata = await deps.resolveMetadataForFile(mediaPath);
|
||||
} catch (error) {
|
||||
deps.logWarn('AniSkip metadata lookup failed', error);
|
||||
return;
|
||||
}
|
||||
if (generation !== requestGeneration || mediaPath !== currentMediaPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
metadata.lookupStatus !== 'ready' ||
|
||||
metadata.introStart === null ||
|
||||
metadata.introEnd === null ||
|
||||
metadata.introEnd <= metadata.introStart
|
||||
) {
|
||||
// Only definitive "no skip window exists" results are cached; transient
|
||||
// lookup failures stay retryable on the next load or refresh.
|
||||
if (metadata.lookupStatus !== 'lookup_failed') {
|
||||
introWindowCache.set(mediaPath, null);
|
||||
}
|
||||
deps.logInfo(
|
||||
`AniSkip: no intro window for "${metadata.title}" (status=${metadata.lookupStatus ?? 'unknown'})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const window: AniSkipIntroWindow = {
|
||||
start: metadata.introStart,
|
||||
end: metadata.introEnd,
|
||||
malId: metadata.malId,
|
||||
};
|
||||
introWindowCache.set(mediaPath, window);
|
||||
await applyIntroWindow(window);
|
||||
}
|
||||
|
||||
function skipIntroNow(): void {
|
||||
if (!deps.getAniSkipConfig().aniskipEnabled) return;
|
||||
if (!introWindow) {
|
||||
deps.showMpvOsd('Intro skip unavailable', PROMPT_OSD_DURATION_MS);
|
||||
return;
|
||||
}
|
||||
const now = deps.getCurrentTimePos();
|
||||
if (!Number.isFinite(now)) {
|
||||
deps.showMpvOsd('Skip unavailable', PROMPT_OSD_DURATION_MS);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
now < introWindow.start - SKIP_WINDOW_EPSILON_SECONDS ||
|
||||
now > introWindow.end + SKIP_WINDOW_EPSILON_SECONDS
|
||||
) {
|
||||
deps.showMpvOsd('Skip intro only during intro', PROMPT_OSD_DURATION_MS);
|
||||
return;
|
||||
}
|
||||
deps.sendMpvCommand(['set_property', 'time-pos', introWindow.end]);
|
||||
deps.showMpvOsd('Skipped intro', PROMPT_OSD_DURATION_MS);
|
||||
}
|
||||
|
||||
function handleTimePosChange({ time }: { time: number }): void {
|
||||
if (!introWindow || promptShown) return;
|
||||
if (!deps.getAniSkipConfig().aniskipEnabled) return;
|
||||
const promptWindowEnd = Math.min(introWindow.start + PROMPT_WINDOW_SECONDS, introWindow.end);
|
||||
if (time >= introWindow.start && time < promptWindowEnd) {
|
||||
promptShown = true;
|
||||
deps.showMpvOsd(`You can skip by pressing ${resolveButtonKey()}`, PROMPT_OSD_DURATION_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMediaPathChange({ path }: { path: string }): void {
|
||||
const nextPath = typeof path === 'string' ? path : '';
|
||||
if (nextPath === currentMediaPath && introWindow) {
|
||||
// Same-media reload: mpv rebuilt the chapter list, so re-apply markers.
|
||||
void setIntroChapters(introWindow.start, introWindow.end).catch(() => {});
|
||||
return;
|
||||
}
|
||||
currentMediaPath = nextPath;
|
||||
clearState();
|
||||
if (!nextPath) return;
|
||||
void resolveForMedia(nextPath).catch((error) => {
|
||||
deps.logWarn('AniSkip media resolution failed', error);
|
||||
});
|
||||
}
|
||||
|
||||
function handleConnectionChange({ connected }: { connected: boolean }): void {
|
||||
if (!connected) {
|
||||
boundButtonKey = null;
|
||||
legacyFallbackBound = false;
|
||||
clearState();
|
||||
currentMediaPath = '';
|
||||
return;
|
||||
}
|
||||
bindSkipKeys();
|
||||
}
|
||||
|
||||
function handleClientMessage({ args }: { args: string[] }): void {
|
||||
const messageName = args[0];
|
||||
if (messageName === ANISKIP_SKIP_INTRO_MESSAGE) {
|
||||
skipIntroNow();
|
||||
return;
|
||||
}
|
||||
if (messageName === ANISKIP_REFRESH_MESSAGE) {
|
||||
const mediaPath = currentMediaPath;
|
||||
if (!mediaPath) return;
|
||||
clearState();
|
||||
void removeIntroChapters().catch(() => {});
|
||||
void resolveForMedia(mediaPath, { force: true }).catch((error) => {
|
||||
deps.logWarn('AniSkip refresh failed', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyConfigChange(): void {
|
||||
bindSkipKeys();
|
||||
const enabled = deps.getAniSkipConfig().aniskipEnabled;
|
||||
if (!enabled) {
|
||||
clearState();
|
||||
void removeIntroChapters().catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (!introWindow && currentMediaPath) {
|
||||
void resolveForMedia(currentMediaPath).catch((error) => {
|
||||
deps.logWarn('AniSkip media resolution failed', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleConnectionChange,
|
||||
handleMediaPathChange,
|
||||
handleTimePosChange,
|
||||
handleClientMessage,
|
||||
applyConfigChange,
|
||||
skipIntroNow,
|
||||
getIntroWindow: () => introWindow,
|
||||
};
|
||||
}
|
||||
|
||||
export type AniSkipRuntime = ReturnType<typeof createAniSkipRuntime>;
|
||||
@@ -1,6 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeJellyfinRuntimeHandlers } from './jellyfin-runtime-composer';
|
||||
import {
|
||||
composeJellyfinRuntimeHandlers,
|
||||
createRestartJellyfinRemoteSessionAfterSetupLoginHandler,
|
||||
} from './jellyfin-runtime-composer';
|
||||
|
||||
test('setup login restart uses auto-connect path without an active remote session', async () => {
|
||||
const startOptions: Array<{ explicit?: boolean } | undefined> = [];
|
||||
const restart = createRestartJellyfinRemoteSessionAfterSetupLoginHandler({
|
||||
getCurrentSession: () => null,
|
||||
startJellyfinRemoteSession: async (options) => {
|
||||
startOptions.push(options);
|
||||
},
|
||||
});
|
||||
|
||||
await restart();
|
||||
|
||||
assert.deepEqual(startOptions, [undefined]);
|
||||
});
|
||||
|
||||
test('setup login restart explicitly refreshes an active remote session', async () => {
|
||||
const startOptions: Array<{ explicit?: boolean } | undefined> = [];
|
||||
const restart = createRestartJellyfinRemoteSessionAfterSetupLoginHandler({
|
||||
getCurrentSession: () => ({ stop: () => {} }),
|
||||
startJellyfinRemoteSession: async (options) => {
|
||||
startOptions.push(options);
|
||||
},
|
||||
});
|
||||
|
||||
await restart();
|
||||
|
||||
assert.deepEqual(startOptions, [{ explicit: true }]);
|
||||
});
|
||||
|
||||
test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers', () => {
|
||||
let activePlayback: unknown = null;
|
||||
|
||||
@@ -153,6 +153,16 @@ export type JellyfinRuntimeComposerResult = ComposerOutputs<{
|
||||
openJellyfinSetupWindow: ReturnType<typeof createOpenJellyfinSetupWindowHandler>;
|
||||
}>;
|
||||
|
||||
export function createRestartJellyfinRemoteSessionAfterSetupLoginHandler(deps: {
|
||||
getCurrentSession: () => unknown | null;
|
||||
startJellyfinRemoteSession: (options?: { explicit?: boolean }) => Promise<void>;
|
||||
}) {
|
||||
return async (): Promise<void> => {
|
||||
const hasActiveSession = deps.getCurrentSession() !== null;
|
||||
await deps.startJellyfinRemoteSession(hasActiveSession ? { explicit: true } : undefined);
|
||||
};
|
||||
}
|
||||
|
||||
export function composeJellyfinRuntimeHandlers(
|
||||
options: JellyfinRuntimeComposerOptions,
|
||||
): JellyfinRuntimeComposerResult {
|
||||
@@ -268,12 +278,19 @@ export function composeJellyfinRuntimeHandlers(
|
||||
const maybeFocusExistingJellyfinSetupWindow = createMaybeFocusExistingJellyfinSetupWindowHandler(
|
||||
options.maybeFocusExistingJellyfinSetupWindowMainDeps,
|
||||
);
|
||||
const restartJellyfinRemoteSessionAfterSetupLogin =
|
||||
createRestartJellyfinRemoteSessionAfterSetupLoginHandler({
|
||||
getCurrentSession: () => options.startJellyfinRemoteSessionMainDeps.getCurrentSession(),
|
||||
startJellyfinRemoteSession: (startOptions) => startJellyfinRemoteSession(startOptions),
|
||||
});
|
||||
const openJellyfinSetupWindow = createOpenJellyfinSetupWindowHandler(
|
||||
createBuildOpenJellyfinSetupWindowMainDepsHandler({
|
||||
...options.openJellyfinSetupWindowMainDeps,
|
||||
maybeFocusExistingSetupWindow: maybeFocusExistingJellyfinSetupWindow,
|
||||
getResolvedJellyfinConfig: () => getResolvedJellyfinConfig(),
|
||||
getJellyfinClientInfo: () => getJellyfinClientInfo(),
|
||||
restartRemoteSession: () => restartJellyfinRemoteSessionAfterSetupLogin(),
|
||||
stopRemoteSession: () => stopJellyfinRemoteSession(),
|
||||
})(),
|
||||
);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ type ConfigHotReloadAppliedDeps = {
|
||||
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
|
||||
setLogRotation?: (rotation: ResolvedConfig['logging']['rotation']) => void;
|
||||
setLogFileToggles?: (files: ResolvedConfig['logging']['files']) => void;
|
||||
applyAniSkipConfig?: () => void;
|
||||
};
|
||||
|
||||
type ConfigHotReloadMessageDeps = {
|
||||
@@ -170,6 +171,10 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
|
||||
deps.setLogFileToggles?.(config.logging.files);
|
||||
}
|
||||
|
||||
if (hasAnyHotReloadField(diff, ['mpv.aniskipEnabled', 'mpv.aniskipButtonKey'])) {
|
||||
deps.applyAniSkipConfig?.();
|
||||
}
|
||||
|
||||
if (diff.hotReloadFields.length > 0) {
|
||||
deps.broadcastToOverlayWindows('config:hot-reload', payload);
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
|
||||
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
|
||||
setLogRotation?: (rotation: ResolvedConfig['logging']['rotation']) => void;
|
||||
setLogFileToggles?: (files: ResolvedConfig['logging']['files']) => void;
|
||||
applyAniSkipConfig?: () => void;
|
||||
}) {
|
||||
return () => ({
|
||||
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
|
||||
@@ -99,6 +100,7 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
|
||||
deps.setLogRotation?.(rotation),
|
||||
setLogFileToggles: (files: ResolvedConfig['logging']['files']) =>
|
||||
deps.setLogFileToggles?.(files),
|
||||
applyAniSkipConfig: () => deps.applyAniSkipConfig?.(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { DEFAULT_CONFIG, deepCloneConfig } from '../../config';
|
||||
import { resolveConfig } from '../../config/resolve';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import { createConfigSettingsRuntime } from './config-settings-runtime';
|
||||
|
||||
@@ -10,7 +14,13 @@ test('config settings runtime exposes inferred Yomitan Anki deck lookup', async
|
||||
fields: [],
|
||||
getConfigPath: () => '/tmp/config.jsonc',
|
||||
getRawConfig: () => ({}),
|
||||
getConfig: () => deepCloneConfig(DEFAULT_CONFIG),
|
||||
getConfig: () => ({
|
||||
...deepCloneConfig(DEFAULT_CONFIG),
|
||||
ankiConnect: {
|
||||
...deepCloneConfig(DEFAULT_CONFIG).ankiConnect,
|
||||
deck: 'Configured',
|
||||
},
|
||||
}),
|
||||
getWarnings: () => [],
|
||||
reloadConfigStrict: () =>
|
||||
({
|
||||
@@ -48,3 +58,62 @@ test('config settings runtime exposes inferred Yomitan Anki deck lookup', async
|
||||
assert.ok(handler);
|
||||
assert.deepEqual(await handler({}, undefined), { ok: true, value: 'Mining' });
|
||||
});
|
||||
|
||||
test('config settings runtime persists inferred Yomitan Anki deck when config deck is empty', async () => {
|
||||
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-settings-'));
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
fs.writeFileSync(configPath, '{"ankiConnect":{"deck":""}}\n', 'utf-8');
|
||||
|
||||
try {
|
||||
let rawConfig = { ankiConnect: { deck: '' } };
|
||||
let resolvedConfig = resolveConfig(rawConfig).resolved;
|
||||
const runtime = createConfigSettingsRuntime({
|
||||
fields: [],
|
||||
getConfigPath: () => configPath,
|
||||
getRawConfig: () => rawConfig,
|
||||
getConfig: () => resolvedConfig,
|
||||
getWarnings: () => [],
|
||||
reloadConfigStrict: () => {
|
||||
rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
resolvedConfig = resolveConfig(rawConfig).resolved;
|
||||
return {
|
||||
ok: true,
|
||||
config: resolvedConfig,
|
||||
warnings: [],
|
||||
path: configPath,
|
||||
};
|
||||
},
|
||||
getSettingsWindow: () => null,
|
||||
setSettingsWindow: () => undefined,
|
||||
createSettingsWindow: () => ({}) as never,
|
||||
settingsHtmlPath: '/tmp/settings.html',
|
||||
openPath: async () => '',
|
||||
defaultAnkiConnectUrl: DEFAULT_CONFIG.ankiConnect.url,
|
||||
createAnkiClient: () =>
|
||||
({
|
||||
deckNames: async () => [],
|
||||
fieldNamesForDeck: async () => [],
|
||||
modelNamesForDeck: async () => [],
|
||||
modelNames: async () => [],
|
||||
modelFieldNames: async () => [],
|
||||
}) as never,
|
||||
getYomitanAnkiDeckName: async () => 'Minecraft',
|
||||
ipcMain: {
|
||||
handle: (channel, listener) => {
|
||||
handlers.set(channel, listener);
|
||||
},
|
||||
},
|
||||
ipcChannels: IPC_CHANNELS.request,
|
||||
});
|
||||
|
||||
runtime.registerHandlers();
|
||||
|
||||
const handler = handlers.get(IPC_CHANNELS.request.getConfigSettingsYomitanAnkiDeckName);
|
||||
assert.ok(handler);
|
||||
assert.deepEqual(await handler({}, undefined), { ok: true, value: 'Minecraft' });
|
||||
assert.equal(JSON.parse(fs.readFileSync(configPath, 'utf-8')).ankiConnect.deck, 'Minecraft');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -193,13 +193,38 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
|
||||
};
|
||||
}
|
||||
|
||||
function persistInferredYomitanDeckIfEmpty(deckName: string): void {
|
||||
const normalizedDeckName = deckName.trim();
|
||||
const configuredDeckName = deps.getConfig().ankiConnect?.deck?.trim() ?? '';
|
||||
if (!normalizedDeckName || configuredDeckName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = savePatch({
|
||||
operations: [
|
||||
{
|
||||
op: 'set',
|
||||
path: 'ankiConnect.deck',
|
||||
value: normalizedDeckName,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!result.ok) {
|
||||
deps.log?.(
|
||||
`Failed to persist inferred Yomitan Anki deck: ${result.error ?? 'unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function getYomitanAnkiDeckName(): Promise<ConfigSettingsAnkiDeckResult> {
|
||||
if (!deps.getYomitanAnkiDeckName) {
|
||||
return { ok: true, value: '' };
|
||||
}
|
||||
try {
|
||||
const value = await deps.getYomitanAnkiDeckName();
|
||||
return { ok: true, value: typeof value === 'string' ? value.trim() : '' };
|
||||
const deckName = typeof value === 'string' ? value.trim() : '';
|
||||
persistInferredYomitanDeckIfEmpty(deckName);
|
||||
return { ok: true, value: deckName };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -79,8 +79,6 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'F8',
|
||||
}),
|
||||
getDefaultMpvLogPath: () => '/tmp/mp.log',
|
||||
defaultMpvArgs: ['--sid=auto'],
|
||||
@@ -105,8 +103,8 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
|
||||
assert.match(scriptOpts ?? '', /subminer-auto_start_visible_overlay=no/);
|
||||
assert.match(scriptOpts ?? '', /subminer-auto_start_pause_until_ready=no/);
|
||||
assert.match(scriptOpts ?? '', /subminer-texthooker_enabled=no/);
|
||||
assert.match(scriptOpts ?? '', /subminer-aniskip_enabled=yes/);
|
||||
assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/);
|
||||
assert.doesNotMatch(scriptOpts ?? '', /subminer-aniskip_enabled=/);
|
||||
assert.doesNotMatch(scriptOpts ?? '', /subminer-aniskip_button_key=/);
|
||||
});
|
||||
|
||||
test('createLaunchMpvIdleForJellyfinPlaybackHandler skips bundled script when installed plugin exists', () => {
|
||||
|
||||
@@ -40,6 +40,10 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
|
||||
clearStoredSession: () => calls.push('clear-session'),
|
||||
patchJellyfinConfig: () => calls.push('patch'),
|
||||
persistAuthenticatedSession: () => calls.push('persist'),
|
||||
restartRemoteSession: () => {
|
||||
calls.push('restart-remote');
|
||||
},
|
||||
stopRemoteSession: () => calls.push('stop-remote'),
|
||||
logInfo: (message) => calls.push(`info:${message}`),
|
||||
logError: (message) => calls.push(`error:${message}`),
|
||||
showMpvOsd: (message) => calls.push(`osd:${message}`),
|
||||
@@ -95,6 +99,8 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
|
||||
},
|
||||
deps.getJellyfinClientInfo(),
|
||||
);
|
||||
await deps.restartRemoteSession?.();
|
||||
deps.stopRemoteSession?.();
|
||||
deps.logInfo('ok');
|
||||
deps.logError('bad', null);
|
||||
deps.showMpvOsd('toast');
|
||||
@@ -110,6 +116,8 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
|
||||
'clear-session',
|
||||
'patch',
|
||||
'persist',
|
||||
'restart-remote',
|
||||
'stop-remote',
|
||||
'info:ok',
|
||||
'error:bad',
|
||||
'osd:toast',
|
||||
|
||||
@@ -20,6 +20,10 @@ export function createBuildOpenJellyfinSetupWindowMainDepsHandler(
|
||||
persistAuthenticatedSession: deps.persistAuthenticatedSession
|
||||
? (session, clientInfo) => deps.persistAuthenticatedSession?.(session, clientInfo)
|
||||
: undefined,
|
||||
restartRemoteSession: deps.restartRemoteSession
|
||||
? () => deps.restartRemoteSession?.()
|
||||
: undefined,
|
||||
stopRemoteSession: deps.stopRemoteSession ? () => deps.stopRemoteSession?.() : undefined,
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
logError: (message: string, error: unknown) => deps.logError(message, error),
|
||||
showMpvOsd: (message: string) => deps.showMpvOsd(message),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user