mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-28 04:49:49 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
d5f887a834
|
|||
|
77b7ed1fa1
|
|||
|
af826837ab
|
@@ -0,0 +1,5 @@
|
||||
type: added
|
||||
area: stats
|
||||
|
||||
- Added a "Delete Entry" action to the stats Library detail view. It removes the whole title in one step — every episode, session, subtitle line, rollup, cover art and vocabulary counts derived from them — and the title disappears from the Library grid. Previously only individual episodes and sessions could be deleted, so a mistakenly created entry had to be cleared episode by episode and still lingered in the library.
|
||||
- Deletion progress is now shown app-wide instead of only on Overview and Sessions. Every delete (session, session group, episode, and library entry) drives a sweeping bar at the top of the window plus a bottom-right status toast, and both stay visible across tab switches, detail views, and the in-app stats overlay window. The per-tab indicators previously went blank the moment you left the tab that started the delete.
|
||||
@@ -0,0 +1,7 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Made deleting stats data much faster and stopped it stalling playback. Every delete used to recompute each affected word's totals by re-reading that word's whole occurrence history across the library, which meant one random read into the largest table per occurrence. On a 960-session library that cost 3.8s to delete a single session and over a minute to clear a 12-episode title, and because the stats server runs in the app process when the app owns it, that time was spent blocking mpv.
|
||||
- Word and kanji occurrences now store the subtitle line's timestamp alongside the count, so those totals are answered from a covering index instead of the subtitle-line table, and a delete only subtracts what it actually removed rather than recomputing untouched entries. Measured on the same 960-session library: deleting one session 3832ms to 190ms, a ten-session day group 6453ms to 433ms, one episode 5437ms to 263ms, and a whole 12-episode title 60556ms to 621ms.
|
||||
- Opening the Vocabulary tab no longer stalls either. It was computing "seen in N titles" for every word in the library before ordering by frequency and keeping the top 100, so the whole occurrence history was walked to produce one page. It now picks the page first and counts only those rows: 2136ms to 62ms on the same library.
|
||||
- The first launch after upgrading migrates the existing stats database in place (about 3s for a 288k-line library) and it grows roughly 20% from the added index. Vocabulary dates recorded before the upgrade keep working while the migration runs.
|
||||
@@ -691,7 +691,7 @@
|
||||
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
||||
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
|
||||
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
||||
"socketPath": "\\\\.\\pipe\\subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
||||
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
|
||||
"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
|
||||
|
||||
@@ -28,7 +28,7 @@ const outDir = optionalEnv(process.env.SUBMINER_DOCS_OUT_DIR);
|
||||
const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? process.cwd();
|
||||
const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
|
||||
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
|
||||
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.18.0';
|
||||
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.14.0';
|
||||
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
|
||||
const versionLinkOrigin =
|
||||
optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? 'production';
|
||||
@@ -306,7 +306,6 @@ const sidebar: DefaultTheme.SidebarItem[] = [
|
||||
{ text: 'Usage', link: '/usage' },
|
||||
{ text: 'Mining Workflow', link: '/mining-workflow' },
|
||||
{ text: 'Launcher Script', link: '/launcher-script' },
|
||||
{ text: 'Feature Demos', link: '/demos' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -71,10 +71,6 @@ When both files exist, SubMiner prefers `config.jsonc` over `config.json`.
|
||||
|
||||
See [config.example.jsonc](/config.example.jsonc) for a comprehensive example with all available options, default values, and detailed comments. Only include the options you want to customize in your config file.
|
||||
|
||||
::: warning One value in that file is platform-specific
|
||||
The example is generated with a fixed Linux/macOS socket path so it stays reproducible, so it shows `"socketPath": "/tmp/subminer-socket"`. On Windows the real default is `\\\\.\\pipe\\subminer-socket`. Leave `mpv.socketPath` out of your config entirely unless you need a custom path, and SubMiner picks the right one for your platform.
|
||||
:::
|
||||
|
||||
Generate a fresh default config from the centralized config registry:
|
||||
|
||||
```bash
|
||||
@@ -149,13 +145,12 @@ The configuration file includes several main sections:
|
||||
- [**Shared AI Provider**](#shared-ai-provider) - Canonical OpenAI-compatible provider config shared by Anki and YouTube subtitle fixing
|
||||
- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media
|
||||
- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis note types
|
||||
- [**N+1 Word Highlighting**](#n-1-word-highlighting) - Known-word cache and single-target highlighting
|
||||
- [**N+1 Word Highlighting**](#n1-word-highlighting) - Known-word cache and single-target highlighting
|
||||
- [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Lapis duplicate card merging
|
||||
|
||||
**External Integrations**
|
||||
|
||||
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
||||
- [**TsukiHime**](#tsukihime) - Multi-language subtitle search and download
|
||||
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
|
||||
- [**AniList**](#anilist) - Optional post-watch progress updates
|
||||
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile
|
||||
@@ -543,8 +538,6 @@ Display a second subtitle track (e.g., English alongside Japanese) in the overla
|
||||
|
||||
See `config.example.jsonc` for detailed configuration options.
|
||||
|
||||
Secondary subtitles do **not** auto-load by default. To turn them on for local and Jellyfin playback, set `autoLoadSecondarySub` to `true` and list the language codes you want:
|
||||
|
||||
```json
|
||||
{
|
||||
"secondarySub": {
|
||||
@@ -555,15 +548,11 @@ Secondary subtitles do **not** auto-load by default. To turn them on for local a
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). |
|
||||
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) |
|
||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
||||
|
||||
These two settings apply to local and Jellyfin playback only. YouTube secondary selection is fixed to English and ignores them; see [YouTube Integration](/youtube-integration#secondary-subtitle-languages). `defaultMode` still controls how the loaded secondary bar is displayed in every case.
|
||||
|
||||
Because the mined-card translation field is filled from the secondary subtitle when one is present, leaving `autoLoadSecondarySub` off means local-file cards fall back to AI translation (when configured) or the original sentence text.
|
||||
| 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.
|
||||
|
||||
@@ -874,8 +863,8 @@ When config hot-reload updates shortcut/keybinding/style values, close and reope
|
||||
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
|
||||
|
||||
Current runtime options cover automatic card updates, known-word highlighting,
|
||||
known-word maturity coloring, N+1 annotation, JLPT underlines, frequency
|
||||
highlighting, known-word match mode, and Kiku field grouping mode.
|
||||
N+1 annotation, JLPT underlines, frequency highlighting, known-word match mode,
|
||||
and Kiku field grouping mode.
|
||||
|
||||
Annotation toggles only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
|
||||
|
||||
@@ -1042,8 +1031,6 @@ This example is intentionally compact. The option table below documents availabl
|
||||
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
||||
| `ankiConnect.knownWords.maturityEnabled` | `true`, `false` | Color known words by Anki card maturity (new/learning/young/mature) instead of one color. Requires `knownWords.highlightEnabled` (default: `false`). Tier colors come from `subtitleStyle.knownWordMaturityColors`. |
|
||||
| `ankiConnect.knownWords.matureThresholdDays` | number | Card interval in days at which a known word counts as mature (default: `21`, matching Anki's own convention) |
|
||||
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
|
||||
@@ -1089,7 +1076,6 @@ Known-word cache policy:
|
||||
- `subtitleStyle.nPlusOneColor` sets the color for the single target token when exactly one eligible unknown word exists.
|
||||
- The N+1 minimum sentence-word setting controls the token count required before N+1 highlighting can trigger.
|
||||
- `subtitleStyle.knownWordColor` sets the known-word highlight color for tokens already in Anki.
|
||||
- Set `ankiConnect.knownWords.maturityEnabled` to `true` to color known words by Anki card maturity instead, using the four `subtitleStyle.knownWordMaturityColors` tiers. See [Known-Word Maturity Highlighting](/subtitle-annotations#known-word-maturity-highlighting) for how tiers are derived. Changing it or `matureThresholdDays` forces a full cache refresh.
|
||||
- The known-word deck map accepts an object keyed by deck name.
|
||||
- Prefer expression/word fields such as `Expression` or `Word`. Avoid reading-only fields unless you intentionally want homophone readings to count as known words.
|
||||
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
|
||||
@@ -1128,7 +1114,6 @@ When the manual merge popup opens, SubMiner pauses playback and closes any open
|
||||
|
||||
<video controls playsinline preload="metadata" :poster="withBase('/assets/kiku-integration-poster.jpg')" style="width: 100%; max-width: 960px;">
|
||||
<source :src="withBase('/assets/kiku-integration.webm')" type="video/webm" />
|
||||
<source :src="withBase('/assets/kiku-integration.mp4')" type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
|
||||
@@ -1154,28 +1139,6 @@ Configure Jimaku API access and defaults:
|
||||
|
||||
Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry delay from the API response.
|
||||
|
||||
### TsukiHime
|
||||
|
||||
TsukiHime subtitle search works out of the box and needs no account or API key. It does require the `xz` binary on your `PATH`, because TsukiHime serves extracted subtitles xz-compressed.
|
||||
|
||||
```json
|
||||
{
|
||||
"tsukihime": {
|
||||
"apiBaseUrl": "https://api.tsukihime.org/v1",
|
||||
"maxSearchResults": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ---------------------------- | ------------ | ---------------------------------------------------------------------------------------------------- |
|
||||
| `tsukihime.apiBaseUrl` | string (URL) | Base URL of the TsukiHime API (default: `https://api.tsukihime.org/v1`). Only change it for a mirror. |
|
||||
| `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) |
|
||||
|
||||
The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift+T`; set to `null` to disable). The older `animetosho` section and `shortcuts.openAnimetosho` are still accepted as deprecated aliases, with the current names taking precedence when both are set.
|
||||
|
||||
See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, language tabs, and troubleshooting.
|
||||
|
||||
### Subtitle Sync
|
||||
|
||||
Sync the active subtitle track from the overlay picker using `alass` or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
|
||||
@@ -1528,7 +1491,7 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
|
||||
"executablePath": "",
|
||||
"launchMode": "normal",
|
||||
"profile": "",
|
||||
"socketPath": "/tmp/subminer-socket",
|
||||
"socketPath": "\\\\.\\pipe\\subminer-socket",
|
||||
"backend": "auto",
|
||||
"autoStartSubMiner": true,
|
||||
"pauseUntilOverlayReady": true,
|
||||
|
||||
@@ -20,6 +20,9 @@ Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner aut
|
||||
</a>
|
||||
</video>
|
||||
|
||||
::: info VIDEO COMING SOON
|
||||
:::
|
||||
|
||||
## Subtitle Download & Sync
|
||||
|
||||
Search and download subtitles from Jimaku, then retime them with alass or ffsubsync - all from within SubMiner.
|
||||
|
||||
@@ -14,10 +14,6 @@ const ankiIntegrationContents = readFileSync(
|
||||
'utf8',
|
||||
);
|
||||
const configurationContents = readFileSync(new URL('./configuration.md', import.meta.url), 'utf8');
|
||||
const troubleshootingContents = readFileSync(
|
||||
new URL('./troubleshooting.md', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function extractReleaseHeadings(content: string, count: number): string[] {
|
||||
return Array.from(content.matchAll(/^## v[^\n]+$/gm))
|
||||
@@ -62,33 +58,6 @@ test('docs reflect current launcher and release surfaces', () => {
|
||||
expect(changelogContents).toContain('v0.5.1 (2026-03-09)');
|
||||
});
|
||||
|
||||
test('docs document config surfaces that are easy to miss when they ship', () => {
|
||||
// Anki maturity-based known-word highlighting (#172) landed in
|
||||
// subtitle-annotations.md but was missing from the config reference.
|
||||
expect(configurationContents).toContain('ankiConnect.knownWords.maturityEnabled');
|
||||
expect(configurationContents).toContain('ankiConnect.knownWords.matureThresholdDays');
|
||||
|
||||
// Every top-level config block should be reachable from the config reference.
|
||||
expect(configurationContents).toContain('### TsukiHime');
|
||||
expect(configurationContents).toContain('tsukihime.maxSearchResults');
|
||||
|
||||
// xz is a hard runtime dependency of the TsukiHime download path.
|
||||
expect(installationContents).toContain('xz');
|
||||
});
|
||||
|
||||
test('docs state the real secondary-subtitle and Anki field-matching behavior', () => {
|
||||
// secondarySub auto-load is off by default; the config example previously
|
||||
// implied otherwise while youtube-integration.md documented it correctly.
|
||||
expect(configurationContents).toContain('Secondary subtitles do **not** auto-load by default');
|
||||
expect(configurationContents).toContain('default: `false`');
|
||||
|
||||
// Anki field names are matched case-insensitively (src/anki-integration.ts
|
||||
// resolveFieldName: exact match first, then a lowercase comparison).
|
||||
expect(usageContents).not.toContain('exactly (case-sensitive)');
|
||||
expect(troubleshootingContents).not.toContain('exactly (case-sensitive)');
|
||||
expect(ankiIntegrationContents).toContain('case-insensitively');
|
||||
});
|
||||
|
||||
test('docs dev server links version navigation to local dev routes', () => {
|
||||
expect(docsPackageContents).toContain('scripts/build-versioned-docs.ts');
|
||||
expect(docsPackageContents).toContain(
|
||||
|
||||
@@ -57,6 +57,8 @@ Jellyfin stream URLs are normalized to stable item links before stats titles are
|
||||
|
||||
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.
|
||||
|
||||
Open a title and use **Delete Entry** in its header to remove a mistakenly tracked show outright. This deletes every episode of that title along with their sessions, subtitle lines, rollups and cover art, drops the words and kanji that were only seen there, and removes the card from the Library grid. Individual episodes and sessions can still be deleted on their own from the episode list and session rows. Entry deletion is refused while that title is the one currently playing.
|
||||
|
||||

|
||||
|
||||
#### Trends
|
||||
|
||||
+17
-111
@@ -12,23 +12,20 @@ Three steps to get started:
|
||||
|
||||
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
|
||||
|
||||
Several entries below exist only for the `subminer` command-line launcher, which is Linux and macOS only. On Windows you launch playback with the **SubMiner mpv** shortcut instead, so you can ignore those rows.
|
||||
|
||||
| Dependency | Status | Platforms | What it does |
|
||||
| -------------------- | ----------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| mpv | Required | All | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
|
||||
| ffmpeg | Recommended | All | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
|
||||
| MeCab + mecab-ipadic | Recommended | All | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
|
||||
| yt-dlp | Optional | All | YouTube playback and subtitle extraction. |
|
||||
| xz | Optional | All | Required for TsukiHime subtitle downloads (subtitles are served xz-compressed). Preinstalled on most Linux distros; not present on Windows by default. |
|
||||
| guessit | Optional | All | Better AniSkip title/season/episode parsing. |
|
||||
| alass | Optional | All | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
|
||||
| ffsubsync | Optional | All | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. |
|
||||
| fzf | Optional | Linux, macOS | Terminal-based video picker in the `subminer` launcher. |
|
||||
| rofi | Optional | Linux | GUI-based video picker in the `subminer` launcher. |
|
||||
| chafa | Optional | Linux, macOS | Thumbnail previews in the fzf picker. |
|
||||
| ffmpegthumbnailer | Optional | Linux, macOS | Video thumbnail generation for the pickers. |
|
||||
| fuse2 | Required | Linux | Needed to run the AppImage. |
|
||||
| Dependency | Status | What it does |
|
||||
| -------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| mpv | Required | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
|
||||
| ffmpeg | Recommended | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
|
||||
| MeCab + mecab-ipadic | Recommended | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
|
||||
| yt-dlp | Optional | YouTube playback and subtitle extraction. |
|
||||
| fzf | Optional | Terminal-based video picker in the launcher. |
|
||||
| rofi | Optional | GUI-based video picker (Linux). |
|
||||
| chafa | Optional | Thumbnail previews in fzf. |
|
||||
| ffmpegthumbnailer | Optional | Video thumbnail generation for the picker. |
|
||||
| guessit | Optional | Better AniSkip title/season/episode parsing. |
|
||||
| alass | Optional | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
|
||||
| ffsubsync | Optional | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. |
|
||||
| fuse2 | Linux only | Required to run the AppImage. |
|
||||
|
||||
### Linux
|
||||
|
||||
@@ -112,100 +109,9 @@ pip install ffsubsync
|
||||
|
||||
### Windows
|
||||
|
||||
Windows 10 or later. No compositor tools or window helpers are needed - native window tracking is built in.
|
||||
Windows 10 or later. Install [`mpv`](https://mpv.io/installation/) and [`ffmpeg`](https://ffmpeg.org/download.html) and ensure both are on `PATH`. Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary.
|
||||
|
||||
You need **mpv** (required) and **ffmpeg** (strongly recommended, for card audio and screenshots), and both must be on your `PATH`.
|
||||
|
||||
::: tip What is PATH?
|
||||
`PATH` is the list of folders Windows searches when a program asks to run another program by name. SubMiner runs `mpv` and `ffmpeg` by name, so if their folders are not on `PATH`, SubMiner cannot find them even though they are installed. The routes below mostly handle `PATH` for you; the manual route explains how to add a folder yourself.
|
||||
:::
|
||||
|
||||
You can install these with a package manager or by hand. Coverage differs, so pick based on what you need:
|
||||
|
||||
| Dependency | winget | Scoop |
|
||||
| ---------------- | --------------- | ------------- |
|
||||
| mpv (required) | `shinchiro.mpv` | `extras/mpv` |
|
||||
| ffmpeg | `Gyan.FFmpeg` | `main/ffmpeg` |
|
||||
| yt-dlp (YouTube) | `yt-dlp.yt-dlp` | `main/yt-dlp` |
|
||||
| xz (TsukiHime) | not packaged | `main/xz` |
|
||||
|
||||
Use **winget** if you want Microsoft's first-party tool and don't need TsukiHime subtitle downloads. Use **Scoop** if you want one package manager to cover everything, since it is the only one that also packages `xz`.
|
||||
|
||||
#### Recommended: winget
|
||||
|
||||
[winget](https://learn.microsoft.com/windows/package-manager/winget/) is Microsoft's own package manager and ships with Windows 11 and current Windows 10 (it comes with **App Installer** from the Microsoft Store). In **PowerShell** or **Command Prompt**:
|
||||
|
||||
```powershell
|
||||
winget install shinchiro.mpv
|
||||
winget install Gyan.FFmpeg
|
||||
```
|
||||
|
||||
Close and reopen your terminal, then check that both are found:
|
||||
|
||||
```powershell
|
||||
mpv --version
|
||||
ffmpeg -version
|
||||
```
|
||||
|
||||
`ffmpeg` is installed as a portable package, so winget links it into a folder that is already on your `PATH` and it should work right away.
|
||||
|
||||
`mpv` uses a regular installer, and depending on the version it may **not** add itself to `PATH`. If `mpv --version` says `not recognized`, you have two easy options:
|
||||
|
||||
- Note where it installed (usually `%LOCALAPPDATA%\Programs\mpv`) and add that folder to `PATH` using the manual steps below, or
|
||||
- Skip `PATH` entirely and set `mpv.executablePath` to the full path of `mpv.exe` during first-run setup.
|
||||
|
||||
Once `mpv --version` works, or you have the full path to `mpv.exe` ready, continue to [step 2](#_2-install-subminer).
|
||||
|
||||
<details>
|
||||
<summary><b>Alternative: Scoop (covers every dependency, no admin rights)</b></summary>
|
||||
|
||||
[Scoop](https://scoop.sh) installs into your user profile, needs no administrator prompt, and always puts commands on `PATH`. It is the only Windows package manager that carries all of SubMiner's optional dependencies, including `xz`, so it is the best choice if you want a single tool to manage everything.
|
||||
|
||||
```powershell
|
||||
# One-time Scoop setup (skip if you already have it)
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression
|
||||
|
||||
# mpv lives in the "extras" bucket; everything else is in "main"
|
||||
scoop bucket add extras
|
||||
scoop install extras/mpv main/ffmpeg
|
||||
|
||||
# Optional: yt-dlp for YouTube playback, xz for TsukiHime subtitle downloads
|
||||
scoop install main/yt-dlp main/xz
|
||||
```
|
||||
|
||||
Close and reopen your terminal, then verify with `mpv --version` and `ffmpeg -version`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Manual install (download the zips yourself)</b></summary>
|
||||
|
||||
1. Download mpv from [mpv.io/installation](https://mpv.io/installation/) (the Windows builds link) and ffmpeg from [ffmpeg.org/download.html](https://ffmpeg.org/download.html).
|
||||
2. Unzip each one somewhere permanent, for example `C:\Tools\mpv` and `C:\Tools\ffmpeg`. Note the folder that actually contains `mpv.exe` and the one containing `ffmpeg.exe` (for ffmpeg this is usually a `bin` subfolder).
|
||||
3. Press `Win`, type **Edit the system environment variables**, and open it.
|
||||
4. Click **Environment Variables…**
|
||||
5. Under **User variables**, select **Path** and click **Edit…**
|
||||
6. Click **New** and paste the folder containing `mpv.exe`. Click **New** again and paste the folder containing `ffmpeg.exe`.
|
||||
7. Click **OK** on all three dialogs.
|
||||
8. Close and reopen your terminal (PATH changes only apply to newly opened windows), then check:
|
||||
|
||||
```powershell
|
||||
mpv --version
|
||||
ffmpeg -version
|
||||
```
|
||||
|
||||
If you see `not recognized as the name of a cmdlet`, the folder you added is not the one holding the `.exe`. Reopen the Path editor and double-check.
|
||||
|
||||
::: tip Don't want to touch PATH at all?
|
||||
mpv is the only hard requirement, and you can point SubMiner straight at it instead: leave `PATH` alone and set `mpv.executablePath` to the full path of `mpv.exe` during first-run setup. ffmpeg still needs to be on `PATH` for card audio and screenshots.
|
||||
:::
|
||||
|
||||
</details>
|
||||
|
||||
**Optional extras:** [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary improves annotation accuracy; it is not in any package manager, so install it from that page. `xz` is needed only for [TsukiHime](/tsukihime-integration) subtitle downloads and is not packaged by winget or Chocolatey, so use `scoop install main/xz` or download [XZ Utils](https://tukaani.org/xz/) and add its folder to `PATH`.
|
||||
|
||||
The `subminer` command-line launcher and its picker tools (`fzf`, `rofi`, `chafa`, `ffmpegthumbnailer`) are Linux/macOS only; on Windows you use the **SubMiner mpv** shortcut instead.
|
||||
No compositor tools or window helpers are needed - native window tracking is built in.
|
||||
|
||||
## 2. Install SubMiner
|
||||
|
||||
@@ -372,7 +278,7 @@ Run the built-in diagnostic to confirm everything is working:
|
||||
subminer doctor
|
||||
```
|
||||
|
||||
This checks for the app binary, mpv, ffmpeg, yt-dlp, fzf, rofi, your config file, and the mpv socket path. Only the app binary and mpv are hard failures; the rest are reported as optional. Fix any hard failures before continuing.
|
||||
This checks for the app binary, mpv, ffmpeg, config file, and socket path. Fix any failures before continuing.
|
||||
|
||||
## Anki Setup (Recommended)
|
||||
|
||||
|
||||
@@ -112,5 +112,5 @@ Verify mpv is running and connected via IPC. SubMiner loads the subtitle by issu
|
||||
## Related
|
||||
|
||||
- [Configuration Reference](/configuration#jimaku) - full config options
|
||||
- [Mining Workflow](/mining-workflow#related-features) - how Jimaku fits into the sentence mining loop
|
||||
- [Mining Workflow](/mining-workflow#jimaku-subtitle-search) - how Jimaku fits into the sentence mining loop
|
||||
- [Troubleshooting](/troubleshooting#jimaku) - additional error guidance
|
||||
|
||||
@@ -176,22 +176,22 @@ Use `subminer <subcommand> -h` for command-specific help.
|
||||
|
||||
## Options
|
||||
|
||||
| Flag | Description |
|
||||
| --------------------- | ---------------------------------------------------------------------------- |
|
||||
| `-d, --directory` | Video search directory (default: cwd) |
|
||||
| `-r, --recursive` | Search directories recursively |
|
||||
| `-R, --rofi` | Use rofi instead of fzf |
|
||||
| `-H, --history` | Browse local watch history (see [Watch History](#watch-history)) |
|
||||
| `-v, --version` | Print the launcher's own version (can differ from the installed app binary) |
|
||||
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
|
||||
| `--start` | Explicitly start overlay after mpv launches |
|
||||
| `-S, --start-overlay` | Force the visible overlay on start |
|
||||
| `-T, --no-texthooker` | Disable texthooker server |
|
||||
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
|
||||
| `-a, --args` | Pass additional mpv arguments as a quoted string |
|
||||
| `-b, --backend` | Force window backend (`auto`, `hyprland`, `sway`, `x11`, `macos`, `windows`) |
|
||||
| `--settings` | Open the SubMiner settings window |
|
||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||
| Flag | Description |
|
||||
| --------------------- | --------------------------------------------------------------------------- |
|
||||
| `-d, --directory` | Video search directory (default: cwd) |
|
||||
| `-r, --recursive` | Search directories recursively |
|
||||
| `-R, --rofi` | Use rofi instead of fzf |
|
||||
| `-H, --history` | Browse local watch history (see [Watch History](#watch-history)) |
|
||||
| `-v, --version` | Print the launcher's own version (can differ from the installed app binary) |
|
||||
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
|
||||
| `--start` | Explicitly start overlay after mpv launches |
|
||||
| `-S, --start-overlay` | Force the visible overlay on start |
|
||||
| `-T, --no-texthooker` | Disable texthooker server |
|
||||
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
|
||||
| `-a, --args` | Pass additional mpv arguments as a quoted string |
|
||||
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) |
|
||||
| `--settings` | Open the SubMiner settings window |
|
||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||
|
||||
App-binary flags such as `--setup`, `--dev`, and `--debug` are not launcher flags - pass them through with `subminer app`, for example `subminer app --setup`.
|
||||
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const docsSiteDir = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
// Mirrors VitePress' heading slugifier (vitepress/dist/node, `rControl` + `rSpecial`).
|
||||
// Note that a *run* of special characters collapses to a single `-`, so
|
||||
// "KDE Plasma & other" becomes "kde-plasma-other", not "kde-plasma--other".
|
||||
const rControl = new RegExp('[\\u0000-\\u001f]', 'g');
|
||||
const rSpecial = /[\s~`!@#$%^&*()\-_+=[\]{}|\\;:"'“”‘’<>,.?/]+/g;
|
||||
|
||||
function slugify(heading: string): string {
|
||||
return heading
|
||||
.replace(rControl, '')
|
||||
.replace(rSpecial, '-')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/^(\d)/, '_$1')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
const EXCLUDED_PAGES = new Set(['README.md']);
|
||||
const PUBLIC_PREFIXES = ['/assets/', '/screenshots/', '/config.example.jsonc', '/favicon'];
|
||||
|
||||
function loadPages(): Map<string, string> {
|
||||
const pages = new Map<string, string>();
|
||||
for (const file of readdirSync(docsSiteDir)) {
|
||||
if (!file.endsWith('.md') || EXCLUDED_PAGES.has(file)) continue;
|
||||
const route = `/${file.replace(/\.md$/, '')}`;
|
||||
pages.set(route, readFileSync(`${docsSiteDir}${file}`, 'utf8'));
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
function anchorsFor(contents: string): Set<string> {
|
||||
const anchors = new Set<string>();
|
||||
for (const match of contents.matchAll(/^#{1,6}\s+(.+?)\s*$/gm)) {
|
||||
let heading = match[1]!;
|
||||
const explicitId = heading.match(/\{#([^}]+)\}\s*$/);
|
||||
if (explicitId) {
|
||||
anchors.add(explicitId[1]!);
|
||||
heading = heading.replace(/\{#[^}]+\}\s*$/, '');
|
||||
}
|
||||
anchors.add(slugify(heading.replace(/`/g, '')));
|
||||
}
|
||||
return anchors;
|
||||
}
|
||||
|
||||
function resolveRoute(target: string, fromRoute: string): string {
|
||||
if (target === '') return fromRoute;
|
||||
if (target.startsWith('./')) return `/${target.slice(2).replace(/\.md$/, '')}`;
|
||||
const normalized = target.replace(/\.md$/, '').replace(/\/$/, '');
|
||||
return normalized === '' ? '/index' : normalized;
|
||||
}
|
||||
|
||||
const pages = loadPages();
|
||||
const anchors = new Map([...pages].map(([route, body]) => [route, anchorsFor(body)]));
|
||||
|
||||
test('every internal docs link resolves to an existing page', () => {
|
||||
const broken: string[] = [];
|
||||
|
||||
for (const [route, body] of pages) {
|
||||
for (const match of body.matchAll(/\]\((\/[^)\s]*|\.\/[^)\s]*)\)/g)) {
|
||||
const link = match[1]!;
|
||||
const target = link.split('#')[0]!;
|
||||
if (PUBLIC_PREFIXES.some((prefix) => target.startsWith(prefix))) continue;
|
||||
|
||||
const resolved = resolveRoute(target, route);
|
||||
if (resolved !== '/index' && !pages.has(resolved)) {
|
||||
broken.push(`${route.slice(1)}.md -> ${link}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(broken).toEqual([]);
|
||||
});
|
||||
|
||||
test('every internal docs anchor matches a real heading slug', () => {
|
||||
const broken: string[] = [];
|
||||
|
||||
for (const [route, body] of pages) {
|
||||
for (const match of body.matchAll(/\]\((\/[^)\s]*|\.\/[^)\s]*|#[^)\s]*)\)/g)) {
|
||||
const link = match[1]!;
|
||||
const hashIndex = link.indexOf('#');
|
||||
if (hashIndex < 0) continue;
|
||||
|
||||
const target = link.slice(0, hashIndex);
|
||||
const anchor = link.slice(hashIndex + 1);
|
||||
if (PUBLIC_PREFIXES.some((prefix) => target.startsWith(prefix))) continue;
|
||||
|
||||
const resolved = resolveRoute(target, route);
|
||||
const pageAnchors = anchors.get(resolved);
|
||||
if (!pageAnchors || pageAnchors.has(anchor)) continue;
|
||||
|
||||
broken.push(`${route.slice(1)}.md -> ${link}`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(broken).toEqual([]);
|
||||
});
|
||||
|
||||
test('slugify matches the VitePress cases these docs actually rely on', () => {
|
||||
// Regression guards for the anchors that were previously wrong.
|
||||
expect(slugify('N+1 Word Highlighting')).toBe('n-1-word-highlighting');
|
||||
expect(slugify('KDE Plasma & other Wayland compositors')).toBe(
|
||||
'kde-plasma-other-wayland-compositors',
|
||||
);
|
||||
expect(slugify('Proxy Mode Setup (Yomitan / Texthooker)')).toBe(
|
||||
'proxy-mode-setup-yomitan-texthooker',
|
||||
);
|
||||
expect(slugify('Kiku/Lapis Integration')).toBe('kiku-lapis-integration');
|
||||
expect(slugify('Secondary Subtitles')).toBe('secondary-subtitles');
|
||||
expect(slugify('2. Install SubMiner')).toBe('_2-install-subminer');
|
||||
});
|
||||
|
||||
test('every docs page is reachable from the sidebar', async () => {
|
||||
const { default: config } = await import('./.vitepress/config');
|
||||
const sidebar = config.themeConfig?.sidebar as Array<{
|
||||
items?: Array<{ text: string; link?: string }>;
|
||||
}>;
|
||||
|
||||
const linked = new Set<string>();
|
||||
for (const group of sidebar) {
|
||||
for (const item of group.items ?? []) {
|
||||
if (item.link) linked.add(item.link === '/' ? '/index' : item.link);
|
||||
}
|
||||
}
|
||||
|
||||
const orphans = [...pages.keys()].filter((route) => !linked.has(route));
|
||||
expect(orphans).toEqual([]);
|
||||
});
|
||||
@@ -183,7 +183,7 @@ If you want to build your own browser client, websocket consumer, or automation
|
||||
These features support the mining loop but have their own dedicated pages:
|
||||
|
||||
- **[Jimaku subtitle search](/jimaku-integration)** - search and download anime subtitle files directly from the overlay (`Ctrl+Shift+J` by default), then load them into mpv.
|
||||
- **[N+1 word highlighting](/subtitle-annotations#n-1-word-highlighting)** - cross-reference your Anki decks to highlight known words, making true N+1 sentences (exactly one unknown word) easy to spot during immersion.
|
||||
- **[N+1 word highlighting](/subtitle-annotations#n1-word-highlighting)** - cross-reference your Anki decks to highlight known words, making true N+1 sentences (exactly one unknown word) easy to spot during immersion.
|
||||
- **[Immersion tracking](/immersion-tracking)** - log watching and mining activity to a local database and view session times, words seen, and cards mined in the built-in stats dashboard.
|
||||
|
||||
Next: [Anki Integration](/anki-integration) - field mapping, media generation, and card enrichment configuration.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"docs:dev": "SUBMINER_DOCS_VERSION_LINK_ORIGIN=local bun run ../scripts/build-versioned-docs.ts && SUBMINER_DOCS_VERSION_LINK_ORIGIN=local SUBMINER_DOCS_VERSION_MANIFEST=\"$(bun run ../scripts/print-docs-version-manifest.ts)\" VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
|
||||
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build",
|
||||
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview --host 0.0.0.0 --port 4173 --strictPort",
|
||||
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts links.test.ts seo.test.ts .vitepress/theme/status-line.test.ts ../scripts/docs-versioning.test.ts"
|
||||
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts seo.test.ts .vitepress/theme/status-line.test.ts ../scripts/docs-versioning.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@catppuccin/vitepress": "^0.1.2",
|
||||
|
||||
@@ -691,7 +691,7 @@
|
||||
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
||||
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
|
||||
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
||||
"socketPath": "\\\\.\\pipe\\subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
||||
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
|
||||
"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
|
||||
|
||||
@@ -251,9 +251,7 @@ test('dev docs version links use local targets for version route testing', async
|
||||
delete process.env.SUBMINER_DOCS_CHANNEL;
|
||||
delete process.env.SUBMINER_DOCS_BASE;
|
||||
delete process.env.SUBMINER_DOCS_VERSION;
|
||||
// Set explicitly (like the sibling version-nav tests) so this assertion stays
|
||||
// pinned to the manifest under test instead of the config's fallback constant.
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
||||
delete process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
|
||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
|
||||
latestStable: 'v0.14.0',
|
||||
|
||||
+87
-104
@@ -16,6 +16,90 @@ SubMiner retries the connection automatically with increasing delays (200 ms, 50
|
||||
|
||||
If the overlay never appears at all, see [Playback Startup Flow](./architecture#playback-startup-flow) for how a managed launch starts mpv and brings up the overlay.
|
||||
|
||||
## Logging and App Mode
|
||||
|
||||
- Default log output is `warn`.
|
||||
- Use `--log-level` for more/less output.
|
||||
- Use `--dev`/`--debug` only to force app/dev mode (for example to get dev behavior from the overlay/app); they do not change log verbosity.
|
||||
- You can combine both, for example `SubMiner.AppImage --start --dev --log-level debug`, when you need maximum diagnostics.
|
||||
|
||||
## Performance and Resource Impact
|
||||
|
||||
### At a glance
|
||||
|
||||
- Baseline: `SubMiner --start` is usually lightweight for normal playback.
|
||||
- Common spikes come from:
|
||||
- first subtitle parse/tokenization bursts
|
||||
- media generation (`ffmpeg` audio/image and AVIF paths)
|
||||
- media sync and subtitle tooling (`alass`, `ffsubsync`)
|
||||
- `ankiConnect` enrichment (plus polling overhead when proxy mode is disabled)
|
||||
|
||||
### If playback feels sluggish
|
||||
|
||||
1. Reduce overlay workload:
|
||||
|
||||
- set secondary subtitles hidden:
|
||||
- `secondarySub.defaultMode: "hidden"`
|
||||
- disable optional enrichment:
|
||||
- `subtitleStyle.enableJlpt: false`
|
||||
- `subtitleStyle.frequencyDictionary.enabled: false`
|
||||
|
||||
2. Reduce rendering pressure:
|
||||
|
||||
- lower `subtitleStyle.css["font-size"]`
|
||||
- keep overlay complexity minimal during heavy CPU periods
|
||||
|
||||
3. Reduce media overhead:
|
||||
|
||||
- keep `ankiConnect.media.imageType` set to `static` (avoid animated AVIF unless needed)
|
||||
- lower `ankiConnect.media.imageQuality`
|
||||
- reduce `ankiConnect.media.maxMediaDuration`
|
||||
|
||||
4. Lower integration cost:
|
||||
|
||||
- disable AI translation when not needed (`ankiConnect.ai.enabled: false`)
|
||||
- if needed, run immersion telemetry with lower duration expectations (`immersionTracking.enabled: false` for constrained sessions)
|
||||
- favor the default lightweight YouTube subtitle startup settings on low-resource systems
|
||||
|
||||
### Practical low-impact profile
|
||||
|
||||
```json
|
||||
{
|
||||
"subtitleStyle": {
|
||||
"css": {
|
||||
"font-size": "30px"
|
||||
},
|
||||
"enableJlpt": false,
|
||||
"frequencyDictionary": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"secondarySub": {
|
||||
"defaultMode": "hidden"
|
||||
},
|
||||
"ankiConnect": {
|
||||
"media": {
|
||||
"imageType": "static",
|
||||
"imageQuality": 80,
|
||||
"maxMediaDuration": 12
|
||||
},
|
||||
"ai": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"immersionTracking": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### If usage is still high
|
||||
|
||||
- Confirm only one SubMiner instance is running.
|
||||
- Check whether bottlenecks are `ffmpeg`, `yt-dlp`, or sync tooling in system monitor.
|
||||
- Keep the default `warn` level for normal use; raise to `info` or `debug` only for targeted diagnosis.
|
||||
- Reproduce once with `SubMiner.AppImage --start --log-level debug` and open DevTools (`y` then `d`) if freezes recur.
|
||||
|
||||
**"Failed to parse MPV message"**
|
||||
|
||||
Logged when a malformed JSON line arrives from the mpv socket. Usually harmless - SubMiner skips the bad line and continues. If it happens constantly, check that nothing else is writing to the same socket path.
|
||||
@@ -64,7 +148,7 @@ SubMiner retries with exponential backoff (up to 5 s) and suppresses repeated er
|
||||
|
||||
**Cards are created but fields are empty**
|
||||
|
||||
Field names in your config must name a field that exists on your Anki note type. Matching is case-insensitive (`sentenceaudio` finds `SentenceAudio`), but the spelling must otherwise match, and unknown fields are skipped silently. Check `ankiConnect.fields` - for example, if your note type uses `SentenceAudio` but your config says `Audio`, the field will not be populated.
|
||||
Field names in your config must match your Anki note type exactly (case-sensitive). Check `ankiConnect.fields` - for example, if your note type uses `SentenceAudio` but your config says `Audio`, the field will not be populated.
|
||||
|
||||
See [Anki Integration](/anki-integration) for the full field mapping reference.
|
||||
|
||||
@@ -232,115 +316,17 @@ If subtitle sync fails (the error message is prefixed with the engine name):
|
||||
- Try running the sync tool manually to see detailed error output.
|
||||
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
|
||||
|
||||
## TsukiHime
|
||||
|
||||
**"xz binary not found"**
|
||||
|
||||
TsukiHime serves extracted subtitles xz-compressed, so SubMiner shells out to `xz` to decompress them. Install it:
|
||||
|
||||
- **Arch Linux**: `sudo pacman -S xz`
|
||||
- **Ubuntu/Debian**: `sudo apt install xz-utils`
|
||||
- **Fedora**: `sudo dnf install xz`
|
||||
- **macOS**: `brew install xz`
|
||||
- **Windows**: neither winget nor Chocolatey packages `xz`. Use `scoop install main/xz`, or download XZ Utils from [tukaani.org/xz](https://tukaani.org/xz/) and add the folder containing `xz.exe` to your `PATH`. Restart SubMiner afterwards.
|
||||
|
||||
Most Linux distributions ship it already. See [TsukiHime Integration](/tsukihime-integration#troubleshooting) for the other TsukiHime error messages.
|
||||
|
||||
## Jimaku
|
||||
|
||||
**"Jimaku request failed" or HTTP 429**
|
||||
|
||||
The Jimaku API has rate limits. If you see 429 errors, wait for the retry duration shown in the OSD message and try again. If you have a Jimaku API key, set it in `jimaku.apiKey` or `jimaku.apiKeyCommand` to get higher rate limits.
|
||||
|
||||
## Logging and App Mode
|
||||
|
||||
- Default log output is `warn`.
|
||||
- Use `--log-level` for more/less output.
|
||||
- Use `--dev`/`--debug` only to force app/dev mode (for example to get dev behavior from the overlay/app); they do not change log verbosity.
|
||||
- You can combine both, for example `SubMiner.AppImage --start --dev --log-level debug`, when you need maximum diagnostics.
|
||||
|
||||
## Performance and Resource Impact
|
||||
|
||||
### At a glance
|
||||
|
||||
- Baseline: `SubMiner --start` is usually lightweight for normal playback.
|
||||
- Common spikes come from:
|
||||
- first subtitle parse/tokenization bursts
|
||||
- media generation (`ffmpeg` audio/image and AVIF paths)
|
||||
- media sync and subtitle tooling (`alass`, `ffsubsync`)
|
||||
- `ankiConnect` enrichment (plus polling overhead when proxy mode is disabled)
|
||||
|
||||
### If playback feels sluggish
|
||||
|
||||
1. Reduce overlay workload:
|
||||
|
||||
- set secondary subtitles hidden:
|
||||
- `secondarySub.defaultMode: "hidden"`
|
||||
- disable optional enrichment:
|
||||
- `subtitleStyle.enableJlpt: false`
|
||||
- `subtitleStyle.frequencyDictionary.enabled: false`
|
||||
|
||||
2. Reduce rendering pressure:
|
||||
|
||||
- lower `subtitleStyle.css["font-size"]`
|
||||
- keep overlay complexity minimal during heavy CPU periods
|
||||
|
||||
3. Reduce media overhead:
|
||||
|
||||
- keep `ankiConnect.media.imageType` set to `static` (avoid animated AVIF unless needed)
|
||||
- lower `ankiConnect.media.imageQuality`
|
||||
- reduce `ankiConnect.media.maxMediaDuration`
|
||||
|
||||
4. Lower integration cost:
|
||||
|
||||
- disable AI translation when not needed (`ankiConnect.ai.enabled: false`)
|
||||
- if needed, run immersion telemetry with lower duration expectations (`immersionTracking.enabled: false` for constrained sessions)
|
||||
- favor the default lightweight YouTube subtitle startup settings on low-resource systems
|
||||
|
||||
### Practical low-impact profile
|
||||
|
||||
```json
|
||||
{
|
||||
"subtitleStyle": {
|
||||
"css": {
|
||||
"font-size": "30px"
|
||||
},
|
||||
"enableJlpt": false,
|
||||
"frequencyDictionary": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"secondarySub": {
|
||||
"defaultMode": "hidden"
|
||||
},
|
||||
"ankiConnect": {
|
||||
"media": {
|
||||
"imageType": "static",
|
||||
"imageQuality": 80,
|
||||
"maxMediaDuration": 12
|
||||
},
|
||||
"ai": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"immersionTracking": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### If usage is still high
|
||||
|
||||
- Confirm only one SubMiner instance is running.
|
||||
- Check whether bottlenecks are `ffmpeg`, `yt-dlp`, or sync tooling in system monitor.
|
||||
- Keep the default `warn` level for normal use; raise to `info` or `debug` only for targeted diagnosis.
|
||||
- Reproduce once with `SubMiner.AppImage --start --log-level debug` and open DevTools (`y` then `d`) if freezes recur.
|
||||
|
||||
## Platform-Specific
|
||||
|
||||
### Linux
|
||||
|
||||
- **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors (KDE Plasma, GNOME, …) are not supported natively - both mpv and SubMiner must run under X11 or Xwayland instead. On those sessions SubMiner forces XWayland automatically for itself and for every mpv it launches (see [KDE Plasma & other Wayland compositors](#kde-plasma-other-wayland-compositors)).
|
||||
- **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors (KDE Plasma, GNOME, …) are not supported natively - both mpv and SubMiner must run under X11 or Xwayland instead. On those sessions SubMiner forces XWayland automatically for itself and for every mpv it launches (see [KDE Plasma & other Wayland compositors](#kde-plasma--other-wayland-compositors)).
|
||||
- **X11 / Xwayland**: Requires `xdotool`, `xprop`, and `xwininfo`. If missing, the overlay cannot track the mpv window position. This is the required backend for any Wayland compositor other than Hyprland or Sway - both mpv and SubMiner must be running under X11/Xwayland for window tracking _and_ for the overlay to stay above mpv (Wayland forbids clients from controlling window stacking). SubMiner uses a managed X11 overlay while mpv is windowed, switches to an override-redirect X11 overlay while tracked mpv is fullscreen, and hides/releases that overlay when another X11/Xwayland app takes focus. The visible overlay stays hidden until SubMiner has tracked mpv geometry, so startup should not create a display-sized fallback overlay while tokenization warms up.
|
||||
- **Tray icon missing**: SubMiner creates an Electron tray icon in `--background` mode, but Linux trays require a StatusNotifier/AppIndicator host. Hyprland does not provide one by itself; enable a tray in Waybar, Hyprpanel, or another panel. If Electron cannot register the tray, SubMiner logs a warning that mentions the missing tray host.
|
||||
- **Mouse passthrough**: On Linux X11/Xwayland, SubMiner uses `xdotool` to poll the cursor and only enables overlay input while the cursor is over subtitle or popup regions. Outside those regions, pointer input passes through to mpv. Native Wayland compositors other than Hyprland/Sway cannot provide the stacking control SubMiner needs.
|
||||
@@ -442,11 +428,8 @@ Feature-specific issues are covered in each feature's own page:
|
||||
- [Character Dictionary](/character-dictionary) - AniList character name matching and inline portraits
|
||||
- [Jellyfin Integration](/jellyfin-integration) - remote playback and library connection
|
||||
- [Jimaku Integration](/jimaku-integration) - subtitle fetching and API rate limits
|
||||
- [TsukiHime Integration](/tsukihime-integration) - multi-language subtitle download and `xz` decompression
|
||||
- [YouTube Integration](/youtube-integration) - subtitle generation and playback
|
||||
- [Immersion Tracking](/immersion-tracking) - telemetry, session logging, and the stats dashboard
|
||||
- [Launcher Script](/launcher-script) - `subminer` commands, pickers, watch history, and cross-machine sync
|
||||
- [MPV Plugin](/mpv-plugin) - in-player chords, script-opts, and binary auto-detection
|
||||
- [Immersion Tracking](/immersion-tracking) - telemetry and session logging
|
||||
- [WebSocket / Texthooker API](/websocket-texthooker-api) - external texthooker clients
|
||||
- [Subtitle Annotations](/subtitle-annotations) - N+1, frequency, JLPT, and name-match layers
|
||||
- [Subtitle Sidebar](/subtitle-sidebar) - sidebar navigation and behavior
|
||||
|
||||
+117
-96
@@ -33,7 +33,7 @@ If you want sentence, audio, and screenshot fields on your Anki cards, add this
|
||||
}
|
||||
```
|
||||
|
||||
Field names must match a field on your Anki note type. Matching is case-insensitive (an exact match wins, then a lowercase comparison), but the spelling must otherwise match. See [Anki Integration](/anki-integration) for the full reference.
|
||||
Field names must match your Anki note type exactly (case-sensitive). See [Anki Integration](/anki-integration) for the full reference.
|
||||
:::
|
||||
|
||||
## How It Works
|
||||
@@ -56,90 +56,126 @@ From there, subtitles render as interactive, hoverable word spans and you mine c
|
||||
|
||||
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. On Linux, normal `subminer` playback auto-installs the launcher-managed runtime plugin copy from the bundled app if that managed copy is missing, so no separate plugin install is needed for standard launcher usage. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
|
||||
|
||||
## Live Config Reload
|
||||
|
||||
While SubMiner is running, it watches your active config file and applies safe updates automatically.
|
||||
|
||||
Live-updated settings include:
|
||||
|
||||
- `subtitleStyle`
|
||||
- `keybindings`
|
||||
- `shortcuts`
|
||||
- `secondarySub.defaultMode`
|
||||
- `subtitleSidebar`
|
||||
- `notifications`
|
||||
- `logging`
|
||||
- `jimaku`, `subsync`
|
||||
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`
|
||||
- `stats.toggleKey`, `stats.markWatchedKey`
|
||||
- `youtube.primarySubLanguages`
|
||||
- most `ankiConnect.*` settings (including `ankiConnect.ai`)
|
||||
|
||||
Invalid config edits are rejected; SubMiner keeps the previous valid runtime config and shows an error notification.
|
||||
For restart-required sections, SubMiner shows a restart-needed notification.
|
||||
|
||||
## Commands
|
||||
|
||||
These are the commands you will actually use day to day. The full inventory of subcommands and flags lives in [Launcher Script](/launcher-script#subcommands).
|
||||
On Windows, replace `SubMiner.AppImage` with `SubMiner.exe` in the direct packaged-app examples below.
|
||||
|
||||
```bash
|
||||
subminer video.mkv # Play a specific file
|
||||
subminer # Browse the current directory (fzf picker)
|
||||
subminer -R # Browse with the rofi picker instead
|
||||
subminer -d ~/Anime -r # Browse a specific directory, recursively
|
||||
subminer -H # Browse watch history, then replay/next/previous
|
||||
# Browse and play videos
|
||||
subminer # Current directory (uses fzf)
|
||||
subminer -R # Use rofi instead of fzf
|
||||
subminer -d ~/Videos # Specific directory
|
||||
subminer -r -d ~/Anime # Recursive search
|
||||
subminer video.mkv # Play specific file (overlay auto-starts)
|
||||
subminer --start video.mkv # Explicit overlay start (use when mpv.autoStartSubMiner is false in config)
|
||||
subminer -S video.mkv # Also force the visible overlay on start (--start-overlay)
|
||||
subminer https://youtu.be/... # Play a YouTube URL
|
||||
subminer stats # Open the immersion stats dashboard
|
||||
subminer doctor # Check dependencies, config, and the mpv socket
|
||||
subminer settings # Open the SubMiner settings window
|
||||
subminer app --setup # Re-open first-run setup
|
||||
subminer -u # Check for updates
|
||||
```
|
||||
subminer ytsearch:"jp news" # Play first YouTube search result
|
||||
subminer -H # Browse history, then choose previous/replay/next after playback
|
||||
subminer app --setup # Open first-run setup popup
|
||||
subminer --version # Print the launcher's version
|
||||
subminer -v # Same as above
|
||||
subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging
|
||||
subminer --log-level warn video.mkv # Set logging level explicitly
|
||||
subminer --args '--fs=opengl-hq --ytdl-format=bestvideo*+bestaudio/best' video.mkv # Pass extra mpv args
|
||||
|
||||
On **Windows** there is no `subminer` launcher. Use the **SubMiner mpv** shortcut for playback (see [Windows mpv Shortcut](#windows-mpv-shortcut)), and run `SubMiner.exe` directly for everything else.
|
||||
|
||||
Two flags are worth knowing early:
|
||||
|
||||
- `-a/--args` passes extra arguments straight to mpv, for example `subminer --args "--ao=alsa --volume=80" video.mkv`.
|
||||
- `--log-level debug` turns on verbose logging when something is not working.
|
||||
|
||||
<details>
|
||||
<summary><b>Less common launcher commands</b></summary>
|
||||
|
||||
```bash
|
||||
subminer --start video.mkv # Explicit overlay start (when mpv.autoStartSubMiner is false)
|
||||
subminer -S video.mkv # Also force the visible overlay on start
|
||||
subminer -T video.mkv # Disable the texthooker server
|
||||
subminer -b x11 video.mkv # Force a window backend
|
||||
# Options
|
||||
subminer -T video.mkv # Disable texthooker server
|
||||
subminer -b x11 video.mkv # Force X11 backend
|
||||
subminer video.mkv # No mpv profile passed by default
|
||||
subminer -p gpu-hq video.mkv # Use a specific mpv profile
|
||||
subminer ytsearch:"jp news" # Play the first YouTube search result
|
||||
subminer texthooker # Texthooker-only mode (-o also opens the browser)
|
||||
subminer jellyfin # Open Jellyfin setup window (subcommand form)
|
||||
subminer jellyfin -l --server http://127.0.0.1:8096 --username me --password 'secret'
|
||||
subminer jellyfin --logout # Clear stored Jellyfin token/session data
|
||||
subminer jellyfin -p # Interactive Jellyfin library/item picker + playback
|
||||
subminer jellyfin -d # Jellyfin cast-discovery mode (background tray app)
|
||||
subminer app --stop # Stop background app (including Jellyfin cast broadcast)
|
||||
subminer doctor # Dependency + config + socket diagnostics
|
||||
subminer logs -e # Export a sanitized log ZIP and print its path
|
||||
subminer config path # Print active config path
|
||||
subminer config show # Print active config contents
|
||||
subminer mpv socket # Print active mpv socket path
|
||||
subminer mpv status # Exit 0 if socket is ready, else exit 1
|
||||
subminer mpv idle # Launch detached idle mpv with SubMiner defaults
|
||||
subminer sync media-box # Sync stats/watch history with an SSH host
|
||||
subminer sync media-box --push # Merge this machine's stats into the host only
|
||||
subminer sync media-box --pull # Merge the host's stats into this machine only
|
||||
subminer sync media-box --check # Verify SSH and remote SubMiner without syncing
|
||||
subminer sync media-box --json # Emit machine-readable NDJSON progress
|
||||
subminer sync --ui # Open the Sync Stats & History window
|
||||
subminer sync --snapshot ~/subminer-snapshot.sqlite # Write a local DB snapshot
|
||||
subminer sync --merge ~/subminer-snapshot.sqlite # Merge a snapshot into the local DB
|
||||
subminer sync --make-temp # Create an internal sync temp directory
|
||||
subminer sync --remove-temp /tmp/subminer-sync-123 # Remove an internal sync temp directory
|
||||
subminer dictionary /path/to/file-or-directory # Generate character dictionary ZIP from target (manual Yomitan import)
|
||||
subminer dictionary --candidates /path/to/file.mkv
|
||||
subminer dictionary --select 21355 /path/to/file.mkv
|
||||
subminer texthooker # Launch texthooker-only mode
|
||||
subminer texthooker -o # Launch texthooker and open it in your browser
|
||||
subminer stats # Start the local stats server (see Immersion Tracking)
|
||||
subminer stats -b # Start/reuse the background stats daemon
|
||||
subminer stats -s # Stop the background stats daemon
|
||||
subminer stats cleanup # Backfill vocabulary metadata, prune stale rows
|
||||
subminer stats rebuild # Rebuild rollup data
|
||||
subminer doctor --refresh-known-words # Refresh the known-word cache
|
||||
subminer logs -e # Export a sanitized log ZIP and print its path
|
||||
subminer config path # Print the active config path
|
||||
subminer config show # Print the active config contents
|
||||
subminer mpv socket # Print the active mpv socket path
|
||||
subminer mpv status # Exit 0 if the socket is ready, else exit 1
|
||||
subminer mpv idle # Launch a detached idle mpv with SubMiner defaults
|
||||
subminer app --stop # Stop the background app
|
||||
subminer --version # Print the launcher's version
|
||||
```
|
||||
subminer app --anilist-setup # Pass args directly to SubMiner binary (example: AniList login flow)
|
||||
|
||||
Jellyfin, cross-machine sync, and character-dictionary commands have their own sections: [Jellyfin](/jellyfin-integration), [Sync Between Machines](/launcher-script#sync-between-machines), and [Character Dictionary](/character-dictionary).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Direct packaged-app flags (advanced)</b></summary>
|
||||
|
||||
These call the app binary directly rather than going through the launcher. On Windows, replace `SubMiner.AppImage` with `SubMiner.exe`.
|
||||
|
||||
```bash
|
||||
SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs)
|
||||
SubMiner.AppImage --start --texthooker # Start overlay with texthooker
|
||||
SubMiner.AppImage --texthooker # Texthooker only (no overlay window)
|
||||
SubMiner.AppImage --setup # Open first-run setup
|
||||
# Direct packaged app control
|
||||
SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs)
|
||||
SubMiner.AppImage --start --texthooker # Start overlay with texthooker
|
||||
SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window)
|
||||
SubMiner.AppImage --texthooker --open-browser # Launch texthooker and open browser
|
||||
SubMiner.AppImage --setup # Open first-run setup popup
|
||||
SubMiner.AppImage --stop # Stop overlay
|
||||
SubMiner.AppImage --start --toggle # Start mpv IPC + toggle visibility
|
||||
SubMiner.AppImage --show-visible-overlay # Force show the visible overlay
|
||||
SubMiner.AppImage --hide-visible-overlay # Force hide the visible overlay
|
||||
SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle the primary subtitle bar
|
||||
SubMiner.AppImage --toggle-subtitle-sidebar # Toggle the subtitle sidebar
|
||||
SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
|
||||
SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility
|
||||
SubMiner.AppImage --show-visible-overlay # Force show visible overlay
|
||||
SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay
|
||||
SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle bar visibility
|
||||
SubMiner.AppImage --toggle-subtitle-sidebar # Toggle the subtitle sidebar
|
||||
SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
|
||||
SubMiner.AppImage --start --dev # Enable app/dev mode only
|
||||
SubMiner.AppImage --start --debug # Alias for --dev
|
||||
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
|
||||
SubMiner.AppImage --playback-feedback "your feedback" # Route playback feedback through the configured feedback surface
|
||||
SubMiner.AppImage --yomitan # Open Yomitan settings
|
||||
SubMiner.AppImage --settings # Open the SubMiner settings window
|
||||
SubMiner.AppImage --jellyfin # Open the Jellyfin setup window
|
||||
SubMiner.AppImage --dictionary # Generate a character dictionary ZIP
|
||||
SubMiner.AppImage --start --dev # Enable app/dev mode
|
||||
SubMiner.AppImage --start --log-level debug # Verbose logging without dev mode
|
||||
SubMiner.AppImage --settings # Open SubMiner settings window
|
||||
SubMiner.AppImage --jellyfin # Open Jellyfin setup window
|
||||
SubMiner.AppImage --jellyfin-login --jellyfin-server http://127.0.0.1:8096 --jellyfin-username me --jellyfin-password 'secret'
|
||||
SubMiner.AppImage --jellyfin-logout # Clear stored Jellyfin token/session data
|
||||
SubMiner.AppImage --jellyfin-libraries
|
||||
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search anime --jellyfin-limit 20
|
||||
SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID --jellyfin-audio-stream-index 1 --jellyfin-subtitle-stream-index 2 # Requires connected mpv IPC (--start)
|
||||
SubMiner.AppImage --jellyfin-remote-announce # Force cast-target capability announce + visibility check
|
||||
SubMiner.AppImage --sync-cli --help # Show the packaged app's headless sync help
|
||||
SubMiner.AppImage --sync-cli sync media-box # Run the sync engine directly in headless mode
|
||||
SubMiner.AppImage --dictionary # Generate character dictionary ZIP for current anime
|
||||
SubMiner.AppImage --dictionary-candidates # List AniList candidates for current character dictionary series
|
||||
SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin correct AniList media for series
|
||||
SubMiner.AppImage --help # Show all options
|
||||
```
|
||||
|
||||
The remaining flags are internal or scripting-only surfaces: the `--jellyfin-*` family (login, library listing, item playback, cast announce), `--sync-cli` (the app's headless sync entrypoint that `subminer sync` proxies to), `--dictionary-candidates` / `--dictionary-select`, and `--playback-feedback <text>`. Run `SubMiner.AppImage --help` for the complete list. The previous `--open-animetosho` flag is still accepted as a deprecated alias for `--open-tsukihime`.
|
||||
`--check` performs connection and version checks without changing data. `--json` emits the NDJSON event protocol used by the sync window. `--ui` opens that window in a detached app process and returns the shell immediately; closing a standalone-launched Sync window exits that app instance. `--make-temp` and `--remove-temp` are internal remote-transfer helpers and should normally be left to SubMiner. The packaged app's `--sync-cli` flag selects its headless sync-compatible entrypoint; the `subminer sync` launcher command proxies to it automatically.
|
||||
|
||||
</details>
|
||||
The previous `--open-animetosho` flag remains accepted as a deprecated alias for `--open-tsukihime`.
|
||||
|
||||
The tray menu includes `Export Logs`, which creates the same sanitized local-date log ZIP as `subminer logs -e` and shows the archive path when complete. Export sanitization masks common PII and secrets, including home-directory usernames, IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL query strings. The exported copy is sanitized; source log files remain unredacted on disk.
|
||||
|
||||
@@ -181,11 +217,18 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
|
||||
|
||||
### Launcher Subcommands
|
||||
|
||||
The launcher groups related work under subcommands: `jellyfin` (aliased `jf`), `stats`, `sync`, `dictionary` (aliased `dict`), `texthooker`, `doctor`, `settings`, `config`, `mpv`, `logs`, and `app` (aliased `bin`) for passing arguments straight to the SubMiner binary.
|
||||
|
||||
Every subcommand has its own help page, for example `subminer jellyfin -h`. See [Launcher Script - Subcommands](/launcher-script#subcommands) for the full table, and [Sync Between Machines](/launcher-script#sync-between-machines) for the SSH stats/history sync.
|
||||
|
||||
A _texthooker_ is a web page that displays the current subtitle line as selectable text, so browser-based dictionary extensions and other tools can read along with playback.
|
||||
- `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
|
||||
- `subminer doctor`: health checks for core dependencies and runtime paths.
|
||||
- `subminer settings`: open the SubMiner settings window (also `subminer --settings`).
|
||||
- `subminer logs -e`: export a sanitized ZIP of today's local-date logs, or the most recent logs when no current-day log exists. The exported copy masks common PII and secrets; on-disk logs are unchanged.
|
||||
- `subminer config`: config file helpers (`path`, `show`).
|
||||
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
|
||||
- `subminer sync <host>`: sync immersion stats and watch history with another machine over SSH. The host is the SSH destination (`user@host` or an SSH config alias). Use `--push` to merge only this machine's data into the host, or `--pull` to merge only the host's data into this machine; both remain insert-only and do not make either database an exact mirror. Remote launcher checks include standard SubMiner and Bun paths even when SSH omits them from `PATH`. Use `--snapshot <file>` to write a consistent local stats DB snapshot, `--merge <file>` to merge a snapshot into the local stats DB, and `--force` to skip the running stats/mpv safety check. Advanced options: `--db <file>` overrides the local stats DB path, and `--remote-cmd <cmd>` overrides the `subminer` command used on the remote host.
|
||||
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
|
||||
- Use `subminer dictionary --candidates <path>` and `subminer dictionary --select <id> <path>` to correct AniList character-dictionary matches for a whole series.
|
||||
- `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`). A _texthooker_ is a web page that displays the current subtitle line as selectable text, so browser-based dictionary extensions and other tools can read along with playback.
|
||||
- `subminer app` / `subminer bin`: direct passthrough to the SubMiner binary/AppImage.
|
||||
- Subcommand help pages are available (for example `subminer jellyfin -h`).
|
||||
|
||||
### First-Run Setup
|
||||
|
||||
@@ -282,28 +325,6 @@ Notes:
|
||||
|
||||
For local video files, SubMiner uses the same config-driven language priorities to auto-select the primary and secondary subtitle tracks from internal and external subtitle sources.
|
||||
|
||||
## Live Config Reload
|
||||
|
||||
While SubMiner is running, it watches your active config file and applies safe updates automatically.
|
||||
|
||||
Live-updated settings include:
|
||||
|
||||
- `subtitleStyle`
|
||||
- `keybindings`
|
||||
- `shortcuts`
|
||||
- `secondarySub.defaultMode`
|
||||
- `subtitleSidebar`
|
||||
- `notifications`
|
||||
- `logging`
|
||||
- `jimaku`, `subsync`
|
||||
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`
|
||||
- `stats.toggleKey`, `stats.markWatchedKey`
|
||||
- `youtube.primarySubLanguages`
|
||||
- most `ankiConnect.*` settings (including `ankiConnect.ai`)
|
||||
|
||||
Invalid config edits are rejected; SubMiner keeps the previous valid runtime config and shows an error notification.
|
||||
For restart-required sections, SubMiner shows a restart-needed notification.
|
||||
|
||||
## Controller Support
|
||||
|
||||
SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled.
|
||||
|
||||
@@ -377,4 +377,4 @@ ws.on('message', async (raw) => {
|
||||
- [Mining Workflow - Texthooker](/mining-workflow#texthooker)
|
||||
- [MPV Plugin](/mpv-plugin)
|
||||
- [Launcher Script](/launcher-script)
|
||||
- [Anki Integration](/anki-integration#proxy-mode-setup-yomitan-texthooker)
|
||||
- [Anki Integration](/anki-integration#proxy-mode-setup-yomitan--texthooker)
|
||||
|
||||
@@ -153,6 +153,6 @@ These settings come from `config.jsonc` (or built-in defaults); there are no CLI
|
||||
|
||||
- [Usage --- YouTube Playback](/usage#youtube-playback)
|
||||
- [Configuration --- YouTube Playback Settings](/configuration#youtube-playback-settings)
|
||||
- [Configuration --- Secondary Subtitles](/configuration#secondary-subtitles)
|
||||
- [Configuration --- Secondary Subtitle](/configuration#secondary-subtitle)
|
||||
- [Keyboard Shortcuts](/shortcuts)
|
||||
- [Jellyfin Integration](/jellyfin-integration)
|
||||
|
||||
@@ -185,6 +185,7 @@ export const IMMERSION_DB_FIXTURE_DDL = `
|
||||
line_id INTEGER NOT NULL,
|
||||
word_id INTEGER NOT NULL,
|
||||
occurrence_count INTEGER NOT NULL,
|
||||
seen_ms INTEGER,
|
||||
PRIMARY KEY(line_id, word_id),
|
||||
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(word_id) REFERENCES imm_words(id) ON DELETE CASCADE
|
||||
@@ -193,6 +194,7 @@ export const IMMERSION_DB_FIXTURE_DDL = `
|
||||
line_id INTEGER NOT NULL,
|
||||
kanji_id INTEGER NOT NULL,
|
||||
occurrence_count INTEGER NOT NULL,
|
||||
seen_ms INTEGER,
|
||||
PRIMARY KEY(line_id, kanji_id),
|
||||
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(kanji_id) REFERENCES imm_kanji(id) ON DELETE CASCADE
|
||||
@@ -313,8 +315,8 @@ export const IMMERSION_DB_FIXTURE_DDL = `
|
||||
CREATE INDEX idx_subtitle_lines_session_line ON imm_subtitle_lines(session_id, line_index);
|
||||
CREATE INDEX idx_subtitle_lines_video_line ON imm_subtitle_lines(video_id, line_index);
|
||||
CREATE INDEX idx_subtitle_lines_anime_line ON imm_subtitle_lines(anime_id, line_index);
|
||||
CREATE INDEX idx_word_line_occurrences_word ON imm_word_line_occurrences(word_id, line_id);
|
||||
CREATE INDEX idx_kanji_line_occurrences_kanji ON imm_kanji_line_occurrences(kanji_id, line_id);
|
||||
CREATE INDEX idx_word_line_occurrences_word_seen ON imm_word_line_occurrences(word_id, seen_ms, occurrence_count, line_id);
|
||||
CREATE INDEX idx_kanji_line_occurrences_kanji_seen ON imm_kanji_line_occurrences(kanji_id, seen_ms, occurrence_count, line_id);
|
||||
CREATE INDEX idx_media_art_cover_blob_hash ON imm_media_art(cover_blob_hash);
|
||||
CREATE INDEX idx_media_art_anilist_id ON imm_media_art(anilist_id);
|
||||
CREATE INDEX idx_media_art_cover_url ON imm_media_art(cover_url);
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.19.0-beta.4",
|
||||
"version": "0.19.0-beta.3",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
|
||||
@@ -17,12 +17,6 @@
|
||||
- **Post-Playback History Menu**
|
||||
- After a watch-history episode ends or mpv closes, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit SubMiner.
|
||||
- Previous/Next continue across season directories, so you can binge a show without manually browsing folders.
|
||||
- The menu shown right after picking a series from `subminer -H` now offers the previous episode too, matching the post-playback menu.
|
||||
|
||||
- **Known-Word Highlighting by Anki Maturity**
|
||||
- Subtitle highlights for known words can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Enable it with `ankiConnect.knownWords.maturityEnabled`, or toggle it live during a session.
|
||||
- The mature-interval threshold and the four tier colors are configurable, and the in-session help legend shows the active tier colors while maturity highlighting is on.
|
||||
- Tiers follow Anki's own card state: a lapsed card correctly shows as learning rather than young, and a note is treated as mature if any of its cards are mature. Stats and other known-word tools stay accurate with this new data.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -34,9 +28,6 @@
|
||||
- **Word Highlighting Accuracy**
|
||||
- Fixed several incorrect word highlighting and annotation cases: inconsistent part-of-speech exclusions on merged quote-particle tokens, missing annotations for rare kanji, katakana punctuation wrongly treated as non-kana noise, and certain kanji vocabulary skipped for next-level ("N+1") highlighting.
|
||||
|
||||
- **Character Dictionary Season Overrides**
|
||||
- Manual AniList overrides for a series now stay in effect for every episode in the same season folder, even when individual episode filenames produce different automatic guesses.
|
||||
|
||||
- **Startup Playback Pausing Too Early**
|
||||
- Fixed playback resuming before subtitle processing finished warming up, which could briefly show untranslated subtitles right after opening a video.
|
||||
- Most noticeable when resuming mid-episode or when a subtitle cue starts within the first couple of seconds.
|
||||
@@ -49,13 +40,9 @@
|
||||
- Fixed video playback failing to start when another process already held the configured AnkiConnect proxy port; SubMiner now shows a notification explaining how to resolve the conflict instead of crashing.
|
||||
|
||||
- **Stats & Settings Reliability**
|
||||
- Fixed session stats reporting zero known words after the known-word cache gained maturity tiers.
|
||||
- Hardened the stats server against malformed requests, stalled AniList lookups, media mismatches during word mining, and missing Yomitan connections.
|
||||
- AnkiConnect settings validation now preserves valid custom configurations while safely falling back on invalid values instead of failing.
|
||||
|
||||
- **Rofi Menu Prompt Spacing**
|
||||
- Rofi menu prompts now keep a space between the prompt label and the input field instead of crowding the search placeholder text.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
|
||||
@@ -63,7 +50,6 @@
|
||||
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
|
||||
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
|
||||
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
|
||||
- Anki maturity-based known-word highlighting by @ksyasuda in #172
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -2873,6 +2873,39 @@ Aligned English subtitle
|
||||
assert.equal(deleteCalls, 0);
|
||||
});
|
||||
|
||||
it('DELETE /api/stats/anime/:animeId deletes the whole library entry', async () => {
|
||||
let deletedAnimeId: number | null = null;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
deleteAnime: async (animeId: number) => {
|
||||
deletedAnimeId = animeId;
|
||||
},
|
||||
} as Partial<ImmersionTrackerService>),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/anime/7', { method: 'DELETE' });
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(deletedAnimeId, 7);
|
||||
assert.deepEqual(await res.json(), { ok: true });
|
||||
});
|
||||
|
||||
it('DELETE /api/stats/anime/:animeId rejects non-positive anime ids', async () => {
|
||||
let deleteCalls = 0;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
deleteAnime: async () => {
|
||||
deleteCalls += 1;
|
||||
},
|
||||
} as Partial<ImmersionTrackerService>),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/anime/0', { method: 'DELETE' });
|
||||
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(deleteCalls, 0);
|
||||
});
|
||||
|
||||
it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => {
|
||||
const app = createStatsApp(createMockTracker());
|
||||
const res = await app.request('/api/stats/anki/browse', { method: 'POST' });
|
||||
|
||||
@@ -1466,6 +1466,190 @@ test('deleteVideo ignores the currently active video and keeps new writes flusha
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Attach the derived rows a real session would produce to every recorded line
|
||||
* of `animeId`: word/kanji entries and their occurrences, monthly rollups, and
|
||||
* cover art backed by a shared blob.
|
||||
*/
|
||||
function seedDerivedAnimeData(db: DatabaseSync, animeId: number): void {
|
||||
const lines = db
|
||||
.prepare(
|
||||
'SELECT line_id AS lineId, CREATED_DATE AS seenMs FROM imm_subtitle_lines WHERE anime_id = ?',
|
||||
)
|
||||
.all(animeId) as Array<{ lineId: number; seenMs: number }>;
|
||||
assert.ok(lines.length > 0, 'expected recorded subtitle lines to decorate');
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO imm_words(id, headword, word, reading, part_of_speech, pos1, first_seen, last_seen, frequency)
|
||||
VALUES (9001, '天気', '天気', 'てんき', 'noun', '名詞', 0, 0, 0)`,
|
||||
).run();
|
||||
db.prepare(
|
||||
`INSERT INTO imm_kanji(id, kanji, first_seen, last_seen, frequency) VALUES (9101, '気', 0, 0, 0)`,
|
||||
).run();
|
||||
|
||||
const insertWordOccurrence = db.prepare(
|
||||
'INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms) VALUES (?, 9001, 1, ?)',
|
||||
);
|
||||
const insertKanjiOccurrence = db.prepare(
|
||||
'INSERT INTO imm_kanji_line_occurrences(line_id, kanji_id, occurrence_count, seen_ms) VALUES (?, 9101, 1, ?)',
|
||||
);
|
||||
for (const line of lines) {
|
||||
insertWordOccurrence.run(line.lineId, line.seenMs);
|
||||
insertKanjiOccurrence.run(line.lineId, line.seenMs);
|
||||
}
|
||||
db.prepare(
|
||||
`UPDATE imm_words SET frequency = ?, first_seen = ?, last_seen = ? WHERE id = 9001`,
|
||||
).run(lines.length, 0, 0);
|
||||
|
||||
const videoIds = (
|
||||
db
|
||||
.prepare('SELECT video_id AS videoId FROM imm_videos WHERE anime_id = ?')
|
||||
.all(animeId) as Array<{ videoId: number }>
|
||||
).map((row) => row.videoId);
|
||||
const insertMonthlyRollup = db.prepare(
|
||||
`INSERT INTO imm_monthly_rollups(rollup_month, video_id, total_sessions, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (202401, ?, 1, '0', '0')`,
|
||||
);
|
||||
const insertArt = db.prepare(
|
||||
`INSERT INTO imm_media_art(video_id, anilist_id, cover_url, cover_blob, cover_blob_hash, fetched_at_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, 4242, 'https://example.test/cover.jpg', NULL, 'deadbeef', '0', '0', '0')`,
|
||||
);
|
||||
db.prepare(
|
||||
`INSERT INTO imm_cover_art_blobs(blob_hash, cover_blob, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES ('deadbeef', X'FFD8FFD9', '0', '0')`,
|
||||
).run();
|
||||
for (const videoId of videoIds) {
|
||||
insertMonthlyRollup.run(videoId);
|
||||
insertArt.run(videoId);
|
||||
}
|
||||
}
|
||||
|
||||
test('deleteAnime removes every episode, session and library row for the title', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
|
||||
for (const episode of ['S02E05', 'S02E06']) {
|
||||
tracker = new Ctor({ dbPath });
|
||||
tracker.handleMediaChange(`/tmp/Little Witch Academia ${episode}.mkv`, `Episode ${episode}`);
|
||||
await waitForPendingAnimeMetadata(tracker);
|
||||
tracker.recordSubtitleLine('今日は晴れです', 0, 1.2);
|
||||
tracker.recordCardsMined(1);
|
||||
tracker.destroy();
|
||||
tracker = null;
|
||||
}
|
||||
|
||||
tracker = new Ctor({ dbPath });
|
||||
const privateApi = tracker as unknown as { db: DatabaseSync };
|
||||
const animeId = (
|
||||
privateApi.db.prepare('SELECT anime_id FROM imm_anime LIMIT 1').get() as {
|
||||
anime_id: number;
|
||||
} | null
|
||||
)?.anime_id;
|
||||
assert.ok(animeId);
|
||||
|
||||
// The tokenizer does not run in this harness, so attach vocabulary, kanji,
|
||||
// rollups and cover art to the recorded lines by hand. Without them the
|
||||
// "everything is gone" assertions below would pass against empty tables.
|
||||
seedDerivedAnimeData(privateApi.db, animeId);
|
||||
|
||||
const countOf = (sql: string): number =>
|
||||
(privateApi.db.prepare(sql).get() as { total: number }).total;
|
||||
for (const table of [
|
||||
'imm_words',
|
||||
'imm_kanji',
|
||||
'imm_word_line_occurrences',
|
||||
'imm_kanji_line_occurrences',
|
||||
'imm_daily_rollups',
|
||||
'imm_monthly_rollups',
|
||||
'imm_media_art',
|
||||
'imm_cover_art_blobs',
|
||||
]) {
|
||||
assert.ok(
|
||||
countOf(`SELECT COUNT(*) AS total FROM ${table}`) > 0,
|
||||
`precondition: ${table} should hold rows before the delete`,
|
||||
);
|
||||
}
|
||||
|
||||
const libraryBefore = await tracker.getAnimeLibrary();
|
||||
assert.equal(libraryBefore.length, 1);
|
||||
assert.equal(libraryBefore[0]?.episodeCount, 2);
|
||||
|
||||
await tracker.deleteAnime(animeId);
|
||||
|
||||
const libraryAfter = await tracker.getAnimeLibrary();
|
||||
assert.equal(libraryAfter.length, 0);
|
||||
|
||||
for (const table of [
|
||||
'imm_anime',
|
||||
'imm_lifetime_anime',
|
||||
'imm_videos',
|
||||
'imm_sessions',
|
||||
'imm_subtitle_lines',
|
||||
'imm_daily_rollups',
|
||||
'imm_monthly_rollups',
|
||||
'imm_lifetime_media',
|
||||
'imm_words',
|
||||
'imm_kanji',
|
||||
'imm_word_line_occurrences',
|
||||
'imm_kanji_line_occurrences',
|
||||
'imm_media_art',
|
||||
'imm_cover_art_blobs',
|
||||
]) {
|
||||
assert.equal(
|
||||
countOf(`SELECT COUNT(*) AS total FROM ${table}`),
|
||||
0,
|
||||
`${table} should be empty after deleting the only title`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('deleteAnime ignores the title of the currently active session', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath });
|
||||
tracker.handleMediaChange('/tmp/Little Witch Academia S02E05.mkv', 'Episode 5');
|
||||
await waitForPendingAnimeMetadata(tracker);
|
||||
|
||||
const privateApi = tracker as unknown as {
|
||||
db: DatabaseSync;
|
||||
sessionState: { sessionId: number; videoId: number } | null;
|
||||
};
|
||||
const videoId = privateApi.sessionState?.videoId;
|
||||
assert.ok(videoId);
|
||||
const animeId = (
|
||||
privateApi.db.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?').get(videoId) as {
|
||||
anime_id: number | null;
|
||||
} | null
|
||||
)?.anime_id;
|
||||
assert.ok(animeId);
|
||||
|
||||
await tracker.deleteAnime(animeId);
|
||||
|
||||
const animeCountRow = privateApi.db
|
||||
.prepare('SELECT COUNT(*) AS total FROM imm_anime WHERE anime_id = ?')
|
||||
.get(animeId) as { total: number };
|
||||
const videoCountRow = privateApi.db
|
||||
.prepare('SELECT COUNT(*) AS total FROM imm_videos WHERE video_id = ?')
|
||||
.get(videoId) as { total: number };
|
||||
|
||||
assert.equal(animeCountRow.total, 1);
|
||||
assert.equal(videoCountRow.total, 1);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('handleMediaChange links parsed anime metadata on the active video row', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
@@ -83,6 +83,7 @@ import {
|
||||
} from './immersion-tracker/query-library';
|
||||
import {
|
||||
cleanupVocabularyStats,
|
||||
deleteAnime as deleteAnimeQuery,
|
||||
deleteSession as deleteSessionQuery,
|
||||
deleteSessions as deleteSessionsQuery,
|
||||
deleteVideo as deleteVideoQuery,
|
||||
@@ -733,6 +734,28 @@ export class ImmersionTrackerService {
|
||||
deleteVideoQuery(this.db, videoId);
|
||||
}
|
||||
|
||||
async deleteAnime(animeId: number): Promise<void> {
|
||||
// The active video's anime link is assigned asynchronously after the title
|
||||
// is parsed, so a guard reading imm_videos too early sees a null and lets
|
||||
// the delete through — then the late update recreates the anime row.
|
||||
const pendingVideoId = this.sessionState?.videoId;
|
||||
if (pendingVideoId !== undefined) {
|
||||
await this.pendingAnimeMetadataUpdates.get(pendingVideoId);
|
||||
}
|
||||
|
||||
const activeVideoId = this.sessionState?.videoId;
|
||||
if (activeVideoId !== undefined) {
|
||||
const activeAnime = this.db
|
||||
.prepare('SELECT anime_id FROM imm_videos WHERE video_id = ?')
|
||||
.get(activeVideoId) as { anime_id: number | null } | null;
|
||||
if (activeAnime?.anime_id === animeId) {
|
||||
this.logger.warn(`Ignoring delete request for active immersion anime ${animeId}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
deleteAnimeQuery(this.db, animeId);
|
||||
}
|
||||
|
||||
async reassignAnimeAnilist(
|
||||
animeId: number,
|
||||
info: {
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { Database } from '../sqlite.js';
|
||||
import type { DatabaseSync } from '../sqlite.js';
|
||||
import { ensureSchema } from '../storage.js';
|
||||
import { deleteSession, deleteSessions, deleteVideo } from '../query-maintenance.js';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const BASE_MS = 1_700_000_000_000;
|
||||
|
||||
function makeDbPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-lexical-removal-test-'));
|
||||
return path.join(dir, 'immersion.sqlite');
|
||||
}
|
||||
|
||||
function cleanupDbPath(dbPath: string): void {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) return;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed two episodes of one anime, each with one ended session.
|
||||
*
|
||||
* `lines` places a word occurrence on a specific day so tests can control which
|
||||
* session holds a word's first/last occurrence.
|
||||
*/
|
||||
function seed(
|
||||
db: DatabaseSync,
|
||||
lines: Array<{ session: 1 | 2; wordId: number; dayOffset: number; count?: number }>,
|
||||
options: { legacyRows?: boolean } = {},
|
||||
): void {
|
||||
db.exec(`
|
||||
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (1, 'show', 'Show', ${BASE_MS}, ${BASE_MS});
|
||||
INSERT INTO imm_videos(video_id, video_key, anime_id, canonical_title, source_type, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (1, 'v1', 1, 'Ep 1', 1, 1, 1440000, ${BASE_MS}, ${BASE_MS}),
|
||||
(2, 'v2', 1, 'Ep 2', 1, 1, 1440000, ${BASE_MS}, ${BASE_MS});
|
||||
INSERT INTO imm_sessions(session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (1, 's1', 1, '${BASE_MS}', '${BASE_MS + 1000}', 2, ${BASE_MS}, ${BASE_MS}),
|
||||
(2, 's2', 2, '${BASE_MS + DAY_MS}', '${BASE_MS + DAY_MS + 1000}', 2, ${BASE_MS}, ${BASE_MS});
|
||||
`);
|
||||
|
||||
const insertLine = db.prepare(
|
||||
`INSERT INTO imm_subtitle_lines(line_id, session_id, video_id, anime_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (?, ?, ?, 1, ?, ?, ?, ?)`,
|
||||
);
|
||||
const insertWord = db.prepare(
|
||||
`INSERT OR IGNORE INTO imm_words(id, headword, word, reading, part_of_speech, pos1, first_seen, last_seen, frequency)
|
||||
VALUES (?, ?, ?, '', 'noun', '名詞', 0, 0, 0)`,
|
||||
);
|
||||
// `legacyRows` reproduces databases written before the seen_ms column existed,
|
||||
// where the timestamp has to be read back off the subtitle line.
|
||||
const insertOccurrence = options.legacyRows
|
||||
? db.prepare(
|
||||
`INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count) VALUES (?, ?, ?)`,
|
||||
)
|
||||
: db.prepare(
|
||||
`INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms) VALUES (?, ?, ?, ?)`,
|
||||
);
|
||||
|
||||
let lineId = 0;
|
||||
for (const line of lines) {
|
||||
lineId += 1;
|
||||
const seenMs = BASE_MS + line.dayOffset * DAY_MS;
|
||||
insertLine.run(lineId, line.session, line.session, lineId, `line ${lineId}`, seenMs, seenMs);
|
||||
insertWord.run(line.wordId, `語${line.wordId}`, `語${line.wordId}`);
|
||||
if (options.legacyRows) {
|
||||
insertOccurrence.run(lineId, line.wordId, line.count ?? 1);
|
||||
} else {
|
||||
insertOccurrence.run(lineId, line.wordId, line.count ?? 1, seenMs);
|
||||
}
|
||||
}
|
||||
|
||||
// Match what the tracker maintains: aggregates derived from the occurrences.
|
||||
db.exec(`
|
||||
UPDATE imm_words SET
|
||||
frequency = (
|
||||
SELECT COALESCE(SUM(o.occurrence_count), 0)
|
||||
FROM imm_word_line_occurrences o WHERE o.word_id = imm_words.id
|
||||
),
|
||||
first_seen = (
|
||||
SELECT MIN(sl.CREATED_DATE) / 1000
|
||||
FROM imm_word_line_occurrences o
|
||||
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
||||
WHERE o.word_id = imm_words.id
|
||||
),
|
||||
last_seen = (
|
||||
SELECT MAX(sl.LAST_UPDATE_DATE) / 1000
|
||||
FROM imm_word_line_occurrences o
|
||||
JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
||||
WHERE o.word_id = imm_words.id
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
function createDb(
|
||||
lines: Parameters<typeof seed>[1],
|
||||
options: Parameters<typeof seed>[2] = {},
|
||||
): { db: DatabaseSync; dbPath: string } {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
ensureSchema(db);
|
||||
seed(db, lines, options);
|
||||
return { db, dbPath };
|
||||
}
|
||||
|
||||
function readWord(
|
||||
db: DatabaseSync,
|
||||
wordId: number,
|
||||
): { frequency: number; firstSeen: number; lastSeen: number } | null {
|
||||
return (
|
||||
(db
|
||||
.prepare(
|
||||
'SELECT frequency, first_seen AS firstSeen, last_seen AS lastSeen FROM imm_words WHERE id = ?',
|
||||
)
|
||||
.get(wordId) as { frequency: number; firstSeen: number; lastSeen: number } | null) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
test('deleting a session subtracts only the occurrences it removed', () => {
|
||||
const { db, dbPath } = createDb([
|
||||
{ session: 1, wordId: 10, dayOffset: 0, count: 3 },
|
||||
{ session: 2, wordId: 10, dayOffset: 1, count: 4 },
|
||||
]);
|
||||
|
||||
try {
|
||||
assert.equal(readWord(db, 10)?.frequency, 7);
|
||||
|
||||
deleteSession(db, 1);
|
||||
|
||||
assert.equal(readWord(db, 10)?.frequency, 4, 'only session 1 occurrences are subtracted');
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('deleting the session that held a word first drops the word entirely', () => {
|
||||
const { db, dbPath } = createDb([
|
||||
{ session: 1, wordId: 11, dayOffset: 0 },
|
||||
{ session: 2, wordId: 12, dayOffset: 1 },
|
||||
]);
|
||||
|
||||
try {
|
||||
deleteSession(db, 1);
|
||||
|
||||
assert.equal(readWord(db, 11), null, 'word seen only in the deleted session is removed');
|
||||
assert.ok(readWord(db, 12), 'word seen elsewhere survives');
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('deleting the earliest session moves first_seen forward to the surviving line', () => {
|
||||
const { db, dbPath } = createDb([
|
||||
{ session: 1, wordId: 13, dayOffset: 0 },
|
||||
{ session: 2, wordId: 13, dayOffset: 5 },
|
||||
]);
|
||||
|
||||
try {
|
||||
assert.equal(readWord(db, 13)?.firstSeen, Math.floor(BASE_MS / 1000));
|
||||
|
||||
deleteSession(db, 1);
|
||||
|
||||
const word = readWord(db, 13);
|
||||
assert.equal(word?.frequency, 1);
|
||||
assert.equal(
|
||||
word?.firstSeen,
|
||||
Math.floor((BASE_MS + 5 * DAY_MS) / 1000),
|
||||
'first_seen advances to the remaining occurrence',
|
||||
);
|
||||
assert.equal(word?.lastSeen, Math.floor((BASE_MS + 5 * DAY_MS) / 1000));
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('deleting the latest session moves last_seen back to the surviving line', () => {
|
||||
const { db, dbPath } = createDb([
|
||||
{ session: 1, wordId: 14, dayOffset: 0 },
|
||||
{ session: 2, wordId: 14, dayOffset: 5 },
|
||||
]);
|
||||
|
||||
try {
|
||||
deleteSession(db, 2);
|
||||
|
||||
const word = readWord(db, 14);
|
||||
assert.equal(word?.frequency, 1);
|
||||
assert.equal(word?.lastSeen, Math.floor(BASE_MS / 1000), 'last_seen falls back to session 1');
|
||||
assert.equal(word?.firstSeen, Math.floor(BASE_MS / 1000));
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('deleting an interior occurrence leaves the surrounding extremes untouched', () => {
|
||||
// Session 2 carries the middle occurrence; sessions bracket it in time.
|
||||
const { db, dbPath } = createDb([
|
||||
{ session: 1, wordId: 15, dayOffset: 0 },
|
||||
{ session: 2, wordId: 15, dayOffset: 3 },
|
||||
{ session: 1, wordId: 15, dayOffset: 9 },
|
||||
]);
|
||||
|
||||
try {
|
||||
deleteSessions(db, [2]);
|
||||
|
||||
const word = readWord(db, 15);
|
||||
assert.equal(word?.frequency, 2);
|
||||
assert.equal(word?.firstSeen, Math.floor(BASE_MS / 1000));
|
||||
assert.equal(word?.lastSeen, Math.floor((BASE_MS + 9 * DAY_MS) / 1000));
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('a stored frequency that has drifted low is repaired instead of dropping a live word', () => {
|
||||
const { db, dbPath } = createDb([
|
||||
{ session: 1, wordId: 16, dayOffset: 0, count: 5 },
|
||||
{ session: 2, wordId: 16, dayOffset: 4, count: 5 },
|
||||
]);
|
||||
|
||||
try {
|
||||
// Simulate drift: the stored total is lower than the occurrences justify, so
|
||||
// naive subtraction would take the word to zero while rows still reference it.
|
||||
db.prepare('UPDATE imm_words SET frequency = 5 WHERE id = ?').run(16);
|
||||
|
||||
deleteSession(db, 1);
|
||||
|
||||
const word = readWord(db, 16);
|
||||
assert.ok(word, 'word with surviving occurrences is not deleted');
|
||||
assert.equal(word?.frequency, 5, 'frequency is recomputed from the surviving occurrences');
|
||||
assert.equal(word?.firstSeen, Math.floor((BASE_MS + 4 * DAY_MS) / 1000));
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('deleting a video subtracts every occurrence carried by its lines', () => {
|
||||
const { db, dbPath } = createDb([
|
||||
{ session: 1, wordId: 17, dayOffset: 0, count: 2 },
|
||||
{ session: 1, wordId: 17, dayOffset: 1, count: 3 },
|
||||
{ session: 2, wordId: 17, dayOffset: 2, count: 4 },
|
||||
{ session: 2, wordId: 18, dayOffset: 2, count: 1 },
|
||||
]);
|
||||
|
||||
try {
|
||||
deleteVideo(db, 1);
|
||||
|
||||
assert.equal(readWord(db, 17)?.frequency, 4, 'both lines from video 1 are subtracted');
|
||||
assert.equal(readWord(db, 18)?.frequency, 1, 'untouched video keeps its counts');
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('occurrence rows written before the seen_ms column still resolve their dates', () => {
|
||||
const { db, dbPath } = createDb(
|
||||
[
|
||||
{ session: 1, wordId: 20, dayOffset: 0 },
|
||||
{ session: 2, wordId: 20, dayOffset: 6 },
|
||||
],
|
||||
{ legacyRows: true },
|
||||
);
|
||||
|
||||
try {
|
||||
assert.equal(
|
||||
(
|
||||
db
|
||||
.prepare('SELECT COUNT(*) AS total FROM imm_word_line_occurrences WHERE seen_ms IS NULL')
|
||||
.get() as { total: number }
|
||||
).total,
|
||||
2,
|
||||
'precondition: rows carry no denormalised timestamp',
|
||||
);
|
||||
|
||||
deleteSession(db, 1);
|
||||
|
||||
const word = readWord(db, 20);
|
||||
assert.equal(word?.frequency, 1);
|
||||
assert.equal(
|
||||
word?.firstSeen,
|
||||
Math.floor((BASE_MS + 6 * DAY_MS) / 1000),
|
||||
'falls back to the subtitle line timestamp',
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('upgrading an older database backfills seen_ms from the subtitle lines', () => {
|
||||
const { db, dbPath } = createDb(
|
||||
[
|
||||
{ session: 1, wordId: 21, dayOffset: 0 },
|
||||
{ session: 2, wordId: 21, dayOffset: 2 },
|
||||
],
|
||||
{ legacyRows: true },
|
||||
);
|
||||
|
||||
try {
|
||||
// Re-run ensureSchema the way a pre-19 database would be opened.
|
||||
db.exec('DELETE FROM imm_schema_version');
|
||||
db.exec(`INSERT INTO imm_schema_version(schema_version, applied_at_ms) VALUES (18, '0')`);
|
||||
ensureSchema(db);
|
||||
|
||||
const rows = db
|
||||
.prepare('SELECT line_id AS lineId, seen_ms AS seenMs FROM imm_word_line_occurrences')
|
||||
.all() as Array<{ lineId: number; seenMs: number | null }>;
|
||||
assert.equal(rows.length, 2);
|
||||
for (const row of rows) {
|
||||
assert.ok(row.seenMs, `line ${row.lineId} should have a backfilled timestamp`);
|
||||
}
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.seenMs).sort((a, b) => Number(a) - Number(b)),
|
||||
[BASE_MS, BASE_MS + 2 * DAY_MS],
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
@@ -4982,3 +4982,145 @@ test('getTrendsDashboard librarySummary is empty when no rollups exist', () => {
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('getVocabularyStats counts the distinct anime each word appeared in', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const startedAtMs = 1_700_000_000_000;
|
||||
db.exec(`
|
||||
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (11, 'vocab-a', 'Vocab A', ${startedAtMs}, ${startedAtMs}),
|
||||
(22, 'vocab-b', 'Vocab B', ${startedAtMs}, ${startedAtMs});
|
||||
`);
|
||||
const videoIds = [1, 2].map((n) =>
|
||||
getOrCreateVideoRecord(db, `local:/tmp/vocab-anime-${n}.mkv`, {
|
||||
canonicalTitle: `Vocab Anime ${n}`,
|
||||
sourcePath: `/tmp/vocab-anime-${n}.mkv`,
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
}),
|
||||
);
|
||||
const sessionIds = videoIds.map(
|
||||
(videoId) => startSessionRecord(db, videoId, startedAtMs).sessionId,
|
||||
);
|
||||
|
||||
// 猫 spans both titles, twice in the first, so the count has to deduplicate
|
||||
// by anime rather than just tallying occurrence rows. 犬 stays in one title.
|
||||
insertFilteredWordOccurrence(db, {
|
||||
sessionId: sessionIds[0]!,
|
||||
videoId: videoIds[0]!,
|
||||
animeId: 11,
|
||||
lineIndex: 1,
|
||||
occurrenceCount: 5,
|
||||
startedAtMs,
|
||||
word: '猫',
|
||||
reading: 'ねこ',
|
||||
});
|
||||
insertFilteredWordOccurrence(db, {
|
||||
sessionId: sessionIds[0]!,
|
||||
videoId: videoIds[0]!,
|
||||
animeId: 11,
|
||||
lineIndex: 2,
|
||||
occurrenceCount: 1,
|
||||
startedAtMs,
|
||||
word: '猫',
|
||||
reading: 'ねこ',
|
||||
});
|
||||
insertFilteredWordOccurrence(db, {
|
||||
sessionId: sessionIds[1]!,
|
||||
videoId: videoIds[1]!,
|
||||
animeId: 22,
|
||||
lineIndex: 3,
|
||||
occurrenceCount: 4,
|
||||
startedAtMs,
|
||||
word: '猫',
|
||||
reading: 'ねこ',
|
||||
});
|
||||
insertFilteredWordOccurrence(db, {
|
||||
sessionId: sessionIds[0]!,
|
||||
videoId: videoIds[0]!,
|
||||
animeId: 11,
|
||||
lineIndex: 4,
|
||||
occurrenceCount: 2,
|
||||
startedAtMs,
|
||||
word: '犬',
|
||||
reading: 'いぬ',
|
||||
});
|
||||
|
||||
const rows = getVocabularyStats(db, 10);
|
||||
const cat = rows.find((row) => row.headword === '猫');
|
||||
const dog = rows.find((row) => row.headword === '犬');
|
||||
|
||||
assert.equal(cat?.animeCount, 2, '猫 was seen in two anime across three lines');
|
||||
assert.equal(dog?.animeCount, 1, '犬 was seen in one');
|
||||
assert.equal(cat?.frequency, 10, 'frequency still aggregates across both titles');
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('getVocabularyStats still applies part-of-speech exclusions', () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
try {
|
||||
ensureSchema(db);
|
||||
const startedAtMs = 1_700_000_000_000;
|
||||
db.exec(`
|
||||
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (11, 'vocab-a', 'Vocab A', ${startedAtMs}, ${startedAtMs});
|
||||
`);
|
||||
const videoId = getOrCreateVideoRecord(db, 'local:/tmp/vocab-exclude.mkv', {
|
||||
canonicalTitle: 'Vocab Exclude',
|
||||
sourcePath: '/tmp/vocab-exclude.mkv',
|
||||
sourceUrl: null,
|
||||
sourceType: SOURCE_TYPE_LOCAL,
|
||||
});
|
||||
const { sessionId } = startSessionRecord(db, videoId, startedAtMs);
|
||||
|
||||
insertFilteredWordOccurrence(db, {
|
||||
sessionId,
|
||||
videoId,
|
||||
animeId: 11,
|
||||
lineIndex: 1,
|
||||
occurrenceCount: 9,
|
||||
startedAtMs,
|
||||
word: '走る',
|
||||
reading: 'はしる',
|
||||
partOfSpeech: 'verb',
|
||||
});
|
||||
insertFilteredWordOccurrence(db, {
|
||||
sessionId,
|
||||
videoId,
|
||||
animeId: 11,
|
||||
lineIndex: 2,
|
||||
occurrenceCount: 3,
|
||||
startedAtMs,
|
||||
word: '猫',
|
||||
reading: 'ねこ',
|
||||
partOfSpeech: 'noun',
|
||||
});
|
||||
|
||||
const all = getVocabularyStats(db, 10);
|
||||
assert.deepEqual(
|
||||
all.map((row) => row.headword).sort(),
|
||||
['猫', '走る'],
|
||||
'precondition: both parts of speech are present',
|
||||
);
|
||||
|
||||
const nounsOnly = getVocabularyStats(db, 10, ['verb']);
|
||||
assert.deepEqual(
|
||||
nounsOnly.map((row) => row.headword),
|
||||
['猫'],
|
||||
'excluded part of speech is filtered out even though it ranks higher',
|
||||
);
|
||||
assert.equal(nounsOnly[0]?.animeCount, 1, 'surviving rows keep their anime count');
|
||||
} finally {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -115,18 +115,29 @@ export function getVocabularyStats(
|
||||
const whereClause = hasExclude
|
||||
? `WHERE (part_of_speech IS NULL OR part_of_speech NOT IN (${placeholders}))`
|
||||
: '';
|
||||
// The page is selected before the join so `animeCount` is only computed for the
|
||||
// rows being returned. Aggregating first made every request walk each word's
|
||||
// entire occurrence history — seconds of blocked event loop on a large library,
|
||||
// because only the ordering, not the aggregate, decides which rows survive.
|
||||
const stmt = db.prepare(`
|
||||
SELECT w.id AS wordId, w.headword, w.word, w.reading,
|
||||
w.part_of_speech AS partOfSpeech, w.pos1, w.pos2, w.pos3,
|
||||
w.frequency, w.frequency_rank AS frequencyRank,
|
||||
w.first_seen AS firstSeen, w.last_seen AS lastSeen,
|
||||
WITH page AS (
|
||||
SELECT id, headword, word, reading, part_of_speech, pos1, pos2, pos3,
|
||||
frequency, frequency_rank, first_seen, last_seen
|
||||
FROM imm_words
|
||||
${whereClause}
|
||||
ORDER BY frequency DESC, id
|
||||
LIMIT ? OFFSET ?
|
||||
)
|
||||
SELECT p.id AS wordId, p.headword, p.word, p.reading,
|
||||
p.part_of_speech AS partOfSpeech, p.pos1, p.pos2, p.pos3,
|
||||
p.frequency, p.frequency_rank AS frequencyRank,
|
||||
p.first_seen AS firstSeen, p.last_seen AS lastSeen,
|
||||
COUNT(DISTINCT sl.anime_id) AS animeCount
|
||||
FROM imm_words w
|
||||
LEFT JOIN imm_word_line_occurrences o ON o.word_id = w.id
|
||||
FROM page p
|
||||
LEFT JOIN imm_word_line_occurrences o ON o.word_id = p.id
|
||||
LEFT JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id AND sl.anime_id IS NOT NULL
|
||||
${whereClause ? whereClause.replace('part_of_speech', 'w.part_of_speech') : ''}
|
||||
GROUP BY w.id
|
||||
ORDER BY w.frequency DESC LIMIT ? OFFSET ?
|
||||
GROUP BY p.id
|
||||
ORDER BY p.frequency DESC, p.id
|
||||
`);
|
||||
const visibleRows: VocabularyStatsRow[] = [];
|
||||
let offset = 0;
|
||||
|
||||
@@ -9,14 +9,12 @@ import { PartOfSpeech, type MergedToken } from '../../../types';
|
||||
import { shouldExcludeTokenFromVocabularyPersistence } from '../tokenizer/annotation-stage';
|
||||
import { deriveStoredPartOfSpeech } from '../tokenizer/part-of-speech';
|
||||
import {
|
||||
applyLexicalRemovals,
|
||||
cleanupUnusedCoverArtBlobHash,
|
||||
deleteSessionsByIds,
|
||||
findSharedCoverBlobHash,
|
||||
getAffectedKanjiIdsForSessions,
|
||||
getAffectedKanjiIdsForVideo,
|
||||
getAffectedWordIdsForSessions,
|
||||
getAffectedWordIdsForVideo,
|
||||
refreshLexicalAggregates,
|
||||
planLexicalRemovalsForSessions,
|
||||
planLexicalRemovalsForVideos,
|
||||
toDbMs,
|
||||
toDbTimestamp,
|
||||
} from './query-shared';
|
||||
@@ -232,12 +230,13 @@ export async function cleanupVocabularyStats(
|
||||
WHERE id = ?`,
|
||||
);
|
||||
const moveOccurrencesStmt = db.prepare(
|
||||
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count)
|
||||
SELECT line_id, ?, occurrence_count
|
||||
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count, seen_ms)
|
||||
SELECT line_id, ?, occurrence_count, seen_ms
|
||||
FROM imm_word_line_occurrences
|
||||
WHERE word_id = ?
|
||||
ON CONFLICT(line_id, word_id) DO UPDATE SET
|
||||
occurrence_count = imm_word_line_occurrences.occurrence_count + excluded.occurrence_count`,
|
||||
occurrence_count = imm_word_line_occurrences.occurrence_count + excluded.occurrence_count,
|
||||
seen_ms = COALESCE(imm_word_line_occurrences.seen_ms, excluded.seen_ms)`,
|
||||
);
|
||||
const deleteOccurrencesStmt = db.prepare(
|
||||
'DELETE FROM imm_word_line_occurrences WHERE word_id = ?',
|
||||
@@ -484,14 +483,16 @@ export function isVideoWatched(db: DatabaseSync, videoId: number): boolean {
|
||||
|
||||
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 {
|
||||
// Measured inside the write lock: the plan records what the delete removes,
|
||||
// and applying a plan taken against a different snapshot would subtract the
|
||||
// wrong totals from imm_words/imm_kanji.
|
||||
const lexicalRemovals = planLexicalRemovalsForSessions(db, sessionIds);
|
||||
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
|
||||
deleteSessionsByIds(db, sessionIds);
|
||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||
applyLexicalRemovals(db, lexicalRemovals);
|
||||
rebuildLifetimeSummariesInTransaction(db);
|
||||
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
|
||||
db.exec('COMMIT');
|
||||
@@ -503,14 +504,13 @@ export function deleteSession(db: DatabaseSync, sessionId: number): void {
|
||||
|
||||
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 {
|
||||
const lexicalRemovals = planLexicalRemovalsForSessions(db, sessionIds);
|
||||
const affectedRollupGroups = getRollupGroupsForSessions(db, sessionIds);
|
||||
deleteSessionsByIds(db, sessionIds);
|
||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||
applyLexicalRemovals(db, lexicalRemovals);
|
||||
rebuildLifetimeSummariesInTransaction(db);
|
||||
refreshRollupsForGroupsInTransaction(db, affectedRollupGroups);
|
||||
db.exec('COMMIT');
|
||||
@@ -520,24 +520,83 @@ export function deleteSessions(db: DatabaseSync, sessionIds: number[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an entire library entry: every episode of the anime, all of their
|
||||
* sessions and derived stats, and the anime row itself.
|
||||
*
|
||||
* Mirrors {@link deleteVideo} per episode, but batches the lexical refresh and
|
||||
* lifetime rebuild into a single transaction so a multi-episode title doesn't
|
||||
* pay for one full rebuild per episode.
|
||||
*/
|
||||
export function deleteAnime(db: DatabaseSync, animeId: number): void {
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const videoIds = (
|
||||
db.prepare('SELECT video_id FROM imm_videos WHERE anime_id = ?').all(animeId) as Array<{
|
||||
video_id: number;
|
||||
}>
|
||||
).map((row) => row.video_id);
|
||||
|
||||
const lexicalRemovals = planLexicalRemovalsForVideos(db, videoIds);
|
||||
const coverBlobHashes: string[] = [];
|
||||
const sessionIds: number[] = [];
|
||||
for (const videoId of videoIds) {
|
||||
const artRow = db
|
||||
.prepare('SELECT cover_blob_hash AS coverBlobHash FROM imm_media_art WHERE video_id = ?')
|
||||
.get(videoId) as { coverBlobHash: string | null } | undefined;
|
||||
if (artRow?.coverBlobHash) {
|
||||
coverBlobHashes.push(artRow.coverBlobHash);
|
||||
}
|
||||
const sessions = db
|
||||
.prepare('SELECT session_id FROM imm_sessions WHERE video_id = ?')
|
||||
.all(videoId) as Array<{ session_id: number }>;
|
||||
sessionIds.push(...sessions.map((session) => session.session_id));
|
||||
}
|
||||
|
||||
deleteSessionsByIds(db, sessionIds);
|
||||
const deleteLinesStmt = db.prepare('DELETE FROM imm_subtitle_lines WHERE video_id = ?');
|
||||
const deleteDailyStmt = db.prepare('DELETE FROM imm_daily_rollups WHERE video_id = ?');
|
||||
const deleteMonthlyStmt = db.prepare('DELETE FROM imm_monthly_rollups WHERE video_id = ?');
|
||||
const deleteArtStmt = db.prepare('DELETE FROM imm_media_art WHERE video_id = ?');
|
||||
const deleteVideoStmt = db.prepare('DELETE FROM imm_videos WHERE video_id = ?');
|
||||
for (const videoId of videoIds) {
|
||||
deleteLinesStmt.run(videoId);
|
||||
deleteDailyStmt.run(videoId);
|
||||
deleteMonthlyStmt.run(videoId);
|
||||
deleteArtStmt.run(videoId);
|
||||
deleteVideoStmt.run(videoId);
|
||||
}
|
||||
for (const coverBlobHash of new Set(coverBlobHashes)) {
|
||||
cleanupUnusedCoverArtBlobHash(db, coverBlobHash);
|
||||
}
|
||||
db.prepare('DELETE FROM imm_lifetime_anime WHERE anime_id = ?').run(animeId);
|
||||
db.prepare('DELETE FROM imm_anime WHERE anime_id = ?').run(animeId);
|
||||
applyLexicalRemovals(db, lexicalRemovals);
|
||||
rebuildLifetimeSummariesInTransaction(db);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteVideo(db: DatabaseSync, videoId: number): void {
|
||||
const artRow = db
|
||||
.prepare(
|
||||
`
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const artRow = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT cover_blob_hash AS coverBlobHash
|
||||
FROM imm_media_art
|
||||
WHERE video_id = ?
|
||||
`,
|
||||
)
|
||||
.get(videoId) as { coverBlobHash: string | null } | undefined;
|
||||
const affectedWordIds = getAffectedWordIdsForVideo(db, videoId);
|
||||
const affectedKanjiIds = getAffectedKanjiIdsForVideo(db, videoId);
|
||||
const sessions = db
|
||||
.prepare('SELECT session_id FROM imm_sessions WHERE video_id = ?')
|
||||
.all(videoId) as Array<{ session_id: number }>;
|
||||
)
|
||||
.get(videoId) as { coverBlobHash: string | null } | undefined;
|
||||
const lexicalRemovals = planLexicalRemovalsForVideos(db, [videoId]);
|
||||
const sessions = db
|
||||
.prepare('SELECT session_id FROM imm_sessions WHERE video_id = ?')
|
||||
.all(videoId) as Array<{ session_id: number }>;
|
||||
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
deleteSessionsByIds(
|
||||
db,
|
||||
sessions.map((session) => session.session_id),
|
||||
@@ -548,7 +607,7 @@ export function deleteVideo(db: DatabaseSync, videoId: number): void {
|
||||
db.prepare('DELETE FROM imm_media_art WHERE video_id = ?').run(videoId);
|
||||
cleanupUnusedCoverArtBlobHash(db, artRow?.coverBlobHash ?? null);
|
||||
db.prepare('DELETE FROM imm_videos WHERE video_id = ?').run(videoId);
|
||||
refreshLexicalAggregates(db, affectedWordIds, affectedKanjiIds);
|
||||
applyLexicalRemovals(db, lexicalRemovals);
|
||||
rebuildLifetimeSummariesInTransaction(db);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
|
||||
@@ -203,6 +203,167 @@ export function getAffectedKanjiIdsForVideo(db: DatabaseSync, videoId: number):
|
||||
return getAffectedIdsForVideo(db, 'kanji', videoId);
|
||||
}
|
||||
|
||||
/** Per-entity totals that a pending delete is about to remove. */
|
||||
interface LexicalRemoval {
|
||||
id: number;
|
||||
removedFrequency: number;
|
||||
removedFirstSeenMs: number | null;
|
||||
removedLastSeenMs: number | null;
|
||||
}
|
||||
|
||||
/** What a pending delete removes from `imm_words` and `imm_kanji`. */
|
||||
export interface LexicalRemovalPlan {
|
||||
words: LexicalRemoval[];
|
||||
kanji: LexicalRemoval[];
|
||||
}
|
||||
|
||||
export const EMPTY_LEXICAL_REMOVAL_PLAN: LexicalRemovalPlan = { words: [], kanji: [] };
|
||||
|
||||
function collectLexicalRemovals(
|
||||
db: DatabaseSync,
|
||||
entity: LexicalEntity,
|
||||
lineScopeSql: string,
|
||||
params: number[],
|
||||
): LexicalRemoval[] {
|
||||
const table = entity === 'word' ? 'imm_word_line_occurrences' : 'imm_kanji_line_occurrences';
|
||||
const col = `${entity}_id`;
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT
|
||||
o.${col} AS id,
|
||||
COALESCE(SUM(o.occurrence_count), 0) AS removedFrequency,
|
||||
MIN(COALESCE(o.seen_ms, sl.CREATED_DATE, sl.LAST_UPDATE_DATE)) AS removedFirstSeenMs,
|
||||
MAX(COALESCE(o.seen_ms, sl.LAST_UPDATE_DATE, sl.CREATED_DATE)) AS removedLastSeenMs
|
||||
FROM imm_subtitle_lines sl
|
||||
JOIN ${table} o ON o.line_id = sl.line_id
|
||||
WHERE ${lineScopeSql}
|
||||
GROUP BY o.${col}`,
|
||||
)
|
||||
.all(...params) as LexicalRemoval[];
|
||||
}
|
||||
|
||||
function planLexicalRemovals(
|
||||
db: DatabaseSync,
|
||||
lineScopeSql: string,
|
||||
params: number[],
|
||||
): LexicalRemovalPlan {
|
||||
return {
|
||||
words: collectLexicalRemovals(db, 'word', lineScopeSql, params),
|
||||
kanji: collectLexicalRemovals(db, 'kanji', lineScopeSql, params),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure what deleting these sessions removes from the vocabulary tables.
|
||||
*
|
||||
* Must run before the rows are deleted. Reads only the subtitle lines in scope,
|
||||
* unlike {@link refreshLexicalAggregates}, which re-reads every occurrence of
|
||||
* every affected word across the whole library.
|
||||
*/
|
||||
export function planLexicalRemovalsForSessions(
|
||||
db: DatabaseSync,
|
||||
sessionIds: number[],
|
||||
): LexicalRemovalPlan {
|
||||
if (sessionIds.length === 0) return EMPTY_LEXICAL_REMOVAL_PLAN;
|
||||
return planLexicalRemovals(db, `sl.session_id IN (${makePlaceholders(sessionIds)})`, sessionIds);
|
||||
}
|
||||
|
||||
/** Measure what deleting these videos removes from the vocabulary tables. */
|
||||
export function planLexicalRemovalsForVideos(
|
||||
db: DatabaseSync,
|
||||
videoIds: number[],
|
||||
): LexicalRemovalPlan {
|
||||
if (videoIds.length === 0) return EMPTY_LEXICAL_REMOVAL_PLAN;
|
||||
return planLexicalRemovals(db, `sl.video_id IN (${makePlaceholders(videoIds)})`, videoIds);
|
||||
}
|
||||
|
||||
function toStoredSeenSeconds(ms: number | null): number | null {
|
||||
if (ms === null || !Number.isFinite(ms)) return null;
|
||||
return Math.floor(ms / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a removal plan to the vocabulary aggregates.
|
||||
*
|
||||
* Frequencies are adjusted by subtraction, which is exact and touches only the
|
||||
* affected rows. `first_seen`/`last_seen` only need a rescan when the removed
|
||||
* lines held the current extreme, and rows whose frequency reaches zero are
|
||||
* verified against the surviving occurrences before deletion — so stored counts
|
||||
* that have drifted still converge on the truth instead of dropping a live row.
|
||||
*/
|
||||
export function applyLexicalRemovals(db: DatabaseSync, plan: LexicalRemovalPlan): void {
|
||||
applyRemovalsForEntity(db, 'word', plan.words);
|
||||
applyRemovalsForEntity(db, 'kanji', plan.kanji);
|
||||
}
|
||||
|
||||
function applyRemovalsForEntity(
|
||||
db: DatabaseSync,
|
||||
entity: LexicalEntity,
|
||||
removals: LexicalRemoval[],
|
||||
): void {
|
||||
if (removals.length === 0) return;
|
||||
|
||||
const entityTable = entity === 'word' ? 'imm_words' : 'imm_kanji';
|
||||
const occurrenceTable =
|
||||
entity === 'word' ? 'imm_word_line_occurrences' : 'imm_kanji_line_occurrences';
|
||||
const col = `${entity}_id`;
|
||||
|
||||
const selectStmt = db.prepare(
|
||||
`SELECT frequency, first_seen AS firstSeen, last_seen AS lastSeen
|
||||
FROM ${entityTable}
|
||||
WHERE id = ?`,
|
||||
);
|
||||
const updateFrequencyStmt = db.prepare(`UPDATE ${entityTable} SET frequency = ? WHERE id = ?`);
|
||||
const hasOccurrencesStmt = db.prepare(
|
||||
`SELECT 1 AS found FROM ${occurrenceTable} WHERE ${col} = ? LIMIT 1`,
|
||||
);
|
||||
const deleteStmt = db.prepare(`DELETE FROM ${entityTable} WHERE id = ?`);
|
||||
|
||||
const needsExactRefresh: number[] = [];
|
||||
|
||||
for (const removal of removals) {
|
||||
const current = selectStmt.get(removal.id) as {
|
||||
frequency: number | null;
|
||||
firstSeen: number | null;
|
||||
lastSeen: number | null;
|
||||
} | null;
|
||||
if (!current) continue;
|
||||
|
||||
const nextFrequency = (current.frequency ?? 0) - removal.removedFrequency;
|
||||
if (nextFrequency <= 0) {
|
||||
// The rows in scope are already gone by now, so anything still pointing at
|
||||
// this entity means the stored frequency was stale rather than exhausted.
|
||||
if (hasOccurrencesStmt.get(removal.id)) {
|
||||
needsExactRefresh.push(removal.id);
|
||||
} else {
|
||||
deleteStmt.run(removal.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const removedFirstSeen = toStoredSeenSeconds(removal.removedFirstSeenMs);
|
||||
const removedLastSeen = toStoredSeenSeconds(removal.removedLastSeenMs);
|
||||
const firstSeenMayHaveMoved =
|
||||
current.firstSeen === null ||
|
||||
(removedFirstSeen !== null && removedFirstSeen <= current.firstSeen);
|
||||
const lastSeenMayHaveMoved =
|
||||
current.lastSeen === null ||
|
||||
(removedLastSeen !== null && removedLastSeen >= current.lastSeen);
|
||||
if (firstSeenMayHaveMoved || lastSeenMayHaveMoved) {
|
||||
needsExactRefresh.push(removal.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
updateFrequencyStmt.run(nextFrequency, removal.id);
|
||||
}
|
||||
|
||||
if (entity === 'word') {
|
||||
refreshWordAggregates(db, needsExactRefresh);
|
||||
} else {
|
||||
refreshKanjiAggregates(db, needsExactRefresh);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
|
||||
if (wordIds.length === 0) {
|
||||
return;
|
||||
@@ -214,11 +375,18 @@ function refreshWordAggregates(db: DatabaseSync, wordIds: number[]): void {
|
||||
SELECT
|
||||
w.id AS wordId,
|
||||
COALESCE(SUM(o.occurrence_count), 0) AS frequency,
|
||||
MIN(COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)) AS firstSeen,
|
||||
MAX(COALESCE(sl.LAST_UPDATE_DATE, sl.CREATED_DATE)) AS lastSeen
|
||||
MIN(COALESCE(o.seen_ms, (
|
||||
SELECT COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)
|
||||
FROM imm_subtitle_lines sl
|
||||
WHERE sl.line_id = o.line_id
|
||||
))) AS firstSeen,
|
||||
MAX(COALESCE(o.seen_ms, (
|
||||
SELECT COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)
|
||||
FROM imm_subtitle_lines sl
|
||||
WHERE sl.line_id = o.line_id
|
||||
))) AS lastSeen
|
||||
FROM imm_words w
|
||||
LEFT JOIN imm_word_line_occurrences o ON o.word_id = w.id
|
||||
LEFT JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
||||
WHERE w.id IN (${makePlaceholders(wordIds)})
|
||||
GROUP BY w.id
|
||||
`,
|
||||
@@ -263,11 +431,18 @@ function refreshKanjiAggregates(db: DatabaseSync, kanjiIds: number[]): void {
|
||||
SELECT
|
||||
k.id AS kanjiId,
|
||||
COALESCE(SUM(o.occurrence_count), 0) AS frequency,
|
||||
MIN(COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)) AS firstSeen,
|
||||
MAX(COALESCE(sl.LAST_UPDATE_DATE, sl.CREATED_DATE)) AS lastSeen
|
||||
MIN(COALESCE(o.seen_ms, (
|
||||
SELECT COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)
|
||||
FROM imm_subtitle_lines sl
|
||||
WHERE sl.line_id = o.line_id
|
||||
))) AS firstSeen,
|
||||
MAX(COALESCE(o.seen_ms, (
|
||||
SELECT COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)
|
||||
FROM imm_subtitle_lines sl
|
||||
WHERE sl.line_id = o.line_id
|
||||
))) AS lastSeen
|
||||
FROM imm_kanji k
|
||||
LEFT JOIN imm_kanji_line_occurrences o ON o.kanji_id = k.id
|
||||
LEFT JOIN imm_subtitle_lines sl ON sl.line_id = o.line_id
|
||||
WHERE k.id IN (${makePlaceholders(kanjiIds)})
|
||||
GROUP BY k.id
|
||||
`,
|
||||
|
||||
@@ -192,6 +192,33 @@ function addColumnIfMissing(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy each subtitle line's timestamp onto its word/kanji occurrence rows.
|
||||
*
|
||||
* Vocabulary aggregates used to be recomputed by joining every occurrence back
|
||||
* to `imm_subtitle_lines` just to read two timestamps, which meant one random
|
||||
* read into the widest table per occurrence — the reason deleting a session cost
|
||||
* seconds on a large library. With the timestamp stored alongside the count, the
|
||||
* covering index answers those aggregates on its own.
|
||||
*/
|
||||
function backfillLexicalOccurrenceSeenMs(db: DatabaseSync): void {
|
||||
for (const table of ['imm_word_line_occurrences', 'imm_kanji_line_occurrences']) {
|
||||
addColumnIfMissing(db, table, 'seen_ms', 'INTEGER');
|
||||
db.exec(`
|
||||
UPDATE ${table}
|
||||
SET seen_ms = (
|
||||
SELECT COALESCE(sl.CREATED_DATE, sl.LAST_UPDATE_DATE)
|
||||
FROM imm_subtitle_lines sl
|
||||
WHERE sl.line_id = ${table}.line_id
|
||||
)
|
||||
WHERE seen_ms IS NULL
|
||||
`);
|
||||
}
|
||||
// Superseded by the covering (entity, seen_ms, occurrence_count, line_id) indexes.
|
||||
db.exec('DROP INDEX IF EXISTS idx_word_line_occurrences_word');
|
||||
db.exec('DROP INDEX IF EXISTS idx_kanji_line_occurrences_kanji');
|
||||
}
|
||||
|
||||
function dropColumnIfExists(db: DatabaseSync, tableName: string, columnName: string): void {
|
||||
if (hasColumn(db, tableName, columnName)) {
|
||||
db.exec(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`);
|
||||
@@ -923,6 +950,7 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
line_id INTEGER NOT NULL,
|
||||
word_id INTEGER NOT NULL,
|
||||
occurrence_count INTEGER NOT NULL,
|
||||
seen_ms INTEGER,
|
||||
PRIMARY KEY(line_id, word_id),
|
||||
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(word_id) REFERENCES imm_words(id) ON DELETE CASCADE
|
||||
@@ -933,6 +961,7 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
line_id INTEGER NOT NULL,
|
||||
kanji_id INTEGER NOT NULL,
|
||||
occurrence_count INTEGER NOT NULL,
|
||||
seen_ms INTEGER,
|
||||
PRIMARY KEY(line_id, kanji_id),
|
||||
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(kanji_id) REFERENCES imm_kanji(id) ON DELETE CASCADE
|
||||
@@ -1093,6 +1122,7 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
line_id INTEGER NOT NULL,
|
||||
word_id INTEGER NOT NULL,
|
||||
occurrence_count INTEGER NOT NULL,
|
||||
seen_ms INTEGER,
|
||||
PRIMARY KEY(line_id, word_id),
|
||||
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(word_id) REFERENCES imm_words(id) ON DELETE CASCADE
|
||||
@@ -1103,6 +1133,7 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
line_id INTEGER NOT NULL,
|
||||
kanji_id INTEGER NOT NULL,
|
||||
occurrence_count INTEGER NOT NULL,
|
||||
seen_ms INTEGER,
|
||||
PRIMARY KEY(line_id, kanji_id),
|
||||
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(kanji_id) REFERENCES imm_kanji(id) ON DELETE CASCADE
|
||||
@@ -1349,13 +1380,19 @@ export function ensureSchema(db: DatabaseSync): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_subtitle_lines_anime_line
|
||||
ON imm_subtitle_lines(anime_id, line_index)
|
||||
`);
|
||||
if (currentVersion?.schema_version && currentVersion.schema_version < 19) {
|
||||
backfillLexicalOccurrenceSeenMs(db);
|
||||
}
|
||||
|
||||
// Covering indexes: vocabulary aggregates (frequency, first/last seen) are
|
||||
// answered from the index alone, without reading the wide subtitle-line rows.
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_word_line_occurrences_word
|
||||
ON imm_word_line_occurrences(word_id, line_id)
|
||||
CREATE INDEX IF NOT EXISTS idx_word_line_occurrences_word_seen
|
||||
ON imm_word_line_occurrences(word_id, seen_ms, occurrence_count, line_id)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_kanji_line_occurrences_kanji
|
||||
ON imm_kanji_line_occurrences(kanji_id, line_id)
|
||||
CREATE INDEX IF NOT EXISTS idx_kanji_line_occurrences_kanji_seen
|
||||
ON imm_kanji_line_occurrences(kanji_id, seen_ms, occurrence_count, line_id)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_media_art_cover_blob_hash
|
||||
@@ -1475,21 +1512,23 @@ export function createTrackerPreparedStatements(db: DatabaseSync): TrackerPrepar
|
||||
`),
|
||||
wordLineOccurrenceUpsertStmt: db.prepare(`
|
||||
INSERT INTO imm_word_line_occurrences (
|
||||
line_id, word_id, occurrence_count
|
||||
line_id, word_id, occurrence_count, seen_ms
|
||||
) VALUES (
|
||||
?, ?, ?
|
||||
?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT(line_id, word_id) DO UPDATE SET
|
||||
occurrence_count = imm_word_line_occurrences.occurrence_count + excluded.occurrence_count
|
||||
occurrence_count = imm_word_line_occurrences.occurrence_count + excluded.occurrence_count,
|
||||
seen_ms = COALESCE(imm_word_line_occurrences.seen_ms, excluded.seen_ms)
|
||||
`),
|
||||
kanjiLineOccurrenceUpsertStmt: db.prepare(`
|
||||
INSERT INTO imm_kanji_line_occurrences (
|
||||
line_id, kanji_id, occurrence_count
|
||||
line_id, kanji_id, occurrence_count, seen_ms
|
||||
) VALUES (
|
||||
?, ?, ?
|
||||
?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT(line_id, kanji_id) DO UPDATE SET
|
||||
occurrence_count = imm_kanji_line_occurrences.occurrence_count + excluded.occurrence_count
|
||||
occurrence_count = imm_kanji_line_occurrences.occurrence_count + excluded.occurrence_count,
|
||||
seen_ms = COALESCE(imm_kanji_line_occurrences.seen_ms, excluded.seen_ms)
|
||||
`),
|
||||
videoAnimeIdSelectStmt: db.prepare(`
|
||||
SELECT anime_id FROM imm_videos
|
||||
@@ -1630,11 +1669,16 @@ export function executeQueuedWrite(write: QueuedWrite, stmts: TrackerPreparedSta
|
||||
const lineId = Number(lineResult.lastInsertRowid);
|
||||
for (const occurrence of write.wordOccurrences) {
|
||||
const wordId = incrementWordAggregate(stmts, occurrence, write.firstSeen, write.lastSeen);
|
||||
stmts.wordLineOccurrenceUpsertStmt.run(lineId, wordId, occurrence.occurrenceCount);
|
||||
stmts.wordLineOccurrenceUpsertStmt.run(lineId, wordId, occurrence.occurrenceCount, currentMs);
|
||||
}
|
||||
for (const occurrence of write.kanjiOccurrences) {
|
||||
const kanjiId = incrementKanjiAggregate(stmts, occurrence, write.firstSeen, write.lastSeen);
|
||||
stmts.kanjiLineOccurrenceUpsertStmt.run(lineId, kanjiId, occurrence.occurrenceCount);
|
||||
stmts.kanjiLineOccurrenceUpsertStmt.run(
|
||||
lineId,
|
||||
kanjiId,
|
||||
occurrence.occurrenceCount,
|
||||
currentMs,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const SCHEMA_VERSION = 18;
|
||||
export const SCHEMA_VERSION = 19;
|
||||
export const DEFAULT_QUEUE_CAP = 1_000;
|
||||
export const DEFAULT_BATCH_SIZE = 25;
|
||||
export const DEFAULT_FLUSH_INTERVAL_MS = 500;
|
||||
|
||||
@@ -190,4 +190,11 @@ export function registerStatsLibraryRoutes(
|
||||
await tracker.deleteVideo(videoId);
|
||||
return c.json(statsJson('deleteVideo', { ok: true }));
|
||||
});
|
||||
|
||||
app.delete('/api/stats/anime/:animeId', async (c) => {
|
||||
const animeId = parseIntQuery(c.req.param('animeId'), 0);
|
||||
if (animeId <= 0) return c.body(null, 400);
|
||||
await tracker.deleteAnime(animeId);
|
||||
return c.json(statsJson('deleteAnime', { ok: true }));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
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 { Database } from '../immersion-tracker/sqlite';
|
||||
import { ensureSchema } from '../immersion-tracker/storage';
|
||||
import { mergeSnapshotIntoDb } from './merge';
|
||||
|
||||
const BASE_MS = 1_700_000_000_000;
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/**
|
||||
* Build a database holding one anime, one episode, one ended session and a
|
||||
* single word occurrence.
|
||||
*
|
||||
* `legacyOccurrences` reproduces a peer whose schema predates the denormalised
|
||||
* `seen_ms` column, so the merge has to recover the timestamp from the line.
|
||||
*/
|
||||
function buildDb(
|
||||
dir: string,
|
||||
name: string,
|
||||
options: { word: string; seenMs: number; legacyOccurrences: boolean },
|
||||
): string {
|
||||
const dbPath = path.join(dir, name);
|
||||
const db = new Database(dbPath);
|
||||
ensureSchema(db);
|
||||
db.exec(`
|
||||
INSERT INTO imm_anime(anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (1, 'key-${name}', 'Show ${name}', ${BASE_MS}, ${BASE_MS});
|
||||
INSERT INTO imm_videos(video_id, video_key, anime_id, canonical_title, source_type, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (1, 'video-${name}', 1, 'Episode 1', 1, 1, 1440000, ${BASE_MS}, ${BASE_MS});
|
||||
INSERT INTO imm_sessions(session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (1, 'uuid-${name}', 1, '${options.seenMs}', '${options.seenMs + 1000}', 2, ${BASE_MS}, ${BASE_MS});
|
||||
INSERT INTO imm_subtitle_lines(line_id, session_id, video_id, anime_id, line_index, text, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (1, 1, 1, 1, 0, 'line', ${options.seenMs}, ${options.seenMs});
|
||||
INSERT INTO imm_words(id, headword, word, reading, part_of_speech, pos1, first_seen, last_seen, frequency)
|
||||
VALUES (1, '${options.word}', '${options.word}', '', 'noun', '名詞',
|
||||
${Math.floor(options.seenMs / 1000)}, ${Math.floor(options.seenMs / 1000)}, 1);
|
||||
`);
|
||||
db.exec(
|
||||
options.legacyOccurrences
|
||||
? 'INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count) VALUES (1, 1, 1)'
|
||||
: `INSERT INTO imm_word_line_occurrences(line_id, word_id, occurrence_count, seen_ms) VALUES (1, 1, 1, ${options.seenMs})`,
|
||||
);
|
||||
db.close();
|
||||
return dbPath;
|
||||
}
|
||||
|
||||
function mergedOccurrences(dbPath: string): Array<{ word: string; seenMs: number | null }> {
|
||||
const db = new Database(dbPath);
|
||||
try {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT w.word AS word, o.seen_ms AS seenMs
|
||||
FROM imm_word_line_occurrences o
|
||||
JOIN imm_words w ON w.id = o.word_id
|
||||
ORDER BY w.word`,
|
||||
)
|
||||
.all() as Array<{ word: string; seenMs: number | null }>;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
for (const legacyOccurrences of [false, true]) {
|
||||
const label = legacyOccurrences ? 'a peer predating the seen_ms column' : 'a current peer';
|
||||
|
||||
test(`sync merge carries occurrence timestamps in from ${label}`, () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-merge-occurrences-test-'));
|
||||
try {
|
||||
const local = buildDb(dir, 'local.sqlite', {
|
||||
word: '猫',
|
||||
seenMs: BASE_MS,
|
||||
legacyOccurrences: false,
|
||||
});
|
||||
const remoteSeenMs = BASE_MS + 5 * DAY_MS;
|
||||
const remote = buildDb(dir, 'remote.sqlite', {
|
||||
word: '犬',
|
||||
seenMs: remoteSeenMs,
|
||||
legacyOccurrences,
|
||||
});
|
||||
|
||||
mergeSnapshotIntoDb(local, remote);
|
||||
|
||||
const rows = mergedOccurrences(local);
|
||||
assert.equal(rows.length, 2, 'local and remote occurrences both survive the merge');
|
||||
for (const row of rows) {
|
||||
assert.ok(row.seenMs, `${row.word} must carry a timestamp so aggregates stay index-only`);
|
||||
}
|
||||
assert.equal(
|
||||
Number(rows.find((row) => row.word === '犬')?.seenMs),
|
||||
remoteSeenMs,
|
||||
"the remote line's own timestamp is preserved rather than stamped with merge time",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -215,12 +215,16 @@ function copySubtitleLines(
|
||||
'SELECT kanji_id, occurrence_count FROM imm_kanji_line_occurrences WHERE line_id = ?',
|
||||
);
|
||||
const insertWordOccurrence = local.query(
|
||||
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count) VALUES (?, ?, ?)
|
||||
ON CONFLICT(line_id, word_id) DO UPDATE SET occurrence_count = occurrence_count + excluded.occurrence_count`,
|
||||
`INSERT INTO imm_word_line_occurrences (line_id, word_id, occurrence_count, seen_ms) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(line_id, word_id) DO UPDATE SET
|
||||
occurrence_count = occurrence_count + excluded.occurrence_count,
|
||||
seen_ms = COALESCE(seen_ms, excluded.seen_ms)`,
|
||||
);
|
||||
const insertKanjiOccurrence = local.query(
|
||||
`INSERT INTO imm_kanji_line_occurrences (line_id, kanji_id, occurrence_count) VALUES (?, ?, ?)
|
||||
ON CONFLICT(line_id, kanji_id) DO UPDATE SET occurrence_count = occurrence_count + excluded.occurrence_count`,
|
||||
`INSERT INTO imm_kanji_line_occurrences (line_id, kanji_id, occurrence_count, seen_ms) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(line_id, kanji_id) DO UPDATE SET
|
||||
occurrence_count = occurrence_count + excluded.occurrence_count,
|
||||
seen_ms = COALESCE(seen_ms, excluded.seen_ms)`,
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
@@ -241,17 +245,20 @@ function copySubtitleLines(
|
||||
],
|
||||
);
|
||||
summary.subtitleLinesAdded += 1;
|
||||
// Taken from the line rather than the remote occurrence row so this also
|
||||
// works when the remote database predates the seen_ms column.
|
||||
const seenMs = row.CREATED_DATE ?? row.LAST_UPDATE_DATE ?? null;
|
||||
|
||||
for (const occurrence of wordOccurrences.all(row.line_id) as SqlRow[]) {
|
||||
const localWordId = lexicon.resolveWord(Number(occurrence.word_id));
|
||||
const count = Number(occurrence.occurrence_count);
|
||||
insertWordOccurrence.run(localLineId, localWordId, count);
|
||||
insertWordOccurrence.run(localLineId, localWordId, count, seenMs);
|
||||
lexicon.addWordOccurrences(Number(occurrence.word_id), count);
|
||||
}
|
||||
for (const occurrence of kanjiOccurrences.all(row.line_id) as SqlRow[]) {
|
||||
const localKanjiId = lexicon.resolveKanji(Number(occurrence.kanji_id));
|
||||
const count = Number(occurrence.occurrence_count);
|
||||
insertKanjiOccurrence.run(localLineId, localKanjiId, count);
|
||||
insertKanjiOccurrence.run(localLineId, localKanjiId, count, seenMs);
|
||||
lexicon.addKanjiOccurrences(Number(occurrence.kanji_id), count);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,5 +85,5 @@ test('writeConfigExampleArtifacts creates parent directories for resolved output
|
||||
test('generateConfigExampleTemplate uses the canonical example socket path', () => {
|
||||
const template = generateConfigExampleTemplate();
|
||||
|
||||
assert.match(template, /"socketPath": "\/tmp\/subminer-socket"/);
|
||||
assert.match(template, /"socketPath": "\\\\\\\\.\\\\pipe\\\\subminer-socket"/);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as path from 'path';
|
||||
import { DEFAULT_CONFIG, deepCloneConfig, generateConfigTemplate } from './config';
|
||||
import { getDefaultMpvSocketPath } from './shared/mpv-socket-path';
|
||||
|
||||
const CONFIG_EXAMPLE_PLATFORM: NodeJS.Platform = 'linux';
|
||||
const CONFIG_EXAMPLE_PLATFORM: NodeJS.Platform = 'win32';
|
||||
|
||||
export function generateConfigExampleTemplate(): string {
|
||||
const config = deepCloneConfig(DEFAULT_CONFIG);
|
||||
|
||||
@@ -141,6 +141,7 @@ export interface StatsJsonResponseMap {
|
||||
deleteSessions: StatsOkResponse;
|
||||
deleteSession: StatsOkResponse;
|
||||
deleteVideo: StatsOkResponse;
|
||||
deleteAnime: StatsOkResponse;
|
||||
anilistSearch: StatsAnilistSearchResult[];
|
||||
knownWords: string[];
|
||||
knownWordsSummary: StatsKnownWordsSummary;
|
||||
@@ -219,6 +220,7 @@ export interface StatsHttpClient {
|
||||
deleteSession: (sessionId: number) => Promise<void>;
|
||||
deleteSessions: (sessionIds: number[]) => Promise<void>;
|
||||
deleteVideo: (videoId: number) => Promise<void>;
|
||||
deleteAnime: (animeId: number) => Promise<void>;
|
||||
getKnownWords: () => Promise<string[]>;
|
||||
getKnownWordsSummary: () => Promise<StatsKnownWordsSummary>;
|
||||
getAnimeKnownWordsSummary: (animeId: number) => Promise<StatsKnownWordsSummary>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Suspense, lazy, useCallback, useState } from 'react';
|
||||
import { DeleteConfirmDialog } from './components/layout/DeleteConfirmDialog';
|
||||
import { DeleteProgressToast } from './components/common/DeleteProgressToast';
|
||||
import { TabBar } from './components/layout/TabBar';
|
||||
import { OverviewTab } from './components/overview/OverviewTab';
|
||||
import { useExcludedWords } from './hooks/useExcludedWords';
|
||||
@@ -293,6 +294,7 @@ export function App() {
|
||||
</Suspense>
|
||||
) : null}
|
||||
<DeleteConfirmDialog />
|
||||
<DeleteProgressToast />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useAnimeDetail } from '../../hooks/useAnimeDetail';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
import { confirmAnimeDelete } from '../../lib/delete-confirm';
|
||||
import { epochDayToDate } from '../../lib/formatters';
|
||||
import { AnimeHeader } from './AnimeHeader';
|
||||
import { EpisodeList } from './EpisodeList';
|
||||
@@ -16,6 +17,8 @@ interface AnimeDetailViewProps {
|
||||
onBack: () => void;
|
||||
onNavigateToWord?: (wordId: number) => void;
|
||||
onOpenEpisodeDetail?: (videoId: number) => void;
|
||||
/** Called after the whole library entry is deleted, so the caller can refresh. */
|
||||
onAnimeDeleted?: () => void;
|
||||
}
|
||||
|
||||
type Range = 14 | 30 | 90;
|
||||
@@ -139,10 +142,14 @@ export function AnimeDetailView({
|
||||
onBack,
|
||||
onNavigateToWord,
|
||||
onOpenEpisodeDetail,
|
||||
onAnimeDeleted,
|
||||
}: AnimeDetailViewProps) {
|
||||
const { data, loading, error, reload } = useAnimeDetail(animeId);
|
||||
const [showAnilistSelector, setShowAnilistSelector] = useState(false);
|
||||
const [coverRetryToken, setCoverRetryToken] = useState(0);
|
||||
const [isDeletingAnime, setIsDeletingAnime] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const isDeletingAnimeRef = useRef(false);
|
||||
const knownWordsSummary = useAnimeKnownWords(animeId);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -154,6 +161,40 @@ export function AnimeDetailView({
|
||||
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Anime not found</div>;
|
||||
|
||||
const { detail, episodes, anilistEntries } = data;
|
||||
|
||||
const handleDeleteAnime = async () => {
|
||||
if (isDeletingAnimeRef.current) return;
|
||||
isDeletingAnimeRef.current = true;
|
||||
// Cleared up front so cancelling a retry doesn't leave the previous
|
||||
// attempt's failure on screen.
|
||||
setDeleteError(null);
|
||||
let confirmed = false;
|
||||
try {
|
||||
confirmed = await confirmAnimeDelete(detail.canonicalTitle, detail.episodeCount);
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : 'Failed to confirm delete.');
|
||||
isDeletingAnimeRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!confirmed) {
|
||||
isDeletingAnimeRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteError(null);
|
||||
setIsDeletingAnime(true);
|
||||
try {
|
||||
await getStatsClient().deleteAnime(animeId);
|
||||
onAnimeDeleted?.();
|
||||
onBack();
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : 'Failed to delete this title.');
|
||||
setIsDeletingAnime(false);
|
||||
} finally {
|
||||
isDeletingAnimeRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
@@ -168,7 +209,10 @@ export function AnimeDetailView({
|
||||
anilistEntries={anilistEntries ?? []}
|
||||
coverRetryToken={coverRetryToken}
|
||||
onChangeAnilist={() => setShowAnilistSelector(true)}
|
||||
onDeleteAnime={() => void handleDeleteAnime()}
|
||||
isDeletingAnime={isDeletingAnime}
|
||||
/>
|
||||
{deleteError ? <div className="text-sm text-ctp-red">{deleteError}</div> : null}
|
||||
<AnimeOverviewStats detail={detail} knownWordsSummary={knownWordsSummary} />
|
||||
<EpisodeList
|
||||
episodes={episodes}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { AnimeHeader } from './AnimeHeader';
|
||||
import { confirmAnimeDelete, setDeleteConfirmPresenter } from '../../lib/delete-confirm';
|
||||
import type { AnimeDetailData } from '../../types/stats';
|
||||
|
||||
const DETAIL: AnimeDetailData['detail'] = {
|
||||
animeId: 3,
|
||||
canonicalTitle: 'Project Radio Noise Season 2',
|
||||
anilistId: 20661,
|
||||
titleRomaji: 'Toaru Kagaku no Railgun S',
|
||||
titleEnglish: 'A Certain Scientific Railgun S',
|
||||
titleNative: null,
|
||||
description: null,
|
||||
totalSessions: 1,
|
||||
totalActiveMs: 960_000,
|
||||
totalCards: 0,
|
||||
totalTokensSeen: 1_655,
|
||||
totalLinesSeen: 300,
|
||||
totalLookupCount: 0,
|
||||
totalLookupHits: 0,
|
||||
totalYomitanLookupCount: 0,
|
||||
episodeCount: 1,
|
||||
lastWatchedMs: 1_700_000_000_000,
|
||||
};
|
||||
|
||||
test('AnimeHeader offers a delete-entry action alongside the AniList actions', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AnimeHeader detail={DETAIL} anilistEntries={[]} onDeleteAnime={() => {}} />,
|
||||
);
|
||||
|
||||
assert.match(markup, /Delete Entry/);
|
||||
assert.match(markup, /Delete this title and every session and stat recorded for it/);
|
||||
});
|
||||
|
||||
test('AnimeHeader hides the delete action when no handler is wired', () => {
|
||||
const markup = renderToStaticMarkup(<AnimeHeader detail={DETAIL} anilistEntries={[]} />);
|
||||
|
||||
assert.doesNotMatch(markup, /Delete Entry/);
|
||||
});
|
||||
|
||||
test('AnimeHeader shows a pending state while the entry is being deleted', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AnimeHeader detail={DETAIL} anilistEntries={[]} onDeleteAnime={() => {}} isDeletingAnime />,
|
||||
);
|
||||
|
||||
assert.match(markup, /disabled=""/);
|
||||
assert.match(markup, /animate-spin/);
|
||||
assert.doesNotMatch(markup, /Delete Entry/);
|
||||
});
|
||||
|
||||
test('confirmAnimeDelete spells out how much data the entry deletion removes', async () => {
|
||||
const seen: string[] = [];
|
||||
const restore = setDeleteConfirmPresenter((message) => {
|
||||
seen.push(message);
|
||||
return false;
|
||||
});
|
||||
|
||||
try {
|
||||
await confirmAnimeDelete('Railgun Season 2', 1);
|
||||
await confirmAnimeDelete('Railgun Season 2', 3);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
|
||||
assert.match(seen[0] ?? '', /"Railgun Season 2"/);
|
||||
assert.match(seen[0] ?? '', /1 episode\b/);
|
||||
assert.match(seen[1] ?? '', /3 episodes/);
|
||||
assert.match(seen[0] ?? '', /every session and stat/);
|
||||
});
|
||||
@@ -6,6 +6,8 @@ interface AnimeHeaderProps {
|
||||
anilistEntries: AnilistEntry[];
|
||||
coverRetryToken?: number;
|
||||
onChangeAnilist?: () => void;
|
||||
onDeleteAnime?: () => void;
|
||||
isDeletingAnime?: boolean;
|
||||
}
|
||||
|
||||
function AnilistButton({ entry }: { entry: AnilistEntry }) {
|
||||
@@ -32,6 +34,8 @@ export function AnimeHeader({
|
||||
anilistEntries,
|
||||
coverRetryToken = 0,
|
||||
onChangeAnilist,
|
||||
onDeleteAnime,
|
||||
isDeletingAnime = false,
|
||||
}: AnimeHeaderProps) {
|
||||
const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative].filter(
|
||||
(t): t is string => t != null && t !== detail.canonicalTitle,
|
||||
@@ -95,6 +99,25 @@ export function AnimeHeader({
|
||||
: 'Link to AniList'}
|
||||
</button>
|
||||
)}
|
||||
{onDeleteAnime && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeleteAnime}
|
||||
disabled={isDeletingAnime}
|
||||
title="Delete this title and every session and stat recorded for it"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-red/80 hover:bg-ctp-red/15 hover:text-ctp-red transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isDeletingAnime ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-3 w-3 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-red"
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden="true">{'\u2715'}</span>
|
||||
)}
|
||||
{isDeletingAnime ? 'Deleting\u2026' : 'Delete Entry'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{detail.description && (
|
||||
<p className="text-xs text-ctp-subtext0 mt-3 line-clamp-3 leading-relaxed">
|
||||
|
||||
@@ -53,7 +53,7 @@ export function AnimeTab({
|
||||
onNavigateToWord,
|
||||
onOpenEpisodeDetail,
|
||||
}: AnimeTabProps) {
|
||||
const { anime, loading, error } = useAnimeLibrary();
|
||||
const { anime, loading, error, reload } = useAnimeLibrary();
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
|
||||
const [cardSize, setCardSize] = useState<LibraryCardSize>(() =>
|
||||
@@ -98,6 +98,7 @@ export function AnimeTab({
|
||||
? (videoId) => onOpenEpisodeDetail(selectedAnimeId, videoId)
|
||||
: undefined
|
||||
}
|
||||
onAnimeDeleted={reload}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,49 @@
|
||||
interface DeleteProgressToastProps {
|
||||
/** Number of sessions currently being deleted. The toast is hidden when 0. */
|
||||
count: number;
|
||||
}
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { getDeleteProgressSnapshot, subscribeDeleteProgress } from '../../lib/delete-progress';
|
||||
|
||||
/**
|
||||
* Fixed-position toast shown while session deletions are in flight.
|
||||
* Global "deletion in progress" indicator.
|
||||
*
|
||||
* The per-row delete buttons are only visible on hover, so once the confirm
|
||||
* dialog closes the user has no signal that a (potentially slow) batch delete
|
||||
* is still running. This stays on screen, independent of hover, until the work
|
||||
* finishes.
|
||||
* Mounted once at the app root, outside every tab panel, so it shows no matter
|
||||
* which view started the delete — per-tab copies were hidden along with their
|
||||
* `hidden` tab panel, and detail views had no indicator at all. State comes
|
||||
* from the delete-progress store rather than props so it also survives the
|
||||
* initiating component unmounting mid-request.
|
||||
*
|
||||
* Renders two signals: a sweeping bar pinned to the top edge (visible even when
|
||||
* the eye is on the content being deleted) and a bottom-right toast naming the
|
||||
* work.
|
||||
*/
|
||||
export function DeleteProgressToast({ count }: DeleteProgressToastProps) {
|
||||
export function DeleteProgressToast() {
|
||||
const { count, label } = useSyncExternalStore(
|
||||
subscribeDeleteProgress,
|
||||
getDeleteProgressSnapshot,
|
||||
getDeleteProgressSnapshot,
|
||||
);
|
||||
|
||||
if (count <= 0) return null;
|
||||
|
||||
const message = count > 1 ? `Deleting ${count} items` : (label ?? 'Deleting');
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 shadow-lg shadow-black/30"
|
||||
>
|
||||
<span
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-red"
|
||||
/>
|
||||
<span className="text-sm text-ctp-text">
|
||||
Deleting {count} session{count === 1 ? '' : 's'}…
|
||||
</span>
|
||||
</div>
|
||||
className="fixed inset-x-0 top-0 z-[2147483646] h-0.5 overflow-hidden bg-ctp-surface1"
|
||||
>
|
||||
<div className="h-full w-1/3 animate-indeterminate rounded-full bg-gradient-to-r from-ctp-red via-ctp-peach to-ctp-red" />
|
||||
</div>
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="fixed bottom-4 right-4 z-[2147483646] flex items-center gap-3 rounded-lg border border-ctp-surface1 bg-ctp-surface0 px-4 py-3 shadow-lg shadow-black/30"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-ctp-surface2 border-t-ctp-red"
|
||||
/>
|
||||
<span className="text-sm text-ctp-text">{message}…</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { StreakCalendar } from './StreakCalendar';
|
||||
import { RecentSessions } from './RecentSessions';
|
||||
import { TrackingSnapshot } from './TrackingSnapshot';
|
||||
import { TrendChart } from '../trends/TrendChart';
|
||||
import { DeleteProgressToast } from '../common/DeleteProgressToast';
|
||||
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
@@ -160,8 +159,6 @@ export function OverviewTab({
|
||||
deletingIds={deletingIds}
|
||||
isActive={isActive}
|
||||
/>
|
||||
|
||||
<DeleteProgressToast count={deletingIds.size} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSessions } from '../../hooks/useSessions';
|
||||
import { SessionRow } from './SessionRow';
|
||||
import { SessionDetail } from './SessionDetail';
|
||||
import { DeleteProgressToast } from '../common/DeleteProgressToast';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { confirmBucketDelete, confirmSessionDelete } from '../../lib/delete-confirm';
|
||||
import { formatDuration, formatNumber, formatSessionDayLabel } from '../../lib/formatters';
|
||||
@@ -344,8 +343,6 @@ export function SessionsTab({
|
||||
{search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DeleteProgressToast count={deletingSessionIds.size} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { AnimeLibraryItem } from '../types/stats';
|
||||
|
||||
@@ -6,6 +6,11 @@ export function useAnimeLibrary() {
|
||||
const [anime, setAnime] = useState<AnimeLibraryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setReloadToken((token) => token + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -23,7 +28,7 @@ export function useAnimeLibrary() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [reloadToken]);
|
||||
|
||||
return { anime, loading, error };
|
||||
return { anime, loading, error, reload };
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
} from '../types/stats';
|
||||
import type { StatsMineCardParams, StatsMineCardResponse } from './mining';
|
||||
import { appendCoverRetryToken } from './cover-retry';
|
||||
import { trackDelete } from './delete-progress';
|
||||
|
||||
type StatsLocationLike = Pick<Location, 'protocol' | 'origin' | 'search'>;
|
||||
|
||||
@@ -169,17 +170,29 @@ export const apiClient = {
|
||||
});
|
||||
},
|
||||
deleteSession: async (sessionId: number): Promise<void> => {
|
||||
await fetchResponse(`/api/stats/sessions/${sessionId}`, { method: 'DELETE' });
|
||||
await trackDelete('Deleting session', () =>
|
||||
fetchResponse(`/api/stats/sessions/${sessionId}`, { method: 'DELETE' }),
|
||||
);
|
||||
},
|
||||
deleteSessions: async (sessionIds: number[]): Promise<void> => {
|
||||
await fetchResponse('/api/stats/sessions', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionIds } satisfies StatsDeleteSessionsRequest),
|
||||
});
|
||||
const label = `Deleting ${sessionIds.length} session${sessionIds.length === 1 ? '' : 's'}`;
|
||||
await trackDelete(label, () =>
|
||||
fetchResponse('/api/stats/sessions', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionIds } satisfies StatsDeleteSessionsRequest),
|
||||
}),
|
||||
);
|
||||
},
|
||||
deleteVideo: async (videoId: number): Promise<void> => {
|
||||
await fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' });
|
||||
await trackDelete('Deleting episode', () =>
|
||||
fetchResponse(`/api/stats/media/${videoId}`, { method: 'DELETE' }),
|
||||
);
|
||||
},
|
||||
deleteAnime: async (animeId: number): Promise<void> => {
|
||||
await trackDelete('Deleting library entry', () =>
|
||||
fetchResponse(`/api/stats/anime/${animeId}`, { method: 'DELETE' }),
|
||||
);
|
||||
},
|
||||
getKnownWords: () => fetchJson('knownWords', '/api/stats/known-words'),
|
||||
getKnownWordsSummary: () => fetchJson('knownWordsSummary', '/api/stats/known-words-summary'),
|
||||
|
||||
@@ -60,6 +60,13 @@ export function confirmAnimeGroupDelete(title: string, count: number): Promise<b
|
||||
);
|
||||
}
|
||||
|
||||
export function confirmAnimeDelete(title: string, episodeCount: number): Promise<boolean> {
|
||||
const episodes = `${episodeCount} episode${episodeCount === 1 ? '' : 's'}`;
|
||||
return confirmWithStatsNativeDialogLayer(
|
||||
`Delete "${title}" from your library? This removes ${episodes} plus every session and stat recorded for them.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function confirmEpisodeDelete(title: string): Promise<boolean> {
|
||||
return confirmWithStatsNativeDialogLayer(`Delete "${title}" and all its sessions?`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { DeleteProgressToast } from '../components/common/DeleteProgressToast';
|
||||
import { apiClient } from './api-client';
|
||||
import {
|
||||
beginDeleteTask,
|
||||
getDeleteProgressSnapshot,
|
||||
resetDeleteProgress,
|
||||
subscribeDeleteProgress,
|
||||
trackDelete,
|
||||
} from './delete-progress';
|
||||
|
||||
test('delete progress store reports the oldest label and notifies subscribers', () => {
|
||||
resetDeleteProgress();
|
||||
let notifications = 0;
|
||||
const unsubscribe = subscribeDeleteProgress(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
try {
|
||||
assert.deepEqual(getDeleteProgressSnapshot(), { count: 0, label: null });
|
||||
|
||||
const endFirst = beginDeleteTask('Deleting session');
|
||||
const endSecond = beginDeleteTask('Deleting episode');
|
||||
assert.deepEqual(getDeleteProgressSnapshot(), { count: 2, label: 'Deleting session' });
|
||||
assert.equal(notifications, 2);
|
||||
|
||||
endFirst();
|
||||
assert.deepEqual(getDeleteProgressSnapshot(), { count: 1, label: 'Deleting episode' });
|
||||
|
||||
endFirst();
|
||||
assert.deepEqual(
|
||||
getDeleteProgressSnapshot(),
|
||||
{ count: 1, label: 'Deleting episode' },
|
||||
'ending the same task twice must not drop another task',
|
||||
);
|
||||
|
||||
endSecond();
|
||||
assert.deepEqual(getDeleteProgressSnapshot(), { count: 0, label: null });
|
||||
} finally {
|
||||
unsubscribe();
|
||||
resetDeleteProgress();
|
||||
}
|
||||
});
|
||||
|
||||
test('trackDelete clears the indicator even when the request rejects', async () => {
|
||||
resetDeleteProgress();
|
||||
await assert.rejects(
|
||||
trackDelete('Deleting library entry', async () => {
|
||||
assert.equal(getDeleteProgressSnapshot().count, 1);
|
||||
throw new Error('boom');
|
||||
}),
|
||||
/boom/,
|
||||
);
|
||||
assert.deepEqual(getDeleteProgressSnapshot(), { count: 0, label: null });
|
||||
});
|
||||
|
||||
test('DeleteProgressToast stays hidden while idle and reports active deletes', () => {
|
||||
resetDeleteProgress();
|
||||
assert.equal(renderToStaticMarkup(<DeleteProgressToast />), '');
|
||||
|
||||
const end = beginDeleteTask('Deleting library entry');
|
||||
try {
|
||||
const markup = renderToStaticMarkup(<DeleteProgressToast />);
|
||||
assert.match(markup, /role="status"/);
|
||||
assert.match(markup, /Deleting library entry/);
|
||||
assert.match(markup, /animate-indeterminate/);
|
||||
} finally {
|
||||
end();
|
||||
}
|
||||
|
||||
const endFirst = beginDeleteTask('Deleting session');
|
||||
const endSecond = beginDeleteTask('Deleting episode');
|
||||
try {
|
||||
assert.match(renderToStaticMarkup(<DeleteProgressToast />), /Deleting 2 items/);
|
||||
} finally {
|
||||
endFirst();
|
||||
endSecond();
|
||||
resetDeleteProgress();
|
||||
}
|
||||
});
|
||||
|
||||
test('the delete indicator is mounted once at the app root, not inside tab panels', () => {
|
||||
const srcDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const read = (relativePath: string): string =>
|
||||
fs.readFileSync(path.join(srcDir, relativePath), 'utf8');
|
||||
|
||||
const app = read('App.tsx');
|
||||
assert.match(app, /<DeleteProgressToast \/>/);
|
||||
// Sibling of the confirm dialog: outside every `hidden` tab panel, so the
|
||||
// indicator survives tab switches and detail-view navigation.
|
||||
assert.match(app, /<DeleteConfirmDialog \/>\s*<DeleteProgressToast \/>/);
|
||||
|
||||
for (const tab of [
|
||||
'components/overview/OverviewTab.tsx',
|
||||
'components/sessions/SessionsTab.tsx',
|
||||
]) {
|
||||
assert.doesNotMatch(read(tab), /DeleteProgressToast/, `${tab} must not mount its own toast`);
|
||||
}
|
||||
});
|
||||
|
||||
test('every api client delete registers with the global progress indicator', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const seenCounts: number[] = [];
|
||||
globalThis.fetch = (async () => {
|
||||
seenCounts.push(getDeleteProgressSnapshot().count);
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
resetDeleteProgress();
|
||||
await apiClient.deleteSession(1);
|
||||
await apiClient.deleteSessions([1, 2]);
|
||||
await apiClient.deleteVideo(3);
|
||||
await apiClient.deleteAnime(4);
|
||||
|
||||
assert.deepEqual(seenCounts, [1, 1, 1, 1]);
|
||||
assert.deepEqual(getDeleteProgressSnapshot(), { count: 0, label: null });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
resetDeleteProgress();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Tiny external store tracking in-flight delete requests.
|
||||
*
|
||||
* Every delete goes through the API client, which registers here, so a single
|
||||
* globally mounted indicator can report progress no matter which tab, detail
|
||||
* view or overlay window started the work. Keeping the state outside React
|
||||
* means the indicator survives the deleting component unmounting mid-request
|
||||
* (navigating back after deleting a library entry, for example).
|
||||
*/
|
||||
|
||||
export interface DeleteProgressSnapshot {
|
||||
/** Number of delete requests currently in flight. */
|
||||
count: number;
|
||||
/** Label for the oldest in-flight request, or null when idle. */
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
const IDLE_SNAPSHOT: DeleteProgressSnapshot = { count: 0, label: null };
|
||||
|
||||
const activeTasks = new Map<number, string>();
|
||||
const listeners = new Set<() => void>();
|
||||
let nextTaskId = 1;
|
||||
let snapshot: DeleteProgressSnapshot = IDLE_SNAPSHOT;
|
||||
|
||||
function publish(): void {
|
||||
if (activeTasks.size === 0) {
|
||||
snapshot = IDLE_SNAPSHOT;
|
||||
} else {
|
||||
const [oldestLabel] = activeTasks.values();
|
||||
snapshot = { count: activeTasks.size, label: oldestLabel ?? null };
|
||||
}
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function subscribeDeleteProgress(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function getDeleteProgressSnapshot(): DeleteProgressSnapshot {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Register a delete as started. Returns the function that ends it. */
|
||||
export function beginDeleteTask(label: string): () => void {
|
||||
const taskId = nextTaskId++;
|
||||
activeTasks.set(taskId, label);
|
||||
publish();
|
||||
let ended = false;
|
||||
return () => {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
activeTasks.delete(taskId);
|
||||
publish();
|
||||
};
|
||||
}
|
||||
|
||||
/** Run a delete request with the global progress indicator active. */
|
||||
export async function trackDelete<T>(label: string, run: () => Promise<T>): Promise<T> {
|
||||
const end = beginDeleteTask(label);
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
end();
|
||||
}
|
||||
}
|
||||
|
||||
/** Test helper: drop every in-flight task so cases don't leak into each other. */
|
||||
export function resetDeleteProgress(): void {
|
||||
activeTasks.clear();
|
||||
publish();
|
||||
}
|
||||
@@ -80,7 +80,7 @@ body.overlay-mode #root {
|
||||
}
|
||||
|
||||
/* Tab content entrance animation */
|
||||
@keyframes fadeSlideIn {
|
||||
@keyframes fade-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
@@ -92,5 +92,19 @@ body.overlay-mode #root {
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeSlideIn 0.25s ease-out;
|
||||
animation: fade-slide-in 0.25s ease-out;
|
||||
}
|
||||
|
||||
/* Indeterminate progress sweep for the global delete indicator */
|
||||
@keyframes indeterminate-sweep {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(400%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-indeterminate {
|
||||
animation: indeterminate-sweep 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user