mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
Compare commits
80
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa3051f9cb | ||
|
|
848a6c4021 | ||
|
|
b99409bbcd | ||
|
|
6d0f37ac21 | ||
|
|
696462a82e | ||
|
|
655510cf2e | ||
|
|
af44735bb8 | ||
|
|
804fb8933e
|
||
|
|
2924f04316 | ||
|
|
a9a4c4f083 | ||
|
|
90fc7cf940 | ||
|
|
2a0e931325 | ||
|
|
0129e03c0f
|
||
|
|
586d7950cf
|
||
|
|
33dceabc3e
|
||
|
|
ad1abc3268
|
||
|
|
e59b78dca0 | ||
|
|
203a61a20f
|
||
|
|
a681bbaf25
|
||
|
|
6219a81144 | ||
|
|
cc9af79d6e | ||
|
|
d3a94536a8
|
||
|
|
3068d02f89
|
||
|
|
9150a60630 | ||
|
|
3549e0ed93 | ||
|
|
b0a8b6b3fc
|
||
|
|
153bc5d4bf
|
||
|
|
cbaa4626e1 | ||
|
|
e690ffba07
|
||
|
|
d047e3e0a4 | ||
|
|
01613f1be5 | ||
|
|
bf4e5bb609 | ||
|
|
7909fcd2cf | ||
|
|
8fc20b2499 | ||
|
|
ea2b3a9507 | ||
|
|
a6d96e9966 | ||
|
|
7108e086ba | ||
|
|
78a9b6346f | ||
|
|
78eca52b7e | ||
|
|
b48083ddd8 | ||
|
|
4b6842d034 | ||
|
|
9ffa2bce94
|
||
|
|
88b73656a7
|
||
|
|
e77aa5c3b3 | ||
|
|
121b688afb | ||
|
|
727be5683a | ||
|
|
138bfe9759 | ||
|
|
0aba17d95b
|
||
|
|
a5d0b63792 | ||
|
|
15cb9fe918
|
||
|
|
db45a70ec2
|
||
|
|
9e79fa93c2 | ||
|
|
9fadbc8cdd
|
||
|
|
ffbbe698fc | ||
|
|
7b72dd2cc4
|
||
|
|
416cd7e9ac
|
||
|
|
eb749f8c19
|
||
|
|
65ac5a7728 | ||
|
|
df675cfbfe
|
||
|
|
e49b5f6589
|
||
|
|
0bbd8617f4 | ||
|
|
4e43c5c6e1 | ||
|
|
953abdf42a
|
||
|
|
fdb0a31fff
|
||
|
|
04b878dd93
|
||
|
|
07b32626a6 | ||
|
|
c68b73c8c1 | ||
|
|
b24008237b | ||
|
|
63eb228e4f | ||
|
|
ba0155e429 | ||
|
|
23af9c6464
|
||
|
|
8825cca642
|
||
|
|
bc73a02623 | ||
|
|
5096ca217b
|
||
|
|
285b19ccc3
|
||
|
|
7fbc36dc41
|
||
|
|
e93b696e71
|
||
|
|
7c161723cd | ||
|
|
b0fb39a8f5 | ||
|
|
a7a302bdd0 |
No files matched your search
@@ -27,6 +27,7 @@ Do not use hidden wrapper commands. Verification commands are owned by `package.
|
||||
- Launcher or mpv plugin: `bun run test:launcher` or `bun run test:env`, based on the behavior changed
|
||||
- Runtime compatibility or dist-sensitive wiring: `bun run test:runtime:compat`
|
||||
- Stats dashboard: `bun run test:stats`
|
||||
- Overlay, renderer, mpv, or mining behavior in the running app: `bun run build`, then `bun run test:e2e`
|
||||
- Build/release scripts: `bun run test:scripts`
|
||||
|
||||
For substantial changes, use the full gate documented in `AGENTS.md` and `docs/workflow/verification.md`.
|
||||
|
||||
@@ -9,3 +9,88 @@ on:
|
||||
jobs:
|
||||
build-test-audit:
|
||||
uses: ./.github/workflows/quality-gate.yml
|
||||
|
||||
# Boots the built app with a real mpv on each platform. Kept outside the
|
||||
# reusable quality gate so releases do not depend on it while the lane is new.
|
||||
e2e:
|
||||
name: E2E (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
env:
|
||||
SUBMINER_E2E_KEEP: '1'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.5
|
||||
|
||||
- name: Point temp files at an uploadable directory
|
||||
run: |
|
||||
# os.tmpdir() follows these, so the kept session logs land under runner.temp.
|
||||
dir="$RUNNER_TEMP/e2e"
|
||||
mkdir -p "$dir"
|
||||
{ echo "TMPDIR=$dir"; echo "TEMP=$dir"; echo "TMP=$dir"; } >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install mpv and ffmpeg (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
# Only Ubuntu sources are needed; unrelated runner repositories can be unavailable.
|
||||
apt_sources=(-o Dir::Etc::sourcelist=sources.list.d/ubuntu.sources -o Dir::Etc::sourceparts=-)
|
||||
sudo apt-get "${apt_sources[@]}" update
|
||||
sudo apt-get "${apt_sources[@]}" install -y mpv ffmpeg xvfb
|
||||
|
||||
- name: Install mpv and ffmpeg (macOS)
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
# Runner images bake in different Homebrew metadata, so without an update the
|
||||
# installed mpv build depends on which image the job lands on.
|
||||
brew update
|
||||
brew install mpv ffmpeg
|
||||
mpv --version
|
||||
|
||||
- name: Install mpv and ffmpeg (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
choco install mpv ffmpeg -y --no-progress
|
||||
# The mpv package adds no shim, so put its directory on PATH for later steps.
|
||||
mpv_exe="$(find /c/ProgramData/chocolatey/lib /c/ProgramData/chocolatey/bin "/c/Program Files" /c/tools -maxdepth 6 -iname mpv.exe 2>/dev/null | head -n 1)"
|
||||
test -n "$mpv_exe"
|
||||
cygpath -w "$(dirname "$mpv_exe")" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
cd stats && bun install --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
- name: End-to-end suite
|
||||
run: bun run test:e2e
|
||||
|
||||
- name: Dictionary backend windows (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: xvfb-run -a bun run test:dictionary:electron
|
||||
|
||||
- name: Upload session logs (on failure)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-logs-${{ matrix.os }}
|
||||
path: ${{ runner.temp }}/e2e/subminer-e2e-*/*.log
|
||||
if-no-files-found: ignore
|
||||
@@ -1,49 +0,0 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
@@ -2,6 +2,11 @@ name: Docs Pages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
rebuild_archives:
|
||||
description: 'Stable tags to rebuild in R2 (comma-separated, or "all"); archives are otherwise built once'
|
||||
required: false
|
||||
default: ''
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -10,6 +15,8 @@ on:
|
||||
paths:
|
||||
- 'docs-site/**'
|
||||
- 'scripts/docs-versioning.ts'
|
||||
- 'scripts/docs-versioned-assets.ts'
|
||||
- 'scripts/docs-archive-store.ts'
|
||||
- 'scripts/build-versioned-docs.ts'
|
||||
- '.github/workflows/docs-pages.yml'
|
||||
- 'package.json'
|
||||
@@ -54,20 +61,21 @@ jobs:
|
||||
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
|
||||
|
||||
# Builds only archives missing from R2 (plus any requested rebuilds), then the
|
||||
# root and /main/ builds that make up the Pages deployment.
|
||||
- name: Build versioned docs
|
||||
if: steps.tag_guard.outputs.stable_tag != 'false'
|
||||
run: bun run docs:build:versioned
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
DOCS_ARCHIVE_R2_ACCESS_KEY_ID: ${{ secrets.DOCS_ARCHIVE_R2_ACCESS_KEY_ID }}
|
||||
DOCS_ARCHIVE_R2_SECRET_ACCESS_KEY: ${{ secrets.DOCS_ARCHIVE_R2_SECRET_ACCESS_KEY }}
|
||||
DOCS_ARCHIVE_R2_BUCKET: ${{ vars.DOCS_ARCHIVE_R2_BUCKET }}
|
||||
REBUILD_ARCHIVES: ${{ inputs.rebuild_archives }}
|
||||
run: bun run scripts/build-versioned-docs.ts --require-archives "--rebuild-archives=${REBUILD_ARCHIVES}"
|
||||
|
||||
- name: Deploy docs to Cloudflare Pages
|
||||
if: steps.tag_guard.outputs.stable_tag != 'false'
|
||||
@@ -75,4 +83,6 @@ jobs:
|
||||
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
|
||||
# Run from docs-site so Wrangler bundles docs-site/functions (the /v/* archive server).
|
||||
workingDirectory: docs-site
|
||||
command: pages deploy ../.tmp/docs-versioned-site --project-name "${{ vars.CLOUDFLARE_PAGES_PROJECT_NAME }}" --branch main
|
||||
@@ -59,17 +59,6 @@ jobs:
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
|
||||
- name: Download previous package size reports
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p .tmp/package-baseline
|
||||
previous=$(gh api "repos/$GITHUB_REPOSITORY/releases" --jq '[.[] | select(.draft == false and .tag_name != env.GITHUB_REF_NAME)] | sort_by(.published_at) | last | .tag_name // empty')
|
||||
if [ -n "$previous" ]; then
|
||||
gh release download "$previous" --pattern 'package-size-*.json' --dir .tmp/package-baseline || echo 'Previous release has no package size reports; size comparison will be skipped.'
|
||||
fi
|
||||
|
||||
- name: Build AppImage
|
||||
run: bun run build:appimage
|
||||
env:
|
||||
@@ -78,27 +67,26 @@ jobs:
|
||||
- name: Build unversioned AppImage
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
appimages=(release/SubMiner-*.AppImage)
|
||||
appimages=(dist/release/SubMiner-*.AppImage)
|
||||
if [ "${#appimages[@]}" -eq 0 ]; then
|
||||
echo "No versioned AppImage found to create unversioned artifact."
|
||||
ls -la release
|
||||
ls -la dist/release
|
||||
exit 1
|
||||
fi
|
||||
cp "${appimages[0]}" release/SubMiner.AppImage
|
||||
cp "${appimages[0]}" dist/release/SubMiner.AppImage
|
||||
|
||||
- name: Smoke packaged runtime assets
|
||||
shell: bash
|
||||
run: xvfb-run -a bun run test:package "release/linux-unpacked/resources"
|
||||
run: xvfb-run -a bun run test:package "dist/release/linux-unpacked/resources"
|
||||
|
||||
- name: Upload AppImage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: appimage
|
||||
path: |
|
||||
release/*.AppImage
|
||||
release/latest*.yml
|
||||
release/*.blockmap
|
||||
release/package-size-*.json
|
||||
dist/release/*.AppImage
|
||||
dist/release/latest*.yml
|
||||
dist/release/*.blockmap
|
||||
if-no-files-found: error
|
||||
|
||||
build-macos:
|
||||
@@ -159,17 +147,6 @@ jobs:
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
|
||||
- name: Download previous package size reports
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p .tmp/package-baseline
|
||||
previous=$(gh api "repos/$GITHUB_REPOSITORY/releases" --jq '[.[] | select(.draft == false and .tag_name != env.GITHUB_REF_NAME)] | sort_by(.published_at) | last | .tag_name // empty')
|
||||
if [ -n "$previous" ]; then
|
||||
gh release download "$previous" --pattern 'package-size-*.json' --dir .tmp/package-baseline || echo 'Previous release has no package size reports; size comparison will be skipped.'
|
||||
fi
|
||||
|
||||
- name: Build signed + notarized macOS artifacts
|
||||
run: bun run build:mac
|
||||
env:
|
||||
@@ -182,18 +159,17 @@ jobs:
|
||||
|
||||
- name: Smoke packaged runtime assets
|
||||
shell: bash
|
||||
run: bun run test:package "release/mac-arm64/SubMiner.app/Contents/Resources"
|
||||
run: bun run test:package "dist/release/mac-arm64/SubMiner.app/Contents/Resources"
|
||||
|
||||
- name: Upload macOS artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos
|
||||
path: |
|
||||
release/*.dmg
|
||||
release/*.zip
|
||||
release/latest*.yml
|
||||
release/*.blockmap
|
||||
release/package-size-*.json
|
||||
dist/release/*.dmg
|
||||
dist/release/*.zip
|
||||
dist/release/latest*.yml
|
||||
dist/release/*.blockmap
|
||||
if-no-files-found: error
|
||||
|
||||
build-windows:
|
||||
@@ -235,17 +211,6 @@ jobs:
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
|
||||
- name: Download previous package size reports
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p .tmp/package-baseline
|
||||
previous=$(gh api "repos/$GITHUB_REPOSITORY/releases" --jq '[.[] | select(.draft == false and .tag_name != env.GITHUB_REF_NAME)] | sort_by(.published_at) | last | .tag_name // empty')
|
||||
if [ -n "$previous" ]; then
|
||||
gh release download "$previous" --pattern 'package-size-*.json' --dir .tmp/package-baseline || echo 'Previous release has no package size reports; size comparison will be skipped.'
|
||||
fi
|
||||
|
||||
- name: Verify managed Windows launcher
|
||||
run: bun test src/main/runtime/managed-launcher.test.ts
|
||||
|
||||
@@ -259,16 +224,15 @@ jobs:
|
||||
|
||||
- name: Smoke packaged runtime assets
|
||||
shell: bash
|
||||
run: bun run test:package "release/win-unpacked/resources"
|
||||
run: bun run test:package "dist/release/win-unpacked/resources"
|
||||
|
||||
- name: Upload Windows artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows
|
||||
path: |
|
||||
release/*.exe
|
||||
release/*.zip
|
||||
release/latest*.yml
|
||||
release/*.blockmap
|
||||
release/package-size-*.json
|
||||
dist/release/*.exe
|
||||
dist/release/*.zip
|
||||
dist/release/latest*.yml
|
||||
dist/release/*.blockmap
|
||||
if-no-files-found: error
|
||||
@@ -44,19 +44,19 @@ jobs:
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: appimage
|
||||
path: release
|
||||
path: dist/release
|
||||
|
||||
- name: Download macOS artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos
|
||||
path: release
|
||||
path: dist/release
|
||||
|
||||
- name: Download Windows artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows
|
||||
path: release
|
||||
path: dist/release
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
@@ -90,13 +90,15 @@ jobs:
|
||||
|
||||
- name: Package optional assets bundle
|
||||
run: |
|
||||
tar -czf "release/subminer-assets.tar.gz" \
|
||||
# Keep the archive layout stable for the AUR PKGBUILD.
|
||||
tar -czf "dist/release/subminer-assets.tar.gz" \
|
||||
--transform 's,^packaging/bun/licenses,resources/bun/licenses,' \
|
||||
config.example.jsonc \
|
||||
plugin/subminer \
|
||||
plugin/subminer.conf \
|
||||
assets/themes/subminer.rasi \
|
||||
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer \
|
||||
resources/bun/licenses
|
||||
packaging/bun/licenses
|
||||
|
||||
- name: Package Bun corresponding source
|
||||
run: bun scripts/package-bun-source.mjs
|
||||
@@ -104,17 +106,16 @@ jobs:
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
|
||||
files+=(release/package-size-*.json)
|
||||
files=(dist/release/*.AppImage dist/release/*.dmg dist/release/*.exe dist/release/*.zip dist/release/*.tar.gz dist/release/latest*.yml dist/release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
echo "No release artifacts found for checksum generation."
|
||||
exit 1
|
||||
fi
|
||||
: > release/SHA256SUMS.txt
|
||||
: > dist/release/SHA256SUMS.txt
|
||||
for file in "${files[@]}"; do
|
||||
printf '%s %s\n' \
|
||||
"$(sha256sum "$file" | awk '{print $1}')" \
|
||||
"${file##*/}" >> release/SHA256SUMS.txt
|
||||
"${file##*/}" >> dist/release/SHA256SUMS.txt
|
||||
done
|
||||
|
||||
- name: Get version from tag
|
||||
@@ -125,12 +126,12 @@ jobs:
|
||||
env:
|
||||
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
run: |
|
||||
if [ ! -s release/prerelease-notes.md ]; then
|
||||
echo "::error::release/prerelease-notes.md is missing or empty. Run 'bun run changelog:prerelease-notes --version <version>' locally and commit the file before tagging."
|
||||
if [ ! -s packaging/release-notes/prerelease-notes.md ]; then
|
||||
echo "::error::packaging/release-notes/prerelease-notes.md is missing or empty. Run 'bun run changelog:prerelease-notes --version <version>' locally and commit the file before tagging."
|
||||
exit 1
|
||||
fi
|
||||
if ! bun run changelog:check-prerelease-notes --version "$RELEASE_VERSION"; then
|
||||
echo "::error::release/prerelease-notes.md was not generated for $RELEASE_VERSION. Rerun 'bun run changelog:prerelease-notes --version $RELEASE_VERSION' locally, commit, and retag."
|
||||
echo "::error::packaging/release-notes/prerelease-notes.md was not generated for $RELEASE_VERSION. Rerun 'bun run changelog:prerelease-notes --version $RELEASE_VERSION' locally, commit, and retag."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -143,16 +144,15 @@ jobs:
|
||||
|
||||
shopt -s nullglob
|
||||
artifacts=(
|
||||
release/*.AppImage
|
||||
release/*.dmg
|
||||
release/*.exe
|
||||
release/*.zip
|
||||
release/*.tar.gz
|
||||
release/*.tar.gz.sha256
|
||||
release/latest*.yml
|
||||
release/*.blockmap
|
||||
release/SHA256SUMS.txt
|
||||
release/package-size-*.json
|
||||
dist/release/*.AppImage
|
||||
dist/release/*.dmg
|
||||
dist/release/*.exe
|
||||
dist/release/*.zip
|
||||
dist/release/*.tar.gz
|
||||
dist/release/*.tar.gz.sha256
|
||||
dist/release/latest*.yml
|
||||
dist/release/*.blockmap
|
||||
dist/release/SHA256SUMS.txt
|
||||
dist/launcher/subminer
|
||||
dist/launcher/subminer.cmd
|
||||
)
|
||||
@@ -167,14 +167,14 @@ jobs:
|
||||
--draft \
|
||||
--prerelease \
|
||||
--title "$RELEASE_VERSION" \
|
||||
--notes-file release/prerelease-notes.md
|
||||
--notes-file packaging/release-notes/prerelease-notes.md
|
||||
else
|
||||
gh release create "$RELEASE_VERSION" \
|
||||
--draft \
|
||||
--latest=false \
|
||||
--prerelease \
|
||||
--title "$RELEASE_VERSION" \
|
||||
--notes-file release/prerelease-notes.md
|
||||
--notes-file packaging/release-notes/prerelease-notes.md
|
||||
fi
|
||||
|
||||
for asset in "${artifacts[@]}"; do
|
||||
@@ -185,4 +185,4 @@ jobs:
|
||||
--draft=false \
|
||||
--prerelease \
|
||||
--title "$RELEASE_VERSION" \
|
||||
--notes-file release/prerelease-notes.md
|
||||
--notes-file packaging/release-notes/prerelease-notes.md
|
||||
@@ -29,6 +29,9 @@ jobs:
|
||||
- name: Verify Windows launcher bootstrap
|
||||
run: bun test src/main/runtime/windows-launcher-bootstrap.test.ts
|
||||
|
||||
- name: Verify native mpv process launch
|
||||
run: bun test src/main/runtime/mpv-process.test.ts
|
||||
|
||||
- name: Verify POSIX launcher bootstrap
|
||||
run: bun test src/main/runtime/posix-launcher-bootstrap.test.ts
|
||||
|
||||
|
||||
@@ -45,19 +45,19 @@ jobs:
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: appimage
|
||||
path: release
|
||||
path: dist/release
|
||||
|
||||
- name: Download macOS artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos
|
||||
path: release
|
||||
path: dist/release
|
||||
|
||||
- name: Download Windows artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows
|
||||
path: release
|
||||
path: dist/release
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
@@ -91,13 +91,15 @@ jobs:
|
||||
|
||||
- name: Package optional assets bundle
|
||||
run: |
|
||||
tar -czf "release/subminer-assets.tar.gz" \
|
||||
# Keep the archive layout stable for the AUR PKGBUILD.
|
||||
tar -czf "dist/release/subminer-assets.tar.gz" \
|
||||
--transform 's,^packaging/bun/licenses,resources/bun/licenses,' \
|
||||
config.example.jsonc \
|
||||
plugin/subminer \
|
||||
plugin/subminer.conf \
|
||||
assets/themes/subminer.rasi \
|
||||
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer \
|
||||
resources/bun/licenses
|
||||
packaging/bun/licenses
|
||||
|
||||
- name: Package Bun corresponding source
|
||||
run: bun scripts/package-bun-source.mjs
|
||||
@@ -105,17 +107,16 @@ jobs:
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
shopt -s nullglob
|
||||
files=(release/*.AppImage release/*.dmg release/*.exe release/*.zip release/*.tar.gz release/latest*.yml release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
|
||||
files+=(release/package-size-*.json)
|
||||
files=(dist/release/*.AppImage dist/release/*.dmg dist/release/*.exe dist/release/*.zip dist/release/*.tar.gz dist/release/latest*.yml dist/release/*.blockmap dist/launcher/subminer dist/launcher/subminer.cmd)
|
||||
if [ "${#files[@]}" -eq 0 ]; then
|
||||
echo "No release artifacts found for checksum generation."
|
||||
exit 1
|
||||
fi
|
||||
: > release/SHA256SUMS.txt
|
||||
: > dist/release/SHA256SUMS.txt
|
||||
for file in "${files[@]}"; do
|
||||
printf '%s %s\n' \
|
||||
"$(sha256sum "$file" | awk '{print $1}')" \
|
||||
"${file##*/}" >> release/SHA256SUMS.txt
|
||||
"${file##*/}" >> dist/release/SHA256SUMS.txt
|
||||
done
|
||||
|
||||
- name: Get version from tag
|
||||
@@ -153,25 +154,24 @@ jobs:
|
||||
gh release edit "$RELEASE_VERSION" \
|
||||
--draft=false \
|
||||
--title "$RELEASE_VERSION" \
|
||||
--notes-file release/release-notes.md
|
||||
--notes-file packaging/release-notes/release-notes.md
|
||||
else
|
||||
gh release create "$RELEASE_VERSION" \
|
||||
--title "$RELEASE_VERSION" \
|
||||
--notes-file release/release-notes.md
|
||||
--notes-file packaging/release-notes/release-notes.md
|
||||
fi
|
||||
|
||||
shopt -s nullglob
|
||||
artifacts=(
|
||||
release/*.AppImage
|
||||
release/*.dmg
|
||||
release/*.exe
|
||||
release/*.zip
|
||||
release/*.tar.gz
|
||||
release/*.tar.gz.sha256
|
||||
release/latest*.yml
|
||||
release/*.blockmap
|
||||
release/SHA256SUMS.txt
|
||||
release/package-size-*.json
|
||||
dist/release/*.AppImage
|
||||
dist/release/*.dmg
|
||||
dist/release/*.exe
|
||||
dist/release/*.zip
|
||||
dist/release/*.tar.gz
|
||||
dist/release/*.tar.gz.sha256
|
||||
dist/release/latest*.yml
|
||||
dist/release/*.blockmap
|
||||
dist/release/SHA256SUMS.txt
|
||||
dist/launcher/subminer
|
||||
dist/launcher/subminer.cmd
|
||||
)
|
||||
@@ -255,22 +255,31 @@ jobs:
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download release assets for AUR
|
||||
id: aur_assets
|
||||
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="$RELEASE_VERSION"
|
||||
install -dm755 .tmp/aur-release-assets
|
||||
gh release download "$version" \
|
||||
--dir .tmp/aur-release-assets \
|
||||
--pattern "SubMiner-${version#v}.AppImage" \
|
||||
--pattern "subminer" \
|
||||
--pattern "subminer-assets.tar.gz"
|
||||
for asset in "SubMiner-${version#v}.AppImage" subminer subminer-assets.tar.gz; do
|
||||
destination=".tmp/aur-release-assets/$asset"
|
||||
if ! curl --fail --silent --show-error --location \
|
||||
--retry 3 --retry-delay 1 --retry-all-errors \
|
||||
--connect-timeout 30 --max-time 600 \
|
||||
--output "$destination.partial" \
|
||||
"$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/download/$version/$asset"; then
|
||||
echo "::warning::Unable to download $asset after retries; skipping automated AUR publish."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
mv "$destination.partial" "$destination"
|
||||
done
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Update AUR packaging metadata
|
||||
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
|
||||
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true' && steps.aur_assets.outputs.skip != 'true'
|
||||
env:
|
||||
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
|
||||
run: |
|
||||
@@ -287,7 +296,7 @@ jobs:
|
||||
--assets ".tmp/aur-release-assets/subminer-assets.tar.gz"
|
||||
|
||||
- name: Commit and push AUR update
|
||||
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
|
||||
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true' && steps.aur_assets.outputs.skip != 'true'
|
||||
working-directory: aur-subminer-bin
|
||||
env:
|
||||
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
|
||||
|
||||
+1
-5
@@ -8,11 +8,6 @@ node_modules/
|
||||
# Electron build output
|
||||
out/
|
||||
dist/
|
||||
release/*
|
||||
!release/
|
||||
!release/release-notes.md
|
||||
!release/prerelease-notes.md
|
||||
build/yomitan/
|
||||
coverage/
|
||||
|
||||
# Launcher build artifact (produced by make build-launcher)
|
||||
@@ -41,6 +36,7 @@ environment.toml
|
||||
.vitepress/dist/
|
||||
docs/.vitepress/cache/
|
||||
docs/.vitepress/dist/
|
||||
# Local test media and scripts
|
||||
tests/*
|
||||
.worktrees/
|
||||
.tmp/
|
||||
|
||||
@@ -8,3 +8,7 @@
|
||||
[submodule "vendor/subminer-yomitan"]
|
||||
path = vendor/subminer-yomitan
|
||||
url = https://github.com/ksyasuda/subminer-yomitan
|
||||
[submodule "vendor/hachidori"]
|
||||
path = vendor/hachidori
|
||||
url = https://github.com/ksyasuda/hachidori.git
|
||||
branch = subminer
|
||||
+3
-1
@@ -1,7 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
release
|
||||
coverage
|
||||
vendor
|
||||
*.log
|
||||
src/core/services/tokenizer/__fixtures__/golden/*.json
|
||||
# Reviewed release text and vendored license texts; never reformat
|
||||
packaging/release-notes
|
||||
packaging/bun/licenses
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -8,6 +8,7 @@ Start here, then leave this file.
|
||||
- Architecture map: [`docs/architecture/README.md`](./docs/architecture/README.md)
|
||||
- Workflow map: [`docs/workflow/README.md`](./docs/workflow/README.md)
|
||||
- Verification lanes: [`docs/workflow/verification.md`](./docs/workflow/verification.md)
|
||||
- Testing guidelines: [`docs/workflow/testing.md`](./docs/workflow/testing.md)
|
||||
- Knowledge-base rules: [`docs/knowledge-base/README.md`](./docs/knowledge-base/README.md)
|
||||
- Release guide: [`docs/RELEASING.md`](./docs/RELEASING.md)
|
||||
|
||||
@@ -44,10 +45,11 @@ Start here, then leave this file.
|
||||
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
|
||||
- Stats dashboard UI (`stats/`): `bun run test:stats`
|
||||
- Overlay / renderer / mpv / mining behavior in the running app: `bun run build`, then `bun run test:e2e` (headless, sandboxed; `bun run e2e start` for exploratory checks)
|
||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||
- Internal docs, `AGENTS.md`, or repo skills: `bun run test:docs:kb`
|
||||
- User-facing `docs-site/`: `bun run docs:test`, then `bun run docs:build`
|
||||
- macOS mpv window helper: `bun test scripts/get-mpv-window-macos.test.ts`
|
||||
- macOS mpv window helper (`scripts/get-mpv-window-macos.swift`): compile it on macOS with `bun run build`
|
||||
- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
|
||||
|
||||
## Docs Upkeep
|
||||
|
||||
+239
@@ -1,5 +1,244 @@
|
||||
# Changelog
|
||||
|
||||
## v0.21.1 (2026-10-26)
|
||||
|
||||
### Changed
|
||||
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
|
||||
- New stat tiles show pace, lines, known-word rate, lookups, and paused time.
|
||||
- A reading density chart of known and unknown words replaces the cumulative chart.
|
||||
- Yomitan lookups and mined cards now appear in their own lane below the chart.
|
||||
|
||||
### Fixed
|
||||
- **Subtitle Retiming**: Mining, the subtitle sidebar, subtitle navigation, and replay now use the timing you set with mpv's subtitle delay keys (z/x and sub-step), so mined audio and images match the retimed line.
|
||||
- **Subtitle Sidebar**: mpv keys and mouse actions, like volume keys and double-click fullscreen, work again while the sidebar is open.
|
||||
|
||||
### Docs
|
||||
- **README**: Refreshed the anime screenshots using the current build.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- **Packaging Layout**: Packaging inputs now live under `packaging/`, and all build output lands in `dist/`, with packaged apps in `dist/release/`.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.21.0 (2026-10-24)
|
||||
|
||||
### Added
|
||||
- **Hachidori Dictionary Backend**:
|
||||
- Hachidori is now bundled as an alternative to Yomitan. Select it with `dictionaryBackend`, then restart SubMiner.
|
||||
- It works with subtitle scanning, popups, frequency highlighting, character dictionaries, Anki mining, and stats dashboard mining.
|
||||
- First-run setup builds Hachidori's Anki template from your SubMiner settings. It can also link an external Hachidori app, browser, or Docker host.
|
||||
- The tray and dictionary-settings shortcut open settings for the active backend. Use `--hachidori` or `--yomitan` to open a specific one.
|
||||
- **Jellyfin Subtitle Generation**: You can now generate Japanese subtitles while playing a Jellyfin episode, with both direct play and transcoded playback.
|
||||
|
||||
### Fixed
|
||||
- **Jellyfin**:
|
||||
- `subminer jellyfin -p` now lists libraries and shows image previews when SubMiner is already running. Real errors are shown instead of an empty list.
|
||||
- Watch progress from terminal playback is reported to Jellyfin even with cast discovery off.
|
||||
- When `subminer jellyfin -p` starts SubMiner, the app shows its tray icon and quits when mpv closes.
|
||||
- Casting on macOS now finds mpv when SubMiner is opened from Finder, the Dock, or Spotlight.
|
||||
- **Halfwidth Katakana**: Halfwidth katakana in subtitles now displays as fullwidth, including in mined cards and older stats lines. Stats sentence search matches katakana in either width.
|
||||
- **Media Timing Review**: The review opens on the first attempt instead of after a delay. Playback stays paused while it is open.
|
||||
- **Subtitle Annotations**: Lines shown while two cues overlap now get annotations right away. Prefetching no longer restarts during startup.
|
||||
- **Keyboard-Only Mode**: `h`, `l`, and the arrow keys now work for typing in modal text fields instead of seeking mpv.
|
||||
- **Stats Dashboard Mining**: Cards mined from the stats dashboard keep the selected history line and media.
|
||||
- **Hyprland and Sway**: The app no longer freezes when `hyprctl` or `swaymsg` is slow or hangs.
|
||||
- **Subtitle Sidebar**: The open sidebar does much less background work during playback.
|
||||
- **macOS Launcher Status**: The command-line launcher no longer shows "Not on PATH" when SubMiner is opened from Finder or the Dock.
|
||||
- **Security Updates**: Updated Electron, undici, and follow-redirects to address security advisories.
|
||||
|
||||
### Docs
|
||||
- **Hachidori Setup**: Documented backend selection, Hachidori dictionary and Anki setup, settings shortcuts, and external host linking.
|
||||
- **Jellyfin Casting**: Explained when SubMiner joins the cast menu at startup, and how casting finds mpv on each platform. Added a troubleshooting entry for casts that never start mpv.
|
||||
- **Jimaku Key**: Removed environment variables that never had any effect. Set the key with `jimaku.apiKey` or `jimaku.apiKeyCommand`.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- **End-to-End Harness**: Added a headless e2e harness (`bun run test:e2e`, `bun run e2e`) with real mpv, a fixture dictionary, and a fake AnkiConnect.
|
||||
- **Hachidori Fork**: The bundled Hachidori integration lives in a pinned fork submodule.
|
||||
- **Dead Code Removal**:
|
||||
- Removed the unused launcher Jimaku client, the mpv plugin hover-highlight module, and the unused character dictionary config keys.
|
||||
- Removed 16 unused overlay IPC methods. SubMiner no longer forwards every ASS line to the overlay.
|
||||
- Typecheck now runs with `noUnusedLocals`.
|
||||
- **Setup Launcher Label**: First-run setup now labels a missing launcher "Not set up" instead of "Not installed".
|
||||
|
||||
</details>
|
||||
|
||||
## v0.20.1 (2026-10-03)
|
||||
|
||||
### Added
|
||||
- **YouTube Browser Window**:
|
||||
- Browse YouTube with your login kept across restarts. Open it with `subminer youtube`, `subminer yt`, or tray **Browse YouTube**.
|
||||
- Clicking a video plays it in mpv. Middle-click, Shift/Ctrl-click, or the right-click menu adds it to mpv's playlist.
|
||||
- Queued videos get the same Japanese subtitle setup, and the rest of the queue is kept on Windows.
|
||||
- Closing the window quits a SubMiner started by `subminer youtube`.
|
||||
- **Whisper Subtitles for YouTube**:
|
||||
- Set `youtube.subtitleSource` to `whisper` to transcribe YouTube videos with Whisper instead of using YouTube's captions.
|
||||
- The video stays paused while Whisper transcribes, and the subtitle generation modal shows progress.
|
||||
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including Windows stream playback.
|
||||
- **Scroll Wheel and Mouse Bindings**: `keybindings` now accepts scroll wheel keys, and the settings key editor can capture them. Mouse button and wheel bindings from mpv now work while the cursor is over the overlay, including on Hyprland.
|
||||
- **Run Commands from Session Help**: During playback, press `Enter` or double-click a row in session help to run that command.
|
||||
|
||||
### Changed
|
||||
- **Release Assets**: Releases no longer include package-size reports.
|
||||
|
||||
### Fixed
|
||||
- **Hyprland Overlay**: The overlay stays clickable after mpv goes fullscreen with `mpv.backend: x11`, and it no longer interrupts mpv while mpv enters fullscreen.
|
||||
- **mpv Key Bindings**: Bindings from mpv such as `9`/`0` for volume now work while the overlay has focus, even if the overlay loaded before mpv connected.
|
||||
- **Jellyfin**:
|
||||
- The Japanese subtitle track is selected as soon as it downloads, without waiting for the other tracks.
|
||||
- Japanese and English tracks are auto-selected on episodes whose embedded subtitles are images.
|
||||
- Timing review previews and mined card audio use the audio track you are playing.
|
||||
- **Media Timing Review**: Trim handles are now thin lines on the clip edges, and original subtitle boundaries no longer cover the waveform. The preview cursor no longer stutters on macOS with Bluetooth headphones.
|
||||
- **YouTube Subtitles**: Japanese subtitles no longer fail with "HTTP 429" on videos where YouTube also lists a machine-translated Japanese track.
|
||||
- **Stats**:
|
||||
- Mined cards are counted once in session and lifetime stats.
|
||||
- Word and kanji dates and the new-words charts stay accurate after deleting sessions. Existing data is repaired in the background.
|
||||
- Overview totals, charts, and the activity calendar refresh after deleting sessions.
|
||||
- **Yomitan Data Safety**: Electron is updated to 43.7.7. SubMiner now refuses to load your Yomitan data with an unsupported or downgraded runtime. Automatic character dictionary changes stop if Yomitan suddenly reports no dictionaries.
|
||||
- **AppImage in Sandboxes**: The AppImage now opens its window when launched in a sandbox such as `firejail --appimage`.
|
||||
- **Ctrl+C Crash**: SubMiner no longer crashes with "write EPIPE" when the terminal command that started it is stopped with Ctrl+C.
|
||||
|
||||
### Docs
|
||||
- **Docs Site**: The docs site is shorter and easier to scan, with a key and default table for each config block. Outdated pages were corrected, and the MeCab install command for Arch Linux is fixed.
|
||||
- **Stats Docs**: The docs now cover the Overview refresh after deleting sessions and how discovery dates are kept after old sessions are pruned.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- **Release Notes**: Release notes and `CHANGELOG.md` are now generated as short items per feature or area.
|
||||
- **Dev Profile**: Development launches use a separate `SubMiner-dev` profile unless production-profile access is requested.
|
||||
- **Dependencies**: undici is updated to 7.29.1 to clear the security advisories that were blocking CI.
|
||||
- **Package Checks**: CI no longer compares package sizes. Package content checks still run.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.20.0 (2026-09-23)
|
||||
|
||||
### Added
|
||||
- **Japanese Subtitle Generation**:
|
||||
- Generate Japanese SRT subtitles locally with whisper.cpp. Start it from a new modal (Ctrl+Shift+G), from the generate button in an empty subtitle sidebar, or with `subminer generate-subs`.
|
||||
- Generation shows progress, can be cancelled, and loads the finished subtitles into mpv automatically.
|
||||
- Pick an official multilingual Whisper model, including quantized variants, with size and accuracy guidance. You can download it in the app or point Settings at a model you already have.
|
||||
- `large-v3-turbo` is recommended when CUDA support is detected, and `small` otherwise.
|
||||
- whisper-cli, ffmpeg, and ffprobe are found on PATH unless you override them. SubMiner names any missing tools before a download starts.
|
||||
- An optional "Focus on spoken dialogue" mode uses a separately downloaded Silero VAD model. It keeps audible sections it is unsure about, so dialogue under music is not dropped, but songs may also be transcribed.
|
||||
- Long passages are split near speech starts or quiet pauses to reduce subtitles that appear too early. When an eligible embedded or external subtitle track is loaded in mpv, it guides the split points.
|
||||
- Each passage runs in a fresh Whisper process, which prevents repeated-character output.
|
||||
- **Subtitle Selection Modal**:
|
||||
- An optional modal for choosing primary and secondary mpv subtitle tracks.
|
||||
- Turn it on in Settings under Behavior, then press g followed by s to open it. Turning it off restores mpv's own subtitle selection binding.
|
||||
- Single-key actions take priority over configured key sequence prefixes.
|
||||
- Conflicting sequences are disabled with a warning, and the existing y commands stay reserved.
|
||||
- **Subtitle Sidebar Copy**:
|
||||
- Select dialogue across several sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button.
|
||||
- Selecting text does not seek playback and does not require mining a card.
|
||||
- **Media Timing Review Screenshot Picker**:
|
||||
- Choose the still screenshot separately from the audio range, with a live preview and its own time slider.
|
||||
- Step through decoded frames one at a time to get the exact frame you want.
|
||||
- Works with local video and with seekable remote streams such as Jellyfin.
|
||||
- **mpv Keybindings in the Overlay**:
|
||||
- The overlay now picks up keyboard bindings from mpv defaults, `input.conf`, and loaded scripts when they do not conflict with SubMiner.
|
||||
- SubMiner controls and bindings you explicitly disabled take precedence.
|
||||
- These bindings apply only to the current session and are not listed in the help menu.
|
||||
- **Jimaku Live Action Search**: The Jimaku modal has new Anime and Live action tabs, so you can search Jimaku's live action catalogue as well as anime. Use Arrow Left and Arrow Right to switch tabs.
|
||||
- **TMDB Live-Action Library**:
|
||||
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB.
|
||||
- Release builds include a project key. Setting `tmdb.apiKey` or `tmdb.apiKeyCommand` overrides it, and one of them is required when running from source.
|
||||
- Titles that AniList cannot match are looked up on TMDB automatically when the parsed filename exactly matches a Japanese live-action title. For everything else, use the new **Link to TMDB** action.
|
||||
- Entries linked to the same TMDB title merge into one card, and the Library kind selector has a new Live Action option.
|
||||
- If a replacement download fails during provider reassignment, the previous link and artwork are kept. Merges and sync keep AniList and TMDB identities separate, and the merge dialog explains mixed selections instead of failing.
|
||||
- **YouTube Library Kind**:
|
||||
- YouTube channels are now their own Library media kind. Existing channel entries migrate automatically, and viewing history and manual video assignments are unchanged.
|
||||
- New All Titles, Anime, and YouTube Library filters.
|
||||
- Channels are excluded from AniList matching, season repair, and duplicate recommendations.
|
||||
- Merges and video moves can no longer combine an anime entry with a YouTube channel.
|
||||
|
||||
### Changed
|
||||
- **Launcher Uses Bundled Bun**:
|
||||
- Every installed and downloadable launcher now runs on the Bun runtime that ships with SubMiner. A system Bun is no longer needed.
|
||||
- Recognized legacy launchers migrate automatically.
|
||||
- Windows gets a `subminer.cmd` launcher download.
|
||||
- First-run setup is reduced to one optional launcher control. Runtime repair guidance appears only when it is needed.
|
||||
- **Faster Sync Transfers**:
|
||||
- Sync between compatible macOS and Linux machines now uses compressed, incremental rsync transfers.
|
||||
- The last snapshot received from each peer is cached, which reduces traffic on later syncs.
|
||||
- Machines without a compatible rsync, including Windows, fall back to compressed scp.
|
||||
- Older peers still work without the upload cache.
|
||||
- Transfers abort after 30 minutes.
|
||||
- **Stats Server Request Safety**:
|
||||
- The stats server now accepts loopback hosts only and rejects requests from browser origins other than its own.
|
||||
- Requests that change data must send an `application/json` body. Scripts that POST to the server need to set a JSON content type.
|
||||
- The in-app stats overlay now loads from the local server, so it gets the same protection.
|
||||
- Dashboards served through a reverse proxy or Tailscale Serve are no longer supported.
|
||||
- **Smaller Downloads**:
|
||||
- Installers and unpacked apps are smaller. Demo media, source maps, TypeScript sources, test fixtures, and unused Koffi binaries are no longer packaged.
|
||||
- All windows now share one Japanese UI font.
|
||||
- Release builds publish package size reports that compare against the previous release.
|
||||
- **Bundled Yomitan**: Updated with upstream Yomitan 26.9.8 changes, including historical Japanese kana transformations, Ukrainian language support, and improvements to Anki duplicate searches and audio retrieval.
|
||||
|
||||
### Fixed
|
||||
- **Jellyfin 12 Compatibility**:
|
||||
- Playback, subtitle, artwork, and remote-control requests now authenticate with the `ApiKey` query parameter, so the integration works on Jellyfin 12, where legacy authorization is off by default.
|
||||
- "Play on SubMiner" stays available. The cast connection now answers keep-alive requests and reconnects when the server stops responding, instead of silently dying after about a minute.
|
||||
- The Jellyfin "now playing" bar clears when you close or finish a cast video instead of running on to the end of the episode.
|
||||
- Cards mined during Jellyfin playback get the episode title in the misc info field again instead of "Unknown media".
|
||||
- **Jellyfin Privacy and Playback**:
|
||||
- Jellyfin streams no longer leak titles taken from the stream URL, or stream URLs that contain credentials, into metadata lookups, Anki source fields, Discord presence, stats, or AniList retries.
|
||||
- Previously cached metadata that contained credentials is cleaned up. Watch history and library assignments are not touched.
|
||||
- Jellyfin playback and casting now use your configured mpv executable, so they work when mpv is installed outside PATH. Portable plugins next to that executable are detected.
|
||||
- **Anki Mining**:
|
||||
- New `ankiConnect.fields.wordAudio` setting reads word audio separately from the sentence audio field. This fixes animated images that started moving immediately when `fields.audio` pointed to `SentenceAudio`. Existing animated images need to be regenerated to pick up the fix.
|
||||
- Sentence furigana on word cards stays in sync with the full stats-search context and with expanded timing review selections. Stale readings are cleared if regeneration fails.
|
||||
- Closing the overlay while media timing review is still loading now cancels the review, resumes playback if the review paused it, and cleans up the hidden preview player.
|
||||
- `ankiConnect.media.maxMediaDuration: 0` now means unlimited when mining from the stats dashboard, matching overlay mining.
|
||||
- Invalid AnkiConnect, Kiku, and Senren settings are now rejected with a warning and fall back to defaults.
|
||||
- **Stats Server Stability**:
|
||||
- A port conflict is now reported in a status notification instead of crashing SubMiner.
|
||||
- Simultaneous startup requests share one server start. Stopping the background server no longer disconnects dashboards open in the foreground.
|
||||
- Shutdown waits only a limited time for active requests to finish.
|
||||
- Malformed resource IDs, and ID lists with any invalid entries, are rejected before Library changes or cover backfills run.
|
||||
- **Subtitle Sidebar**:
|
||||
- Clicking a cue no longer leaves the row focused, and Space no longer seeks back to a focused cue. Enter still seeks to the focused cue, and Space keeps its configured playback action.
|
||||
- The sidebar stays near the current playback position during gaps when the subtitle file has a cue that starts at zero.
|
||||
- **Settings Save Feedback**:
|
||||
- Settings marked LIVE no longer show false restart warnings, including for notifications and subtitle generation.
|
||||
- When a save mixes live and restart-only changes, the live changes apply right away and only the changed sections that need a restart are listed.
|
||||
- **Overlay Windows**:
|
||||
- On Hyprland, recovery dialogs stay above SubMiner windows so overlay placement updates no longer cover their Wait and Close buttons.
|
||||
- On Linux, a delayed close callback during teardown can no longer reopen the overlay.
|
||||
- **First Launch on macOS**: SubMiner no longer exits on first launch when the config directory does not exist yet.
|
||||
|
||||
### Docs
|
||||
- **Launcher**: Documented the launcher install that uses the bundled runtime, migration from legacy launchers, package-managed updates, and the bundled Bun runtime's MIT and LGPL notices. The AUR package installs these notices under `/usr/share/licenses/subminer-bin`, and they are also included in `subminer-assets.tar.gz`.
|
||||
- **Subtitle Generation**: Documented model choice, VAD behavior, splitting guided by a reference track, fallback behavior, and known limits.
|
||||
- **Subtitle Selection**: Documented the subtitle selector setting, its shortcut override, and the primary and secondary track controls.
|
||||
- **Settings**: Clarified save feedback for live settings, warnings for saves that mix live and restart-only changes, and how subtitle generation settings reload.
|
||||
- **Jellyfin**:
|
||||
- Clarified that Windows mpv playback and Jellyfin casting can use a configured executable path instead of PATH.
|
||||
- Documented how Jellyfin media titles and stats identities keep stream credentials out of metadata.
|
||||
- **Stats Library**:
|
||||
- Documented TMDB linking, provider reassignment, merge compatibility, and caching of the credential command's output.
|
||||
- Documented YouTube channel filtering and video statistics in the Library.
|
||||
- **Mining**:
|
||||
- Documented choosing the screenshot separately in media timing review.
|
||||
- Documented the separate word audio field mapping, including that existing animated images need to be regenerated.
|
||||
- **Sync**: Documented compressed transfers, where the incremental sync cache is stored, and compatibility with older peers.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- Removed duplicate source and launcher smoke runs from the reusable CI quality gate. Every distinct test lane and failure artifact is kept.
|
||||
- Replaced mislabeled dist source reruns with a small Electron-runtime smoke check. It covers compiled stats startup, the HTTP service, native SQLite, port conflicts, and cleanup.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.19.6 (2026-09-04)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -21,11 +21,10 @@ MACOS_DATA_DIR ?= $(HOME)/Library/Application Support/SubMiner
|
||||
MACOS_APP_DIR ?= $(HOME)/Applications
|
||||
MACOS_APP_DEST ?= $(MACOS_APP_DIR)/SubMiner.app
|
||||
|
||||
# If building from source, the AppImage will typically land in release/.
|
||||
APPIMAGE_SRC = $(firstword $(wildcard release/SubMiner-*.AppImage))
|
||||
MACOS_APP_SRC = $(firstword $(wildcard release/*.app release/*/*.app))
|
||||
MACOS_ZIP_SRC = $(firstword $(wildcard release/SubMiner-*.zip))
|
||||
PRERELEASE_NOTES := release/prerelease-notes.md
|
||||
# If building from source, the AppImage will typically land in dist/release/.
|
||||
APPIMAGE_SRC = $(firstword $(wildcard dist/release/SubMiner-*.AppImage))
|
||||
MACOS_APP_SRC = $(firstword $(wildcard dist/release/*.app dist/release/*/*.app))
|
||||
MACOS_ZIP_SRC = $(firstword $(wildcard dist/release/SubMiner-*.zip))
|
||||
|
||||
UNAME_S := $(shell uname -s 2>/dev/null || echo Unknown)
|
||||
ifeq ($(OS),Windows_NT)
|
||||
@@ -57,7 +56,7 @@ help:
|
||||
" build-linux Build Linux AppImage" \
|
||||
" build-macos Build macOS DMG/ZIP (signed if configured)" \
|
||||
" build-macos-unsigned Build macOS DMG/ZIP without signing/notarization" \
|
||||
" clean Remove build artifacts (dist/, release/, AppImage, binary)" \
|
||||
" clean Remove build artifacts (dist/, AppImage, binary)" \
|
||||
" dev-start Build and launch local Electron app" \
|
||||
" dev-start-macos Build and launch local Electron app with macOS tracker backend" \
|
||||
" dev-watch Start fast watch loop (tsc + renderer + Electron dev app)" \
|
||||
@@ -165,15 +164,7 @@ build-launcher:
|
||||
|
||||
clean:
|
||||
@printf '%s\n' "[INFO] Removing build artifacts"
|
||||
@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 -rf dist
|
||||
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
|
||||
|
||||
generate-config: ensure-bun
|
||||
@@ -229,7 +220,7 @@ install-linux: build-launcher
|
||||
@if [ -n "$(APPIMAGE_SRC)" ]; then \
|
||||
install -m 0755 "$(APPIMAGE_SRC)" "$(BINDIR)/SubMiner.AppImage"; \
|
||||
else \
|
||||
printf '%s\n' "[WARN] No release/SubMiner-*.AppImage found; skipping AppImage install"; \
|
||||
printf '%s\n' "[WARN] No dist/release/SubMiner-*.AppImage found; skipping AppImage install"; \
|
||||
printf '%s\n' " Build one with: make build"; \
|
||||
fi
|
||||
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)"
|
||||
@@ -252,7 +243,7 @@ install-macos: build-launcher
|
||||
ditto -x -k "$(MACOS_ZIP_SRC)" "$(MACOS_APP_DIR)"; \
|
||||
printf '%s\n' "[INFO] Installed app bundle from $(MACOS_ZIP_SRC)"; \
|
||||
else \
|
||||
printf '%s\n' "[WARN] No macOS app bundle or zip found in release/; skipping app install"; \
|
||||
printf '%s\n' "[WARN] No macOS app bundle or zip found in dist/release/; skipping app install"; \
|
||||
printf '%s\n' " Build one with: make build"; \
|
||||
fi
|
||||
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(MACOS_DATA_DIR)/themes/$(THEME_FILE)" " $(MACOS_APP_DEST)"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
# SubMiner
|
||||
|
||||
Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersion without leaving the player
|
||||
Look up words with Yomitan or Hachidori, mine them to Anki, and track your immersion without leaving mpv
|
||||
|
||||
[Installation](#quick-start) · [Requirements](#requirements) · [Usage](https://docs.subminer.moe/usage) · [Documentation](https://docs.subminer.moe)
|
||||
|
||||
@@ -23,7 +23,9 @@ Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersio
|
||||
|
||||
### Dictionary Lookups
|
||||
|
||||
Hover over any word and trigger a lookup to get the full Yomitan popup - definitions, pitch accent, and frequency data - without ever leaving mpv.
|
||||
Hover over any word in the subtitles to open the full Yomitan popup with definitions, pitch accent, and frequency data. SubMiner bundles its own Yomitan, separate from any browser install.
|
||||
|
||||
Yomitan remains the default. Select the bundled [Hachidori](https://github.com/bee-san/hachidori) backend with `dictionaryBackend: "hachidori"` and restart SubMiner. The tray opens the selected backend's settings. See [dictionary setup](https://docs.subminer.moe/usage#hachidori-setup) for importing dictionaries, linking an external Hachidori host, and configuring Anki.
|
||||
|
||||
<div align="center">
|
||||
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv">
|
||||
@@ -33,27 +35,27 @@ Hover over any word and trigger a lookup to get the full Yomitan popup - definit
|
||||
|
||||
### Instant Anki Mining
|
||||
|
||||
Create an Anki card with the sentence, audio clip, screenshot, and machine translation from the exact playback moment with one key press, click, or controller input.
|
||||
Create an Anki card from the exact playback moment with one key press, click, or controller input. SubMiner fills in the sentence, an audio clip, and a screenshot or animated image.
|
||||
|
||||
<div align="center">
|
||||
<img src="docs-site/public/screenshots/one-key-mining.png" width="800" alt="Anki card created from SubMiner with sentence, audio, and screenshot">
|
||||
<img src="docs-site/public/screenshots/one-key-mining.png" width="800" alt="Yomitan lookup over an anime scene with Anki mining controls">
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
### Reading Annotations
|
||||
|
||||
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.
|
||||
Subtitles are annotated as they play with frequency highlighting, JLPT tags, N+1 targeting, and character names from a generated dictionary. Particles and grammar-only tokens stay plain so the words worth learning stand out.
|
||||
|
||||
<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 colors, character portraits, and JLPT underlines">
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
### Immersion Dashboard
|
||||
|
||||
Local stats dashboard tracking watch time, vocabulary growth, mining throughput, session history, and trends. All stored locally, no third-party tracking.
|
||||
A stats dashboard tracks watch time, vocabulary growth, mining throughput, session history, and trends. Everything stays on your machine, with no third-party tracking.
|
||||
|
||||
<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">
|
||||
@@ -61,22 +63,12 @@ Local stats dashboard tracking watch time, vocabulary growth, mining throughput,
|
||||
|
||||
<br>
|
||||
|
||||
### Playlist Browser
|
||||
|
||||
Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback.
|
||||
|
||||
<div align="center">
|
||||
<img src="docs-site/public/screenshots/playlist-browser.png" width="800" alt="Playlist browser modal showing sibling episode files beside the active mpv queue">
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
### Integrations
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><b>YouTube</b></td>
|
||||
<td>Auto-loaded yt-dlp subtitle tracks at startup with config-driven primary/secondary language priorities and a manual overlay picker on demand (<code>Ctrl+Alt+C</code>)</td>
|
||||
<td>Play YouTube URLs with subtitle tracks picked by your language priorities, or choose tracks yourself in the overlay picker (<code>Ctrl+Alt+C</code>). Requires <code>yt-dlp</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>AniList</b></td>
|
||||
@@ -84,19 +76,19 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Jellyfin</b></td>
|
||||
<td>Browse, launch, and cast media from your Jellyfin server with setup and discovery controls in the app tray</td>
|
||||
<td>Browse your Jellyfin library, or cast to SubMiner from any Jellyfin client. Setup and discovery live in the tray menu</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Jimaku</b></td>
|
||||
<td>Search and download Japanese subtitles</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Local Subtitle Generation</b></td>
|
||||
<td>Generate Japanese subtitles from local audio in a standalone modal (<code>Ctrl+Shift+G</code>), the sidebar button, or launcher, with progress and optional managed model downloads. Requires whisper.cpp and FFmpeg. Optional Silero speech detection prioritizes dialogue in separately timed passages. <a href="https://docs.subminer.moe/main/subtitle-generation">Setup guide</a></td>
|
||||
<td>Search and download Japanese subtitles. Requires a free Jimaku API key</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>TsukiHime</b></td>
|
||||
<td>Search and download subtitles extracted from anime releases, with Japanese and secondary-language tabs (<code>Ctrl+Shift+T</code>) — no API key, requires <code>xz</code> on your <code>PATH</code></td>
|
||||
<td>Search and download subtitles extracted from anime releases, with Japanese and secondary-language tabs (<code>Ctrl+Shift+T</code>). Requires <code>xz</code> on your <code>PATH</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Subtitle generation</b></td>
|
||||
<td>Transcribe a video's audio into Japanese subtitles locally from the generation modal (<code>Ctrl+Shift+G</code>), the subtitle sidebar, or the launcher. SubMiner can download models for you; optional Silero speech detection helps focus on dialogue. Requires whisper.cpp and FFmpeg. <a href="https://docs.subminer.moe/subtitle-generation">Setup guide</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>AniSkip</b></td>
|
||||
@@ -104,37 +96,41 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>alass / ffsubsync</b></td>
|
||||
<td>Manual subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td>
|
||||
<td>Retime a subtitle against the audio or another subtitle track (<code>Ctrl+Alt+S</code>). Requires <code>alass</code> or <code>ffsubsync</code>; set <code>subsync.alass_path</code> or <code>subsync.ffsubsync_path</code> if they are not in <code>/usr/bin</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>WebSocket</b></td>
|
||||
<td>Plain subtitle feed plus a dedicated annotated feed for texthooker pages and custom tools</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><b>Plugins</b></td>
|
||||
<td>Load your own JavaScript at startup to react to playback, run timers, add key-bound actions, and show results in an overlay panel. The bundled example explains the current line with Codex. <a href="https://docs.subminer.moe/plugins">Plugin guide</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div align="center">
|
||||
<img src="docs-site/public/screenshots/texthooker.png" width="800" alt="Texthooker page receiving annotated subtitle lines via WebSocket">
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
Only **mpv** is required to run SubMiner. Anki + AnkiConnect are required to mine cards, which is the point of the app, but everything else is optional.
|
||||
SubMiner runs on Linux, macOS 11+, and Windows 10+. Only **mpv** is required to run it (plus `fuse2` for the Linux AppImage). Mining cards also needs Anki with the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on. Everything else is optional.
|
||||
|
||||
| Dependency | Status | What it does |
|
||||
| -------------------- | ---------------- | -------------------------------------------------------- |
|
||||
| mpv | Required | The video player SubMiner overlays on |
|
||||
| Anki + AnkiConnect | Required to mine | Card creation from the Yomitan popup |
|
||||
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
|
||||
| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
|
||||
| yt-dlp | Optional | YouTube playback |
|
||||
| xz | Optional | TsukiHime subtitle downloads (not on Windows by default) |
|
||||
| alass / ffsubsync | Optional | Subtitle sync |
|
||||
| guessit | Optional | Better anime title and episode detection |
|
||||
| fzf / rofi | Optional | Video picker in the `subminer` launcher (Linux/macOS) |
|
||||
| Dependency | Status | What it does |
|
||||
| ------------------------ | ---------------- | -------------------------------------------------------------------- |
|
||||
| mpv | Required | The video player SubMiner draws over |
|
||||
| fuse2 | Required (Linux) | Running the AppImage |
|
||||
| Anki + AnkiConnect | Required to mine | Card creation from the Yomitan popup |
|
||||
| ffmpeg | Recommended | Audio clips and screenshots on cards |
|
||||
| MeCab + mecab-ipadic | Recommended | More accurate N+1, JLPT, and frequency highlighting |
|
||||
| xdotool + xwininfo | Required (X11) | Window tracking on desktops other than Hyprland or Sway |
|
||||
| yt-dlp | Optional | YouTube playback |
|
||||
| xz | Optional | TsukiHime subtitle downloads (most Linux distros already have it) |
|
||||
| alass / ffsubsync | Optional | Subtitle sync |
|
||||
| whisper.cpp | Optional | [Subtitle generation](https://docs.subminer.moe/subtitle-generation) |
|
||||
| guessit | Optional | Better title, season, and episode detection for AniSkip and AniList |
|
||||
| fzf / rofi | Optional | Video picker in the `subminer` launcher (rofi is Linux only) |
|
||||
| chafa, ffmpegthumbnailer | Optional | Thumbnail previews in the launcher pickers |
|
||||
|
||||
<details>
|
||||
<summary><b>Platform-specific install commands</b></summary>
|
||||
@@ -142,9 +138,12 @@ Only **mpv** is required to run SubMiner. Anki + AnkiConnect are required to min
|
||||
**Arch Linux:**
|
||||
|
||||
```bash
|
||||
sudo pacman -S --needed mpv ffmpeg mecab mecab-ipadic
|
||||
sudo pacman -S --needed mpv ffmpeg
|
||||
paru -S --needed mecab-git mecab-ipadic # MeCab is only in the AUR
|
||||
```
|
||||
|
||||
On desktops other than Hyprland or Sway, also install `xdotool` and `xorg-xwininfo`.
|
||||
|
||||
**macOS:**
|
||||
|
||||
```bash
|
||||
@@ -158,16 +157,16 @@ winget install shinchiro.mpv
|
||||
winget install Gyan.FFmpeg
|
||||
```
|
||||
|
||||
Then reopen your terminal and check `mpv --version` and `ffmpeg -version`. winget puts `ffmpeg` on `PATH` automatically; mpv uses a regular installer that may not, so if `mpv` is not found, either add its folder (usually `%LOCALAPPDATA%\Programs\mpv`) to `PATH` or set `mpv.executablePath` during first-run setup.
|
||||
Then reopen your terminal and check `mpv --version` and `ffmpeg -version`. ffmpeg must be on `PATH`; mpv does not have to be. If `mpv` is not found, either add its folder (usually `%LOCALAPPDATA%\Programs\mpv`) to `PATH` or enter the full path to `mpv.exe` during first-run setup.
|
||||
|
||||
[Scoop](https://scoop.sh) is the alternative if you want one package manager for everything, since it is the only one that also carries `xz`:
|
||||
[Scoop](https://scoop.sh) is the alternative if you want one package manager for everything. It is the only one that also carries `xz`:
|
||||
|
||||
```powershell
|
||||
scoop bucket add extras
|
||||
scoop install extras/mpv main/ffmpeg main/yt-dlp main/xz
|
||||
```
|
||||
|
||||
See the [full requirements list](https://docs.subminer.moe/installation#_1-install-requirements) for optional dependencies.
|
||||
See the [installation guide](https://docs.subminer.moe/installation#_1-install-requirements) for Ubuntu, Debian, and Fedora commands and the full optional package lists.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -184,6 +183,8 @@ See the [full requirements list](https://docs.subminer.moe/installation#_1-insta
|
||||
paru -S subminer-bin
|
||||
```
|
||||
|
||||
Includes the AppImage and the `subminer` command.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -195,14 +196,7 @@ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppI
|
||||
&& chmod +x ~/.local/bin/SubMiner.AppImage
|
||||
```
|
||||
|
||||
The AppImage is all you need. First-run setup can install the optional `subminer` command-line launcher. Every current launcher uses Bun included with the app, so you do not need Bun installed or on `PATH`.
|
||||
|
||||
You can also download the launcher wrapper directly:
|
||||
|
||||
```bash
|
||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
|
||||
&& chmod +x ~/.local/bin/subminer
|
||||
```
|
||||
The AppImage is all you need. First-run setup can install the optional `subminer` command, which runs on a copy of Bun bundled with the app, so you do not need Bun installed.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -211,14 +205,14 @@ wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~
|
||||
|
||||
Download the latest DMG from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest) and drag `SubMiner.app` into `/Applications`.
|
||||
|
||||
Then enable SubMiner under **System Settings > Privacy & Security > Accessibility** (named **Device Control and Data Access** on macOS 27) so the overlay can track the mpv window. Without it, SubMiner falls back to a basic window lookup that cannot tell when mpv is minimized. If macOS blocks the first launch, right-click the app and choose **Open**.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Windows</b></summary>
|
||||
|
||||
Download and run the latest installer (`.exe`) from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest).
|
||||
|
||||
For terminal use, download `subminer.cmd`. It locates the installed app and uses its private Bun runtime.
|
||||
Download and run the latest installer (`SubMiner-<version>.exe`) from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). A portable `.zip` is also available.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -231,28 +225,30 @@ See the [build-from-source guide](https://docs.subminer.moe/installation#from-so
|
||||
|
||||
### 2. Launch & Set Up
|
||||
|
||||
Run the installed app and the first-run setup wizard will guide you through importing Yomitan dictionaries and optionally installing the `subminer` command-line launcher. Setup records a custom app location when needed, and the wrapper runs with the app's private Bun runtime.
|
||||
Start SubMiner and the setup window opens on first launch. It creates your config file, imports Yomitan dictionaries (you need at least one for lookups), and can install the `subminer` command. On Windows it also creates a **SubMiner mpv** shortcut.
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
~/.local/bin/SubMiner.AppImage --setup
|
||||
|
||||
# macOS
|
||||
open -a SubMiner --args --setup
|
||||
subminer app --setup # AUR
|
||||
~/.local/bin/SubMiner.AppImage --setup # AppImage
|
||||
```
|
||||
|
||||
On **Windows**, just run `SubMiner.exe` and the setup will open automatically on first launch.
|
||||
On **macOS**, open `SubMiner.app` from `/Applications`. On **Windows**, run SubMiner from the Start menu. To reopen setup later, run `subminer app --setup`.
|
||||
|
||||
For card creation, keep Anki open with AnkiConnect installed. SubMiner connects to it at its default address with no extra setup.
|
||||
|
||||
### 3. Mine
|
||||
|
||||
```bash
|
||||
subminer video.mkv # launch mpv with SubMiner
|
||||
subminer video.mkv # play a video with SubMiner
|
||||
subminer /path/to/dir # pick a file with fzf
|
||||
subminer -R /path/to/dir # pick a file with rofi (Linux only)
|
||||
subminer -H # browse history, then previous / replay / next / select / quit
|
||||
subminer -H # watch history: replay, next, or previous episode
|
||||
subminer doctor # check your setup
|
||||
```
|
||||
|
||||
On **Windows**, use the **SubMiner mpv** shortcut created during setup. Double-click it or drag a video file onto it.
|
||||
On **Windows**, double-click the **SubMiner mpv** shortcut or drag a video file onto it.
|
||||
|
||||
Starting mpv some other way? See [Launching mpv yourself](https://docs.subminer.moe/installation#launching-mpv-yourself) for the IPC socket option the overlay needs.
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -275,7 +271,8 @@ SubMiner builds on the work of these open-source projects:
|
||||
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
|
||||
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
|
||||
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
|
||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
|
||||
| [Yomitan](https://github.com/yomidevs/yomitan) | Default dictionary engine and morphological parser |
|
||||
| [Hachidori](https://github.com/bee-san/hachidori) | Alternative dictionary backend, powered by HoshiDicts |
|
||||
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
|
||||
|
||||
## License
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// Sends the current subtitle line, with a few lines of context, to `codex exec` and shows the
|
||||
// answer in the SubMiner plugin panel. Registers two actions: `explain` and `translate`.
|
||||
const { mkdtemp, readFile, rm } = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
codexPath: 'codex',
|
||||
model: '',
|
||||
// Passed as -c model_reasoning_effort; low keeps answers to a few seconds. Empty uses your Codex config.
|
||||
reasoningEffort: 'low',
|
||||
before: 2,
|
||||
after: 1,
|
||||
timeoutMs: 120_000,
|
||||
pause: true,
|
||||
prompts: {},
|
||||
};
|
||||
|
||||
const ACTIONS = {
|
||||
explain: {
|
||||
title: 'Grammar',
|
||||
prompt:
|
||||
'You are a Japanese tutor. Explain the line marked >>> for an intermediate learner. ' +
|
||||
'Use the other lines only as context. Reply in English markdown: a natural translation, ' +
|
||||
'then a bullet list of the grammar points and notable vocabulary. Be concise.',
|
||||
},
|
||||
translate: {
|
||||
title: 'Translation',
|
||||
prompt:
|
||||
'Translate the line marked >>> into natural English. Use the other lines only as context. ' +
|
||||
'Reply with the translation, then one short note only if the line is ambiguous.',
|
||||
},
|
||||
};
|
||||
|
||||
function activate(ctx) {
|
||||
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
|
||||
for (const [name, action] of Object.entries(ACTIONS)) {
|
||||
const prompt = settings.prompts?.[name] ?? action.prompt;
|
||||
ctx.actions.register(name, () => ask(ctx, settings, action.title, prompt));
|
||||
}
|
||||
}
|
||||
|
||||
function quote(text) {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => `> ${line}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// Subtitles wrap across lines; flatten each cue so the >>> marker covers all of it.
|
||||
function oneLine(text) {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function tail(text, lineCount) {
|
||||
return text.trim().split('\n').slice(-lineCount).join('\n');
|
||||
}
|
||||
|
||||
async function ask(ctx, settings, title, instructions) {
|
||||
const { before, current, after } = ctx.subtitles.context({
|
||||
before: settings.before,
|
||||
after: settings.after,
|
||||
});
|
||||
if (!current) {
|
||||
ctx.notify('No subtitle line to send.', { variant: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (settings.pause) ctx.mpv.command(['set', 'pause', 'yes']);
|
||||
|
||||
const header = quote(current.text);
|
||||
const panel = ctx.ui.panel({ title, markdown: `${header}\n\n_Asking Codex..._` });
|
||||
const prompt = [
|
||||
instructions,
|
||||
'',
|
||||
'Subtitles:',
|
||||
...before.map((line) => oneLine(line.text)),
|
||||
`>>> ${oneLine(current.text)}`,
|
||||
...after.map((line) => oneLine(line.text)),
|
||||
].join('\n');
|
||||
|
||||
// Codex runs in an empty read-only directory so any command it tries cannot touch real files.
|
||||
const workDir = await mkdtemp(path.join(os.tmpdir(), 'subminer-ai-explain-'));
|
||||
const answerPath = path.join(workDir, 'answer.md');
|
||||
const args = [
|
||||
'exec',
|
||||
'--ephemeral',
|
||||
'--skip-git-repo-check',
|
||||
'--sandbox',
|
||||
'read-only',
|
||||
'--color',
|
||||
'never',
|
||||
'-C',
|
||||
workDir,
|
||||
'-o',
|
||||
answerPath,
|
||||
];
|
||||
if (settings.model) args.push('-m', settings.model);
|
||||
if (settings.reasoningEffort) {
|
||||
args.push('-c', `model_reasoning_effort=${JSON.stringify(settings.reasoningEffort)}`);
|
||||
}
|
||||
args.push('-');
|
||||
|
||||
try {
|
||||
const result = await ctx.exec(settings.codexPath, args, {
|
||||
stdin: prompt,
|
||||
timeoutMs: settings.timeoutMs,
|
||||
signal: panel.signal,
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(tail(result.stderr, 6) || `codex exited with code ${result.exitCode}`);
|
||||
}
|
||||
const answer = (await readFile(answerPath, 'utf8')).trim();
|
||||
panel.update({ markdown: `${header}\n\n${answer || '_Codex returned an empty answer._'}` });
|
||||
} catch (error) {
|
||||
// Closing the panel aborts the request; there is nobody left to show the error to.
|
||||
if (panel.signal.aborted) return;
|
||||
const message =
|
||||
error?.code === 'ENOENT'
|
||||
? `Cannot find \`${settings.codexPath}\`. Set \`plugins.ai-explain.settings.codexPath\` to its full path.`
|
||||
: String(error?.message ?? error);
|
||||
ctx.log.warn(`Codex request failed: ${message}`);
|
||||
panel.update({
|
||||
markdown: `${header}\n\n**Codex request failed.**\n\n\`\`\`\n${message}\n\`\`\``,
|
||||
});
|
||||
} finally {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { activate };
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "AI Explain",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 1,
|
||||
"main": "main.js"
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Runs AnkiConnect's `sync` (AnkiWeb) after you mine cards. A burst of mining becomes one sync:
|
||||
// it waits until no card has been mined for `delayMs`, and syncs right away when playback ends.
|
||||
// Also registers a `sync` action for a manual sync.
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
delayMs: 30_000,
|
||||
syncOnEnd: true,
|
||||
timeoutMs: 60_000,
|
||||
notify: true,
|
||||
};
|
||||
|
||||
const END_REASONS = new Set(['eof', 'stop', 'quit']);
|
||||
|
||||
function activate(ctx) {
|
||||
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
|
||||
// Cards mined since the last sync started.
|
||||
let dirty = false;
|
||||
let syncing = false;
|
||||
let cancelTimer = null;
|
||||
|
||||
function schedule() {
|
||||
cancelTimer?.();
|
||||
cancelTimer = ctx.after(settings.delayMs, flush);
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
cancelTimer?.();
|
||||
cancelTimer = null;
|
||||
// A sync already running picks up new cards when it finishes.
|
||||
if (!dirty || syncing) return;
|
||||
dirty = false;
|
||||
syncing = true;
|
||||
try {
|
||||
await ctx.anki.invoke('sync', {}, { timeoutMs: settings.timeoutMs });
|
||||
ctx.log.info('Synced Anki.');
|
||||
if (settings.notify) ctx.notify('Anki synced');
|
||||
} catch (error) {
|
||||
ctx.log.warn(`Anki sync failed: ${error.message}`);
|
||||
ctx.notify(`Anki sync failed: ${error.message}`, { variant: 'error' });
|
||||
} finally {
|
||||
syncing = false;
|
||||
if (dirty) schedule();
|
||||
}
|
||||
}
|
||||
|
||||
ctx.anki.on('card-mined', () => {
|
||||
dirty = true;
|
||||
schedule();
|
||||
});
|
||||
|
||||
ctx.mpv.on('end-file', ({ reason }) => {
|
||||
if (settings.syncOnEnd && END_REASONS.has(reason)) return flush();
|
||||
});
|
||||
|
||||
ctx.actions.register('sync', () => {
|
||||
dirty = true;
|
||||
return flush();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { activate };
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Anki Sync",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 1,
|
||||
"main": "main.js"
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Opens the Jimaku subtitle search when a file starts without a Japanese subtitle track.
|
||||
// It waits a few seconds after the file loads so external subtitles mpv or SubMiner add count.
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
// Built-in action to run, e.g. `openTsukihime` or `openSubtitleSelection`.
|
||||
action: 'openJimaku',
|
||||
// Track `lang` codes that count as Japanese. Track titles with "Japanese" or 日本語 count too.
|
||||
languages: ['ja', 'jp', 'jpn'],
|
||||
delayMs: 3_000,
|
||||
// Also check http(s) streams; Jimaku can only search by name, which streams rarely have.
|
||||
streams: false,
|
||||
};
|
||||
|
||||
const JAPANESE_TITLE = /japanese|日本語/i;
|
||||
|
||||
function activate(ctx) {
|
||||
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
|
||||
const languages = new Set(settings.languages.map((lang) => lang.toLowerCase()));
|
||||
let cancelCheck = null;
|
||||
let playbackId = null;
|
||||
|
||||
function hasJapaneseSubtitle(trackList) {
|
||||
return trackList.some(
|
||||
(track) =>
|
||||
track?.type === 'sub' &&
|
||||
(languages.has(String(track.lang ?? '').toLowerCase()) ||
|
||||
JAPANESE_TITLE.test(String(track.title ?? ''))),
|
||||
);
|
||||
}
|
||||
|
||||
async function check(media) {
|
||||
cancelCheck = null;
|
||||
const trackList = await ctx.mpv.getProperty('track-list');
|
||||
if (ctx.media.current()?.playbackId !== media.playbackId || !Array.isArray(trackList)) return;
|
||||
if (hasJapaneseSubtitle(trackList)) return;
|
||||
ctx.log.info(`No Japanese subtitles for ${media.path}; running ${settings.action}.`);
|
||||
await ctx.actions.run(settings.action);
|
||||
}
|
||||
|
||||
ctx.media.on('change', (media) => {
|
||||
if (media?.playbackId === playbackId) return;
|
||||
playbackId = media?.playbackId ?? null;
|
||||
cancelCheck?.();
|
||||
cancelCheck = null;
|
||||
if (!media) return;
|
||||
if (!settings.streams && /^[a-z]+:\/\//i.test(media.path)) return;
|
||||
cancelCheck = ctx.after(settings.delayMs, () => check(media));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { activate };
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Auto Jimaku",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 1,
|
||||
"main": "main.js"
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Tags each mined card with the show and episode it came from, e.g. `show::Frieren::S01E07`.
|
||||
// The show and episode are parsed from the file name, or from mpv's media title for streams.
|
||||
const path = require('node:path');
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
// Placeholders: {show}, {season}, {episode}, and {ep} (S01E07, or E07 without a season).
|
||||
// A tag stops before the first `::` level whose placeholder has no value.
|
||||
tags: ['show::{show}::{ep}'],
|
||||
notify: false,
|
||||
};
|
||||
|
||||
// Tried in order; the first match wins. Runs on names with brackets and dots already removed.
|
||||
const EPISODE_PATTERNS = [
|
||||
/^(?<show>.*?)\s*S(?<season>\d{1,2})\s?E(?<episode>\d{1,4})\b/i,
|
||||
/^(?<show>.*?)\s*(?:Season\s?(?<season>\d{1,2})\s*)?(?:Episode|Ep)\s?(?<episode>\d{1,4})\b/i,
|
||||
/^(?<show>.*?)\s+-\s+(?<episode>\d{1,4})(?:v\d)?\b/,
|
||||
/^(?<show>.*?)\s+(?<episode>\d{1,4})(?:v\d)?$/,
|
||||
/^(?<show>.*?)\s*第(?<episode>\d{1,4})話/,
|
||||
];
|
||||
|
||||
// `[Group] Show Name - 07 (1080p).mkv` -> { show: 'Show Name', season: null, episode: '07' }
|
||||
function parseMediaName(name) {
|
||||
const cleaned = name
|
||||
.replace(/\.[a-z0-9]{2,4}$/i, '')
|
||||
.replace(/\[[^\]]*\]|\([^)]*\)|【[^】]*】/g, ' ')
|
||||
.replace(/[._]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
for (const pattern of EPISODE_PATTERNS) {
|
||||
const groups = cleaned.match(pattern)?.groups;
|
||||
const show = groups?.show.replace(/[\s-]+$/, '');
|
||||
if (!show) continue;
|
||||
return { show, season: pad(groups.season), episode: pad(groups.episode) };
|
||||
}
|
||||
return { show: cleaned || null, season: null, episode: null };
|
||||
}
|
||||
|
||||
function pad(number) {
|
||||
return number ? String(Number(number)).padStart(2, '0') : null;
|
||||
}
|
||||
|
||||
// Anki tags can't hold spaces, and a `::` in a value would add a hierarchy level.
|
||||
function tagValue(value) {
|
||||
return value.replace(/::?/g, ' ').trim().replace(/\s+/g, '_').replace(/"/g, '');
|
||||
}
|
||||
|
||||
function renderTag(template, values) {
|
||||
const levels = [];
|
||||
for (const level of template.split('::')) {
|
||||
let missing = false;
|
||||
const rendered = level.replace(/\{(\w+)\}/g, (_, key) => {
|
||||
const value = values[key];
|
||||
if (!value) missing = true;
|
||||
return value ? tagValue(value) : '';
|
||||
});
|
||||
if (missing) break;
|
||||
levels.push(rendered);
|
||||
}
|
||||
return levels.join('::');
|
||||
}
|
||||
|
||||
function mediaName(mediaPath, title) {
|
||||
// A stream URL's last segment is usually an id, so streams use the title mpv shows.
|
||||
if (typeof mediaPath === 'string' && mediaPath && !/^[a-z]+:\/\//i.test(mediaPath)) {
|
||||
return path.basename(mediaPath);
|
||||
}
|
||||
return typeof title === 'string' && title ? title : null;
|
||||
}
|
||||
|
||||
function renderTags(templates, name) {
|
||||
const { show, season, episode } = parseMediaName(name);
|
||||
const ep = episode ? `${season ? `S${season}` : ''}E${episode}` : null;
|
||||
const values = { show, season, episode, ep };
|
||||
return [...new Set(templates.map((template) => renderTag(template, values)))].filter(Boolean);
|
||||
}
|
||||
|
||||
function activate(ctx) {
|
||||
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
|
||||
ctx.anki.on('card-mined', async ({ notes }) => {
|
||||
// Notes from the same file share one addTags call.
|
||||
const notesByName = new Map();
|
||||
for (const { noteId, provenance } of notes) {
|
||||
const name = mediaName(provenance?.media?.path, provenance?.media?.title);
|
||||
if (!name) {
|
||||
ctx.log.debug(`Note ${noteId} has no known media source; not tagging it.`);
|
||||
continue;
|
||||
}
|
||||
if (!notesByName.has(name)) notesByName.set(name, new Set());
|
||||
notesByName.get(name).add(noteId);
|
||||
}
|
||||
|
||||
for (const [name, notes] of notesByName) {
|
||||
const tags = renderTags(settings.tags, name);
|
||||
if (tags.length === 0) {
|
||||
ctx.log.debug(`No tags for "${name}".`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await ctx.anki.invoke('addTags', { notes: [...notes], tags: tags.join(' ') });
|
||||
ctx.log.info(`Tagged ${notes.size} note(s): ${tags.join(' ')}`);
|
||||
if (settings.notify) ctx.notify(`Tagged ${tags.join(' ')}`);
|
||||
} catch (error) {
|
||||
ctx.log.warn(`Tagging failed: ${error.message}`);
|
||||
ctx.notify(`Episode tagging failed: ${error.message}`, { variant: 'error' });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { activate };
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Episode Tags",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 1,
|
||||
"main": "main.js"
|
||||
}
|
||||
@@ -5,10 +5,8 @@
|
||||
"": {
|
||||
"name": "subminer",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/geist-mono": "^5.2.7",
|
||||
"@xhayper/discord-rpc": "^1.3.4",
|
||||
"axios": "^1.18.1",
|
||||
"axios": "^1.20.0",
|
||||
"commander": "^14.0.3",
|
||||
"electron-updater": "^6.8.9",
|
||||
"hono": "^4.12.28",
|
||||
@@ -21,13 +19,12 @@
|
||||
"@electron/asar": "3.4.1",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"electron": "42.6.0",
|
||||
"electron": "43.8.3",
|
||||
"electron-builder": "26.16.1",
|
||||
"esbuild": "^0.25.12",
|
||||
"eslint": "^10.8.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "7.29.0",
|
||||
"undici": "7.30.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -37,9 +34,10 @@
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "0.8.15",
|
||||
"app-builder-lib": "26.16.1",
|
||||
"brace-expansion": "5.0.9",
|
||||
"brace-expansion": "5.0.11",
|
||||
"electron-builder-squirrel-windows": "26.16.1",
|
||||
"fast-uri": "3.1.6",
|
||||
"fast-uri": "3.1.7",
|
||||
"follow-redirects": "1.16.2",
|
||||
"form-data": "4.0.6",
|
||||
"ip-address": "10.2.0",
|
||||
"js-yaml": "4.3.2",
|
||||
@@ -48,7 +46,7 @@
|
||||
"picomatch": "4.0.4",
|
||||
"tar": "7.5.21",
|
||||
"tmp": "0.2.7",
|
||||
"undici": "7.29.0",
|
||||
"undici": "7.30.1",
|
||||
},
|
||||
"packages": {
|
||||
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
@@ -127,34 +125,6 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
|
||||
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
|
||||
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="],
|
||||
|
||||
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
|
||||
|
||||
"@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="],
|
||||
|
||||
"@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.2.7", "", {}, "sha512-ZKlZ5sjtalb2TwXKs400mAGDlt/+2ENLNySPx0wTz3bP3mWARCsUW+rpxzZc7e05d2qGch70pItt3K4qttbIYA=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
|
||||
|
||||
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
|
||||
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||
|
||||
"@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.5.28", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Lc/b8JXO2W2+H+5UXfw7PCHZCim1jlrB0CmLPsjfVmihMluBpdYafFImhjAHxHlWGfuZ32WzjVPUap5fGmkthw=="],
|
||||
@@ -203,16 +173,10 @@
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
|
||||
|
||||
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/fs-extra": ["@types/fs-extra@9.0.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA=="],
|
||||
|
||||
"@types/http-cache-semantics": ["@types/http-cache-semantics@4.2.0", "", {}, "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/keyv": ["@types/keyv@3.1.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
@@ -231,13 +195,9 @@
|
||||
|
||||
"abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
|
||||
|
||||
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
|
||||
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
@@ -259,7 +219,7 @@
|
||||
|
||||
"aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="],
|
||||
|
||||
"axios": ["axios@1.18.1", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g=="],
|
||||
"axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
@@ -269,7 +229,7 @@
|
||||
|
||||
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
|
||||
"brace-expansion": ["brace-expansion@5.0.11", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-awigjhi6cLTh90bdw6+QJ9CtmJmyYhEIi70iCbc8Rozn04Fw9FeQIBjv/E22FFGuCGx1bLJyUfB64x/szUSXUg=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
@@ -317,8 +277,6 @@
|
||||
|
||||
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"defer-to-connect": ["defer-to-connect@2.0.1", "", {}, "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="],
|
||||
|
||||
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||
@@ -347,7 +305,7 @@
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
"electron": ["electron@42.6.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-axGNgd+yCTg+vi1VEGrQqAj9WVWkePKwbICSAvMiT2eTaxhij9a/xhBHD6rXV8wrlW9ZfJzE5+xg752ImxrmTw=="],
|
||||
"electron": ["electron@43.8.3", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-KgvSm3bHXz96hSXzqxyRjLeli80rIdszbDQFKlRSfckmGazTPCH/CxUN31Dp9c5rFKTQSNf9gPJRWl1yWNB3pA=="],
|
||||
|
||||
"electron-builder": ["electron-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.16.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-LrLK65QX5PUYYODXqp23FKrV7CILTtVY7mrJckNknO9jLNSMiqFkKbSMiDRw4CjOADMPVDdWLxY4mezOZWswxg=="],
|
||||
|
||||
@@ -383,45 +341,17 @@
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@10.8.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
|
||||
|
||||
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||
|
||||
"espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
|
||||
|
||||
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||
|
||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
|
||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.6", "", {}, "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q=="],
|
||||
"fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||
|
||||
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
|
||||
"follow-redirects": ["follow-redirects@1.16.2", "", {}, "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow=="],
|
||||
|
||||
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
|
||||
|
||||
@@ -441,8 +371,6 @@
|
||||
|
||||
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"global-agent": ["global-agent@3.0.0", "", { "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", "matcher": "^3.0.0", "roarr": "^2.15.3", "semver": "^7.3.2", "serialize-error": "^7.0.1" } }, "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q=="],
|
||||
|
||||
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
|
||||
@@ -467,7 +395,7 @@
|
||||
|
||||
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
||||
|
||||
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
|
||||
"http-cache-semantics": ["http-cache-semantics@4.3.0", "", {}, "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
@@ -475,20 +403,12 @@
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
|
||||
@@ -503,9 +423,7 @@
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
|
||||
|
||||
@@ -521,12 +439,8 @@
|
||||
|
||||
"lazy-val": ["lazy-val@1.0.5", "", {}, "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"libsql": ["libsql@0.5.28", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.28", "@libsql/darwin-x64": "0.5.28", "@libsql/linux-arm-gnueabihf": "0.5.28", "@libsql/linux-arm-musleabihf": "0.5.28", "@libsql/linux-arm64-gnu": "0.5.28", "@libsql/linux-arm64-musl": "0.5.28", "@libsql/linux-x64-gnu": "0.5.28", "@libsql/linux-x64-musl": "0.5.28", "@libsql/win32-x64-msvc": "0.5.28" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-wKqx9FgtPcKHdPfR/Kfm0gejsnbuf8zV+ESPmltFvsq5uXwdeN9fsWn611DmqrdXj1e94NkARcMA2f1syiAqOg=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash": ["lodash@4.18.0", "", {}, "sha512-l1mfj2atMqndAHI3ls7XqPxEjV2J9ZkcNyHpoZA3r2T1LLwDB69jgkMWh71YKwhBbK0G2f4WSn05ahmQXVxupA=="],
|
||||
|
||||
"lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="],
|
||||
@@ -563,8 +477,6 @@
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"node-abi": ["node-abi@4.28.0", "", { "dependencies": { "semver": "^7.6.3" } }, "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g=="],
|
||||
|
||||
"node-api-version": ["node-api-version@0.2.1", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q=="],
|
||||
@@ -581,16 +493,10 @@
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
@@ -607,8 +513,6 @@
|
||||
|
||||
"postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
||||
|
||||
"proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="],
|
||||
@@ -625,8 +529,6 @@
|
||||
|
||||
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
|
||||
|
||||
"pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
|
||||
@@ -711,13 +613,11 @@
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
"undici": ["undici@7.30.1", "", {}, "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
@@ -725,8 +625,6 @@
|
||||
|
||||
"unzipper": ["unzipper@0.12.5", "", { "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", "fs-extra": "11.3.1", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } }, "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
@@ -735,8 +633,6 @@
|
||||
|
||||
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
@@ -771,8 +667,6 @@
|
||||
|
||||
"@electron/windows-sign/fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
|
||||
|
||||
"@types/cacheable-request/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
@@ -787,8 +681,6 @@
|
||||
|
||||
"app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
|
||||
|
||||
"app-builder-lib/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"app-builder-lib/ci-info": ["ci-info@4.3.1", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
|
||||
|
||||
"builder-util/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
@@ -835,8 +727,6 @@
|
||||
|
||||
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"app-builder-lib/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"builder-util/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
+3
-3
@@ -42,13 +42,13 @@ How fragments turn into a release:
|
||||
|
||||
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
|
||||
- The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries.
|
||||
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use short top-level items with one nested bullet per distinct change, instead of packing a release's worth of detail into a single paragraph bullet. An item with only one thing to say stays inline on the top-level bullet. Release notes and prerelease notes additionally cover user benefit and any useful action note in their nested bullets.
|
||||
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use one item per feature or area, inline by default: `- **Name**: one or two short sentences.` Items with several separate changes may use a few short nested bullets instead of a run-on sentence. Items say what changed and how to reach it; implementation details, edge cases, and step-by-step usage belong in the docs, not the release body.
|
||||
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
|
||||
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
|
||||
- The polished `CHANGELOG.md` and `packaging/release-notes/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
|
||||
|
||||
Prerelease notes:
|
||||
|
||||
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md`
|
||||
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `packaging/release-notes/prerelease-notes.md`
|
||||
- from the second prerelease of a base version onward, the notes also open with a `## Changes since <previous tag>` section generated from the fragment diff against the previous beta/RC tag; keep fragment edits meaningful. Editorial-only rewording is filtered out of that section, while genuinely changed behavior and deleted fragments (reverted changes) are reported
|
||||
- existing prerelease notes are a reviewed baseline; later prerelease runs should replace stale beta/RC wording with the current outcome instead of appending fix churn
|
||||
- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md`
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- The controller settings window (`Alt+C`) no longer edits a controller it cannot see. A newly opened window cannot see any controller until you press one of its buttons (browsers hide a pad from a new window until then); it used to show and edit the global defaults meanwhile, and its status could stay stuck on "No controllers detected". Now it shows the bindings of the controller the overlay is using (or the saved one), labelled as a preview, and Learn, Clear, Reset and the button style are locked until the press.
|
||||
- With several controllers connected, `Alt+C` now selects the one you just pressed, and Learn listens to that controller.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: anilist
|
||||
|
||||
- AniList progress sync now adds a show to your Watching list (or Completed on the final episode) when it is not on your list yet, instead of failing with "AniList update not possible". This only happens when the file title exactly matches an AniList title or the entry is pinned with the character dictionary override; loose matches still require adding the show yourself.
|
||||
- Entries marked Completed, Paused, Dropped, or Rewatching are still never changed.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- `ankiConnect.fields.audio` now defaults to `SentenceAudio` instead of `ExpressionAudio`, so generated sentence audio no longer overwrites Yomitan's word audio on default configs. `fields.wordAudio` still defaults to `ExpressionAudio`. If you relied on the old default, set `fields.audio` to `ExpressionAudio` explicitly.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: linux
|
||||
|
||||
- Fixed AppImage builds on Ubuntu 26.04+ (uutils coreutils) releasing their mount before Chromium child processes finished shutting down, which brought back "Service Crash" notifications on quit.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: docs
|
||||
area: launcher
|
||||
|
||||
- Documented the bundled-runtime launcher install, legacy launcher migration, package-managed updates, and the bundled Bun runtime's MIT and LGPL notices (installed under `/usr/share/licenses/subminer-bin` by the AUR package and included in `subminer-assets.tar.gz`).
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: launcher
|
||||
|
||||
- Every installed and downloadable launcher now uses the Bun runtime bundled with SubMiner instead of a system Bun. Recognized legacy launchers migrate automatically, Windows gets a `subminer.cmd` download, and first-run setup is reduced to a single optional launcher control with runtime repair guidance shown only when needed.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: internal
|
||||
area: ci
|
||||
|
||||
- Removed duplicate source and launcher smoke executions from the reusable quality gate while preserving every distinct test lane and failure artifact.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: internal
|
||||
area: verification
|
||||
|
||||
- Replaced mislabeled dist source reruns with a small Electron-runtime smoke check for compiled stats startup, HTTP service, native SQLite, port conflicts, and cleanup.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: sync
|
||||
|
||||
- Sync now uses compressed, incremental rsync transfers between compatible macOS and Linux machines, caching the last received snapshot per peer to cut traffic on later syncs. Machines without compatible rsync (including Windows) fall back to compressed scp, and older peers still work without the upload cache. Transfers abort after 30 minutes.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Controller remaps made in `Alt+C` now apply right away; before, the overlay kept the old bindings until SubMiner restarted.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- `Alt+C` now warns when two controller actions share an input, as the dialog layouts already did. Both rows show "⚠ Same as …", including a button on the D-pad while a **(D-pad)** row under **Navigation** is still on, and a stick direction used by a stick action.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- `Alt+C` has a **Button Style** choice (Xbox, Nintendo or PlayStation), saved per controller. PlayStation is new: buttons and the review and duplicate card shortcuts show ╳ ○ □ △. `controller.faceButtonLabels` accepts `playstation` and stays the default for controllers without their own style.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Clicking a row in `Alt+C` shows its **Learn**, **Clear** and **Reset** buttons again. The panel opened but was squeezed to nothing once the list was taller than the window, so **Clear** (set a binding to None) could not be reached.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- The duplicate card (field grouping) dialog has a controller layout: the D-pad picks the original or new card, `A` continues and confirms the merge, `B` cancels or goes back from the preview, `X` turns "Delete duplicate" on or off, `Y` switches Compact / Full, and the D-pad scrolls the preview. Each control shows its shortcut, and the layout can be changed per controller in the new **Duplicate card (field grouping)** group of `Alt+C`.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Added a controller layout for the media timing review dialog covering every button, with L1 / R1 + D-pad combos for the clip start and end, and D-pad actions that repeat while held. Each button shows its controller shortcut while controller support is on, and the first press in a newly opened dialog already counts. The controller's usual actions pause while the dialog is open, including in the hidden subtitle overlay behind it.
|
||||
- Added `controller.faceButtonLabels` (`xbox` or `nintendo`) so fixed controller layouts and the `Alt+C` button names follow the letters printed on the pad. Use `nintendo` for controllers with A on the right and B at the bottom.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: overlay
|
||||
|
||||
- The controller's **Toggle MPV Pause** button now works without keyboard-only mode, so you can pause while just watching.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- The media timing review controller layout can now be changed in the new **Review media timing** group of `Alt+C`. Learn records a single button or a combo (hold one button, press another), any button can be a hold button, changes are saved per controller, rows warn about clashing inputs, and **Reset all** restores the default layout. The review's button hints follow your bindings.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Added a controller binding that shows the secondary subtitle only while a button is held (`controller.bindings.holdSecondarySub`). It is unbound by default; assign it with Learn in the `Alt+C` controller modal. It works with or without keyboard-only mode and does not change the saved secondary subtitle mode.
|
||||
- Added controller bindings to select the previous or next word entry in the open Yomitan popup (`controller.bindings.previousEntry` / `nextEntry`). Mine Card mines the selected entry. Both are unbound by default; assign them with Learn in the `Alt+C` controller modal.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- The `Alt+C` controller screen now shows the right button names for controllers that report the W3C standard layout (most Xbox, PlayStation, Switch and 8BitDo pads). Previously Select and Start showed as `L3` and `R3`, and the triggers as `Back` and `Start`. Bindings themselves were unaffected.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- New controller actions for subtitle lines, in the **Subtitles** group of `Alt+C`: **Previous Subtitle**, **Next Subtitle**, **Replay Subtitle** and **Play Next Subtitle**, matching `Shift+H`, `Shift+L`, `Ctrl+Shift+H` and `Ctrl+Shift+L`. They are unbound by default and work with or without keyboard-only mode.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Added `ankiConnect.fields.wordAudio` so word audio is read separately from the sentence-audio destination, fixing animated images that started moving immediately when `fields.audio` pointed to `SentenceAudio`.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: config
|
||||
|
||||
- Validate direct AnkiConnect, Kiku, and Senren settings before admitting them to runtime config, with warnings and defaults for invalid values.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: startup
|
||||
|
||||
- Fixed first launch exiting on macOS when the SubMiner config directory did not yet exist by creating it before acquiring the startup lock.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Jellyfin streams no longer leak URL-derived titles or credential-bearing stream URLs into metadata lookups, Anki source fields, Discord presence, stats identities, or AniList retry keys. Previously cached credential-bearing parser metadata is cleaned up without touching watch history or library assignments.
|
||||
@@ -1,7 +0,0 @@
|
||||
type: fixed
|
||||
area: jellyfin
|
||||
|
||||
- Jellyfin playback, subtitle, artwork, and remote-control URLs now authenticate with the `ApiKey` query parameter instead of the legacy `X-Emby-*` headers, so the integration works on Jellyfin 12 where legacy authorization is disabled by default.
|
||||
- "Play on SubMiner" keeps working on Jellyfin 12: the cast-target websocket answers keep-alive requests and reconnects when the server stops replying instead of silently dying after about a minute.
|
||||
- The Jellyfin "now playing" bar clears when you close or finish a cast video instead of running on to the end of the episode.
|
||||
- Anki cards mined from Jellyfin playback get the episode title in the misc info field again instead of "Unknown media".
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Clicking a subtitle sidebar cue releases row focus, and Space no longer seeks back to a focused cue. Enter still seeks the focused cue, and Space keeps its configured playback action.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Reject malformed resource IDs and partly invalid ID lists before stats library mutations or cover backfills run.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Stats server port conflicts are reported through status notifications instead of crashing SubMiner. Startup and shutdown are also more robust: concurrent startup requests are shared, background stop no longer disconnects foreground dashboards, and shutdown bounds how long it waits for active requests.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Closing the overlay while media timing review is still loading now cancels setup and modal retries, restores playback if the review paused it, and cleans up the hidden preview player.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Treat `ankiConnect.media.maxMediaDuration: 0` as unlimited for stats dashboard mining, matching overlay mining and configuration.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Keep Hyprland recovery dialogs above SubMiner windows so overlay placement updates do not cover their Wait and Close buttons.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: docs
|
||||
area: subtitles
|
||||
|
||||
- Documented subtitle generation: model choice, VAD behavior, reference-track guided splitting, fallback behavior, and known limits.
|
||||
@@ -1,7 +0,0 @@
|
||||
type: added
|
||||
area: subtitles
|
||||
|
||||
- Generate Japanese SRT subtitles locally with whisper.cpp from a modal (Ctrl+Shift+G), the empty subtitle sidebar's generation button, or `subminer generate-subs`, with progress, cancellation, and automatic loading into mpv.
|
||||
- Pick an official multilingual model (including quantized variants) with size and accuracy guidance and download it in-app, or point Settings at an existing model. `large-v3-turbo` is recommended when CUDA support is detected, `small` otherwise. whisper-cli, ffmpeg, and ffprobe are found on PATH unless overridden, and missing tools are named before any download starts.
|
||||
- Optional "Focus on spoken dialogue" mode uses a separately downloadable Silero VAD model, keeping uncertain audible sections so dialogue under music is not dropped (songs may be transcribed too).
|
||||
- Long passages are split near detected speech starts or quiet pauses, guided by an eligible embedded or external subtitle track already loaded in mpv when one is available, to reduce early subtitle timing. Each passage runs in a fresh Whisper process to avoid repeated-character output.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: docs
|
||||
area: jellyfin
|
||||
|
||||
- Documented how Jellyfin media titles and stats identities keep stream credentials out of metadata.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: jimaku
|
||||
|
||||
- Jimaku modal gains Anime / Live action tabs so searches can pull from Jimaku's live action catalogue instead of only anime entries (Arrow Left / Right switch tabs).
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- New **Pause Video In Keyboard-Only Mode** setting (`subtitleStyle.autoPauseVideoOnKeyboardMode`, off by default): turning keyboard-only mode on, from the keyboard or a controller, pauses the video, and turning it off resumes it. A video that was already paused, or that you played or paused yourself in the meantime, is left as it is.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: overlay
|
||||
|
||||
- Turning keyboard-only mode off, from the keyboard or a controller, now also closes the dictionary popup.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: docs
|
||||
area: mining
|
||||
|
||||
- Document independent screenshot selection in media timing review.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: mining
|
||||
|
||||
- Choose a still screenshot independently of the audio in media timing review, with a live preview, a separate time slider, and decoded-frame stepping. Local video and seekable remote streams such as Jellyfin are supported.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- The overlay picks up non-conflicting keyboard bindings from mpv defaults, `input.conf`, and loaded scripts. SubMiner controls and explicitly disabled bindings take precedence; discovered bindings are session-only and not listed in the help menu.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: release
|
||||
|
||||
- Reduced installer and unpacked app size by dropping demo media, source maps, TypeScript sources, test fixtures, and unused Koffi binaries from the package, and sharing one Japanese UI font across windows. Release builds now publish package size reports with comparisons against the previous release.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: mpv-plugin
|
||||
|
||||
- Fixed the "Overlay loading" message staying on screen when mpv moves to the next file in a playlist, then turning into "Overlay did not become ready; check SubMiner logs" after 30 seconds even though the overlay was working. The overlay window is reused between files, so the plugin no longer restarts the loading message once the overlay has reported ready.
|
||||
@@ -0,0 +1,10 @@
|
||||
type: added
|
||||
area: plugins
|
||||
|
||||
- Added JavaScript plugins loaded at startup from `<config dir>/plugins/<id>/` and enabled under `plugins` in the config. Plugins can listen to mpv events, run timers, register actions bound with `__plugin-action:<id>/<action>` keybindings or run with `--plugin-action`, run built-in actions, react to mined Anki cards and call AnkiConnect, start mpv, run programs, keep state in a per-plugin data folder, and show results in an overlay panel or their own window.
|
||||
- Added an example `ai-explain` plugin that sends the current line and its neighbors to `codex exec` and shows a translation or grammar explanation.
|
||||
- Added an example `anki-sync` plugin that syncs Anki to AnkiWeb after you mine cards, once mining goes quiet or playback ends.
|
||||
- Added an example `episode-tags` plugin that tags mined cards with the show and episode, such as `show::Frieren::S01E07`.
|
||||
- Added an example `auto-jimaku` plugin that opens the Jimaku search when a file starts without a Japanese subtitle track.
|
||||
- Added a versioned plugin API with standalone authoring types, cached playback snapshots, lifecycle cancellation and cleanup, and awaitable mpv commands. Async activation is bounded and actions wait only for their own plugin. Shutdown skips pending loads, runs each deactivation once, and blocks calls from disposed plugins.
|
||||
- Mining events preserve original and kept note ids with the media and subtitle captured when mining starts, so delayed enrichment and merges into older notes keep the source episode. Stats-dashboard cards do not trigger plugin events. Plugin subtitle lookups and mining provenance account for subtitle delay and return timing on the subtitle file's timeline.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Moving between words with the keyboard or a controller right after closing the dictionary popup no longer reopens it. A popup that closed under the mouse pointer was still treated as open.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Cancel pending Linux overlay window replacements during teardown so a delayed close callback cannot reopen the overlay.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: docs
|
||||
area: config
|
||||
|
||||
- Clarified live-setting save feedback, mixed restart warnings, and subtitle-generation reload behavior.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: config
|
||||
|
||||
- Settings marked LIVE now use the same reload policy as save results, fixing false restart warnings for notifications and subtitle generation.
|
||||
- Mixed saves apply live changes and list only sections with changed fields that require a restart.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Select dialogue across subtitle sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button. Selecting does not seek or require mining a card.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: stats
|
||||
|
||||
- The stats server now rejects requests from non-loopback hosts and browser origins and requires `application/json` for mutation bodies. The in-app stats overlay loads from the local server so it shares the same protection. Reverse-proxied or Tailscale Serve dashboards are unsupported; scripts that POST must set a JSON content type.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: added
|
||||
area: subtitles
|
||||
|
||||
- Added subtitle filters (`subtitleFilter`) that remove unwanted text from primary subtitles before it reaches the overlay, sidebar, mined sentences and stats. Built-in presets cover text in parentheses (speaker names, readings, notes; whole-line inner thoughts are kept), square-bracket sound descriptions, speaker names before a colon, and music notes. Custom regex rules with a replacement can be added after the presets. Lines left with no words are hidden.
|
||||
- Configure them in the settings window under Behavior → Subtitle Filters; changes apply live to the current subtitle file.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: subtitles
|
||||
|
||||
- Keep the subtitle sidebar near playback during gaps when the subtitle file has a cue starting at zero.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: docs
|
||||
area: sync
|
||||
|
||||
- Documented compressed transfers, incremental sync cache storage, and compatibility with older peers.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: docs
|
||||
area: stats
|
||||
|
||||
- Documented TMDB linking, provider reassignment, merge compatibility, and credential command caching.
|
||||
@@ -1,6 +0,0 @@
|
||||
type: added
|
||||
area: stats
|
||||
|
||||
- Live-action dramas and movies in the stats Library get posters, synopses, and titles from TMDB. Release builds include a project key; `tmdb.apiKey` (or `tmdb.apiKeyCommand`) overrides it and is required when running from source.
|
||||
- Titles AniList cannot match are looked up on TMDB automatically when the parsed filename matches a Japanese live-action title exactly; otherwise use the new **Link to TMDB** action. Entries linked to the same TMDB title merge into one card, and the Library kind selector gained a Live Action option.
|
||||
- Provider reassignment keeps the previous link and artwork if the replacement download fails. Merges and sync keep AniList and TMDB identities separate, and the merge dialog explains mixed selections instead of failing.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: docs
|
||||
area: anki
|
||||
|
||||
- Documented the separate word-audio field mapping; existing animated images need regeneration to pick up the fix.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: changed
|
||||
area: yomitan
|
||||
|
||||
- Updated bundled Yomitan with upstream 26.9.8 changes, including historical Japanese kana transformations, Ukrainian language support, and improvements to Anki duplicate searches and audio retrieval.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: docs
|
||||
area: stats
|
||||
|
||||
- Document YouTube channel filtering and video statistics in the Library.
|
||||
@@ -1,5 +0,0 @@
|
||||
type: added
|
||||
area: stats
|
||||
|
||||
- YouTube channels are now a separate Library media kind. Existing channel entries migrate automatically without changing viewing history or manual video assignments.
|
||||
- Added All Titles, Anime, and YouTube Library filters. Channels stay out of AniList matching, season repair, and duplicate recommendations, and merges or video moves cannot mix an anime entry with a YouTube channel.
|
||||
+352
-5
@@ -6,6 +6,22 @@
|
||||
*/
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Dictionary Backend
|
||||
// Select the dictionary lookup backend: yomitan or hachidori.
|
||||
// Restart SubMiner after changing the backend. Each backend keeps separate settings and dictionaries.
|
||||
// ==========================================
|
||||
"dictionaryBackend": "yomitan", // Dictionary lookup backend. Restart SubMiner after changing this setting. Values: yomitan | hachidori
|
||||
|
||||
// ==========================================
|
||||
// Subtitle Selection
|
||||
// Select primary and secondary mpv subtitle tracks from the overlay.
|
||||
// Hot-reload: enabling or disabling updates the session shortcut immediately.
|
||||
// ==========================================
|
||||
"subtitleSelection": {
|
||||
"enabled": false // Use the SubMiner modal to select primary and secondary subtitle tracks. When enabled, its shortcut overrides mpv subtitle selection. Values: true | false
|
||||
}, // Select primary and secondary mpv subtitle tracks from the overlay.
|
||||
|
||||
// ==========================================
|
||||
// Japanese Subtitle Generation
|
||||
// Generate timed Japanese subtitles from local audio using whisper.cpp.
|
||||
@@ -92,6 +108,7 @@
|
||||
"stickDeadzone": 0.2, // Deadzone applied to controller stick axes.
|
||||
"triggerInputMode": "auto", // How controller triggers are interpreted: auto, pressed-only, or thresholded analog. Values: auto | digital | analog
|
||||
"triggerDeadzone": 0.5, // Minimum analog trigger value required when trigger input uses auto or analog mode.
|
||||
"faceButtonLabels": "xbox", // Face button style of your controller: xbox (A at the bottom), nintendo (A on the right, B at the bottom; Switch-style and many 8BitDo pads) or playstation (╳ ○ □ △, laid out like xbox). Places the A/B/X/Y of the dialog layouts and names buttons in Alt+C and the dialog hints. Alt+C sets it per controller (controller.profiles.<id>.faceButtonLabels); this is the default for the others. Values: xbox | nintendo | playstation
|
||||
"repeatDelayMs": 320, // Delay before repeating held controller actions.
|
||||
"repeatIntervalMs": 120, // Repeat interval for held controller actions.
|
||||
"buttonIndices": {
|
||||
@@ -142,7 +159,28 @@
|
||||
"toggleMpvPause": {
|
||||
"kind": "button", // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
"buttonIndex": 9 // Raw button index captured for this discrete controller action.
|
||||
}, // Controller binding descriptor for toggling mpv play/pause. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
}, // Controller binding descriptor for toggling mpv play/pause. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"holdSecondarySub": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that shows the secondary subtitle only while held. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"previousEntry": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that selects the previous word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"nextEntry": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that selects the next word entry in the open Yomitan popup. Mine Card mines the selected entry. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"previousSubtitle": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that jumps to the previous subtitle line, like Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"nextSubtitle": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that jumps to the next subtitle line, like Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"replaySubtitle": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that replays the current subtitle line, then pauses, like Ctrl+Shift+H. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"playNextSubtitle": {
|
||||
"kind": "none" // Discrete binding input source kind. When kind is "axis", set both axisIndex and direction. Values: none | button | axis
|
||||
}, // Controller binding descriptor that plays the next subtitle line, then pauses, like Ctrl+Shift+L. Works with or without keyboard-only mode. Use Alt+C learn mode or set a raw button/axis descriptor manually. If kind is "axis", direction is required.
|
||||
"leftStickHorizontal": {
|
||||
"kind": "axis", // Analog binding input source kind. Values: none | axis
|
||||
"axisIndex": 0, // Raw axis index captured for this analog controller action.
|
||||
@@ -164,6 +202,288 @@
|
||||
"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.
|
||||
}, // Raw controller binding descriptors saved by Alt+C learn mode. For discrete axis bindings, kind "axis" requires axisIndex and direction.
|
||||
"reviewBindings": {
|
||||
"confirm": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
}, // Review dialog: Use this timing.
|
||||
"cancel": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
}, // Review dialog: Cancel.
|
||||
"play": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
}, // Review dialog: Play selection.
|
||||
"resetTiming": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
}, // Review dialog: Reset timing.
|
||||
"showEarlier": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
}, // Review dialog: Earlier −2s. Repeats while held.
|
||||
"showLater": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
}, // Review dialog: Later +2s. Repeats while held.
|
||||
"framePrevious": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
}, // Review dialog: Previous screenshot frame. Repeats while held.
|
||||
"frameNext": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
}, // Review dialog: Next screenshot frame. Repeats while held.
|
||||
"frameReset": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"buttonIndex": 5 // Raw button index.
|
||||
}, // Input that triggers the action.
|
||||
"hold": {
|
||||
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"buttonIndex": 4 // Raw button index.
|
||||
} // Optional input held while pressing; null for none.
|
||||
}, // Review dialog: Reset screenshot frame.
|
||||
"startBack": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||
}, // Input that triggers the action.
|
||||
"hold": {
|
||||
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"buttonIndex": 4 // Raw button index.
|
||||
} // Optional input held while pressing; null for none.
|
||||
}, // Review dialog: Start −0.1s. Repeats while held.
|
||||
"startForward": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||
}, // Input that triggers the action.
|
||||
"hold": {
|
||||
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"buttonIndex": 4 // Raw button index.
|
||||
} // Optional input held while pressing; null for none.
|
||||
}, // Review dialog: Start +0.1s. Repeats while held.
|
||||
"previousLineAdd": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||
}, // Input that triggers the action.
|
||||
"hold": {
|
||||
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"buttonIndex": 4 // Raw button index.
|
||||
} // Optional input held while pressing; null for none.
|
||||
}, // Review dialog: Add previous line. Repeats while held.
|
||||
"previousLineRemove": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||
}, // Input that triggers the action.
|
||||
"hold": {
|
||||
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"buttonIndex": 4 // Raw button index.
|
||||
} // Optional input held while pressing; null for none.
|
||||
}, // Review dialog: Remove previous line. Repeats while held.
|
||||
"endBack": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||
}, // Input that triggers the action.
|
||||
"hold": {
|
||||
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"buttonIndex": 5 // Raw button index.
|
||||
} // Optional input held while pressing; null for none.
|
||||
}, // Review dialog: End −0.1s. Repeats while held.
|
||||
"endForward": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||
}, // Input that triggers the action.
|
||||
"hold": {
|
||||
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"buttonIndex": 5 // Raw button index.
|
||||
} // Optional input held while pressing; null for none.
|
||||
}, // Review dialog: End +0.1s. Repeats while held.
|
||||
"nextLineAdd": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||
}, // Input that triggers the action.
|
||||
"hold": {
|
||||
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"buttonIndex": 5 // Raw button index.
|
||||
} // Optional input held while pressing; null for none.
|
||||
}, // Review dialog: Add next line. Repeats while held.
|
||||
"nextLineRemove": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||
}, // Input that triggers the action.
|
||||
"hold": {
|
||||
"kind": "button", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"buttonIndex": 5 // Raw button index.
|
||||
} // Optional input held while pressing; null for none.
|
||||
}, // Review dialog: Remove next line. Repeats while held.
|
||||
"useOriginal": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
}, // Review dialog: Use original timing (after cancel).
|
||||
"keepEditing": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
}, // Review dialog: Keep editing (after cancel).
|
||||
"skipMedia": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
}, // Review dialog: Without media (after cancel).
|
||||
"discard": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
} // Review dialog: Discard card (after cancel).
|
||||
}, // Controller layout for the media timing review dialog, one entry per action. Set it with the Review media timing group in Alt+C. Each entry is {"kind": "none"} or {"press": input, "hold": input}, where an input is {"kind": "button", "buttonIndex"}, {"kind": "axis", "axisIndex", "direction"}, {"kind": "face", "letter": "a"|"b"|"x"|"y"} (printed letter) or {"kind": "dpad", "direction": "left"|"right"|"up"|"down"}.
|
||||
"fieldGroupingBindings": {
|
||||
"selectOriginal": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"direction": "left" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
}, // Duplicate card dialog: Original card.
|
||||
"selectNew": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"direction": "right" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
}, // Duplicate card dialog: New card.
|
||||
"toggleDeleteDuplicate": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"letter": "x" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
}, // Duplicate card dialog: Delete duplicate on/off.
|
||||
"continue": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
}, // Duplicate card dialog: Continue.
|
||||
"cancel": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
}, // Duplicate card dialog: Cancel.
|
||||
"confirmMerge": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"letter": "a" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
}, // Duplicate card dialog: Confirm merge (merge preview).
|
||||
"back": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"letter": "b" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
}, // Duplicate card dialog: Back (merge preview).
|
||||
"cancelMerge": {
|
||||
"kind": "none" // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
}, // Duplicate card dialog: Cancel (merge preview).
|
||||
"toggleView": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "face", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"letter": "y" // Printed face letter, placed by controller.faceButtonLabels. Values: a | b | x | y
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
}, // Duplicate card dialog: Compact / Full (merge preview).
|
||||
"scrollUp": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"direction": "up" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
}, // Duplicate card dialog: Scroll up (merge preview). Repeats while held.
|
||||
"scrollDown": {
|
||||
"kind": "combo", // none disables the action; combo presses "press" (while holding "hold", if set). Values: none | combo
|
||||
"press": {
|
||||
"kind": "dpad", // Input kind: a raw button or axis recorded by Alt+C, a printed face letter, or a D-pad direction. Values: button | axis | face | dpad
|
||||
"direction": "down" // D-pad direction, or axis direction for raw axis inputs. Values: left | right | up | down | negative | positive
|
||||
}, // Input that triggers the action.
|
||||
"hold": null // Optional input held while pressing; null for none.
|
||||
} // Duplicate card dialog: Scroll down (merge preview). Repeats while held.
|
||||
}, // Controller layout for the duplicate card (field grouping) dialog, one entry per action. Set it with the Duplicate card (field grouping) group in Alt+C. Each entry is {"kind": "none"} or {"press": input, "hold": input}, where an input is {"kind": "button", "buttonIndex"}, {"kind": "axis", "axisIndex", "direction"}, {"kind": "face", "letter": "a"|"b"|"x"|"y"} (printed letter) or {"kind": "dpad", "direction": "left"|"right"|"up"|"down"}.
|
||||
"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.
|
||||
|
||||
@@ -202,6 +522,14 @@
|
||||
"overlayPosition": "top-right" // Position for in-overlay notification cards. Values: top-left | top | top-right
|
||||
}, // Overlay notification display behavior.
|
||||
|
||||
// ==========================================
|
||||
// Plugins
|
||||
// JavaScript plugins loaded from <config dir>/plugins/<id>/ at startup.
|
||||
// Plugins run inside SubMiner with full access to your system. Only enable code you trust.
|
||||
// Restart SubMiner after changing plugins.
|
||||
// ==========================================
|
||||
"plugins": {}, // Plugins to load at startup, keyed by directory name under <config dir>/plugins/. Each entry is { "enabled": true, "settings": { ... } }; settings are passed to the plugin as ctx.settings.
|
||||
|
||||
// ==========================================
|
||||
// Keyboard Shortcuts
|
||||
// Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
@@ -223,6 +551,7 @@
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||
"openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).
|
||||
"openSubtitleSelection": "g-s", // Open subtitle selection when enabled. Use g-s to press g then s. Set null to unbind.
|
||||
"openSubtitleGeneration": "Ctrl+Shift+G", // Accelerator that opens the standalone Japanese subtitle generation modal.
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||
@@ -445,6 +774,7 @@
|
||||
"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
|
||||
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
||||
"autoPauseVideoOnKeyboardMode": false, // Pause mpv when keyboard-only mode turns on (from the keyboard or a controller) and resume when it turns off. Leaving the mode resumes only a pause it made: a video that was already paused, or that you played or paused yourself meanwhile, is left as it is. Values: true | false
|
||||
"primaryVisibleOnYomitanPopup": true, // Keep the primary subtitle bar visible while a Yomitan popup is open when primary subtitles are in hover mode. Values: true | false
|
||||
"nameMatchEnabled": false, // Enable character dictionary sync and subtitle token coloring for character-name matches. Values: true | false
|
||||
"nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false
|
||||
@@ -524,6 +854,22 @@
|
||||
} // 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.
|
||||
|
||||
// ==========================================
|
||||
// Subtitle Filters
|
||||
// Remove unwanted text from primary subtitles, such as speaker names, readings and sound descriptions.
|
||||
// Applies to the overlay, sidebar, mined sentences and stats.
|
||||
// Hot-reload: filter changes apply to the current subtitle file immediately.
|
||||
// ==========================================
|
||||
"subtitleFilter": {
|
||||
"presets": {
|
||||
"parentheses": false, // Remove text in () and (), such as speaker names, readings and notes. A line wrapped entirely in parentheses, usually inner thoughts, is kept. Values: true | false
|
||||
"brackets": false, // Remove text in [], [], 【】 and 〔〕, such as sound effects and music cues. Values: true | false
|
||||
"speakerPrefix": false, // Remove a short name followed by a colon at the start of a line, as in 山田:元気? Values: true | false
|
||||
"musicSymbols": false // Remove ♪, ♫ and ♬. A line left with only symbols such as ~ is removed. Values: true | false
|
||||
}, // Built-in filters. Each removes its matches and is off by default.
|
||||
"customRules": [] // Your own regex rules, applied in order after the presets. Each rule is { name, pattern, replacement, enabled }; patterns use the gmu flags, so ^ and $ match per line. Leave replacement empty to remove the match.
|
||||
}, // Remove unwanted text from primary subtitles, such as speaker names, readings and sound descriptions.
|
||||
|
||||
// ==========================================
|
||||
// Shared AI Provider
|
||||
// Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
|
||||
@@ -561,7 +907,7 @@
|
||||
"deck": "", // Restrict duplicate detection and card enrichment to this Anki deck. Leave empty to use the Yomitan mining deck when available.
|
||||
"fields": {
|
||||
"word": "Expression", // Card field for the mined word or expression text.
|
||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||
"audio": "SentenceAudio", // Card field that receives generated sentence audio.
|
||||
"wordAudio": "ExpressionAudio", // Existing word-audio field read to time the frozen first frame of animated images. This mapping is only used for synchronization.
|
||||
"image": "Picture", // Card field that receives the captured screenshot or animated image.
|
||||
"sentence": "Sentence", // Card field that receives the source sentence text.
|
||||
@@ -672,13 +1018,14 @@
|
||||
// ==========================================
|
||||
// YouTube Playback Settings
|
||||
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||
// Hot-reload: primarySubLanguages applies to the next YouTube subtitle load.
|
||||
// Hot-reload: primarySubLanguages and subtitleSource apply to the next YouTube subtitle load.
|
||||
// ==========================================
|
||||
"youtube": {
|
||||
"primarySubLanguages": [
|
||||
"ja",
|
||||
"jpn"
|
||||
], // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||
"subtitleSource": "youtube", // Where primary YouTube subtitles come from. Whisper transcribes the audio locally using the subtitleGeneration settings. Values: youtube | whisper
|
||||
"mediaCache": {
|
||||
"mode": "direct", // How YouTube card audio/images are extracted. Values: direct | background
|
||||
"maxHeight": 720 // Maximum video height downloaded for the YouTube background media cache. Set to 0 for unlimited.
|
||||
@@ -723,10 +1070,10 @@
|
||||
// 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.profile to pass an mpv profile to managed mpv launches; leave it blank to pass none.
|
||||
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
||||
// Leave mpv.executablePath blank to auto-discover mpv from SUBMINER_MPV_PATH, PATH, or (macOS) Homebrew and /Applications/mpv.app.
|
||||
// ==========================================
|
||||
"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 the mpv binary SubMiner starts itself (Windows launches, Jellyfin casting, timing preview). Leave empty to auto-discover from SUBMINER_MPV_PATH, PATH, and on macOS the Homebrew and /Applications/mpv.app locations.
|
||||
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
||||
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
|
||||
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
||||
|
||||
+14
-193
@@ -1,6 +1,6 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||
import { extname, join, posix, resolve, sep } from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { DefaultTheme, HeadConfig, TransformContext, UserConfig } from 'vitepress';
|
||||
|
||||
const DOCS_HOSTNAME = 'https://docs.subminer.moe';
|
||||
@@ -14,12 +14,6 @@ const PLAUSIBLE_INIT_SCRIPT = [
|
||||
|
||||
type DocsChannel = 'stable-root' | 'stable-archive' | 'main';
|
||||
|
||||
type VersionManifest = {
|
||||
latestStable: string;
|
||||
channels: Array<{ label: string; path: string }>;
|
||||
versions: Array<{ version: string; path: string }>;
|
||||
};
|
||||
|
||||
function optionalEnv(value: string | undefined): string | undefined {
|
||||
return value && value !== 'undefined' ? value : undefined;
|
||||
}
|
||||
@@ -32,17 +26,6 @@ const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? proce
|
||||
const repoDocsDir = optionalEnv(process.env.SUBMINER_DOCS_REPO_DIR) ?? process.cwd();
|
||||
const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
|
||||
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
|
||||
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.18.0';
|
||||
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
|
||||
const versionLinkOrigin =
|
||||
optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? 'production';
|
||||
|
||||
function getLocalArchiveDir(): string {
|
||||
return resolve(
|
||||
optionalEnv(process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR) ??
|
||||
join(docsSourceDir, '..', '.tmp/docs-versioned-site'),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBase(value: string): string {
|
||||
if (!value || value === '/') return '/';
|
||||
@@ -54,21 +37,6 @@ function normalizeChannel(value: string | undefined): DocsChannel {
|
||||
return 'stable-root';
|
||||
}
|
||||
|
||||
function parseVersionManifest(value: string | undefined): VersionManifest {
|
||||
if (!value || value === 'undefined') {
|
||||
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}`;
|
||||
@@ -164,137 +132,16 @@ function filterSidebar(items: DefaultTheme.SidebarItem[]): DefaultTheme.SidebarI
|
||||
.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 localArchiveDir = getLocalArchiveDir();
|
||||
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 (
|
||||
(optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? 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,
|
||||
})),
|
||||
];
|
||||
// Version navigation targets other builds (root, `/main/`, `/versions`), so it links to
|
||||
// production by absolute URL: base-relative links would stay inside this build, and
|
||||
// `target: '_self'` makes the VitePress router do a full page load. The list is
|
||||
// deliberately static; the full release list lives on the root-only `/versions` page
|
||||
// so frozen `/v/<version>/` archives never need a rebuild when a new tag ships.
|
||||
const versionItems: DefaultTheme.NavItemWithLink[] = [
|
||||
{ text: 'Latest stable', link: `${DOCS_HOSTNAME}/` },
|
||||
{ text: 'main', link: `${DOCS_HOSTNAME}/main/` },
|
||||
{ text: 'All versions', link: `${DOCS_HOSTNAME}/versions` },
|
||||
].map((item) => ({ ...item, target: '_self', noIcon: true }));
|
||||
|
||||
function sitemapUrlToPage(url: string): string {
|
||||
const route = url.replace(/\.html$/, '').replace(/^\/+|\/+$/g, '');
|
||||
@@ -336,7 +183,7 @@ const nav: DefaultTheme.NavItem[] = [
|
||||
{ text: 'Configuration', link: '/configuration' },
|
||||
{ text: 'Changelog', link: '/changelog' },
|
||||
{ text: 'Troubleshooting', link: '/troubleshooting' },
|
||||
{ text: docsVersion ?? (channel === 'main' ? 'main' : latestStable), items: versionItems },
|
||||
{ text: docsVersion ?? 'main', items: versionItems },
|
||||
];
|
||||
|
||||
const sidebar: DefaultTheme.SidebarItem[] = [
|
||||
@@ -374,6 +221,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
|
||||
{ text: 'AniList', link: '/anilist-integration' },
|
||||
{ text: 'AniSkip', link: '/aniskip-integration' },
|
||||
{ text: 'Character Dictionary', link: '/character-dictionary' },
|
||||
{ text: 'Plugins', link: '/plugins' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -394,33 +242,6 @@ const config: UserConfig = {
|
||||
'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: [
|
||||
['link', { rel: 'preconnect', href: PLAUSIBLE_PROXY_HOSTNAME }],
|
||||
[
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup>
|
||||
import { useRoute, useData } from 'vitepress';
|
||||
import { computed } from 'vue';
|
||||
import { formatStatusLineFilePath } from '../status-line';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { formatStatusLineDate, formatStatusLineFilePath } from '../status-line';
|
||||
|
||||
const route = useRoute();
|
||||
const { page, frontmatter } = useData();
|
||||
const { frontmatter } = useData();
|
||||
|
||||
const mode = computed(() => {
|
||||
const layout = frontmatter.value.layout;
|
||||
@@ -23,10 +23,10 @@ const section = computed(() => {
|
||||
return parts[0] || 'root';
|
||||
});
|
||||
|
||||
const lastUpdated = computed(() => {
|
||||
if (!page.value.lastUpdated) return '';
|
||||
const date = new Date(page.value.lastUpdated);
|
||||
return date.toISOString().slice(0, 10);
|
||||
// Set on the client only, so the prerendered HTML never bakes in the build date.
|
||||
const today = ref('');
|
||||
onMounted(() => {
|
||||
today.value = formatStatusLineDate(new Date());
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -40,8 +40,8 @@ const lastUpdated = computed(() => {
|
||||
<div class="tui-statusline__right">
|
||||
<span class="tui-statusline__section">{{ section }}</span>
|
||||
<span class="tui-statusline__sep"></span>
|
||||
<span v-if="lastUpdated" class="tui-statusline__date">{{ lastUpdated }}</span>
|
||||
<span v-if="lastUpdated" class="tui-statusline__sep"></span>
|
||||
<span v-if="today" class="tui-statusline__date">{{ today }}</span>
|
||||
<span v-if="today" class="tui-statusline__sep"></span>
|
||||
<span class="tui-statusline__branch">GPL-3.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { formatStatusLineFilePath } from './status-line';
|
||||
import { formatStatusLineDate, formatStatusLineFilePath } from './status-line';
|
||||
|
||||
test('status line file path formats root home as index markdown', () => {
|
||||
expect(formatStatusLineFilePath('/')).toBe('index.md');
|
||||
@@ -10,7 +10,9 @@ test('status line file path formats version archive home without trailing slash'
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
expect(formatStatusLineFilePath('/v/0.12.0/configuration')).toBe('v/0.12.0/configuration.md');
|
||||
});
|
||||
|
||||
test('status line date uses the local calendar day, zero padded', () => {
|
||||
expect(formatStatusLineDate(new Date(2026, 0, 5, 23, 59))).toBe('2026-01-05');
|
||||
});
|
||||
@@ -2,3 +2,10 @@ export function formatStatusLineFilePath(routePath: string): string {
|
||||
if (routePath === '/') return 'index.md';
|
||||
return `${routePath.replace(/^\/|\/$/g, '')}.md`;
|
||||
}
|
||||
|
||||
// Local calendar date as YYYY-MM-DD (toISOString would give the UTC date).
|
||||
export function formatStatusLineDate(date: Date): string {
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
+16
-1
@@ -40,8 +40,23 @@ The public docs root is stable-only:
|
||||
- `/` serves the latest stable release docs.
|
||||
- `/main/` serves development docs from `main`.
|
||||
- `/v/<version>/` serves stable release archives.
|
||||
- `/versions` (root build only) lists every published version.
|
||||
- Prerelease tags do not update the docs site.
|
||||
|
||||
Only `/` is indexable. `/main/` and every `/v/<version>/` page carries a self-referential canonical plus `noindex,follow`, and the generated `_headers` file repeats that as an `X-Robots-Tag`. They stay crawlable so their links still resolve, but ~30 archived copies of every page would otherwise consume the crawl budget the current docs need. Only the root build emits `sitemap.xml`, and its `<lastmod>` dates come from `git log` against the tracked checkout at the released tag, because the build renders from an untracked snapshot that VitePress cannot date itself.
|
||||
Only `/` is indexable. `/main/` and every `/v/<version>/` page carries a self-referential canonical plus `noindex,follow`, repeated as an `X-Robots-Tag` header (by the generated `_headers` file for `/main/`, by the archive function for `/v/`). They stay crawlable so their links still resolve, but ~30 archived copies of every page would otherwise consume the crawl budget the current docs need. Only the root build emits `sitemap.xml`, and its `<lastmod>` dates come from `git log` against the tracked checkout at the released tag, because the build renders from an untracked snapshot that VitePress cannot date itself.
|
||||
|
||||
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.
|
||||
|
||||
### Stable archives in R2
|
||||
|
||||
`/v/<version>/` archives are not part of the Pages deployment. Each one is built once, uploaded to an R2 bucket under `v/<version>/`, and served by the Pages Function in `functions/v/[[path]].ts`. Each deploy builds only archives the bucket is missing (an archive counts as present once its `_archive.json` marker exists), plus the root and `/main/` builds. Archive nav links to `/versions` instead of listing releases, so a new tag never invalidates old archives.
|
||||
|
||||
To re-render archives on purpose (theme change, docs fix), run the `Docs Pages` workflow manually with `rebuild_archives` set to comma-separated tags or `all`.
|
||||
|
||||
One-time setup:
|
||||
|
||||
- R2 bucket for archives; its name goes in the `DOCS_ARCHIVE_R2_BUCKET` repository variable.
|
||||
- R2 API token with Object Read & Write on that bucket; its S3 credentials go in the `DOCS_ARCHIVE_R2_ACCESS_KEY_ID` and `DOCS_ARCHIVE_R2_SECRET_ACCESS_KEY` repository secrets.
|
||||
- Pages project: Settings > Bindings > R2 bucket, variable name `DOCS_ARCHIVES`, pointing at the same bucket.
|
||||
|
||||
The first deploy after setup builds and uploads every stable archive; later deploys only add new tags.
|
||||
@@ -1,137 +1,68 @@
|
||||
# AniList integration
|
||||
|
||||
SubMiner syncs your watch progress to [AniList](https://anilist.co). Finish an episode and it reads the title and episode number off the filename, finds the matching AniList entry, and updates your progress through the GraphQL API. A failed update retries in the background with exponential backoff.
|
||||
|
||||
The same AniList data feeds [cover art](#cover-art) in the stats dashboard and the [Character Dictionary](/character-dictionary) for in-overlay name lookup.
|
||||
|
||||
[AniList](https://anilist.co) is a free anime tracking site. The **access token** is a private key SubMiner keeps so it can update your list for you. You approve it once during setup, and your AniList password never touches SubMiner.
|
||||
SubMiner updates your [AniList](https://anilist.co) watch progress when you finish an episode. The same connection supplies cover art for the stats dashboard and names for the [character dictionary](/character-dictionary).
|
||||
|
||||
## Setup
|
||||
|
||||
AniList integration is opt-in. To enable it:
|
||||
1. Set `anilist.enabled` to `true`:
|
||||
|
||||
1. Set `anilist.enabled` to `true` in your config.
|
||||
2. Leave `anilist.accessToken` empty and restart SubMiner (or run `--anilist-setup`).
|
||||
3. Approve access in the AniList authorization page.
|
||||
4. The callback returns to SubMiner via the `subminer://anilist-setup?...` protocol URL, and SubMiner stores the token automatically.
|
||||
```jsonc
|
||||
{
|
||||
"anilist": {
|
||||
"enabled": true,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"anilist": {
|
||||
"enabled": true,
|
||||
"accessToken": "",
|
||||
},
|
||||
}
|
||||
```
|
||||
2. Restart SubMiner. With no token stored, it opens the AniList setup window. You can also open it from the tray (**Configure AniList**) or with `subminer app --anilist-setup`.
|
||||
3. Approve access on the AniList page. SubMiner receives the token through a `subminer://` link and stores it encrypted.
|
||||
|
||||
The access token is encrypted at rest using Electron's `safeStorage` API. On Linux this defaults to `gnome-libsecret`; override the backend with `--password-store=<backend>` (for example `--password-store=basic_text`).
|
||||
If the setup window does not render, SubMiner opens the authorization page in your browser instead. To skip the flow entirely, paste a token into `anilist.accessToken`.
|
||||
|
||||
If the embedded auth UI fails to render, SubMiner opens the authorize URL in your default browser and shows fallback instructions in-app.
|
||||
On Linux, the token is stored with `gnome-libsecret` by default. If your keyring is unavailable, start it (gnome-keyring or KWallet) or launch SubMiner with `--password-store=basic_text`.
|
||||
|
||||
::: tip
|
||||
You can also set `anilist.accessToken` directly in config to skip the setup flow entirely. When blank, SubMiner uses the locally stored encrypted token.
|
||||
:::
|
||||
## How updates work
|
||||
|
||||
## How tracking works
|
||||
An episode counts as watched after 85% of its length and at least 10 minutes of playback. SubMiner then:
|
||||
|
||||
SubMiner watches playback and pushes an AniList progress update once an episode counts as watched. That means at least 85% of its duration, and at least 10 minutes either way.
|
||||
1. Reads the title, season, and episode from the file name and folder. Install [guessit](https://github.com/guessit-io/guessit) for better parsing. A folder named `Season 2` is a strong season hint.
|
||||
2. Finds the matching AniList entry. For season 2 and later, it follows the show's sequels.
|
||||
3. Sets your progress to that episode and marks the entry Watching, or Completed on the final episode.
|
||||
|
||||
The update flow:
|
||||
If the show is not on your list yet, SubMiner adds it as Watching, but only when the file title exactly matches an AniList title or you pinned the entry with the character dictionary's AniList override. For a looser match, add the show to Planning or Watching yourself. SubMiner never changes entries marked Completed, Paused, Dropped, or Rewatching, and it never lowers your progress.
|
||||
|
||||
1. **Title detection** - SubMiner extracts the anime title, season, and episode number from the media filename and path. Season folders such as `Season 2` are treated as a strong season signal. SubMiner tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
|
||||
2. **AniList search** - The base title (with any `Season N` / `SN` marker stripped) is searched against the AniList GraphQL API, and SubMiner picks the best match by comparing titles (romaji, English, native, synonyms) and filtering by episode count. AniList has no notion of numbered seasons - sequels are separate entries with their own titles (`Zoku`, `Kan`, `2nd Season`), so searching `<title> Season 3` finds nothing. For season 2 and later, SubMiner instead walks `SEQUEL` relations from the season 1 entry, preferring the TV line, and falls back to ordering the franchise's TV entries by air date when the relation chain is incomplete. If neither locates the season, SubMiner **skips the update** rather than writing progress to the season 1 entry, and tells you to pin the right entry with a [character dictionary override](/character-dictionary#correcting-anilist-matches).
|
||||
3. **Progress check** - SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
|
||||
4. **Mutation** - A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`, or `COMPLETED` when the watched episode is the final episode of the season (the "already at this progress" skip is bypassed for the final episode so completion still lands).
|
||||
Failed updates are saved and retried in the background, up to 8 times with growing delays. The queue survives restarts.
|
||||
|
||||
## Update queue and retry
|
||||
## Fixing a wrong match
|
||||
|
||||
Failed AniList updates are persisted to a retry queue on disk and retried with exponential backoff.
|
||||
If a cover or title in the stats Library is wrong, open the title and use **Change AniList Entry**.
|
||||
|
||||
Updates are skipped if the media path cannot produce a safe, nonempty identity. Invalid entries are discarded when loading or adding to the retry queue.
|
||||
If SubMiner cannot find a later season, it skips the update rather than writing progress to season 1. Pin the right entry with the character dictionary's AniList override. See [Character dictionary](/character-dictionary).
|
||||
|
||||
| Parameter | Value |
|
||||
| ---------------- | ---------- |
|
||||
| Initial backoff | 30 seconds |
|
||||
| Maximum backoff | 6 hours |
|
||||
| Maximum attempts | 8 |
|
||||
| Queue capacity | 500 items |
|
||||
## Commands
|
||||
|
||||
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.
|
||||
| Command | What it does |
|
||||
| ------------------------------------ | --------------------------------------- |
|
||||
| `subminer app --anilist-setup` | Open the AniList setup window |
|
||||
| `subminer app --anilist-status` | Show token state and retry queue counts |
|
||||
| `subminer app --anilist-logout` | Remove the stored token |
|
||||
| `subminer app --anilist-retry-queue` | Retry one queued update now |
|
||||
|
||||
Use `--anilist-retry-queue` to manually process one ready item from the queue.
|
||||
## Options
|
||||
|
||||
## Cover art
|
||||
| Key | What it does |
|
||||
| --------------------- | ------------------------------------------------------------- |
|
||||
| `anilist.enabled` | Turns on progress updates. |
|
||||
| `anilist.accessToken` | Token override. Leave empty to use the token stored by setup. |
|
||||
|
||||
SubMiner fetches cover art from AniList for display in the stats dashboard. When a new video starts playing, the cover art fetcher:
|
||||
|
||||
1. Checks the local database for cached art.
|
||||
2. If missing, parses the media title (guessit then fallback) and searches the AniList API.
|
||||
3. Downloads the cover image from the AniList CDN and caches it locally (both URL and blob).
|
||||
4. Stores AniList metadata (romaji/English titles, total episodes) alongside the cover for dashboard display.
|
||||
|
||||
A no-match result is cached for 5 minutes before SubMiner retries, preventing repeated API calls for unrecognized media.
|
||||
|
||||
When AniList has no match, SubMiner tries [TMDB](/configuration#tmdb) next so live-action dramas and movies get a poster and synopsis too. See [Immersion tracking](/immersion-tracking#library) for how live-action entries are grouped.
|
||||
|
||||
If the automatic match is wrong, use **Change AniList Entry** on a title in the stats Library. Relinking rewrites the cached art for every episode of that title, and both the detail view and the Library grid pick up the new cover right away: the grid refetches after a relink, and cover responses carry an ETag and are revalidated on each request instead of being cached for a day.
|
||||
|
||||
## Rate limiting
|
||||
|
||||
All AniList API calls go through a shared rate limiter that enforces a sliding window of 20 requests per minute. The limiter also reads AniList's `X-RateLimit-Remaining` and `Retry-After` response headers and pauses requests when the server signals throttling. This applies to both episode tracking and cover art fetching.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"anilist": {
|
||||
"enabled": true,
|
||||
"accessToken": "",
|
||||
"characterDictionary": {
|
||||
"maxLoaded": 3,
|
||||
"profileScope": "all",
|
||||
"collapsibleSections": {
|
||||
"description": false,
|
||||
"characterInformation": false,
|
||||
"voicedBy": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| `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: `""`) |
|
||||
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
|
||||
| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 1–8760) |
|
||||
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) |
|
||||
| `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 |
|
||||
|
||||
There is no `characterDictionary.enabled` key: character dictionary sync is enabled by `subtitleStyle.nameMatchEnabled`. 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
|
||||
|
||||
| Command | Description |
|
||||
| ----------------------- | ------------------------------------------------------------- |
|
||||
| `--anilist-setup` | Open AniList setup/auth flow helper window |
|
||||
| `--anilist-status` | Print current token resolution state and retry queue counters |
|
||||
| `--anilist-logout` | Clear stored AniList token from local persisted state |
|
||||
| `--anilist-retry-queue` | Process one ready retry queue item immediately |
|
||||
Character dictionary settings live under `anilist.characterDictionary` and are covered on the [Character dictionary](/character-dictionary) page. See [Configuration](/configuration#anilist) for defaults.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Cover art missing:** Cover art is fetched on a best-effort basis using title matching. If the filename is hard to parse, the search may return no results. The fetcher retries after 5 minutes.
|
||||
- **Encryption unavailable on Linux:** If you see warnings about safeStorage, try `--password-store=basic_text` as a workaround, or start your desktop keyring (gnome-keyring, KWallet).
|
||||
**No update after an episode.** Check that `anilist.enabled` is `true` and that you watched at least 85% of the episode.
|
||||
|
||||
## Related
|
||||
**"AniList update not possible."** The show is marked Completed, Paused, Dropped, or Rewatching, or it is not on your list and the title match was not exact. Add the show to your Planning or Watching list, or pin it with the character dictionary's AniList override, then mark the episode watched again.
|
||||
|
||||
- [Character Dictionary](/character-dictionary) - AniList-powered character name dictionary for Yomitan
|
||||
- [Configuration Reference](/configuration) - full config options
|
||||
- [Jellyfin Integration](/jellyfin-integration) - media server integration
|
||||
**Wrong show or episode.** Install guessit and make sure it is on your `PATH`. Unusual file names parse poorly without it.
|
||||
|
||||
**Token errors.** Run `subminer app --anilist-status`. If the token is invalid, run `--anilist-logout`, then `--anilist-setup`.
|
||||
@@ -1,53 +1,39 @@
|
||||
# AniSkip integration
|
||||
|
||||
SubMiner looks up anime intro timings from [AniSkip](https://aniskip.com) so you can jump past the OP with one key.
|
||||
|
||||
Intro detection runs in the SubMiner app over the mpv IPC socket. It works whenever the overlay is connected to mpv, not only at launch, and covers every local file loaded during the session including playlist advances.
|
||||
SubMiner looks up opening timestamps on [AniSkip](https://aniskip.com) so you can skip an anime's intro with one key.
|
||||
|
||||
## Setup
|
||||
|
||||
AniSkip is enabled by default. Disable it or change the skip key in your config:
|
||||
AniSkip is on by default. To turn it off or change the key:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mpv": {
|
||||
"aniskipEnabled": true, // default: true
|
||||
"aniskipEnabled": true,
|
||||
"aniskipButtonKey": "TAB",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Both settings hot-reload: changing them in your config takes effect immediately without restarting playback or mpv.
|
||||
|
||||
For best title and episode detection, install [`guessit`](https://github.com/guessit-io/guessit):
|
||||
Both settings apply immediately, without restarting mpv. For better title and episode detection, install [guessit](https://github.com/guessit-io/guessit):
|
||||
|
||||
```bash
|
||||
python3 -m pip install --user guessit
|
||||
```
|
||||
|
||||
Without `guessit`, SubMiner falls back to its own filename parser. That handles the usual release naming, but unusual formats slip past it.
|
||||
## Usage
|
||||
|
||||
## How it works
|
||||
When a local file loads, SubMiner reads the title and episode from the file name, finds the show on MyAnimeList, and asks AniSkip for the intro's timestamps. Streams and URLs are skipped.
|
||||
|
||||
On each local file load:
|
||||
If AniSkip has an intro, SubMiner adds `AniSkip Intro Start` and `AniSkip Intro End` chapters. When the intro starts, mpv shows "You can skip by pressing TAB" (with your key) for 3 seconds. Press the key any time during the intro to jump to its end.
|
||||
|
||||
1. SubMiner infers the anime title, season, and episode number from the filename and path (using `guessit` if available, otherwise the built-in parser). Remote URLs are skipped entirely.
|
||||
2. The title is matched against MyAnimeList to resolve a MAL id.
|
||||
3. SubMiner queries the AniSkip API for an OP skip interval for that MAL id and episode.
|
||||
4. If an interval is found, SubMiner adds `AniSkip Intro Start` and `AniSkip Intro End` chapter markers to the current file and binds the skip key (`mpv.aniskipButtonKey`, default `TAB`).
|
||||
5. At the start of the intro, an OSD prompt appears for 3 seconds: `You can skip by pressing TAB` (reflects your configured key). Pressing the key at any point during the intro seeks to the intro end.
|
||||
|
||||
When a custom key (other than `TAB` or `y-k`) is configured, the legacy `y-k` chord is also bound as a fallback skip trigger.
|
||||
|
||||
Results are cached per file for the app session. Only a definitive "no intro found" is cached, so a failed lookup gets retried on the next load rather than sticking. If mpv reloads the same file, SubMiner re-applies the chapter markers without hitting the API again.
|
||||
With a custom key other than `TAB` or `y-k`, `y-k` also skips.
|
||||
|
||||
## Triggering from mpv
|
||||
|
||||
AniSkip actions are also reachable from mpv script-messages:
|
||||
| Command | What it does |
|
||||
| ----------------------------------------- | ------------------------------------------------------- |
|
||||
| `script-message subminer-skip-intro` | Skip to the end of the intro |
|
||||
| `script-message subminer-aniskip-refresh` | Look up the current file again, ignoring cached results |
|
||||
|
||||
| Command | Effect |
|
||||
| ------- | ------ |
|
||||
| `script-message subminer-skip-intro` | Skip to the intro end immediately (same as pressing the key) |
|
||||
| `script-message subminer-aniskip-refresh` | Force a fresh lookup for the current file, discarding any cached result |
|
||||
|
||||
The SubMiner app handles both over the IPC socket.
|
||||
Use `subminer-aniskip-refresh` after a lookup failed or matched the wrong show.
|
||||
+171
-311
@@ -1,51 +1,34 @@
|
||||
# Anki integration
|
||||
|
||||
SubMiner uses the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on to create and update Anki cards with sentence context, audio, and screenshots.
|
||||
This project is built primarily for [Kiku](https://kiku.youyoumu.my.id/) and [Lapis](https://github.com/donkuri/lapis) note types, including sentence-card and field-grouping behavior.
|
||||
SubMiner talks to Anki through the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on. It fills new cards with the sentence, an audio clip, and a screenshot, and can create sentence cards and merge duplicate words. It is built for the [Lapis](https://github.com/donkuri/lapis), [Kiku](https://kiku.youyoumu.my.id/), and [Senren](https://github.com/BrenoAqua/Senren) note types, but works with any note type once you map its fields.
|
||||
|
||||
::: tip New to these terms?
|
||||
|
||||
- **Anki** is the flashcard app where your study cards live.
|
||||
- **AnkiConnect** is a free add-on that lets other programs (like SubMiner) talk to Anki over a local connection. SubMiner needs it installed to add or edit cards.
|
||||
- A **note type** (also called a "model") is the template that defines what a card looks like - for example the Kiku or Lapis templates many Japanese learners use.
|
||||
- A **field** is one labeled slot in that template, such as `Sentence`, `Expression`, or `Picture`. SubMiner fills these fields when it mines a card.
|
||||
:::
|
||||
For the day-to-day flow, see [Mining workflow](/mining-workflow). Every key on this page, with its default, is listed in the [AnkiConnect config reference](/configuration#ankiconnect).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Install [Anki](https://apps.ankiweb.net/).
|
||||
2. Install the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on (code: `2055492159`).
|
||||
3. Keep Anki running while using SubMiner.
|
||||
2. Install AnkiConnect (add-on code `2055492159`).
|
||||
3. Install FFmpeg and make sure it is on your `PATH`. SubMiner uses it for audio and images.
|
||||
4. Keep Anki running while you mine.
|
||||
|
||||
AnkiConnect listens on `http://127.0.0.1:8765` by default. If you changed the port in AnkiConnect's settings, update `ankiConnect.url` in your SubMiner config.
|
||||
If you changed AnkiConnect's port, set `ankiConnect.url` to match.
|
||||
|
||||
AnkiConnect and Kiku/Senren settings follow the [configuration validation rules](/configuration#configuration-file): invalid values produce a warning and fall back to the option's default. Use JSON booleans such as `true`, not strings such as `"true"`, and a positive number for `ankiConnect.pollingRate`.
|
||||
## How cards get filled
|
||||
|
||||
## Auto-enrichment transport
|
||||
When Yomitan or Hachidori adds a note, SubMiner fills the sentence, audio, image, and MiscInfo fields. It finds new notes in one of two ways:
|
||||
|
||||
When you add a word via Yomitan, SubMiner detects the new card and fills in the sentence, audio, and image fields automatically. Two detection methods are available:
|
||||
- **Proxy (default).** SubMiner runs a local AnkiConnect-compatible server. The dictionary sends notes through it, and SubMiner fills each one right after Anki accepts it.
|
||||
- **Polling.** With `ankiConnect.proxy.enabled` set to `false`, SubMiner asks AnkiConnect for recently added notes every `ankiConnect.pollingRate` milliseconds.
|
||||
|
||||
**Proxy mode** (default) - SubMiner runs a small local server between Yomitan and Anki. Yomitan sends the new card to SubMiner, SubMiner fills in the media fields, and the finished card goes on to Anki. There is no polling delay.
|
||||
Set `ankiConnect.behavior.autoUpdateNewCards` to `false` to stop automatic filling and update cards by hand with `Ctrl/Cmd+V` instead.
|
||||
|
||||
**Polling mode** (fallback, when the proxy is disabled) - SubMiner asks AnkiConnect every few seconds whether new cards showed up, then fills them in. Less to configure, at the cost of roughly a 3 second delay.
|
||||
`ankiConnect.deck` limits enrichment and duplicate checks to one deck. If it is empty, SubMiner uses Yomitan's mining deck when it can read it, and otherwise searches all decks.
|
||||
|
||||
Use proxy mode unless your Yomitan runs in a browser rather than the bundled instance, in which case polling is the simpler path.
|
||||
|
||||
In both modes, the enrichment workflow is the same:
|
||||
|
||||
1. Checks if a duplicate expression already exists (for field grouping).
|
||||
2. Updates the sentence field with the current subtitle.
|
||||
3. Generates and uploads audio and image media.
|
||||
4. Writes metadata to the miscInfo field.
|
||||
|
||||
Polling mode uses the query `"deck:<ankiConnect.deck>" added:1` to find recently added cards. If no deck is configured, it searches all decks (`added:1`). In Settings, the AnkiConnect deck dropdown auto-fills and persists Yomitan's current mining deck when available, then falls back to the decks reported by AnkiConnect; stats-dashboard mining also falls back to Yomitan's mining deck when `ankiConnect.deck` is empty.
|
||||
Known-word sync scope is controlled by `ankiConnect.knownWords.decks`.
|
||||
|
||||
### Proxy mode setup (Yomitan / texthooker)
|
||||
### Proxy mode setup (Yomitan / texthooker) {#proxy-mode-setup-yomitan-texthooker}
|
||||
|
||||
```jsonc
|
||||
"ankiConnect": {
|
||||
"url": "http://127.0.0.1:8765", // real AnkiConnect
|
||||
"url": "http://127.0.0.1:8765",
|
||||
"proxy": {
|
||||
"enabled": true,
|
||||
"host": "127.0.0.1",
|
||||
@@ -55,371 +38,248 @@ Known-word sync scope is controlled by `ankiConnect.knownWords.decks`.
|
||||
}
|
||||
```
|
||||
|
||||
Then point Yomitan/clients to `http://127.0.0.1:8766` instead of `8765`.
|
||||
Clients must send notes to the proxy (`http://127.0.0.1:8766` here), not to AnkiConnect directly.
|
||||
|
||||
When SubMiner loads the bundled Yomitan extension, it also attempts to update the **currently active Yomitan profile**'s Anki server to the active SubMiner endpoint (falling back to `profiles[0]` if the active-profile index is invalid):
|
||||
- **Bundled Yomitan.** SubMiner sets the active Yomitan profile's Anki server for you. With the proxy on, it always points the profile at the proxy. With the proxy off, it sets `ankiConnect.url`, but only if the profile's server is blank or the stock `http://127.0.0.1:8765`.
|
||||
- **Browser Yomitan or other clients.** Set the Anki server to the proxy URL yourself. To leave your main profile alone, create a separate Yomitan profile for SubMiner, set its Anki server (Settings, Anki) to the proxy URL, and make it active while you mine.
|
||||
- **Hachidori.** SubMiner routes Hachidori to the proxy while it is active. Keep the proxy on for screenshots and sentence audio.
|
||||
|
||||
- proxy URL when `ankiConnect.proxy.enabled` is `true`
|
||||
- direct `ankiConnect.url` when proxy mode is disabled
|
||||
### Hachidori settings from SubMiner
|
||||
|
||||
To avoid clobbering custom setups, this auto-update only changes the profile when its current server is blank or the stock Yomitan default (`http://127.0.0.1:8765`).
|
||||
With the [Hachidori backend](/usage#hachidori-setup), SubMiner fills Hachidori's first Anki template from your `ankiConnect` settings on startup and whenever you open Hachidori Settings:
|
||||
|
||||
For browser-based Yomitan or other external clients (for example Texthooker in a normal browser profile), set their Anki server to the same proxy URL separately: `http://127.0.0.1:8766` (or your configured `proxy.host` + `proxy.port`).
|
||||
- The deck always follows `ankiConnect.deck`, because polling only looks for new cards in that deck.
|
||||
- While the template still has Hachidori's default `hachidori` tag, the configured tags are added ahead of it so cards carry both. Tags you edit in Hachidori settings are kept as written.
|
||||
- Missing word, sentence, pronunciation-audio, and picture mappings are filled with fields that exist in Anki. Pronunciation uses `fields.wordAudio`, or `fields.audio` when no word-audio field is set.
|
||||
- If the note type is unset, SubMiner picks the one note type that has your word and sentence fields. Enabled Lapis, Kiku, or Senren narrows the search. A fresh mapping also gets Hachidori's matching preset for readings, definitions, and other known fields.
|
||||
|
||||
### Browser/Yomitan external setup (separate profile)
|
||||
Apart from the deck, SubMiner only fills missing settings. Custom tags, field mappings, advanced templates, and extra templates stay as you set them. If several note types match, pick one in Hachidori Settings. If Anki was closed, start it and open Hachidori Settings again to retry.
|
||||
|
||||
If you want SubMiner to use proxy mode without touching your main/default Yomitan profile, create or select a separate Yomitan profile just for SubMiner and set its Anki server to the proxy URL.
|
||||
Sentence audio, image timing, translation, metadata, and field grouping stay under SubMiner's control. Pronunciation sources are set in Hachidori. Linking an external dictionary host does not change any of this.
|
||||
|
||||
That profile isolation gives you both benefits:
|
||||
### Proxy troubleshooting
|
||||
|
||||
- SubMiner can auto-enrich immediately via proxy.
|
||||
- Your default Yomitan profile keeps its existing Anki server setting.
|
||||
If cards are not getting filled:
|
||||
|
||||
In Yomitan, go to Settings → Profile and:
|
||||
1. Check that the proxy is listening while SubMiner runs:
|
||||
|
||||
1. Create a profile for SubMiner (or choose one dedicated profile).
|
||||
2. Open Anki settings for that profile.
|
||||
3. Set server to `http://127.0.0.1:8766` (or your configured proxy URL).
|
||||
4. Save and make that profile active when using SubMiner.
|
||||
```bash
|
||||
ss -ltnp | grep 8766
|
||||
```
|
||||
|
||||
This is only for non-bundled, external/browser Yomitan or other clients. The bundled profile auto-update logic only targets the active profile when its server is blank or still default.
|
||||
2. Check that requests pass through to Anki:
|
||||
|
||||
### Proxy troubleshooting (quick checks)
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:8766 \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"action":"version","version":2}'
|
||||
```
|
||||
|
||||
If auto-enrichment appears to do nothing:
|
||||
|
||||
1. Confirm proxy listener is running while SubMiner is active:
|
||||
|
||||
```bash
|
||||
ss -ltnp | rg 8766
|
||||
```
|
||||
|
||||
2. Confirm requests can pass through the proxy:
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:8766 \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"action":"version","version":2}'
|
||||
```
|
||||
|
||||
3. Check the log sinks in `~/.config/SubMiner/logs/`:
|
||||
|
||||
- App runtime log: `app-YYYY-MM-DD.log`
|
||||
- Launcher log: `launcher-YYYY-MM-DD.log`
|
||||
- mpv log: `mpv-YYYY-MM-DD.log`
|
||||
|
||||
4. Check that the config JSONC parses and the logging shape is right:
|
||||
|
||||
```jsonc
|
||||
"logging": {
|
||||
"level": "debug"
|
||||
}
|
||||
```
|
||||
|
||||
`"logging": "debug"` is invalid for current schema and can break reload/start behavior.
|
||||
3. Read the app log (`app-YYYY-MM-DD.log`) in the logs folder. See [Troubleshooting](/troubleshooting) for where logs live.
|
||||
|
||||
## Field mapping
|
||||
|
||||
SubMiner maps its data to your Anki note fields. Configure these under `ankiConnect.fields`:
|
||||
`ankiConnect.fields` maps SubMiner's data to fields on your note type.
|
||||
|
||||
| Key | Receives |
|
||||
| ------------------ | ------------------------------------------------------------------------- |
|
||||
| `fields.word` | The mined word |
|
||||
| `fields.audio` | Sentence audio cut from the video |
|
||||
| `fields.wordAudio` | Read only: Yomitan's word audio, used to time animated images (see below) |
|
||||
| `fields.image` | Screenshot or animated clip |
|
||||
| `fields.sentence` | Subtitle text |
|
||||
| `fields.miscInfo` | Text from `ankiConnect.metadata.pattern` |
|
||||
|
||||
```jsonc
|
||||
"ankiConnect": {
|
||||
"fields": {
|
||||
"word": "Expression", // mined word / expression text
|
||||
"audio": "SentenceAudio", // sentence audio clip cut from the video
|
||||
"wordAudio": "ExpressionAudio", // existing Yomitan word audio, read for animation sync
|
||||
"image": "Picture", // screenshot or animated clip
|
||||
"sentence": "Sentence", // subtitle text
|
||||
"miscInfo": "MiscInfo" // metadata (filename, timestamp)
|
||||
"audio": "SentenceAudio",
|
||||
"sentence": "Sentence"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`fields.audio` receives the **sentence** audio SubMiner cuts from the video, not word audio. Yomitan writes its own dictionary audio when you mine, so point this at a separate field such as `SentenceAudio` to keep the two apart. The built-in default is still `ExpressionAudio`, which collides with Yomitan on note types that use that field for word audio.
|
||||
Field names are matched case-insensitively. A mapped field that is missing from the note type is skipped.
|
||||
|
||||
Field names are matched against your Anki note type case-insensitively (an exact match wins, then a lowercase comparison). If a configured field does not exist on the note type, SubMiner skips it without error.
|
||||
Hachidori prepares downloadable word audio before it saves a note through the proxy, so the animated image delay works on the first mine. Set a downloadable pronunciation source in Hachidori's Audio settings. Browser speech cannot be saved to Anki. Without word audio, Hachidori shows a warning and the card gets no word-audio hold.
|
||||
|
||||
`fields.wordAudio` selects the existing dictionary-audio field used to calculate the animated image's opening freeze. This mapping only reads audio; `fields.audio` still controls where generated sentence audio is written. See [config.example.jsonc](/config.example.jsonc) for defaults.
|
||||
`fields.audio` gets sentence audio, not word audio. Yomitan writes its own dictionary audio into your note (often `ExpressionAudio`), so keep `fields.audio` pointed at a separate field such as the default `SentenceAudio`. Pointing it at Yomitan's word-audio field would overwrite that audio.
|
||||
|
||||
These mappings always control normal word-card enrichment, including Yomitan proxy/polling updates and manual clipboard updates. Enabling Lapis or Kiku does not replace the configured word-card sentence and audio fields with `Sentence` and `SentenceAudio`. The dedicated sentence-card and audio-card shortcuts still use those Lapis/Kiku field names.
|
||||
`ankiConnect.tags` adds tags to every mined or updated card. Set it to `[]` to add none.
|
||||
|
||||
Two related options live alongside `fields`: `ankiConnect.deck` (target deck; empty falls back as described above) and `ankiConnect.tags` (tags added to mined cards, default `["SubMiner"]`; set `[]` to disable tagging). The `miscInfo` content is controlled by `ankiConnect.metadata.pattern` (default `[SubMiner] %f (%t)`; tokens: `%f` filename, `%F` filename with extension, `%t` timestamp, `%T` timestamp with milliseconds, `<br>` newline).
|
||||
`ankiConnect.metadata.pattern` builds the MiscInfo text. Tokens: `%f` file name, `%F` file name with extension, `%t` timestamp, `%T` timestamp with milliseconds, `<br>` line break.
|
||||
|
||||
### Minimal config
|
||||
## Media
|
||||
|
||||
If you only want sentence and audio on your cards:
|
||||
| Key | What it does |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `media.generateAudio` | Cut sentence audio (MP3) from the subtitle's start and end time |
|
||||
| `media.audioPadding` | Seconds added before and after the clip |
|
||||
| `media.fallbackDuration` | Clip length when the subtitle has no timing |
|
||||
| `media.maxMediaDuration` | Longest allowed clip, in seconds (`0` removes the cap) |
|
||||
| `media.normalizeAudio` | Normalize clip loudness |
|
||||
| `media.mirrorMpvVolume` | Scale the clip by mpv's current volume, so quiet playback gives quiet clips |
|
||||
| `media.generateImage` | Capture an image |
|
||||
| `media.imageType` | `static` for one frame, `avif` for an animated clip of the line |
|
||||
| `media.imageFormat` | Static format: `jpg`, `png`, or `webp` |
|
||||
| `media.imageQuality` | Static image quality |
|
||||
| `media.imageMaxWidth` / `Height` | Static size limit (`0` keeps source size) |
|
||||
| `media.animatedFps` | Animated clip frame rate |
|
||||
| `media.animatedMaxWidth` / `Height` | Animated size limit (`0` keeps aspect ratio) |
|
||||
| `media.animatedCrf` | Animated quality, `0` to `63`, lower is better |
|
||||
| `media.syncAnimatedImageToWordAudio` | Hold the first frame for the length of the word audio in `fields.wordAudio`, so the motion starts with the sentence audio |
|
||||
| `media.reviewTiming` | Pause and let you adjust the clip before media is made (see below) |
|
||||
|
||||
```jsonc
|
||||
"ankiConnect": {
|
||||
"enabled": true,
|
||||
"fields": {
|
||||
"sentence": "Sentence",
|
||||
"audio": "SentenceAudio"
|
||||
}
|
||||
}
|
||||
```
|
||||
Animated AVIF needs an FFmpeg build with an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`).
|
||||
|
||||
## Media generation
|
||||
Media settings apply to the next card without a restart.
|
||||
|
||||
SubMiner shells out to FFmpeg for audio clips and screenshots, so FFmpeg has to be installed and on `PATH`.
|
||||
### Review media timing
|
||||
|
||||
For remote streams such as Jellyfin playback, SubMiner downloads the clip's time window once into a temporary Matroska file (a stream copy, no re-encoding) and reads the timing review waveform, audio preview, audio, and image from that file instead of fetching the stream again for each step. The window covers the clip plus padding, plus the visible timeline in timing review, and grows when you reveal more of the timeline. It is deleted when a different window replaces it, after ten minutes without use, or when SubMiner exits. If the download fails, media generation reads the remote stream directly as before.
|
||||
With `media.reviewTiming` on, SubMiner pauses before making media for word, sentence, and audio cards and opens a review dialog. You can also toggle it for the current session with **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`). Clipboard updates and stats-dashboard mining skip the review.
|
||||
|
||||
### Audio
|
||||
Playback stays paused while the dialog is open, even if the popup or hover that paused it goes away. When the dialog closes, playback resumes if it was playing before, or if the popup closed in the meantime. A popup that is still open keeps it paused.
|
||||
|
||||
Audio is extracted from the video file using the subtitle's start and end timestamps. Padding is opt-in; keep it at `0` when you want sentence audio to start exactly at the mined sentence.
|
||||
The dialog shows the clip over a speech waveform. When the waveform loads, an untouched clip end moves back to just after the last speech in the line. The orange bars still mark the subtitle's original start and end.
|
||||
|
||||
```jsonc
|
||||
"ankiConnect": {
|
||||
"media": {
|
||||
"generateAudio": true,
|
||||
"normalizeAudio": true, // normalize generated clip loudness
|
||||
"mirrorMpvVolume": true, // apply the current mpv volume level
|
||||
"reviewTiming": false, // review and adjust timing before media generation
|
||||
"audioPadding": 0, // optional seconds before and after subtitle timing
|
||||
"maxMediaDuration": 30 // cap total duration in seconds
|
||||
}
|
||||
}
|
||||
```
|
||||
During preview, the yellow cursor follows the audio player's position through the selected range and stays at the end when playback finishes. Buffering pauses the cursor too. If the player stops reporting progress for 15 seconds, the preview stops so you can retry.
|
||||
|
||||
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream. Generated sentence audio is loudness-normalized to -23 LUFS by default during extraction; set `normalizeAudio` to `false` to keep raw source loudness. When subtitle timing is missing, clips fall back to `media.fallbackDuration` seconds (default `3`). Changing these settings applies to the next extraction without restarting SubMiner.
|
||||
| Action | How |
|
||||
| ------------------------ | --------------------------------------------------------------------- |
|
||||
| Trim | Drag either edge, or click the waveform to move the nearer edge there |
|
||||
| Nudge an edge | Arrow keys on a focused edge (100 ms, `Shift` for 500 ms) |
|
||||
| Slide the clip | Drag the middle |
|
||||
| Show more timeline | Earlier / Later |
|
||||
| Pick the screenshot | Screenshot slider or Frame buttons (static images only) |
|
||||
| Add previous / next line | `P` / `N` (`Shift+P` / `Shift+N` removes) |
|
||||
| Preview | `Space` |
|
||||
| Confirm | `Enter` |
|
||||
| Cancel | `Escape` |
|
||||
|
||||
`mirrorMpvVolume` is also enabled by default. Immediately before extracting each playback-overlay card's audio, SubMiner reads mpv's numeric `volume` and applies mpv's cubic software-volume curve after loudness normalization. For example, mpv volume `50` produces `0.5³ = 0.125` gain. Amplified output above mpv volume `100` is limited to a `-1 dBFS` ceiling before MP3 encoding to prevent clipping. It ignores mpv's separate `mute` state. If the volume property is missing, invalid, or unavailable, extraction continues with unity scaling; disabling this option skips the query and volume filter. Changing this setting applies to the next extraction without restarting SubMiner. YouTube cards queued for a background media-cache download retain the volume captured when the card was mined. Stats-dashboard mining does not currently have access to the active mpv property client, so it does not apply mpv volume scaling.
|
||||
The confirmed range is used as is, with no extra padding. Added lines go into the sentence field. Reset restores the original timing and removes added lines.
|
||||
|
||||
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
|
||||
When you cancel, you can go back to editing, keep the original timing, create the card without media, or discard it. Discard deletes the Yomitan note or audio card, and skips creation for a sentence card.
|
||||
|
||||
Overlay and stats-dashboard mining use the same `media.maxMediaDuration` limit. See the [configuration example](/config.example.jsonc) for its default and how to disable the cap.
|
||||
#### With a controller
|
||||
|
||||
Set `media.reviewTiming` to `true` to pause playback and check the clip before its media is generated. It applies to word, sentence, and audio cards.
|
||||
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each button shows its controller shortcut. It works with or without keyboard-only mode, and the controller's usual actions pause until the dialog closes. Buttons that were already held when the dialog opened, such as the mine button, wait for a fresh press.
|
||||
|
||||
The review opens on the subtitle range plus your configured audio padding. Subtitles usually hang around after the dialogue has stopped, so once the waveform loads, an untouched clip end pulls back to just after the last speech in the line. The Line end rail still marks the original subtitle timing, Reset puts it back, and a line whose speech runs right through its end is left alone.
|
||||
`A`, `B`, `X` and `Y` mean the letters printed on your controller. Pick your controller's **Button Style** in `Alt+C`: choose Nintendo if A is on the right and B at the bottom (Switch-style and many 8BitDo pads). On PlayStation, `A` is ╳, `B` ○, `X` □ and `Y` △, and the shortcuts on screen show those symbols.
|
||||
|
||||
**Adjusting the clip.** Drag either edge to trim, drag the middle to slide the whole clip without changing its length, or click anywhere on the waveform to snap the nearer edge there. A focused edge also moves with the arrow keys: 100 ms per press, or 500 ms with Shift. The 100 ms buttons do the same thing. Earlier and Later each reveal two more seconds of timeline without moving the selection.
|
||||
Hold `L1` or `R1` and press the D-pad for the combos. `L1` covers the clip start and the lines before it, and `R1` the clip end and the lines after it. D-pad actions repeat while held.
|
||||
|
||||
**Choosing the screenshot.** With still images enabled, drag the screenshot slider or use the Frame buttons to choose a video frame independently of the audio. Earlier and Later reveal more time for both sliders. The image follows the audio midpoint until you pick a frame, then stays fixed while you trim audio. Reset below the image restores automatic selection; the audio Reset affects only the audio. The screenshot slider also supports arrow keys, Home, and End.
|
||||
This is the default layout:
|
||||
|
||||
The picker supports local video and seekable remote streams, including Jellyfin, without seeking the main player. A selected frame must load before you can confirm; if it fails, choose another frame or Reset. The picker is hidden when image generation is disabled or animated AVIF is selected. Animated images continue to use the reviewed audio range.
|
||||
| Controller | Editing | After Cancel |
|
||||
| ----------------------- | -------------------------------- | ------------------- |
|
||||
| `A` | Use this timing | Use original timing |
|
||||
| `B` | Cancel | Keep editing |
|
||||
| `X` | Play selection | Without media |
|
||||
| `Y` | Reset timing | Discard |
|
||||
| D-pad left / right | Earlier −2s / Later +2s | |
|
||||
| D-pad up / down | Previous / next screenshot frame | |
|
||||
| `L1` + D-pad left/right | Start −0.1s / +0.1s | |
|
||||
| `L1` + D-pad up/down | Add / remove a previous line | |
|
||||
| `R1` + D-pad left/right | End −0.1s / +0.1s | |
|
||||
| `R1` + D-pad up/down | Add / remove a next line | |
|
||||
| `L1` + `R1` | Reset the screenshot frame | |
|
||||
|
||||
**Keys.** Space previews the selection with a playhead sweeping the clip. The preview ends when the hidden player has actually played the last sample, so Bluetooth output latency does not clip the tail. Enter confirms and Escape cancels.
|
||||
To change it, open `Alt+C` and expand **Review media timing**. Click an action's pencil, then press a button, or hold one button and press another to make a combo; the first button becomes the one you hold. Any button can be a hold button. Changes are saved per controller, and **Reset all** brings back the default layout.
|
||||
|
||||
**The waveform.** SubMiner reads a center channel when one carries dialogue and falls back to a mono mix otherwise, keeps only the 250 to 3500 Hz speech band, and draws each slice's loudness against the clip's own noise floor. Steady background music flattens out and dialogue stands up, which makes it much easier to tell adjacent lines apart. The mined subtitle appears as a tinted band with labeled line-start and line-end rails. If waveform analysis fails, the timing controls still work.
|
||||
Nudges, Earlier/Later, lines and frames repeat while held whichever buttons they are on. A row warns you when two actions on the same step share an input, or when an action uses a button alone that other actions hold.
|
||||
|
||||
The range you confirm is used exactly as-is; SubMiner does not add audio padding a second time. Static screenshots take its midpoint, and animated AVIF clips cover the whole range.
|
||||
### Update behavior
|
||||
|
||||
**Pulling in adjacent lines.** Press `P` or `N`, or use the Prev and Next steppers above the sentence preview, to add the previous or next subtitle line. Repeat for as many lines as exist. Shift+`P` and Shift+`N` remove them again. The sentence preview lists every included line with the mined one highlighted, so you always see the sentence field before confirming. The clip bounds and the waveform rails follow the outermost added line, keeping the review's audio padding.
|
||||
| Key | What it does |
|
||||
| ----------------------------- | ---------------------------------------------------- |
|
||||
| `behavior.overwriteAudio` | Replace existing audio instead of adding to it |
|
||||
| `behavior.overwriteImage` | Replace the existing image instead of adding to it |
|
||||
| `behavior.mediaInsertMode` | `append` or `prepend` new media when not overwriting |
|
||||
| `behavior.autoUpdateNewCards` | Fill new Yomitan notes automatically |
|
||||
| `behavior.highlightWord` | Bold the mined word in the sentence field |
|
||||
| `behavior.notificationType` | `overlay`, `system`, `both`, or `none` |
|
||||
|
||||
Confirming writes the combined lines to the sentence field. Reset drops the added lines along with any timing changes. Adjacent lines come from the parsed subtitle track when one is loaded; otherwise you only get lines that already played. A clip capped by `media.maxMediaDuration` still keeps the full combined sentence even when the audio cannot stretch to cover every added line.
|
||||
Manual clipboard updates (`Ctrl/Cmd+V`) always replace the sentence audio, whatever `overwriteAudio` says.
|
||||
|
||||
**Canceling.** You can go back to editing, finish with the original timing, create the card without audio or an image, or discard it. Discard deletes an existing Yomitan or audio card, and skips creation entirely for a direct sentence card. A failed audio preview does not block confirmation or card creation.
|
||||
## Sentence cards (Lapis) {#sentence-cards-lapis}
|
||||
|
||||
Clipboard updates and stats-dashboard mining never open timing review. The option is off by default and hot-reloads. **Review Media Timing** in the runtime options palette (`Ctrl/Cmd+Shift+O`) toggles it for the current session.
|
||||
|
||||
If SubMiner closes the overlay while a timing review is still loading, it cancels pending setup and modal retries and restores playback if the review paused it. A new timing review can start after the overlay reopens.
|
||||
|
||||
### Screenshots (static)
|
||||
|
||||
A single frame is captured at the current playback position.
|
||||
|
||||
```jsonc
|
||||
"ankiConnect": {
|
||||
"media": {
|
||||
"generateImage": true,
|
||||
"imageType": "static",
|
||||
"imageFormat": "jpg", // "jpg", "png", or "webp"
|
||||
"imageQuality": 92, // 1–100
|
||||
"imageMaxWidth": 0, // 0 = preserve source resolution
|
||||
"imageMaxHeight": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Animated clips (AVIF)
|
||||
|
||||
SubMiner can produce an animated AVIF spanning the subtitle duration instead of a still frame.
|
||||
|
||||
```jsonc
|
||||
"ankiConnect": {
|
||||
"media": {
|
||||
"generateImage": true,
|
||||
"imageType": "avif",
|
||||
"animatedFps": 10,
|
||||
"animatedMaxWidth": 640,
|
||||
"animatedMaxHeight": 0, // 0 = preserve aspect ratio
|
||||
"animatedCrf": 35 // 0–63, lower = better quality
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`) in your FFmpeg build. Generation timeout is 60 seconds. `media.syncAnimatedImageToWordAudio` (default `true`) prepends a frozen first frame matching the existing audio duration in `fields.wordAudio`, so the motion starts together with the sentence audio. The freeze is baked into the image when mined; changing the mapping does not repair previously generated images.
|
||||
|
||||
### Behavior options
|
||||
|
||||
```jsonc
|
||||
"ankiConnect": {
|
||||
"behavior": {
|
||||
"overwriteAudio": true, // replace existing audio, or append
|
||||
"overwriteImage": true, // replace existing image, or append
|
||||
"mediaInsertMode": "append", // "append" or "prepend" to field content
|
||||
"autoUpdateNewCards": true, // auto-update when new card detected
|
||||
"highlightWord": true, // bold the mined word inside the sentence field
|
||||
"notificationType": "overlay" // "overlay", "system", "both", or "none"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`both` now means overlay + system notification. `osd` and `osd-system` are legacy config-file-only values; set `notificationType` to `"osd-system"` in `config.jsonc` if you previously used `both` and want to keep mpv OSD + system notifications. The Settings window shows `osd` or `osd-system` when already configured, but only offers `overlay`, `system`, `both`, and `none` as normal choices.
|
||||
|
||||
When media is available, mined-card overlay and system notifications include the same current-frame thumbnail.
|
||||
|
||||
`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated sentence audio in `ankiConnect.fields.audio`, even when `overwriteAudio` is disabled.
|
||||
|
||||
## Sentence cards (Lapis)
|
||||
|
||||
SubMiner can create standalone sentence cards (without a word/expression) using a separate note type. This is designed for use with [Lapis](https://github.com/donkuri/Lapis) and similar sentence-focused note types.
|
||||
|
||||
::: warning Required config
|
||||
Sentence card creation and audio card marking require a non-empty `ankiConnect.isLapis.sentenceCardModel` naming a note type that exists in Anki (default: `"Lapis"`). If the model is empty or missing, the `Ctrl/Cmd+S` and `Ctrl/Cmd+Shift+A` shortcuts will not create cards.
|
||||
:::
|
||||
`Ctrl/Cmd+S` creates a standalone sentence card from the current line, and `Ctrl/Cmd+Shift+S` then a digit combines several lines. The card uses the note type named in `ankiConnect.isLapis.sentenceCardModel`, which must exist in Anki. If it is empty, no card is created.
|
||||
|
||||
```jsonc
|
||||
"ankiConnect": {
|
||||
"isLapis": {
|
||||
"enabled": true,
|
||||
"sentenceCardModel": "Lapis" // default; point at your Lapis/Kiku note type
|
||||
"sentenceCardModel": "Lapis"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Trigger with the mine sentence shortcut (`Ctrl/Cmd+S` by default). The card is created directly via AnkiConnect with the sentence, audio, and image filled in.
|
||||
|
||||
The dedicated sentence-card and audio-card shortcuts use the Lapis/Kiku-compatible `Sentence` and `SentenceAudio` fields. This does not affect the configured fields used to enrich normal word cards.
|
||||
|
||||
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (1–9) to select how many recent lines to combine.
|
||||
Sentence cards and audio cards (`Ctrl/Cmd+Shift+A`) always write to the `Sentence` and `SentenceAudio` fields. Normal word cards keep using your `ankiConnect.fields` mapping.
|
||||
|
||||
## Word card type (Kiku/Lapis)
|
||||
|
||||
Word cards get a card-type flag when SubMiner fills their sentence, whether that comes from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining. By default the flag is `IsWordAndSentenceCard`; pick a different one with `ankiConnect.lapisKiku.wordCardKind`.
|
||||
When `isKiku` or `isLapis` is enabled, SubMiner sets a card-type flag on word cards it fills. Choose the flag with `ankiConnect.lapisKiku.wordCardKind`:
|
||||
|
||||
```jsonc
|
||||
"ankiConnect": {
|
||||
"isKiku": { "enabled": true },
|
||||
"lapisKiku": {
|
||||
"wordCardKind": "click" // word-and-sentence (default), click, sentence, audio, none
|
||||
}
|
||||
}
|
||||
```
|
||||
| Value | Flag |
|
||||
| ------------------- | ----------------------- |
|
||||
| `word-and-sentence` | `IsWordAndSentenceCard` |
|
||||
| `click` | `IsClickCard` |
|
||||
| `sentence` | `IsSentenceCard` |
|
||||
| `audio` | `IsAudioCard` |
|
||||
| `none` | Leaves flags alone |
|
||||
|
||||
`click` marks `IsClickCard`, `sentence` marks `IsSentenceCard`, `audio` marks `IsAudioCard`, and `none` leaves the flags untouched for templates that manage them elsewhere. Whichever flag is chosen, the other card-type flags are cleared so the note never claims two card types. The setting is only read when `isKiku` or `isLapis` is enabled, and cards mined with Mine Sentence or Mine Audio keep their own flag.
|
||||
The other card-type flags are cleared. Sentence cards and audio cards keep their own flag.
|
||||
|
||||
## Field grouping (Kiku/Senren)
|
||||
## Field grouping (Kiku/Senren) {#field-grouping-kiku-senren}
|
||||
|
||||
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types that support grouped fields: [Kiku](https://github.com/youyoumu/kiku) and [Senren](https://github.com/BrenoAqua/Senren) (which calls the feature scene switching).
|
||||
When you mine a word that already has a card, SubMiner can merge the new card into the old one. The sentence, audio, image, and MiscInfo from both cards are kept as grouped entries, and the template lets you switch between them. This works with [Kiku](https://github.com/youyoumu/kiku) and [Senren](https://github.com/BrenoAqua/Senren) (which calls it [scene switching](https://github.com/BrenoAqua/Senren/blob/main/docs/scene_switching.md)).
|
||||
|
||||
Enable one of them. They write different markup to the same fields, so only one can be on. If both are enabled, Kiku is used and SubMiner logs a config warning.
|
||||
|
||||
```jsonc
|
||||
"ankiConnect": {
|
||||
"isKiku": {
|
||||
"enabled": true,
|
||||
"fieldGrouping": "manual", // "auto", "manual", or "disabled"
|
||||
"deleteDuplicateInAuto": true // delete new card after auto-merge
|
||||
"fieldGrouping": "manual",
|
||||
"deleteDuplicateInAuto": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For Senren note types, enable `isSenren` instead. Kiku and Senren write incompatible markup into the same fields, so only one can be enabled at a time; if both are enabled, Kiku wins and a config warning is emitted.
|
||||
For Senren, use the same keys under `isSenren`.
|
||||
|
||||
```jsonc
|
||||
"ankiConnect": {
|
||||
"isSenren": {
|
||||
"enabled": true,
|
||||
"fieldGrouping": "auto", // "auto" (default), "manual", or "disabled"
|
||||
"deleteDuplicateInAuto": true // delete new card after auto-merge
|
||||
}
|
||||
}
|
||||
```
|
||||
| `fieldGrouping` | Behavior |
|
||||
| --------------- | ------------------------------------------------------------------------------- |
|
||||
| `disabled` | No duplicate check |
|
||||
| `auto` | Merge into the existing card. With `deleteDuplicateInAuto`, delete the new card |
|
||||
| `manual` | Show both cards, let you choose which to keep and preview the merge |
|
||||
|
||||
### Modes
|
||||
Grouping runs when a new note is added while duplicates exist. With Hachidori, that is the popup's **Add anyway** choice. **Overwrite** updates the existing note in place and only gets media.
|
||||
|
||||
**Disabled** (`"disabled"`): No duplicate detection. Each card is independent.
|
||||
The manual dialog cancels itself after 90 seconds. Identical entries are not deduplicated. Press `Ctrl/Cmd+G` to run the duplicate check on the last card yourself.
|
||||
|
||||
**Auto** (`"auto"`): When a duplicate expression is found, SubMiner merges the new card into the existing one automatically. Both cards' sentences, audio clips, and images are preserved as grouped entries. If `deleteDuplicateInAuto` is true, the new card is deleted after merging.
|
||||
| Key | Action |
|
||||
| ----------- | ------------------------------------- |
|
||||
| `1` / `2` | Keep card 1 or card 2 |
|
||||
| `Enter` | Confirm |
|
||||
| `Backspace` | Back from the merge preview |
|
||||
| `Esc` | Cancel and leave both cards unchanged |
|
||||
|
||||
**Manual** (`"manual"`): A modal appears in the overlay showing both cards. You choose which card to keep, preview the merge result, then confirm. The modal has a 90-second timeout, after which it cancels automatically.
|
||||
### With a controller {#field-grouping-with-a-controller}
|
||||
|
||||
### What gets merged
|
||||
With [controller support](/usage#controller-support) on, the dialog has its own controller layout, and each control shows its controller shortcut. Like the [media timing review](#with-a-controller), it works with or without keyboard-only mode, `A`/`B`/`X`/`Y` are the printed letters (set by **Button Style** in `Alt+C`), and a button already held when the dialog opens waits for a fresh press.
|
||||
|
||||
| Field | Merge behavior |
|
||||
| -------- | ----------------------------------------------- |
|
||||
| Sentence | Both cards' sentences kept as grouped entries |
|
||||
| Audio | Both cards' `[sound:...]` entries kept |
|
||||
| Image | Both cards' images kept |
|
||||
| MiscInfo | Both cards' source info kept as grouped entries |
|
||||
| Controller | Choosing the card | Merge preview |
|
||||
| ------------------ | --------------------------------- | ----------------------- |
|
||||
| D-pad left / right | Original card / New card | |
|
||||
| D-pad up / down | | Scroll (repeats) |
|
||||
| `A` | Continue | Confirm merge |
|
||||
| `B` | Cancel | Back to the card choice |
|
||||
| `X` | Turn "Delete duplicate" on or off | |
|
||||
| `Y` | | Compact / Full |
|
||||
|
||||
Identical values from both cards are kept as separate grouped entries; the merge does not deduplicate.
|
||||
To cancel from the preview, press `B` twice, or bind the preview's **Cancel** row (unbound by default). Change any of it in the **Duplicate card (field grouping)** group of `Alt+C`, the same way as the review layout.
|
||||
|
||||
The merge markup depends on the note type. Kiku entries are wrapped in `<span data-group-id="...">` spans ordered newest first. Senren entries follow the [scene switching](https://github.com/BrenoAqua/Senren/blob/main/docs/scene_switching.md) format: sentence, sentenceFurigana, and miscInfo entries use `group` spans when ordinal order is sufficient and numbered `groupN` spans when they need an absolute scene target. Audio and pictures are appended positionally, and the number of sentenceAudio entries drives Senren's scene count. Ungrouped legacy content is wrapped into a group span on first merge, and source `groupN` spans are rebased after the kept note's existing audio scenes.
|
||||
## Config validation
|
||||
|
||||
### Keyboard shortcuts in the modal
|
||||
|
||||
| Key | Action |
|
||||
| ----------- | ---------------------------------- |
|
||||
| `1` / `2` | Select card 1 or card 2 to keep |
|
||||
| `Enter` | Confirm selection |
|
||||
| `Backspace` | Go back from the merge preview |
|
||||
| `Esc` | Cancel (keep both cards unchanged) |
|
||||
|
||||
## Full config example
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"ankiConnect": {
|
||||
"enabled": true,
|
||||
"url": "http://127.0.0.1:8765",
|
||||
"pollingRate": 3000,
|
||||
"deck": "",
|
||||
"tags": ["SubMiner"],
|
||||
"proxy": {
|
||||
"enabled": true, // default
|
||||
"host": "127.0.0.1",
|
||||
"port": 8766,
|
||||
"upstreamUrl": "http://127.0.0.1:8765",
|
||||
},
|
||||
"fields": {
|
||||
"word": "Expression",
|
||||
"audio": "SentenceAudio",
|
||||
"image": "Picture",
|
||||
"sentence": "Sentence",
|
||||
"miscInfo": "MiscInfo",
|
||||
},
|
||||
"media": {
|
||||
"generateAudio": true,
|
||||
"generateImage": true,
|
||||
"imageType": "static",
|
||||
"imageFormat": "jpg",
|
||||
"imageQuality": 92,
|
||||
"normalizeAudio": true,
|
||||
"mirrorMpvVolume": true,
|
||||
"audioPadding": 0,
|
||||
"maxMediaDuration": 30,
|
||||
},
|
||||
"behavior": {
|
||||
"overwriteAudio": true,
|
||||
"overwriteImage": true,
|
||||
"mediaInsertMode": "append",
|
||||
"autoUpdateNewCards": true,
|
||||
"notificationType": "overlay",
|
||||
},
|
||||
"metadata": {
|
||||
"pattern": "[SubMiner] %f (%t)",
|
||||
},
|
||||
"isKiku": {
|
||||
"enabled": false,
|
||||
"fieldGrouping": "disabled",
|
||||
"deleteDuplicateInAuto": true,
|
||||
},
|
||||
"isLapis": {
|
||||
"enabled": false,
|
||||
"sentenceCardModel": "Lapis",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
Invalid `ankiConnect` values produce a warning and fall back to the default. Use JSON booleans (`true`, not `"true"`) and a positive number for `pollingRate`.
|
||||
+78
-205
@@ -1,148 +1,56 @@
|
||||
# Architecture
|
||||
|
||||
This page is a contributor-facing architecture summary. Canonical internal architecture guidance lives in `docs/architecture/README.md` at the repo root.
|
||||
A contributor-facing map of how SubMiner is put together. The canonical internal guidance, including domain ownership and layering rules, is [`docs/architecture/README.md`](https://github.com/ksyasuda/SubMiner/blob/main/docs/architecture/README.md) in the repo.
|
||||
|
||||
SubMiner is split into three cooperating runtimes:
|
||||
SubMiner runs as three cooperating runtimes:
|
||||
|
||||
- Electron desktop app (`src/`) for overlay/UI/runtime orchestration.
|
||||
- Launcher CLI (`launcher/`) for mpv/app command workflows.
|
||||
- mpv Lua plugin (`plugin/subminer/main.lua` + module files) for player-side controls and IPC handoff.
|
||||
- the Electron desktop app (`src/`): overlay, UI, and runtime orchestration
|
||||
- the launcher CLI (`launcher/`): mpv and app command workflows
|
||||
- the mpv Lua plugin (`plugin/subminer/`): player-side controls and handoff to the app
|
||||
|
||||
Within the desktop app, `src/main.ts` is a composition root that wires small runtime/domain modules plus core services.
|
||||
Inside the app, `src/main.ts` is a composition root. It owns wiring and state, and delegates behavior to small runtime and domain modules that can be tested without Electron or mpv.
|
||||
|
||||
## Goals
|
||||
|
||||
- Keep behavior stable while reducing coupling.
|
||||
- Prefer small, single-purpose units that can be tested in isolation.
|
||||
- Keep `main.ts` focused on wiring and state ownership, not implementation detail.
|
||||
- Follow Unix-style composability:
|
||||
- each service does one job
|
||||
- services compose through explicit inputs/outputs
|
||||
- orchestration is separate from implementation
|
||||
|
||||
## Project structure
|
||||
## Project layout
|
||||
|
||||
```text
|
||||
launcher/ # Standalone CLI launcher wrapper and mpv helpers
|
||||
commands/ # Command modules (doctor/config/mpv/jellyfin/playback/app passthrough/
|
||||
# dictionary/history/logs/stats/update)
|
||||
config/ # Launcher config parsers + CLI parser builder
|
||||
main.ts # Launcher entrypoint and command dispatch
|
||||
plugin/
|
||||
subminer/ # Modular mpv plugin (main · init · bootstrap · lifecycle · process
|
||||
# state · messages · hover · ui · options · environment · log
|
||||
# binary · session_bindings · version)
|
||||
launcher/
|
||||
main.ts # entrypoint and command dispatch
|
||||
commands/ # one module per subcommand (playback, jellyfin, stats, sync, ...)
|
||||
config/ # launcher config readers and CLI parser
|
||||
plugin/subminer/ # mpv plugin; main.lua loads init.lua, which boots the other modules
|
||||
src/
|
||||
main-entry.ts # Background-mode bootstrap wrapper before loading main.js
|
||||
main.ts # Entry point - delegates to runtime composers/domain modules
|
||||
preload.ts # Electron preload bridge
|
||||
types.ts # Shared type definitions
|
||||
main/ # Main-process composition/runtime adapters
|
||||
boot/ # Pre-ready boot helpers
|
||||
app-lifecycle.ts # App lifecycle + app-ready runtime runner factories
|
||||
character-dictionary-runtime.ts # Character-dictionary orchestration/public runtime API
|
||||
cli-runtime.ts # CLI command runtime service adapters
|
||||
config-validation.ts # Startup/hot-reload config error formatting and fail-fast helpers
|
||||
dependencies.ts # Shared dependency builders for IPC/runtime services
|
||||
ipc-runtime.ts # IPC runtime registration wrappers
|
||||
overlay-runtime.ts # Overlay modal routing + active-window selection
|
||||
overlay-shortcuts-runtime.ts # Overlay keyboard shortcut handling
|
||||
overlay-visibility-runtime.ts # Overlay visibility + tracker-driven bounds service
|
||||
frequency-dictionary-runtime.ts # Frequency dictionary runtime adapter
|
||||
jlpt-runtime.ts # JLPT dictionary runtime adapter
|
||||
media-runtime.ts # Media path/title/subtitle-position runtime service
|
||||
startup.ts # Startup bootstrap dependency builder
|
||||
startup-lifecycle.ts # Lifecycle runtime runner adapter
|
||||
state.ts # Application runtime state container + reducer transitions
|
||||
subsync-runtime.ts # Subsync command runtime adapter
|
||||
character-dictionary-runtime/ # Character-dictionary fetch/build/cache modules + focused tests
|
||||
runtime/
|
||||
composers/ # High-level composition clusters used by main.ts
|
||||
domains/ # Domain barrel exports (startup/overlay/mpv/jellyfin/...)
|
||||
registry.ts # Domain registry consumed by main.ts
|
||||
core/
|
||||
services/ # Focused runtime services (Electron adapters + pure logic)
|
||||
anilist/ # AniList token store/update queue/update helpers
|
||||
immersion-tracker/ # Immersion persistence/session/metadata modules
|
||||
tokenizer/ # Tokenizer stage modules (selection/enrichment/annotation)
|
||||
utils/ # Pure helpers and coercion/config utilities
|
||||
cli/ # CLI parsing and help output
|
||||
config/ # Config defaults/definitions, loading, parse, resolution pipeline
|
||||
definitions/ # Domain-specific defaults + option registries
|
||||
resolve/ # Domain-specific config resolution pipeline stages
|
||||
shared/ipc/ # Cross-process IPC channel constants + payload validators
|
||||
renderer/ # Overlay renderer (modularized UI/runtime)
|
||||
handlers/ # Keyboard/mouse/gamepad interaction modules
|
||||
modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help,
|
||||
# changelog, character dictionary, playlist browser, subtitle
|
||||
# sidebar, YouTube track picker, controller config/debug/select)
|
||||
positioning/ # Subtitle position controller (drag-to-reposition)
|
||||
settings/ # Settings window UI (model, controls, markup)
|
||||
types/ # Domain type modules (anki, config, integrations, ...)
|
||||
window-trackers/ # Backend-specific tracker implementations (Hyprland, Sway, X11, macOS, Windows)
|
||||
jimaku/ # Jimaku API integration helpers
|
||||
subsync/ # Subtitle sync (alass/ffsubsync) helpers
|
||||
anki-integration/ # AnkiConnect proxy server + note-update enrichment workflow
|
||||
main-entry.ts # bootstrap wrapper that runs before main.js
|
||||
main.ts # composition root
|
||||
preload*.ts # preload bridges (overlay, settings, stats, sync, Jellyfin setup)
|
||||
main/ # main-process runtime modules and IPC/CLI wiring
|
||||
boot/ # pre-ready boot helpers
|
||||
assemblies/ # per-domain assemblies main.ts builds once (startup, overlay, playback, mining, stats, Yomitan, shortcuts, tray, first-run setup, YouTube, Jellyfin, AniList, settings, sync, dictionaries, overlay modals)
|
||||
runtime/composers/ # larger runtime clusters assembled for main.ts
|
||||
runtime/domains/ # domain barrels (startup, overlay, mpv, ipc, shortcuts, anilist, jellyfin, mining)
|
||||
core/services/ # focused services: mpv client, overlay, tokenizer, mining, integrations, stats
|
||||
core/utils/ # pure helpers
|
||||
shared/ipc/ # IPC channel constants and payload validators
|
||||
renderer/ # overlay renderer: subtitle rendering, input handlers, modals
|
||||
config/ # definitions/ (defaults + option registries) and resolve/ (resolution pipeline)
|
||||
cli/ # app CLI parsing and help output
|
||||
settings/, syncui/ # settings and sync windows
|
||||
window-trackers/ # Hyprland, Sway, X11, macOS, and Windows trackers
|
||||
anki-integration/ # AnkiConnect proxy and note-update workflow
|
||||
jimaku/, subsync/, tsukihime/ # integration helpers
|
||||
types/ # shared domain types
|
||||
stats/ # stats dashboard UI (Vite)
|
||||
vendor/ # Yomitan fork, texthooker-ui, JLPT vocab
|
||||
```
|
||||
|
||||
### Service layer (`src/core/services/`)
|
||||
A few ownership notes that are hard to guess from file names:
|
||||
|
||||
- **Overlay/window runtime:** `overlay-manager.ts`, `overlay-window.ts`, `overlay-visibility.ts`, `overlay-bridge.ts`, `overlay-runtime-init.ts`, `overlay-content-measurement.ts`
|
||||
- **Shortcuts/input:** `shortcut.ts`, `overlay-shortcut.ts`, `overlay-shortcut-handler.ts`, `shortcut-fallback.ts`, `numeric-shortcut.ts`
|
||||
- **MPV runtime:** `mpv.ts`, `mpv-transport.ts`, `mpv-protocol.ts`, `mpv-properties.ts`, `mpv-render-metrics.ts`
|
||||
- **Mining + Anki/Jimaku runtime:** `mining.ts`, `field-grouping.ts`, `field-grouping-overlay.ts`, `anki-jimaku.ts`, `anki-jimaku-ipc.ts`
|
||||
- **Subtitle/token pipeline:** `subtitle-processing-controller.ts`, `subtitle-position.ts`, `subtitle-ws.ts`, `tokenizer.ts` + `tokenizer/*` stage modules (including `parser-enrichment-worker-runtime.ts` for async MeCab enrichment and `yomitan-parser-runtime.ts`)
|
||||
- **Integrations:** `jimaku.ts`, `subsync.ts`, `subsync-runner.ts`, `texthooker.ts`, `jellyfin.ts`, `jellyfin-remote.ts`, `discord-presence.ts`, `yomitan-extension-loader.ts`, `yomitan-settings.ts`
|
||||
- **Anki integration (repo `src/` root, not under `core/services/`):** `src/anki-integration.ts`, `src/anki-integration/anki-connect-proxy.ts` (local proxy for push-based auto-enrichment), `src/anki-integration/note-update-workflow.ts`
|
||||
- **Config/runtime controls:** `config-hot-reload.ts`, `runtime-options-ipc.ts`, `cli-command.ts`, `startup.ts`
|
||||
- **Domain submodules:** `anilist/*` (token/update queue/updater), `immersion-tracker/*` (storage/session/metadata/query/reducer)
|
||||
- mpv access is split into transport (`mpv-transport.ts`), protocol (`mpv-protocol.ts`), and property modules under `src/core/services/`.
|
||||
- The renderer keeps `renderer.ts` to orchestration. Keyboard, mouse, and gamepad input live in `renderer/handlers/`, and each modal flow has its own file in `renderer/modals/`.
|
||||
- AniSkip intro detection runs in the app (`src/main/runtime/aniskip-runtime.ts`), which drives mpv chapters and the skip key over the mpv IPC socket. The plugin does not handle it.
|
||||
|
||||
### Renderer layer (`src/renderer/`)
|
||||
## Component diagram
|
||||
|
||||
The renderer keeps `renderer.ts` focused on orchestration. UI behavior is delegated to per-concern modules.
|
||||
|
||||
```text
|
||||
src/renderer/
|
||||
renderer.ts # Entrypoint/orchestration only
|
||||
context.ts # Shared runtime context contract
|
||||
state.ts # Centralized renderer mutable state (visible overlay only)
|
||||
error-recovery.ts # Global renderer error boundary + recovery actions
|
||||
overlay-content-measurement.ts # Reports rendered bounds to main process
|
||||
subtitle-render.ts # Primary/secondary subtitle rendering + style application
|
||||
positioning.ts # Facade export for positioning controller
|
||||
yomitan-popup.ts # Yomitan popup iframe detection utilities
|
||||
positioning/
|
||||
controller.ts # Subtitle drag-position controller
|
||||
position-state.ts # Position state helpers (yPercent)
|
||||
handlers/
|
||||
keyboard.ts # Keybindings, chord handling, modal key routing
|
||||
mouse.ts # Hover/drag behavior, selection + observer wiring
|
||||
gamepad-controller.ts # Gamepad/controller input handling
|
||||
controller-binding-capture.ts # Controller binding capture flow
|
||||
modals/
|
||||
jimaku.ts # Jimaku modal flow
|
||||
kiku.ts # Kiku field-grouping modal flow
|
||||
runtime-options.ts # Runtime options modal flow
|
||||
session-help.ts # Keyboard shortcuts/help modal flow
|
||||
subsync.ts # Manual subsync modal flow
|
||||
character-dictionary.ts # Character dictionary modal flow
|
||||
playlist-browser.ts # Playlist browser modal flow
|
||||
subtitle-sidebar.ts # Subtitle sidebar modal flow
|
||||
youtube-track-picker.ts # YouTube subtitle track picker
|
||||
controller-*.ts # Controller config/debug/select modals
|
||||
utils/
|
||||
dom.ts # Required DOM lookups + typed handles
|
||||
platform.ts # Layer/platform capability detection
|
||||
```
|
||||
|
||||
### Launcher + plugin runtimes
|
||||
|
||||
- `launcher/main.ts` dispatches commands through `launcher/commands/*` and shared config readers in `launcher/config/*`. It handles mpv startup, app passthrough, Jellyfin helper commands, and playback handoff.
|
||||
- `plugin/subminer/main.lua` is the mpv entrypoint: it sets up the module path and loads `init.lua`, a thin shim that boots the modular Lua files: `bootstrap.lua` (startup), `lifecycle.lua` (connect/disconnect), `process.lua` (process management), `state.lua` (shared state), `messages.lua` (IPC), `hover.lua` (hover-token highlight rendering), `ui.lua` (OSD rendering), `options.lua` (config), `environment.lua` (detection), `log.lua` (logging), `binary.lua` (path resolution), `session_bindings.lua` (configurable session keybindings), `version.lua` (version metadata). AniSkip intro detection lives in the SubMiner app (`src/main/runtime/aniskip-runtime.ts`), which drives mpv chapters and the skip key over the IPC socket.
|
||||
|
||||
## Flow diagram
|
||||
|
||||
The main process orchestrates a single primary overlay window plus modal surfaces: `main.ts` delegates to composition modules that wire together domain services. Subtitle layers (primary + secondary bar) are rendered in the same overlay renderer process, connected through `preload.ts`. External runtimes (launcher CLI and mpv plugin) operate independently and communicate via IPC socket or CLI passthrough.
|
||||
The main process drives one primary overlay window plus modal surfaces. Primary and secondary subtitle layers render in the same overlay renderer, connected to the main process through `preload.ts`. The launcher and mpv plugin run as separate processes and talk to the app through sockets or CLI passthrough.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
@@ -225,63 +133,39 @@ flowchart TB
|
||||
|
||||
## Composition pattern
|
||||
|
||||
Most runtime code follows a dependency-injection pattern:
|
||||
Runtime code uses dependency injection:
|
||||
|
||||
1. Define a service interface in `src/core/services/*`.
|
||||
2. Keep core logic in pure or side-effect-bounded functions.
|
||||
3. Build runtime deps in `src/main/` composition modules; extract an adapter/helper only when it adds meaningful behavior or reuse.
|
||||
4. Call the service from lifecycle/command wiring points.
|
||||
1. Put the logic in a service under `src/core/services/`, as pure or side-effect-bounded functions.
|
||||
2. Build its runtime dependencies in a `src/main/` module. Pass simple dependencies inline; extract an adapter only when it adds behavior or gets reused.
|
||||
3. Call the service from lifecycle or command wiring.
|
||||
|
||||
The composition root (`src/main.ts`) delegates to focused modules in `src/main/` and `src/main/runtime/composers/`:
|
||||
Domain handlers live in the barrels under `src/main/runtime/domains/`. Larger clusters, such as app-ready startup, mpv, Jellyfin, AniList tracking, shortcuts, and IPC, are assembled by composers in `src/main/runtime/composers/`. A domain whose runtimes share state, such as app startup, the overlay windows, mpv playback and subtitle state, card mining, stats, YouTube, Jellyfin, AniList, config settings, sync, or the character dictionary, is grouped into one assembly in `src/main/assemblies/` that owns that state and returns its runtimes. An assembly that answers renderer IPC or CLI commands also returns typed `ipc`/`cli` slices, and `main.ts` registers the handlers by combining those slices. Handlers take their dependencies as a plain object passed straight to the handler factory, which keeps side effects out of the unit under test.
|
||||
|
||||
- `startup.ts` - argv/env processing and bootstrap flow
|
||||
- `app-lifecycle.ts` - Electron lifecycle event registration
|
||||
- `startup-lifecycle.ts` - app-ready initialization sequence
|
||||
- `state.ts` - centralized application runtime state container
|
||||
- `ipc-runtime.ts` - IPC channel registration and handler wiring
|
||||
- `cli-runtime.ts` - CLI command parsing and dispatch
|
||||
- `overlay-runtime.ts` - overlay window selection and modal state management
|
||||
- `subsync-runtime.ts` - subsync command orchestration
|
||||
- `runtime/composers/anilist-tracking-composer.ts` - AniList media tracking/probe/retry wiring
|
||||
- `runtime/composers/jellyfin-runtime-composer.ts` - Jellyfin config/client/playback/command/setup composition wiring
|
||||
- `runtime/composers/mpv-runtime-composer.ts` - MPV event/factory/tokenizer/warmup wiring
|
||||
Composers declare their inputs with `ComposerInputs<T>` and results with `ComposerOutputs<T>` from `src/main/runtime/composers/contracts.ts`. A missing dependency then fails at compile time.
|
||||
|
||||
Composer modules share contract conventions via `src/main/runtime/composers/contracts.ts`:
|
||||
### IPC boundary
|
||||
|
||||
- composer input surfaces are declared with `ComposerInputs<T>` so required dependencies cannot be omitted at compile time
|
||||
- composer outputs are declared with `ComposerOutputs<T>` to keep result contracts explicit and stable
|
||||
- builder return payload extraction should use shared type helpers instead of inline ad-hoc inference
|
||||
Channel names live in `src/shared/ipc/contracts.ts` and payload validators in `src/shared/ipc/validators.ts`. Renderer payloads are validated at the IPC entry points (`src/core/services/ipc.ts`, `src/core/services/anki-jimaku-ipc.ts`) before any domain handler runs. See [IPC + runtime contracts](/ipc-contracts) for the full rules.
|
||||
|
||||
This keeps side effects explicit and makes behavior easy to unit-test with fakes.
|
||||
### Runtime state ownership
|
||||
|
||||
Additional conventions in the current code:
|
||||
Some domains, such as AniList token, queue, and media-guess state, use reducer-style transitions:
|
||||
|
||||
- `main.ts` uses `createMainRuntimeRegistry()` (`src/main/runtime/registry.ts`) to access domain handlers (`startup`, `overlay`, `mpv`, `ipc`, `shortcuts`, `anilist`, `jellyfin`, `mining`) without importing every runtime module directly.
|
||||
- Domain barrels in `src/main/runtime/domains/*` re-export runtime handlers + main-deps builders, while composers in `src/main/runtime/composers/*` assemble larger runtime clusters.
|
||||
- Many runtime handlers accept `*MainDeps` objects generated by `createBuild*MainDepsHandler` builders to isolate side effects and keep units testable.
|
||||
|
||||
### IPC contract + validation boundary
|
||||
|
||||
- Central channel constants live in `src/shared/ipc/contracts.ts` and are consumed by both main (`ipcMain`) and renderer preload (`ipcRenderer`) wiring.
|
||||
- Runtime payload parsers/type guards live in `src/shared/ipc/validators.ts`.
|
||||
- Rule: renderer-supplied payloads must be validated at IPC entry points (`src/core/services/ipc.ts`, `src/core/services/anki-jimaku-ipc.ts`) before calling domain handlers.
|
||||
- Malformed invoke payloads return explicit structured errors (for example `{ ok: false, error: ... }`) and malformed fire-and-forget payloads are ignored safely.
|
||||
|
||||
### Runtime state ownership (migrated domains)
|
||||
|
||||
For domains migrated to reducer-style transitions (for example AniList token/queue/media-guess runtime state), follow these rules:
|
||||
|
||||
- Composition/runtime modules own mutable state cells and expose narrow `get*`/`set*` accessors.
|
||||
- Domain handlers do not mutate foreign state directly; they call explicit transition helpers that encode invariants.
|
||||
- Transition helpers may sync derived counters/snapshots, but must preserve non-owned metadata unless the transition explicitly owns that metadata.
|
||||
- Reducer boundary: when a domain has transition helpers in `src/main/state.ts`, new callsites should route updates through those helpers instead of ad-hoc object mutation in `main.ts` or composers.
|
||||
- Tests for migrated domains should assert both the intended field changes and non-targeted field invariants.
|
||||
- Composition modules own the mutable state and expose narrow `get*`/`set*` accessors.
|
||||
- Handlers change another domain's state only through its transition helpers in `src/main/state.ts`, never by mutating the object directly.
|
||||
- A transition may update derived counters or snapshots, but must leave metadata it does not own untouched.
|
||||
- Tests for these domains check both the fields that should change and the ones that should not.
|
||||
|
||||
## Playback startup flow
|
||||
|
||||
Before the app boots, something has to launch mpv, inject the plugin, and bring the overlay up. SubMiner-managed launches own this step - the `subminer` launcher, the app's own playback, and the packaged Windows shortcut all follow the same path. The launcher reads `config.jsonc`, spawns mpv with the IPC socket and the bundled plugin, and passes runtime settings as `--script-opts`. The plugin never reads a config file: the shipped `subminer.conf` is intentionally empty so command-line opts always win.
|
||||
A SubMiner-managed launch (the `subminer` launcher, the app's own playback, or the packaged Windows shortcut) starts mpv, injects the plugin, and brings up the overlay. The launcher reads `config.jsonc`, spawns mpv with the IPC socket and the bundled plugin, and passes runtime settings as `--script-opts`. The plugin never reads a config file: the shipped `subminer.conf` has no settings, so command-line options always win.
|
||||
|
||||
Once mpv is up, exactly one of two triggers brings up the overlay. On a first launch the plugin's `file-loaded` hook self-starts the app once the socket is ready (because the launcher injected `auto_start=yes`). When the app is already running - or for explicit `--start-overlay` and YouTube flows - the launcher instead attaches over the control socket and suppresses the plugin's auto-start, so the two never fire together. Both converge on the same app bring-up, which then runs the Program Lifecycle below.
|
||||
Once mpv is up, exactly one of two triggers starts the overlay:
|
||||
|
||||
- On a first launch, the launcher sets `auto_start=yes` and the plugin's `file-loaded` hook starts the app once the socket is ready.
|
||||
- When the app is already running, or for `--start-overlay` and YouTube flows, the launcher attaches over the app control socket and suppresses the plugin's auto-start.
|
||||
|
||||
Both paths end in the same app bring-up, which then runs the program lifecycle below.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
@@ -312,17 +196,17 @@ flowchart TB
|
||||
Conn --> Show["Transparent overlay over mpv<br/>Yomitan lookup · mine"]:::overlay
|
||||
```
|
||||
|
||||
The runtime sockets in this flow are detailed in [IPC + Runtime Contracts](./ipc-contracts#runtime-sockets).
|
||||
The sockets in this flow are described in [IPC + runtime contracts](./ipc-contracts#runtime-sockets).
|
||||
|
||||
## Program lifecycle
|
||||
|
||||
- **Module-level init:** Before `app.ready`, the composition root registers protocols, sets platform flags, constructs all services, and wires dependency injection. `runAndApplyStartupState()` parses CLI args and detects the compositor backend.
|
||||
- **Startup:** If `--generate-config` is passed, it writes the template and exits. Otherwise `app-lifecycle.ts` acquires the single-instance lock and registers Electron lifecycle hooks.
|
||||
- **Critical-path init:** Once `app.whenReady()` fires, `composeAppReadyRuntime()` runs strict config reload, resolves keybindings, creates the `MpvIpcClient` (which immediately connects and subscribes to mpv subtitle/playback properties via `observe_property`), and initializes the `RuntimeOptionsManager`, `SubtitleTimingTracker`, and `ImmersionTrackerService`.
|
||||
- **Overlay runtime:** `initializeOverlayRuntime()` creates the primary overlay window (interactive Yomitan lookups and subtitle rendering), registers global shortcuts, and sets up bounds tracking via the active window tracker. mpv subtitle suppression is handled by a dedicated `overlay-mpv-sub-visibility` service.
|
||||
- **Background warmups:** Non-critical services are launched asynchronously: MeCab tokenizer check (with async worker thread), Yomitan extension load, JLPT + frequency dictionary prewarm, optional Jellyfin remote session, Discord presence service, AniList token refresh, and optional AnkiConnect proxy server. Warmup coverage is configurable through `startupWarmups` (including low-power mode that defers all but Yomitan).
|
||||
- **Runtime:** Event-driven. mpv property changes, IPC messages, CLI commands, overlay shortcuts, and hot-reload notifications route through runtime handlers/composers. Subtitle text flows through the `SubtitleProcessingController` (normalize → tokenize → merge), and results are sent to the main overlay renderer and modal surfaces.
|
||||
- **Shutdown:** `onWillQuitCleanup` destroys tray + config watcher, unregisters shortcuts, stops WebSocket + texthooker servers, closes the mpv socket + flushes OSD log, stops the window tracker, closes the Yomitan parser window, flushes the immersion tracker (SQLite), stops Jellyfin/Discord services, stops the AnkiConnect proxy server, and cleans Anki/AniList state.
|
||||
1. **Module init.** Before `app.ready`, the composition root registers protocols, sets platform flags, constructs services, and wires dependencies. `runAndApplyStartupState()` parses CLI args and detects the compositor backend.
|
||||
2. **Startup.** `--generate-config` writes the template and exits. Otherwise `app-lifecycle.ts` takes the single-instance lock and registers Electron lifecycle hooks.
|
||||
3. **App ready.** `composeAppReadyRuntime()` reloads config strictly, resolves keybindings, creates the `MpvIpcClient` (which connects and observes subtitle and playback properties), and starts the runtime options manager, subtitle timing tracker, and immersion tracker.
|
||||
4. **Overlay.** `initializeOverlayRuntime()` creates the overlay window, registers global shortcuts, and tracks mpv's window bounds through the active window tracker. `src/main/runtime/overlay-mpv-sub-visibility.ts` hides mpv's own subtitles while the overlay shows them.
|
||||
5. **Background warmups.** MeCab, Yomitan, JLPT and frequency dictionaries, the optional Jellyfin remote session, Discord presence, AniList token refresh, and the optional AnkiConnect proxy start asynchronously. `startupWarmups` controls which run; its low-power mode defers everything except Yomitan.
|
||||
6. **Runtime.** Event-driven. mpv property changes, IPC messages, CLI commands, shortcuts, and config hot-reloads route through handlers and composers. Subtitle text goes through `SubtitleProcessingController` (normalize, tokenize, merge) and out to the overlay renderer and modals.
|
||||
7. **Shutdown.** `onWillQuitCleanup` tears down the tray, config watcher, shortcuts, WebSocket and texthooker servers, mpv socket, window tracker, and Yomitan parser window. It flushes the immersion tracker to SQLite and stops Jellyfin, Discord, and the AnkiConnect proxy.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
@@ -386,11 +270,11 @@ flowchart TB
|
||||
style Loop fill:#363a4f,stroke:#494d64,color:#cad3f5
|
||||
```
|
||||
|
||||
## Subtitle prefetch pipeline
|
||||
## Subtitle prefetch
|
||||
|
||||
SubMiner can pre-tokenize upcoming subtitle lines before they appear on screen. When an external subtitle file (SRT, VTT, or ASS) is detected on the active track, the `SubtitlePrefetchService` parses all cues via the subtitle cue parser (`subtitle-cue-parser.ts`), identifies a priority window of upcoming lines based on the current playback position, and tokenizes them in the background through the same pipeline used for live subtitles. Results are stored directly into the `SubtitleProcessingController` cache, so when a subtitle actually appears during playback, it hits a warm cache and renders in ~30-50ms instead of ~200-320ms.
|
||||
SubMiner tokenizes upcoming subtitle lines before they appear, so they render from a warm cache. `SubtitlePrefetchService` (`src/core/services/subtitle-prefetch.ts`) gets the cue list from the active track: an external subtitle file, or for local media an embedded text track extracted with ffmpeg. It parses the cues with `subtitle-cue-parser.ts`, picks a window of upcoming lines from the playback position, and tokenizes them through the live pipeline, storing results in the `SubtitleProcessingController` cache.
|
||||
|
||||
The prefetcher yields to live subtitle processing (which always takes priority over background work) and re-computes its priority window on seek. Cache invalidation events (e.g. marking a word as known) trigger re-prefetching of the current window to keep results fresh.
|
||||
Live subtitle processing always takes priority; the prefetcher pauses while the on-screen line is being processed. It recomputes its window on seek and re-prefetches when the cache is invalidated, for example after a word is marked known.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
@@ -399,7 +283,7 @@ flowchart TB
|
||||
classDef runtime fill:#8bd5ca,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
||||
classDef warmup fill:#eed49f,stroke:#494d64,color:#24273a,stroke-width:1.5px
|
||||
|
||||
SubFile["External Sub File"]:::init
|
||||
SubFile["Subtitle Track"]:::init
|
||||
Parse["Cue Parser"]:::phase
|
||||
Window["Upcoming Lines"]:::phase
|
||||
Tokenize["Pre-tokenize"]:::warmup
|
||||
@@ -416,22 +300,11 @@ flowchart TB
|
||||
style Render stroke-width:2px
|
||||
```
|
||||
|
||||
## Why this design
|
||||
|
||||
- **Smaller blast radius:** changing one feature usually touches one service.
|
||||
- **Better testability:** most behavior can be tested without Electron windows/mpv.
|
||||
- **Better reviewability:** PRs can be scoped to one subsystem.
|
||||
- **Backward compatibility:** CLI flags and IPC channels can remain stable while internals evolve.
|
||||
- **Runtime registry + domain barrels:** `src/main/runtime/registry.ts` and `src/main/runtime/domains/*` reduce direct fan-in inside `main.ts` while keeping domain ownership explicit.
|
||||
- **Extracted composition root:** `main.ts` delegates to focused modules under `src/main/` and `src/main/runtime/composers/` for lifecycle, IPC, overlay, mpv, shortcut, and integration wiring.
|
||||
- **Split MPV service layers:** MPV internals are separated into transport (`mpv-transport.ts`), protocol (`mpv-protocol.ts`), and properties/render metrics modules for maintainability.
|
||||
- **Config by domain:** defaults, option registries, and resolution are split by domain under `src/config/definitions/*` and `src/config/resolve/*`, keeping config evolution localized.
|
||||
|
||||
## Extension rules
|
||||
|
||||
- Add behavior to an existing service in `src/core/services/*` or create a focused runtime module under `src/main/runtime/*`; avoid ad-hoc logic in `main.ts`.
|
||||
- Add new cross-process channels in `src/shared/ipc/contracts.ts` first, validate payloads in `src/shared/ipc/validators.ts`, then wire handlers in IPC runtime modules.
|
||||
- See also the contributor IPC onboarding page: [IPC + Runtime Contracts](/ipc-contracts).
|
||||
- If change spans startup/overlay/mpv/integration wiring, prefer composing through `src/main/runtime/domains/*` + `src/main/runtime/composers/*` rather than direct wiring in `main.ts`.
|
||||
- Keep service APIs explicit and narrowly scoped, and preserve existing CLI flag / IPC channel behavior unless the change is intentionally breaking.
|
||||
- Add or update focused tests (including malformed-payload IPC tests) when runtime boundaries or contracts change.
|
||||
- Add behavior to a service in `src/core/services/` or a focused module under `src/main/runtime/`. Keep new logic out of `main.ts`.
|
||||
- For changes that span startup, overlay, mpv, or integration wiring, compose through `src/main/runtime/domains/` and `src/main/runtime/composers/` instead of wiring directly in `main.ts`.
|
||||
- Add a cross-process channel in `src/shared/ipc/contracts.ts` first, validate it in `src/shared/ipc/validators.ts`, then wire the handler. See [IPC + runtime contracts](/ipc-contracts#add-a-new-ipc-action).
|
||||
- Config is split by domain under `src/config/definitions/` and `src/config/resolve/`. Keep config changes in the matching domain file.
|
||||
- Keep CLI flags and IPC channels stable unless a change is meant to break them.
|
||||
- Add or update focused tests when a runtime boundary or contract changes, including malformed-payload tests for IPC.
|
||||
@@ -0,0 +1,116 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { onRequest, resolveArchiveRoute, type ArchiveBucket } from './functions/v/[[path]]';
|
||||
|
||||
// In-memory stand-in for the R2 binding, including the range and precondition behavior
|
||||
// the function relies on.
|
||||
function fakeBucket(objects: Record<string, string>): ArchiveBucket {
|
||||
return {
|
||||
async get(key, options) {
|
||||
const content = objects[key];
|
||||
if (content === undefined) return null;
|
||||
const bytes = new TextEncoder().encode(content);
|
||||
const httpEtag = `"${key}"`;
|
||||
|
||||
if (options?.onlyIf?.get('if-none-match') === httpEtag) {
|
||||
return { size: bytes.length, httpEtag };
|
||||
}
|
||||
|
||||
const rangeHeader = options?.range?.get('range');
|
||||
const match = rangeHeader ? /^bytes=(\d+)-(\d*)$/.exec(rangeHeader) : null;
|
||||
if (match) {
|
||||
const offset = Number(match[1]);
|
||||
const end = match[2] ? Number(match[2]) : bytes.length - 1;
|
||||
const slice = bytes.slice(offset, end + 1);
|
||||
return {
|
||||
size: bytes.length,
|
||||
httpEtag,
|
||||
range: { offset, length: slice.length },
|
||||
body: new Blob([slice]).stream(),
|
||||
};
|
||||
}
|
||||
|
||||
return { size: bytes.length, httpEtag, body: new Blob([bytes]).stream() };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const bucket = fakeBucket({
|
||||
'v/0.19.6/index.html': '<h1>home</h1>',
|
||||
'v/0.19.6/usage.html': '<h1>usage</h1>',
|
||||
'v/0.19.6/404.html': '<h1>missing</h1>',
|
||||
'v/0.19.6/assets/app.abc123.js': 'console.log(1)',
|
||||
});
|
||||
|
||||
function request(path: string, init?: RequestInit) {
|
||||
return onRequest({
|
||||
request: new Request(`https://docs.subminer.moe${path}`, init),
|
||||
env: { DOCS_ARCHIVES: bucket },
|
||||
});
|
||||
}
|
||||
|
||||
test('archive routes follow the clean-URL layout of the built archives', () => {
|
||||
expect(resolveArchiveRoute('/v/0.19.6/usage')).toEqual({
|
||||
kind: 'lookup',
|
||||
version: '0.19.6',
|
||||
keys: ['v/0.19.6/usage.html', 'v/0.19.6/usage/index.html'],
|
||||
});
|
||||
expect(resolveArchiveRoute('/v/0.19.6/')).toEqual({
|
||||
kind: 'lookup',
|
||||
version: '0.19.6',
|
||||
keys: ['v/0.19.6/index.html'],
|
||||
});
|
||||
expect(resolveArchiveRoute('/v/0.19.6', '?q=1')).toEqual({
|
||||
kind: 'redirect',
|
||||
location: '/v/0.19.6/?q=1',
|
||||
status: 301,
|
||||
});
|
||||
expect(resolveArchiveRoute('/v/')).toEqual({
|
||||
kind: 'redirect',
|
||||
location: '/versions',
|
||||
status: 302,
|
||||
});
|
||||
expect(resolveArchiveRoute('/v/0.19.6/%2e%2e/secret')).toEqual({ kind: 'not-found' });
|
||||
expect(resolveArchiveRoute('/v/latest/')).toEqual({ kind: 'not-found' });
|
||||
});
|
||||
|
||||
test('serves archive pages and hashed assets with their cache policy', async () => {
|
||||
const page = await request('/v/0.19.6/usage');
|
||||
expect(page.status).toBe(200);
|
||||
expect(page.headers.get('content-type')).toBe('text/html; charset=utf-8');
|
||||
expect(page.headers.get('cache-control')).toBe('public, max-age=3600');
|
||||
expect(page.headers.get('x-robots-tag')).toBe('noindex, follow');
|
||||
expect(await page.text()).toBe('<h1>usage</h1>');
|
||||
|
||||
const asset = await request('/v/0.19.6/assets/app.abc123.js');
|
||||
expect(asset.headers.get('content-type')).toBe('text/javascript; charset=utf-8');
|
||||
expect(asset.headers.get('cache-control')).toContain('immutable');
|
||||
});
|
||||
|
||||
test('missing archive pages fall back to the archive 404 page', async () => {
|
||||
const response = await request('/v/0.19.6/nope');
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.text()).toBe('<h1>missing</h1>');
|
||||
|
||||
const unknownVersion = await request('/v/0.1.0/usage');
|
||||
expect(unknownVersion.status).toBe(404);
|
||||
});
|
||||
|
||||
test('supports byte ranges, conditional requests, and HEAD', async () => {
|
||||
const partial = await request('/v/0.19.6/usage', { headers: { Range: 'bytes=4-8' } });
|
||||
expect(partial.status).toBe(206);
|
||||
expect(partial.headers.get('content-range')).toBe('bytes 4-8/14');
|
||||
expect(await partial.text()).toBe('usage');
|
||||
|
||||
const notModified = await request('/v/0.19.6/usage', {
|
||||
headers: { 'If-None-Match': '"v/0.19.6/usage.html"' },
|
||||
});
|
||||
expect(notModified.status).toBe(304);
|
||||
|
||||
const head = await request('/v/0.19.6/usage', { method: 'HEAD' });
|
||||
expect(head.status).toBe(200);
|
||||
expect(head.headers.get('content-length')).toBe('14');
|
||||
expect(await head.text()).toBe('');
|
||||
|
||||
const post = await request('/v/0.19.6/usage', { method: 'POST' });
|
||||
expect(post.status).toBe(405);
|
||||
});
|
||||
+257
-8
@@ -1,6 +1,255 @@
|
||||
# Changelog
|
||||
|
||||
## v0.19.6 (2026-09-04)
|
||||
## v0.21.1 (2026-10-26)
|
||||
|
||||
**Changed**
|
||||
- **Stats Session View**: Redesigned the expanded session view in the stats dashboard.
|
||||
- New stat tiles show pace, lines, known-word rate, lookups, and paused time.
|
||||
- A reading density chart of known and unknown words replaces the cumulative chart.
|
||||
- Yomitan lookups and mined cards now appear in their own lane below the chart.
|
||||
|
||||
**Fixed**
|
||||
- **Subtitle Retiming**: Mining, the subtitle sidebar, subtitle navigation, and replay now use the timing you set with mpv's subtitle delay keys (z/x and sub-step), so mined audio and images match the retimed line.
|
||||
- **Subtitle Sidebar**: mpv keys and mouse actions, like volume keys and double-click fullscreen, work again while the sidebar is open.
|
||||
|
||||
**Docs**
|
||||
- **README**: Refreshed the anime screenshots using the current build.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
**Internal**
|
||||
- **Packaging Layout**: Packaging inputs now live under `packaging/`, and all build output lands in `dist/`, with packaged apps in `dist/release/`.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.21.0 (2026-10-24)
|
||||
|
||||
**Added**
|
||||
- **Hachidori Dictionary Backend**:
|
||||
- Hachidori is now bundled as an alternative to Yomitan. Select it with `dictionaryBackend`, then restart SubMiner.
|
||||
- It works with subtitle scanning, popups, frequency highlighting, character dictionaries, Anki mining, and stats dashboard mining.
|
||||
- First-run setup builds Hachidori's Anki template from your SubMiner settings. It can also link an external Hachidori app, browser, or Docker host.
|
||||
- The tray and dictionary-settings shortcut open settings for the active backend. Use `--hachidori` or `--yomitan` to open a specific one.
|
||||
- **Jellyfin Subtitle Generation**: You can now generate Japanese subtitles while playing a Jellyfin episode, with both direct play and transcoded playback.
|
||||
|
||||
**Fixed**
|
||||
- **Jellyfin**:
|
||||
- `subminer jellyfin -p` now lists libraries and shows image previews when SubMiner is already running. Real errors are shown instead of an empty list.
|
||||
- Watch progress from terminal playback is reported to Jellyfin even with cast discovery off.
|
||||
- When `subminer jellyfin -p` starts SubMiner, the app shows its tray icon and quits when mpv closes.
|
||||
- Casting on macOS now finds mpv when SubMiner is opened from Finder, the Dock, or Spotlight.
|
||||
- **Halfwidth Katakana**: Halfwidth katakana in subtitles now displays as fullwidth, including in mined cards and older stats lines. Stats sentence search matches katakana in either width.
|
||||
- **Media Timing Review**: The review opens on the first attempt instead of after a delay. Playback stays paused while it is open.
|
||||
- **Subtitle Annotations**: Lines shown while two cues overlap now get annotations right away. Prefetching no longer restarts during startup.
|
||||
- **Keyboard-Only Mode**: `h`, `l`, and the arrow keys now work for typing in modal text fields instead of seeking mpv.
|
||||
- **Stats Dashboard Mining**: Cards mined from the stats dashboard keep the selected history line and media.
|
||||
- **Hyprland and Sway**: The app no longer freezes when `hyprctl` or `swaymsg` is slow or hangs.
|
||||
- **Subtitle Sidebar**: The open sidebar does much less background work during playback.
|
||||
- **macOS Launcher Status**: The command-line launcher no longer shows "Not on PATH" when SubMiner is opened from Finder or the Dock.
|
||||
- **Security Updates**: Updated Electron, undici, and follow-redirects to address security advisories.
|
||||
|
||||
**Docs**
|
||||
- **Hachidori Setup**: Documented backend selection, Hachidori dictionary and Anki setup, settings shortcuts, and external host linking.
|
||||
- **Jellyfin Casting**: Explained when SubMiner joins the cast menu at startup, and how casting finds mpv on each platform. Added a troubleshooting entry for casts that never start mpv.
|
||||
- **Jimaku Key**: Removed environment variables that never had any effect. Set the key with `jimaku.apiKey` or `jimaku.apiKeyCommand`.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
**Internal**
|
||||
- **End-to-End Harness**: Added a headless e2e harness (`bun run test:e2e`, `bun run e2e`) with real mpv, a fixture dictionary, and a fake AnkiConnect.
|
||||
- **Hachidori Fork**: The bundled Hachidori integration lives in a pinned fork submodule.
|
||||
- **Dead Code Removal**:
|
||||
- Removed the unused launcher Jimaku client, the mpv plugin hover-highlight module, and the unused character dictionary config keys.
|
||||
- Removed 16 unused overlay IPC methods. SubMiner no longer forwards every ASS line to the overlay.
|
||||
- Typecheck now runs with `noUnusedLocals`.
|
||||
- **Setup Launcher Label**: First-run setup now labels a missing launcher "Not set up" instead of "Not installed".
|
||||
|
||||
</details>
|
||||
|
||||
## Previous Versions
|
||||
|
||||
<details>
|
||||
<summary>v0.20.x</summary>
|
||||
|
||||
<h2>v0.20.1 (2026-10-03)</h2>
|
||||
|
||||
**Added**
|
||||
- **YouTube Browser Window**:
|
||||
- Browse YouTube with your login kept across restarts. Open it with `subminer youtube`, `subminer yt`, or tray **Browse YouTube**.
|
||||
- Clicking a video plays it in mpv. Middle-click, Shift/Ctrl-click, or the right-click menu adds it to mpv's playlist.
|
||||
- Queued videos get the same Japanese subtitle setup, and the rest of the queue is kept on Windows.
|
||||
- Closing the window quits a SubMiner started by `subminer youtube`.
|
||||
- **Whisper Subtitles for YouTube**:
|
||||
- Set `youtube.subtitleSource` to `whisper` to transcribe YouTube videos with Whisper instead of using YouTube's captions.
|
||||
- The video stays paused while Whisper transcribes, and the subtitle generation modal shows progress.
|
||||
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including Windows stream playback.
|
||||
- **Scroll Wheel and Mouse Bindings**: `keybindings` now accepts scroll wheel keys, and the settings key editor can capture them. Mouse button and wheel bindings from mpv now work while the cursor is over the overlay, including on Hyprland.
|
||||
- **Run Commands from Session Help**: During playback, press `Enter` or double-click a row in session help to run that command.
|
||||
|
||||
**Changed**
|
||||
- **Release Assets**: Releases no longer include package-size reports.
|
||||
|
||||
**Fixed**
|
||||
- **Hyprland Overlay**: The overlay stays clickable after mpv goes fullscreen with `mpv.backend: x11`, and it no longer interrupts mpv while mpv enters fullscreen.
|
||||
- **mpv Key Bindings**: Bindings from mpv such as `9`/`0` for volume now work while the overlay has focus, even if the overlay loaded before mpv connected.
|
||||
- **Jellyfin**:
|
||||
- The Japanese subtitle track is selected as soon as it downloads, without waiting for the other tracks.
|
||||
- Japanese and English tracks are auto-selected on episodes whose embedded subtitles are images.
|
||||
- Timing review previews and mined card audio use the audio track you are playing.
|
||||
- **Media Timing Review**: Trim handles are now thin lines on the clip edges, and original subtitle boundaries no longer cover the waveform. The preview cursor no longer stutters on macOS with Bluetooth headphones.
|
||||
- **YouTube Subtitles**: Japanese subtitles no longer fail with "HTTP 429" on videos where YouTube also lists a machine-translated Japanese track.
|
||||
- **Stats**:
|
||||
- Mined cards are counted once in session and lifetime stats.
|
||||
- Word and kanji dates and the new-words charts stay accurate after deleting sessions. Existing data is repaired in the background.
|
||||
- Overview totals, charts, and the activity calendar refresh after deleting sessions.
|
||||
- **Yomitan Data Safety**: Electron is updated to 43.7.7. SubMiner now refuses to load your Yomitan data with an unsupported or downgraded runtime. Automatic character dictionary changes stop if Yomitan suddenly reports no dictionaries.
|
||||
- **AppImage in Sandboxes**: The AppImage now opens its window when launched in a sandbox such as `firejail --appimage`.
|
||||
- **Ctrl+C Crash**: SubMiner no longer crashes with "write EPIPE" when the terminal command that started it is stopped with Ctrl+C.
|
||||
|
||||
**Docs**
|
||||
- **Docs Site**: The docs site is shorter and easier to scan, with a key and default table for each config block. Outdated pages were corrected, and the MeCab install command for Arch Linux is fixed.
|
||||
- **Stats Docs**: The docs now cover the Overview refresh after deleting sessions and how discovery dates are kept after old sessions are pruned.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
**Internal**
|
||||
- **Release Notes**: Release notes and `CHANGELOG.md` are now generated as short items per feature or area.
|
||||
- **Dev Profile**: Development launches use a separate `SubMiner-dev` profile unless production-profile access is requested.
|
||||
- **Dependencies**: undici is updated to 7.29.1 to clear the security advisories that were blocking CI.
|
||||
- **Package Checks**: CI no longer compares package sizes. Package content checks still run.
|
||||
|
||||
</details>
|
||||
|
||||
<h2>v0.20.0 (2026-09-23)</h2>
|
||||
|
||||
**Added**
|
||||
- **Japanese Subtitle Generation**:
|
||||
- Generate Japanese SRT subtitles locally with whisper.cpp. Start it from a new modal (Ctrl+Shift+G), from the generate button in an empty subtitle sidebar, or with `subminer generate-subs`.
|
||||
- Generation shows progress, can be cancelled, and loads the finished subtitles into mpv automatically.
|
||||
- Pick an official multilingual Whisper model, including quantized variants, with size and accuracy guidance. You can download it in the app or point Settings at a model you already have.
|
||||
- `large-v3-turbo` is recommended when CUDA support is detected, and `small` otherwise.
|
||||
- whisper-cli, ffmpeg, and ffprobe are found on PATH unless you override them. SubMiner names any missing tools before a download starts.
|
||||
- An optional "Focus on spoken dialogue" mode uses a separately downloaded Silero VAD model. It keeps audible sections it is unsure about, so dialogue under music is not dropped, but songs may also be transcribed.
|
||||
- Long passages are split near speech starts or quiet pauses to reduce subtitles that appear too early. When an eligible embedded or external subtitle track is loaded in mpv, it guides the split points.
|
||||
- Each passage runs in a fresh Whisper process, which prevents repeated-character output.
|
||||
- **Subtitle Selection Modal**:
|
||||
- An optional modal for choosing primary and secondary mpv subtitle tracks.
|
||||
- Turn it on in Settings under Behavior, then press g followed by s to open it. Turning it off restores mpv's own subtitle selection binding.
|
||||
- Single-key actions take priority over configured key sequence prefixes.
|
||||
- Conflicting sequences are disabled with a warning, and the existing y commands stay reserved.
|
||||
- **Subtitle Sidebar Copy**:
|
||||
- Select dialogue across several sidebar rows and copy it without timestamps using Ctrl/Cmd+C or the Copy button.
|
||||
- Selecting text does not seek playback and does not require mining a card.
|
||||
- **Media Timing Review Screenshot Picker**:
|
||||
- Choose the still screenshot separately from the audio range, with a live preview and its own time slider.
|
||||
- Step through decoded frames one at a time to get the exact frame you want.
|
||||
- Works with local video and with seekable remote streams such as Jellyfin.
|
||||
- **mpv Keybindings in the Overlay**:
|
||||
- The overlay now picks up keyboard bindings from mpv defaults, `input.conf`, and loaded scripts when they do not conflict with SubMiner.
|
||||
- SubMiner controls and bindings you explicitly disabled take precedence.
|
||||
- These bindings apply only to the current session and are not listed in the help menu.
|
||||
- **Jimaku Live Action Search**: The Jimaku modal has new Anime and Live action tabs, so you can search Jimaku's live action catalogue as well as anime. Use Arrow Left and Arrow Right to switch tabs.
|
||||
- **TMDB Live-Action Library**:
|
||||
- Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB.
|
||||
- Release builds include a project key. Setting `tmdb.apiKey` or `tmdb.apiKeyCommand` overrides it, and one of them is required when running from source.
|
||||
- Titles that AniList cannot match are looked up on TMDB automatically when the parsed filename exactly matches a Japanese live-action title. For everything else, use the new **Link to TMDB** action.
|
||||
- Entries linked to the same TMDB title merge into one card, and the Library kind selector has a new Live Action option.
|
||||
- If a replacement download fails during provider reassignment, the previous link and artwork are kept. Merges and sync keep AniList and TMDB identities separate, and the merge dialog explains mixed selections instead of failing.
|
||||
- **YouTube Library Kind**:
|
||||
- YouTube channels are now their own Library media kind. Existing channel entries migrate automatically, and viewing history and manual video assignments are unchanged.
|
||||
- New All Titles, Anime, and YouTube Library filters.
|
||||
- Channels are excluded from AniList matching, season repair, and duplicate recommendations.
|
||||
- Merges and video moves can no longer combine an anime entry with a YouTube channel.
|
||||
|
||||
**Changed**
|
||||
- **Launcher Uses Bundled Bun**:
|
||||
- Every installed and downloadable launcher now runs on the Bun runtime that ships with SubMiner. A system Bun is no longer needed.
|
||||
- Recognized legacy launchers migrate automatically.
|
||||
- Windows gets a `subminer.cmd` launcher download.
|
||||
- First-run setup is reduced to one optional launcher control. Runtime repair guidance appears only when it is needed.
|
||||
- **Faster Sync Transfers**:
|
||||
- Sync between compatible macOS and Linux machines now uses compressed, incremental rsync transfers.
|
||||
- The last snapshot received from each peer is cached, which reduces traffic on later syncs.
|
||||
- Machines without a compatible rsync, including Windows, fall back to compressed scp.
|
||||
- Older peers still work without the upload cache.
|
||||
- Transfers abort after 30 minutes.
|
||||
- **Stats Server Request Safety**:
|
||||
- The stats server now accepts loopback hosts only and rejects requests from browser origins other than its own.
|
||||
- Requests that change data must send an `application/json` body. Scripts that POST to the server need to set a JSON content type.
|
||||
- The in-app stats overlay now loads from the local server, so it gets the same protection.
|
||||
- Dashboards served through a reverse proxy or Tailscale Serve are no longer supported.
|
||||
- **Smaller Downloads**:
|
||||
- Installers and unpacked apps are smaller. Demo media, source maps, TypeScript sources, test fixtures, and unused Koffi binaries are no longer packaged.
|
||||
- All windows now share one Japanese UI font.
|
||||
- Release builds publish package size reports that compare against the previous release.
|
||||
- **Bundled Yomitan**: Updated with upstream Yomitan 26.9.8 changes, including historical Japanese kana transformations, Ukrainian language support, and improvements to Anki duplicate searches and audio retrieval.
|
||||
|
||||
**Fixed**
|
||||
- **Jellyfin 12 Compatibility**:
|
||||
- Playback, subtitle, artwork, and remote-control requests now authenticate with the `ApiKey` query parameter, so the integration works on Jellyfin 12, where legacy authorization is off by default.
|
||||
- "Play on SubMiner" stays available. The cast connection now answers keep-alive requests and reconnects when the server stops responding, instead of silently dying after about a minute.
|
||||
- The Jellyfin "now playing" bar clears when you close or finish a cast video instead of running on to the end of the episode.
|
||||
- Cards mined during Jellyfin playback get the episode title in the misc info field again instead of "Unknown media".
|
||||
- **Jellyfin Privacy and Playback**:
|
||||
- Jellyfin streams no longer leak titles taken from the stream URL, or stream URLs that contain credentials, into metadata lookups, Anki source fields, Discord presence, stats, or AniList retries.
|
||||
- Previously cached metadata that contained credentials is cleaned up. Watch history and library assignments are not touched.
|
||||
- Jellyfin playback and casting now use your configured mpv executable, so they work when mpv is installed outside PATH. Portable plugins next to that executable are detected.
|
||||
- **Anki Mining**:
|
||||
- New `ankiConnect.fields.wordAudio` setting reads word audio separately from the sentence audio field. This fixes animated images that started moving immediately when `fields.audio` pointed to `SentenceAudio`. Existing animated images need to be regenerated to pick up the fix.
|
||||
- Sentence furigana on word cards stays in sync with the full stats-search context and with expanded timing review selections. Stale readings are cleared if regeneration fails.
|
||||
- Closing the overlay while media timing review is still loading now cancels the review, resumes playback if the review paused it, and cleans up the hidden preview player.
|
||||
- `ankiConnect.media.maxMediaDuration: 0` now means unlimited when mining from the stats dashboard, matching overlay mining.
|
||||
- Invalid AnkiConnect, Kiku, and Senren settings are now rejected with a warning and fall back to defaults.
|
||||
- **Stats Server Stability**:
|
||||
- A port conflict is now reported in a status notification instead of crashing SubMiner.
|
||||
- Simultaneous startup requests share one server start. Stopping the background server no longer disconnects dashboards open in the foreground.
|
||||
- Shutdown waits only a limited time for active requests to finish.
|
||||
- Malformed resource IDs, and ID lists with any invalid entries, are rejected before Library changes or cover backfills run.
|
||||
- **Subtitle Sidebar**:
|
||||
- Clicking a cue no longer leaves the row focused, and Space no longer seeks back to a focused cue. Enter still seeks to the focused cue, and Space keeps its configured playback action.
|
||||
- The sidebar stays near the current playback position during gaps when the subtitle file has a cue that starts at zero.
|
||||
- **Settings Save Feedback**:
|
||||
- Settings marked LIVE no longer show false restart warnings, including for notifications and subtitle generation.
|
||||
- When a save mixes live and restart-only changes, the live changes apply right away and only the changed sections that need a restart are listed.
|
||||
- **Overlay Windows**:
|
||||
- On Hyprland, recovery dialogs stay above SubMiner windows so overlay placement updates no longer cover their Wait and Close buttons.
|
||||
- On Linux, a delayed close callback during teardown can no longer reopen the overlay.
|
||||
- **First Launch on macOS**: SubMiner no longer exits on first launch when the config directory does not exist yet.
|
||||
|
||||
**Docs**
|
||||
- **Launcher**: Documented the launcher install that uses the bundled runtime, migration from legacy launchers, package-managed updates, and the bundled Bun runtime's MIT and LGPL notices. The AUR package installs these notices under `/usr/share/licenses/subminer-bin`, and they are also included in `subminer-assets.tar.gz`.
|
||||
- **Subtitle Generation**: Documented model choice, VAD behavior, splitting guided by a reference track, fallback behavior, and known limits.
|
||||
- **Subtitle Selection**: Documented the subtitle selector setting, its shortcut override, and the primary and secondary track controls.
|
||||
- **Settings**: Clarified save feedback for live settings, warnings for saves that mix live and restart-only changes, and how subtitle generation settings reload.
|
||||
- **Jellyfin**:
|
||||
- Clarified that Windows mpv playback and Jellyfin casting can use a configured executable path instead of PATH.
|
||||
- Documented how Jellyfin media titles and stats identities keep stream credentials out of metadata.
|
||||
- **Stats Library**:
|
||||
- Documented TMDB linking, provider reassignment, merge compatibility, and caching of the credential command's output.
|
||||
- Documented YouTube channel filtering and video statistics in the Library.
|
||||
- **Mining**:
|
||||
- Documented choosing the screenshot separately in media timing review.
|
||||
- Documented the separate word audio field mapping, including that existing animated images need to be regenerated.
|
||||
- **Sync**: Documented compressed transfers, where the incremental sync cache is stored, and compatibility with older peers.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
**Internal**
|
||||
- Removed duplicate source and launcher smoke runs from the reusable CI quality gate. Every distinct test lane and failure artifact is kept.
|
||||
- Replaced mislabeled dist source reruns with a small Electron-runtime smoke check. It covers compiled stats startup, the HTTP service, native SQLite, port conflicts, and cleanup.
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v0.19.x</summary>
|
||||
|
||||
<h2>v0.19.6 (2026-09-04)</h2>
|
||||
|
||||
**Added**
|
||||
|
||||
@@ -31,7 +280,7 @@
|
||||
- **Jellyfin Subtitle Sync**: Jellyfin subtitle files now load with zero mpv delay instead of inferring and saving an offset from Japanese and English cue timelines.
|
||||
- **Secondary Subtitle Visibility**: Native mpv secondary subtitles stay hidden when switching secondary subtitle tracks during playback.
|
||||
|
||||
## v0.19.5 (2026-08-30)
|
||||
<h2>v0.19.5 (2026-08-30)</h2>
|
||||
|
||||
**Fixed**
|
||||
|
||||
@@ -46,7 +295,7 @@
|
||||
- Long speech is paged instead of covering the video with a wall of text.
|
||||
- Explicitly timed sound cues like `[音楽]` no longer cover later dialogue.
|
||||
|
||||
## v0.19.4 (2026-08-25)
|
||||
<h2>v0.19.4 (2026-08-25)</h2>
|
||||
|
||||
**Added**
|
||||
- **Library Merge & Move**: Duplicate library cards for the same show can now be combined. Select cards in the library grid and use "Merge Selected" to pick which entry to keep and move every episode onto it, preserving sessions, mined cards, and watch time. Episodes can also be reassigned individually via the "→" button, useful when a file lands under a stray title; manual assignments survive later filename parsing, Jellyfin refreshes, and season repair. Exact AniList title matches with compatible seasons now merge automatically, while fuzzy matches surface as dismissible "Possible duplicate" reviews instead of merging silently.
|
||||
@@ -87,7 +336,7 @@
|
||||
|
||||
</details>
|
||||
|
||||
## v0.19.3 (2026-08-13)
|
||||
<h2>v0.19.3 (2026-08-13)</h2>
|
||||
|
||||
**Added**
|
||||
- Changelog Modal: Adds an in-app changelog you can open from the tray ("View Changelog") or the "What's New" button on the update notification, so the notification stays reachable while you read. It shows the newest published release notes (falling back to the bundled changelog if that fetch fails), folds older versions while keeping the current one expanded, and supports keyboard navigation (`J`/`K`/arrows, `Enter`, `R`, `Esc`).
|
||||
@@ -111,7 +360,7 @@
|
||||
|
||||
</details>
|
||||
|
||||
## v0.19.2 (2026-08-04)
|
||||
<h2>v0.19.2 (2026-08-04)</h2>
|
||||
|
||||
**Changed**
|
||||
- Subsync: The sync modal now lets you choose both the reference subtitle (correct timing) and the out-of-sync subtitle to retime, for both alass and ffsubsync. alass can also use the loaded video's audio as a reference for local files. Retiming the secondary track now reloads the result into the secondary slot instead of overwriting the primary subtitle.
|
||||
@@ -129,7 +378,7 @@
|
||||
|
||||
</details>
|
||||
|
||||
## v0.19.1 (2026-08-01)
|
||||
<h2>v0.19.1 (2026-08-01)</h2>
|
||||
|
||||
**Added**
|
||||
- Word Card Type: Adds a setting (Settings > Mining/Anki > Kiku/Lapis Features > "Word Card Type") to choose which card-type flag SubMiner marks on Kiku/Lapis word cards — `word-and-sentence` (default), `click`, `sentence`, `audio`, or `none`. Click cards (`IsClickCard`) can now be flagged, and setting any card-type flag clears the others so a note can't claim two types at once.
|
||||
@@ -138,7 +387,7 @@
|
||||
- Yomitan Popup: Fixes the macOS Yomitan popup going inert after mining a card — clicks outside the popup no longer pass through to mpv, and scrolling over the popup scrolls its definitions instead of seeking playback.
|
||||
- YouTube Playlist Links: Fixes opening a video from a playlist URL (e.g. a Watch Later link with `list=`/`index=`) timing out while probing subtitles, metadata, or the playback URL.
|
||||
|
||||
## v0.19.0 (2026-07-29)
|
||||
<h2>v0.19.0 (2026-07-29)</h2>
|
||||
|
||||
**Added**
|
||||
- Anki Maturity Highlighting: Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Tier thresholds and colors are configurable, with a runtime toggle and an updated help legend.
|
||||
@@ -174,7 +423,7 @@
|
||||
|
||||
</details>
|
||||
|
||||
## Previous Versions
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v0.18.x</summary>
|
||||
|
||||
@@ -1,313 +1,104 @@
|
||||
# Character dictionary
|
||||
|
||||
SubMiner builds a Yomitan-compatible dictionary of a show's characters from [AniList](https://anilist.co), the online anime and manga database. Once it is loaded, character names in subtitles get recognized and highlighted, and hovering one shows the portrait, role, voice actor, and biography without leaving the overlay.
|
||||
SubMiner builds a Yomitan dictionary of the characters in the show you are watching, using data from [AniList](https://anilist.co). Character names in subtitles get their own color, and hovering one shows the character's portrait, role, voice actor, and description.
|
||||
|
||||
Proper names rarely appear in ordinary dictionaries, so without this every character name reads as an unknown word. That wrecks N+1 highlighting, since a line naming two characters looks like a line with two unknowns. Recognizing them keeps the highlighting pointed at real vocabulary.
|
||||
Ordinary dictionaries rarely contain character names, so without this every name counts as an unknown word and throws off [N+1 highlighting](/subtitle-annotations#n-1-word-highlighting).
|
||||
|
||||
The dictionary is generated per-media, merged across your recently-watched titles, and auto-imported into Yomitan. When a character name appears in a subtitle line, it gets highlighted and becomes available for hover-driven Yomitan profile lookup.
|
||||
## Turning it on
|
||||
|
||||
## How it works
|
||||
|
||||
The feature has three stages: **snapshot**, **merge**, and **match**.
|
||||
|
||||
1. **Snapshot** - When you start watching a new title, SubMiner queries the AniList GraphQL API for the media's character list. Each character's names, reading, role, description, birthday, voice actors, and portrait are fetched and saved as a local JSON snapshot in `character-dictionaries/snapshots/anilist-{mediaId}.json`. Images are downloaded and base64-encoded into the snapshot.
|
||||
|
||||
2. **Merge** - SubMiner maintains a most-recently-used list of media IDs (default: 3). Snapshots from those titles are merged into a single Yomitan ZIP - `character-dictionaries/merged.zip` - which is always named "SubMiner Character Dictionary" so Yomitan treats it as a single stable dictionary across rebuilds.
|
||||
|
||||
3. **Match** - During subtitle rendering, Yomitan scans subtitle text against all loaded dictionaries including the character dictionary. SubMiner only accepts character entries for the current AniList media when that media ID is known, then flags matching tokens with `isNameMatch` and highlights them in the overlay with a distinct color.
|
||||
|
||||
## Enabling the feature
|
||||
|
||||
Character dictionary sync is disabled by default. To turn it on:
|
||||
|
||||
1. Enable **Name Match** in Settings → Subtitle Style, or set `subtitleStyle.nameMatchEnabled: true` in your config.
|
||||
2. Start watching. SubMiner queries AniList's public GraphQL API, which needs no authentication, and imports the merged dictionary into Yomitan.
|
||||
3. Optionally enable **Name Match Images** (Settings → Subtitle Style) to show inline circular character portraits next to matched names in subtitles.
|
||||
1. Set `subtitleStyle.nameMatchEnabled` to `true`, or turn it on in the Settings window under Annotation Display, Character Names.
|
||||
2. Optionally set `subtitleStyle.nameMatchImagesEnabled` to `true` to show a small portrait next to each name in the subtitle line.
|
||||
3. Play an episode.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"subtitleStyle": {
|
||||
"nameMatchEnabled": true,
|
||||
"nameMatchImagesEnabled": true, // optional - inline portraits
|
||||
"nameMatchImagesEnabled": true,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
::: 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 media match and snapshot without a fresh AniList lookup.
|
||||
:::
|
||||
No AniList account is needed. Logging in to AniList is only for [watch progress sync](/anilist-integration).
|
||||
|
||||
::: info
|
||||
AniList character data is fetched via public GraphQL queries - no account or access token is needed. AniList authentication is only required for the separate [watch-progress sync](/anilist-integration) feature.
|
||||
:::
|
||||
The character dictionary does not work when `yomitan.externalProfilePath` is set, because SubMiner then uses another app's Yomitan profile read-only.
|
||||
|
||||
::: warning
|
||||
If `yomitan.externalProfilePath` is set, SubMiner switches to read-only external-profile mode. In that mode SubMiner can reuse another app's installed Yomitan dictionaries/settings, but SubMiner's own character-dictionary features are fully disabled.
|
||||
:::
|
||||
## What happens when you play something
|
||||
|
||||
## Name generation
|
||||
When a new show starts, SubMiner:
|
||||
|
||||
A single character produces many searchable terms so that names are recognized regardless of how they appear in dialogue. SubMiner generates variants for:
|
||||
1. Guesses the title from the filename and finds it on AniList.
|
||||
2. Downloads the cast list and portraits.
|
||||
3. Builds the dictionary and imports it into SubMiner's Yomitan.
|
||||
|
||||
**Spacing and combination:**
|
||||
A notification shows each step. Once it says the dictionary is ready, names match from the next subtitle line.
|
||||
|
||||
- Full name with space: 須々木 心一
|
||||
- Combined form: 須々木心一
|
||||
- Family name alone: 須々木
|
||||
- Given name alone: 心一
|
||||
Each character gets entries for the full name, family name, given name, and common honorifics (`さん`, `君`, `ちゃん`, `先生`, and others), so `太郎さん` matches as well as `太郎`.
|
||||
|
||||
Unspaced native names (AniList often stores 渡辺真奈美 without a separator) are split into family/given parts with MeCab when it is available: person-name POS tags (姓/名) decide the boundary, validated against AniList's romanized first/last name readings. Without MeCab, a length heuristic based on the romanized readings guesses the boundary. That guess can be ambiguous, since 東紫乃 could be 東+紫乃 or 東紫+乃, so SubMiner generates terms for the top two candidate boundaries and the real surname still matches. Snapshots built without MeCab are regenerated automatically once MeCab becomes available, upgrading them to the exact splits.
|
||||
SubMiner keeps your most recent shows loaded in one merged dictionary. `anilist.characterDictionary.maxLoaded` sets how many. Starting another show drops the oldest one. Only the current show's characters are highlighted.
|
||||
|
||||
**Middle-dot removal** (common in katakana foreign names):
|
||||
### How long it takes
|
||||
|
||||
- ア・リ・ス → アリス (combined), plus individual segments
|
||||
|
||||
**Honorific suffixes** - each base name is expanded with 15 common suffixes:
|
||||
|
||||
| Honorific | Reading |
|
||||
| --------- | ---------- |
|
||||
| さん | さん |
|
||||
| 様 | さま |
|
||||
| 先生 | せんせい |
|
||||
| 先輩 | せんぱい |
|
||||
| 後輩 | こうはい |
|
||||
| 氏 | し |
|
||||
| 君 | くん |
|
||||
| くん | くん |
|
||||
| ちゃん | ちゃん |
|
||||
| たん | たん |
|
||||
| 坊 | ぼう |
|
||||
| 殿 | どの |
|
||||
| 博士 | はかせ |
|
||||
| 社長 | しゃちょう |
|
||||
| 部長 | ぶちょう |
|
||||
|
||||
**Romanized names** - names stored in romaji on AniList are converted to kana aliases so they can match against Japanese subtitle text.
|
||||
|
||||
This means a character like "太郎" generates entries for 太郎, 太郎さん, 太郎先生, 太郎君, 太郎ちゃん, and so on - all with correct readings.
|
||||
|
||||
## Name matching
|
||||
|
||||
Name matching runs inside Yomitan's scanning pipeline during subtitle tokenization.
|
||||
|
||||
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'`.
|
||||
3. When the current AniList media ID is known, entries whose embedded media ID belongs to a different title are ignored for name matching and inline portraits.
|
||||
4. Matched tokens are flagged `isNameMatch: true` and forwarded to the renderer.
|
||||
5. If `subtitleStyle.nameMatchEnabled` is enabled, the renderer applies the name-match highlight color (default: `#f5bde6`).
|
||||
6. If `subtitleStyle.nameMatchImagesEnabled` is enabled, the renderer also injects a small circular AniList portrait from the cached snapshot image data.
|
||||
|
||||
Older snapshot schema versions are regenerated automatically. Current-version snapshots are normally reused, but when `subtitleStyle.nameMatchImagesEnabled` is enabled SubMiner also checks whether the cached snapshot contains usable character portrait data. If it does not, the snapshot is refreshed so the merged dictionary can include images.
|
||||
|
||||
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.
|
||||
|
||||
**Key settings:**
|
||||
|
||||
| Option | Default | Description |
|
||||
| -------------------------------------- | --------- | ----------------------------------------- |
|
||||
| `subtitleStyle.nameMatchEnabled` | `false` | Enable dictionary sync and highlighting |
|
||||
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside names |
|
||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
|
||||
|
||||
## Inline character portraits
|
||||
|
||||
When `subtitleStyle.nameMatchImagesEnabled` is enabled, SubMiner injects a small circular portrait image directly into the subtitle line next to each matched character name.
|
||||
|
||||
Portraits are sourced from the local snapshot - they are embedded at snapshot-generation time and served from the cached ZIP, so no network request happens during playback. Images are downloaded from AniList CDN once per character and stored in `character-dictionaries/img/`.
|
||||
|
||||
If a snapshot was generated before portrait data was available (e.g. during an earlier version or offline sync), SubMiner detects the missing image data on the next media match and automatically refreshes the snapshot so portraits are included in the next merged dictionary build.
|
||||
|
||||
**To enable:**
|
||||
|
||||
- Settings → Subtitle Style → **Name Match Images**, or
|
||||
- `subtitleStyle.nameMatchImagesEnabled: true` in config.
|
||||
|
||||
The portrait size is controlled by the surrounding subtitle font size and renders as a circle clipped from the character's AniList cover image.
|
||||
|
||||
::: tip
|
||||
Inline portraits help you quickly associate names with faces while building vocabulary - especially useful for shows with large casts where you're still learning who's who.
|
||||
:::
|
||||
|
||||
## Dictionary entries
|
||||
|
||||
Each character entry in the Yomitan dictionary includes structured content:
|
||||
|
||||
- **Name** - the matched Japanese name form
|
||||
- **Known names** - generated non-honorific Japanese aliases for that character, excluding raw romanized/English aliases from lookup results
|
||||
- **Role badge** - color-coded by role: main / "Protagonist" (score 100), primary / "Main Character" (75), side / "Side Character" (50), appears / "Minor Role" (25). AniList's MAIN maps to main, SUPPORTING to primary, and BACKGROUND to side.
|
||||
- **Portrait** - character image from AniList, embedded in the ZIP
|
||||
- **Description** - biography text from AniList (collapsible)
|
||||
- **Character information** - age, birthday, gender, blood type (collapsible)
|
||||
- **Voiced by** - voice actor name and portrait (collapsible)
|
||||
|
||||
The three collapsible sections can be configured to start open or closed:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"anilist": {
|
||||
"characterDictionary": {
|
||||
"collapsibleSections": {
|
||||
"description": false,
|
||||
"characterInformation": false,
|
||||
"voicedBy": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Auto-sync lifecycle
|
||||
|
||||
When `subtitleStyle.nameMatchEnabled` is `true`, SubMiner runs an auto-sync routine whenever the active media changes.
|
||||
|
||||
These phases are emitted through the configured notification surface. Some phases are skipped when unnecessary: `generating` only appears on a cache miss, `building` only appears when the merged ZIP must be rebuilt, and `importing` only appears when Yomitan needs a new dictionary import.
|
||||
|
||||
**Phases:**
|
||||
|
||||
1. **checking** - Is there already a cached snapshot for this media ID?
|
||||
2. **generating** - No cache hit: fetch characters from AniList GraphQL, download portraits (250ms throttle between image requests), save snapshot JSON.
|
||||
3. MRU update (no notification) - add the media ID to the most-recently-used list and evict old entries beyond `maxLoaded`.
|
||||
4. **building** - Merge active snapshots into a single Yomitan ZIP. A SHA-1 revision hash is computed from the media set - if it matches the previously imported revision, the import is skipped.
|
||||
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.
|
||||
|
||||
**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
|
||||
{
|
||||
"activeMediaIds": ["170942 - Frieren", "163134 - ...", "154587 - ..."],
|
||||
"mergedRevision": "a1b2c3d4e5f6",
|
||||
"mergedDictionaryTitle": "SubMiner Character Dictionary",
|
||||
}
|
||||
```
|
||||
|
||||
(Entries are `"<mediaId> - <title>"` label strings; bare numeric IDs from older versions are still read.)
|
||||
|
||||
The `maxLoaded` setting (default: 3) controls how many media snapshots stay in the active set. When you start a 4th title, the oldest is evicted and the merged dictionary is rebuilt without it.
|
||||
|
||||
## Manual generation
|
||||
|
||||
You can generate a character dictionary from the command line without auto-sync:
|
||||
|
||||
```bash
|
||||
# Generate for a file or directory
|
||||
subminer dictionary /path/to/media
|
||||
|
||||
# Generate for current anime (AppImage)
|
||||
SubMiner.AppImage --dictionary
|
||||
```
|
||||
|
||||
This creates a standalone dictionary ZIP for the target media and saves it alongside the snapshots.
|
||||
The first time you watch a show, most of the time goes into downloading portraits, one at a time. A typical cast takes seconds to a minute. A very large cast takes much longer: One Piece has over a thousand characters and takes around 10 minutes. After that the show is cached, and later episodes load it from disk.
|
||||
|
||||
## Correcting AniList matches
|
||||
|
||||
SubMiner uses `guessit` to infer the anime title from the active filename before searching AniList. Some filenames can still resolve to the wrong title. For example, `Re - ZERO, Starting Life in Another World (2016)` can be misread as a different `Re...` series.
|
||||
SubMiner can match the wrong show when a filename is ambiguous, for example `Re - ZERO, Starting Life in Another World (2016)` matching a different `Re...` series. To fix it:
|
||||
|
||||
Use the in-app selector or CLI to pin the correct AniList media for the whole series:
|
||||
1. Press `Ctrl/Cmd+D` to open the character dictionary manager.
|
||||
2. Click **Override**, edit the title if needed, search, and pick the right result.
|
||||
|
||||
- In-app: open the manager with `Ctrl/Cmd+D`, use the **Override** tab/button, edit the prefilled title if needed, then search and choose the correct result.
|
||||
- CLI: `--dictionary-candidates` still lists matches for the current filename guess.
|
||||
From the command line:
|
||||
|
||||
```bash
|
||||
# List candidate AniList matches for a file
|
||||
# List AniList matches for a file
|
||||
subminer dictionary --candidates "/path/to/episode.mkv"
|
||||
|
||||
# Save the correct AniList media ID for that series
|
||||
# Save the correct AniList ID for that series
|
||||
subminer dictionary --select 21355 "/path/to/episode.mkv"
|
||||
|
||||
# Equivalent direct app flags
|
||||
SubMiner.AppImage --dictionary-candidates --dictionary-target "/path/to/episode.mkv"
|
||||
SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary-target "/path/to/episode.mkv"
|
||||
|
||||
# Open the in-app selector from the running app
|
||||
subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}'
|
||||
```
|
||||
|
||||
SubMiner stores manual selections in `character-dictionaries/anilist-overrides.json`. The episode's parent directory **and detected season** define the override scope, so later episodes in the same season keep the selected AniList ID even if their filename guesses differ, while a different season never inherits the override - including when every season sits in one flat folder. When you replace a wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
|
||||
The override applies to every episode of that season in the same folder. Other seasons are not affected, even when they share the folder. The override also sets which entry [AniList watch progress](/anilist-integration) updates, so one fix covers both.
|
||||
|
||||
An override also pins the entry used for [AniList watch progress](/anilist-integration), so correcting a wrong match once fixes both the character dictionary and progress tracking.
|
||||
## Managing loaded shows
|
||||
|
||||
## Managing loaded entries
|
||||
The manager (`Ctrl/Cmd+D`) lists the shows in the merged dictionary and marks the current one.
|
||||
|
||||
Open the manager with `Ctrl/Cmd+D` (`shortcuts.openCharacterDictionaryManager`). The manager shows the merged dictionary's active MRU entries, marks the current anime, and lets you adjust eviction priority for the other loaded entries.
|
||||
- **Remove** drops a show from the dictionary. You cannot remove the show you are watching.
|
||||
- **Up/Down** changes which show gets dropped first when a new one is added.
|
||||
- **Override** replaces a show's AniList match.
|
||||
|
||||
- **Remove** drops a non-current entry from the active merged dictionary and rebuilds/imports once.
|
||||
- **Up/Down** changes MRU order for future eviction; the merged dictionary is rebuilt and re-imported after a reorder.
|
||||
- **Override** opens the AniList selector for that entry's title so you can replace a saved loaded entry.
|
||||
## Generating from the command line
|
||||
|
||||
The current anime cannot be removed while you are watching it; it stays loaded until playback changes.
|
||||
|
||||
## File structure
|
||||
|
||||
All character dictionary data lives under `{userData}/character-dictionaries/`:
|
||||
|
||||
```text
|
||||
character-dictionaries/
|
||||
snapshots/
|
||||
anilist-170942.json # Per-media character snapshot
|
||||
anilist-163134.json
|
||||
merged.zip # Active merged dictionary (imported into Yomitan)
|
||||
auto-sync-state.json # Tracks active media IDs and revision
|
||||
anilist-overrides.json # Manual series-to-AniList overrides
|
||||
img/
|
||||
m170942-c12345.jpg # Character portrait
|
||||
m170942-va67890.jpg # Voice actor portrait
|
||||
```bash
|
||||
subminer dictionary /path/to/media
|
||||
```
|
||||
|
||||
**Snapshot format** (v19, `CHARACTER_DICTIONARY_FORMAT_VERSION`): each snapshot contains the media ID, title, entry count, timestamp, an array of Yomitan term entries, and base64-encoded images. Snapshots with a different format version are regenerated.
|
||||
This builds a standalone dictionary file for that file or folder without playing it. With the AppImage directly, use `SubMiner.AppImage --dictionary`.
|
||||
|
||||
**ZIP structure** follows the Yomitan dictionary format:
|
||||
## Configuration
|
||||
|
||||
```text
|
||||
merged.zip
|
||||
index.json # { title, revision, format: 3, author: "SubMiner", description }
|
||||
tag_bank_1.json # Tag definitions
|
||||
term_bank_1.json # Up to 10,000 terms per bank
|
||||
term_bank_2.json
|
||||
img/ # Embedded character and VA portraits
|
||||
```
|
||||
Defaults are in the [configuration reference](/configuration).
|
||||
|
||||
## Configuration reference
|
||||
|
||||
| Option | Default | Description |
|
||||
| ---------------------------------------------------------------------- | --------- | --------------------------------------------------------------- |
|
||||
| `anilist.characterDictionary.maxLoaded` | `3` | Number of recent media snapshots kept in the merged dictionary |
|
||||
| `anilist.characterDictionary.profileScope` | `"all"` | Apply dictionary to `"all"` Yomitan profiles or `"active"` only |
|
||||
| `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded |
|
||||
| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded |
|
||||
| `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded |
|
||||
| `subtitleStyle.nameMatchEnabled` | `false` | Enable character-dictionary sync and name highlighting |
|
||||
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside matched names |
|
||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches |
|
||||
|
||||
## Reference implementation
|
||||
|
||||
SubMiner's character dictionary builder is inspired by the [Japanese Character Name Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) project - a standalone Rust web service that generates Yomitan character dictionaries from AniList and VNDB data.
|
||||
|
||||
The reference implementation covers the same ground: name variant generation, honorific expansion, structured Yomitan content, and portrait embedding. It also reads VNDB as a source for visual novel characters. Key differences:
|
||||
|
||||
| | SubMiner | Reference Implementation |
|
||||
| ---------------------- | -------------------------------------------- | ------------------------------------- |
|
||||
| **Runtime** | TypeScript, runs inside Electron | Rust, standalone web service |
|
||||
| **Data sources** | AniList only | AniList + VNDB |
|
||||
| **Delivery** | Auto-synced into bundled Yomitan | ZIP download via web UI |
|
||||
| **Honorific strategy** | Eager generation at build time | Lazy generation during ZIP export |
|
||||
| **Caching** | File-based snapshots | Multi-tier (memory + disk + SQLite) |
|
||||
| **Updates** | Revision-hashed; skips reimport if unchanged | URL-encoded settings for auto-refresh |
|
||||
|
||||
If you work with visual novels or want a standalone dictionary generator independent of SubMiner, the reference implementation is worth checking out.
|
||||
| Key | What it does |
|
||||
| ---------------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| `subtitleStyle.nameMatchEnabled` | Build the dictionary and color character names |
|
||||
| `subtitleStyle.nameMatchImagesEnabled` | Show a portrait next to matched names |
|
||||
| `subtitleStyle.nameMatchColor` | Color for character names |
|
||||
| `anilist.characterDictionary.maxLoaded` | Number of recent shows kept in the dictionary |
|
||||
| `anilist.characterDictionary.collapsibleSections.description` | Show the description expanded in the popup |
|
||||
| `anilist.characterDictionary.collapsibleSections.characterInformation` | Show age, birthday, and similar details expanded |
|
||||
| `anilist.characterDictionary.collapsibleSections.voicedBy` | Show the voice actor section expanded |
|
||||
| `shortcuts.openCharacterDictionaryManager` | Shortcut for the manager |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Names not highlighting:** Confirm `subtitleStyle.nameMatchEnabled` is `true`. Check that the current media has an AniList entry - SubMiner needs a media ID to fetch characters.
|
||||
- **Inline portraits missing:** Confirm `subtitleStyle.nameMatchImagesEnabled` is `true`. On the next character dictionary sync, SubMiner refreshes current-version snapshots that do not contain usable cached character portrait data. Portraits still require AniList to return an image and the image download to succeed.
|
||||
- **Sync seems stuck:** The auto-sync debounces for 800ms after media changes and throttles image downloads at 250ms per image. Large casts (50+ characters) take longer. Check the status bar for the current sync phase.
|
||||
- **Wrong characters showing:** Open the in-app character dictionary manager (`Ctrl/Cmd+D`) to remove/reorder loaded titles, then use **Override** to correct the active AniList match. You can also run `--dictionary-candidates`, then save the correct media with `--dictionary-select --dictionary-anilist-id <id>`. SubMiner ignores character entries from other loaded titles for subtitle name matching and inline portraits once the current media ID is known.
|
||||
- **Yomitan import fails:** SubMiner waits up to 7 seconds for Yomitan to be ready for mutations. If Yomitan is still loading dictionaries or performing another import, the operation may time out. Restarting the overlay typically resolves this.
|
||||
- **Portraits missing:** Images are downloaded from AniList CDN during snapshot generation. If the network was unavailable during the initial sync, delete the snapshot file from `character-dictionaries/snapshots/` and let it regenerate.
|
||||
**It seems stuck.** Check the notification. While generating it shows counts (`image 120/400`), an estimate of time left, and an elapsed clock. If the clock moves, it is still working, and large casts are slow (see [how long it takes](#how-long-it-takes)). If you missed the notification, open the notification history with `Ctrl/Cmd+N`. For errors, check the app log ([log locations](/troubleshooting)).
|
||||
|
||||
## Related
|
||||
**Import failed or timed out.** Yomitan may have been busy importing another dictionary. Play the next episode or restart SubMiner. The import may have finished anyway, so check for the character popup before retrying.
|
||||
|
||||
- [Subtitle Annotations](/subtitle-annotations) - how name matches interact with N+1, frequency, and JLPT layers
|
||||
- [AniList Integration](/anilist-integration) - watch-progress sync and AniList authentication (separate from character dictionary)
|
||||
- [Configuration Reference](/configuration) - full config options
|
||||
**Names are not highlighted.** Check that `subtitleStyle.nameMatchEnabled` is `true`, that `yomitan.externalProfilePath` is empty, and that the show was found on AniList. The wrong show's cast means a wrong match. See [correcting AniList matches](#correcting-anilist-matches).
|
||||
|
||||
**Portraits are missing.** Portraits need AniList to have an image and the download to succeed. If you were offline during the first sync, delete that show's file from `character-dictionaries/snapshots/` in the SubMiner config directory and replay it.
|
||||
|
||||
SubMiner's generator is based on the [Japanese Character Name Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) project, which also supports VNDB and works without SubMiner.
|
||||
+415
-1393
File diff suppressed because it is too large.
Load diff
+3
-3
@@ -2,7 +2,7 @@
|
||||
|
||||
Short recordings from real playback sessions.
|
||||
|
||||
Some vocabulary for what follows. _Yomitan_ is the pop-up dictionary. _Jimaku_ is a community subtitle database. _alass_ and _ffsubsync_ retime subtitles against the audio. _Jellyfin_ is a self-hosted media server. A _texthooker_ is a web page that mirrors the current subtitle as selectable text so browser tools can read it.
|
||||
_Yomitan_ is the pop-up dictionary. _Jimaku_ is a community subtitle database. _alass_ and _ffsubsync_ retime subtitles against the audio. _Jellyfin_ is a self-hosted media server. A _texthooker_ is a web page that mirrors the current subtitle as selectable text.
|
||||
|
||||
<script setup>
|
||||
import { withBase } from 'vitepress';
|
||||
@@ -18,7 +18,7 @@ Mine a card from Yomitan or straight from a subtitle line. SubMiner attaches the
|
||||
<source :src="withBase(`/assets/minecard.webm?v=${v}`)" type="video/webm" />
|
||||
<source :src="withBase(`/assets/minecard.mp4?v=${v}`)" type="video/mp4" />
|
||||
<a :href="withBase(`/assets/minecard.webm?v=${v}`)" target="_blank" rel="noreferrer">
|
||||
<img :src="withBase(`/assets/minecard.webp?v=${v}`)" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
|
||||
<img :src="withBase(`/assets/minecard.webp?v=${v}`)" alt="Animated demo of mining a card" style="width: 100%; height: auto;" />
|
||||
</a>
|
||||
</video>
|
||||
|
||||
@@ -36,7 +36,7 @@ Search Jimaku, download a track, then retime it with alass or ffsubsync without
|
||||
|
||||
## Jellyfin integration
|
||||
|
||||
Browse your Jellyfin library, cast to a device, and start playback from SubMiner. Watch progress goes back to the Jellyfin server.
|
||||
Browse your Jellyfin library and play from SubMiner, or cast to SubMiner from another Jellyfin client. Watch progress syncs back to the server.
|
||||
|
||||
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/jellyfin-poster.jpg?v=${v}`)">
|
||||
<source :src="withBase(`/assets/demos/jellyfin.webm?v=${v}`)" type="video/webm" />
|
||||
|
||||
Loaded 100 of 1056 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user