mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
fed1bd3b42
|
|||
|
661e54144d
|
|||
|
a53237f1ce
|
|||
|
355d7d95b2
|
|||
|
47f92129af
|
|||
|
525cb7e1fd
|
|||
|
02a5d95542
|
|||
|
166015897d
|
|||
|
fcd6511aa1
|
|||
|
e18b6eda77
|
|||
|
1145e131da
|
|||
|
dde19ad0da
|
|||
|
4813ce1fea
|
|||
|
403ee32579
|
|||
|
e4165a418c
|
|||
|
2772c61aba
|
|||
|
5c710ffcaf
|
|||
|
ab29d56649
|
|||
|
1f7318d615
|
|||
|
cc7c3939e9
|
|||
|
887de056c5
|
|||
|
553117356d
|
|||
|
193b3136f2
|
|||
|
1bb7b26641
|
|||
|
48447c2f1a
|
|||
|
2b13c82d69
|
|||
|
db60365b0e
|
|||
|
93d9ed81a2
|
|||
|
6f48d4b65b
|
|||
|
7fb1e6d7a5
|
|||
|
1ff44e0d69
|
|||
|
0354a0e74b
|
|||
|
b0fd7bd9e8
|
|||
|
58f5fff6ad
|
|||
|
309ce6ef8f
|
|||
| a54f03f0cd | |||
| 799cce6991 | |||
| 6b2cb002ac | |||
| e84674e3b5 | |||
|
6ca5cede3e
|
|||
|
4d010e6a18
|
|||
| 5250ca8214 | |||
| 49f89e6452 | |||
|
89723e2ccb
|
@@ -0,0 +1,76 @@
|
|||||||
|
name: Docs Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
paths:
|
||||||
|
- 'docs-site/**'
|
||||||
|
- 'scripts/docs-versioning.ts'
|
||||||
|
- 'scripts/build-versioned-docs.ts'
|
||||||
|
- '.github/workflows/docs-pages.yml'
|
||||||
|
- 'package.json'
|
||||||
|
- 'bun.lock'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: docs-pages-production
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
if: ${{ github.ref_type != 'tag' || !contains(github.ref_name, '-') }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Guard stable docs tag shape
|
||||||
|
id: tag_guard
|
||||||
|
if: github.ref_type == 'tag'
|
||||||
|
run: |
|
||||||
|
if [[ ! "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "::notice::Skipping non-stable docs tag ${{ github.ref_name }}"
|
||||||
|
echo "stable_tag=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "stable_tag=true" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
if: steps.tag_guard.outputs.stable_tag != 'false'
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: 1.3.5
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.tag_guard.outputs.stable_tag != 'false'
|
||||||
|
run: |
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
cd docs-site && bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Cache versioned docs archives
|
||||||
|
if: steps.tag_guard.outputs.stable_tag != 'false'
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: .tmp/docs-versioned-archive-cache
|
||||||
|
key: docs-versioned-archives-${{ runner.os }}-${{ hashFiles('docs-site/.vitepress/**', 'docs-site/public/assets/fonts/**', 'docs-site/package.json', 'docs-site/bun.lock', 'scripts/build-versioned-docs.ts', 'scripts/docs-versioning.ts') }}
|
||||||
|
|
||||||
|
- name: Test docs
|
||||||
|
if: steps.tag_guard.outputs.stable_tag != 'false'
|
||||||
|
run: bun run docs:test
|
||||||
|
|
||||||
|
- name: Build versioned docs
|
||||||
|
if: steps.tag_guard.outputs.stable_tag != 'false'
|
||||||
|
run: bun run docs:build:versioned
|
||||||
|
|
||||||
|
- name: Deploy docs to Cloudflare Pages
|
||||||
|
if: steps.tag_guard.outputs.stable_tag != 'false'
|
||||||
|
uses: cloudflare/wrangler-action@v3
|
||||||
|
with:
|
||||||
|
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
command: pages deploy .tmp/docs-versioned-site --project-name "${{ vars.CLOUDFLARE_PAGES_PROJECT_NAME }}" --branch main
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop
|
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop docs-test docs-build docs-build-versioned docs-dev
|
||||||
|
|
||||||
APP_NAME := subminer
|
APP_NAME := subminer
|
||||||
THEME_SOURCE := assets/themes/subminer.rasi
|
THEME_SOURCE := assets/themes/subminer.rasi
|
||||||
@@ -23,6 +23,7 @@ MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
|
|||||||
APPIMAGE_SRC = $(firstword $(wildcard release/SubMiner-*.AppImage))
|
APPIMAGE_SRC = $(firstword $(wildcard release/SubMiner-*.AppImage))
|
||||||
MACOS_APP_SRC = $(firstword $(wildcard release/*.app release/*/*.app))
|
MACOS_APP_SRC = $(firstword $(wildcard release/*.app release/*/*.app))
|
||||||
MACOS_ZIP_SRC = $(firstword $(wildcard release/SubMiner-*.zip))
|
MACOS_ZIP_SRC = $(firstword $(wildcard release/SubMiner-*.zip))
|
||||||
|
PRERELEASE_NOTES := release/prerelease-notes.md
|
||||||
|
|
||||||
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
|
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
|
||||||
ifeq ($(OS),Windows_NT)
|
ifeq ($(OS),Windows_NT)
|
||||||
@@ -61,6 +62,10 @@ help:
|
|||||||
" dev-watch-macos Start watch loop with forced macOS tracker backend" \
|
" dev-watch-macos Start watch loop with forced macOS tracker backend" \
|
||||||
" dev-toggle Toggle overlay in a running local Electron app" \
|
" dev-toggle Toggle overlay in a running local Electron app" \
|
||||||
" dev-stop Stop a running local Electron app" \
|
" dev-stop Stop a running local Electron app" \
|
||||||
|
" docs-test Run docs tests" \
|
||||||
|
" docs-build Build the docs site" \
|
||||||
|
" docs-build-versioned Build production versioned docs site" \
|
||||||
|
" docs-dev Start the docs dev server" \
|
||||||
" install-linux Install Linux wrapper/theme/app artifacts" \
|
" install-linux Install Linux wrapper/theme/app artifacts" \
|
||||||
" install-macos Install macOS wrapper/theme/app artifacts" \
|
" install-macos Install macOS wrapper/theme/app artifacts" \
|
||||||
" install-windows Print Windows packaging/install guidance" \
|
" install-windows Print Windows packaging/install guidance" \
|
||||||
@@ -161,7 +166,15 @@ build-launcher:
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
@printf '%s\n' "[INFO] Removing build artifacts"
|
@printf '%s\n' "[INFO] Removing build artifacts"
|
||||||
@rm -rf dist release
|
@if [ -f "$(PRERELEASE_NOTES)" ]; then \
|
||||||
|
PRERELEASE_NOTES_BACKUP="$$(mktemp -t subminer-prerelease-notes.XXXXXX)" && \
|
||||||
|
cp "$(PRERELEASE_NOTES)" "$$PRERELEASE_NOTES_BACKUP" && \
|
||||||
|
rm -rf dist release && \
|
||||||
|
install -d release && \
|
||||||
|
mv "$$PRERELEASE_NOTES_BACKUP" "$(PRERELEASE_NOTES)"; \
|
||||||
|
else \
|
||||||
|
rm -rf dist release; \
|
||||||
|
fi
|
||||||
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
|
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
|
||||||
|
|
||||||
generate-config: ensure-bun
|
generate-config: ensure-bun
|
||||||
@@ -191,6 +204,18 @@ dev-toggle: ensure-bun
|
|||||||
dev-stop: ensure-bun
|
dev-stop: ensure-bun
|
||||||
@bun run electron . --stop
|
@bun run electron . --stop
|
||||||
|
|
||||||
|
docs-test: ensure-bun
|
||||||
|
@bun run docs:test
|
||||||
|
|
||||||
|
docs-build: ensure-bun
|
||||||
|
@bun run docs:build
|
||||||
|
|
||||||
|
docs-build-versioned: ensure-bun
|
||||||
|
@bun run docs:build:versioned
|
||||||
|
|
||||||
|
docs-dev: ensure-bun
|
||||||
|
@bun run docs:dev
|
||||||
|
|
||||||
|
|
||||||
install-linux: build-launcher
|
install-linux: build-launcher
|
||||||
@printf '%s\n' "[INFO] Installing Linux wrapper/theme artifacts"
|
@printf '%s\n' "[INFO] Installing Linux wrapper/theme artifacts"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
# SubMiner
|
# SubMiner
|
||||||
|
|
||||||
Look up words with Yomitan, export to Anki in one key, track your immersion — all without leaving mpv.
|
Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersion without leaving the player
|
||||||
|
|
||||||
[Installation](#quick-start) · [Requirements](#requirements) · [Usage](https://docs.subminer.moe/usage) · [Documentation](https://docs.subminer.moe)
|
[Installation](#quick-start) · [Requirements](#requirements) · [Usage](https://docs.subminer.moe/usage) · [Documentation](https://docs.subminer.moe)
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ Look up words with Yomitan, export to Anki in one key, track your immersion —
|
|||||||
|
|
||||||
### Dictionary Lookups
|
### Dictionary Lookups
|
||||||
|
|
||||||
Yomitan runs inside the overlay. Trigger a lookup on any word for full dictionary popups — definitions, pitch accent, frequency data — without ever leaving mpv.
|
Hover over any word and trigger a lookup to get the full Yomitan popup - definitions, pitch accent, and frequency data - without ever leaving mpv.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv">
|
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv">
|
||||||
@@ -43,7 +43,7 @@ Create an Anki card with the sentence, audio clip, screenshot, and machine trans
|
|||||||
|
|
||||||
### Reading Annotations
|
### Reading Annotations
|
||||||
|
|
||||||
Real-time subtitle annotations with frequency highlighting, JLPT tags, N+1 targeting, and a character name dictionary. Known words fade back; new words stand out. Grammar-only tokens render as plain text so you focus on what matters.
|
Real-time subtitle annotations with frequency highlighting, JLPT tags, N+1 targeting, and a character name dictionary. Grammar-only tokens and particles render as plain text so you focus on what matters.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs-site/public/screenshots/annotations.png" width="800" alt="Annotated subtitles with frequency coloring, JLPT underlines, and N+1 targets">
|
<img src="docs-site/public/screenshots/annotations.png" width="800" alt="Annotated subtitles with frequency coloring, JLPT underlines, and N+1 targets">
|
||||||
@@ -53,7 +53,7 @@ Real-time subtitle annotations with frequency highlighting, JLPT tags, N+1 targe
|
|||||||
|
|
||||||
### Immersion Dashboard
|
### Immersion Dashboard
|
||||||
|
|
||||||
Local stats dashboard — watch time, anime library, vocabulary growth, mining throughput, session history, and trends. All stored locally, no third-party tracking.
|
Local stats dashboard tracking watch time, vocabulary growth, mining throughput, session history, and trends. All stored locally, no third-party tracking.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs-site/public/screenshots/stats-overview.png" width="800" alt="Stats dashboard showing watch time, cards mined, streaks, and tracking data">
|
<img src="docs-site/public/screenshots/stats-overview.png" width="800" alt="Stats dashboard showing watch time, cards mined, streaks, and tracking data">
|
||||||
@@ -92,11 +92,11 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>alass / ffsubsync</b></td>
|
<td><b>alass / ffsubsync</b></td>
|
||||||
<td>Automatic subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td>
|
<td>Manual subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>WebSocket</b></td>
|
<td><b>WebSocket</b></td>
|
||||||
<td>Annotated subtitle feed for external clients (texthooker pages, custom tools)</td>
|
<td>Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -110,65 +110,36 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
| | Required | Recommended | Optional |
|
Only **mpv** and Anki+AnkiConnect are required. Everything else is optional but enhances the experience.
|
||||||
| -------------- | --------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **Player** | [`mpv`](https://mpv.io) with IPC socket | — | — |
|
|
||||||
| **Processing** | — | `ffmpeg` (audio clips & screenshots) | `mecab` + `mecab-ipadic` (annotation POS filtering), `guessit` (AniSkip), `alass` / `ffsubsync` (subtitle sync) |
|
|
||||||
| **Media** | — | — | `yt-dlp`, `chafa`, `ffmpegthumbnailer` |
|
|
||||||
| **Selection** | — | — | `fzf` / `rofi` |
|
|
||||||
|
|
||||||
> [!TIP]
|
| Dependency | Status | What it does |
|
||||||
> `ffmpeg` is not strictly required to run SubMiner, but without it audio clips and screenshots will not be attached to Anki cards. Most users will want it installed.
|
| -------------------- | ----------- | ---------------------------------------- |
|
||||||
|
| mpv | Required | The video player SubMiner overlays on |
|
||||||
> [!NOTE]
|
| Anki + AnkiConnect | Required | Card creation from the Yomitan popup |
|
||||||
> [`bun`](https://bun.sh) is required if building from source or using the CLI wrapper: `subminer`. Pre-built releases (AppImage, DMG, installer) do not require it.
|
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
|
||||||
|
| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
|
||||||
**Platform-specific:**
|
| yt-dlp | Optional | YouTube playback |
|
||||||
|
| fzf / rofi | Optional | Video picker in the launcher |
|
||||||
| Linux | macOS | Windows |
|
| alass / ffsubsync | Optional | Subtitle sync |
|
||||||
| ------------------------------------------------------------ | ------------------------ | ------------- |
|
|
||||||
| Hyprland (`hyprctl`) · X11/Xwayland (`xdotool` + `xwininfo`) | Accessibility permission | No extra deps |
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> **Wayland support is compositor-specific.** Wayland has no universal API for window positioning and each compositor exposes its own IPC, so SubMiner needs a dedicated backend per compositor. Hyprland is the only native Wayland backend supported currenlty. All other Linux compositors require both mpv and SubMiner to run under X11 or Xwayland. The launcher detects your compositor and configures this automatically.
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Arch Linux</b></summary>
|
<summary><b>Platform-specific install commands</b></summary>
|
||||||
|
|
||||||
|
**Arch Linux:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
paru -S --needed mpv ffmpeg
|
sudo pacman -S --needed mpv ffmpeg mecab mecab-ipadic
|
||||||
# Optional
|
|
||||||
paru -S --needed mecab-git mecab-ipadic yt-dlp fzf rofi chafa ffmpegthumbnailer xdotool xorg-xwininfo
|
|
||||||
# Optional: subtitle sync (install at least one for subtitle syncing to work)
|
|
||||||
paru -S --needed alass python-ffsubsync
|
|
||||||
# X11 / Xwayland (required for non-Hyprland compositors)
|
|
||||||
paru -S --needed xdotool xorg-xwininfo
|
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
**macOS:**
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>macOS</b></summary>
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install mpv ffmpeg
|
brew install mpv ffmpeg mecab mecab-ipadic
|
||||||
# Optional
|
|
||||||
brew install mecab mecab-ipadic yt-dlp fzf rofi chafa ffmpegthumbnailer
|
|
||||||
# Optional: subtitle sync (install at least one for subtitle syncing to work)
|
|
||||||
brew install alass
|
|
||||||
pip install ffsubsync
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Grant Accessibility permission to SubMiner in **System Settings > Privacy & Security > Accessibility**.
|
**Windows:** Install [mpv](https://mpv.io/installation/) and [ffmpeg](https://ffmpeg.org/download.html) and ensure both are on `PATH`.
|
||||||
|
|
||||||
</details>
|
See the [full requirements list](https://docs.subminer.moe/installation#1-install-requirements) for optional dependencies.
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Windows</b></summary>
|
|
||||||
|
|
||||||
Install [`mpv`](https://mpv.io/installation/) and [`ffmpeg`](https://ffmpeg.org/download.html) and ensure both are on your `PATH`.
|
|
||||||
|
|
||||||
Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary for additional metadata enrichment.
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -176,7 +147,7 @@ Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### 1. Install
|
### 1. Install SubMiner
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Arch Linux (AUR)</b></summary>
|
<summary><b>Arch Linux (AUR)</b></summary>
|
||||||
@@ -185,12 +156,6 @@ Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download
|
|||||||
paru -S subminer-bin
|
paru -S subminer-bin
|
||||||
```
|
```
|
||||||
|
|
||||||
Or manually:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://aur.archlinux.org/subminer-bin.git && cd subminer-bin && makepkg -si
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -199,40 +164,24 @@ git clone https://aur.archlinux.org/subminer-bin.git && cd subminer-bin && makep
|
|||||||
```bash
|
```bash
|
||||||
mkdir -p ~/.local/bin
|
mkdir -p ~/.local/bin
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
|
||||||
&& chmod +x ~/.local/bin/SubMiner.AppImage
|
&& chmod +x ~/.local/bin/SubMiner.AppImage
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
|
||||||
&& chmod +x ~/.local/bin/subminer
|
&& chmod +x ~/.local/bin/subminer
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> The `subminer` wrapper uses a [Bun](https://bun.sh) shebang. First-run setup can optionally install Bun and the launcher into an existing writable PATH directory.
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>macOS</b></summary>
|
<summary><b>macOS (DMG)</b></summary>
|
||||||
|
|
||||||
Download the latest DMG or ZIP from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) and drag `SubMiner.app` into `/Applications`.
|
Download the latest DMG from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) and drag `SubMiner.app` into `/Applications`.
|
||||||
|
|
||||||
Also download the `subminer` launcher (recommended):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p ~/.local/bin
|
|
||||||
curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o ~/.local/bin/subminer \
|
|
||||||
&& chmod +x ~/.local/bin/subminer
|
|
||||||
```
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> The `subminer` launcher uses a [Bun](https://bun.sh) shebang. First-run setup can optionally install Bun and the launcher into an existing writable PATH directory. Make sure `~/.local/bin` is on your PATH before installing there.
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Windows</b></summary>
|
<summary><b>Windows</b></summary>
|
||||||
|
|
||||||
Download the latest installer (`.exe`) [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). Make sure `mpv` is on your `PATH`.
|
Download and run the latest installer (`.exe`) from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest).
|
||||||
|
|
||||||
**Note:** On Windows the recommended way to run playback is with the **SubMiner mpv** shortcut created during first-run setup. First-run setup can also optionally install Bun and a `subminer.cmd` command shim to your user PATH, so new terminals can run `subminer` without adding `SubMiner.exe` to PATH.
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -243,28 +192,29 @@ See the [build-from-source guide](https://docs.subminer.moe/installation#from-so
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
### 2. First Launch
|
### 2. Launch & Set Up
|
||||||
|
|
||||||
|
Run SubMiner and the first-run setup wizard will guide you through importing Yomitan dictionaries and optionally installing the `subminer` command-line launcher.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
subminer app --setup # launch the first-run setup wizard
|
# Linux
|
||||||
|
subminer app --setup
|
||||||
|
|
||||||
|
# macOS — open SubMiner.app, or:
|
||||||
|
subminer app --setup
|
||||||
```
|
```
|
||||||
|
|
||||||
SubMiner creates a default config, starts in the system tray, and opens a setup popup that walks you through installing Yomitan dictionaries. The setup popup can also optionally install Bun and the `subminer` command-line launcher; those choices do not block setup completion.
|
On **Windows**, just run `SubMiner.exe` and the setup will open automatically on first launch.
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> On Windows, run `SubMiner.exe` directly — it opens the setup wizard automatically on first launch.
|
|
||||||
|
|
||||||
### 3. Mine
|
### 3. Mine
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
subminer video.mkv # play video with overlay
|
subminer video.mkv # launch mpv with SubMiner
|
||||||
subminer --start video.mkv # explicit overlay start
|
subminer /path/to/dir # pick a file with fzf
|
||||||
subminer stats # open immersion dashboard
|
subminer -R /path/to/dir # pick a file with rofi (Linux only)
|
||||||
subminer stats -b # stats daemon in background
|
|
||||||
subminer stats -s # stop background stats daemon
|
|
||||||
```
|
```
|
||||||
|
|
||||||
On **Windows**, use the **SubMiner mpv** shortcut created during first-run setup — double-click it to open mpv, or drag a video file onto it. You can also run `SubMiner.exe --launch-mpv` from a terminal.
|
On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: jellyfin
|
||||||
|
|
||||||
|
- Fixed the Jellyfin setup popup login path on Windows by using an IPC bridge, showing immediate login progress, and timing out unreachable server login attempts with an inline error.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Settings: Changed the AniSkip button key setting to use click-to-learn key capture instead of raw text entry.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: launcher
|
||||||
|
|
||||||
|
- Reused an already-running background SubMiner app for launcher-opened videos, closed launcher-owned tray apps after playback ends, and reapplied preferred subtitles for warm launches.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: character-dictionary
|
||||||
|
|
||||||
|
- Reused cached character-dictionary media matches so loading a title with an existing snapshot no longer sends another AniList search request.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Updated the generated example config to use the same CSS declaration paths written by the Settings window for subtitle and sidebar appearance.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Preserved user config files during legacy config compatibility handling.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Reorganized the Settings window into clearer Appearance, Behavior, Anki, input, and integration sections with learned keybinding controls and AnkiConnect-backed deck, field, and note-type pickers.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: settings
|
||||||
|
|
||||||
|
- Simplified configuration option rows by hiding raw config paths and placing the live/restart status beside each option title.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Fixed Settings window search so it searches across all categories, narrows on multi-word terms, hides settings owned by richer editors, and no longer shows the Open File button.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
type: added
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Added a dedicated Settings window with launcher entry points via `subminer --settings` and `subminer settings`.
|
||||||
|
- Fixed the Settings window preload so launcher-opened windows can initialize even when Electron sandboxing is active.
|
||||||
|
- Kept settings-window startup lightweight by skipping AniList token refresh and automatic update polling.
|
||||||
|
- Marked safe live config options in the Settings window and expanded hot reload for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, Anki media/sentence/misc field mappings, sentence card model, and selected Anki annotation/runtime options.
|
||||||
|
- Hid AI and translation fields from the Settings window while keeping them supported in config files.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Controller config and debug shortcuts now stay closed while controller support is disabled and show a notice to enable `controller.enabled` manually.
|
||||||
|
- Controller binding rows now start learn mode from the edit pencil, so clicking edit and pressing a controller button saves the remap.
|
||||||
|
- Controller remaps are now saved per controller profile, binding badges also start learn mode, and row reset buttons restore individual bindings to their defaults.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Defaulted Jellyfin remote-session startup warmup and character-name subtitle highlighting to off.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: docs
|
||||||
|
area: docs
|
||||||
|
|
||||||
|
- Published stable docs at the site root with current development docs under `/main/`.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
type: fixed
|
||||||
|
area: anilist
|
||||||
|
|
||||||
|
- Used fresh mpv time-position, duration, and subtitle timing events for AniList post-watch threshold checks so progress updates still fire when playback reaches or skips past the watched threshold.
|
||||||
|
- Prefer season-specific AniList search results for multi-season files before falling back to the base title.
|
||||||
|
- Show a clear AniList message when the matched season is not in Planning or Watching instead of silently queueing an impossible progress update.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Primed the first startup subtitle before autoplay resumes so the overlay can render text before video playback begins.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: launcher
|
||||||
|
|
||||||
|
- Kept launcher-opened videos paused when attaching to an already-running background app until subtitle priming and tokenization readiness complete.
|
||||||
|
- Moved mpv plugin subtitle auto-selection to pre-load so launch-time subtitle choices are not reset after the video opens.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: subtitles
|
||||||
|
|
||||||
|
- Kept frequency highlighting for determiner-led noun compounds like `その場` while still filtering standalone determiners.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Reorganized each known-words deck row in the Settings window into a card with the deck name on its own header line so longer deck names stay readable instead of being truncated.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: launcher
|
||||||
|
|
||||||
|
- Suppressed Electron macOS menu diagnostics from `subminer settings` launcher output.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: shortcuts
|
||||||
|
|
||||||
|
- Disabled native mpv menu shortcuts during managed macOS playback so configured SubMiner shortcuts also work while mpv has focus.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Hid the macOS visible overlay when mpv is no longer the foreground target so other apps and Spaces are not covered by SubMiner subtitles.
|
||||||
|
- Kept the macOS overlay layered above active mpv while stats mouse passthrough is enabled, and treated the frontmost mpv app as the focus signal.
|
||||||
|
- Opened the stats overlay inactive on macOS so it appears over fullscreen mpv instead of switching back to SubMiner's original desktop.
|
||||||
|
- Preserved the active mpv focus state through transient macOS helper misses so subtitles do not flicker while mpv remains foreground.
|
||||||
|
- Kept fullscreen macOS overlays stable when mpv remains frontmost but window geometry temporarily disappears from the macOS window APIs.
|
||||||
|
- Released the macOS overlay when the helper reports mpv is no longer foreground so other apps are no longer covered.
|
||||||
|
- Reduced macOS window-tracker background work by preferring the compiled helper and slowing polls while mpv is stably focused.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: playback
|
||||||
|
|
||||||
|
- Fixed managed mpv startup so launcher-owned videos quit SubMiner when playback ends, background/tray sessions stay alive, and pause-until-ready waits for the overlay and tokenization readiness before playback resumes.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: docs
|
||||||
|
|
||||||
|
- Fixed versioned docs navigation so archived pages keep local links under the selected version, the version switcher no longer nests targets under the current archive path, local dev version routes serve warmed archive files instead of redirecting to production or falling through to VitePress 404s, and internal README files do not break archived builds.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: setup
|
||||||
|
|
||||||
|
- Setup: Removed the bundled mpv runtime plugin readiness card; legacy mpv plugin removal still appears when needed.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Config: Moved known-word and N+1 annotation colors to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`; legacy Anki color keys are still accepted with warnings.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: added
|
||||||
|
area: launcher
|
||||||
|
|
||||||
|
- Added `subminer --version` and `subminer -v` to print the installed SubMiner app version.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: changed
|
||||||
|
area: updater
|
||||||
|
|
||||||
|
- Linux tray "Check for Updates" now installs the new AppImage automatically via `electron-updater`, matching the macOS and Windows tray flow, instead of stopping at a "manual update required" dialog. AppImages managed by a system package (AUR `/opt/SubMiner/SubMiner.AppImage`) and non-AppImage launches (no `APPIMAGE` env) still fall back to the GitHub-asset flow.
|
||||||
|
- Routed `electron-updater` HTTP through `/usr/bin/curl` on Linux and disabled differential downloads, matching the macOS path, so background update checks stay off Electron's network service.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: updater
|
||||||
|
|
||||||
|
- Fixed Linux automatic update checks to avoid Electron networking, preventing native Electron network-service crashes during video startup.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: launcher
|
||||||
|
|
||||||
|
- macOS `subminer settings` launches now exit cleanly after the settings window is closed, returning control to the terminal without requiring Ctrl+C.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: updater
|
||||||
|
|
||||||
|
- Fixed tray update checks for builds that cannot install native app updates, showing a manual install message instead of a restart prompt that cannot apply the update.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: updater
|
||||||
|
|
||||||
|
- macOS update dialogs triggered by `subminer -u` now reliably appear in the foreground. SubMiner now shows the dock icon and activates itself via `osascript` (LaunchServices) before opening the modal alert; `app.focus({ steal: true })` alone was unreliable when SubMiner was reached through single-instance forwarding from the CLI-spawned child, leaving the dialog stranded behind other apps with a bouncing dock icon.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: updater
|
||||||
|
|
||||||
|
- Routed macOS supplemental GitHub release lookups through `/usr/bin/curl` instead of Electron `net.fetch`, eliminating the last Electron-networking path from background update checks and avoiding the network-service crashes seen in earlier prereleases.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: added
|
||||||
|
area: launcher
|
||||||
|
|
||||||
|
- Managed bundled mpv plugin startup options from SubMiner config.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Wired configured session shortcuts, including `stats.markWatchedKey`, through mpv so custom add/remove changes work while mpv has focus.
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: updates
|
area: updates
|
||||||
|
|
||||||
- Avoided native `electron-updater` checks where they are unsafe, so tray and background update checks continue through GitHub release metadata without crashing the app.
|
- Restored the standard macOS `electron-updater`/Squirrel update path and routed supplemental GitHub updater requests through Electron networking instead of Node fetch.
|
||||||
|
- macOS update checks now skip local build-output apps outside Applications before touching Squirrel, and macOS tray checks no longer perform the supplemental GitHub asset lookup.
|
||||||
|
- macOS `electron-updater` metadata and full ZIP downloads now use `/usr/bin/curl` under the hood to avoid the Electron network crash seen during tray update checks while preserving Squirrel installation.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Defaulted the note-fields note type picker to the configured Anki deck's note type when available, then exact `Kiku`, then exact `Lapis`, otherwise leaving it blank for manual selection.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Config: Preserved N+1 subtitle highlighting for existing configs that already enabled known-word highlighting, while keeping N+1 disabled by default for new configs unless `ankiConnect.nPlusOne.enabled` is set.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Kept the visible overlay and subtitle stream alive after restarting SubMiner from the mpv `y-r` shortcut by transporting Linux AppImage control args safely, restoring mpv subtitle visibility during shutdown, snapshotting subtitles before overlay suppression resumes, reapplying Linux overlay bounds after the restarted window maps, allowing Hyprland to resize the visible overlay window, and preserving user-paused playback while readiness gates clear.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: websocket
|
||||||
|
|
||||||
|
- WebSocket: Kept the regular subtitle websocket plain-text only; annotation spans and token metadata now stay on the annotation websocket.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: release
|
||||||
|
|
||||||
|
- Prerelease note generation now reuses existing reviewed prerelease notes and asks Claude to merge only new fragment material, while `make clean` preserves `release/prerelease-notes.md`.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: jellyfin
|
||||||
|
|
||||||
|
- Removed the Jellyfin setup server presets dropdown; setup now shows a single editable server URL field.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
type: changed
|
||||||
|
area: launcher
|
||||||
|
breaking: true
|
||||||
|
|
||||||
|
- Renamed the SubMiner Configuration window to the Settings window across the UI, tray menu, docs, and CLI verbiage.
|
||||||
|
- Replaced the `--config` flag and `subminer config` (no action) entry points with `--settings` and `subminer settings`. The `subminer config` subcommand now only accepts `path` or `show`.
|
||||||
|
- Removed the `--settings` alias that previously opened the bundled Yomitan settings popup. Use `--yomitan` to open Yomitan settings.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Settings: reorganized playback, shortcut, WebSocket, tracking, Jellyfin, character dictionary, and Discord presence controls in the settings modal.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: added
|
||||||
|
area: setup
|
||||||
|
|
||||||
|
- Setup: Added an Open SubMiner Settings button to first-run setup and moved Finish setup to the right-side action slot.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: subtitles
|
||||||
|
|
||||||
|
- Subsync now always opens the manual picker and the `subsync.defaultMode` config/settings option has been removed.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Config: Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Migrated legacy subtitle hover token colors into `subtitleStyle.css` instead of leaving `hoverTokenColor` or `hoverTokenBackgroundColor` behind.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Migrated legacy primary and secondary subtitle appearance options into `subtitleStyle.css` automatically when loading config files.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Fixed live Settings window saves so primary and secondary subtitle CSS declarations apply immediately to open video overlays.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: config
|
||||||
|
|
||||||
|
- Added `subtitleSidebar.css`, migrated legacy sidebar appearance fields into it, and updated subtitle font defaults to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: windows
|
||||||
|
|
||||||
|
- Windows startup failures now show a native error dialog and write fatal details to the SubMiner app log instead of exiting silently.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: updates
|
||||||
|
|
||||||
|
- Windows automatic updates now keep the native `electron-updater`/NSIS install path enabled while routing updater HTTP through main-process fetch, avoiding the delayed app exit seen shortly after launch without requiring `curl.exe`.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed Yomitan popups not opening when playback/overlay startup races the Yomitan extension load.
|
||||||
+135
-116
@@ -7,10 +7,11 @@
|
|||||||
{
|
{
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Overlay Auto-Start
|
// Visible Overlay Auto-Start
|
||||||
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
|
||||||
|
// SubMiner can still auto-start in the background when this is false.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false
|
"auto_start_overlay": true, // Show the visible subtitle overlay automatically when the bundled mpv plugin starts SubMiner. Values: true | false
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Texthooker Server
|
// Texthooker Server
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"texthooker": {
|
"texthooker": {
|
||||||
"launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false
|
"launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false
|
||||||
"openBrowser": false // Open browser setting. Values: true | false
|
"openBrowser": false // Open the texthooker page in the default browser when the server starts. Values: true | false
|
||||||
}, // Configure texthooker startup launch and browser opening behavior.
|
}, // Configure texthooker startup launch and browser opening behavior.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
// Logging
|
// Logging
|
||||||
// Controls logging verbosity.
|
// Controls logging verbosity.
|
||||||
// Set to debug for full runtime diagnostics.
|
// Set to debug for full runtime diagnostics.
|
||||||
|
// Hot-reload: logging.level applies live while SubMiner is running.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"logging": {
|
"logging": {
|
||||||
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
||||||
@@ -138,7 +140,8 @@
|
|||||||
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
||||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||||
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
|
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||||
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
||||||
|
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
|
||||||
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -152,7 +155,7 @@
|
|||||||
"mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false
|
"mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false
|
||||||
"yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false
|
"yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false
|
||||||
"subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false
|
"subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false
|
||||||
"jellyfinRemoteSession": true // Warm up Jellyfin remote session at startup. Values: true | false
|
"jellyfinRemoteSession": false // Warm up Jellyfin remote session at startup. Values: true | false
|
||||||
}, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
}, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -173,24 +176,24 @@
|
|||||||
// Hot-reload: shortcut changes apply live and update the session help modal on reopen.
|
// Hot-reload: shortcut changes apply live and update the session help modal on reopen.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
|
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Global accelerator that toggles overlay visibility from anywhere on the system. Use null to disable.
|
||||||
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
|
"copySubtitle": "CommandOrControl+C", // Accelerator that copies the current subtitle line to the clipboard.
|
||||||
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
|
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Accelerator that copies consecutive subtitle lines while the multi-copy window stays open.
|
||||||
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
|
"updateLastCardFromClipboard": "CommandOrControl+V", // Accelerator that updates the last mined Anki card using the current clipboard contents.
|
||||||
"triggerFieldGrouping": "CommandOrControl+G", // Trigger field grouping setting.
|
"triggerFieldGrouping": "CommandOrControl+G", // Accelerator that triggers Kiku field grouping on duplicate cards.
|
||||||
"triggerSubsync": "Ctrl+Alt+S", // Trigger subsync setting.
|
"triggerSubsync": "Ctrl+Alt+S", // Accelerator that triggers subsync against the active subtitle file.
|
||||||
"mineSentence": "CommandOrControl+S", // Mine sentence setting.
|
"mineSentence": "CommandOrControl+S", // Accelerator that mines the current sentence as a new Anki card.
|
||||||
"mineSentenceMultiple": "CommandOrControl+Shift+S", // Mine sentence multiple setting.
|
"mineSentenceMultiple": "CommandOrControl+Shift+S", // Accelerator that mines consecutive sentences while the multi-mine window stays open.
|
||||||
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
|
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
|
||||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
"toggleSecondarySub": "CommandOrControl+Shift+V", // Accelerator that toggles the secondary subtitle bar visibility.
|
||||||
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
"markAudioCard": "CommandOrControl+Shift+A", // Accelerator that marks the last mined card as an audio card.
|
||||||
"openCharacterDictionary": "CommandOrControl+Alt+A", // Open character dictionary setting.
|
"openCharacterDictionary": "CommandOrControl+Alt+A", // Accelerator that opens the character dictionary modal.
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||||
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
|
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||||
"openSessionHelp": "CommandOrControl+Slash", // Open session help setting.
|
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||||
"openControllerSelect": "Alt+C", // Open controller select setting.
|
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||||
"openControllerDebug": "Alt+Shift+C", // Open controller debug setting.
|
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
|
||||||
"toggleSubtitleSidebar": "Backslash" // Toggle subtitle sidebar setting.
|
"toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility.
|
||||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -327,20 +330,20 @@
|
|||||||
// Hot-reload: defaultMode updates live while SubMiner is running.
|
// Hot-reload: defaultMode updates live while SubMiner is running.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"secondarySub": {
|
"secondarySub": {
|
||||||
"secondarySubLanguages": [], // Secondary sub languages setting.
|
"secondarySubLanguages": [], // Language code priority list used to auto-select a secondary subtitle track when available.
|
||||||
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
"autoLoadSecondarySub": false, // Automatically load a matching secondary subtitle when the primary subtitle loads. Values: true | false
|
||||||
"defaultMode": "hover" // Default mode setting.
|
"defaultMode": "hover" // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover
|
||||||
}, // Dual subtitle track options.
|
}, // Dual subtitle track options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Auto Subtitle Sync
|
// Subtitle Sync
|
||||||
// Subsync engine and executable paths.
|
// Subsync engine and executable paths.
|
||||||
|
// Hot-reload: subsync changes apply to the next subtitle sync run.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"subsync": {
|
"subsync": {
|
||||||
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
|
"alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH.
|
||||||
"alass_path": "", // Alass path setting.
|
"ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH.
|
||||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
"ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH.
|
||||||
"ffmpeg_path": "", // Ffmpeg path setting.
|
|
||||||
"replace": true // Replace the active subtitle file when sync completes. Values: true | false
|
"replace": true // Replace the active subtitle file when sync completes. Values: true | false
|
||||||
}, // Subsync engine and executable paths.
|
}, // Subsync engine and executable paths.
|
||||||
|
|
||||||
@@ -349,7 +352,7 @@
|
|||||||
// Initial vertical subtitle position from the bottom.
|
// Initial vertical subtitle position from the bottom.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"subtitlePosition": {
|
"subtitlePosition": {
|
||||||
"yPercent": 10 // Y percent setting.
|
"yPercent": 10 // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen.
|
||||||
}, // Initial vertical subtitle position from the bottom.
|
}, // Initial vertical subtitle position from the bottom.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -359,29 +362,31 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"subtitleStyle": {
|
"subtitleStyle": {
|
||||||
"primaryDefaultMode": "visible", // Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover. Values: hidden | visible | hover
|
"primaryDefaultMode": "visible", // Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover. Values: hidden | visible | hover
|
||||||
|
"css": {
|
||||||
|
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||||
|
"color": "#cad3f5", // Color setting.
|
||||||
|
"background-color": "transparent", // Background color setting.
|
||||||
|
"font-size": "35px", // Font size setting.
|
||||||
|
"font-weight": "600", // Font weight setting.
|
||||||
|
"font-style": "normal", // Font style setting.
|
||||||
|
"line-height": "1.35", // Line height setting.
|
||||||
|
"letter-spacing": "-0.01em", // Letter spacing setting.
|
||||||
|
"word-spacing": "0", // Word spacing setting.
|
||||||
|
"font-kerning": "normal", // Font kerning setting.
|
||||||
|
"text-rendering": "geometricPrecision", // Text rendering setting.
|
||||||
|
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
|
||||||
|
"backdrop-filter": "blur(6px)", // Backdrop filter setting.
|
||||||
|
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting.
|
||||||
|
"--subtitle-hover-token-background-color": "transparent" // Subtitle hover token background color setting.
|
||||||
|
}, // CSS declaration object applied to primary subtitles after normal subtitle style defaults.
|
||||||
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
||||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
||||||
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
||||||
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
"nameMatchEnabled": false, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
|
||||||
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
|
|
||||||
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
|
|
||||||
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
||||||
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
||||||
"fontSize": 35, // Font size setting.
|
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
||||||
"fontColor": "#cad3f5", // Font color setting.
|
|
||||||
"fontWeight": "600", // Font weight setting.
|
|
||||||
"lineHeight": 1.35, // Line height setting.
|
|
||||||
"letterSpacing": "-0.01em", // Letter spacing setting.
|
|
||||||
"wordSpacing": 0, // Word spacing setting.
|
|
||||||
"fontKerning": "normal", // Font kerning setting.
|
|
||||||
"textRendering": "geometricPrecision", // Text rendering setting.
|
|
||||||
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
|
|
||||||
"fontStyle": "normal", // Font style setting.
|
|
||||||
"backgroundColor": "transparent", // Background color setting.
|
|
||||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
|
||||||
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
|
|
||||||
"knownWordColor": "#a6da95", // Known word color setting.
|
|
||||||
"jlptColors": {
|
"jlptColors": {
|
||||||
"N1": "#ed8796", // N1 setting.
|
"N1": "#ed8796", // N1 setting.
|
||||||
"N2": "#f5a97f", // N2 setting.
|
"N2": "#f5a97f", // N2 setting.
|
||||||
@@ -405,19 +410,21 @@
|
|||||||
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||||
}, // Frequency dictionary setting.
|
}, // Frequency dictionary setting.
|
||||||
"secondary": {
|
"secondary": {
|
||||||
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
|
"css": {
|
||||||
"fontSize": 24, // Font size setting.
|
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||||
"fontColor": "#cad3f5", // Font color setting.
|
"color": "#cad3f5", // Color setting.
|
||||||
"lineHeight": 1.35, // Line height setting.
|
"background-color": "transparent", // Background color setting.
|
||||||
"letterSpacing": "-0.01em", // Letter spacing setting.
|
"font-size": "24px", // Font size setting.
|
||||||
"wordSpacing": 0, // Word spacing setting.
|
"font-weight": "600", // Font weight setting.
|
||||||
"fontKerning": "normal", // Font kerning setting.
|
"font-style": "normal", // Font style setting.
|
||||||
"textRendering": "geometricPrecision", // Text rendering setting.
|
"line-height": "1.35", // Line height setting.
|
||||||
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
|
"letter-spacing": "-0.01em", // Letter spacing setting.
|
||||||
"backgroundColor": "transparent", // Background color setting.
|
"word-spacing": "0", // Word spacing setting.
|
||||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
"font-kerning": "normal", // Font kerning setting.
|
||||||
"fontWeight": "600", // Font weight setting.
|
"text-rendering": "geometricPrecision", // Text rendering setting.
|
||||||
"fontStyle": "normal" // Font style setting.
|
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
|
||||||
|
"backdrop-filter": "blur(6px)" // Backdrop filter setting.
|
||||||
|
} // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
|
||||||
} // Secondary setting.
|
} // Secondary setting.
|
||||||
}, // Primary and secondary subtitle styling.
|
}, // Primary and secondary subtitle styling.
|
||||||
|
|
||||||
@@ -431,18 +438,20 @@
|
|||||||
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
|
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
|
||||||
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
|
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
|
||||||
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
|
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
|
||||||
"pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
|
"pauseVideoOnHover": true, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
|
||||||
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
|
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
|
||||||
"maxWidth": 420, // Maximum sidebar width in CSS pixels.
|
"css": {
|
||||||
"opacity": 0.95, // Base opacity applied to the sidebar shell.
|
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||||
"backgroundColor": "rgba(73, 77, 100, 0.9)", // Background color for the subtitle sidebar shell.
|
"color": "#cad3f5", // Color setting.
|
||||||
"textColor": "#cad3f5", // Default cue text color in the subtitle sidebar.
|
"background-color": "rgba(73, 77, 100, 0.9)", // Background color setting.
|
||||||
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif", // Font family used for subtitle sidebar cue text.
|
"font-size": "16px", // Font size setting.
|
||||||
"fontSize": 16, // Base font size for subtitle sidebar cue text in CSS pixels.
|
"opacity": "0.95", // Opacity setting.
|
||||||
"timestampColor": "#a5adcb", // Timestamp color in the subtitle sidebar.
|
"--subtitle-sidebar-max-width": "420px", // Subtitle sidebar max width setting.
|
||||||
"activeLineColor": "#f5bde6", // Text color for the active subtitle cue.
|
"--subtitle-sidebar-timestamp-color": "#a5adcb", // Subtitle sidebar timestamp color setting.
|
||||||
"activeLineBackgroundColor": "rgba(138, 173, 244, 0.22)", // Background color for the active subtitle cue.
|
"--subtitle-sidebar-active-line-color": "#f5bde6", // Subtitle sidebar active line color setting.
|
||||||
"hoverLineBackgroundColor": "rgba(54, 58, 79, 0.84)" // Background color for hovered subtitle cues.
|
"--subtitle-sidebar-active-background-color": "rgba(138, 173, 244, 0.22)", // Subtitle sidebar active background color setting.
|
||||||
|
"--subtitle-sidebar-hover-background-color": "rgba(54, 58, 79, 0.84)" // Subtitle sidebar hover background color setting.
|
||||||
|
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
|
||||||
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
|
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -453,22 +462,22 @@
|
|||||||
"enabled": false, // Enable shared OpenAI-compatible AI provider features. Values: true | false
|
"enabled": false, // Enable shared OpenAI-compatible AI provider features. Values: true | false
|
||||||
"apiKey": "", // Static API key for the shared OpenAI-compatible AI provider.
|
"apiKey": "", // Static API key for the shared OpenAI-compatible AI provider.
|
||||||
"apiKeyCommand": "", // Shell command used to resolve the shared AI provider API key.
|
"apiKeyCommand": "", // Shell command used to resolve the shared AI provider API key.
|
||||||
"model": "openai/gpt-4o-mini", // Model setting.
|
"model": "openai/gpt-4o-mini", // Default model identifier requested from the shared AI provider.
|
||||||
"baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider.
|
"baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider.
|
||||||
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting.
|
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // Default system prompt sent with shared AI provider requests.
|
||||||
"requestTimeoutMs": 15000 // Timeout in milliseconds for shared AI provider requests.
|
"requestTimeoutMs": 15000 // Timeout in milliseconds for shared AI provider requests.
|
||||||
}, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
|
}, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// AnkiConnect Integration
|
// AnkiConnect Integration
|
||||||
// Automatic Anki updates and media generation options.
|
// Automatic Anki updates and media generation options.
|
||||||
// Hot-reload: ankiConnect.ai.enabled updates live while SubMiner is running.
|
// Hot-reload: ankiConnect.ai.enabled, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
||||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||||
// Most other AnkiConnect settings still require restart.
|
// Most other AnkiConnect settings still require restart.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"ankiConnect": {
|
"ankiConnect": {
|
||||||
"enabled": true, // Enable AnkiConnect integration. Values: true | false
|
"enabled": true, // Enable AnkiConnect integration. Values: true | false
|
||||||
"url": "http://127.0.0.1:8765", // Url setting.
|
"url": "http://127.0.0.1:8765", // Base URL of the AnkiConnect HTTP server.
|
||||||
"pollingRate": 3000, // Polling interval in milliseconds.
|
"pollingRate": 3000, // Polling interval in milliseconds.
|
||||||
"proxy": {
|
"proxy": {
|
||||||
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
|
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
|
||||||
@@ -481,11 +490,11 @@
|
|||||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||||
"fields": {
|
"fields": {
|
||||||
"word": "Expression", // Card field for the mined word or expression text.
|
"word": "Expression", // Card field for the mined word or expression text.
|
||||||
"audio": "ExpressionAudio", // Audio setting.
|
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||||
"image": "Picture", // Image setting.
|
"image": "Picture", // Card field that receives the captured screenshot or animated image.
|
||||||
"sentence": "Sentence", // Sentence setting.
|
"sentence": "Sentence", // Card field that receives the source sentence text.
|
||||||
"miscInfo": "MiscInfo", // Misc info setting.
|
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
|
||||||
"translation": "SelectionText" // Translation setting.
|
"translation": "SelectionText" // Card field that receives the current selection or translated text.
|
||||||
}, // Fields setting.
|
}, // Fields setting.
|
||||||
"ai": {
|
"ai": {
|
||||||
"enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false
|
"enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false
|
||||||
@@ -493,59 +502,59 @@
|
|||||||
"systemPrompt": "" // Optional system prompt override for Anki AI translation/enrichment flows.
|
"systemPrompt": "" // Optional system prompt override for Anki AI translation/enrichment flows.
|
||||||
}, // Ai setting.
|
}, // Ai setting.
|
||||||
"media": {
|
"media": {
|
||||||
"generateAudio": true, // Generate audio setting. Values: true | false
|
"generateAudio": true, // Generate sentence audio for mined cards. Values: true | false
|
||||||
"generateImage": true, // Generate image setting. Values: true | false
|
"generateImage": true, // Generate screenshot or animated image for mined cards. Values: true | false
|
||||||
"imageType": "static", // Image type setting.
|
"imageType": "static", // Image capture type: "static" for a single still frame, "avif" for an animated AVIF. Values: static | avif
|
||||||
"imageFormat": "jpg", // Image format setting.
|
"imageFormat": "jpg", // Encoding format used when imageType is "static". Values: jpg | png | webp
|
||||||
"imageQuality": 92, // Image quality setting.
|
"imageQuality": 92, // Quality (0-100) used for lossy static image encoders.
|
||||||
"animatedFps": 10, // Animated fps setting.
|
"animatedFps": 10, // Target frame rate for animated AVIF captures.
|
||||||
"animatedMaxWidth": 640, // Animated max width setting.
|
"animatedMaxWidth": 640, // Maximum width applied to animated AVIF captures.
|
||||||
"animatedCrf": 35, // Animated crf setting.
|
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||||
"audioPadding": 0.5, // Audio padding setting.
|
"audioPadding": 0.5, // Seconds of padding appended to both ends of generated sentence audio.
|
||||||
"fallbackDuration": 3, // Fallback duration setting.
|
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||||
"maxMediaDuration": 30 // Max media duration setting.
|
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||||
}, // Media setting.
|
}, // Media setting.
|
||||||
"knownWords": {
|
"knownWords": {
|
||||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
|
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
|
||||||
"decks": {}, // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
|
"decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
|
||||||
"color": "#a6da95" // Color used for known-word highlights.
|
|
||||||
}, // Known words setting.
|
}, // Known words setting.
|
||||||
"behavior": {
|
"behavior": {
|
||||||
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
||||||
"overwriteImage": true, // Overwrite image setting. Values: true | false
|
"overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false
|
||||||
"mediaInsertMode": "append", // Media insert mode setting.
|
"mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend
|
||||||
"highlightWord": true, // Highlight word setting. Values: true | false
|
"highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false
|
||||||
"notificationType": "osd", // Notification type setting.
|
"notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none
|
||||||
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
||||||
}, // Behavior setting.
|
}, // Behavior setting.
|
||||||
"nPlusOne": {
|
"nPlusOne": {
|
||||||
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
|
"enabled": false, // Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data. Values: true | false
|
||||||
"nPlusOne": "#c6a0f6" // Color used for the single N+1 target token highlight.
|
"minSentenceWords": 3 // Minimum sentence word count required for N+1 targeting (default: 3).
|
||||||
}, // N plus one setting.
|
}, // N plus one setting.
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"pattern": "[SubMiner] %f (%t)" // Pattern setting.
|
"pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).
|
||||||
}, // Metadata setting.
|
}, // Metadata setting.
|
||||||
"isLapis": {
|
"isLapis": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
|
||||||
"sentenceCardModel": "Japanese sentences" // Sentence card model setting.
|
"sentenceCardModel": "Lapis" // Note type name used by Lapis sentence cards.
|
||||||
}, // Is lapis setting.
|
}, // Is lapis setting.
|
||||||
"isKiku": {
|
"isKiku": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||||
"deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
|
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||||
} // Is kiku setting.
|
} // Is kiku setting.
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Jimaku
|
// Jimaku
|
||||||
// Jimaku API configuration and defaults.
|
// Jimaku API configuration and defaults.
|
||||||
|
// Hot-reload: Jimaku changes apply to the next Jimaku request.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"jimaku": {
|
"jimaku": {
|
||||||
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
|
||||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
||||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
||||||
}, // Jimaku API configuration and defaults.
|
}, // Jimaku API configuration and defaults.
|
||||||
@@ -553,6 +562,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// YouTube Playback Settings
|
// YouTube Playback Settings
|
||||||
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||||
|
// Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"youtube": {
|
"youtube": {
|
||||||
"primarySubLanguages": [
|
"primarySubLanguages": [
|
||||||
@@ -597,14 +607,23 @@
|
|||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// MPV Launcher
|
// MPV Launcher
|
||||||
// Optional mpv.exe override for Windows playback entry points.
|
// SubMiner-managed mpv launch and bundled plugin options.
|
||||||
|
// Set mpv.socketPath to the IPC socket used by the launcher, Electron app, and bundled plugin.
|
||||||
|
// autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.
|
||||||
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
|
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
|
||||||
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"mpv": {
|
"mpv": {
|
||||||
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||||
"launchMode": "normal" // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
||||||
}, // Optional mpv.exe override for Windows playback entry points.
|
"socketPath": "/tmp/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
|
||||||
|
"subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.
|
||||||
|
"aniskipEnabled": true, // Enable AniSkip intro detection and skip markers in the bundled mpv plugin. Values: true | false
|
||||||
|
"aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible.
|
||||||
|
}, // SubMiner-managed mpv launch and bundled plugin options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Jellyfin
|
// Jellyfin
|
||||||
@@ -617,9 +636,9 @@
|
|||||||
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
||||||
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
|
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
|
||||||
"username": "", // Default Jellyfin username used during CLI login.
|
"username": "", // Default Jellyfin username used during CLI login.
|
||||||
"deviceId": "subminer", // Device id setting.
|
"deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.
|
||||||
"clientName": "SubMiner", // Client name setting.
|
"clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal.
|
||||||
"clientVersion": "0.1.0", // Client version setting.
|
"clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal.
|
||||||
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
|
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
|
||||||
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
|
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
|
||||||
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
|
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
|
||||||
@@ -647,7 +666,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"discordPresence": {
|
"discordPresence": {
|
||||||
"enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false
|
"enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false
|
||||||
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".
|
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". Values: default | meme | japanese | minimal
|
||||||
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
||||||
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
||||||
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
||||||
|
|||||||
+355
-65
@@ -1,3 +1,7 @@
|
|||||||
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||||
|
import { extname, join, posix, resolve, sep } from 'node:path';
|
||||||
|
import type { DefaultTheme, HeadConfig, TransformContext, UserConfig } from 'vitepress';
|
||||||
|
|
||||||
const DOCS_HOSTNAME = 'https://docs.subminer.moe';
|
const DOCS_HOSTNAME = 'https://docs.subminer.moe';
|
||||||
const PLAUSIBLE_PROXY_HOSTNAME = 'https://worker.sudacode.com';
|
const PLAUSIBLE_PROXY_HOSTNAME = 'https://worker.sudacode.com';
|
||||||
const PLAUSIBLE_SITE_SCRIPT_PATH = '/js/pa-h28Pn9ppgTJRmiSJlyPT6.js';
|
const PLAUSIBLE_SITE_SCRIPT_PATH = '/js/pa-h28Pn9ppgTJRmiSJlyPT6.js';
|
||||||
@@ -7,20 +11,358 @@ const PLAUSIBLE_INIT_SCRIPT = [
|
|||||||
`plausible.init({ endpoint: '${PLAUSIBLE_ENDPOINT}' });`,
|
`plausible.init({ endpoint: '${PLAUSIBLE_ENDPOINT}' });`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
function pageToCanonicalHref(page: string): string | null {
|
type DocsChannel = 'stable-root' | 'stable-archive' | 'main';
|
||||||
|
|
||||||
|
type VersionManifest = {
|
||||||
|
latestStable: string;
|
||||||
|
channels: Array<{ label: string; path: string }>;
|
||||||
|
versions: Array<{ version: string; path: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const base = normalizeBase(process.env.SUBMINER_DOCS_BASE ?? '/');
|
||||||
|
const outDir = process.env.SUBMINER_DOCS_OUT_DIR;
|
||||||
|
const docsSourceDir = process.env.SUBMINER_DOCS_SOURCE_DIR ?? process.cwd();
|
||||||
|
const localArchiveDir = resolve(
|
||||||
|
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR ??
|
||||||
|
join(docsSourceDir, '..', '.tmp/docs-versioned-site'),
|
||||||
|
);
|
||||||
|
const channel = normalizeChannel(process.env.SUBMINER_DOCS_CHANNEL);
|
||||||
|
const docsVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||||
|
const latestStable = process.env.SUBMINER_DOCS_LATEST_STABLE ?? 'v0.14.0';
|
||||||
|
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
|
||||||
|
const versionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN ?? 'production';
|
||||||
|
|
||||||
|
function normalizeBase(value: string): string {
|
||||||
|
if (!value || value === '/') return '/';
|
||||||
|
return `/${value.replace(/^\/+|\/+$/g, '')}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeChannel(value: string | undefined): DocsChannel {
|
||||||
|
if (value === 'main' || value === 'stable-archive') return value;
|
||||||
|
return 'stable-root';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVersionManifest(value: string | undefined): VersionManifest {
|
||||||
|
if (!value) {
|
||||||
|
return {
|
||||||
|
latestStable,
|
||||||
|
channels: [
|
||||||
|
{ label: 'Latest stable', path: '/' },
|
||||||
|
{ label: 'main', path: '/main/' },
|
||||||
|
],
|
||||||
|
versions: [{ version: latestStable, path: `/v/${latestStable.replace(/^v/, '')}/` }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(value) as VersionManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withDocsBase(path: string): string {
|
||||||
|
if (/^[a-z]+:\/\//i.test(path)) return path;
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
if (base === '/') return normalizedPath;
|
||||||
|
return `${base.replace(/\/$/, '')}${normalizedPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageToRoute(page: string): string | null {
|
||||||
if (page === '404.md') return null;
|
if (page === '404.md') return null;
|
||||||
|
|
||||||
const route = page
|
const route = page
|
||||||
.replace(/(^|\/)index\.md$/, '')
|
.replace(/(^|\/)index\.md$/, '')
|
||||||
.replace(/\.md$/, '')
|
.replace(/\.md$/, '')
|
||||||
.replace(/\/$/, '');
|
.replace(/\/$/, '');
|
||||||
return route ? `${DOCS_HOSTNAME}/${route}` : `${DOCS_HOSTNAME}/`;
|
return route ? `/${route}` : '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
function pageToCanonicalHref(page: string): string | null {
|
||||||
|
const route = pageToRoute(page);
|
||||||
|
if (!route) return null;
|
||||||
|
|
||||||
|
if (channel === 'main') {
|
||||||
|
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel === 'stable-archive' && docsVersion !== latestStable) {
|
||||||
|
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return route === '/' ? `${DOCS_HOSTNAME}/` : `${DOCS_HOSTNAME}${route}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalRouteWithBase(route: string): string {
|
||||||
|
const routeWithBase = withDocsBase(route);
|
||||||
|
return route === '/' ? routeWithBase : routeWithBase.replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformPageHead({ page }: TransformContext): HeadConfig[] {
|
||||||
|
const href = pageToCanonicalHref(page);
|
||||||
|
const head: HeadConfig[] = href ? [['link', { rel: 'canonical', href }]] : [];
|
||||||
|
|
||||||
|
if (channel === 'main') {
|
||||||
|
head.push(['meta', { name: 'robots', content: 'noindex,follow' }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return head;
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkToPagePath(link: string): string | null {
|
||||||
|
if (!link.startsWith('/') || link.startsWith('/v/') || link.startsWith('/main/')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const withoutHash = link.split('#')[0] ?? '/';
|
||||||
|
const withoutQuery = withoutHash.split('?')[0] ?? '/';
|
||||||
|
const route = withoutQuery.replace(/^\/+|\/+$/g, '');
|
||||||
|
return route ? `${route}.md` : 'index.md';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPageForLink(link: string): boolean {
|
||||||
|
const pagePath = linkToPagePath(link);
|
||||||
|
if (!pagePath) return true;
|
||||||
|
return existsSync(join(docsSourceDir, pagePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterNav(items: DefaultTheme.NavItem[]): DefaultTheme.NavItem[] {
|
||||||
|
return items
|
||||||
|
.map((item) => {
|
||||||
|
if ('items' in item && item.items) {
|
||||||
|
return { ...item, items: filterNav(item.items as DefaultTheme.NavItem[]) };
|
||||||
|
}
|
||||||
|
if ('link' in item && item.link && !hasPageForLink(item.link)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
.filter((item): item is DefaultTheme.NavItem => Boolean(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterSidebar(items: DefaultTheme.SidebarItem[]): DefaultTheme.SidebarItem[] {
|
||||||
|
return items
|
||||||
|
.map((item) => {
|
||||||
|
const filteredChildren = item.items ? filterSidebar(item.items) : undefined;
|
||||||
|
if (item.link && !hasPageForLink(item.link)) return null;
|
||||||
|
if (item.items && filteredChildren?.length === 0 && !item.link) return null;
|
||||||
|
return { ...item, items: filteredChildren };
|
||||||
|
})
|
||||||
|
.filter((item): item is DefaultTheme.SidebarItem => Boolean(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionSwitchLink(path: string): string {
|
||||||
|
if (/^[a-z]+:\/\//i.test(path)) return path;
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
if (versionLinkOrigin === 'local') return localVersionSwitchLink(normalizedPath);
|
||||||
|
return `${DOCS_HOSTNAME}${normalizedPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localVersionSwitchLink(path: string): string {
|
||||||
|
if (base === '/') return path;
|
||||||
|
|
||||||
|
const basePath = base.replace(/\/$/, '');
|
||||||
|
const targetPath = path === '/' ? '/' : path.replace(/\/$/, '');
|
||||||
|
const relativePath = posix.relative(basePath, targetPath) || '.';
|
||||||
|
|
||||||
|
return path.endsWith('/') ? `${relativePath}/` : relativePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldHandleLocalVersionRoute(pathname: string): boolean {
|
||||||
|
if (base !== '/' || channel !== 'stable-root') return false;
|
||||||
|
return /^\/main(?:\/|$)/.test(pathname) || /^\/v\/[^/]+(?:\/|$)/.test(pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentTypeForPath(path: string): string {
|
||||||
|
switch (extname(path)) {
|
||||||
|
case '.css':
|
||||||
|
return 'text/css; charset=utf-8';
|
||||||
|
case '.gif':
|
||||||
|
return 'image/gif';
|
||||||
|
case '.ico':
|
||||||
|
return 'image/x-icon';
|
||||||
|
case '.jpg':
|
||||||
|
case '.jpeg':
|
||||||
|
return 'image/jpeg';
|
||||||
|
case '.js':
|
||||||
|
case '.mjs':
|
||||||
|
return 'text/javascript; charset=utf-8';
|
||||||
|
case '.json':
|
||||||
|
case '.jsonc':
|
||||||
|
return 'application/json; charset=utf-8';
|
||||||
|
case '.mp4':
|
||||||
|
return 'video/mp4';
|
||||||
|
case '.png':
|
||||||
|
return 'image/png';
|
||||||
|
case '.svg':
|
||||||
|
return 'image/svg+xml';
|
||||||
|
case '.ttf':
|
||||||
|
return 'font/ttf';
|
||||||
|
case '.webm':
|
||||||
|
return 'video/webm';
|
||||||
|
case '.woff':
|
||||||
|
return 'font/woff';
|
||||||
|
case '.woff2':
|
||||||
|
return 'font/woff2';
|
||||||
|
case '.xml':
|
||||||
|
return 'application/xml; charset=utf-8';
|
||||||
|
default:
|
||||||
|
return 'text/html; charset=utf-8';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFile(path: string): boolean {
|
||||||
|
try {
|
||||||
|
return statSync(path).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function archiveFileForPathname(pathname: string): string | null {
|
||||||
|
if (!shouldHandleLocalVersionRoute(pathname)) return null;
|
||||||
|
|
||||||
|
const routePath = decodeURIComponent(pathname).replace(/^\/+/, '');
|
||||||
|
const filePath = resolve(localArchiveDir, routePath);
|
||||||
|
if (filePath !== localArchiveDir && !filePath.startsWith(`${localArchiveDir}${sep}`)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = pathname.endsWith('/')
|
||||||
|
? [join(filePath, 'index.html')]
|
||||||
|
: extname(filePath)
|
||||||
|
? [filePath]
|
||||||
|
: [`${filePath}.html`, join(filePath, 'index.html')];
|
||||||
|
|
||||||
|
return candidates.find(isFile) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveLocalArchiveRoute(pathname: string, response: DevServerResponse): boolean {
|
||||||
|
if (versionLinkOrigin !== 'local') return false;
|
||||||
|
|
||||||
|
const filePath = archiveFileForPathname(pathname);
|
||||||
|
if (!filePath) return false;
|
||||||
|
|
||||||
|
response.statusCode = 200;
|
||||||
|
response.setHeader('Content-Type', contentTypeForPath(filePath));
|
||||||
|
response.end(readFileSync(filePath));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DevServerResponse = {
|
||||||
|
statusCode: number;
|
||||||
|
setHeader(name: string, value: string): void;
|
||||||
|
end(chunk?: string | Uint8Array): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const versionItems = [
|
||||||
|
{
|
||||||
|
text: `Latest stable (${versionManifest.latestStable})`,
|
||||||
|
link: versionSwitchLink('/'),
|
||||||
|
target: '_self',
|
||||||
|
noIcon: true,
|
||||||
|
},
|
||||||
|
...versionManifest.channels
|
||||||
|
.filter((entry) => entry.label !== 'Latest stable')
|
||||||
|
.map((entry) => ({
|
||||||
|
text: entry.label,
|
||||||
|
link: versionSwitchLink(entry.path),
|
||||||
|
target: '_self',
|
||||||
|
noIcon: true,
|
||||||
|
})),
|
||||||
|
...versionManifest.versions.map((entry) => ({
|
||||||
|
text: entry.version,
|
||||||
|
link: versionSwitchLink(entry.path),
|
||||||
|
target: '_self',
|
||||||
|
noIcon: true,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const nav: DefaultTheme.NavItem[] = [
|
||||||
|
{ text: 'Home', link: '/' },
|
||||||
|
{ text: 'Get Started', link: '/installation' },
|
||||||
|
{ text: 'Mining', link: '/mining-workflow' },
|
||||||
|
{ text: 'Configuration', link: '/configuration' },
|
||||||
|
{ text: 'Changelog', link: '/changelog' },
|
||||||
|
{ text: 'Troubleshooting', link: '/troubleshooting' },
|
||||||
|
{ text: docsVersion ?? (channel === 'main' ? 'main' : latestStable), items: versionItems },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sidebar: DefaultTheme.SidebarItem[] = [
|
||||||
|
{
|
||||||
|
text: 'Getting Started',
|
||||||
|
items: [
|
||||||
|
{ text: 'Overview', link: '/' },
|
||||||
|
{ text: 'Installation', link: '/installation' },
|
||||||
|
{ text: 'Usage', link: '/usage' },
|
||||||
|
{ text: 'Mining Workflow', link: '/mining-workflow' },
|
||||||
|
{ text: 'Launcher Script', link: '/launcher-script' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Reference',
|
||||||
|
items: [
|
||||||
|
{ text: 'Configuration', link: '/configuration' },
|
||||||
|
{ text: 'Keyboard Shortcuts', link: '/shortcuts' },
|
||||||
|
{ text: 'Subtitle Annotations', link: '/subtitle-annotations' },
|
||||||
|
{ text: 'Subtitle Sidebar', link: '/subtitle-sidebar' },
|
||||||
|
{ text: 'Immersion Tracking', link: '/immersion-tracking' },
|
||||||
|
{ text: 'Troubleshooting', link: '/troubleshooting' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Integrations',
|
||||||
|
items: [
|
||||||
|
{ text: 'MPV Plugin', link: '/mpv-plugin' },
|
||||||
|
{ text: 'Anki', link: '/anki-integration' },
|
||||||
|
{ text: 'Jellyfin', link: '/jellyfin-integration' },
|
||||||
|
{ text: 'YouTube', link: '/youtube-integration' },
|
||||||
|
{ text: 'Jimaku', link: '/jimaku-integration' },
|
||||||
|
{ text: 'AniList', link: '/anilist-integration' },
|
||||||
|
{ text: 'Character Dictionary', link: '/character-dictionary' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Development',
|
||||||
|
items: [
|
||||||
|
{ text: 'Building & Testing', link: '/development' },
|
||||||
|
{ text: 'Architecture', link: '/architecture' },
|
||||||
|
{ text: 'IPC + Runtime Contracts', link: '/ipc-contracts' },
|
||||||
|
{ text: 'WebSocket + Texthooker API', link: '/websocket-texthooker-api' },
|
||||||
|
{ text: 'Changelog', link: '/changelog' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const config: UserConfig = {
|
||||||
title: 'SubMiner Docs',
|
title: 'SubMiner Docs',
|
||||||
description:
|
description:
|
||||||
'SubMiner: an MPV immersion-mining overlay with Yomitan and AnkiConnect integration.',
|
'SubMiner: an MPV immersion-mining overlay with Yomitan and AnkiConnect integration.',
|
||||||
|
base,
|
||||||
|
...(outDir ? { outDir } : {}),
|
||||||
|
vite: {
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: 'subminer-docs-local-version-redirects',
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((request, response, next) => {
|
||||||
|
const requestUrl = new URL(request.url ?? '/', 'http://localhost');
|
||||||
|
if (serveLocalArchiveRoute(requestUrl.pathname, response)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldHandleLocalVersionRoute(requestUrl.pathname)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.statusCode = 302;
|
||||||
|
response.setHeader(
|
||||||
|
'Location',
|
||||||
|
`${DOCS_HOSTNAME}${requestUrl.pathname}${requestUrl.search}`,
|
||||||
|
);
|
||||||
|
response.end();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
head: [
|
head: [
|
||||||
['link', { rel: 'preconnect', href: PLAUSIBLE_PROXY_HOSTNAME }],
|
['link', { rel: 'preconnect', href: PLAUSIBLE_PROXY_HOSTNAME }],
|
||||||
[
|
[
|
||||||
@@ -31,13 +373,13 @@ export default {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
['script', {}, PLAUSIBLE_INIT_SCRIPT],
|
['script', {}, PLAUSIBLE_INIT_SCRIPT],
|
||||||
['link', { rel: 'icon', href: '/favicon.ico', sizes: 'any' }],
|
['link', { rel: 'icon', href: withDocsBase('/favicon.ico'), sizes: 'any' }],
|
||||||
[
|
[
|
||||||
'link',
|
'link',
|
||||||
{
|
{
|
||||||
rel: 'icon',
|
rel: 'icon',
|
||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
href: '/favicon-32x32.png',
|
href: withDocsBase('/favicon-32x32.png'),
|
||||||
sizes: '32x32',
|
sizes: '32x32',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -46,7 +388,7 @@ export default {
|
|||||||
{
|
{
|
||||||
rel: 'icon',
|
rel: 'icon',
|
||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
href: '/favicon-16x16.png',
|
href: withDocsBase('/favicon-16x16.png'),
|
||||||
sizes: '16x16',
|
sizes: '16x16',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -54,7 +396,7 @@ export default {
|
|||||||
'link',
|
'link',
|
||||||
{
|
{
|
||||||
rel: 'apple-touch-icon',
|
rel: 'apple-touch-icon',
|
||||||
href: '/apple-touch-icon.png',
|
href: withDocsBase('/apple-touch-icon.png'),
|
||||||
sizes: '180x180',
|
sizes: '180x180',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -70,12 +412,9 @@ export default {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
transformHead({ page }) {
|
transformHead: transformPageHead,
|
||||||
const href = pageToCanonicalHref(page);
|
|
||||||
return href ? [['link', { rel: 'canonical', href }]] : [];
|
|
||||||
},
|
|
||||||
lastUpdated: true,
|
lastUpdated: true,
|
||||||
srcExclude: ['subagents/**'],
|
srcExclude: ['subagents/**', 'README.md'],
|
||||||
markdown: {
|
markdown: {
|
||||||
theme: {
|
theme: {
|
||||||
light: 'catppuccin-latte',
|
light: 'catppuccin-latte',
|
||||||
@@ -88,59 +427,8 @@ export default {
|
|||||||
dark: '/assets/SubMiner.png',
|
dark: '/assets/SubMiner.png',
|
||||||
},
|
},
|
||||||
siteTitle: 'SubMiner Docs',
|
siteTitle: 'SubMiner Docs',
|
||||||
nav: [
|
nav: filterNav(nav),
|
||||||
{ text: 'Home', link: '/' },
|
sidebar: filterSidebar(sidebar),
|
||||||
{ text: 'Get Started', link: '/installation' },
|
|
||||||
{ text: 'Mining', link: '/mining-workflow' },
|
|
||||||
{ text: 'Configuration', link: '/configuration' },
|
|
||||||
{ text: 'Changelog', link: '/changelog' },
|
|
||||||
{ text: 'Troubleshooting', link: '/troubleshooting' },
|
|
||||||
],
|
|
||||||
sidebar: [
|
|
||||||
{
|
|
||||||
text: 'Getting Started',
|
|
||||||
items: [
|
|
||||||
{ text: 'Overview', link: '/' },
|
|
||||||
{ text: 'Installation', link: '/installation' },
|
|
||||||
{ text: 'Usage', link: '/usage' },
|
|
||||||
{ text: 'Mining Workflow', link: '/mining-workflow' },
|
|
||||||
{ text: 'Launcher Script', link: '/launcher-script' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Reference',
|
|
||||||
items: [
|
|
||||||
{ text: 'Configuration', link: '/configuration' },
|
|
||||||
{ text: 'Keyboard Shortcuts', link: '/shortcuts' },
|
|
||||||
{ text: 'Subtitle Annotations', link: '/subtitle-annotations' },
|
|
||||||
{ text: 'Subtitle Sidebar', link: '/subtitle-sidebar' },
|
|
||||||
{ text: 'Immersion Tracking', link: '/immersion-tracking' },
|
|
||||||
{ text: 'Troubleshooting', link: '/troubleshooting' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Integrations',
|
|
||||||
items: [
|
|
||||||
{ text: 'MPV Plugin', link: '/mpv-plugin' },
|
|
||||||
{ text: 'Anki', link: '/anki-integration' },
|
|
||||||
{ text: 'Jellyfin', link: '/jellyfin-integration' },
|
|
||||||
{ text: 'YouTube', link: '/youtube-integration' },
|
|
||||||
{ text: 'Jimaku', link: '/jimaku-integration' },
|
|
||||||
{ text: 'AniList', link: '/anilist-integration' },
|
|
||||||
{ text: 'Character Dictionary', link: '/character-dictionary' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Development',
|
|
||||||
items: [
|
|
||||||
{ text: 'Building & Testing', link: '/development' },
|
|
||||||
{ text: 'Architecture', link: '/architecture' },
|
|
||||||
{ text: 'IPC + Runtime Contracts', link: '/ipc-contracts' },
|
|
||||||
{ text: 'WebSocket + Texthooker API', link: '/websocket-texthooker-api' },
|
|
||||||
{ text: 'Changelog', link: '/changelog' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
search: {
|
search: {
|
||||||
provider: 'local',
|
provider: 'local',
|
||||||
},
|
},
|
||||||
@@ -159,3 +447,5 @@ export default {
|
|||||||
socialLinks: [{ icon: 'github', link: 'https://github.com/ksyasuda/SubMiner' }],
|
socialLinks: [{ icon: 'github', link: 'https://github.com/ksyasuda/SubMiner' }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useRoute, useData } from 'vitepress';
|
import { useRoute, useData } from 'vitepress';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import { formatStatusLineFilePath } from '../status-line';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { page, frontmatter } = useData();
|
const { page, frontmatter } = useData();
|
||||||
@@ -12,8 +13,7 @@ const mode = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const filePath = computed(() => {
|
const filePath = computed(() => {
|
||||||
const path = route.path;
|
return formatStatusLineFilePath(route.path);
|
||||||
return path === '/' ? 'index.md' : `${path.replace(/^\//, '')}.md`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const section = computed(() => {
|
const section = computed(() => {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
import { formatStatusLineFilePath } from './status-line';
|
||||||
|
|
||||||
|
test('status line file path formats root home as index markdown', () => {
|
||||||
|
expect(formatStatusLineFilePath('/')).toBe('index.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status line file path formats version archive home without trailing slash', () => {
|
||||||
|
expect(formatStatusLineFilePath('/v/0.12.0/')).toBe('v/0.12.0.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status line file path keeps normal docs routes as markdown files', () => {
|
||||||
|
expect(formatStatusLineFilePath('/v/0.12.0/configuration')).toBe(
|
||||||
|
'v/0.12.0/configuration.md',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export function formatStatusLineFilePath(routePath: string): string {
|
||||||
|
if (routePath === '/') return 'index.md';
|
||||||
|
return `${routePath.replace(/^\/|\/$/g, '')}.md`;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'M PLUS 1';
|
font-family: 'M PLUS 1';
|
||||||
src: url('/assets/fonts/Mplus1-Medium.ttf') format('truetype');
|
src: url('../../public/assets/fonts/Mplus1-Medium.ttf') format('truetype');
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Manrope Default';
|
font-family: 'Manrope Default';
|
||||||
src: url('/assets/fonts/manrope-latin-600-normal.ttf') format('truetype');
|
src: url('../../public/assets/fonts/manrope-latin-600-normal.ttf') format('truetype');
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
|
|||||||
+12
-5
@@ -30,9 +30,16 @@ bun run docs:dev
|
|||||||
## Cloudflare Pages
|
## Cloudflare Pages
|
||||||
|
|
||||||
- Git repo: `ksyasuda/SubMiner`
|
- Git repo: `ksyasuda/SubMiner`
|
||||||
- Root directory: `docs-site`
|
- Production branch: `main`
|
||||||
- Build command: `bun run docs:build`
|
- Automatic production and preview deployments: disabled
|
||||||
- Build output directory: `.vitepress/dist`
|
- Custom domain: `docs.subminer.moe` attached to Production
|
||||||
- Build watch paths: `docs-site/*`
|
- Deployment path: GitHub Actions direct upload with Wrangler
|
||||||
|
|
||||||
Cloudflare Pages watch paths use a single `*` wildcard for monorepo subdirectories. `docs-site/*` matches nested files under the docs site; `docs-site/**` can cause docs-only pushes to be skipped.
|
The public docs root is stable-only:
|
||||||
|
|
||||||
|
- `/` serves the latest stable release docs.
|
||||||
|
- `/main/` serves development docs from `main` and is marked `noindex,follow`.
|
||||||
|
- `/v/<version>/` serves stable release archives.
|
||||||
|
- Prerelease tags do not update the docs site.
|
||||||
|
|
||||||
|
Keep Cloudflare Git auto-deploy disabled. The production deploy is `.github/workflows/docs-pages.yml`, which uploads `.tmp/docs-versioned-site` with `--branch main` so tag-triggered runs update Production instead of creating preview deployments.
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ AniList integration is opt-in. To enable it:
|
|||||||
{
|
{
|
||||||
"anilist": {
|
"anilist": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"accessToken": ""
|
"accessToken": "",
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -37,20 +37,20 @@ SubMiner monitors playback and triggers an AniList progress update when an episo
|
|||||||
The update flow:
|
The update flow:
|
||||||
|
|
||||||
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename. It tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
|
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename. It tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
|
||||||
2. **AniList search** -- The detected title is searched against the AniList GraphQL API. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count.
|
2. **AniList search** -- The detected title is searched against the AniList GraphQL API. For season 2 and later files, SubMiner searches the season-specific title first, then falls back to the base title. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count.
|
||||||
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
|
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
|
||||||
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`.
|
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`.
|
||||||
|
|
||||||
## Update Queue and Retry
|
## Update Queue and Retry
|
||||||
|
|
||||||
Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff.
|
Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff.
|
||||||
|
|
||||||
| Parameter | Value |
|
| Parameter | Value |
|
||||||
| --- | --- |
|
| ---------------- | ---------- |
|
||||||
| Initial backoff | 30 seconds |
|
| Initial backoff | 30 seconds |
|
||||||
| Maximum backoff | 6 hours |
|
| Maximum backoff | 6 hours |
|
||||||
| Maximum attempts | 8 |
|
| Maximum attempts | 8 |
|
||||||
| Queue capacity | 500 items |
|
| Queue capacity | 500 items |
|
||||||
|
|
||||||
After 8 failed attempts, the update is moved to a dead-letter queue and no longer retried automatically. The queue is persisted across restarts so no updates are lost if SubMiner exits before a retry succeeds.
|
After 8 failed attempts, the update is moved to a dead-letter queue and no longer retried automatically. The queue is persisted across restarts so no updates are lost if SubMiner exits before a retry succeeds.
|
||||||
|
|
||||||
@@ -85,36 +85,37 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w
|
|||||||
"collapsibleSections": {
|
"collapsibleSections": {
|
||||||
"description": false,
|
"description": false,
|
||||||
"characterInformation": false,
|
"characterInformation": false,
|
||||||
"voicedBy": false
|
"voicedBy": false,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| --- | --- | --- |
|
| ------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||||
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
||||||
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
|
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
|
||||||
| `characterDictionary.enabled` | `true`, `false` | Enable auto-sync of the merged character dictionary from AniList (default: `false`) |
|
| `characterDictionary.enabled` | `true`, `false` | Enable auto-sync of the merged character dictionary from AniList (default: `false`) |
|
||||||
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
|
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
|
||||||
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
|
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one |
|
||||||
| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
|
| `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded |
|
||||||
|
|
||||||
See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format.
|
See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format.
|
||||||
|
|
||||||
## CLI Commands
|
## CLI Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| --- | --- |
|
| ----------------------- | ------------------------------------------------------------- |
|
||||||
| `--anilist-setup` | Open AniList setup/auth flow helper window |
|
| `--anilist-setup` | Open AniList setup/auth flow helper window |
|
||||||
| `--anilist-status` | Print current token resolution state and retry queue counters |
|
| `--anilist-status` | Print current token resolution state and retry queue counters |
|
||||||
| `--anilist-logout` | Clear stored AniList token from local persisted state |
|
| `--anilist-logout` | Clear stored AniList token from local persisted state |
|
||||||
| `--anilist-retry-queue` | Process one ready retry queue item immediately |
|
| `--anilist-retry-queue` | Process one ready retry queue item immediately |
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
- **Updates not triggering:** Confirm `anilist.enabled` is `true`. SubMiner requires at least 85% of the episode watched and a minimum of 10 minutes. Short episodes or partial watches will not trigger an update.
|
- **Updates not triggering:** Confirm `anilist.enabled` is `true`. SubMiner requires at least 85% of the episode watched and a minimum of 10 minutes. Short episodes or partial watches will not trigger an update.
|
||||||
|
- **Update not possible:** Add the season to your AniList Planning or Watching list first. SubMiner will not create new AniList list entries automatically.
|
||||||
- **Wrong episode or title matched:** Detection quality is best when `guessit` is installed and on your `PATH`. Without it, SubMiner falls back to internal filename parsing which can be less accurate with unusual naming conventions.
|
- **Wrong episode or title matched:** Detection quality is best when `guessit` is installed and on your `PATH`. Without it, SubMiner falls back to internal filename parsing which can be less accurate with unusual naming conventions.
|
||||||
- **Token issues:** Run `--anilist-status` to check token state. If the token is invalid or expired, run `--anilist-setup` or `--anilist-logout` and re-authenticate.
|
- **Token issues:** Run `--anilist-status` to check token state. If the token is invalid or expired, run `--anilist-setup` or `--anilist-logout` and re-authenticate.
|
||||||
- **Updates failing repeatedly:** Run `--anilist-status` to see retry queue counters. Items that fail 8 times are moved to the dead-letter queue. Check network connectivity and AniList API status.
|
- **Updates failing repeatedly:** Run `--anilist-status` to see retry queue counters. Items that fail 8 times are moved to the dead-letter queue. Check network connectivity and AniList API status.
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ Character dictionary sync is disabled by default. To turn it on:
|
|||||||
```
|
```
|
||||||
|
|
||||||
::: tip
|
::: tip
|
||||||
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached snapshot.
|
The first sync for a media title takes a few seconds while character data and portraits are fetched from AniList. Subsequent launches reuse the cached media match and snapshot without a fresh AniList lookup.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
::: warning
|
::: warning
|
||||||
@@ -88,7 +88,7 @@ Name matching runs inside Yomitan's scanning pipeline during subtitle tokenizati
|
|||||||
1. Yomitan receives subtitle text and scans for dictionary matches.
|
1. Yomitan receives subtitle text and scans for dictionary matches.
|
||||||
2. Entries from "SubMiner Character Dictionary" are checked with exact primary-source matching — the token must match the entry's `originalText` with `isPrimary: true` and `matchType: 'exact'`.
|
2. Entries from "SubMiner Character Dictionary" are checked with exact primary-source matching — the token must match the entry's `originalText` with `isPrimary: true` and `matchType: 'exact'`.
|
||||||
3. Matched tokens are flagged `isNameMatch: true` and forwarded to the renderer.
|
3. Matched tokens are flagged `isNameMatch: true` and forwarded to the renderer.
|
||||||
4. The renderer applies the name-match highlight color (default: `#f5bde6`).
|
4. If `subtitleStyle.nameMatchEnabled` is enabled, the renderer applies the name-match highlight color (default: `#f5bde6`).
|
||||||
|
|
||||||
Name matches are visually distinct from [N+1 targeting, frequency highlighting, and JLPT tags](/subtitle-annotations) so you can tell at a glance whether a highlighted word is a character name or a vocabulary target.
|
Name matches are visually distinct from [N+1 targeting, frequency highlighting, and JLPT tags](/subtitle-annotations) so you can tell at a glance whether a highlighted word is a character name or a vocabulary target.
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ Name matches are visually distinct from [N+1 targeting, frequency highlighting,
|
|||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| -------------------------------- | --------- | ---------------------------------- |
|
| -------------------------------- | --------- | ---------------------------------- |
|
||||||
| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting |
|
| `subtitleStyle.nameMatchEnabled` | `false` | Toggle character-name highlighting |
|
||||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
|
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
|
||||||
|
|
||||||
## Dictionary Entries
|
## Dictionary Entries
|
||||||
@@ -139,7 +139,7 @@ When `characterDictionary.enabled` is `true`, SubMiner runs an auto-sync routine
|
|||||||
5. **importing** — Push the ZIP into Yomitan. Waits for Yomitan mutation readiness (7-second timeout per operation).
|
5. **importing** — Push the ZIP into Yomitan. Waits for Yomitan mutation readiness (7-second timeout per operation).
|
||||||
6. **ready** — Dictionary is live. Character names will match on the next subtitle line.
|
6. **ready** — Dictionary is live. Character names will match on the next subtitle line.
|
||||||
|
|
||||||
**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`:
|
**State tracking** is persisted in `character-dictionaries/auto-sync-state.json`. AniList media matches are cached separately in `character-dictionaries/anilist-resolution-cache.json` so snapshot hits do not need another AniList search.
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
@@ -228,7 +228,7 @@ merged.zip
|
|||||||
| `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded |
|
| `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded |
|
||||||
| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded |
|
| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded |
|
||||||
| `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded |
|
| `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded |
|
||||||
| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting in subtitles |
|
| `subtitleStyle.nameMatchEnabled` | `false` | Toggle character-name highlighting in subtitles |
|
||||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches |
|
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches |
|
||||||
|
|
||||||
## Reference Implementation
|
## Reference Implementation
|
||||||
|
|||||||
+120
-49
@@ -4,9 +4,9 @@ outline: [2, 3]
|
|||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
|
|
||||||
Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` when `XDG_CONFIG_HOME` is unset).
|
<script setup>
|
||||||
On Windows, the default path is `%APPDATA%\SubMiner\config.jsonc`.
|
import { withBase } from 'vitepress';
|
||||||
When both files exist, SubMiner prefers `config.jsonc` over `config.json`.
|
</script>
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -35,9 +35,38 @@ For most users, start with this minimal configuration:
|
|||||||
|
|
||||||
Then customize as needed using the sections below.
|
Then customize as needed using the sections below.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
SubMiner includes a dedicated **Settings** window accessible from the tray menu, the app `--settings` flag, or launcher commands such as `subminer --settings` and `subminer settings`. It is the primary way to configure SubMiner — all changes are written directly to `config.jsonc`, so manual file editing is not required for most users.
|
||||||
|
|
||||||
|
The Settings window groups options by workflow instead of mirroring the raw config-file shape:
|
||||||
|
|
||||||
|
- Appearance
|
||||||
|
- Behavior
|
||||||
|
- Mining & Anki
|
||||||
|
- Playback & Sources
|
||||||
|
- Input
|
||||||
|
- Integrations
|
||||||
|
- Tracking & App
|
||||||
|
- Advanced
|
||||||
|
|
||||||
|
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names, and keybinding fields use click-to-learn controls instead of raw text boxes.
|
||||||
|
|
||||||
|
The Settings window preserves existing JSONC comments, trailing commas, unrelated keys, and unsupported legacy options. Resetting a field removes the explicit config path so the built-in default applies.
|
||||||
|
|
||||||
|
Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `ai.apiKeyCommand` when available.
|
||||||
|
|
||||||
|
Saving validates the candidate config before writing. Live-reloadable changes are applied immediately; other changes return a restart-required banner in the window.
|
||||||
|
|
||||||
## Configuration File
|
## Configuration File
|
||||||
|
|
||||||
See [config.example.jsonc](/config.example.jsonc) for a comprehensive example configuration file with all available options, default values, and detailed comments. Only include the options you want to customize in your config file.
|
The Settings window writes to `config.jsonc` directly, so most users do not need to edit the file by hand. The config file and the option reference below are provided for advanced use, scripting, or cases where you prefer editing config directly.
|
||||||
|
|
||||||
|
Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` when `XDG_CONFIG_HOME` is unset).
|
||||||
|
On Windows, the default path is `%APPDATA%\SubMiner\config.jsonc`.
|
||||||
|
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.
|
||||||
|
|
||||||
Generate a fresh default config from the centralized config registry:
|
Generate a fresh default config from the centralized config registry:
|
||||||
|
|
||||||
@@ -70,7 +99,28 @@ Hot-reloadable fields:
|
|||||||
- `keybindings`
|
- `keybindings`
|
||||||
- `shortcuts`
|
- `shortcuts`
|
||||||
- `secondarySub.defaultMode`
|
- `secondarySub.defaultMode`
|
||||||
- `ankiConnect.ai`
|
- `stats.toggleKey`
|
||||||
|
- `stats.markWatchedKey`
|
||||||
|
- `logging.level`
|
||||||
|
- `youtube.primarySubLanguages`
|
||||||
|
- `jimaku.*`
|
||||||
|
- `subsync.*`
|
||||||
|
- `ankiConnect.ai.enabled`
|
||||||
|
- `ankiConnect.behavior.autoUpdateNewCards`
|
||||||
|
- `ankiConnect.knownWords.highlightEnabled`
|
||||||
|
- `ankiConnect.knownWords.refreshMinutes`
|
||||||
|
- `ankiConnect.knownWords.addMinedWordsImmediately`
|
||||||
|
- `ankiConnect.knownWords.matchMode`
|
||||||
|
- `ankiConnect.knownWords.decks`
|
||||||
|
- `ankiConnect.nPlusOne.enabled`
|
||||||
|
- `ankiConnect.nPlusOne.minSentenceWords`
|
||||||
|
- `ankiConnect.fields.word`
|
||||||
|
- `ankiConnect.fields.audio`
|
||||||
|
- `ankiConnect.fields.image`
|
||||||
|
- `ankiConnect.fields.sentence`
|
||||||
|
- `ankiConnect.fields.miscInfo`
|
||||||
|
- `ankiConnect.isLapis.sentenceCardModel`
|
||||||
|
- `ankiConnect.isKiku.fieldGrouping`
|
||||||
|
|
||||||
When these values change, SubMiner applies them live. Invalid config edits are rejected and the previous valid runtime config remains active.
|
When these values change, SubMiner applies them live. Invalid config edits are rejected and the previous valid runtime config remains active.
|
||||||
|
|
||||||
@@ -78,6 +128,7 @@ Restart-required changes:
|
|||||||
|
|
||||||
- Any other config sections still require restart.
|
- Any other config sections still require restart.
|
||||||
- Shared top-level `ai` provider settings still require restart.
|
- Shared top-level `ai` provider settings still require restart.
|
||||||
|
- AnkiConnect transport/proxy/media/deck/tag fields still require restart unless listed above.
|
||||||
- SubMiner shows an on-screen/system notification listing restart-required sections when they change.
|
- SubMiner shows an on-screen/system notification listing restart-required sections when they change.
|
||||||
|
|
||||||
### Configuration Options Overview
|
### Configuration Options Overview
|
||||||
@@ -120,7 +171,7 @@ The configuration file includes several main sections:
|
|||||||
**External Integrations**
|
**External Integrations**
|
||||||
|
|
||||||
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
||||||
- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
|
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
|
||||||
- [**AniList**](#anilist) - Optional post-watch progress updates
|
- [**AniList**](#anilist) - Optional post-watch progress updates
|
||||||
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile via `yomitan.externalProfilePath`
|
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile via `yomitan.externalProfilePath`
|
||||||
- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
|
- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch
|
||||||
@@ -207,7 +258,7 @@ Control which startup warmups run in the background versus deferring to first re
|
|||||||
"mecab": true,
|
"mecab": true,
|
||||||
"yomitanExtension": true,
|
"yomitanExtension": true,
|
||||||
"subtitleDictionaries": true,
|
"subtitleDictionaries": true,
|
||||||
"jellyfinRemoteSession": true
|
"jellyfinRemoteSession": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -220,11 +271,11 @@ Control which startup warmups run in the background versus deferring to first re
|
|||||||
| `subtitleDictionaries` | `true`, `false` | Warm up JLPT + frequency dictionaries at startup |
|
| `subtitleDictionaries` | `true`, `false` | Warm up JLPT + frequency dictionaries at startup |
|
||||||
| `jellyfinRemoteSession` | `true`, `false` | Warm up Jellyfin remote session at startup (still requires Jellyfin remote auto-connect settings) |
|
| `jellyfinRemoteSession` | `true`, `false` | Warm up Jellyfin remote session at startup (still requires Jellyfin remote auto-connect settings) |
|
||||||
|
|
||||||
Defaults warm everything (`true` for all toggles, `lowPowerMode: false`). Setting a warmup toggle to `false` defers that work until first usage.
|
Defaults warm local tokenizer/dictionary work (`true` for `mecab`, `yomitanExtension`, and `subtitleDictionaries`) with `lowPowerMode: false`; Jellyfin remote session warmup is opt-in (`false` by default). Setting a warmup toggle to `false` defers that work until first usage.
|
||||||
|
|
||||||
### WebSocket Server
|
### WebSocket Server
|
||||||
|
|
||||||
The overlay includes a built-in WebSocket server that broadcasts subtitle text to connected clients (such as texthooker-ui) for external processing.
|
The overlay includes a built-in WebSocket server that broadcasts plain subtitle text to connected clients for external processing.
|
||||||
|
|
||||||
For endpoint details, payload examples, and client patterns, see [WebSocket / Texthooker API & Integration](/websocket-texthooker-api).
|
For endpoint details, payload examples, and client patterns, see [WebSocket / Texthooker API & Integration](/websocket-texthooker-api).
|
||||||
|
|
||||||
@@ -297,25 +348,29 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"subtitleStyle": {
|
"subtitleStyle": {
|
||||||
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
|
|
||||||
"fontSize": 35,
|
|
||||||
"fontColor": "#cad3f5",
|
"fontColor": "#cad3f5",
|
||||||
"fontWeight": "600",
|
|
||||||
"lineHeight": 1.35,
|
|
||||||
"letterSpacing": "-0.01em",
|
|
||||||
"wordSpacing": 0,
|
|
||||||
"fontKerning": "normal",
|
|
||||||
"textRendering": "geometricPrecision",
|
|
||||||
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)",
|
|
||||||
"fontStyle": "normal",
|
|
||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"backdropFilter": "blur(6px)",
|
"css": {
|
||||||
|
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
|
||||||
|
"font-size": "35px",
|
||||||
|
"font-weight": "600",
|
||||||
|
"line-height": "1.35",
|
||||||
|
"letter-spacing": "-0.01em",
|
||||||
|
"word-spacing": "0",
|
||||||
|
"font-kerning": "normal",
|
||||||
|
"text-rendering": "geometricPrecision",
|
||||||
|
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)",
|
||||||
|
"font-style": "normal",
|
||||||
|
"backdrop-filter": "blur(6px)"
|
||||||
|
},
|
||||||
"secondary": {
|
"secondary": {
|
||||||
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif",
|
|
||||||
"fontSize": 24,
|
|
||||||
"fontColor": "#cad3f5",
|
"fontColor": "#cad3f5",
|
||||||
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)",
|
"backgroundColor": "transparent",
|
||||||
"backgroundColor": "transparent"
|
"css": {
|
||||||
|
"font-family": "Inter, Noto Sans, Helvetica Neue, sans-serif",
|
||||||
|
"font-size": "24px",
|
||||||
|
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,6 +381,7 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
| `fontFamily` | string | CSS font-family value (default: `"Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`) |
|
| `fontFamily` | string | CSS font-family value (default: `"Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`) |
|
||||||
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
|
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
|
||||||
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
|
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
|
||||||
|
| `css` | object | CSS declarations applied to subtitles after normal style defaults; the settings window writes textbox edits here |
|
||||||
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) |
|
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) |
|
||||||
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
|
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
|
||||||
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"transparent"`) |
|
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"transparent"`) |
|
||||||
@@ -334,9 +390,11 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
||||||
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
|
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
|
||||||
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
|
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
|
||||||
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight; `hoverBackground` is accepted as an alias |
|
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: `"transparent"`); `hoverBackground` is accepted as an alias |
|
||||||
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`true` by default) |
|
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`false` by default) |
|
||||||
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
||||||
|
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
|
||||||
|
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
|
||||||
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
||||||
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
|
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
|
||||||
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |
|
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |
|
||||||
@@ -344,10 +402,13 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
|
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
|
||||||
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
||||||
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
|
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
|
||||||
| `nPlusOneColor` | string | Existing n+1 highlight color (default: `#c6a0f6`) |
|
|
||||||
| `knownWordColor` | string | Existing known-word highlight color (default: `#a6da95`) |
|
|
||||||
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
||||||
| `secondary` | object | Override any of the above for secondary subtitles (optional) |
|
| `secondary` | object | Override any of the above for secondary subtitles (optional), including `secondary.css` declarations |
|
||||||
|
|
||||||
|
The Settings window keeps subtitle color controls separate, then saves CSS textboxes to
|
||||||
|
`subtitleStyle.css`, `subtitleStyle.secondary.css`, and `subtitleSidebar.css`. The generated example
|
||||||
|
uses that same CSS declaration shape; existing top-level style keys such as `fontSize` and
|
||||||
|
`textShadow` remain supported for hand-written or older configs.
|
||||||
|
|
||||||
Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`.
|
Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`.
|
||||||
|
|
||||||
@@ -382,7 +443,7 @@ Configure the parsed-subtitle sidebar modal.
|
|||||||
"autoOpen": false,
|
"autoOpen": false,
|
||||||
"layout": "overlay",
|
"layout": "overlay",
|
||||||
"toggleKey": "Backslash",
|
"toggleKey": "Backslash",
|
||||||
"pauseVideoOnHover": false,
|
"pauseVideoOnHover": true,
|
||||||
"autoScroll": true,
|
"autoScroll": true,
|
||||||
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
|
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
|
||||||
"fontSize": 16
|
"fontSize": 16
|
||||||
@@ -396,7 +457,7 @@ Configure the parsed-subtitle sidebar modal.
|
|||||||
| `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) |
|
| `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) |
|
||||||
| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout |
|
| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout |
|
||||||
| `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) |
|
| `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) |
|
||||||
| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list |
|
| `pauseVideoOnHover` | boolean | Pause playback while hovering the sidebar cue list (`true` by default) |
|
||||||
| `autoScroll` | boolean | Keep the active cue in view while playback advances |
|
| `autoScroll` | boolean | Keep the active cue in view while playback advances |
|
||||||
| `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) |
|
| `maxWidth` | number | Maximum sidebar width in CSS pixels (default: `420`) |
|
||||||
| `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.95`) |
|
| `opacity` | number | Sidebar opacity between `0` and `1` (default: `0.95`) |
|
||||||
@@ -608,8 +669,12 @@ Important behavior:
|
|||||||
- Controller input is only active while keyboard-only mode is enabled.
|
- Controller input is only active while keyboard-only mode is enabled.
|
||||||
- Keyboard-only mode continues to work normally without a controller.
|
- Keyboard-only mode continues to work normally without a controller.
|
||||||
- By default SubMiner uses the first connected controller.
|
- By default SubMiner uses the first connected controller.
|
||||||
|
- Fresh installs keep controller support disabled until you set `controller.enabled` to `true`.
|
||||||
- `Alt+C` opens the controller config modal by default, and you can remap that shortcut through `shortcuts.openControllerSelect`.
|
- `Alt+C` opens the controller config modal by default, and you can remap that shortcut through `shortcuts.openControllerSelect`.
|
||||||
- Click `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
|
- The `Alt+C` config modal and `Alt+Shift+C` debug modal stay closed while controller support is disabled.
|
||||||
|
- Click the binding badge, edit pencil, or `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
|
||||||
|
- Click the reset button beside the edit pencil to restore one binding to the built-in default.
|
||||||
|
- Learned bindings are saved under `controller.profiles` for the selected controller id. Global `controller.bindings` remains the fallback for controllers without a profile.
|
||||||
- `Alt+Shift+C` opens the debug modal by default, and you can remap that shortcut through `shortcuts.openControllerDebug`.
|
- `Alt+Shift+C` opens the debug modal by default, and you can remap that shortcut through `shortcuts.openControllerDebug`.
|
||||||
- The debug modal shows raw axes/button values plus a ready-to-copy `buttonIndices` config block.
|
- The debug modal shows raw axes/button values plus a ready-to-copy `buttonIndices` config block.
|
||||||
- `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`.
|
- `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`.
|
||||||
@@ -658,6 +723,15 @@ Important behavior:
|
|||||||
"rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" },
|
"rightStickHorizontal": { "kind": "axis", "axisIndex": 3, "dpadFallback": "none" },
|
||||||
"rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" },
|
"rightStickVertical": { "kind": "axis", "axisIndex": 4, "dpadFallback": "none" },
|
||||||
},
|
},
|
||||||
|
"profiles": {
|
||||||
|
"Xbox Wireless Controller": {
|
||||||
|
"label": "Xbox Wireless Controller",
|
||||||
|
"bindings": {
|
||||||
|
"toggleLookup": { "kind": "button", "buttonIndex": 0 },
|
||||||
|
"mineCard": { "kind": "button", "buttonIndex": 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -678,7 +752,7 @@ Default logical mapping:
|
|||||||
- `L3`: toggle mpv pause
|
- `L3`: toggle mpv pause
|
||||||
- `L2` / `R2`: unbound by default
|
- `L2` / `R2`: unbound by default
|
||||||
|
|
||||||
Discrete bindings may use raw button indices or raw axis directions, and analog bindings use raw axis indices with optional D-pad fallback. The `Alt+C` learn flow writes those descriptors for you, so manual edits are only needed when you want to script or copy exact mappings.
|
Discrete bindings may use raw button indices or raw axis directions, and analog bindings use raw axis indices with optional D-pad fallback. The `Alt+C` learn flow writes those descriptors under `controller.profiles["<controller id>"]` for the selected controller. Manual edits are only needed when you want to script or copy exact mappings.
|
||||||
|
|
||||||
If you bind a discrete action to an axis manually, include `direction`:
|
If you bind a discrete action to an axis manually, include `direction`:
|
||||||
|
|
||||||
@@ -692,15 +766,15 @@ If you bind a discrete action to an axis manually, include `direction`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
|
Treat `controller.buttonIndices` as reference-only unless you are still using legacy semantic bindings or copying values from the debug modal. Updating `controller.buttonIndices` alone does not rewrite the hardcoded raw numeric values already present in `controller.bindings` or `controller.profiles.*.bindings`. If you need a real remap, prefer the `Alt+C` learn flow so both the source and the descriptor shape stay correct.
|
||||||
|
|
||||||
If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default.
|
If you choose to bind `L2` or `R2` manually, set `triggerInputMode` to `analog` and tune `triggerDeadzone` when your controller reports triggers as analog values instead of digital pressed/not-pressed buttons. `auto` accepts either style and remains the default.
|
||||||
|
|
||||||
If your controller reports non-standard raw button numbers, override `controller.buttonIndices` using values from the `Alt+Shift+C` debug modal.
|
If one controller reports non-standard raw button numbers, override `controller.profiles["<controller id>"].buttonIndices` using values from the `Alt+Shift+C` debug modal. Use global `controller.buttonIndices` only when the mapping should apply to every controller without a profile.
|
||||||
|
|
||||||
If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging.
|
If you update this controller documentation or the generated controller examples, run `bun run docs:test` and `bun run docs:build` before merging.
|
||||||
|
|
||||||
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
|
Tune `scrollPixelsPerSecond`, `horizontalJumpPixels`, deadzones, repeat timing, and profile `buttonIndices` to match your controller. See [config.example.jsonc](/config.example.jsonc) for the full generated comments for every controller field.
|
||||||
|
|
||||||
### Manual Card Update Shortcuts
|
### Manual Card Update Shortcuts
|
||||||
|
|
||||||
@@ -924,11 +998,10 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
||||||
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
|
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
|
||||||
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
|
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
|
||||||
| `ankiConnect.knownWords.color` | hex color string | Text color for tokens already found in the local known-word cache (default: `"#a6da95"`). |
|
|
||||||
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
||||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
|
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
|
||||||
| `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). |
|
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||||
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
|
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
|
||||||
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
|
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
|
||||||
@@ -970,9 +1043,9 @@ Known-word cache policy:
|
|||||||
|
|
||||||
- Initial sync runs when the integration starts if the cache is missing or stale.
|
- Initial sync runs when the integration starts if the cache is missing or stale.
|
||||||
- `ankiConnect.knownWords.refreshMinutes` controls the minimum time between refreshes; between refreshes, cached words are reused without querying Anki.
|
- `ankiConnect.knownWords.refreshMinutes` controls the minimum time between refreshes; between refreshes, cached words are reused without querying Anki.
|
||||||
- `ankiConnect.nPlusOne.nPlusOne` sets the color for the single target token when exactly one eligible unknown word exists.
|
- `subtitleStyle.nPlusOneColor` sets the color for the single target token when exactly one eligible unknown word exists.
|
||||||
- `ankiConnect.nPlusOne.minSentenceWords` sets the minimum token count required in a sentence for N+1 highlighting (default: `3`).
|
- `ankiConnect.nPlusOne.minSentenceWords` sets the minimum token count required in a sentence for N+1 highlighting (default: `3`).
|
||||||
- `ankiConnect.knownWords.color` sets the known-word highlight color for tokens already in Anki.
|
- `subtitleStyle.knownWordColor` sets the known-word highlight color for tokens already in Anki.
|
||||||
- `ankiConnect.knownWords.decks` accepts an object keyed by deck name. If omitted or empty, it falls back to the legacy `ankiConnect.deck` single-deck scope.
|
- `ankiConnect.knownWords.decks` accepts an object keyed by deck name. If omitted or empty, it falls back to the legacy `ankiConnect.deck` single-deck scope.
|
||||||
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
|
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
|
||||||
- The cache is automatically invalidated when the configured scope changes (for example, when deck changes).
|
- The cache is automatically invalidated when the configured scope changes (for example, when deck changes).
|
||||||
@@ -1009,12 +1082,12 @@ To refresh roughly once per day, set:
|
|||||||
`deleteDuplicateInAuto` controls whether `auto` mode deletes the duplicate after merge (default: `true`). In `manual` mode, the popup asks each time whether to delete the duplicate.
|
`deleteDuplicateInAuto` controls whether `auto` mode deletes the duplicate after merge (default: `true`). In `manual` mode, the popup asks each time whether to delete the duplicate.
|
||||||
When the manual merge popup opens, SubMiner pauses playback and closes any open Yomitan popup first so the merge flow can take focus.
|
When the manual merge popup opens, SubMiner pauses playback and closes any open Yomitan popup first so the merge flow can take focus.
|
||||||
|
|
||||||
<video controls playsinline preload="metadata" poster="/assets/kiku-integration-poster.jpg" style="width: 100%; max-width: 960px;">
|
<video controls playsinline preload="metadata" :poster="withBase('/assets/kiku-integration-poster.jpg')" style="width: 100%; max-width: 960px;">
|
||||||
<source :src="'/assets/kiku-integration.webm'" type="video/webm" />
|
<source :src="withBase('/assets/kiku-integration.webm')" type="video/webm" />
|
||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
</video>
|
</video>
|
||||||
|
|
||||||
<a :href="'/assets/kiku-integration.webm'" target="_blank" rel="noreferrer">Open demo in a new tab</a>
|
<a :href="withBase('/assets/kiku-integration.webm')" target="_blank" rel="noreferrer">Open demo in a new tab</a>
|
||||||
|
|
||||||
## External Integrations
|
## External Integrations
|
||||||
|
|
||||||
@@ -1038,17 +1111,16 @@ Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry dela
|
|||||||
|
|
||||||
Set `openBrowser` to `false` to only print the URL without opening a browser.
|
Set `openBrowser` to `false` to only print the URL without opening a browser.
|
||||||
|
|
||||||
### Auto Subtitle Sync
|
### Subtitle Sync
|
||||||
|
|
||||||
Sync the active subtitle track using `alass` (preferred) or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below). Subtitle syncing is silently skipped if neither is found.
|
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).
|
||||||
|
|
||||||
- [`alass`](https://github.com/kaegi/alass) — fast, audio-independent sync using a secondary subtitle as reference
|
- [`alass`](https://github.com/kaegi/alass) — fast, audio-independent sync using a secondary subtitle as reference
|
||||||
- [`ffsubsync`](https://github.com/smacke/ffsubsync) — audio-based sync using the video file as reference (fallback)
|
- [`ffsubsync`](https://github.com/smacke/ffsubsync) — audio-based sync using the video file as reference
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"subsync": {
|
"subsync": {
|
||||||
"defaultMode": "auto",
|
|
||||||
"alass_path": "",
|
"alass_path": "",
|
||||||
"ffsubsync_path": "",
|
"ffsubsync_path": "",
|
||||||
"ffmpeg_path": "",
|
"ffmpeg_path": "",
|
||||||
@@ -1059,7 +1131,6 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`. Both ar
|
|||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ---------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `defaultMode` | `"auto"`, `"manual"` | `auto`: try `alass` against secondary subtitle, then fallback to `ffsubsync`; `manual`: open overlay picker |
|
|
||||||
| `alass_path` | string path | Path to `alass` executable. Empty or `null` resolves from `PATH`. `alass` must be installed separately. |
|
| `alass_path` | string path | Path to `alass` executable. Empty or `null` resolves from `PATH`. `alass` must be installed separately. |
|
||||||
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` resolves from `PATH`. `ffsubsync` must be installed separately. |
|
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` resolves from `PATH`. `ffsubsync` must be installed separately. |
|
||||||
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
|
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
|
||||||
@@ -1216,7 +1287,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
|
|||||||
| `directPlayContainers` | string[] | Container allowlist for direct play decisions |
|
| `directPlayContainers` | string[] | Container allowlist for direct play decisions |
|
||||||
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
|
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
|
||||||
|
|
||||||
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup.
|
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken` and `jellyfin.userId` config keys are not resolver-backed settings in the current runtime. The Settings window also hides low-level client identity and default library fields (`deviceId`, `clientName`, `clientVersion`, and `defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior.
|
||||||
|
|
||||||
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
|
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
|
||||||
|
|
||||||
|
|||||||
+17
-15
@@ -3,6 +3,8 @@
|
|||||||
Short recordings of SubMiner's key features and integrations from real playback sessions.
|
Short recordings of SubMiner's key features and integrations from real playback sessions.
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { withBase } from 'vitepress';
|
||||||
|
|
||||||
const v = '20260301-1';
|
const v = '20260301-1';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -10,11 +12,11 @@ const v = '20260301-1';
|
|||||||
|
|
||||||
Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner automatically attaches the sentence, a timing-accurate audio clip, a screenshot, and a translation.
|
Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner automatically attaches the sentence, a timing-accurate audio clip, a screenshot, and a translation.
|
||||||
|
|
||||||
<video controls playsinline preload="metadata" :poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`">
|
<video controls playsinline preload="metadata" :poster="withBase(`/assets/minecard-poster.jpg?v=${v}`)">
|
||||||
<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />
|
<source :src="withBase(`/assets/minecard.webm?v=${v}`)" type="video/webm" />
|
||||||
<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />
|
<source :src="withBase(`/assets/minecard.mp4?v=${v}`)" type="video/mp4" />
|
||||||
<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">
|
<a :href="withBase(`/assets/minecard.webm?v=${v}`)" target="_blank" rel="noreferrer">
|
||||||
<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
|
<img :src="withBase(`/assets/minecard.webp?v=${v}`)" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
|
||||||
</a>
|
</a>
|
||||||
</video>
|
</video>
|
||||||
|
|
||||||
@@ -23,11 +25,11 @@ Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner aut
|
|||||||
|
|
||||||
## Subtitle Download & Sync
|
## Subtitle Download & Sync
|
||||||
|
|
||||||
Search and download subtitles from Jimaku, then automatically synchronize them with alass or ffsubsync — all from within SubMiner.
|
Search and download subtitles from Jimaku, then retime them with alass or ffsubsync — all from within SubMiner.
|
||||||
|
|
||||||
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/subtitle-sync-poster.jpg?v=${v}`">
|
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/subtitle-sync-poster.jpg?v=${v}`)">
|
||||||
<source :src="`/assets/demos/subtitle-sync.webm?v=${v}`" type="video/webm" />
|
<source :src="withBase(`/assets/demos/subtitle-sync.webm?v=${v}`)" type="video/webm" />
|
||||||
<source :src="`/assets/demos/subtitle-sync.mp4?v=${v}`" type="video/mp4" />
|
<source :src="withBase(`/assets/demos/subtitle-sync.mp4?v=${v}`)" type="video/mp4" />
|
||||||
</video> -->
|
</video> -->
|
||||||
|
|
||||||
::: info VIDEO COMING SOON
|
::: info VIDEO COMING SOON
|
||||||
@@ -37,9 +39,9 @@ Search and download subtitles from Jimaku, then automatically synchronize them w
|
|||||||
|
|
||||||
Browse your Jellyfin library, cast to devices, and launch playback directly from SubMiner. Watch progress syncs back to your Jellyfin server.
|
Browse your Jellyfin library, cast to devices, and launch playback directly from SubMiner. Watch progress syncs back to your Jellyfin server.
|
||||||
|
|
||||||
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/jellyfin-poster.jpg?v=${v}`">
|
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/jellyfin-poster.jpg?v=${v}`)">
|
||||||
<source :src="`/assets/demos/jellyfin.webm?v=${v}`" type="video/webm" />
|
<source :src="withBase(`/assets/demos/jellyfin.webm?v=${v}`)" type="video/webm" />
|
||||||
<source :src="`/assets/demos/jellyfin.mp4?v=${v}`" type="video/mp4" />
|
<source :src="withBase(`/assets/demos/jellyfin.mp4?v=${v}`)" type="video/mp4" />
|
||||||
</video> -->
|
</video> -->
|
||||||
|
|
||||||
::: info VIDEO COMING SOON
|
::: info VIDEO COMING SOON
|
||||||
@@ -49,9 +51,9 @@ Browse your Jellyfin library, cast to devices, and launch playback directly from
|
|||||||
|
|
||||||
Open subtitles in an external texthooker page for use with browser-based tools and extensions alongside the overlay.
|
Open subtitles in an external texthooker page for use with browser-based tools and extensions alongside the overlay.
|
||||||
|
|
||||||
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/texthooker-poster.jpg?v=${v}`">
|
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/texthooker-poster.jpg?v=${v}`)">
|
||||||
<source :src="`/assets/demos/texthooker.webm?v=${v}`" type="video/webm" />
|
<source :src="withBase(`/assets/demos/texthooker.webm?v=${v}`)" type="video/webm" />
|
||||||
<source :src="`/assets/demos/texthooker.mp4?v=${v}`" type="video/mp4" />
|
<source :src="withBase(`/assets/demos/texthooker.mp4?v=${v}`)" type="video/mp4" />
|
||||||
</video> -->
|
</video> -->
|
||||||
|
|
||||||
::: info VIDEO COMING SOON
|
::: info VIDEO COMING SOON
|
||||||
|
|||||||
+20
-11
@@ -113,6 +113,14 @@ bun run docs:test
|
|||||||
bun run docs:build
|
bun run docs:build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For production docs routing, run the versioned build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run docs:build:versioned
|
||||||
|
```
|
||||||
|
|
||||||
|
The versioned build writes `.tmp/docs-versioned-site` with latest stable docs at `/`, development docs at `/main/`, and stable archives under `/v/<version>/`. Prerelease tags are skipped. Public assets from `docs-site/public/assets` are shared from root `/assets/` so large demo media is not duplicated into every version archive; generated VitePress CSS and JS assets stay under each version route. Stale `.tmp/docs-versioned-archive-cache` generations are pruned after a successful build, and intermediate `.tmp/docs-versioned-build` workspaces are removed.
|
||||||
|
|
||||||
Focused commands:
|
Focused commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -154,6 +162,7 @@ bun run format:check:src
|
|||||||
- `make pretty` runs the maintained Prettier allowlist only (`format:src`).
|
- `make pretty` runs the maintained Prettier allowlist only (`format:src`).
|
||||||
- `bun run format:check:src` checks the same scoped set without writing changes.
|
- `bun run format:check:src` checks the same scoped set without writing changes.
|
||||||
- `bun run format` remains the broad repo-wide Prettier command; use it intentionally.
|
- `bun run format` remains the broad repo-wide Prettier command; use it intentionally.
|
||||||
|
|
||||||
## Config Generation
|
## Config Generation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -197,17 +206,17 @@ Use Cloudflare's single `*` wildcard syntax for watch paths. `docs-site/*` cover
|
|||||||
|
|
||||||
Run `make help` for a full list of targets. Key ones:
|
Run `make help` for a full list of targets. Key ones:
|
||||||
|
|
||||||
| Target | Description |
|
| Target | Description |
|
||||||
| ---------------------- | ---------------------------------------------------------------- |
|
| --------------------------- | ----------------------------------------------------------------- |
|
||||||
| `make build` | Build platform package for detected OS |
|
| `make build` | Build platform package for detected OS |
|
||||||
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
|
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
|
||||||
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) |
|
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) |
|
||||||
| `make deps` | Install JS dependencies (root + stats + texthooker-ui) |
|
| `make deps` | Install JS dependencies (root + stats + texthooker-ui) |
|
||||||
| `make pretty` | Run scoped Prettier formatting for maintained source/config files |
|
| `make pretty` | Run scoped Prettier formatting for maintained source/config files |
|
||||||
| `make generate-config` | Generate default config from centralized registry |
|
| `make generate-config` | Generate default config from centralized registry |
|
||||||
| `make build-linux` | Convenience wrapper for Linux packaging |
|
| `make build-linux` | Convenience wrapper for Linux packaging |
|
||||||
| `make build-macos` | Convenience wrapper for signed macOS packaging |
|
| `make build-macos` | Convenience wrapper for signed macOS packaging |
|
||||||
| `make build-macos-unsigned` | Convenience wrapper for unsigned macOS packaging |
|
| `make build-macos-unsigned` | Convenience wrapper for unsigned macOS packaging |
|
||||||
|
|
||||||
## Contributor Notes
|
## Contributor Notes
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const installationContents = readFileSync(new URL('./installation.md', import.me
|
|||||||
const mpvPluginContents = readFileSync(new URL('./mpv-plugin.md', import.meta.url), 'utf8');
|
const mpvPluginContents = readFileSync(new URL('./mpv-plugin.md', import.meta.url), 'utf8');
|
||||||
const developmentContents = readFileSync(new URL('./development.md', import.meta.url), 'utf8');
|
const developmentContents = readFileSync(new URL('./development.md', import.meta.url), 'utf8');
|
||||||
const changelogContents = readFileSync(new URL('./changelog.md', import.meta.url), 'utf8');
|
const changelogContents = readFileSync(new URL('./changelog.md', import.meta.url), 'utf8');
|
||||||
|
const docsPackageContents = readFileSync(new URL('./package.json', import.meta.url), 'utf8');
|
||||||
const ankiIntegrationContents = readFileSync(
|
const ankiIntegrationContents = readFileSync(
|
||||||
new URL('./anki-integration.md', import.meta.url),
|
new URL('./anki-integration.md', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
@@ -37,13 +38,13 @@ test('docs reflect current launcher and release surfaces', () => {
|
|||||||
|
|
||||||
expect(mpvPluginContents).toContain('\\\\.\\pipe\\subminer-socket');
|
expect(mpvPluginContents).toContain('\\\\.\\pipe\\subminer-socket');
|
||||||
|
|
||||||
expect(readmeContents).toContain('Root directory: `docs-site`');
|
expect(readmeContents).toContain('Automatic production and preview deployments: disabled');
|
||||||
expect(readmeContents).toContain('Build output directory: `.vitepress/dist`');
|
expect(readmeContents).toContain('/main/');
|
||||||
expect(readmeContents).toContain('Build watch paths: `docs-site/*`');
|
expect(readmeContents).toContain('GitHub Actions direct upload with Wrangler');
|
||||||
expect(developmentContents).not.toContain('../subminer-docs');
|
expect(developmentContents).not.toContain('../subminer-docs');
|
||||||
expect(developmentContents).toContain('bun run docs:build');
|
expect(developmentContents).toContain('bun run docs:build');
|
||||||
expect(developmentContents).toContain('bun run docs:test');
|
expect(developmentContents).toContain('bun run docs:test');
|
||||||
expect(developmentContents).toContain('Build watch paths: `docs-site/*`');
|
expect(developmentContents).toContain('bun run docs:build:versioned');
|
||||||
expect(developmentContents).not.toContain('test:subtitle:dist');
|
expect(developmentContents).not.toContain('test:subtitle:dist');
|
||||||
expect(developmentContents).toContain('bun run build:win');
|
expect(developmentContents).toContain('bun run build:win');
|
||||||
|
|
||||||
@@ -57,6 +58,15 @@ test('docs reflect current launcher and release surfaces', () => {
|
|||||||
expect(changelogContents).toContain('v0.5.1 (2026-03-09)');
|
expect(changelogContents).toContain('v0.5.1 (2026-03-09)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('docs dev server links version navigation to local dev routes', () => {
|
||||||
|
expect(docsPackageContents).toContain('scripts/build-versioned-docs.ts');
|
||||||
|
expect(docsPackageContents).toContain(
|
||||||
|
'SUBMINER_DOCS_VERSION_LINK_ORIGIN=local bun run ../scripts/build-versioned-docs.ts',
|
||||||
|
);
|
||||||
|
expect(docsPackageContents).toContain('SUBMINER_DOCS_VERSION_LINK_ORIGIN=local');
|
||||||
|
expect(docsPackageContents).toContain('SUBMINER_DOCS_VERSION_MANIFEST');
|
||||||
|
});
|
||||||
|
|
||||||
test('docs changelog keeps the current minor release headings aligned with the root changelog', () => {
|
test('docs changelog keeps the current minor release headings aligned with the root changelog', () => {
|
||||||
const docsHeadings = extractCurrentMinorHeadings(changelogContents);
|
const docsHeadings = extractCurrentMinorHeadings(changelogContents);
|
||||||
expect(docsHeadings.length).toBeGreaterThan(0);
|
expect(docsHeadings.length).toBeGreaterThan(0);
|
||||||
|
|||||||
@@ -7,18 +7,18 @@ const docsIndexContents = readFileSync(docsIndexPath, 'utf8');
|
|||||||
test('docs demo media uses shared cache-busting asset version token', () => {
|
test('docs demo media uses shared cache-busting asset version token', () => {
|
||||||
expect(docsIndexContents).toMatch(/const demoAssetVersion = ['"][^'"]+['"]/);
|
expect(docsIndexContents).toMatch(/const demoAssetVersion = ['"][^'"]+['"]/);
|
||||||
expect(docsIndexContents).toContain(
|
expect(docsIndexContents).toContain(
|
||||||
':poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`"',
|
':poster="withBase(`/assets/minecard-poster.jpg?v=${demoAssetVersion}`)"',
|
||||||
);
|
);
|
||||||
expect(docsIndexContents).toContain(
|
expect(docsIndexContents).toContain(
|
||||||
'<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />',
|
'<source :src="withBase(`/assets/minecard.webm?v=${demoAssetVersion}`)" type="video/webm" />',
|
||||||
);
|
);
|
||||||
expect(docsIndexContents).toContain(
|
expect(docsIndexContents).toContain(
|
||||||
'<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />',
|
'<source :src="withBase(`/assets/minecard.mp4?v=${demoAssetVersion}`)" type="video/mp4" />',
|
||||||
);
|
);
|
||||||
expect(docsIndexContents).toContain(
|
expect(docsIndexContents).toContain(
|
||||||
'<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">',
|
'<a :href="withBase(`/assets/minecard.webm?v=${demoAssetVersion}`)" target="_blank" rel="noreferrer">',
|
||||||
);
|
);
|
||||||
expect(docsIndexContents).toContain(
|
expect(docsIndexContents).toContain(
|
||||||
'<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />',
|
'<img :src="withBase(`/assets/minecard.webp?v=${demoAssetVersion}`)" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-6
@@ -66,7 +66,7 @@ features:
|
|||||||
src: /assets/subtitle-download.svg
|
src: /assets/subtitle-download.svg
|
||||||
alt: Subtitle download icon
|
alt: Subtitle download icon
|
||||||
title: Subtitle Download & Sync
|
title: Subtitle Download & Sync
|
||||||
details: Search and pull subtitles from Jimaku, then auto-sync timing with alass or ffsubsync — all from the overlay.
|
details: Search and pull subtitles from Jimaku, then retime subtitles with alass or ffsubsync — all from the overlay.
|
||||||
link: /jimaku-integration
|
link: /jimaku-integration
|
||||||
linkText: Jimaku integration
|
linkText: Jimaku integration
|
||||||
- icon:
|
- icon:
|
||||||
@@ -86,6 +86,8 @@ features:
|
|||||||
---
|
---
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { withBase } from 'vitepress';
|
||||||
|
|
||||||
const demoAssetVersion = '20260223-2';
|
const demoAssetVersion = '20260223-2';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -135,11 +137,11 @@ const demoAssetVersion = '20260223-2';
|
|||||||
<span class="demo-window__dot"></span>
|
<span class="demo-window__dot"></span>
|
||||||
<span class="demo-window__title">subminer -- playback</span>
|
<span class="demo-window__title">subminer -- playback</span>
|
||||||
</div>
|
</div>
|
||||||
<video controls playsinline preload="metadata" :poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`">
|
<video controls playsinline preload="metadata" :poster="withBase(`/assets/minecard-poster.jpg?v=${demoAssetVersion}`)">
|
||||||
<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />
|
<source :src="withBase(`/assets/minecard.webm?v=${demoAssetVersion}`)" type="video/webm" />
|
||||||
<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />
|
<source :src="withBase(`/assets/minecard.mp4?v=${demoAssetVersion}`)" type="video/mp4" />
|
||||||
<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">
|
<a :href="withBase(`/assets/minecard.webm?v=${demoAssetVersion}`)" target="_blank" rel="noreferrer">
|
||||||
<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
|
<img :src="withBase(`/assets/minecard.webp?v=${demoAssetVersion}`)" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
|
||||||
</a>
|
</a>
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+188
-307
@@ -1,34 +1,33 @@
|
|||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
## How the Pieces Fit Together
|
Three steps to get started:
|
||||||
|
|
||||||
SubMiner is an overlay that sits on top of mpv. It connects to mpv through an IPC socket, renders subtitles as interactive text using a bundled Yomitan dictionary engine, and optionally creates Anki flashcards via AnkiConnect.
|
1. **Install requirements** — mpv and a few optional extras
|
||||||
|
2. **Install SubMiner** — from the AUR, or download from GitHub Releases
|
||||||
|
3. **Launch the app** — first-run setup walks you through dictionaries, the launcher, and everything else
|
||||||
|
|
||||||
To get a working setup you need:
|
## 1. Install Requirements
|
||||||
|
|
||||||
1. **mpv** launched with an IPC socket so SubMiner can read subtitle data
|
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
|
||||||
2. **SubMiner** (the Electron overlay app)
|
|
||||||
3. **Dictionaries** imported into the bundled Yomitan instance (lookups won't work without at least one)
|
|
||||||
4. **Anki + AnkiConnect** _(optional but recommended)_ for card creation and enrichment
|
|
||||||
|
|
||||||
The `subminer` launcher script handles step 1 automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or the equivalent named pipe on Windows) — without it the overlay will start but subtitles will never appear.
|
| 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. |
|
||||||
|
|
||||||
## Requirements
|
### Linux
|
||||||
|
|
||||||
### System Dependencies
|
**Window backend** — you need one of these depending on your compositor:
|
||||||
|
|
||||||
| Dependency | Required | Notes |
|
|
||||||
| -------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| mpv | Yes | Must support IPC sockets (`--input-ipc-server`) |
|
|
||||||
| Bun | For wrapper | Required for `subminer` CLI wrapper and source builds. Pre-built releases (AppImage, DMG, installer) work without it — only the `subminer` wrapper script needs Bun on `PATH`. |
|
|
||||||
| ffmpeg | Recommended | Audio extraction and screenshot generation. Without it SubMiner still runs, but audio and image fields on Anki cards will be empty. |
|
|
||||||
| MeCab + mecab-ipadic | No | Adds part-of-speech data used to filter particles out of N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less precise. |
|
|
||||||
| fuse2 | Linux only | Required for AppImage |
|
|
||||||
| yt-dlp | No | Recommended for YouTube playback and subtitle extraction |
|
|
||||||
|
|
||||||
### Platform-Specific
|
|
||||||
|
|
||||||
**Linux** — one of the following window backends:
|
|
||||||
|
|
||||||
- **Hyprland** — native Wayland support (uses `hyprctl`)
|
- **Hyprland** — native Wayland support (uses `hyprctl`)
|
||||||
- **Sway** — native Wayland support (uses `swaymsg`)
|
- **Sway** — native Wayland support (uses `swaymsg`)
|
||||||
@@ -43,8 +42,10 @@ Wayland has no universal API for window positioning — each compositor exposes
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo pacman -S --needed mpv ffmpeg
|
sudo pacman -S --needed mpv ffmpeg
|
||||||
|
# Recommended
|
||||||
|
sudo pacman -S --needed mecab mecab-ipadic
|
||||||
# Optional
|
# Optional
|
||||||
sudo pacman -S --needed mecab mecab-ipadic yt-dlp fzf rofi chafa ffmpegthumbnailer
|
sudo pacman -S --needed yt-dlp fzf rofi chafa ffmpegthumbnailer
|
||||||
# Optional: subtitle sync (at least one needed for subtitle syncing)
|
# Optional: subtitle sync (at least one needed for subtitle syncing)
|
||||||
paru -S --needed alass python-ffsubsync
|
paru -S --needed alass python-ffsubsync
|
||||||
# X11 / Xwayland (required for non-Hyprland/Sway compositors)
|
# X11 / Xwayland (required for non-Hyprland/Sway compositors)
|
||||||
@@ -58,8 +59,10 @@ sudo pacman -S --needed xdotool xorg-xwininfo
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt install mpv ffmpeg
|
sudo apt install mpv ffmpeg
|
||||||
|
# Recommended
|
||||||
|
sudo apt install mecab libmecab-dev mecab-ipadic-utf8
|
||||||
# Optional
|
# Optional
|
||||||
sudo apt install mecab libmecab-dev mecab-ipadic-utf8 fzf rofi chafa ffmpegthumbnailer yt-dlp
|
sudo apt install yt-dlp fzf rofi chafa ffmpegthumbnailer
|
||||||
# X11 / Xwayland (required for non-Hyprland/Sway compositors)
|
# X11 / Xwayland (required for non-Hyprland/Sway compositors)
|
||||||
sudo apt install xdotool x11-utils
|
sudo apt install xdotool x11-utils
|
||||||
# Optional: subtitle sync
|
# Optional: subtitle sync
|
||||||
@@ -74,8 +77,10 @@ pip install ffsubsync
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo dnf install mpv ffmpeg
|
sudo dnf install mpv ffmpeg
|
||||||
|
# Recommended
|
||||||
|
sudo dnf install mecab mecab-ipadic
|
||||||
# Optional
|
# Optional
|
||||||
sudo dnf install mecab mecab-ipadic fzf rofi chafa ffmpegthumbnailer yt-dlp
|
sudo dnf install yt-dlp fzf rofi chafa ffmpegthumbnailer
|
||||||
# X11 / Xwayland (required for non-Hyprland/Sway compositors)
|
# X11 / Xwayland (required for non-Hyprland/Sway compositors)
|
||||||
sudo dnf install xdotool xorg-x11-utils
|
sudo dnf install xdotool xorg-x11-utils
|
||||||
# Optional: subtitle sync
|
# Optional: subtitle sync
|
||||||
@@ -85,38 +90,32 @@ pip install ffsubsync
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
**macOS** — macOS 10.13 or later. Accessibility permission required for window tracking.
|
### macOS
|
||||||
|
|
||||||
|
macOS 10.13 or later. Accessibility permission is required for window tracking (see [step 2](#macos-dmg)).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
brew install mpv ffmpeg
|
brew install mpv ffmpeg
|
||||||
# Optional but recommended for annotations
|
# Recommended
|
||||||
brew install mecab mecab-ipadic
|
brew install mecab mecab-ipadic
|
||||||
# Optional
|
# Optional
|
||||||
brew install yt-dlp fzf rofi chafa ffmpegthumbnailer
|
brew install yt-dlp fzf chafa ffmpegthumbnailer
|
||||||
# Optional: subtitle sync
|
# Optional: subtitle sync
|
||||||
brew install alass
|
brew install alass
|
||||||
pip install ffsubsync
|
pip install ffsubsync
|
||||||
```
|
```
|
||||||
|
|
||||||
**Windows** — Windows 10 or later. Install [`mpv`](https://mpv.io/installation/) and [`ffmpeg`](https://ffmpeg.org/download.html) and ensure both are on `PATH`. Keep `mpv.exe` on `PATH` for auto-discovery or set `mpv.executablePath` in config if it lives elsewhere. SubMiner's packaged build handles window tracking directly. Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary.
|
### Windows
|
||||||
|
|
||||||
### Optional Tools
|
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.
|
||||||
|
|
||||||
| Tool | Purpose |
|
No compositor tools or window helpers are needed — native window tracking is built in.
|
||||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| fzf | Terminal-based video picker (default) |
|
|
||||||
| rofi | GUI-based video picker |
|
|
||||||
| chafa | Thumbnail previews in fzf |
|
|
||||||
| ffmpegthumbnailer | Generate video thumbnails for picker |
|
|
||||||
| guessit | Better AniSkip title/season/episode parsing for file playback |
|
|
||||||
| alass | Subtitle sync engine (preferred) — must be on `PATH` or set `subsync.alass_path` in config; subtitle syncing is disabled without it or ffsubsync |
|
|
||||||
| ffsubsync | Subtitle sync engine (fallback) — must be on `PATH` or set `subsync.ffsubsync_path` in config; subtitle syncing is disabled without it or alass |
|
|
||||||
|
|
||||||
## Linux
|
## 2. Install SubMiner
|
||||||
|
|
||||||
### Arch Linux (AUR)
|
### Arch Linux (AUR) {#arch-aur}
|
||||||
|
|
||||||
Install [`subminer-bin`](https://aur.archlinux.org/packages/subminer-bin) from the AUR if you want the packaged Linux release managed by pacman. The package installs the official SubMiner AppImage plus the `subminer` wrapper.
|
Install [`subminer-bin`](https://aur.archlinux.org/packages/subminer-bin) from the AUR. The package includes the SubMiner AppImage and the `subminer` launcher.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
paru -S subminer-bin
|
paru -S subminer-bin
|
||||||
@@ -130,125 +129,73 @@ cd subminer-bin
|
|||||||
makepkg -si
|
makepkg -si
|
||||||
```
|
```
|
||||||
|
|
||||||
### AppImage (Recommended)
|
### Linux (AppImage) {#linux-appimage}
|
||||||
|
|
||||||
Download the latest AppImage and the `subminer` launcher from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest).
|
Download the latest AppImage from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest):
|
||||||
|
|
||||||
**Step 1 — Install Bun** (required for the launcher):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://bun.sh/install | bash
|
|
||||||
```
|
|
||||||
|
|
||||||
The `subminer` launcher uses a Bun shebang. The AppImage itself does **not** need Bun — only the launcher does. If you skip the launcher and run the AppImage directly (for example `SubMiner.AppImage --start`), you can skip this step, but you will need to configure `mpv.conf` with `input-ipc-server=/tmp/subminer-socket` manually.
|
|
||||||
|
|
||||||
**Step 2 — Download and install:**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p ~/.local/bin
|
mkdir -p ~/.local/bin
|
||||||
|
|
||||||
# Download and install AppImage
|
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage
|
||||||
chmod +x ~/.local/bin/SubMiner.AppImage
|
chmod +x ~/.local/bin/SubMiner.AppImage
|
||||||
|
|
||||||
# Download and install the subminer launcher (recommended)
|
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer
|
|
||||||
chmod +x ~/.local/bin/subminer
|
|
||||||
|
|
||||||
# Download the optional Linux rofi theme
|
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
|
||||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
|
||||||
mkdir -p ~/.local/share/SubMiner/themes
|
|
||||||
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The `subminer` launcher is the recommended way to use SubMiner on Linux. It ensures mpv is launched with the correct IPC socket, SubMiner defaults, and the bundled runtime plugin so you don't need to configure `mpv.conf` or install a global mpv plugin.
|
::: tip Launcher install is optional
|
||||||
|
First-run setup can install [Bun](https://bun.sh) and the `subminer` command-line launcher for you automatically. You don't need to download the launcher separately.
|
||||||
|
|
||||||
The first-run setup window can also install Bun and the packaged `subminer` launcher into an existing writable PATH directory. Both steps are optional.
|
If you prefer to install it manually, see [manual launcher install](#manual-launcher-install-linux).
|
||||||
|
:::
|
||||||
|
|
||||||
To check for updates later:
|
### macOS (DMG) {#macos-dmg}
|
||||||
|
|
||||||
|
Download the DMG from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest), open it, and drag `SubMiner.app` into `/Applications`. A ZIP artifact is also available as a fallback.
|
||||||
|
|
||||||
|
**Gatekeeper:** If macOS blocks SubMiner on first launch, right-click the app and select **Open** to bypass the warning. Alternatively:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
subminer -u
|
xattr -d com.apple.quarantine /Applications/SubMiner.app
|
||||||
# or
|
|
||||||
subminer --update
|
|
||||||
```
|
```
|
||||||
|
|
||||||
SubMiner verifies launcher and Linux rofi theme downloads against `SHA256SUMS.txt`. If the launcher is installed in a protected path such as `/usr/local/bin/subminer`, SubMiner does not elevate itself; it shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead.
|
**Accessibility permission:** Grant accessibility permission so the overlay can track the mpv window:
|
||||||
|
|
||||||
On Linux, `subminer -u` performs this update from the launcher process, so it does not need to start or IPC into the tray app.
|
1. Open **System Settings** → **Privacy & Security** → **Accessibility**
|
||||||
|
2. Enable SubMiner in the list (add it if it does not appear)
|
||||||
|
|
||||||
|
::: tip Launcher install is optional
|
||||||
|
First-run setup can install [Bun](https://bun.sh) and the `subminer` command-line launcher for you automatically. You don't need to download the launcher separately.
|
||||||
|
|
||||||
|
If you prefer to install it manually, see [manual launcher install](#manual-launcher-install-macos).
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Windows (Installer) {#windows-installer}
|
||||||
|
|
||||||
|
Download the latest installer from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest):
|
||||||
|
|
||||||
|
- `SubMiner-<version>.exe` — installer (recommended)
|
||||||
|
- `SubMiner-<version>.zip` — portable fallback
|
||||||
|
|
||||||
|
Make sure `mpv.exe` is on your `PATH`, or set `mpv.executablePath` in the config during first-run setup.
|
||||||
|
|
||||||
### From Source
|
### From Source
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Linux</b></summary>
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
|
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
|
||||||
cd SubMiner
|
cd SubMiner
|
||||||
# if you cloned without --recurse-submodules:
|
|
||||||
git submodule update --init --recursive
|
|
||||||
|
|
||||||
bun install
|
bun install
|
||||||
bun run build
|
bun run build
|
||||||
|
|
||||||
# Optional packaged Linux artifact
|
# Optional: build AppImage
|
||||||
bun run build:appimage
|
bun run build:appimage
|
||||||
```
|
```
|
||||||
|
|
||||||
Bundled Yomitan is built during `bun run build`.
|
Bundled Yomitan is built during `bun run build`.
|
||||||
If you prefer Make wrappers for local install flows, `make build-launcher` still generates `dist/launcher/subminer` and `make install` still installs the wrapper/theme/AppImage when those artifacts exist.
|
|
||||||
|
|
||||||
`make build` also builds the bundled Yomitan Chrome extension from the `vendor/subminer-yomitan` submodule into `build/yomitan` using Bun.
|
</details>
|
||||||
|
|
||||||
## macOS
|
<details>
|
||||||
|
<summary><b>macOS</b></summary>
|
||||||
### DMG (Recommended)
|
|
||||||
|
|
||||||
Download the **DMG** artifact from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). Open it and drag `SubMiner.app` into `/Applications`.
|
|
||||||
|
|
||||||
A **ZIP** artifact is also available as a fallback — unzip and drag `SubMiner.app` into `/Applications`.
|
|
||||||
|
|
||||||
Install dependencies using Homebrew:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
brew install mpv ffmpeg
|
|
||||||
# Optional but recommended if you use N+1, JLPT, or frequency annotations
|
|
||||||
brew install mecab mecab-ipadic
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Install the `subminer` launcher (recommended)
|
|
||||||
|
|
||||||
The `subminer` launcher is the recommended way to use SubMiner on macOS. It launches mpv with the correct IPC socket and SubMiner defaults so you don't need to set up an `mpv.conf` profile manually.
|
|
||||||
|
|
||||||
First-run setup can install Bun and the packaged launcher into a writable directory that is already on PATH. It does not edit shell profiles.
|
|
||||||
|
|
||||||
Download it from the same [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) page:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O /usr/local/bin/subminer
|
|
||||||
sudo chmod +x /usr/local/bin/subminer
|
|
||||||
```
|
|
||||||
|
|
||||||
Or with curl:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer
|
|
||||||
sudo chmod +x /usr/local/bin/subminer
|
|
||||||
```
|
|
||||||
|
|
||||||
To check for updates later:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
subminer -u
|
|
||||||
# or
|
|
||||||
subminer --update
|
|
||||||
```
|
|
||||||
|
|
||||||
SubMiner verifies launcher downloads against `SHA256SUMS.txt`. If `/usr/local/bin/subminer` is protected, SubMiner shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead of elevating itself.
|
|
||||||
|
|
||||||
::: warning Bun required for the launcher
|
|
||||||
The `subminer` launcher uses a Bun shebang (`#!/usr/bin/env bun`), so [Bun](https://bun.sh) must be installed and available on `PATH`. Install Bun if you haven't already: `curl -fsSL https://bun.sh/install | bash`.
|
|
||||||
:::
|
|
||||||
|
|
||||||
### From Source (macOS)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
|
git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git
|
||||||
@@ -257,122 +204,19 @@ git submodule update --init --recursive
|
|||||||
make build-macos
|
make build-macos
|
||||||
```
|
```
|
||||||
|
|
||||||
The built app will be available in the `release` directory (`.dmg` and `.zip`).
|
The built app will be in the `release` directory (`.dmg` and `.zip`). For unsigned local builds: `bun run build:mac:unsigned`.
|
||||||
|
|
||||||
For unsigned local builds:
|
</details>
|
||||||
|
|
||||||
```bash
|
<details>
|
||||||
bun run build:mac:unsigned
|
<summary><b>Windows</b></summary>
|
||||||
```
|
|
||||||
|
|
||||||
Build and install the launcher alongside the app:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make install-macos
|
|
||||||
```
|
|
||||||
|
|
||||||
This builds the `subminer` launcher into `dist/launcher/subminer` and installs it to `~/.local/bin/subminer` along with the app bundle. To install to `/usr/local/bin` instead (already on the default macOS `PATH`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo make install-macos PREFIX=/usr/local
|
|
||||||
```
|
|
||||||
|
|
||||||
### Gatekeeper
|
|
||||||
|
|
||||||
If macOS blocks SubMiner on first launch, right-click the app and select **Open** to bypass the warning. Alternatively, remove the quarantine attribute:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
xattr -d com.apple.quarantine /Applications/SubMiner.app
|
|
||||||
```
|
|
||||||
|
|
||||||
### Accessibility Permission
|
|
||||||
|
|
||||||
After launching SubMiner for the first time, grant accessibility permission:
|
|
||||||
|
|
||||||
1. Open **System Settings** → **Privacy & Security** → **Accessibility**
|
|
||||||
2. Enable SubMiner in the list (add it if it does not appear)
|
|
||||||
|
|
||||||
Without this permission, window tracking will not work and the overlay won't follow the mpv window.
|
|
||||||
|
|
||||||
### macOS Usage Notes
|
|
||||||
|
|
||||||
**Launching with the `subminer` launcher (recommended):**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
subminer video.mkv
|
|
||||||
```
|
|
||||||
|
|
||||||
The launcher handles the IPC socket and SubMiner defaults automatically. If you prefer to launch mpv manually:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mpv --input-ipc-server=/tmp/subminer-socket video.mkv
|
|
||||||
```
|
|
||||||
|
|
||||||
**Config location:** `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`).
|
|
||||||
|
|
||||||
**MeCab paths (Homebrew):**
|
|
||||||
|
|
||||||
- Apple Silicon (M1/M2): `/opt/homebrew/bin/mecab`
|
|
||||||
- Intel: `/usr/local/bin/mecab`
|
|
||||||
|
|
||||||
Ensure `mecab` is available on your PATH when launching SubMiner.
|
|
||||||
|
|
||||||
**Fullscreen:** The overlay should appear correctly in fullscreen. If you encounter issues, check that accessibility permissions are granted.
|
|
||||||
|
|
||||||
**mpv plugin binary path:**
|
|
||||||
|
|
||||||
```ini
|
|
||||||
binary_path=/Applications/SubMiner.app/Contents/MacOS/subminer
|
|
||||||
```
|
|
||||||
|
|
||||||
## Windows
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
1. Install [`mpv`](https://mpv.io/installation/) and ensure `mpv.exe` is on `PATH`. If mpv is installed elsewhere, you can set `mpv.executablePath` in `config.jsonc` or use the first-run setup field to point at the executable.
|
|
||||||
2. Install [`ffmpeg`](https://ffmpeg.org/download.html) and add it to `PATH` — recommended for audio/screenshot extraction (without it, media fields on Anki cards will be empty).
|
|
||||||
3. _(Optional)_ Install [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary for annotation POS filtering.
|
|
||||||
|
|
||||||
No compositor tools or window helpers are needed — native window tracking is built in on Windows.
|
|
||||||
|
|
||||||
### Installer (Recommended)
|
|
||||||
|
|
||||||
Download the latest Windows installer from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest):
|
|
||||||
|
|
||||||
- `SubMiner-<version>.exe` installs the app, Start menu shortcut, and default files under `Program Files`
|
|
||||||
- `SubMiner-<version>.zip` is available as a portable fallback
|
|
||||||
|
|
||||||
### Getting Started on Windows
|
|
||||||
|
|
||||||
1. **Run `SubMiner.exe` once** — first-run setup creates `%APPDATA%\SubMiner\config.jsonc` and opens Yomitan settings for dictionary import. The global mpv plugin install is optional for compatibility; the SubMiner mpv shortcut injects the bundled runtime plugin.
|
|
||||||
2. **Create the SubMiner mpv shortcut** _(recommended)_ — the setup popup offers to create a `SubMiner mpv` Start Menu and/or Desktop shortcut. This is the recommended way to launch playback on Windows.
|
|
||||||
3. **Optional: install the command-line launcher** — first-run setup can install Bun with winget/Scoop/the official installer and add `%LOCALAPPDATA%\SubMiner\bin\subminer.cmd` to your user PATH. Open a new terminal and type `subminer`.
|
|
||||||
4. **Play a video** — double-click the shortcut, drag a video file onto it, or run from a terminal:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
& "C:\Program Files\SubMiner\SubMiner.exe" --launch-mpv "C:\Videos\episode 01.mkv"
|
|
||||||
```
|
|
||||||
|
|
||||||
The shortcut and `--launch-mpv` pass SubMiner's default IPC socket, subtitle args, and bundled runtime plugin directly — no `mpv.conf` profile or global mpv plugin install is needed.
|
|
||||||
|
|
||||||
### Windows-Specific Notes
|
|
||||||
|
|
||||||
- The **SubMiner mpv** shortcut created during first-run setup is the recommended way to launch playback on Windows.
|
|
||||||
- The optional command-line launcher installs a `subminer.cmd` shim, but users type `subminer`; Windows resolves `.cmd` through `PATHEXT`.
|
|
||||||
- First-run setup adds only `%LOCALAPPDATA%\SubMiner\bin` to the HKCU user PATH. It does not add `SubMiner.exe` or the app install directory to PATH.
|
|
||||||
- First-run plugin installs pin `binary_path` to the current `SubMiner.exe` automatically. Manual plugin configs can leave `binary_path` empty unless SubMiner is in a non-standard location.
|
|
||||||
- Plugin installs rewrite `socket_path` to `\\.\pipe\subminer-socket` — do not keep `/tmp/subminer-socket` on Windows.
|
|
||||||
- Config is stored at `%APPDATA%\SubMiner\config.jsonc`.
|
|
||||||
|
|
||||||
### From Source (Windows)
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
git clone https://github.com/ksyasuda/SubMiner.git
|
git clone https://github.com/ksyasuda/SubMiner.git
|
||||||
cd SubMiner
|
cd SubMiner
|
||||||
bun install
|
bun install
|
||||||
|
|
||||||
# Windows requires building the texthooker-ui submodule manually before
|
# Windows requires building texthooker-ui manually before the main build
|
||||||
# the main build (Linux/macOS handle this automatically during `bun run build`).
|
|
||||||
Set-Location vendor/texthooker-ui
|
Set-Location vendor/texthooker-ui
|
||||||
bun install --frozen-lockfile
|
bun install --frozen-lockfile
|
||||||
bun run build
|
bun run build
|
||||||
@@ -381,86 +225,52 @@ Set-Location ../..
|
|||||||
bun run build:win
|
bun run build:win
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows installer builds already get the required NSIS `WinShell` helper through electron-builder's cached `nsis-resources` bundle.
|
</details>
|
||||||
No extra repo-local WinShell plugin install step is required.
|
|
||||||
|
|
||||||
## MPV Plugin
|
## 3. Launch & First-Run Setup
|
||||||
|
|
||||||
SubMiner-managed playback loads the bundled mpv plugin at runtime. No separate global mpv plugin install is required when launching from the app, the launcher, or the packaged Windows SubMiner mpv shortcut.
|
Launch SubMiner and the setup wizard will open automatically:
|
||||||
|
|
||||||
::: warning Important
|
|
||||||
If first-run setup detects an older global SubMiner mpv plugin under mpv's `scripts` directory, use **Remove legacy mpv plugin** so regular mpv playback stops loading SubMiner.
|
|
||||||
:::
|
|
||||||
|
|
||||||
See [MPV Plugin](/mpv-plugin) for the keybindings, script messages, and runtime configuration reference.
|
|
||||||
|
|
||||||
## Anki Setup (Recommended)
|
|
||||||
|
|
||||||
If you plan to mine Anki cards (the primary use case for most users):
|
|
||||||
|
|
||||||
1. Install [Anki](https://apps.ankiweb.net/).
|
|
||||||
2. Install the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on — open Anki, go to **Tools → Add-ons → Get Add-ons**, enter code `2055492159`.
|
|
||||||
3. Restart Anki and keep it running while using SubMiner.
|
|
||||||
|
|
||||||
AnkiConnect listens on `http://127.0.0.1:8765` by default. SubMiner will connect to it automatically with no extra config needed for basic card creation.
|
|
||||||
|
|
||||||
For enrichment configuration (sentence, audio, screenshot fields), see [Anki Integration](/anki-integration).
|
|
||||||
|
|
||||||
## First-Run Setup
|
|
||||||
|
|
||||||
Run the setup wizard to create a default config and finish initial configuration. You do **not** need to create the config manually — SubMiner handles it.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Linux (AUR install)
|
||||||
|
subminer app --setup
|
||||||
|
|
||||||
|
# Linux (AppImage directly)
|
||||||
|
~/.local/bin/SubMiner.AppImage --setup
|
||||||
|
|
||||||
|
# macOS — launch SubMiner.app from /Applications, or:
|
||||||
subminer app --setup
|
subminer app --setup
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!NOTE]
|
On **Windows**, just run `SubMiner.exe` — the setup wizard opens automatically on first launch.
|
||||||
> On Windows, run `SubMiner.exe` directly — it opens the setup wizard automatically on first launch.
|
|
||||||
|
|
||||||
The setup popup walks you through:
|
The setup wizard walks you through:
|
||||||
|
|
||||||
- **Config file**: auto-created at `~/.config/SubMiner/config.jsonc` (Linux/macOS) or `%APPDATA%\SubMiner\config.jsonc` (Windows)
|
- **Config file** — auto-created at `~/.config/SubMiner/config.jsonc` (Linux/macOS) or `%APPDATA%\SubMiner\config.jsonc` (Windows)
|
||||||
- **mpv plugin**: install the bundled Lua plugin for in-player keybindings
|
- **Yomitan dictionaries** — import at least one dictionary so word lookups work
|
||||||
- **Yomitan dictionaries**: import at least one dictionary so lookups work
|
- **Bun + `subminer` launcher** _(optional)_ — installs the command-line launcher into a writable PATH directory
|
||||||
- **Windows shortcut** _(Windows only)_: optionally create a `SubMiner mpv` Start Menu/Desktop shortcut
|
- **Windows shortcut** _(Windows only)_ — create a `SubMiner mpv` Start Menu/Desktop shortcut
|
||||||
- **Command line launcher**: optionally install Bun and the `subminer` launcher to your command-line PATH
|
|
||||||
|
|
||||||
The `Finish setup` button follows the normal config/Yomitan readiness checks. Bun and the command-line launcher are optional and never block setup completion.
|
The `Finish setup` button requires a config file and at least one Yomitan dictionary. Bun and the launcher are optional and never block setup completion.
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> You can re-open the setup popup at any time with `subminer app --setup` or `SubMiner.AppImage --setup`.
|
> You can re-open the setup wizard at any time with `subminer app --setup` or `SubMiner.AppImage --setup`.
|
||||||
|
|
||||||
Once setup is complete, play a video to verify everything works:
|
### Play a Video
|
||||||
|
|
||||||
|
Once setup is complete:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
subminer video.mkv
|
subminer video.mkv
|
||||||
```
|
```
|
||||||
|
|
||||||
You should see the overlay appear over mpv. If subtitles are loaded in the video, they will appear as interactive text in the overlay.
|
You should see the overlay appear over mpv. If subtitles are loaded, they will appear as interactive text in the overlay.
|
||||||
|
|
||||||
<details>
|
On **Windows**, the recommended way to play video is with the **SubMiner mpv** shortcut created during setup — double-click it, or drag a video file onto it.
|
||||||
<summary><b>More launch examples</b></summary>
|
|
||||||
|
|
||||||
```bash
|
### Verify Setup
|
||||||
# Optional explicit overlay start for setups with plugin auto_start=no
|
|
||||||
subminer --start video.mkv
|
|
||||||
|
|
||||||
# Useful launch modes for troubleshooting
|
Run the built-in diagnostic to confirm everything is working:
|
||||||
subminer --log-level debug video.mkv
|
|
||||||
SubMiner.AppImage --start --log-level debug
|
|
||||||
|
|
||||||
# Or with direct AppImage control
|
|
||||||
SubMiner.AppImage --background # Background tray service mode
|
|
||||||
SubMiner.AppImage --start
|
|
||||||
SubMiner.AppImage --start --dev
|
|
||||||
SubMiner.AppImage --help # Show all CLI options
|
|
||||||
```
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## Verify Setup
|
|
||||||
|
|
||||||
After completing first-run setup, run the built-in diagnostic to confirm everything is in place:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
subminer doctor
|
subminer doctor
|
||||||
@@ -468,19 +278,90 @@ subminer doctor
|
|||||||
|
|
||||||
This checks for the app binary, mpv, ffmpeg, config file, and socket path. Fix any failures before continuing.
|
This checks for the app binary, mpv, ffmpeg, config file, and socket path. Fix any failures before continuing.
|
||||||
|
|
||||||
> [!NOTE]
|
## Anki Setup (Recommended)
|
||||||
> On Windows, run `SubMiner.exe` directly. Replace `SubMiner.AppImage` with `SubMiner.exe` in the direct app commands below.
|
|
||||||
|
If you plan to mine Anki cards:
|
||||||
|
|
||||||
|
1. Install [Anki](https://apps.ankiweb.net/)
|
||||||
|
2. Install [AnkiConnect](https://ankiweb.net/shared/info/2055492159) — open Anki → **Tools → Add-ons → Get Add-ons** → enter code `2055492159`
|
||||||
|
3. Restart Anki and keep it running while using SubMiner
|
||||||
|
|
||||||
|
AnkiConnect listens on `http://127.0.0.1:8765` by default. SubMiner connects automatically with no extra config needed.
|
||||||
|
|
||||||
|
For enrichment configuration (sentence, audio, screenshot fields), see [Anki Integration](/anki-integration).
|
||||||
|
|
||||||
|
## Updates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
subminer -u
|
||||||
|
# or
|
||||||
|
subminer --update
|
||||||
|
```
|
||||||
|
|
||||||
|
SubMiner verifies AppImage, launcher, and rofi theme downloads against `SHA256SUMS.txt`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself.
|
||||||
|
|
||||||
|
The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. On Linux it replaces the running `.AppImage` in place via `electron-updater`; AppImages managed by a system package (for example the AUR `/opt/SubMiner/SubMiner.AppImage`) are skipped so the package manager stays in charge.
|
||||||
|
|
||||||
|
`subminer -u` also performs the AppImage update directly from the launcher process, which is useful when SubMiner is not currently running.
|
||||||
|
|
||||||
|
## How It All Fits Together
|
||||||
|
|
||||||
|
SubMiner is an overlay that sits on top of mpv. It connects to mpv through an IPC socket, renders subtitles as interactive text using a bundled Yomitan dictionary engine, and optionally creates Anki flashcards via AnkiConnect.
|
||||||
|
|
||||||
|
The `subminer` launcher handles mpv IPC socket setup automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or `\\.\pipe\subminer-socket` on Windows) — without it the overlay starts but subtitles won't appear.
|
||||||
|
|
||||||
|
The bundled mpv plugin is injected at runtime automatically — you don't need to install it separately. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
|
||||||
|
|
||||||
|
## Platform Notes
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
**MeCab paths (Homebrew):**
|
||||||
|
- Apple Silicon (M1/M2): `/opt/homebrew/bin/mecab`
|
||||||
|
- Intel: `/usr/local/bin/mecab`
|
||||||
|
|
||||||
|
Ensure `mecab` is available on your PATH when launching SubMiner.
|
||||||
|
|
||||||
|
**Fullscreen:** The overlay should appear correctly in fullscreen. If you encounter issues, check that accessibility permissions are granted.
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
- The **SubMiner mpv** shortcut is the recommended way to launch playback. It starts `mpv.exe` with the right IPC socket and subtitle defaults.
|
||||||
|
- First-run setup adds only `%LOCALAPPDATA%\SubMiner\bin` to the HKCU user PATH. It does not add `SubMiner.exe` to PATH.
|
||||||
|
- IPC socket on Windows is `\\.\pipe\subminer-socket` — do not use `/tmp/subminer-socket`.
|
||||||
|
- Config is stored at `%APPDATA%\SubMiner\config.jsonc`.
|
||||||
|
|
||||||
|
## Manual Launcher Install
|
||||||
|
|
||||||
|
The `subminer` launcher uses a [Bun](https://bun.sh) shebang, so Bun must be installed. First-run setup can handle this automatically, but if you prefer to do it yourself:
|
||||||
|
|
||||||
|
### Linux {#manual-launcher-install-linux}
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Bun
|
||||||
|
curl -fsSL https://bun.sh/install | bash
|
||||||
|
|
||||||
|
# Download the launcher
|
||||||
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer
|
||||||
|
chmod +x ~/.local/bin/subminer
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS {#manual-launcher-install-macos}
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Bun
|
||||||
|
curl -fsSL https://bun.sh/install | bash
|
||||||
|
|
||||||
|
# Download the launcher
|
||||||
|
sudo curl -fSL https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -o /usr/local/bin/subminer
|
||||||
|
sudo chmod +x /usr/local/bin/subminer
|
||||||
|
```
|
||||||
|
|
||||||
## Optional Extras
|
## Optional Extras
|
||||||
|
|
||||||
### Rofi Theme (Linux Only)
|
### Rofi Theme (Linux Only)
|
||||||
|
|
||||||
SubMiner ships a custom rofi theme bundled in the release assets tarball.
|
SubMiner ships a custom rofi theme in the release assets:
|
||||||
|
|
||||||
Install path (default auto-detected by `subminer`):
|
|
||||||
|
|
||||||
- Linux: `~/.local/share/SubMiner/themes/subminer.rasi`
|
|
||||||
- macOS: `~/Library/Application Support/SubMiner/themes/subminer.rasi`
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ SubMiner includes an optional Jellyfin CLI integration for:
|
|||||||
- listing libraries and media items
|
- listing libraries and media items
|
||||||
- launching item playback in the connected mpv instance
|
- launching item playback in the connected mpv instance
|
||||||
- receiving Jellyfin remote cast-to-device playback events in-app
|
- receiving Jellyfin remote cast-to-device playback events in-app
|
||||||
- opening an in-app setup window for server selection and authentication
|
- opening an in-app setup window for server URL and authentication
|
||||||
- toggling Jellyfin cast discovery from the tray once configured
|
- toggling Jellyfin cast discovery from the tray once configured
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
@@ -50,7 +50,7 @@ subminer jellyfin -l \
|
|||||||
--password 'your-password'
|
--password 'your-password'
|
||||||
```
|
```
|
||||||
|
|
||||||
`subminer jellyfin` opens the setup window. It offers the configured server, recent servers, and a manual server URL field. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username/client metadata, and refreshes recent servers. Passwords are never stored.
|
`subminer jellyfin` opens the setup window. It pre-fills the server URL from the configured server, a recent successful server, or the local default. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username/client metadata, and refreshes recent servers. Passwords are never stored.
|
||||||
|
|
||||||
3. List libraries:
|
3. List libraries:
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ subminer stats -b # start background stats daemon
|
|||||||
| `subminer stats -b` | Start or reuse background stats daemon (non-blocking) |
|
| `subminer stats -b` | Start or reuse background stats daemon (non-blocking) |
|
||||||
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows |
|
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows |
|
||||||
| `subminer doctor` | Dependency + config + socket diagnostics |
|
| `subminer doctor` | Dependency + config + socket diagnostics |
|
||||||
|
| `subminer settings` | Open the SubMiner settings window |
|
||||||
| `subminer config path` | Print active config file path |
|
| `subminer config path` | Print active config file path |
|
||||||
| `subminer config show` | Print active config contents |
|
| `subminer config show` | Print active config contents |
|
||||||
| `subminer mpv status` | Check mpv socket readiness |
|
| `subminer mpv status` | Check mpv socket readiness |
|
||||||
@@ -99,6 +100,7 @@ Use `subminer <subcommand> -h` for command-specific help.
|
|||||||
| `-r, --recursive` | Search directories recursively |
|
| `-r, --recursive` | Search directories recursively |
|
||||||
| `-R, --rofi` | Use rofi instead of fzf |
|
| `-R, --rofi` | Use rofi instead of fzf |
|
||||||
| `--setup` | Open first-run setup popup manually |
|
| `--setup` | Open first-run setup popup manually |
|
||||||
|
| `-v, --version` | Print installed SubMiner version |
|
||||||
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
|
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
|
||||||
| `--start` | Explicitly start overlay after mpv launches |
|
| `--start` | Explicitly start overlay after mpv launches |
|
||||||
| `-S, --start-overlay` | Explicitly start overlay after mpv launches |
|
| `-S, --start-overlay` | Explicitly start overlay after mpv launches |
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ With a gamepad connected and keyboard-only mode enabled, the full mining loop wo
|
|||||||
6. **Close** — press `B` to dismiss the Yomitan popup and return to subtitle navigation.
|
6. **Close** — press `B` to dismiss the Yomitan popup and return to subtitle navigation.
|
||||||
7. **Pause/resume** — press `L3` (left stick click) to toggle mpv pause at any time.
|
7. **Pause/resume** — press `L3` (left stick click) to toggle mpv pause at any time.
|
||||||
|
|
||||||
The controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
|
After controller support is enabled, the controller and keyboard can be used interchangeably — switching mid-session is seamless. Toggle keyboard-only mode on or off with `Y` on the controller.
|
||||||
|
|
||||||
See [Usage — Controller Support](/usage#controller-support) for setup details and [Configuration — Controller Support](/configuration#controller-support) for the full mapping and tuning options.
|
See [Usage — Controller Support](/usage#controller-support) for setup details and [Configuration — Controller Support](/configuration#controller-support) for the full mapping and tuning options.
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
"description": "In-repo VitePress documentation site for SubMiner",
|
"description": "In-repo VitePress documentation site for SubMiner",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"docs:dev": "VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
|
"docs:dev": "SUBMINER_DOCS_VERSION_LINK_ORIGIN=local bun run ../scripts/build-versioned-docs.ts && SUBMINER_DOCS_VERSION_LINK_ORIGIN=local SUBMINER_DOCS_VERSION_MANIFEST=\"$(bun run ../scripts/print-docs-version-manifest.ts)\" VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
|
||||||
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build",
|
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build",
|
||||||
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview --host 0.0.0.0 --port 4173 --strictPort",
|
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview --host 0.0.0.0 --port 4173 --strictPort",
|
||||||
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts seo.test.ts"
|
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts seo.test.ts .vitepress/theme/status-line.test.ts ../scripts/docs-versioning.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@catppuccin/vitepress": "^0.1.2",
|
"@catppuccin/vitepress": "^0.1.2",
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { readFileSync } from 'node:fs';
|
|||||||
const docsConfigPath = new URL('./.vitepress/config.ts', import.meta.url);
|
const docsConfigPath = new URL('./.vitepress/config.ts', import.meta.url);
|
||||||
const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
|
const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
|
||||||
const docsPackagePath = new URL('./package.json', import.meta.url);
|
const docsPackagePath = new URL('./package.json', import.meta.url);
|
||||||
|
const versionedBuildPath = new URL('../scripts/build-versioned-docs.ts', import.meta.url);
|
||||||
const docsConfigContents = readFileSync(docsConfigPath, 'utf8');
|
const docsConfigContents = readFileSync(docsConfigPath, 'utf8');
|
||||||
const docsThemeContents = readFileSync(docsThemePath, 'utf8');
|
const docsThemeContents = readFileSync(docsThemePath, 'utf8');
|
||||||
const docsPackageContents = readFileSync(docsPackagePath, 'utf8');
|
const docsPackageContents = readFileSync(docsPackagePath, 'utf8');
|
||||||
|
const versionedBuildContents = readFileSync(versionedBuildPath, 'utf8');
|
||||||
|
|
||||||
test('docs site loads the docs.subminer.moe Plausible script through the analytics proxy', () => {
|
test('docs site loads the docs.subminer.moe Plausible script through the analytics proxy', () => {
|
||||||
expect(docsConfigContents).toContain("const DOCS_HOSTNAME = 'https://docs.subminer.moe'");
|
expect(docsConfigContents).toContain("const DOCS_HOSTNAME = 'https://docs.subminer.moe'");
|
||||||
@@ -34,3 +36,33 @@ test('docs site loads the docs.subminer.moe Plausible script through the analyti
|
|||||||
expect(docsThemeContents).not.toContain('initPlausibleTracker');
|
expect(docsThemeContents).not.toContain('initPlausibleTracker');
|
||||||
expect(docsPackageContents).not.toContain('@plausible-analytics/tracker');
|
expect(docsPackageContents).not.toContain('@plausible-analytics/tracker');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('versioned docs reuse current VitePress internals for old page snapshots', () => {
|
||||||
|
expect(versionedBuildContents).toContain("cpSync(join(currentDocsSite, '.vitepress')");
|
||||||
|
expect(versionedBuildContents).toContain('overlayCurrentVitePress(snapshotDocsSite)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('versioned docs build reports archive cache hits and rebuilds', () => {
|
||||||
|
expect(versionedBuildContents).toContain(
|
||||||
|
'console.info(`[docs] archive cache key ${archiveCacheKey.slice(0, 12)}`)',
|
||||||
|
);
|
||||||
|
expect(versionedBuildContents).toContain('console.info(`[docs] cache hit ${version}`)');
|
||||||
|
expect(versionedBuildContents).toContain('console.info(`[docs] rebuilding archive ${version}`)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('versioned docs build deduplicates public assets and prunes stale workspaces', () => {
|
||||||
|
expect(versionedBuildContents).toContain('dedupeVersionedPublicAssets({');
|
||||||
|
expect(versionedBuildContents).toContain('pruneArchiveCacheGenerations({');
|
||||||
|
expect(versionedBuildContents).toContain('rmSync(buildRoot, { recursive: true, force: true });');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('versioned docs archive cache key ignores generated and test-only files', () => {
|
||||||
|
expect(versionedBuildContents).toContain('isSharedInternalsHashIgnoredPath(path)');
|
||||||
|
expect(versionedBuildContents).toContain('|| /\\.test\\.[cm]?[jt]s$/.test(path)');
|
||||||
|
expect(versionedBuildContents).toContain('process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN');
|
||||||
|
expect(versionedBuildContents).not.toContain('hash.update(String(stat.mode))');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('docs builds exclude the internal README from VitePress page entries', () => {
|
||||||
|
expect(docsConfigContents).toContain("srcExclude: ['subagents/**', 'README.md']");
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
{
|
{
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Overlay Auto-Start
|
// Visible Overlay Auto-Start
|
||||||
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
|
||||||
|
// SubMiner can still auto-start in the background when this is false.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false
|
"auto_start_overlay": true, // Show the visible subtitle overlay automatically when the bundled mpv plugin starts SubMiner. Values: true | false
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Texthooker Server
|
// Texthooker Server
|
||||||
@@ -18,7 +19,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"texthooker": {
|
"texthooker": {
|
||||||
"launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false
|
"launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false
|
||||||
"openBrowser": false // Open browser setting. Values: true | false
|
"openBrowser": false // Open the texthooker page in the default browser when the server starts. Values: true | false
|
||||||
}, // Configure texthooker startup launch and browser opening behavior.
|
}, // Configure texthooker startup launch and browser opening behavior.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
// Logging
|
// Logging
|
||||||
// Controls logging verbosity.
|
// Controls logging verbosity.
|
||||||
// Set to debug for full runtime diagnostics.
|
// Set to debug for full runtime diagnostics.
|
||||||
|
// Hot-reload: logging.level applies live while SubMiner is running.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"logging": {
|
"logging": {
|
||||||
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
"level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error
|
||||||
@@ -138,7 +140,8 @@
|
|||||||
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
"axisIndex": 4, // Raw axis index captured for this analog controller action.
|
||||||
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
"dpadFallback": "none" // Optional D-pad fallback used when this analog controller action should also read D-pad input. Values: none | horizontal | vertical
|
||||||
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
|
} // Axis binding descriptor used for popup page jumps. Use Alt+C learn mode or set a raw axis descriptor manually.
|
||||||
} // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
||||||
|
"profiles": {} // Per-controller binding and button-index overrides keyed by the controller id reported by the Gamepad API.
|
||||||
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
}, // Gamepad support for the visible overlay while keyboard-only mode is active.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -152,7 +155,7 @@
|
|||||||
"mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false
|
"mecab": true, // Warm up MeCab tokenizer at startup. Values: true | false
|
||||||
"yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false
|
"yomitanExtension": true, // Warm up Yomitan extension at startup. Values: true | false
|
||||||
"subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false
|
"subtitleDictionaries": true, // Warm up subtitle dictionaries at startup. Values: true | false
|
||||||
"jellyfinRemoteSession": true // Warm up Jellyfin remote session at startup. Values: true | false
|
"jellyfinRemoteSession": false // Warm up Jellyfin remote session at startup. Values: true | false
|
||||||
}, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
}, // Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -173,24 +176,24 @@
|
|||||||
// Hot-reload: shortcut changes apply live and update the session help modal on reopen.
|
// Hot-reload: shortcut changes apply live and update the session help modal on reopen.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"shortcuts": {
|
"shortcuts": {
|
||||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
|
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Global accelerator that toggles overlay visibility from anywhere on the system. Use null to disable.
|
||||||
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
|
"copySubtitle": "CommandOrControl+C", // Accelerator that copies the current subtitle line to the clipboard.
|
||||||
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
|
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Accelerator that copies consecutive subtitle lines while the multi-copy window stays open.
|
||||||
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
|
"updateLastCardFromClipboard": "CommandOrControl+V", // Accelerator that updates the last mined Anki card using the current clipboard contents.
|
||||||
"triggerFieldGrouping": "CommandOrControl+G", // Trigger field grouping setting.
|
"triggerFieldGrouping": "CommandOrControl+G", // Accelerator that triggers Kiku field grouping on duplicate cards.
|
||||||
"triggerSubsync": "Ctrl+Alt+S", // Trigger subsync setting.
|
"triggerSubsync": "Ctrl+Alt+S", // Accelerator that triggers subsync against the active subtitle file.
|
||||||
"mineSentence": "CommandOrControl+S", // Mine sentence setting.
|
"mineSentence": "CommandOrControl+S", // Accelerator that mines the current sentence as a new Anki card.
|
||||||
"mineSentenceMultiple": "CommandOrControl+Shift+S", // Mine sentence multiple setting.
|
"mineSentenceMultiple": "CommandOrControl+Shift+S", // Accelerator that mines consecutive sentences while the multi-mine window stays open.
|
||||||
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
|
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
|
||||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
"toggleSecondarySub": "CommandOrControl+Shift+V", // Accelerator that toggles the secondary subtitle bar visibility.
|
||||||
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
"markAudioCard": "CommandOrControl+Shift+A", // Accelerator that marks the last mined card as an audio card.
|
||||||
"openCharacterDictionary": "CommandOrControl+Alt+A", // Open character dictionary setting.
|
"openCharacterDictionary": "CommandOrControl+Alt+A", // Accelerator that opens the character dictionary modal.
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||||
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
|
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||||
"openSessionHelp": "CommandOrControl+Slash", // Open session help setting.
|
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||||
"openControllerSelect": "Alt+C", // Open controller select setting.
|
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||||
"openControllerDebug": "Alt+Shift+C", // Open controller debug setting.
|
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
|
||||||
"toggleSubtitleSidebar": "Backslash" // Toggle subtitle sidebar setting.
|
"toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility.
|
||||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -327,20 +330,20 @@
|
|||||||
// Hot-reload: defaultMode updates live while SubMiner is running.
|
// Hot-reload: defaultMode updates live while SubMiner is running.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"secondarySub": {
|
"secondarySub": {
|
||||||
"secondarySubLanguages": [], // Secondary sub languages setting.
|
"secondarySubLanguages": [], // Language code priority list used to auto-select a secondary subtitle track when available.
|
||||||
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
"autoLoadSecondarySub": false, // Automatically load a matching secondary subtitle when the primary subtitle loads. Values: true | false
|
||||||
"defaultMode": "hover" // Default mode setting.
|
"defaultMode": "hover" // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover
|
||||||
}, // Dual subtitle track options.
|
}, // Dual subtitle track options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Auto Subtitle Sync
|
// Subtitle Sync
|
||||||
// Subsync engine and executable paths.
|
// Subsync engine and executable paths.
|
||||||
|
// Hot-reload: subsync changes apply to the next subtitle sync run.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"subsync": {
|
"subsync": {
|
||||||
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
|
"alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH.
|
||||||
"alass_path": "", // Alass path setting.
|
"ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH.
|
||||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
"ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH.
|
||||||
"ffmpeg_path": "", // Ffmpeg path setting.
|
|
||||||
"replace": true // Replace the active subtitle file when sync completes. Values: true | false
|
"replace": true // Replace the active subtitle file when sync completes. Values: true | false
|
||||||
}, // Subsync engine and executable paths.
|
}, // Subsync engine and executable paths.
|
||||||
|
|
||||||
@@ -349,7 +352,7 @@
|
|||||||
// Initial vertical subtitle position from the bottom.
|
// Initial vertical subtitle position from the bottom.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"subtitlePosition": {
|
"subtitlePosition": {
|
||||||
"yPercent": 10 // Y percent setting.
|
"yPercent": 10 // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen.
|
||||||
}, // Initial vertical subtitle position from the bottom.
|
}, // Initial vertical subtitle position from the bottom.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -359,29 +362,31 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"subtitleStyle": {
|
"subtitleStyle": {
|
||||||
"primaryDefaultMode": "visible", // Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover. Values: hidden | visible | hover
|
"primaryDefaultMode": "visible", // Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover. Values: hidden | visible | hover
|
||||||
|
"css": {
|
||||||
|
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||||
|
"color": "#cad3f5", // Color setting.
|
||||||
|
"background-color": "transparent", // Background color setting.
|
||||||
|
"font-size": "35px", // Font size setting.
|
||||||
|
"font-weight": "600", // Font weight setting.
|
||||||
|
"font-style": "normal", // Font style setting.
|
||||||
|
"line-height": "1.35", // Line height setting.
|
||||||
|
"letter-spacing": "-0.01em", // Letter spacing setting.
|
||||||
|
"word-spacing": "0", // Word spacing setting.
|
||||||
|
"font-kerning": "normal", // Font kerning setting.
|
||||||
|
"text-rendering": "geometricPrecision", // Text rendering setting.
|
||||||
|
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
|
||||||
|
"backdrop-filter": "blur(6px)", // Backdrop filter setting.
|
||||||
|
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting.
|
||||||
|
"--subtitle-hover-token-background-color": "transparent" // Subtitle hover token background color setting.
|
||||||
|
}, // CSS declaration object applied to primary subtitles after normal subtitle style defaults.
|
||||||
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
||||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
||||||
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
||||||
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
"nameMatchEnabled": false, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
|
||||||
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
|
|
||||||
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
|
|
||||||
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
||||||
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
||||||
"fontSize": 35, // Font size setting.
|
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
||||||
"fontColor": "#cad3f5", // Font color setting.
|
|
||||||
"fontWeight": "600", // Font weight setting.
|
|
||||||
"lineHeight": 1.35, // Line height setting.
|
|
||||||
"letterSpacing": "-0.01em", // Letter spacing setting.
|
|
||||||
"wordSpacing": 0, // Word spacing setting.
|
|
||||||
"fontKerning": "normal", // Font kerning setting.
|
|
||||||
"textRendering": "geometricPrecision", // Text rendering setting.
|
|
||||||
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
|
|
||||||
"fontStyle": "normal", // Font style setting.
|
|
||||||
"backgroundColor": "transparent", // Background color setting.
|
|
||||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
|
||||||
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
|
|
||||||
"knownWordColor": "#a6da95", // Known word color setting.
|
|
||||||
"jlptColors": {
|
"jlptColors": {
|
||||||
"N1": "#ed8796", // N1 setting.
|
"N1": "#ed8796", // N1 setting.
|
||||||
"N2": "#f5a97f", // N2 setting.
|
"N2": "#f5a97f", // N2 setting.
|
||||||
@@ -405,19 +410,21 @@
|
|||||||
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||||
}, // Frequency dictionary setting.
|
}, // Frequency dictionary setting.
|
||||||
"secondary": {
|
"secondary": {
|
||||||
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
|
"css": {
|
||||||
"fontSize": 24, // Font size setting.
|
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||||
"fontColor": "#cad3f5", // Font color setting.
|
"color": "#cad3f5", // Color setting.
|
||||||
"lineHeight": 1.35, // Line height setting.
|
"background-color": "transparent", // Background color setting.
|
||||||
"letterSpacing": "-0.01em", // Letter spacing setting.
|
"font-size": "24px", // Font size setting.
|
||||||
"wordSpacing": 0, // Word spacing setting.
|
"font-weight": "600", // Font weight setting.
|
||||||
"fontKerning": "normal", // Font kerning setting.
|
"font-style": "normal", // Font style setting.
|
||||||
"textRendering": "geometricPrecision", // Text rendering setting.
|
"line-height": "1.35", // Line height setting.
|
||||||
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
|
"letter-spacing": "-0.01em", // Letter spacing setting.
|
||||||
"backgroundColor": "transparent", // Background color setting.
|
"word-spacing": "0", // Word spacing setting.
|
||||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
"font-kerning": "normal", // Font kerning setting.
|
||||||
"fontWeight": "600", // Font weight setting.
|
"text-rendering": "geometricPrecision", // Text rendering setting.
|
||||||
"fontStyle": "normal" // Font style setting.
|
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
|
||||||
|
"backdrop-filter": "blur(6px)" // Backdrop filter setting.
|
||||||
|
} // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
|
||||||
} // Secondary setting.
|
} // Secondary setting.
|
||||||
}, // Primary and secondary subtitle styling.
|
}, // Primary and secondary subtitle styling.
|
||||||
|
|
||||||
@@ -431,18 +438,20 @@
|
|||||||
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
|
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
|
||||||
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
|
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
|
||||||
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
|
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
|
||||||
"pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
|
"pauseVideoOnHover": true, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
|
||||||
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
|
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
|
||||||
"maxWidth": 420, // Maximum sidebar width in CSS pixels.
|
"css": {
|
||||||
"opacity": 0.95, // Base opacity applied to the sidebar shell.
|
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||||
"backgroundColor": "rgba(73, 77, 100, 0.9)", // Background color for the subtitle sidebar shell.
|
"color": "#cad3f5", // Color setting.
|
||||||
"textColor": "#cad3f5", // Default cue text color in the subtitle sidebar.
|
"background-color": "rgba(73, 77, 100, 0.9)", // Background color setting.
|
||||||
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif", // Font family used for subtitle sidebar cue text.
|
"font-size": "16px", // Font size setting.
|
||||||
"fontSize": 16, // Base font size for subtitle sidebar cue text in CSS pixels.
|
"opacity": "0.95", // Opacity setting.
|
||||||
"timestampColor": "#a5adcb", // Timestamp color in the subtitle sidebar.
|
"--subtitle-sidebar-max-width": "420px", // Subtitle sidebar max width setting.
|
||||||
"activeLineColor": "#f5bde6", // Text color for the active subtitle cue.
|
"--subtitle-sidebar-timestamp-color": "#a5adcb", // Subtitle sidebar timestamp color setting.
|
||||||
"activeLineBackgroundColor": "rgba(138, 173, 244, 0.22)", // Background color for the active subtitle cue.
|
"--subtitle-sidebar-active-line-color": "#f5bde6", // Subtitle sidebar active line color setting.
|
||||||
"hoverLineBackgroundColor": "rgba(54, 58, 79, 0.84)" // Background color for hovered subtitle cues.
|
"--subtitle-sidebar-active-background-color": "rgba(138, 173, 244, 0.22)", // Subtitle sidebar active background color setting.
|
||||||
|
"--subtitle-sidebar-hover-background-color": "rgba(54, 58, 79, 0.84)" // Subtitle sidebar hover background color setting.
|
||||||
|
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
|
||||||
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
|
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -453,22 +462,22 @@
|
|||||||
"enabled": false, // Enable shared OpenAI-compatible AI provider features. Values: true | false
|
"enabled": false, // Enable shared OpenAI-compatible AI provider features. Values: true | false
|
||||||
"apiKey": "", // Static API key for the shared OpenAI-compatible AI provider.
|
"apiKey": "", // Static API key for the shared OpenAI-compatible AI provider.
|
||||||
"apiKeyCommand": "", // Shell command used to resolve the shared AI provider API key.
|
"apiKeyCommand": "", // Shell command used to resolve the shared AI provider API key.
|
||||||
"model": "openai/gpt-4o-mini", // Model setting.
|
"model": "openai/gpt-4o-mini", // Default model identifier requested from the shared AI provider.
|
||||||
"baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider.
|
"baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider.
|
||||||
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting.
|
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // Default system prompt sent with shared AI provider requests.
|
||||||
"requestTimeoutMs": 15000 // Timeout in milliseconds for shared AI provider requests.
|
"requestTimeoutMs": 15000 // Timeout in milliseconds for shared AI provider requests.
|
||||||
}, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
|
}, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// AnkiConnect Integration
|
// AnkiConnect Integration
|
||||||
// Automatic Anki updates and media generation options.
|
// Automatic Anki updates and media generation options.
|
||||||
// Hot-reload: ankiConnect.ai.enabled updates live while SubMiner is running.
|
// Hot-reload: ankiConnect.ai.enabled, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
||||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||||
// Most other AnkiConnect settings still require restart.
|
// Most other AnkiConnect settings still require restart.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"ankiConnect": {
|
"ankiConnect": {
|
||||||
"enabled": true, // Enable AnkiConnect integration. Values: true | false
|
"enabled": true, // Enable AnkiConnect integration. Values: true | false
|
||||||
"url": "http://127.0.0.1:8765", // Url setting.
|
"url": "http://127.0.0.1:8765", // Base URL of the AnkiConnect HTTP server.
|
||||||
"pollingRate": 3000, // Polling interval in milliseconds.
|
"pollingRate": 3000, // Polling interval in milliseconds.
|
||||||
"proxy": {
|
"proxy": {
|
||||||
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
|
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
|
||||||
@@ -481,11 +490,11 @@
|
|||||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||||
"fields": {
|
"fields": {
|
||||||
"word": "Expression", // Card field for the mined word or expression text.
|
"word": "Expression", // Card field for the mined word or expression text.
|
||||||
"audio": "ExpressionAudio", // Audio setting.
|
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||||
"image": "Picture", // Image setting.
|
"image": "Picture", // Card field that receives the captured screenshot or animated image.
|
||||||
"sentence": "Sentence", // Sentence setting.
|
"sentence": "Sentence", // Card field that receives the source sentence text.
|
||||||
"miscInfo": "MiscInfo", // Misc info setting.
|
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
|
||||||
"translation": "SelectionText" // Translation setting.
|
"translation": "SelectionText" // Card field that receives the current selection or translated text.
|
||||||
}, // Fields setting.
|
}, // Fields setting.
|
||||||
"ai": {
|
"ai": {
|
||||||
"enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false
|
"enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false
|
||||||
@@ -493,59 +502,59 @@
|
|||||||
"systemPrompt": "" // Optional system prompt override for Anki AI translation/enrichment flows.
|
"systemPrompt": "" // Optional system prompt override for Anki AI translation/enrichment flows.
|
||||||
}, // Ai setting.
|
}, // Ai setting.
|
||||||
"media": {
|
"media": {
|
||||||
"generateAudio": true, // Generate audio setting. Values: true | false
|
"generateAudio": true, // Generate sentence audio for mined cards. Values: true | false
|
||||||
"generateImage": true, // Generate image setting. Values: true | false
|
"generateImage": true, // Generate screenshot or animated image for mined cards. Values: true | false
|
||||||
"imageType": "static", // Image type setting.
|
"imageType": "static", // Image capture type: "static" for a single still frame, "avif" for an animated AVIF. Values: static | avif
|
||||||
"imageFormat": "jpg", // Image format setting.
|
"imageFormat": "jpg", // Encoding format used when imageType is "static". Values: jpg | png | webp
|
||||||
"imageQuality": 92, // Image quality setting.
|
"imageQuality": 92, // Quality (0-100) used for lossy static image encoders.
|
||||||
"animatedFps": 10, // Animated fps setting.
|
"animatedFps": 10, // Target frame rate for animated AVIF captures.
|
||||||
"animatedMaxWidth": 640, // Animated max width setting.
|
"animatedMaxWidth": 640, // Maximum width applied to animated AVIF captures.
|
||||||
"animatedCrf": 35, // Animated crf setting.
|
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||||
"audioPadding": 0.5, // Audio padding setting.
|
"audioPadding": 0.5, // Seconds of padding appended to both ends of generated sentence audio.
|
||||||
"fallbackDuration": 3, // Fallback duration setting.
|
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||||
"maxMediaDuration": 30 // Max media duration setting.
|
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||||
}, // Media setting.
|
}, // Media setting.
|
||||||
"knownWords": {
|
"knownWords": {
|
||||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
|
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
|
||||||
"decks": {}, // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
|
"decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
|
||||||
"color": "#a6da95" // Color used for known-word highlights.
|
|
||||||
}, // Known words setting.
|
}, // Known words setting.
|
||||||
"behavior": {
|
"behavior": {
|
||||||
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
||||||
"overwriteImage": true, // Overwrite image setting. Values: true | false
|
"overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false
|
||||||
"mediaInsertMode": "append", // Media insert mode setting.
|
"mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend
|
||||||
"highlightWord": true, // Highlight word setting. Values: true | false
|
"highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false
|
||||||
"notificationType": "osd", // Notification type setting.
|
"notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none
|
||||||
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
||||||
}, // Behavior setting.
|
}, // Behavior setting.
|
||||||
"nPlusOne": {
|
"nPlusOne": {
|
||||||
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
|
"enabled": false, // Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data. Values: true | false
|
||||||
"nPlusOne": "#c6a0f6" // Color used for the single N+1 target token highlight.
|
"minSentenceWords": 3 // Minimum sentence word count required for N+1 targeting (default: 3).
|
||||||
}, // N plus one setting.
|
}, // N plus one setting.
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"pattern": "[SubMiner] %f (%t)" // Pattern setting.
|
"pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).
|
||||||
}, // Metadata setting.
|
}, // Metadata setting.
|
||||||
"isLapis": {
|
"isLapis": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
|
||||||
"sentenceCardModel": "Japanese sentences" // Sentence card model setting.
|
"sentenceCardModel": "Lapis" // Note type name used by Lapis sentence cards.
|
||||||
}, // Is lapis setting.
|
}, // Is lapis setting.
|
||||||
"isKiku": {
|
"isKiku": {
|
||||||
"enabled": false, // Enabled setting. Values: true | false
|
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||||
"deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
|
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||||
} // Is kiku setting.
|
} // Is kiku setting.
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Jimaku
|
// Jimaku
|
||||||
// Jimaku API configuration and defaults.
|
// Jimaku API configuration and defaults.
|
||||||
|
// Hot-reload: Jimaku changes apply to the next Jimaku request.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"jimaku": {
|
"jimaku": {
|
||||||
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
|
||||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
||||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
||||||
}, // Jimaku API configuration and defaults.
|
}, // Jimaku API configuration and defaults.
|
||||||
@@ -553,6 +562,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// YouTube Playback Settings
|
// YouTube Playback Settings
|
||||||
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||||
|
// Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"youtube": {
|
"youtube": {
|
||||||
"primarySubLanguages": [
|
"primarySubLanguages": [
|
||||||
@@ -597,14 +607,23 @@
|
|||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// MPV Launcher
|
// MPV Launcher
|
||||||
// Optional mpv.exe override for Windows playback entry points.
|
// SubMiner-managed mpv launch and bundled plugin options.
|
||||||
|
// Set mpv.socketPath to the IPC socket used by the launcher, Electron app, and bundled plugin.
|
||||||
|
// autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.
|
||||||
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
|
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
|
||||||
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"mpv": {
|
"mpv": {
|
||||||
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||||
"launchMode": "normal" // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
||||||
}, // Optional mpv.exe override for Windows playback entry points.
|
"socketPath": "/tmp/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
|
||||||
|
"subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.
|
||||||
|
"aniskipEnabled": true, // Enable AniSkip intro detection and skip markers in the bundled mpv plugin. Values: true | false
|
||||||
|
"aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible.
|
||||||
|
}, // SubMiner-managed mpv launch and bundled plugin options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Jellyfin
|
// Jellyfin
|
||||||
@@ -617,9 +636,9 @@
|
|||||||
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
||||||
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
|
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
|
||||||
"username": "", // Default Jellyfin username used during CLI login.
|
"username": "", // Default Jellyfin username used during CLI login.
|
||||||
"deviceId": "subminer", // Device id setting.
|
"deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.
|
||||||
"clientName": "SubMiner", // Client name setting.
|
"clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal.
|
||||||
"clientVersion": "0.1.0", // Client version setting.
|
"clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal.
|
||||||
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
|
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
|
||||||
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
|
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
|
||||||
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
|
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
|
||||||
@@ -647,7 +666,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
"discordPresence": {
|
"discordPresence": {
|
||||||
"enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false
|
"enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false
|
||||||
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".
|
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". Values: default | meme | japanese | minimal
|
||||||
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
||||||
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
||||||
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { expect, test } from 'bun:test';
|
import { expect, test } from 'bun:test';
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
import type { TransformContext } from 'vitepress';
|
import type { TransformContext } from 'vitepress';
|
||||||
import docsConfig from './.vitepress/config';
|
import docsConfig from './.vitepress/config';
|
||||||
|
|
||||||
|
const docsSiteDir = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
function makeTransformContext(page: string): TransformContext {
|
function makeTransformContext(page: string): TransformContext {
|
||||||
return {
|
return {
|
||||||
page,
|
page,
|
||||||
@@ -31,6 +37,391 @@ test('docs pages emit stable self-referential canonical URLs', async () => {
|
|||||||
expect(JSON.stringify(rootHead).toLowerCase()).not.toContain('noindex');
|
expect(JSON.stringify(rootHead).toLowerCase()).not.toContain('noindex');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('main docs canonical uses /main/ and emits noindex', async () => {
|
||||||
|
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||||
|
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||||
|
process.env.SUBMINER_DOCS_CHANNEL = 'main';
|
||||||
|
process.env.SUBMINER_DOCS_BASE = '/main/';
|
||||||
|
const { default: mainDocsConfig } = await import('./.vitepress/config?main-docs');
|
||||||
|
|
||||||
|
const head = await mainDocsConfig.transformHead?.(makeTransformContext('usage.md'));
|
||||||
|
const rootHead = await mainDocsConfig.transformHead?.(makeTransformContext('index.md'));
|
||||||
|
|
||||||
|
expect(head).toContainEqual([
|
||||||
|
'link',
|
||||||
|
{ rel: 'canonical', href: 'https://docs.subminer.moe/main/usage' },
|
||||||
|
]);
|
||||||
|
expect(rootHead).toContainEqual([
|
||||||
|
'link',
|
||||||
|
{ rel: 'canonical', href: 'https://docs.subminer.moe/main/' },
|
||||||
|
]);
|
||||||
|
expect(head).toContainEqual(['meta', { name: 'robots', content: 'noindex,follow' }]);
|
||||||
|
|
||||||
|
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||||
|
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('latest stable archive canonical points to root equivalent', async () => {
|
||||||
|
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||||
|
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||||
|
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||||
|
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||||
|
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
|
||||||
|
process.env.SUBMINER_DOCS_BASE = '/v/0.14.0/';
|
||||||
|
process.env.SUBMINER_DOCS_VERSION = 'v0.14.0';
|
||||||
|
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
||||||
|
const { default: latestArchiveConfig } = await import('./.vitepress/config?latest-archive');
|
||||||
|
|
||||||
|
const head = await latestArchiveConfig.transformHead?.(makeTransformContext('usage.md'));
|
||||||
|
|
||||||
|
expect(head).toContainEqual([
|
||||||
|
'link',
|
||||||
|
{ rel: 'canonical', href: 'https://docs.subminer.moe/usage' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||||
|
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||||
|
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||||
|
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stable archive theme links stay on the selected version', async () => {
|
||||||
|
const previousCwd = process.cwd();
|
||||||
|
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||||
|
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||||
|
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||||
|
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||||
|
const previousManifest = process.env.SUBMINER_DOCS_VERSION_MANIFEST;
|
||||||
|
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
|
||||||
|
process.chdir(docsSiteDir);
|
||||||
|
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
|
||||||
|
process.env.SUBMINER_DOCS_BASE = '/v/0.12.0/';
|
||||||
|
process.env.SUBMINER_DOCS_VERSION = 'v0.12.0';
|
||||||
|
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
||||||
|
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'production';
|
||||||
|
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
|
||||||
|
latestStable: 'v0.14.0',
|
||||||
|
channels: [
|
||||||
|
{ label: 'Latest stable', path: '/' },
|
||||||
|
{ label: 'main', path: '/main/' },
|
||||||
|
],
|
||||||
|
versions: [
|
||||||
|
{ version: 'v0.14.0', path: '/v/0.14.0/' },
|
||||||
|
{ version: 'v0.12.0', path: '/v/0.12.0/' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const { default: archiveConfig } = await import('./.vitepress/config?stable-archive-links');
|
||||||
|
|
||||||
|
const nav = archiveConfig.themeConfig?.nav as Array<{
|
||||||
|
text: string;
|
||||||
|
link?: string;
|
||||||
|
items?: Array<{ text: string; link: string }>;
|
||||||
|
}>;
|
||||||
|
const sidebar = archiveConfig.themeConfig?.sidebar as Array<{
|
||||||
|
text: string;
|
||||||
|
items?: Array<{ text: string; link: string }>;
|
||||||
|
}>;
|
||||||
|
const configurationNav = nav.find((item) => item.text === 'Configuration');
|
||||||
|
const versionNav = nav.find((item) => item.text === 'v0.12.0');
|
||||||
|
const referenceSidebar = sidebar.find((item) => item.text === 'Reference');
|
||||||
|
const configurationSidebar = referenceSidebar?.items?.find(
|
||||||
|
(item) => item.text === 'Configuration',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(configurationNav?.link).toBe('/configuration');
|
||||||
|
expect(configurationSidebar?.link).toBe('/configuration');
|
||||||
|
expect(versionNav?.items).toContainEqual({
|
||||||
|
text: 'Latest stable (v0.14.0)',
|
||||||
|
link: 'https://docs.subminer.moe/',
|
||||||
|
target: '_self',
|
||||||
|
noIcon: true,
|
||||||
|
});
|
||||||
|
expect(versionNav?.items).toContainEqual({
|
||||||
|
text: 'main',
|
||||||
|
link: 'https://docs.subminer.moe/main/',
|
||||||
|
target: '_self',
|
||||||
|
noIcon: true,
|
||||||
|
});
|
||||||
|
expect(versionNav?.items).toContainEqual({
|
||||||
|
text: 'v0.14.0',
|
||||||
|
link: 'https://docs.subminer.moe/v/0.14.0/',
|
||||||
|
target: '_self',
|
||||||
|
noIcon: true,
|
||||||
|
});
|
||||||
|
expect(versionNav?.items).toContainEqual({
|
||||||
|
text: 'v0.12.0',
|
||||||
|
link: 'https://docs.subminer.moe/v/0.12.0/',
|
||||||
|
target: '_self',
|
||||||
|
noIcon: true,
|
||||||
|
});
|
||||||
|
expect(archiveConfig.themeConfig?.logo).toEqual({
|
||||||
|
light: '/assets/SubMiner.png',
|
||||||
|
dark: '/assets/SubMiner.png',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
process.chdir(previousCwd);
|
||||||
|
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||||
|
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||||
|
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||||
|
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||||
|
process.env.SUBMINER_DOCS_VERSION_MANIFEST = previousManifest;
|
||||||
|
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('local stable archive version links stay on the dev server', async () => {
|
||||||
|
const previousCwd = process.cwd();
|
||||||
|
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||||
|
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||||
|
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||||
|
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||||
|
const previousManifest = process.env.SUBMINER_DOCS_VERSION_MANIFEST;
|
||||||
|
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
|
||||||
|
process.chdir(docsSiteDir);
|
||||||
|
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
|
||||||
|
process.env.SUBMINER_DOCS_BASE = '/v/0.10.0/';
|
||||||
|
process.env.SUBMINER_DOCS_VERSION = 'v0.10.0';
|
||||||
|
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
||||||
|
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
|
||||||
|
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
|
||||||
|
latestStable: 'v0.14.0',
|
||||||
|
channels: [
|
||||||
|
{ label: 'Latest stable', path: '/' },
|
||||||
|
{ label: 'main', path: '/main/' },
|
||||||
|
],
|
||||||
|
versions: [
|
||||||
|
{ version: 'v0.14.0', path: '/v/0.14.0/' },
|
||||||
|
{ version: 'v0.10.0', path: '/v/0.10.0/' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const { default: archiveConfig } = await import('./.vitepress/config?local-archive-links');
|
||||||
|
|
||||||
|
const nav = archiveConfig.themeConfig?.nav as Array<{
|
||||||
|
text: string;
|
||||||
|
items?: Array<{ text: string; link: string }>;
|
||||||
|
}>;
|
||||||
|
const versionNav = nav.find((item) => item.text === 'v0.10.0');
|
||||||
|
|
||||||
|
expect(versionNav?.items).toContainEqual({
|
||||||
|
text: 'Latest stable (v0.14.0)',
|
||||||
|
link: '../../',
|
||||||
|
target: '_self',
|
||||||
|
noIcon: true,
|
||||||
|
});
|
||||||
|
expect(versionNav?.items).toContainEqual({
|
||||||
|
text: 'main',
|
||||||
|
link: '../../main/',
|
||||||
|
target: '_self',
|
||||||
|
noIcon: true,
|
||||||
|
});
|
||||||
|
expect(versionNav?.items).toContainEqual({
|
||||||
|
text: 'v0.14.0',
|
||||||
|
link: '../0.14.0/',
|
||||||
|
target: '_self',
|
||||||
|
noIcon: true,
|
||||||
|
});
|
||||||
|
expect(versionNav?.items).toContainEqual({
|
||||||
|
text: 'v0.10.0',
|
||||||
|
link: './',
|
||||||
|
target: '_self',
|
||||||
|
noIcon: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
process.chdir(previousCwd);
|
||||||
|
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||||
|
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||||
|
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||||
|
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||||
|
process.env.SUBMINER_DOCS_VERSION_MANIFEST = previousManifest;
|
||||||
|
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dev docs version links use local targets for version route testing', async () => {
|
||||||
|
const previousCwd = process.cwd();
|
||||||
|
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||||
|
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||||
|
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||||
|
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||||
|
const previousManifest = process.env.SUBMINER_DOCS_VERSION_MANIFEST;
|
||||||
|
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
|
||||||
|
process.chdir(docsSiteDir);
|
||||||
|
delete process.env.SUBMINER_DOCS_CHANNEL;
|
||||||
|
delete process.env.SUBMINER_DOCS_BASE;
|
||||||
|
delete process.env.SUBMINER_DOCS_VERSION;
|
||||||
|
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',
|
||||||
|
channels: [
|
||||||
|
{ label: 'Latest stable', path: '/' },
|
||||||
|
{ label: 'main', path: '/main/' },
|
||||||
|
],
|
||||||
|
versions: [
|
||||||
|
{ version: 'v0.14.0', path: '/v/0.14.0/' },
|
||||||
|
{ version: 'v0.12.0', path: '/v/0.12.0/' },
|
||||||
|
{ version: 'v0.11.2', path: '/v/0.11.2/' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const { default: devConfig } = await import('./.vitepress/config?dev-version-links');
|
||||||
|
|
||||||
|
const nav = devConfig.themeConfig?.nav as Array<{
|
||||||
|
text: string;
|
||||||
|
items?: Array<{ text: string; link: string }>;
|
||||||
|
}>;
|
||||||
|
const versionNav = nav.find((item) => item.text === 'v0.14.0');
|
||||||
|
|
||||||
|
expect(versionNav?.items).toContainEqual({
|
||||||
|
text: 'Latest stable (v0.14.0)',
|
||||||
|
link: '/',
|
||||||
|
target: '_self',
|
||||||
|
noIcon: true,
|
||||||
|
});
|
||||||
|
expect(versionNav?.items).toContainEqual({
|
||||||
|
text: 'main',
|
||||||
|
link: '/main/',
|
||||||
|
target: '_self',
|
||||||
|
noIcon: true,
|
||||||
|
});
|
||||||
|
expect(versionNav?.items).toContainEqual({
|
||||||
|
text: 'v0.12.0',
|
||||||
|
link: '/v/0.12.0/',
|
||||||
|
target: '_self',
|
||||||
|
noIcon: true,
|
||||||
|
});
|
||||||
|
expect(versionNav?.items?.map((item) => item.text)).toEqual([
|
||||||
|
'Latest stable (v0.14.0)',
|
||||||
|
'main',
|
||||||
|
'v0.14.0',
|
||||||
|
'v0.12.0',
|
||||||
|
'v0.11.2',
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
process.chdir(previousCwd);
|
||||||
|
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||||
|
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||||
|
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||||
|
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||||
|
process.env.SUBMINER_DOCS_VERSION_MANIFEST = previousManifest;
|
||||||
|
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dev server redirects unserved version routes to production docs', () => {
|
||||||
|
let routeHandler:
|
||||||
|
| ((req: { url?: string }, res: DevRedirectResponse, next: () => void) => void)
|
||||||
|
| undefined;
|
||||||
|
const fakeServer = {
|
||||||
|
middlewares: {
|
||||||
|
use(handler: typeof routeHandler) {
|
||||||
|
routeHandler = handler;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const plugins = Array.isArray(docsConfig.vite?.plugins)
|
||||||
|
? docsConfig.vite.plugins
|
||||||
|
: [docsConfig.vite?.plugins].filter(Boolean);
|
||||||
|
const redirectPlugin = plugins.find(
|
||||||
|
(plugin): plugin is { name: string; configureServer: (server: never) => void } =>
|
||||||
|
Boolean(plugin) &&
|
||||||
|
typeof plugin === 'object' &&
|
||||||
|
'name' in plugin &&
|
||||||
|
plugin.name === 'subminer-docs-local-version-redirects' &&
|
||||||
|
'configureServer' in plugin,
|
||||||
|
);
|
||||||
|
expect(redirectPlugin).toBeDefined();
|
||||||
|
redirectPlugin?.configureServer(fakeServer as never);
|
||||||
|
|
||||||
|
const response = new DevRedirectResponse();
|
||||||
|
let nextCalled = false;
|
||||||
|
routeHandler?.({ url: '/v/0.14.0/?from=dev' }, response, () => {
|
||||||
|
nextCalled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(nextCalled).toBe(false);
|
||||||
|
expect(response.statusCode).toBe(302);
|
||||||
|
expect(response.headers.location).toBe('https://docs.subminer.moe/v/0.14.0/?from=dev');
|
||||||
|
|
||||||
|
const rootResponse = new DevRedirectResponse();
|
||||||
|
routeHandler?.({ url: '/configuration' }, rootResponse, () => {
|
||||||
|
nextCalled = true;
|
||||||
|
});
|
||||||
|
expect(rootResponse.ended).toBe(false);
|
||||||
|
expect(nextCalled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dev server serves local archive files for local version links', async () => {
|
||||||
|
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
|
||||||
|
const previousArchiveDir = process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR;
|
||||||
|
const archiveDir = mkdtempSync(join(tmpdir(), 'subminer-docs-archive-'));
|
||||||
|
mkdirSync(join(archiveDir, 'v/0.14.0'), { recursive: true });
|
||||||
|
writeFileSync(join(archiveDir, 'v/0.14.0/index.html'), '<h1>local archive</h1>');
|
||||||
|
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
|
||||||
|
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR = archiveDir;
|
||||||
|
try {
|
||||||
|
const { default: localDevConfig } = await import('./.vitepress/config?local-dev-redirects');
|
||||||
|
let routeHandler:
|
||||||
|
| ((req: { url?: string }, res: DevRedirectResponse, next: () => void) => void)
|
||||||
|
| undefined;
|
||||||
|
const fakeServer = {
|
||||||
|
middlewares: {
|
||||||
|
use(handler: typeof routeHandler) {
|
||||||
|
routeHandler = handler;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const plugins = Array.isArray(localDevConfig.vite?.plugins)
|
||||||
|
? localDevConfig.vite.plugins
|
||||||
|
: [localDevConfig.vite?.plugins].filter(Boolean);
|
||||||
|
const redirectPlugin = plugins.find(
|
||||||
|
(plugin): plugin is { name: string; configureServer: (server: never) => void } =>
|
||||||
|
Boolean(plugin) &&
|
||||||
|
typeof plugin === 'object' &&
|
||||||
|
'name' in plugin &&
|
||||||
|
plugin.name === 'subminer-docs-local-version-redirects' &&
|
||||||
|
'configureServer' in plugin,
|
||||||
|
);
|
||||||
|
redirectPlugin?.configureServer(fakeServer as never);
|
||||||
|
|
||||||
|
const response = new DevRedirectResponse();
|
||||||
|
let nextCalled = false;
|
||||||
|
routeHandler?.({ url: '/v/0.14.0/?from=dev' }, response, () => {
|
||||||
|
nextCalled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(nextCalled).toBe(false);
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.headers['content-type']).toBe('text/html; charset=utf-8');
|
||||||
|
expect(response.headers.location).toBeUndefined();
|
||||||
|
expect(response.body).toBe('<h1>local archive</h1>');
|
||||||
|
} finally {
|
||||||
|
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
|
||||||
|
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR = previousArchiveDir;
|
||||||
|
rmSync(archiveDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
class DevRedirectResponse {
|
||||||
|
statusCode = 200;
|
||||||
|
headers: Record<string, string> = {};
|
||||||
|
ended = false;
|
||||||
|
body = '';
|
||||||
|
|
||||||
|
setHeader(name: string, value: string) {
|
||||||
|
this.headers[name.toLowerCase()] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
end(chunk?: string | Uint8Array) {
|
||||||
|
if (chunk) {
|
||||||
|
this.body = typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk);
|
||||||
|
}
|
||||||
|
this.ended = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test('docs sitemap excludes duplicate README page from indexable URLs', async () => {
|
test('docs sitemap excludes duplicate README page from indexable URLs', async () => {
|
||||||
const items = [{ url: '' }, { url: 'README' }, { url: 'usage' }];
|
const items = [{ url: '' }, { url: 'README' }, { url: 'usage' }];
|
||||||
|
|
||||||
|
|||||||
+13
-11
@@ -61,6 +61,8 @@ These control playback and subtitle display. They require overlay window focus.
|
|||||||
|
|
||||||
These keybindings can be overridden or disabled via the `keybindings` config array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
|
These keybindings can be overridden or disabled via the `keybindings` config array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
|
||||||
|
|
||||||
|
On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions.
|
||||||
|
|
||||||
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave).
|
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave).
|
||||||
|
|
||||||
## Subtitle & Feature Shortcuts
|
## Subtitle & Feature Shortcuts
|
||||||
@@ -68,7 +70,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
|||||||
| Shortcut | Action | Config key |
|
| Shortcut | Action | Config key |
|
||||||
| ------------------ | -------------------------------------------------------- | ----------------------------------- |
|
| ------------------ | -------------------------------------------------------- | ----------------------------------- |
|
||||||
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
||||||
| `Ctrl/Cmd+Alt+A` | Open character dictionary AniList selector | `shortcuts.openCharacterDictionary` |
|
| `Ctrl/Cmd+Alt+A` | Open character dictionary AniList selector | `shortcuts.openCharacterDictionary` |
|
||||||
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
||||||
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
|
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
|
||||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||||
@@ -96,17 +98,17 @@ Controller input only drives the overlay while keyboard-only mode is enabled. Th
|
|||||||
|
|
||||||
When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second.
|
When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second.
|
||||||
|
|
||||||
| Chord | Action |
|
| Chord | Action |
|
||||||
| ----- | ------------------------ |
|
| ----- | -------------------------------------- |
|
||||||
| `y-y` | Open SubMiner menu (OSD) |
|
| `y-y` | Open SubMiner menu (OSD) |
|
||||||
| `y-s` | Start overlay |
|
| `y-s` | Start overlay |
|
||||||
| `y-S` | Stop overlay |
|
| `y-S` | Stop overlay |
|
||||||
| `y-t` | Toggle visible overlay |
|
| `y-t` | Toggle visible overlay |
|
||||||
| `v` | Toggle primary subtitle bar visibility |
|
| `v` | Toggle primary subtitle bar visibility |
|
||||||
| `y-o` | Open Yomitan settings |
|
| `y-o` | Open Yomitan settings |
|
||||||
| `y-r` | Restart overlay |
|
| `y-r` | Restart overlay |
|
||||||
| `y-c` | Check overlay status |
|
| `y-c` | Check overlay status |
|
||||||
| `y-h` | Open session help |
|
| `y-h` | Open session help |
|
||||||
|
|
||||||
The bare `v` plugin binding intentionally overrides mpv's native primary subtitle visibility toggle so the SubMiner primary subtitle bar is hidden or restored instead.
|
The bare `v` plugin binding intentionally overrides mpv's native primary subtitle visibility toggle so the SubMiner primary subtitle bar is hidden or restored instead.
|
||||||
|
|
||||||
|
|||||||
@@ -15,20 +15,21 @@ N+1 highlighting identifies sentences where you know every word except one, maki
|
|||||||
1. SubMiner queries your Anki decks for existing `Expression` / `Word` field values.
|
1. SubMiner queries your Anki decks for existing `Expression` / `Word` field values.
|
||||||
2. The results are cached locally (`known-words-cache.json`) and refreshed on a configurable interval.
|
2. The results are cached locally (`known-words-cache.json`) and refreshed on a configurable interval.
|
||||||
3. When a subtitle line appears, each token is checked against the cache.
|
3. When a subtitle line appears, each token is checked against the cache.
|
||||||
4. If exactly one unknown word remains in the sentence, it is highlighted with `nPlusOneColor` (default: `#c6a0f6`).
|
4. If exactly one unknown word remains in the sentence, it is highlighted with `subtitleStyle.nPlusOneColor` (default: `#c6a0f6`).
|
||||||
5. Already-known tokens can optionally display in `knownWordColor` (default: `#a6da95`).
|
5. Already-known tokens can optionally display in `subtitleStyle.knownWordColor` (default: `#a6da95`).
|
||||||
|
|
||||||
**Key settings:**
|
**Key settings:**
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| --- | --- | --- |
|
| ----------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting |
|
| `ankiConnect.knownWords.highlightEnabled` | `false` | Enable known-word cache lookups used by N+1 highlighting |
|
||||||
| `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes |
|
| `ankiConnect.knownWords.refreshMinutes` | `1440` | Minutes between Anki cache refreshes |
|
||||||
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries (legacy fallback: `ankiConnect.deck`) |
|
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries (legacy fallback: `ankiConnect.deck`) |
|
||||||
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
|
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
|
||||||
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
|
| `ankiConnect.nPlusOne.enabled` | `false` | Enable N+1 target highlighting. Existing configs with known-word highlighting enabled are treated as enabled for compatibility unless this is explicitly set. |
|
||||||
| `ankiConnect.nPlusOne.nPlusOne` | `#c6a0f6` | Color for the single unknown target word |
|
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
|
||||||
| `ankiConnect.knownWords.color` | `#a6da95` | Color for already-known tokens |
|
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word |
|
||||||
|
| `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens |
|
||||||
|
|
||||||
::: tip
|
::: tip
|
||||||
Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large.
|
Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large.
|
||||||
@@ -46,10 +47,10 @@ Character-name matches are built from the active merged SubMiner character dicti
|
|||||||
|
|
||||||
**Key settings:**
|
**Key settings:**
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| --- | --- | --- |
|
| -------------------------------- | --------- | ---------------------------------------- |
|
||||||
| `subtitleStyle.nameMatchEnabled` | `true` | Enable character-name token highlighting |
|
| `subtitleStyle.nameMatchEnabled` | `false` | Enable character-name token highlighting |
|
||||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Color used for character-name matches |
|
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Color used for character-name matches |
|
||||||
|
|
||||||
For full details on dictionary generation, name variant expansion, auto-sync lifecycle, and configuration, see the dedicated [Character Dictionary](/character-dictionary) page.
|
For full details on dictionary generation, name variant expansion, auto-sync lifecycle, and configuration, see the dedicated [Character Dictionary](/character-dictionary) page.
|
||||||
|
|
||||||
@@ -66,15 +67,15 @@ SubMiner looks up each token's `frequencyRank` from `term_meta_bank_*.json` file
|
|||||||
|
|
||||||
**Key settings:**
|
**Key settings:**
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| --- | --- | --- |
|
| ------------------------------------------------ | ------------ | ---------------------------------------- |
|
||||||
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Enable frequency highlighting |
|
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Enable frequency highlighting |
|
||||||
| `subtitleStyle.frequencyDictionary.topX` | `1000` | Max frequency rank to highlight |
|
| `subtitleStyle.frequencyDictionary.topX` | `1000` | Max frequency rank to highlight |
|
||||||
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `"single"` or `"banded"` |
|
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `"single"` or `"banded"` |
|
||||||
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | `"headword"` or `"surface"` |
|
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | `"headword"` or `"surface"` |
|
||||||
| `subtitleStyle.frequencyDictionary.singleColor` | — | Color for single mode |
|
| `subtitleStyle.frequencyDictionary.singleColor` | — | Color for single mode |
|
||||||
| `subtitleStyle.frequencyDictionary.bandedColors` | — | Array of five hex colors for banded mode |
|
| `subtitleStyle.frequencyDictionary.bandedColors` | — | Array of five hex colors for banded mode |
|
||||||
| `subtitleStyle.frequencyDictionary.sourcePath` | — | Custom path to frequency dictionary root |
|
| `subtitleStyle.frequencyDictionary.sourcePath` | — | Custom path to frequency dictionary root |
|
||||||
|
|
||||||
When `sourcePath` is omitted, SubMiner searches default install/runtime locations for `frequency-dictionary` directories automatically.
|
When `sourcePath` is omitted, SubMiner searches default install/runtime locations for `frequency-dictionary` directories automatically.
|
||||||
|
|
||||||
@@ -96,22 +97,22 @@ SubMiner loads offline `term_meta_bank_*.json` files from `vendor/yomitan-jlpt-v
|
|||||||
|
|
||||||
**Default colors:**
|
**Default colors:**
|
||||||
|
|
||||||
| Level | Color | Preview |
|
| Level | Color | Preview |
|
||||||
| --- | --- | --- |
|
| ----- | --------- | ------- |
|
||||||
| N1 | `#ed8796` | Red |
|
| N1 | `#ed8796` | Red |
|
||||||
| N2 | `#f5a97f` | Peach |
|
| N2 | `#f5a97f` | Peach |
|
||||||
| N3 | `#f9e2af` | Yellow |
|
| N3 | `#f9e2af` | Yellow |
|
||||||
| N4 | `#a6e3a1` | Green |
|
| N4 | `#a6e3a1` | Green |
|
||||||
| N5 | `#8aadf4` | Blue |
|
| N5 | `#8aadf4` | Blue |
|
||||||
|
|
||||||
All colors are customizable via the `subtitleStyle.jlptColors` object.
|
All colors are customizable via the `subtitleStyle.jlptColors` object.
|
||||||
|
|
||||||
**Key settings:**
|
**Key settings:**
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| --- | --- | --- |
|
| ---------------------------------- | --------- | ----------------------------- |
|
||||||
| `subtitleStyle.enableJlpt` | `false` | Enable JLPT underline styling |
|
| `subtitleStyle.enableJlpt` | `false` | Enable JLPT underline styling |
|
||||||
| `subtitleStyle.jlptColors.N1`–`N5` | see above | Per-level underline colors |
|
| `subtitleStyle.jlptColors.N1`–`N5` | see above | Per-level underline colors |
|
||||||
|
|
||||||
## Runtime Toggles
|
## Runtime Toggles
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Enable and configure the sidebar under `subtitleSidebar` in your config file:
|
|||||||
"autoOpen": false,
|
"autoOpen": false,
|
||||||
"layout": "overlay",
|
"layout": "overlay",
|
||||||
"toggleKey": "Backslash",
|
"toggleKey": "Backslash",
|
||||||
"pauseVideoOnHover": false,
|
"pauseVideoOnHover": true,
|
||||||
"autoScroll": true,
|
"autoScroll": true,
|
||||||
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
|
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif",
|
||||||
"fontSize": 16
|
"fontSize": 16
|
||||||
@@ -47,7 +47,7 @@ Enable and configure the sidebar under `subtitleSidebar` in your config file:
|
|||||||
| `autoOpen` | boolean | `false` | Open the sidebar automatically on overlay startup |
|
| `autoOpen` | boolean | `false` | Open the sidebar automatically on overlay startup |
|
||||||
| `layout` | string | `"overlay"` | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space |
|
| `layout` | string | `"overlay"` | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space |
|
||||||
| `toggleKey` | string | `"Backslash"` | `KeyboardEvent.code` for the toggle shortcut |
|
| `toggleKey` | string | `"Backslash"` | `KeyboardEvent.code` for the toggle shortcut |
|
||||||
| `pauseVideoOnHover` | boolean | `false` | Pause playback while hovering the cue list |
|
| `pauseVideoOnHover` | boolean | `true` | Pause playback while hovering the cue list |
|
||||||
| `autoScroll` | boolean | `true` | Keep the active cue in view during playback |
|
| `autoScroll` | boolean | `true` | Keep the active cue in view during playback |
|
||||||
| `maxWidth` | number | `420` | Maximum sidebar width in CSS pixels |
|
| `maxWidth` | number | `420` | Maximum sidebar width in CSS pixels |
|
||||||
| `opacity` | number | `0.95` | Sidebar opacity between `0` and `1` |
|
| `opacity` | number | `0.95` | Sidebar opacity between `0` and `1` |
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ If you installed from the AppImage and see this error, the package may be incomp
|
|||||||
**Yomitan lookup popup does not appear when hovering words or triggering lookup**
|
**Yomitan lookup popup does not appear when hovering words or triggering lookup**
|
||||||
|
|
||||||
- Verify Yomitan loaded successfully — check the terminal output for "Loaded Yomitan extension".
|
- Verify Yomitan loaded successfully — check the terminal output for "Loaded Yomitan extension".
|
||||||
- Yomitan requires dictionaries to be installed. Open Yomitan settings (`Alt+Shift+Y` or `SubMiner.AppImage --settings`) and confirm at least one dictionary is imported.
|
- Yomitan requires dictionaries to be installed. Open Yomitan settings (`Alt+Shift+Y` or `SubMiner.AppImage --yomitan`) and confirm at least one dictionary is imported.
|
||||||
- If `yomitan.externalProfilePath` is set, import/check dictionaries in the external app/profile instead. SubMiner treats that profile as read-only and does not open its own Yomitan settings window.
|
- If `yomitan.externalProfilePath` is set, import/check dictionaries in the external app/profile instead. SubMiner treats that profile as read-only and does not open its own Yomitan settings window.
|
||||||
- If the overlay shows subtitles but hover lookup never resolves on tokens, the tokenizer may have failed. See the MeCab section below.
|
- If the overlay shows subtitles but hover lookup never resolves on tokens, the tokenizer may have failed. See the MeCab section below.
|
||||||
|
|
||||||
@@ -296,7 +296,7 @@ Install ffsubsync or configure the path:
|
|||||||
|
|
||||||
**"Subtitle synchronization failed"**
|
**"Subtitle synchronization failed"**
|
||||||
|
|
||||||
SubMiner tries alass first, then falls back to ffsubsync. If both fail:
|
If subtitle sync fails:
|
||||||
|
|
||||||
- Ensure the reference subtitle track exists in the video (alass requires a source track).
|
- Ensure the reference subtitle track exists in the video (alass requires a source track).
|
||||||
- Check that `ffmpeg` is available (used to extract the internal subtitle track).
|
- Check that `ffmpeg` is available (used to extract the internal subtitle track).
|
||||||
|
|||||||
+42
-29
@@ -1,11 +1,23 @@
|
|||||||
# Usage
|
# Usage
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Play a video with SubMiner:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
subminer video.mkv
|
||||||
|
```
|
||||||
|
|
||||||
|
On **Windows**, use the **SubMiner mpv** shortcut created during first-run setup — double-click it, or drag a video file onto it.
|
||||||
|
|
||||||
|
That's the simplest way to get started. The `subminer` launcher handles mpv, the IPC socket, and the overlay automatically.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> SubMiner requires the bundled Yomitan instance to have at least one dictionary imported for lookups to work.
|
> SubMiner requires the bundled Yomitan instance to have at least one dictionary imported for lookups to work.
|
||||||
> See [Yomitan setup](#yomitan-setup) for details.
|
> See [Yomitan setup](#yomitan-setup) for details.
|
||||||
|
|
||||||
::: tip Just finished first-run setup?
|
::: tip Anki card enrichment
|
||||||
If you want Anki card enrichment (sentence, audio, screenshot), the only config you need is `ankiConnect` with your deck name and field names. Here is a minimal working example:
|
If you want sentence, audio, and screenshot fields on your Anki cards, add this to your config:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
@@ -27,25 +39,22 @@ Field names must match your Anki note type exactly (case-sensitive). See [Anki I
|
|||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
1. SubMiner starts the overlay app in the background
|
1. SubMiner starts the overlay app in the background
|
||||||
2. MPV runs with an IPC socket at `/tmp/subminer-socket`
|
2. mpv runs with an IPC socket at `/tmp/subminer-socket`
|
||||||
3. The overlay connects and subscribes to subtitle changes
|
3. The overlay connects and subscribes to subtitle changes
|
||||||
4. Subtitles are tokenized with Yomitan's internal parser
|
4. Subtitles are tokenized with Yomitan's internal parser
|
||||||
5. Words are displayed as interactive spans in the overlay
|
5. Words are displayed as interactive spans in the overlay
|
||||||
6. Hover a word, then trigger Yomitan lookup with your configured lookup key/modifier to open the Yomitan popup
|
6. Hover a word, then trigger Yomitan lookup with your configured lookup key/modifier to open the Yomitan popup
|
||||||
7. Optional [subtitle annotations](/subtitle-annotations) (N+1, character-name, frequency, JLPT) highlight useful cues in real time
|
7. Optional [subtitle annotations](/subtitle-annotations) (N+1, character-name, frequency, JLPT) highlight useful cues in real time
|
||||||
|
|
||||||
There are several ways to use SubMiner:
|
### Ways to Launch
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> **New users on Linux/macOS: start with the `subminer` wrapper script.** On Windows, use the **SubMiner mpv** shortcut created during first-run setup. Both handle mpv launch, IPC socket setup, and overlay lifecycle automatically so you don't need to configure anything in `mpv.conf`.
|
|
||||||
|
|
||||||
| Approach | Use when | How |
|
| Approach | Use when | How |
|
||||||
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||||||
| **`subminer` script** | You want SubMiner to handle everything — launch mpv, set up the socket, start the overlay. **The simplest path and recommended starting point.** | `subminer video.mkv` |
|
| **`subminer` launcher** | You want SubMiner to handle everything — launch mpv, set up the socket, start the overlay. **Recommended for most users.** | `subminer video.mkv` |
|
||||||
| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults directly. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` |
|
| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` |
|
||||||
| **MPV plugin** (all platforms) | You launch mpv yourself or from another tool (file manager, Jellyfin, etc.). Requires `--input-ipc-server=/tmp/subminer-socket` in your mpv config. | Use `y` chord keybindings inside mpv |
|
| **mpv plugin** (all platforms) | Bundled and injected at runtime. Provides `y` chord keybindings for controlling the overlay from within mpv. No manual install needed. | Automatic when using the launcher or shortcut |
|
||||||
|
|
||||||
You can use both — the plugin provides in-player controls, while the `subminer` script (Linux/macOS) or the SubMiner mpv shortcut (Windows) is convenient for direct playback.
|
The mpv plugin is always available — it's bundled with SubMiner and injected at runtime. 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
|
## Live Config Reload
|
||||||
|
|
||||||
@@ -72,12 +81,14 @@ subminer # Current directory (uses fzf)
|
|||||||
subminer -R # Use rofi instead of fzf
|
subminer -R # Use rofi instead of fzf
|
||||||
subminer -d ~/Videos # Specific directory
|
subminer -d ~/Videos # Specific directory
|
||||||
subminer -r -d ~/Anime # Recursive search
|
subminer -r -d ~/Anime # Recursive search
|
||||||
subminer video.mkv # Play specific file (default plugin config auto-starts visible overlay)
|
subminer video.mkv # Play specific file (overlay auto-starts)
|
||||||
subminer --start video.mkv # Optional explicit overlay start (use when plugin auto_start=no)
|
subminer --start video.mkv # Explicit overlay start (use when auto_start=no in config)
|
||||||
subminer -S video.mkv # Same as above via --start-overlay
|
subminer -S video.mkv # Same as above via --start-overlay
|
||||||
subminer https://youtu.be/... # Play a YouTube URL
|
subminer https://youtu.be/... # Play a YouTube URL
|
||||||
subminer ytsearch:"jp news" # Play first YouTube search result
|
subminer ytsearch:"jp news" # Play first YouTube search result
|
||||||
subminer --setup # Open first-run setup popup
|
subminer --setup # Open first-run setup popup
|
||||||
|
subminer --version # Print installed SubMiner version
|
||||||
|
subminer -v # Same as above
|
||||||
subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging
|
subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging
|
||||||
subminer --log-level warn video.mkv # Set logging level explicitly
|
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
|
subminer --args '--fs=opengl-hq --ytdl-format=bestvideo*+bestaudio/best' video.mkv # Pass extra mpv args
|
||||||
@@ -120,13 +131,14 @@ SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle
|
|||||||
SubMiner.AppImage --start --dev # Enable app/dev mode only
|
SubMiner.AppImage --start --dev # Enable app/dev mode only
|
||||||
SubMiner.AppImage --start --debug # Alias for --dev
|
SubMiner.AppImage --start --debug # Alias for --dev
|
||||||
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
|
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
|
||||||
SubMiner.AppImage --settings # Open Yomitan settings
|
SubMiner.AppImage --yomitan # Open Yomitan settings
|
||||||
|
SubMiner.AppImage --settings # Open SubMiner settings window
|
||||||
SubMiner.AppImage --jellyfin # Open Jellyfin setup 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-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-logout # Clear stored Jellyfin token/session data
|
||||||
SubMiner.AppImage --jellyfin-libraries
|
SubMiner.AppImage --jellyfin-libraries
|
||||||
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search anime --jellyfin-limit 20
|
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 or plugin workflow)
|
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 --jellyfin-remote-announce # Force cast-target capability announce + visibility check
|
||||||
SubMiner.AppImage --dictionary # Generate character dictionary ZIP for current anime
|
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-candidates # List AniList candidates for current character dictionary series
|
||||||
@@ -151,7 +163,7 @@ Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for sta
|
|||||||
|
|
||||||
### Windows mpv Shortcut
|
### Windows mpv Shortcut
|
||||||
|
|
||||||
First-run setup creates the config file, then requires Yomitan dictionaries before it can finish. The global mpv plugin install is optional because SubMiner-managed mpv launches inject the bundled runtime plugin.
|
First-run setup creates the config file, then requires Yomitan dictionaries before it can finish.
|
||||||
|
|
||||||
If you enabled the optional Windows shortcut during install, SubMiner creates a `SubMiner mpv` shortcut in the Start menu and/or on the desktop. On Windows, that shortcut is the recommended way to launch local files with SubMiner because it starts `mpv.exe` with the right defaults directly.
|
If you enabled the optional Windows shortcut during install, SubMiner creates a `SubMiner mpv` shortcut in the Start menu and/or on the desktop. On Windows, that shortcut is the recommended way to launch local files with SubMiner because it starts `mpv.exe` with the right defaults directly.
|
||||||
After setup completes, the shortcut is the normal Windows playback entry point.
|
After setup completes, the shortcut is the normal Windows playback entry point.
|
||||||
@@ -173,7 +185,8 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
|
|||||||
|
|
||||||
- `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
|
- `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
|
||||||
- `subminer doctor`: health checks for core dependencies and runtime paths.
|
- `subminer doctor`: health checks for core dependencies and runtime paths.
|
||||||
- `subminer config`: config helpers (`path`, `show`).
|
- `subminer settings`: open the SubMiner settings window (also `subminer --settings`).
|
||||||
|
- `subminer config`: config file helpers (`path`, `show`).
|
||||||
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
|
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
|
||||||
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
|
- `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.
|
- Use `subminer dictionary --candidates <path>` and `subminer dictionary --select <id> <path>` to correct AniList character-dictionary matches for a whole series.
|
||||||
@@ -195,13 +208,12 @@ SubMiner.AppImage --setup
|
|||||||
Setup flow:
|
Setup flow:
|
||||||
|
|
||||||
- config file: create the default config directory and prefer `config.jsonc`
|
- config file: create the default config directory and prefer `config.jsonc`
|
||||||
- plugin compatibility: optionally install the legacy global mpv plugin; managed launches use the bundled runtime plugin without it
|
- legacy plugin cleanup: remove detected older global SubMiner mpv plugin files if present (the bundled plugin is injected at runtime automatically)
|
||||||
- legacy plugin cleanup: remove detected global SubMiner mpv plugin files from mpv script directories via the OS trash when you do not want regular mpv to load SubMiner
|
|
||||||
- Yomitan shortcut: open bundled Yomitan settings directly from the setup window
|
- Yomitan shortcut: open bundled Yomitan settings directly from the setup window
|
||||||
- dictionary check: ensure at least one bundled Yomitan dictionary is available, unless an external Yomitan profile is configured
|
- dictionary check: ensure at least one bundled Yomitan dictionary is available, unless an external Yomitan profile is configured
|
||||||
- Windows: optionally create or remove `SubMiner mpv` Start Menu/Desktop shortcuts (`SubMiner.exe --launch-mpv`)
|
- Windows: optionally create or remove `SubMiner mpv` Start Menu/Desktop shortcuts (`SubMiner.exe --launch-mpv`)
|
||||||
- Windows: optionally set `mpv.executablePath` if `mpv.exe` is not on `PATH`
|
- Windows: optionally set `mpv.executablePath` if `mpv.exe` is not on `PATH`
|
||||||
- refresh: re-check plugin + dictionary state without restarting
|
- refresh: re-check dictionary state without restarting
|
||||||
- `Finish setup` stays disabled until the config and dictionary gates are satisfied
|
- `Finish setup` stays disabled until the config and dictionary gates are satisfied
|
||||||
- finish action writes setup completion state and suppresses future auto-open prompts
|
- finish action writes setup completion state and suppresses future auto-open prompts
|
||||||
|
|
||||||
@@ -254,7 +266,7 @@ secondary-sub-visibility=no
|
|||||||
|
|
||||||
SubMiner includes a bundled Yomitan extension for overlay word lookup. This bundled extension is separate from any Yomitan browser extension you may have installed.
|
SubMiner includes a bundled Yomitan extension for overlay word lookup. This bundled extension is separate from any Yomitan browser extension you may have installed.
|
||||||
|
|
||||||
For SubMiner overlay lookups to work, open Yomitan settings (`subminer app --settings` or `SubMiner.AppImage --settings`) and import at least one dictionary in the bundled Yomitan instance.
|
For SubMiner overlay lookups to work, open Yomitan settings (`subminer app --yomitan` or `SubMiner.AppImage --yomitan`) and import at least one dictionary in the bundled Yomitan instance.
|
||||||
|
|
||||||
If you also use Yomitan in a browser, configure that browser profile separately; it does not inherit dictionaries or settings from the bundled instance.
|
If you also use Yomitan in a browser, configure that browser profile separately; it does not inherit dictionaries or settings from the bundled instance.
|
||||||
|
|
||||||
@@ -283,13 +295,14 @@ SubMiner supports gamepad/controller input for couch-friendly usage via the Chro
|
|||||||
### Getting Started
|
### Getting Started
|
||||||
|
|
||||||
1. Connect a controller before or after launching SubMiner.
|
1. Connect a controller before or after launching SubMiner.
|
||||||
2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
|
2. Set `controller.enabled` to `true` in your config.
|
||||||
3. Press `Alt+C` in the overlay by default to pick the controller you want to save and remap any action inline.
|
3. Press `Alt+C` in the overlay by default to pick the controller you want to save and remap any action inline.
|
||||||
4. Click `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller.
|
4. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
|
||||||
5. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
|
5. Click the binding badge, edit pencil, or `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller.
|
||||||
6. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
|
6. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
|
||||||
|
7. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
|
||||||
|
|
||||||
By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline, and `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`.
|
By default SubMiner uses the first connected controller after controller support is enabled. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline per controller. The reset button beside each edit pencil restores that binding to its built-in default for the selected controller. `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both modals stay closed while `controller.enabled` is false, and both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`.
|
||||||
|
|
||||||
### Default Button Mapping
|
### Default Button Mapping
|
||||||
|
|
||||||
@@ -316,7 +329,7 @@ By default SubMiner uses the first connected controller. `Alt+C` opens the contr
|
|||||||
|
|
||||||
Learn mode ignores already-held inputs and waits for the next fresh button press or axis direction, which avoids accidental captures when you open the modal mid-input.
|
Learn mode ignores already-held inputs and waits for the next fresh button press or axis direction, which avoids accidental captures when you open the modal mid-input.
|
||||||
|
|
||||||
All button and axis mappings are configurable under the `controller` config block. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
|
All button and axis mappings are configurable under the `controller` config block. Learned remaps are saved under `controller.profiles` for the selected controller id. See [Configuration — Controller Support](/configuration#controller-support) for the full options.
|
||||||
|
|
||||||
## Keybindings
|
## Keybindings
|
||||||
|
|
||||||
@@ -333,9 +346,9 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
|
|||||||
|
|
||||||
Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback.
|
Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback.
|
||||||
|
|
||||||
Press `V` to hide or restore the primary SubMiner subtitle bar. The mpv plugin also binds bare `v` to the same action, overriding mpv's native primary subtitle visibility toggle.
|
Press `V` to hide or restore the primary SubMiner subtitle bar. The bundled mpv plugin also binds bare `v` to the same action (injected at runtime).
|
||||||
|
|
||||||
`Ctrl/Cmd+/` opens the session help modal with the current overlay and mpv keybindings. If you use the mpv plugin, the same help view is also available through the `y-h` chord.
|
`Ctrl/Cmd+/` opens the session help modal with the current overlay and mpv keybindings. The same help view is also available through the `y-h` chord in mpv.
|
||||||
|
|
||||||
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
|
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ If you use the [mpv plugin](/mpv-plugin), it can also start a texthooker-only he
|
|||||||
|
|
||||||
### 1. Subtitle WebSocket
|
### 1. Subtitle WebSocket
|
||||||
|
|
||||||
Use the basic subtitle websocket when you only need the current subtitle line and a ready-to-render HTML sentence string.
|
Use the basic subtitle websocket when you only need the current subtitle line as plain text.
|
||||||
|
|
||||||
- **Default URL:** `ws://127.0.0.1:6677`
|
- **Default URL:** `ws://127.0.0.1:6677`
|
||||||
- **Transport:** local WebSocket server bound to `127.0.0.1`
|
- **Transport:** local WebSocket server bound to `127.0.0.1`
|
||||||
@@ -64,6 +64,36 @@ When a client connects, SubMiner immediately sends the latest subtitle payload i
|
|||||||
|
|
||||||
#### Message shape
|
#### Message shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"text": "無事",
|
||||||
|
"sentence": "無事",
|
||||||
|
"tokens": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Field reference
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `version` | number | Current websocket payload version. Today this is `1`. |
|
||||||
|
| `text` | string | Raw subtitle text. |
|
||||||
|
| `sentence` | string | Plain subtitle text with line breaks represented as `<br>`. No annotation spans or attributes. |
|
||||||
|
| `tokens` | array | Always empty on the basic subtitle websocket. |
|
||||||
|
|
||||||
|
### 2. Annotation WebSocket
|
||||||
|
|
||||||
|
Use the annotation websocket for custom clients that want the same structured token payload the bundled texthooker UI consumes.
|
||||||
|
|
||||||
|
- **Default URL:** `ws://127.0.0.1:6678`
|
||||||
|
- **Payload shape:** JSON payload with `text`, rendered `sentence` HTML, and token metadata
|
||||||
|
- **Primary difference:** this stream is intended to stay on even when the basic websocket auto-disables because `mpv_websocket` is installed
|
||||||
|
|
||||||
|
In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer.
|
||||||
|
|
||||||
|
#### Message shape
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
@@ -91,16 +121,7 @@ When a client connects, SubMiner immediately sends the latest subtitle payload i
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Field reference
|
Each annotation token may include:
|
||||||
|
|
||||||
| Field | Type | Notes |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `version` | number | Current websocket payload version. Today this is `1`. |
|
|
||||||
| `text` | string | Raw subtitle text. |
|
|
||||||
| `sentence` | string | HTML string with `<span>` wrappers and `data-*` attributes for client rendering. |
|
|
||||||
| `tokens` | array | Token metadata; empty when the subtitle is not tokenized yet. |
|
|
||||||
|
|
||||||
Each token may include:
|
|
||||||
|
|
||||||
| Token field | Type | Notes |
|
| Token field | Type | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -119,16 +140,6 @@ Each token may include:
|
|||||||
| `frequencyRankLabel` | string or `null` | Preformatted rank label for UIs |
|
| `frequencyRankLabel` | string or `null` | Preformatted rank label for UIs |
|
||||||
| `jlptLevelLabel` | string or `null` | Preformatted JLPT label for UIs |
|
| `jlptLevelLabel` | string or `null` | Preformatted JLPT label for UIs |
|
||||||
|
|
||||||
### 2. Annotation WebSocket
|
|
||||||
|
|
||||||
Use the annotation websocket for custom clients that want the same structured token payload the bundled texthooker UI consumes.
|
|
||||||
|
|
||||||
- **Default URL:** `ws://127.0.0.1:6678`
|
|
||||||
- **Payload shape:** same JSON contract as the basic subtitle websocket
|
|
||||||
- **Primary difference:** this stream is intended to stay on even when the basic websocket auto-disables because `mpv_websocket` is installed
|
|
||||||
|
|
||||||
In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer.
|
|
||||||
|
|
||||||
### 3. HTML markup conventions
|
### 3. HTML markup conventions
|
||||||
|
|
||||||
The `sentence` field is pre-rendered HTML generated by SubMiner. Depending on token state, it can include classes such as:
|
The `sentence` field is pre-rendered HTML generated by SubMiner. Depending on token state, it can include classes such as:
|
||||||
|
|||||||
+16
-4
@@ -37,6 +37,7 @@
|
|||||||
8. If `docs-site/` changed, also run:
|
8. If `docs-site/` changed, also run:
|
||||||
`bun run docs:test`
|
`bun run docs:test`
|
||||||
`bun run docs:build`
|
`bun run docs:build`
|
||||||
|
`bun run docs:build:versioned`
|
||||||
9. Commit release prep.
|
9. Commit release prep.
|
||||||
10. Tag the commit: `git tag v<version>`.
|
10. Tag the commit: `git tag v<version>`.
|
||||||
11. Push commit + tag.
|
11. Push commit + tag.
|
||||||
@@ -57,12 +58,16 @@
|
|||||||
`*.yml` and `*.blockmap` files under `release/`.
|
`*.yml` and `*.blockmap` files under `release/`.
|
||||||
5. Commit the prerelease prep (package.json version bump + the generated
|
5. Commit the prerelease prep (package.json version bump + the generated
|
||||||
`release/prerelease-notes.md`). CI does not regenerate notes — it uses the
|
`release/prerelease-notes.md`). CI does not regenerate notes — it uses the
|
||||||
committed file — so review it before committing. Do not run
|
committed file — so review it before committing. If you add more
|
||||||
`bun run changelog:build`.
|
`changes/*.md` fragments for a later beta/RC, rerun
|
||||||
|
`bun run changelog:prerelease-notes --version <version>`; the generator uses
|
||||||
|
the existing prerelease notes as the baseline and asks Claude to merge only
|
||||||
|
the new fragment material. Do not run `bun run changelog:build`.
|
||||||
6. Tag the commit: `git tag v<version>`.
|
6. Tag the commit: `git tag v<version>`.
|
||||||
7. Push commit + tag.
|
7. Push commit + tag.
|
||||||
|
|
||||||
Prerelease tags publish a GitHub prerelease only. They do not update `CHANGELOG.md`, `docs-site/changelog.md`, or the AUR package, and they do not consume `changes/*.md` fragments. The final stable release is still the point where `bun run changelog:build` consumes fragments into `CHANGELOG.md` and regenerates stable release notes.
|
Prerelease tags publish a GitHub prerelease only. They do not update `CHANGELOG.md`, `docs-site/changelog.md`, or the AUR package, and they do not consume `changes/*.md` fragments. The final stable release is still the point where `bun run changelog:build` consumes fragments into `CHANGELOG.md` and regenerates stable release notes.
|
||||||
|
Prerelease tags also do not update `https://docs.subminer.moe/`.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
@@ -70,16 +75,23 @@ Notes:
|
|||||||
- Supported prerelease channels are `beta` and `rc`, with versions like `0.11.3-beta.1` and `0.11.3-rc.1`.
|
- Supported prerelease channels are `beta` and `rc`, with versions like `0.11.3-beta.1` and `0.11.3-rc.1`.
|
||||||
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
|
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
|
||||||
- `changelog:check` now rejects tag/package version mismatches.
|
- `changelog:check` now rejects tag/package version mismatches.
|
||||||
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files.
|
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
|
||||||
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
|
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
|
||||||
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
|
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
|
||||||
- Do not tag while `changes/*.md` fragments still exist.
|
- Do not tag while `changes/*.md` fragments still exist.
|
||||||
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut.
|
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. `make clean` preserves `release/prerelease-notes.md` while deleting generated build artifacts.
|
||||||
- If you need to repair a published release body (for example, a prior version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
|
- If you need to repair a published release body (for example, a prior version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
|
||||||
- Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job.
|
- Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job.
|
||||||
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
|
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
|
||||||
|
- Stable release tags update `https://docs.subminer.moe/` and `https://docs.subminer.moe/v/<version>/` through `.github/workflows/docs-pages.yml`; `/main/` continues to show development docs from `main`.
|
||||||
|
- Keep Cloudflare Pages Git auto-deploy disabled for `docs.subminer.moe`. Production docs are direct-uploaded by Wrangler from GitHub Actions with `--branch main`.
|
||||||
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
|
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
|
||||||
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
|
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
|
||||||
- Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled.
|
- Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled.
|
||||||
|
- macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS ZIP, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer.
|
||||||
|
- macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks.
|
||||||
|
- Windows tray app updates use the standard `electron-updater`/NSIS path. Keep `latest.yml`, the Windows NSIS installer, and installer blockmap published; updater HTTP is routed through main-process fetch to avoid Electron main-process network crashes during update checks.
|
||||||
|
- Linux GitHub release metadata and asset downloads also use `/usr/bin/curl` instead of Electron networking for the same reason.
|
||||||
|
- Local macOS build-output apps outside `/Applications` or `~/Applications` skip native update checks. To validate auto-update end to end, install the signed and notarized app bundle into one of those Applications folders and point it at a published updater feed.
|
||||||
- The first updater-enabled release cannot update older installs automatically. Users need one manual install to get the updater code.
|
- The first updater-enabled release cannot update older installs automatically. Users need one manual install to get the updater code.
|
||||||
- Stable auto-update checks ignore beta/RC prereleases by default. Set `updates.channel` to `"prerelease"` on a test install when validating beta/RC updater behavior.
|
- Stable auto-update checks ignore beta/RC prereleases by default. Set `updates.channel` to `"prerelease"` on a test install when validating beta/RC updater behavior.
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Config Settings Window
|
||||||
|
|
||||||
|
read_when: changing config UI, config save behavior, or config docs
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
Add a dedicated Electron settings window for editing canonical config values without exposing the historical layout mistakes in `config.jsonc`.
|
||||||
|
|
||||||
|
The UI groups options by workflow:
|
||||||
|
|
||||||
|
- Viewing
|
||||||
|
- Mining & Anki
|
||||||
|
- Playback & Sources
|
||||||
|
- Input
|
||||||
|
- Integrations
|
||||||
|
- Tracking & App
|
||||||
|
- Advanced
|
||||||
|
|
||||||
|
Each field maps back to its current raw config path. The presentation layer must stay separate from generated config-template sections.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- Canonical defaults: `DEFAULT_CONFIG`
|
||||||
|
- Existing option descriptions/enums: `CONFIG_OPTION_REGISTRY`
|
||||||
|
- UI registry: `src/config/settings/registry.ts`
|
||||||
|
- JSONC save path: `src/config/settings/jsonc-edit.ts`
|
||||||
|
- Window runtime: `src/main/runtime/config-settings-window.ts`
|
||||||
|
|
||||||
|
## Save Contract
|
||||||
|
|
||||||
|
Settings writes use `jsonc-parser.modify`, not `JSON.stringify`.
|
||||||
|
|
||||||
|
Required behavior:
|
||||||
|
|
||||||
|
- Preserve comments, trailing commas, unrelated keys, and hidden legacy keys.
|
||||||
|
- Reset removes the explicit path so defaults resolve normally.
|
||||||
|
- Validate the candidate config before writing.
|
||||||
|
- Reject warnings caused by modified fields.
|
||||||
|
- Preserve unrelated existing warnings and return them in the snapshot.
|
||||||
|
- Write atomically, reload `ConfigService`, classify with existing hot-reload logic, and apply live changes where supported.
|
||||||
|
- Never return secret values to the renderer; snapshots only expose configured/not-configured state.
|
||||||
|
|
||||||
|
## Hidden Compatibility Keys
|
||||||
|
|
||||||
|
Do not expose these as first-class controls:
|
||||||
|
|
||||||
|
- `ankiConnect.deck`
|
||||||
|
- Legacy top-level Anki migration fields such as `wordField`, `audioField`, media-generation aliases, and behavior aliases
|
||||||
|
- Legacy `ankiConnect.nPlusOne.*` aliases except canonical `nPlusOne.nPlusOne` and `nPlusOne.minSentenceWords`
|
||||||
|
- Deprecated Lapis sentence-card fields
|
||||||
|
- `youtubeSubgen.primarySubLanguages`
|
||||||
|
- `anilist.characterDictionary.refreshTtlHours`
|
||||||
|
- `anilist.characterDictionary.evictionPolicy`
|
||||||
|
- `jellyfin.accessToken`
|
||||||
|
- `jellyfin.userId`
|
||||||
|
- `controller.buttonIndices` as a normal editable field
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Minimum targeted checks:
|
||||||
|
|
||||||
|
- `bun test src/config/settings/registry.test.ts src/config/settings/jsonc-edit.test.ts src/settings/settings-model.test.ts src/main/runtime/config-settings-window.test.ts`
|
||||||
|
- `bun run test:config`
|
||||||
|
- `bun run typecheck`
|
||||||
|
- `bun run build`
|
||||||
|
|
||||||
|
If docs changed:
|
||||||
|
|
||||||
|
- `bun run docs:test`
|
||||||
|
- `bun run docs:build`
|
||||||
@@ -567,9 +567,11 @@ export function buildSubminerScriptOpts(
|
|||||||
logLevel: LogLevel = 'info',
|
logLevel: LogLevel = 'info',
|
||||||
extraParts: string[] = [],
|
extraParts: string[] = [],
|
||||||
): string {
|
): string {
|
||||||
|
const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path='));
|
||||||
|
const hasSocketPath = extraParts.some((part) => part.startsWith('subminer-socket_path='));
|
||||||
const parts = [
|
const parts = [
|
||||||
`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`,
|
...(hasBinaryPath ? [] : [`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`]),
|
||||||
`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`,
|
...(hasSocketPath ? [] : [`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`]),
|
||||||
...extraParts.map(sanitizeScriptOptValue),
|
...extraParts.map(sanitizeScriptOptValue),
|
||||||
];
|
];
|
||||||
if (logLevel !== 'info') {
|
if (logLevel !== 'info') {
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ import type { LauncherCommandContext } from './context.js';
|
|||||||
|
|
||||||
export function runAppPassthroughCommand(context: LauncherCommandContext): boolean {
|
export function runAppPassthroughCommand(context: LauncherCommandContext): boolean {
|
||||||
const { args, appPath } = context;
|
const { args, appPath } = context;
|
||||||
if (!args.appPassthrough || !appPath) {
|
if (!appPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (args.settings) {
|
||||||
|
runAppCommandWithInherit(appPath, ['--settings']);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!args.appPassthrough) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
runAppCommandWithInherit(appPath, args.appArgs);
|
runAppCommandWithInherit(appPath, args.appArgs);
|
||||||
|
|||||||
@@ -38,9 +38,14 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
|
|||||||
mpvSocketPath: '/tmp/subminer.sock',
|
mpvSocketPath: '/tmp/subminer.sock',
|
||||||
pluginRuntimeConfig: {
|
pluginRuntimeConfig: {
|
||||||
socketPath: '/tmp/subminer.sock',
|
socketPath: '/tmp/subminer.sock',
|
||||||
|
binaryPath: '',
|
||||||
|
backend: 'auto',
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
autoStartVisibleOverlay: true,
|
autoStartVisibleOverlay: true,
|
||||||
autoStartPauseUntilReady: true,
|
autoStartPauseUntilReady: true,
|
||||||
|
texthookerEnabled: false,
|
||||||
|
aniskipEnabled: true,
|
||||||
|
aniskipButtonKey: 'TAB',
|
||||||
},
|
},
|
||||||
appPath: '/tmp/subminer.app',
|
appPath: '/tmp/subminer.app',
|
||||||
launcherJellyfinConfig: {},
|
launcherJellyfinConfig: {},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface MpvCommandDeps {
|
|||||||
appPath: string,
|
appPath: string,
|
||||||
args: LauncherCommandContext['args'],
|
args: LauncherCommandContext['args'],
|
||||||
runtimePluginPath?: string | null,
|
runtimePluginPath?: string | null,
|
||||||
|
runtimePluginConfig?: LauncherCommandContext['pluginRuntimeConfig'],
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ export async function runMpvPostAppCommand(
|
|||||||
context: LauncherCommandContext,
|
context: LauncherCommandContext,
|
||||||
deps: MpvCommandDeps = defaultDeps,
|
deps: MpvCommandDeps = defaultDeps,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const { args, appPath, scriptPath, mpvSocketPath } = context;
|
const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig } = context;
|
||||||
if (!args.mpvIdle) {
|
if (!args.mpvIdle) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -62,6 +63,11 @@ export async function runMpvPostAppCommand(
|
|||||||
appPath,
|
appPath,
|
||||||
args,
|
args,
|
||||||
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||||
|
{
|
||||||
|
...pluginRuntimeConfig,
|
||||||
|
backend: args.backend,
|
||||||
|
texthookerEnabled: args.useTexthooker && pluginRuntimeConfig.texthookerEnabled,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
|
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
import { runPlaybackCommandWithDeps } from './playback-command.js';
|
import { runPlaybackCommandWithDeps } from './playback-command.js';
|
||||||
import { state } from '../mpv.js';
|
import { state } from '../mpv.js';
|
||||||
@@ -52,6 +55,8 @@ function createContext(): LauncherCommandContext {
|
|||||||
stats: false,
|
stats: false,
|
||||||
doctor: false,
|
doctor: false,
|
||||||
doctorRefreshKnownWords: false,
|
doctorRefreshKnownWords: false,
|
||||||
|
version: false,
|
||||||
|
settings: false,
|
||||||
configPath: false,
|
configPath: false,
|
||||||
configShow: false,
|
configShow: false,
|
||||||
mpvIdle: false,
|
mpvIdle: false,
|
||||||
@@ -70,9 +75,14 @@ function createContext(): LauncherCommandContext {
|
|||||||
mpvSocketPath: '/tmp/subminer.sock',
|
mpvSocketPath: '/tmp/subminer.sock',
|
||||||
pluginRuntimeConfig: {
|
pluginRuntimeConfig: {
|
||||||
socketPath: '/tmp/subminer.sock',
|
socketPath: '/tmp/subminer.sock',
|
||||||
|
binaryPath: '',
|
||||||
|
backend: 'auto',
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
autoStartVisibleOverlay: true,
|
autoStartVisibleOverlay: true,
|
||||||
autoStartPauseUntilReady: true,
|
autoStartPauseUntilReady: true,
|
||||||
|
texthookerEnabled: false,
|
||||||
|
aniskipEnabled: true,
|
||||||
|
aniskipButtonKey: 'TAB',
|
||||||
},
|
},
|
||||||
appPath: '/tmp/SubMiner.AppImage',
|
appPath: '/tmp/SubMiner.AppImage',
|
||||||
launcherJellyfinConfig: {},
|
launcherJellyfinConfig: {},
|
||||||
@@ -138,18 +148,24 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
|
|||||||
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
|
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('plugin auto-start playback marks background app for cleanup when mpv exits', async () => {
|
test('plugin auto-start playback leaves app lifetime to managed-playback owner', async () => {
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
context.args = {
|
context.args = {
|
||||||
...context.args,
|
...context.args,
|
||||||
target: '/tmp/movie.mkv',
|
target: '/tmp/movie.mkv',
|
||||||
targetKind: 'file',
|
targetKind: 'file',
|
||||||
|
useTexthooker: true,
|
||||||
};
|
};
|
||||||
context.pluginRuntimeConfig = {
|
context.pluginRuntimeConfig = {
|
||||||
socketPath: '/tmp/subminer.sock',
|
socketPath: '/tmp/subminer.sock',
|
||||||
|
binaryPath: '',
|
||||||
|
backend: 'auto',
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
autoStartVisibleOverlay: false,
|
autoStartVisibleOverlay: false,
|
||||||
autoStartPauseUntilReady: false,
|
autoStartPauseUntilReady: false,
|
||||||
|
texthookerEnabled: false,
|
||||||
|
aniskipEnabled: true,
|
||||||
|
aniskipButtonKey: 'TAB',
|
||||||
};
|
};
|
||||||
const appPath = context.appPath ?? '';
|
const appPath = context.appPath ?? '';
|
||||||
state.appPath = appPath;
|
state.appPath = appPath;
|
||||||
@@ -162,7 +178,7 @@ test('plugin auto-start playback marks background app for cleanup when mpv exits
|
|||||||
mpvProc.exitCode = null;
|
mpvProc.exitCode = null;
|
||||||
mpvProc.killed = false;
|
mpvProc.killed = false;
|
||||||
mpvProc.kill = () => true;
|
mpvProc.kill = () => true;
|
||||||
let cleanupSawManagedOverlay = false;
|
let cleanupSawManagedOverlay = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runPlaybackCommandWithDeps(context, {
|
await runPlaybackCommandWithDeps(context, {
|
||||||
@@ -188,9 +204,178 @@ test('plugin auto-start playback marks background app for cleanup when mpv exits
|
|||||||
getMpvProc: () => mpvProc as NonNullable<typeof state.mpvProc>,
|
getMpvProc: () => mpvProc as NonNullable<typeof state.mpvProc>,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(cleanupSawManagedOverlay, true);
|
assert.equal(cleanupSawManagedOverlay, false);
|
||||||
} finally {
|
} finally {
|
||||||
state.appPath = '';
|
state.appPath = '';
|
||||||
state.overlayManagedByLauncher = false;
|
state.overlayManagedByLauncher = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('plugin auto-start playback attaches a warm background app through the launcher', async () => {
|
||||||
|
const context = createContext();
|
||||||
|
context.args = {
|
||||||
|
...context.args,
|
||||||
|
target: '/tmp/movie.mkv',
|
||||||
|
targetKind: 'file',
|
||||||
|
useTexthooker: true,
|
||||||
|
};
|
||||||
|
context.pluginRuntimeConfig = {
|
||||||
|
socketPath: '/tmp/subminer.sock',
|
||||||
|
binaryPath: '',
|
||||||
|
backend: 'auto',
|
||||||
|
autoStart: true,
|
||||||
|
autoStartVisibleOverlay: true,
|
||||||
|
autoStartPauseUntilReady: true,
|
||||||
|
texthookerEnabled: true,
|
||||||
|
aniskipEnabled: true,
|
||||||
|
aniskipButtonKey: 'TAB',
|
||||||
|
};
|
||||||
|
const calls: string[] = [];
|
||||||
|
const receivedStartMpvOptions: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
|
await runPlaybackCommandWithDeps(context, {
|
||||||
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||||
|
checkDependencies: () => {},
|
||||||
|
registerCleanup: () => {},
|
||||||
|
startMpv: async (
|
||||||
|
_target,
|
||||||
|
_targetKind,
|
||||||
|
_args,
|
||||||
|
_socketPath,
|
||||||
|
_appPath,
|
||||||
|
_preloadedSubtitles,
|
||||||
|
options,
|
||||||
|
) => {
|
||||||
|
calls.push('startMpv');
|
||||||
|
if (options) {
|
||||||
|
receivedStartMpvOptions.push(options as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
waitForUnixSocketReady: async () => true,
|
||||||
|
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
|
||||||
|
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
|
||||||
|
},
|
||||||
|
launchAppCommandDetached: () => {},
|
||||||
|
log: () => {},
|
||||||
|
cleanupPlaybackSession: async () => {},
|
||||||
|
getMpvProc: () => null,
|
||||||
|
isAppControlServerAvailable: async () => true,
|
||||||
|
} as Parameters<typeof runPlaybackCommandWithDeps>[1] & {
|
||||||
|
isAppControlServerAvailable: () => Promise<boolean>;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay --texthooker']);
|
||||||
|
assert.equal(receivedStartMpvOptions[0]?.startPaused, true);
|
||||||
|
assert.equal(
|
||||||
|
(receivedStartMpvOptions[0]?.runtimePluginConfig as { autoStart?: boolean } | undefined)
|
||||||
|
?.autoStart,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin auto-start attach mode reuses launcher-resolved config dir for app control', async () => {
|
||||||
|
const context = createContext();
|
||||||
|
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
|
||||||
|
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-'));
|
||||||
|
const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner');
|
||||||
|
fs.mkdirSync(expectedConfigDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(expectedConfigDir, 'config.jsonc'), '{}');
|
||||||
|
context.args = {
|
||||||
|
...context.args,
|
||||||
|
target: '/tmp/movie.mkv',
|
||||||
|
targetKind: 'file',
|
||||||
|
useTexthooker: true,
|
||||||
|
};
|
||||||
|
context.pluginRuntimeConfig = {
|
||||||
|
socketPath: '/tmp/subminer.sock',
|
||||||
|
binaryPath: '',
|
||||||
|
backend: 'auto',
|
||||||
|
autoStart: true,
|
||||||
|
autoStartVisibleOverlay: true,
|
||||||
|
autoStartPauseUntilReady: true,
|
||||||
|
texthookerEnabled: true,
|
||||||
|
aniskipEnabled: true,
|
||||||
|
aniskipButtonKey: 'TAB',
|
||||||
|
};
|
||||||
|
let availabilityConfigDir: string | undefined;
|
||||||
|
let overlayConfigDir: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
||||||
|
|
||||||
|
await runPlaybackCommandWithDeps(context, {
|
||||||
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||||
|
checkDependencies: () => {},
|
||||||
|
registerCleanup: () => {},
|
||||||
|
startMpv: async () => {},
|
||||||
|
waitForUnixSocketReady: async () => true,
|
||||||
|
startOverlay: async (_appPath, _args, _socketPath, _extraAppArgs = [], configDir) => {
|
||||||
|
overlayConfigDir = configDir;
|
||||||
|
},
|
||||||
|
launchAppCommandDetached: () => {},
|
||||||
|
log: () => {},
|
||||||
|
cleanupPlaybackSession: async () => {},
|
||||||
|
getMpvProc: () => null,
|
||||||
|
isAppControlServerAvailable: async (_logLevel, configDir) => {
|
||||||
|
availabilityConfigDir = configDir;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(availabilityConfigDir, expectedConfigDir);
|
||||||
|
assert.equal(overlayConfigDir, expectedConfigDir);
|
||||||
|
} finally {
|
||||||
|
if (originalXdgConfigHome === undefined) {
|
||||||
|
delete process.env.XDG_CONFIG_HOME;
|
||||||
|
} else {
|
||||||
|
process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
|
||||||
|
}
|
||||||
|
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin auto-start attach mode omits texthooker flag when CLI texthooker is disabled', async () => {
|
||||||
|
const context = createContext();
|
||||||
|
context.args = {
|
||||||
|
...context.args,
|
||||||
|
target: '/tmp/movie.mkv',
|
||||||
|
targetKind: 'file',
|
||||||
|
};
|
||||||
|
context.pluginRuntimeConfig = {
|
||||||
|
socketPath: '/tmp/subminer.sock',
|
||||||
|
binaryPath: '',
|
||||||
|
backend: 'auto',
|
||||||
|
autoStart: true,
|
||||||
|
autoStartVisibleOverlay: true,
|
||||||
|
autoStartPauseUntilReady: true,
|
||||||
|
texthookerEnabled: true,
|
||||||
|
aniskipEnabled: true,
|
||||||
|
aniskipButtonKey: 'TAB',
|
||||||
|
};
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
await runPlaybackCommandWithDeps(context, {
|
||||||
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
chooseTarget: async () => ({ target: context.args.target, kind: 'file' }),
|
||||||
|
checkDependencies: () => {},
|
||||||
|
registerCleanup: () => {},
|
||||||
|
startMpv: async () => {
|
||||||
|
calls.push('startMpv');
|
||||||
|
},
|
||||||
|
waitForUnixSocketReady: async () => true,
|
||||||
|
startOverlay: async (_appPath, _args, _socketPath, extraAppArgs = []) => {
|
||||||
|
calls.push(`startOverlay:${extraAppArgs.join(' ')}`);
|
||||||
|
},
|
||||||
|
launchAppCommandDetached: () => {},
|
||||||
|
log: () => {},
|
||||||
|
cleanupPlaybackSession: async () => {},
|
||||||
|
getMpvProc: () => null,
|
||||||
|
isAppControlServerAvailable: async () => true,
|
||||||
|
} as Parameters<typeof runPlaybackCommandWithDeps>[1] & {
|
||||||
|
isAppControlServerAvailable: () => Promise<boolean>;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['startMpv', 'startOverlay:--show-visible-overlay']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
|
|||||||
import {
|
import {
|
||||||
cleanupPlaybackSession,
|
cleanupPlaybackSession,
|
||||||
launchAppCommandDetached,
|
launchAppCommandDetached,
|
||||||
markOverlayManagedByLauncher,
|
|
||||||
resolveLauncherRuntimePluginPath,
|
resolveLauncherRuntimePluginPath,
|
||||||
|
isRunningAppControlServerAvailable,
|
||||||
startMpv,
|
startMpv,
|
||||||
startOverlay,
|
startOverlay,
|
||||||
state,
|
state,
|
||||||
@@ -30,6 +30,13 @@ import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
|
|||||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
const SETUP_POLL_INTERVAL_MS = 500;
|
const SETUP_POLL_INTERVAL_MS = 500;
|
||||||
|
|
||||||
|
function getLauncherConfigDir(): string {
|
||||||
|
return getDefaultConfigDir({
|
||||||
|
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||||
|
homeDir: os.homedir(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function checkDependencies(args: Args): void {
|
function checkDependencies(args: Args): void {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
|
|
||||||
@@ -100,10 +107,7 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
|||||||
const { args, appPath } = context;
|
const { args, appPath } = context;
|
||||||
if (!appPath) return;
|
if (!appPath) return;
|
||||||
|
|
||||||
const configDir = getDefaultConfigDir({
|
const configDir = getLauncherConfigDir();
|
||||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
|
||||||
homeDir: os.homedir(),
|
|
||||||
});
|
|
||||||
const statePath = getSetupStatePath(configDir);
|
const statePath = getSetupStatePath(configDir);
|
||||||
const ready = await ensureLauncherSetupReady({
|
const ready = await ensureLauncherSetupReady({
|
||||||
readSetupState: () => readSetupState(statePath),
|
readSetupState: () => readSetupState(statePath),
|
||||||
@@ -147,6 +151,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
waitForUnixSocketReady,
|
waitForUnixSocketReady,
|
||||||
startOverlay,
|
startOverlay,
|
||||||
launchAppCommandDetached,
|
launchAppCommandDetached,
|
||||||
|
isAppControlServerAvailable: isRunningAppControlServerAvailable,
|
||||||
log,
|
log,
|
||||||
cleanupPlaybackSession,
|
cleanupPlaybackSession,
|
||||||
getMpvProc: () => state.mpvProc,
|
getMpvProc: () => state.mpvProc,
|
||||||
@@ -165,6 +170,7 @@ type PlaybackCommandDeps = {
|
|||||||
waitForUnixSocketReady: typeof waitForUnixSocketReady;
|
waitForUnixSocketReady: typeof waitForUnixSocketReady;
|
||||||
startOverlay: typeof startOverlay;
|
startOverlay: typeof startOverlay;
|
||||||
launchAppCommandDetached: typeof launchAppCommandDetached;
|
launchAppCommandDetached: typeof launchAppCommandDetached;
|
||||||
|
isAppControlServerAvailable?: (logLevel: Args['logLevel'], configDir: string) => Promise<boolean>;
|
||||||
log: typeof log;
|
log: typeof log;
|
||||||
cleanupPlaybackSession: typeof cleanupPlaybackSession;
|
cleanupPlaybackSession: typeof cleanupPlaybackSession;
|
||||||
getMpvProc: () => typeof state.mpvProc;
|
getMpvProc: () => typeof state.mpvProc;
|
||||||
@@ -209,11 +215,23 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target);
|
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target);
|
||||||
const isAppOwnedYoutubeFlow = isYoutubeUrl;
|
const isAppOwnedYoutubeFlow = isYoutubeUrl;
|
||||||
const youtubeMode = args.youtubeMode ?? 'download';
|
const youtubeMode = args.youtubeMode ?? 'download';
|
||||||
|
const configDir = getLauncherConfigDir();
|
||||||
|
|
||||||
if (isYoutubeUrl) {
|
if (isYoutubeUrl) {
|
||||||
deps.log('info', args.logLevel, 'YouTube subtitle flow: app-owned picker after mpv bootstrap');
|
deps.log('info', args.logLevel, 'YouTube subtitle flow: app-owned picker after mpv bootstrap');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
|
||||||
|
const shouldLauncherAttachRunningApp =
|
||||||
|
pluginAutoStartEnabled &&
|
||||||
|
!args.startOverlay &&
|
||||||
|
!args.autoStartOverlay &&
|
||||||
|
!isAppOwnedYoutubeFlow &&
|
||||||
|
((await deps.isAppControlServerAvailable?.(args.logLevel, configDir)) ?? false);
|
||||||
|
const effectivePluginRuntimeConfig = shouldLauncherAttachRunningApp
|
||||||
|
? { ...pluginRuntimeConfig, autoStart: false }
|
||||||
|
: pluginRuntimeConfig;
|
||||||
|
|
||||||
const shouldPauseUntilOverlayReady =
|
const shouldPauseUntilOverlayReady =
|
||||||
pluginRuntimeConfig.autoStart &&
|
pluginRuntimeConfig.autoStart &&
|
||||||
pluginRuntimeConfig.autoStartVisibleOverlay &&
|
pluginRuntimeConfig.autoStartVisibleOverlay &&
|
||||||
@@ -238,12 +256,20 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow,
|
startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow,
|
||||||
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
|
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
|
||||||
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||||
|
runtimePluginConfig: {
|
||||||
|
...effectivePluginRuntimeConfig,
|
||||||
|
backend: args.backend,
|
||||||
|
texthookerEnabled: args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 10000);
|
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 10000);
|
||||||
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
|
const shouldStartOverlay =
|
||||||
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay || isAppOwnedYoutubeFlow;
|
args.startOverlay ||
|
||||||
|
args.autoStartOverlay ||
|
||||||
|
isAppOwnedYoutubeFlow ||
|
||||||
|
shouldLauncherAttachRunningApp;
|
||||||
if (shouldStartOverlay) {
|
if (shouldStartOverlay) {
|
||||||
if (ready) {
|
if (ready) {
|
||||||
deps.log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay');
|
deps.log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay');
|
||||||
@@ -254,16 +280,20 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway',
|
'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await deps.startOverlay(
|
const extraAppArgs = isAppOwnedYoutubeFlow
|
||||||
appPath,
|
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
|
||||||
args,
|
: shouldLauncherAttachRunningApp
|
||||||
mpvSocketPath,
|
? [
|
||||||
isAppOwnedYoutubeFlow
|
pluginRuntimeConfig.autoStartVisibleOverlay
|
||||||
? ['--youtube-play', selectedTarget.target, '--youtube-mode', youtubeMode]
|
? '--show-visible-overlay'
|
||||||
: [],
|
: '--hide-visible-overlay',
|
||||||
);
|
...(args.useTexthooker && effectivePluginRuntimeConfig.texthookerEnabled
|
||||||
|
? ['--texthooker']
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
await deps.startOverlay(appPath, args, mpvSocketPath, extraAppArgs, configDir);
|
||||||
} else if (pluginAutoStartEnabled) {
|
} else if (pluginAutoStartEnabled) {
|
||||||
markOverlayManagedByLauncher(appPath);
|
|
||||||
if (ready) {
|
if (ready) {
|
||||||
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
|
|||||||
import { parseLauncherMpvConfig } from './config/mpv-config.js';
|
import { parseLauncherMpvConfig } from './config/mpv-config.js';
|
||||||
import { readExternalYomitanProfilePath } from './config.js';
|
import { readExternalYomitanProfilePath } from './config.js';
|
||||||
import {
|
import {
|
||||||
getPluginConfigCandidates,
|
buildPluginRuntimeScriptOptParts,
|
||||||
parsePluginRuntimeConfigContent,
|
parsePluginRuntimeConfigFromMainConfig,
|
||||||
} from './config/plugin-runtime-config.js';
|
} from './config/plugin-runtime-config.js';
|
||||||
import { getDefaultSocketPath } from './types.js';
|
import { getDefaultSocketPath } from './types.js';
|
||||||
|
|
||||||
@@ -86,10 +86,34 @@ test('parseLauncherMpvConfig reads launch mode preference', () => {
|
|||||||
mpv: {
|
mpv: {
|
||||||
launchMode: ' maximized ',
|
launchMode: ' maximized ',
|
||||||
executablePath: 'ignored-here',
|
executablePath: 'ignored-here',
|
||||||
|
socketPath: '/tmp/custom.sock',
|
||||||
|
backend: 'x11',
|
||||||
|
autoStartSubMiner: false,
|
||||||
|
pauseUntilOverlayReady: false,
|
||||||
|
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||||
|
aniskipEnabled: false,
|
||||||
|
aniskipButtonKey: 'F8',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(parsed.launchMode, 'maximized');
|
assert.equal(parsed.launchMode, 'maximized');
|
||||||
|
assert.equal(parsed.socketPath, '/tmp/custom.sock');
|
||||||
|
assert.equal(parsed.backend, 'x11');
|
||||||
|
assert.equal(parsed.autoStartSubMiner, false);
|
||||||
|
assert.equal(parsed.pauseUntilOverlayReady, false);
|
||||||
|
assert.equal(parsed.subminerBinaryPath, '/opt/SubMiner/SubMiner.AppImage');
|
||||||
|
assert.equal(parsed.aniskipEnabled, false);
|
||||||
|
assert.equal(parsed.aniskipButtonKey, 'F8');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseLauncherMpvConfig ignores blank subminer binary paths', () => {
|
||||||
|
const parsed = parseLauncherMpvConfig({
|
||||||
|
mpv: {
|
||||||
|
subminerBinaryPath: ' ',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(parsed.subminerBinaryPath, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseLauncherMpvConfig ignores invalid launch mode values', () => {
|
test('parseLauncherMpvConfig ignores invalid launch mode values', () => {
|
||||||
@@ -102,39 +126,102 @@ test('parseLauncherMpvConfig ignores invalid launch mode values', () => {
|
|||||||
assert.equal(parsed.launchMode, undefined);
|
assert.equal(parsed.launchMode, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parsePluginRuntimeConfigContent reads socket path and startup gate options', () => {
|
test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugin defaults', () => {
|
||||||
const parsed = parsePluginRuntimeConfigContent(`
|
const parsed = parsePluginRuntimeConfigFromMainConfig({
|
||||||
# comment
|
auto_start_overlay: false,
|
||||||
socket_path = /tmp/custom.sock # trailing comment
|
texthooker: {
|
||||||
auto_start = yes
|
launchAtStartup: false,
|
||||||
auto_start_visible_overlay = true
|
},
|
||||||
auto_start_pause_until_ready = 1
|
mpv: {
|
||||||
`);
|
socketPath: '/tmp/config.sock',
|
||||||
assert.equal(parsed.socketPath, '/tmp/custom.sock');
|
backend: 'sway',
|
||||||
|
autoStartSubMiner: true,
|
||||||
|
pauseUntilOverlayReady: true,
|
||||||
|
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||||
|
aniskipEnabled: false,
|
||||||
|
aniskipButtonKey: 'F8',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(parsed.socketPath, '/tmp/config.sock');
|
||||||
|
assert.equal(parsed.backend, 'sway');
|
||||||
assert.equal(parsed.autoStart, true);
|
assert.equal(parsed.autoStart, true);
|
||||||
assert.equal(parsed.autoStartVisibleOverlay, true);
|
|
||||||
assert.equal(parsed.autoStartPauseUntilReady, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parsePluginRuntimeConfigContent falls back to disabled startup gate options', () => {
|
|
||||||
const parsed = parsePluginRuntimeConfigContent(`
|
|
||||||
auto_start = maybe
|
|
||||||
auto_start_visible_overlay = no
|
|
||||||
auto_start_pause_until_ready = off
|
|
||||||
`);
|
|
||||||
assert.equal(parsed.autoStart, false);
|
|
||||||
assert.equal(parsed.autoStartVisibleOverlay, false);
|
assert.equal(parsed.autoStartVisibleOverlay, false);
|
||||||
assert.equal(parsed.autoStartPauseUntilReady, false);
|
assert.equal(parsed.autoStartPauseUntilReady, true);
|
||||||
|
assert.equal(parsed.binaryPath, '/opt/SubMiner/SubMiner.AppImage');
|
||||||
|
assert.equal(parsed.texthookerEnabled, false);
|
||||||
|
assert.equal(parsed.aniskipEnabled, false);
|
||||||
|
assert.equal(parsed.aniskipButtonKey, 'F8');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getPluginConfigCandidates resolves Windows mpv script-opts path', () => {
|
test('parsePluginRuntimeConfigFromMainConfig defaults to background-only managed startup', () => {
|
||||||
|
const parsed = parsePluginRuntimeConfigFromMainConfig(null);
|
||||||
|
|
||||||
|
assert.equal(parsed.autoStart, true);
|
||||||
|
assert.equal(parsed.autoStartVisibleOverlay, false);
|
||||||
|
assert.equal(parsed.autoStartPauseUntilReady, true);
|
||||||
|
assert.equal(parsed.texthookerEnabled, false);
|
||||||
|
assert.equal(parsed.aniskipEnabled, true);
|
||||||
|
assert.equal(parsed.aniskipButtonKey, 'TAB');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildPluginRuntimeScriptOptParts emits config values that override plugin defaults', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
getPluginConfigCandidates({
|
buildPluginRuntimeScriptOptParts(
|
||||||
platform: 'win32',
|
{
|
||||||
homeDir: 'C:\\Users\\tester',
|
socketPath: '/tmp/config.sock',
|
||||||
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
|
binaryPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||||
}),
|
backend: 'x11',
|
||||||
['C:\\Users\\tester\\AppData\\Roaming\\mpv\\script-opts\\subminer.conf'],
|
autoStart: true,
|
||||||
|
autoStartVisibleOverlay: false,
|
||||||
|
autoStartPauseUntilReady: true,
|
||||||
|
texthookerEnabled: false,
|
||||||
|
aniskipEnabled: false,
|
||||||
|
aniskipButtonKey: 'F8',
|
||||||
|
},
|
||||||
|
'/fallback/SubMiner.AppImage',
|
||||||
|
),
|
||||||
|
[
|
||||||
|
'subminer-binary_path=/opt/SubMiner/SubMiner.AppImage',
|
||||||
|
'subminer-socket_path=/tmp/config.sock',
|
||||||
|
'subminer-backend=x11',
|
||||||
|
'subminer-auto_start=yes',
|
||||||
|
'subminer-auto_start_visible_overlay=no',
|
||||||
|
'subminer-auto_start_pause_until_ready=yes',
|
||||||
|
'subminer-texthooker_enabled=no',
|
||||||
|
'subminer-aniskip_enabled=no',
|
||||||
|
'subminer-aniskip_button_key=F8',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildPluginRuntimeScriptOptParts strips script-option delimiters from string values', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
buildPluginRuntimeScriptOptParts(
|
||||||
|
{
|
||||||
|
socketPath: '/tmp/config.sock,subminer-auto_start=no\nother=yes',
|
||||||
|
binaryPath: '/opt/SubMiner,\nSubMiner.AppImage',
|
||||||
|
backend: 'x11',
|
||||||
|
autoStart: true,
|
||||||
|
autoStartVisibleOverlay: false,
|
||||||
|
autoStartPauseUntilReady: true,
|
||||||
|
texthookerEnabled: false,
|
||||||
|
aniskipEnabled: false,
|
||||||
|
aniskipButtonKey: 'F8,\nF9',
|
||||||
|
},
|
||||||
|
'/fallback/SubMiner.AppImage',
|
||||||
|
),
|
||||||
|
[
|
||||||
|
'subminer-binary_path=/opt/SubMiner SubMiner.AppImage',
|
||||||
|
'subminer-socket_path=/tmp/config.sock subminer-auto_start=no other=yes',
|
||||||
|
'subminer-backend=x11',
|
||||||
|
'subminer-auto_start=yes',
|
||||||
|
'subminer-auto_start_visible_overlay=no',
|
||||||
|
'subminer-auto_start_pause_until_ready=yes',
|
||||||
|
'subminer-texthooker_enabled=no',
|
||||||
|
'subminer-aniskip_enabled=no',
|
||||||
|
'subminer-aniskip_button_key=F8 F9',
|
||||||
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
|||||||
action: 'show',
|
action: 'show',
|
||||||
logLevel: 'warn',
|
logLevel: 'warn',
|
||||||
},
|
},
|
||||||
|
settingsInvocation: null,
|
||||||
mpvInvocation: null,
|
mpvInvocation: null,
|
||||||
appInvocation: null,
|
appInvocation: null,
|
||||||
dictionaryTriggered: false,
|
dictionaryTriggered: false,
|
||||||
@@ -159,12 +160,86 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
|||||||
assert.equal(parsed.logLevel, 'warn');
|
assert.equal(parsed.logLevel, 'warn');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('applyInvocationsToArgs maps settings invocation to settings window', () => {
|
||||||
|
const parsed = createDefaultArgs({});
|
||||||
|
|
||||||
|
applyInvocationsToArgs(parsed, {
|
||||||
|
jellyfinInvocation: null,
|
||||||
|
configInvocation: null,
|
||||||
|
settingsInvocation: {
|
||||||
|
logLevel: undefined,
|
||||||
|
},
|
||||||
|
mpvInvocation: null,
|
||||||
|
appInvocation: null,
|
||||||
|
dictionaryTriggered: false,
|
||||||
|
dictionaryTarget: null,
|
||||||
|
dictionaryLogLevel: null,
|
||||||
|
dictionaryCandidates: false,
|
||||||
|
dictionarySelect: false,
|
||||||
|
dictionaryAnilistId: null,
|
||||||
|
statsTriggered: false,
|
||||||
|
statsBackground: false,
|
||||||
|
statsStop: false,
|
||||||
|
statsCleanup: false,
|
||||||
|
statsCleanupVocab: false,
|
||||||
|
statsCleanupLifetime: false,
|
||||||
|
statsLogLevel: null,
|
||||||
|
doctorTriggered: false,
|
||||||
|
doctorLogLevel: null,
|
||||||
|
doctorRefreshKnownWords: false,
|
||||||
|
texthookerTriggered: false,
|
||||||
|
texthookerLogLevel: null,
|
||||||
|
texthookerOpenBrowser: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(parsed.settings, true);
|
||||||
|
assert.equal(parsed.configPath, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyInvocationsToArgs fails when config invocation has no action', () => {
|
||||||
|
const parsed = createDefaultArgs({});
|
||||||
|
|
||||||
|
const error = withProcessExitIntercept(() => {
|
||||||
|
applyInvocationsToArgs(parsed, {
|
||||||
|
jellyfinInvocation: null,
|
||||||
|
configInvocation: {
|
||||||
|
action: undefined,
|
||||||
|
},
|
||||||
|
settingsInvocation: null,
|
||||||
|
mpvInvocation: null,
|
||||||
|
appInvocation: null,
|
||||||
|
dictionaryTriggered: false,
|
||||||
|
dictionaryTarget: null,
|
||||||
|
dictionaryLogLevel: null,
|
||||||
|
dictionaryCandidates: false,
|
||||||
|
dictionarySelect: false,
|
||||||
|
dictionaryAnilistId: null,
|
||||||
|
statsTriggered: false,
|
||||||
|
statsBackground: false,
|
||||||
|
statsStop: false,
|
||||||
|
statsCleanup: false,
|
||||||
|
statsCleanupVocab: false,
|
||||||
|
statsCleanupLifetime: false,
|
||||||
|
statsLogLevel: null,
|
||||||
|
doctorTriggered: false,
|
||||||
|
doctorLogLevel: null,
|
||||||
|
doctorRefreshKnownWords: false,
|
||||||
|
texthookerTriggered: false,
|
||||||
|
texthookerLogLevel: null,
|
||||||
|
texthookerOpenBrowser: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(error.code, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
||||||
const parsed = createDefaultArgs({});
|
const parsed = createDefaultArgs({});
|
||||||
|
|
||||||
applyInvocationsToArgs(parsed, {
|
applyInvocationsToArgs(parsed, {
|
||||||
jellyfinInvocation: null,
|
jellyfinInvocation: null,
|
||||||
configInvocation: null,
|
configInvocation: null,
|
||||||
|
settingsInvocation: null,
|
||||||
mpvInvocation: null,
|
mpvInvocation: null,
|
||||||
appInvocation: null,
|
appInvocation: null,
|
||||||
dictionaryTriggered: false,
|
dictionaryTriggered: false,
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function createDefaultArgs(
|
|||||||
const youtubeAudioLangs = uniqueNormalizedLangCodes([...primarySubLangs, ...secondarySubLangs]);
|
const youtubeAudioLangs = uniqueNormalizedLangCodes([...primarySubLangs, ...secondarySubLangs]);
|
||||||
|
|
||||||
const parsed: Args = {
|
const parsed: Args = {
|
||||||
backend: 'auto',
|
backend: mpvConfig.backend ?? 'auto',
|
||||||
directory: '.',
|
directory: '.',
|
||||||
recursive: false,
|
recursive: false,
|
||||||
profile: '',
|
profile: '',
|
||||||
@@ -156,7 +156,9 @@ export function createDefaultArgs(
|
|||||||
statsCleanupLifetime: false,
|
statsCleanupLifetime: false,
|
||||||
doctor: false,
|
doctor: false,
|
||||||
doctorRefreshKnownWords: false,
|
doctorRefreshKnownWords: false,
|
||||||
|
version: false,
|
||||||
update: false,
|
update: false,
|
||||||
|
settings: false,
|
||||||
configPath: false,
|
configPath: false,
|
||||||
configShow: false,
|
configShow: false,
|
||||||
mpvIdle: false,
|
mpvIdle: false,
|
||||||
@@ -219,6 +221,8 @@ export function applyRootOptionsToArgs(
|
|||||||
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
|
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
|
||||||
if (options.rofi === true) parsed.useRofi = true;
|
if (options.rofi === true) parsed.useRofi = true;
|
||||||
if (options.update === true) parsed.update = true;
|
if (options.update === true) parsed.update = true;
|
||||||
|
if (options.version === true) parsed.version = true;
|
||||||
|
if (options.settings === true) parsed.settings = true;
|
||||||
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
||||||
if (options.texthooker === false) parsed.useTexthooker = false;
|
if (options.texthooker === false) parsed.useTexthooker = false;
|
||||||
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
|
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
|
||||||
@@ -306,10 +310,20 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
|||||||
if (invocations.configInvocation.logLevel) {
|
if (invocations.configInvocation.logLevel) {
|
||||||
parsed.logLevel = parseLogLevel(invocations.configInvocation.logLevel);
|
parsed.logLevel = parseLogLevel(invocations.configInvocation.logLevel);
|
||||||
}
|
}
|
||||||
const action = (invocations.configInvocation.action || 'path').toLowerCase();
|
const action = (invocations.configInvocation.action || '').toLowerCase();
|
||||||
if (action === 'path') parsed.configPath = true;
|
if (action === 'path') parsed.configPath = true;
|
||||||
else if (action === 'show') parsed.configShow = true;
|
else if (action === 'show') parsed.configShow = true;
|
||||||
else fail(`Unknown config action: ${invocations.configInvocation.action}`);
|
else
|
||||||
|
fail(
|
||||||
|
`Unknown config action: ${invocations.configInvocation.action || '(none)'}. Expected path or show.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invocations.settingsInvocation) {
|
||||||
|
if (invocations.settingsInvocation.logLevel) {
|
||||||
|
parsed.logLevel = parseLogLevel(invocations.settingsInvocation.logLevel);
|
||||||
|
}
|
||||||
|
parsed.settings = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invocations.mpvInvocation) {
|
if (invocations.mpvInvocation) {
|
||||||
|
|||||||
@@ -15,13 +15,14 @@ export interface JellyfinInvocation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandActionInvocation {
|
export interface CommandActionInvocation {
|
||||||
action: string;
|
action?: string;
|
||||||
logLevel?: string;
|
logLevel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CliInvocations {
|
export interface CliInvocations {
|
||||||
jellyfinInvocation: JellyfinInvocation | null;
|
jellyfinInvocation: JellyfinInvocation | null;
|
||||||
configInvocation: CommandActionInvocation | null;
|
configInvocation: CommandActionInvocation | null;
|
||||||
|
settingsInvocation: CommandActionInvocation | null;
|
||||||
mpvInvocation: CommandActionInvocation | null;
|
mpvInvocation: CommandActionInvocation | null;
|
||||||
appInvocation: { appArgs: string[] } | null;
|
appInvocation: { appArgs: string[] } | null;
|
||||||
dictionaryTriggered: boolean;
|
dictionaryTriggered: boolean;
|
||||||
@@ -57,6 +58,8 @@ function applyRootOptions(program: Command): void {
|
|||||||
.option('-p, --profile <profile>', 'MPV profile')
|
.option('-p, --profile <profile>', 'MPV profile')
|
||||||
.option('--start', 'Explicitly start overlay')
|
.option('--start', 'Explicitly start overlay')
|
||||||
.option('--log-level <level>', 'Log level')
|
.option('--log-level <level>', 'Log level')
|
||||||
|
.option('-v, --version', 'Show SubMiner version')
|
||||||
|
.option('--settings', 'Open settings window')
|
||||||
.option('-u, --update', 'Check for updates')
|
.option('-u, --update', 'Check for updates')
|
||||||
.option('-R, --rofi', 'Use rofi picker')
|
.option('-R, --rofi', 'Use rofi picker')
|
||||||
.option('-S, --start-overlay', 'Auto-start overlay')
|
.option('-S, --start-overlay', 'Auto-start overlay')
|
||||||
@@ -86,6 +89,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
|
|||||||
'jf',
|
'jf',
|
||||||
'doctor',
|
'doctor',
|
||||||
'config',
|
'config',
|
||||||
|
'settings',
|
||||||
'mpv',
|
'mpv',
|
||||||
'dictionary',
|
'dictionary',
|
||||||
'dict',
|
'dict',
|
||||||
@@ -136,6 +140,7 @@ export function parseCliPrograms(
|
|||||||
} {
|
} {
|
||||||
let jellyfinInvocation: JellyfinInvocation | null = null;
|
let jellyfinInvocation: JellyfinInvocation | null = null;
|
||||||
let configInvocation: CommandActionInvocation | null = null;
|
let configInvocation: CommandActionInvocation | null = null;
|
||||||
|
let settingsInvocation: CommandActionInvocation | null = null;
|
||||||
let mpvInvocation: CommandActionInvocation | null = null;
|
let mpvInvocation: CommandActionInvocation | null = null;
|
||||||
let appInvocation: { appArgs: string[] } | null = null;
|
let appInvocation: { appArgs: string[] } | null = null;
|
||||||
let dictionaryTriggered = false;
|
let dictionaryTriggered = false;
|
||||||
@@ -291,16 +296,26 @@ export function parseCliPrograms(
|
|||||||
|
|
||||||
commandProgram
|
commandProgram
|
||||||
.command('config')
|
.command('config')
|
||||||
.description('Config helpers')
|
.description('Config file helpers (path|show)')
|
||||||
.argument('[action]', 'path|show', 'path')
|
.argument('[action]', 'path|show')
|
||||||
.option('--log-level <level>', 'Log level')
|
.option('--log-level <level>', 'Log level')
|
||||||
.action((action: string, options: Record<string, unknown>) => {
|
.action((action: string | undefined, options: Record<string, unknown>) => {
|
||||||
configInvocation = {
|
configInvocation = {
|
||||||
action,
|
action,
|
||||||
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
commandProgram
|
||||||
|
.command('settings')
|
||||||
|
.description('Open SubMiner settings window')
|
||||||
|
.option('--log-level <level>', 'Log level')
|
||||||
|
.action((options: Record<string, unknown>) => {
|
||||||
|
settingsInvocation = {
|
||||||
|
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
commandProgram
|
commandProgram
|
||||||
.command('mpv')
|
.command('mpv')
|
||||||
.description('MPV helpers')
|
.description('MPV helpers')
|
||||||
@@ -354,6 +369,7 @@ export function parseCliPrograms(
|
|||||||
invocations: {
|
invocations: {
|
||||||
jellyfinInvocation,
|
jellyfinInvocation,
|
||||||
configInvocation,
|
configInvocation,
|
||||||
|
settingsInvocation,
|
||||||
mpvInvocation,
|
mpvInvocation,
|
||||||
appInvocation,
|
appInvocation,
|
||||||
dictionaryTriggered,
|
dictionaryTriggered,
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
import { parseMpvLaunchMode } from '../../src/shared/mpv-launch-mode.js';
|
import { parseMpvLaunchMode } from '../../src/shared/mpv-launch-mode.js';
|
||||||
|
import type { Backend } from '../types.js';
|
||||||
import type { LauncherMpvConfig } from '../types.js';
|
import type { LauncherMpvConfig } from '../types.js';
|
||||||
|
|
||||||
|
function parseBackend(value: unknown): Backend | undefined {
|
||||||
|
if (typeof value !== 'string') return undefined;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (
|
||||||
|
normalized === 'auto' ||
|
||||||
|
normalized === 'hyprland' ||
|
||||||
|
normalized === 'sway' ||
|
||||||
|
normalized === 'x11' ||
|
||||||
|
normalized === 'macos' ||
|
||||||
|
normalized === 'windows'
|
||||||
|
) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNonEmptyString(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== 'string') return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherMpvConfig {
|
export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherMpvConfig {
|
||||||
const mpvRaw = root.mpv;
|
const mpvRaw = root.mpv;
|
||||||
if (!mpvRaw || typeof mpvRaw !== 'object') return {};
|
if (!mpvRaw || typeof mpvRaw !== 'object') return {};
|
||||||
@@ -8,5 +31,14 @@ export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherM
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
launchMode: parseMpvLaunchMode(mpv.launchMode),
|
launchMode: parseMpvLaunchMode(mpv.launchMode),
|
||||||
|
socketPath: parseNonEmptyString(mpv.socketPath),
|
||||||
|
backend: parseBackend(mpv.backend),
|
||||||
|
autoStartSubMiner:
|
||||||
|
typeof mpv.autoStartSubMiner === 'boolean' ? mpv.autoStartSubMiner : undefined,
|
||||||
|
pauseUntilOverlayReady:
|
||||||
|
typeof mpv.pauseUntilOverlayReady === 'boolean' ? mpv.pauseUntilOverlayReady : undefined,
|
||||||
|
subminerBinaryPath: parseNonEmptyString(mpv.subminerBinaryPath),
|
||||||
|
aniskipEnabled: typeof mpv.aniskipEnabled === 'boolean' ? mpv.aniskipEnabled : undefined,
|
||||||
|
aniskipButtonKey: parseNonEmptyString(mpv.aniskipButtonKey),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,126 +1,76 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { log } from '../log.js';
|
import { log } from '../log.js';
|
||||||
import type { LogLevel, PluginRuntimeConfig } from '../types.js';
|
import type { Backend, LogLevel, PluginRuntimeConfig } from '../types.js';
|
||||||
import { DEFAULT_SOCKET_PATH } from '../types.js';
|
import { DEFAULT_SOCKET_PATH } from '../types.js';
|
||||||
|
import { buildSubminerPluginRuntimeScriptOptParts } from '../../src/shared/subminer-plugin-script-opts.js';
|
||||||
|
import { parseLauncherMpvConfig } from './mpv-config.js';
|
||||||
|
import { readLauncherMainConfigObject } from './shared-config-reader.js';
|
||||||
|
|
||||||
function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
|
function rootObject(root: Record<string, unknown> | null, key: string): Record<string, unknown> {
|
||||||
return platform === 'win32' ? path.win32 : path.posix;
|
const value = root?.[key];
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPluginConfigCandidates(options?: {
|
function booleanOrDefault(value: unknown, fallback: boolean): boolean {
|
||||||
platform?: NodeJS.Platform;
|
return typeof value === 'boolean' ? value : fallback;
|
||||||
homeDir?: string;
|
}
|
||||||
xdgConfigHome?: string;
|
|
||||||
appDataDir?: string;
|
|
||||||
}): string[] {
|
|
||||||
const platform = options?.platform ?? process.platform;
|
|
||||||
const homeDir = options?.homeDir ?? os.homedir();
|
|
||||||
const platformPath = getPlatformPath(platform);
|
|
||||||
|
|
||||||
if (platform === 'win32') {
|
function nonEmptyStringOrDefault(value: unknown, fallback: string): string {
|
||||||
const appDataDir =
|
if (typeof value !== 'string') return fallback;
|
||||||
options?.appDataDir?.trim() ||
|
const trimmed = value.trim();
|
||||||
process.env.APPDATA?.trim() ||
|
return trimmed.length > 0 ? trimmed : fallback;
|
||||||
platformPath.join(homeDir, 'AppData', 'Roaming');
|
}
|
||||||
return [platformPath.join(appDataDir, 'mpv', 'script-opts', 'subminer.conf')];
|
|
||||||
|
function validBackendOrDefault(value: unknown, fallback: Backend): Backend {
|
||||||
|
if (typeof value !== 'string') return fallback;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (
|
||||||
|
normalized === 'auto' ||
|
||||||
|
normalized === 'hyprland' ||
|
||||||
|
normalized === 'sway' ||
|
||||||
|
normalized === 'x11' ||
|
||||||
|
normalized === 'macos' ||
|
||||||
|
normalized === 'windows'
|
||||||
|
) {
|
||||||
|
return normalized;
|
||||||
}
|
}
|
||||||
|
return fallback;
|
||||||
const xdgConfigHome =
|
|
||||||
options?.xdgConfigHome?.trim() ||
|
|
||||||
process.env.XDG_CONFIG_HOME ||
|
|
||||||
platformPath.join(homeDir, '.config');
|
|
||||||
return Array.from(
|
|
||||||
new Set([
|
|
||||||
platformPath.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
|
||||||
platformPath.join(homeDir, '.config', 'mpv', 'script-opts', 'subminer.conf'),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parsePluginRuntimeConfigContent(
|
export function parsePluginRuntimeConfigFromMainConfig(
|
||||||
content: string,
|
root: Record<string, unknown> | null,
|
||||||
logLevel: LogLevel = 'warn',
|
|
||||||
): PluginRuntimeConfig {
|
): PluginRuntimeConfig {
|
||||||
const runtimeConfig: PluginRuntimeConfig = {
|
const mpvConfig = root ? parseLauncherMpvConfig(root) : {};
|
||||||
socketPath: DEFAULT_SOCKET_PATH,
|
const texthooker = rootObject(root, 'texthooker');
|
||||||
autoStart: true,
|
|
||||||
autoStartVisibleOverlay: true,
|
return {
|
||||||
autoStartPauseUntilReady: true,
|
socketPath: mpvConfig.socketPath ?? DEFAULT_SOCKET_PATH,
|
||||||
|
binaryPath: mpvConfig.subminerBinaryPath ?? '',
|
||||||
|
backend: validBackendOrDefault(mpvConfig.backend, 'auto'),
|
||||||
|
autoStart: booleanOrDefault(mpvConfig.autoStartSubMiner, true),
|
||||||
|
autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false),
|
||||||
|
autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true),
|
||||||
|
texthookerEnabled: booleanOrDefault(texthooker.launchAtStartup, false),
|
||||||
|
aniskipEnabled: booleanOrDefault(mpvConfig.aniskipEnabled, true),
|
||||||
|
aniskipButtonKey: nonEmptyStringOrDefault(mpvConfig.aniskipButtonKey, 'TAB'),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const parseBooleanValue = (key: string, value: string): boolean => {
|
export function buildPluginRuntimeScriptOptParts(
|
||||||
const normalized = value.trim().toLowerCase();
|
runtimeConfig: PluginRuntimeConfig,
|
||||||
if (['yes', 'true', '1', 'on'].includes(normalized)) return true;
|
fallbackAppPath: string,
|
||||||
if (['no', 'false', '0', 'off'].includes(normalized)) return false;
|
): string[] {
|
||||||
log('warn', logLevel, `Invalid boolean value for ${key}: "${value}". Using false.`);
|
return buildSubminerPluginRuntimeScriptOptParts(runtimeConfig, fallbackAppPath);
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const line of content.split(/\r?\n/)) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
|
|
||||||
const keyValueMatch = trimmed.match(/^([a-z0-9_-]+)\s*=\s*(.+)$/i);
|
|
||||||
if (!keyValueMatch) continue;
|
|
||||||
const key = (keyValueMatch[1] || '').toLowerCase();
|
|
||||||
const value = (keyValueMatch[2] || '').split('#', 1)[0]?.trim() || '';
|
|
||||||
if (!value) continue;
|
|
||||||
|
|
||||||
if (key === 'socket_path') {
|
|
||||||
runtimeConfig.socketPath = value;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (key === 'auto_start') {
|
|
||||||
runtimeConfig.autoStart = parseBooleanValue('auto_start', value);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (key === 'auto_start_visible_overlay') {
|
|
||||||
runtimeConfig.autoStartVisibleOverlay = parseBooleanValue(
|
|
||||||
'auto_start_visible_overlay',
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (key === 'auto_start_pause_until_ready') {
|
|
||||||
runtimeConfig.autoStartPauseUntilReady = parseBooleanValue(
|
|
||||||
'auto_start_pause_until_ready',
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return runtimeConfig;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
||||||
const candidates = getPluginConfigCandidates();
|
const parsed = parsePluginRuntimeConfigFromMainConfig(readLauncherMainConfigObject());
|
||||||
const defaults: PluginRuntimeConfig = {
|
|
||||||
socketPath: DEFAULT_SOCKET_PATH,
|
|
||||||
autoStart: true,
|
|
||||||
autoStartVisibleOverlay: true,
|
|
||||||
autoStartPauseUntilReady: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const configPath of candidates) {
|
|
||||||
if (!fs.existsSync(configPath)) continue;
|
|
||||||
try {
|
|
||||||
const parsed = parsePluginRuntimeConfigContent(fs.readFileSync(configPath, 'utf8'));
|
|
||||||
log(
|
|
||||||
'debug',
|
|
||||||
logLevel,
|
|
||||||
`Using mpv plugin settings from ${configPath}: socket_path=${parsed.socketPath}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}`,
|
|
||||||
);
|
|
||||||
return parsed;
|
|
||||||
} catch {
|
|
||||||
log('warn', logLevel, `Failed to read ${configPath}; using launcher defaults`);
|
|
||||||
return defaults;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log(
|
log(
|
||||||
'debug',
|
'debug',
|
||||||
logLevel,
|
logLevel,
|
||||||
`No mpv subminer.conf found; using launcher defaults (socket_path=${defaults.socketPath}, auto_start=${defaults.autoStart}, auto_start_visible_overlay=${defaults.autoStartVisibleOverlay}, auto_start_pause_until_ready=${defaults.autoStartPauseUntilReady})`,
|
`Using mpv plugin settings from SubMiner config: socket_path=${parsed.socketPath}, backend=${parsed.backend}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}, texthooker_enabled=${parsed.texthookerEnabled}, aniskip_enabled=${parsed.aniskipEnabled}, aniskip_button_key=${parsed.aniskipButtonKey}`,
|
||||||
);
|
);
|
||||||
return defaults;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|||||||
+133
-15
@@ -99,6 +99,30 @@ test('config discovery ignores lowercase subminer candidate', () => {
|
|||||||
assert.equal(resolved, expected);
|
assert.equal(resolved, expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('version flag prints installed app version without requiring app binary', () => {
|
||||||
|
withTempDir((root) => {
|
||||||
|
const homeDir = path.join(root, 'home');
|
||||||
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
|
const result = runLauncher(['--version'], makeTestEnv(homeDir, xdgConfigHome));
|
||||||
|
|
||||||
|
assert.equal(result.status, 0);
|
||||||
|
assert.match(result.stdout.trim(), /^SubMiner \d+\.\d+\.\d+/);
|
||||||
|
assert.equal(result.stderr, '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('short version flag prints installed app version without requiring app binary', () => {
|
||||||
|
withTempDir((root) => {
|
||||||
|
const homeDir = path.join(root, 'home');
|
||||||
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
|
const result = runLauncher(['-v'], makeTestEnv(homeDir, xdgConfigHome));
|
||||||
|
|
||||||
|
assert.equal(result.status, 0);
|
||||||
|
assert.match(result.stdout.trim(), /^SubMiner \d+\.\d+\.\d+/);
|
||||||
|
assert.equal(result.stderr, '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('config path prefers jsonc over json for same directory', () => {
|
test('config path prefers jsonc over json for same directory', () => {
|
||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
@@ -133,10 +157,10 @@ test('mpv socket command returns socket path from plugin runtime config', () =>
|
|||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
const expectedSocket = path.join(root, 'custom', 'subminer.sock');
|
const expectedSocket = path.join(root, 'custom', 'subminer.sock');
|
||||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
|
||||||
`socket_path=${expectedSocket}\n`,
|
JSON.stringify({ mpv: { socketPath: expectedSocket } }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = runLauncher(['mpv', 'socket'], makeTestEnv(homeDir, xdgConfigHome));
|
const result = runLauncher(['mpv', 'socket'], makeTestEnv(homeDir, xdgConfigHome));
|
||||||
@@ -151,10 +175,10 @@ test('mpv status exits non-zero when socket is not ready', () => {
|
|||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
const socketPath = path.join(root, 'missing.sock');
|
const socketPath = path.join(root, 'missing.sock');
|
||||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
|
||||||
`socket_path=${socketPath}\n`,
|
JSON.stringify({ mpv: { socketPath } }),
|
||||||
);
|
);
|
||||||
const result = runLauncher(['mpv', 'status'], makeTestEnv(homeDir, xdgConfigHome));
|
const result = runLauncher(['mpv', 'status'], makeTestEnv(homeDir, xdgConfigHome));
|
||||||
|
|
||||||
@@ -208,6 +232,82 @@ test('doctor refresh-known-words forwards app refresh command without requiring
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('launcher settings option forwards app settings window command', () => {
|
||||||
|
withTempDir((root) => {
|
||||||
|
const homeDir = path.join(root, 'home');
|
||||||
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
|
const appPath = path.join(root, 'fake-subminer.sh');
|
||||||
|
const capturePath = path.join(root, 'captured-args.txt');
|
||||||
|
fs.writeFileSync(
|
||||||
|
appPath,
|
||||||
|
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||||
|
);
|
||||||
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...makeTestEnv(homeDir, xdgConfigHome),
|
||||||
|
SUBMINER_APPIMAGE_PATH: appPath,
|
||||||
|
SUBMINER_TEST_CAPTURE: capturePath,
|
||||||
|
};
|
||||||
|
const result = runLauncher(['--settings'], env);
|
||||||
|
|
||||||
|
assert.equal(result.status, 0);
|
||||||
|
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--settings\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('launcher settings command forwards app settings window command', () => {
|
||||||
|
withTempDir((root) => {
|
||||||
|
const homeDir = path.join(root, 'home');
|
||||||
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
|
const appPath = path.join(root, 'fake-subminer.sh');
|
||||||
|
const capturePath = path.join(root, 'captured-args.txt');
|
||||||
|
fs.writeFileSync(
|
||||||
|
appPath,
|
||||||
|
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||||
|
);
|
||||||
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...makeTestEnv(homeDir, xdgConfigHome),
|
||||||
|
SUBMINER_APPIMAGE_PATH: appPath,
|
||||||
|
SUBMINER_TEST_CAPTURE: capturePath,
|
||||||
|
};
|
||||||
|
const result = runLauncher(['settings'], env);
|
||||||
|
|
||||||
|
assert.equal(result.status, 0);
|
||||||
|
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--settings\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('launcher settings command suppresses known Electron macOS menu diagnostics', () => {
|
||||||
|
withTempDir((root) => {
|
||||||
|
const homeDir = path.join(root, 'home');
|
||||||
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
|
const appPath = path.join(root, 'fake-subminer.sh');
|
||||||
|
fs.writeFileSync(
|
||||||
|
appPath,
|
||||||
|
[
|
||||||
|
'#!/bin/sh',
|
||||||
|
'printf "%s\\n" "2026-05-17 02:59:52.141 SubMiner[29060:305323] representedObject is not a WeakPtrToElectronMenuModelAsNSObject" >&2',
|
||||||
|
'printf "%s\\n" "real stderr line" >&2',
|
||||||
|
'exit 0',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...makeTestEnv(homeDir, xdgConfigHome),
|
||||||
|
SUBMINER_APPIMAGE_PATH: appPath,
|
||||||
|
};
|
||||||
|
const result = runLauncher(['settings'], env);
|
||||||
|
|
||||||
|
assert.equal(result.status, 0);
|
||||||
|
assert.equal(result.stderr, 'real stderr line\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, () => {
|
test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, () => {
|
||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
@@ -221,7 +321,6 @@ test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, ()
|
|||||||
|
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
|
||||||
fs.writeFileSync(videoPath, 'fake video content');
|
fs.writeFileSync(videoPath, 'fake video content');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
||||||
@@ -236,8 +335,15 @@ test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, ()
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
|
||||||
`socket_path=${socketPath}\nauto_start=no\nauto_start_visible_overlay=no\nauto_start_pause_until_ready=no\n`,
|
JSON.stringify({
|
||||||
|
auto_start_overlay: false,
|
||||||
|
mpv: {
|
||||||
|
socketPath,
|
||||||
|
autoStartSubMiner: false,
|
||||||
|
pauseUntilOverlayReady: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
||||||
fs.chmodSync(appPath, 0o755);
|
fs.chmodSync(appPath, 0o755);
|
||||||
@@ -301,7 +407,6 @@ test('launcher forwards non-info log level into mpv plugin script opts', { timeo
|
|||||||
|
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
|
||||||
fs.writeFileSync(videoPath, 'fake video content');
|
fs.writeFileSync(videoPath, 'fake video content');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
||||||
@@ -316,8 +421,15 @@ test('launcher forwards non-info log level into mpv plugin script opts', { timeo
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
|
||||||
`socket_path=${socketPath}\nauto_start=yes\nauto_start_visible_overlay=yes\nauto_start_pause_until_ready=yes\n`,
|
JSON.stringify({
|
||||||
|
auto_start_overlay: true,
|
||||||
|
mpv: {
|
||||||
|
socketPath,
|
||||||
|
autoStartSubMiner: true,
|
||||||
|
pauseUntilOverlayReady: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
||||||
fs.chmodSync(appPath, 0o755);
|
fs.chmodSync(appPath, 0o755);
|
||||||
@@ -371,7 +483,6 @@ test('launcher routes youtube urls through regular playback startup', { timeout:
|
|||||||
|
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -385,8 +496,15 @@ test('launcher routes youtube urls through regular playback startup', { timeout:
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
|
||||||
`socket_path=${socketPath}\nauto_start=yes\nauto_start_visible_overlay=yes\nauto_start_pause_until_ready=yes\n`,
|
JSON.stringify({
|
||||||
|
auto_start_overlay: true,
|
||||||
|
mpv: {
|
||||||
|
socketPath,
|
||||||
|
autoStartSubMiner: true,
|
||||||
|
pauseUntilOverlayReady: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
appPath,
|
appPath,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user