diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b12165 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +
+ SubMiner logo +

SubMiner

+ Look up words, mine to Anki, and enrich cards with context — without leaving mpv. +

+ +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) +[![Linux](https://img.shields.io/badge/platform-Linux%20%7C%20macOS-informational)]() +[![Docs](https://img.shields.io/badge/docs-docs.subminer.moe-blueviolet)](https://docs.subminer.moe) + +
+ +
+ +
+ +[![SubMiner demo (GIF preview)](./assets/minecard.gif)](./assets/minecard.mp4) + +
+ +
+ +## What it does + +SubMiner is an Electron overlay that sits on top of mpv. It turns your video player into a full sentence-mining workstation: + +- **Hover to look up** — Yomitan dictionary popups directly on subtitles +- **One-key mining** — Creates Anki cards with sentence, audio, screenshot, and translation +- **N+1 highlighting** — Marks known words from your Anki deck so unknown ones jump out +- **Subtitle tools** — Download from Jimaku, sync with alass/ffsubsync, all in-player +- **Immersion tracking** — SQLite-powered stats on your watch time and mining activity +- **Texthooker page built in** — WebSocket streaming to external tools, no extra setup + +## Quick start + +### 1. Install + +**Linux (AppImage):** + +```bash +wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner-0.1.0.AppImage -O ~/.local/bin/SubMiner.AppImage +chmod +x ~/.local/bin/SubMiner.AppImage +wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer +chmod +x ~/.local/bin/subminer +``` + +> [!NOTE] +> The `subminer` wrapper uses a [Bun](https://bun.sh) shebang. Make sure `bun` is on your `PATH`. + +**From source** or **macOS** — see the [installation guide](https://docs.subminer.moe/installation#from-source). + +### 2. Install the mpv plugin and configuration file + +```bash +wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets-0.1.0.tar.gz -O /tmp/subminer-assets.tar.gz +tar -xzf /tmp/subminer-assets.tar.gz -C /tmp +cp /tmp/plugin/subminer.lua ~/.config/mpv/scripts/ +cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/ +mkdir -p ~/.config/SubMiner && cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc +``` + +### 3. Set up Yomitan Dictionaries + +```bash +subminer app --start --yomitan +``` + +### 4. Mine + +```bash +subminer app --start --background +subminer video.mkv +``` + +## Requirements + +| Required | Optional | +| ------------------------------------------ | -------------------------------------------------- | +| `bun` | | +| `mpv` with IPC socket | `yt-dlp` | +| `ffmpeg` | `guessit` (better AniSkip title/episode detection) | +| `mecab` + `mecab-ipadic` | `fzf` / `rofi` | +| Linux: `hyprctl` or `xdotool` + `xwininfo` | `chafa`, `ffmpegthumbnailer` | +| macOS: Accessibility permission | | + +## Documentation + +For full guides on configuration, Anki, Jellyfin, and more, see [docs.subminer.moe](https://docs.subminer.moe). + +## Acknowledgments + +Built on the shoulders of [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner), [mpvacious](https://github.com/Ajatt-Tools/mpvacious), [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script), and [autosubsync-mpv](https://github.com/joaquintorres/autosubsync-mpv). Subtitles powered by [Jimaku.cc](https://jimaku.cc). Dictionary lookups via [Yomitan](https://github.com/yomidevs/yomitan). + +## License + +[GNU General Public License v3.0](LICENSE) diff --git a/config.example.jsonc b/config.example.jsonc new file mode 100644 index 0000000..f7f4926 --- /dev/null +++ b/config.example.jsonc @@ -0,0 +1,340 @@ +/** + * SubMiner Example Configuration File + * + * This file is auto-generated from src/config/definitions.ts. + * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed. + */ +{ + + // ========================================== + // Overlay Auto-Start + // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. + // ========================================== + "auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false + + // ========================================== + // Visible Overlay Subtitle Binding + // Control whether visible overlay toggles also toggle MPV subtitle visibility. + // When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged. + // ========================================== + "bind_visible_overlay_to_mpv_sub_visibility": true, // Link visible overlay toggles to MPV subtitle visibility (primary and secondary). Values: true | false + + // ========================================== + // Texthooker Server + // Control whether browser opens automatically for texthooker. + // ========================================== + "texthooker": { + "openBrowser": true // Open browser setting. Values: true | false + }, // Control whether browser opens automatically for texthooker. + + // ========================================== + // WebSocket Server + // Built-in WebSocket server broadcasts subtitle text to connected clients. + // Auto mode disables built-in server if mpv_websocket is detected. + // ========================================== + "websocket": { + "enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false + "port": 6677 // Built-in subtitle websocket server port. + }, // Built-in WebSocket server broadcasts subtitle text to connected clients. + + // ========================================== + // Logging + // Controls logging verbosity. + // Set to debug for full runtime diagnostics. + // ========================================== + "logging": { + "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error + }, // Controls logging verbosity. + + // ========================================== + // Keyboard Shortcuts + // Overlay keyboard shortcuts. Set a shortcut to null to disable. + // Hot-reload: shortcut changes apply live and update the session help modal on reopen. + // ========================================== + "shortcuts": { + "toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting. + "toggleInvisibleOverlayGlobal": "Alt+Shift+I", // Toggle invisible overlay global setting. + "copySubtitle": "CommandOrControl+C", // Copy subtitle setting. + "copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting. + "updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting. + "triggerFieldGrouping": "CommandOrControl+G", // Trigger field grouping setting. + "triggerSubsync": "Ctrl+Alt+S", // Trigger subsync setting. + "mineSentence": "CommandOrControl+S", // Mine sentence setting. + "mineSentenceMultiple": "CommandOrControl+Shift+S", // Mine sentence multiple setting. + "multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes. + "toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting. + "markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting. + "openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting. + "openJimaku": "Ctrl+Shift+J" // Open jimaku setting. + }, // Overlay keyboard shortcuts. Set a shortcut to null to disable. + + // ========================================== + // Invisible Overlay + // Startup behavior for the invisible interactive subtitle mining layer. + // Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel. + // This edit-mode shortcut is fixed and is not currently configurable. + // ========================================== + "invisibleOverlay": { + "startupVisibility": "platform-default" // Startup visibility setting. + }, // Startup behavior for the invisible interactive subtitle mining layer. + + // ========================================== + // Keybindings (MPV Commands) + // Extra keybindings that are merged with built-in defaults. + // Set command to null to disable a default keybinding. + // Hot-reload: keybinding changes apply live and update the session help modal on reopen. + // ========================================== + "keybindings": [], // Extra keybindings that are merged with built-in defaults. + + // ========================================== + // Secondary Subtitles + // Dual subtitle track options. + // Used by subminer YouTube subtitle generation as secondary language preferences. + // Hot-reload: defaultMode updates live while SubMiner is running. + // ========================================== + "secondarySub": { + "secondarySubLanguages": [], // Secondary sub languages setting. + "autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false + "defaultMode": "hover" // Default mode setting. + }, // Dual subtitle track options. + + // ========================================== + // Auto Subtitle Sync + // Subsync engine and executable paths. + // ========================================== + "subsync": { + "defaultMode": "auto", // Subsync default mode. Values: auto | manual + "alass_path": "", // Alass path setting. + "ffsubsync_path": "", // Ffsubsync path setting. + "ffmpeg_path": "" // Ffmpeg path setting. + }, // Subsync engine and executable paths. + + // ========================================== + // Subtitle Position + // Initial vertical subtitle position from the bottom. + // ========================================== + "subtitlePosition": { + "yPercent": 10 // Y percent setting. + }, // Initial vertical subtitle position from the bottom. + + // ========================================== + // Subtitle Appearance + // Primary and secondary subtitle styling. + // Hot-reload: subtitle style changes apply live without restarting SubMiner. + // ========================================== + "subtitleStyle": { + "enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false + "preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false + "hoverTokenColor": "#c6a0f6", // Hex color used for hovered subtitle token highlight in mpv. + "fontFamily": "M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif", // Font family setting. + "fontSize": 35, // Font size setting. + "fontColor": "#cad3f5", // Font color setting. + "fontWeight": "normal", // Font weight setting. + "fontStyle": "normal", // Font style setting. + "backgroundColor": "rgb(30, 32, 48, 0.88)", // Background color setting. + "nPlusOneColor": "#c6a0f6", // N plus one color setting. + "knownWordColor": "#a6da95", // Known word color setting. + "jlptColors": { + "N1": "#ed8796", // N1 setting. + "N2": "#f5a97f", // N2 setting. + "N3": "#f9e2af", // N3 setting. + "N4": "#a6e3a1", // N4 setting. + "N5": "#8aadf4" // N5 setting. + }, // Jlpt colors setting. + "frequencyDictionary": { + "enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false + "sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, built-in discovery search paths are used. + "topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000). + "mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded + "singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`. + "bandedColors": [ + "#ed8796", + "#f5a97f", + "#f9e2af", + "#a6e3a1", + "#8aadf4" + ] // Five colors used for rank bands when mode is `banded` (from most common to least within topX). + }, // Frequency dictionary setting. + "secondary": { + "fontSize": 24, // Font size setting. + "fontColor": "#ffffff", // Font color setting. + "backgroundColor": "transparent", // Background color setting. + "fontWeight": "normal", // Font weight setting. + "fontStyle": "normal", // Font style setting. + "fontFamily": "M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif" // Font family setting. + } // Secondary setting. + }, // Primary and secondary subtitle styling. + + // ========================================== + // AnkiConnect Integration + // Automatic Anki updates and media generation options. + // Hot-reload: AI translation settings update live while SubMiner is running. + // Most other AnkiConnect settings still require restart. + // ========================================== + "ankiConnect": { + "enabled": false, // Enable AnkiConnect integration. Values: true | false + "url": "http://127.0.0.1:8765", // Url setting. + "pollingRate": 3000, // Polling interval in milliseconds. + "tags": [ + "SubMiner" + ], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. + "fields": { + "audio": "ExpressionAudio", // Audio setting. + "image": "Picture", // Image setting. + "sentence": "Sentence", // Sentence setting. + "miscInfo": "MiscInfo", // Misc info setting. + "translation": "SelectionText" // Translation setting. + }, // Fields setting. + "ai": { + "enabled": false, // Enabled setting. Values: true | false + "alwaysUseAiTranslation": false, // Always use ai translation setting. Values: true | false + "apiKey": "", // Api key setting. + "model": "openai/gpt-4o-mini", // Model setting. + "baseUrl": "https://openrouter.ai/api", // Base url setting. + "targetLanguage": "English", // Target language setting. + "systemPrompt": "You are a translation engine. Return only the translated text with no explanations." // System prompt setting. + }, // Ai setting. + "media": { + "generateAudio": true, // Generate audio setting. Values: true | false + "generateImage": true, // Generate image setting. Values: true | false + "imageType": "static", // Image type setting. + "imageFormat": "jpg", // Image format setting. + "imageQuality": 92, // Image quality setting. + "animatedFps": 10, // Animated fps setting. + "animatedMaxWidth": 640, // Animated max width setting. + "animatedCrf": 35, // Animated crf setting. + "audioPadding": 0.5, // Audio padding setting. + "fallbackDuration": 3, // Fallback duration setting. + "maxMediaDuration": 30 // Max media duration setting. + }, // Media setting. + "behavior": { + "overwriteAudio": true, // Overwrite audio setting. Values: true | false + "overwriteImage": true, // Overwrite image setting. Values: true | false + "mediaInsertMode": "append", // Media insert mode setting. + "highlightWord": true, // Highlight word setting. Values: true | false + "notificationType": "osd", // Notification type setting. + "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false + }, // Behavior setting. + "nPlusOne": { + "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false + "refreshMinutes": 1440, // Minutes between known-word cache refreshes. + "matchMode": "headword", // Known-word matching strategy for N+1 highlighting. Values: headword | surface + "decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names. + "minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3). + "nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight. + "knownWord": "#a6da95" // Color used for legacy known-word highlights. + }, // N plus one setting. + "metadata": { + "pattern": "[SubMiner] %f (%t)" // Pattern setting. + }, // Metadata setting. + "isLapis": { + "enabled": false, // Enabled setting. Values: true | false + "sentenceCardModel": "Japanese sentences" // Sentence card model setting. + }, // Is lapis setting. + "isKiku": { + "enabled": false, // Enabled setting. Values: true | false + "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled + "deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false + } // Is kiku setting. + }, // Automatic Anki updates and media generation options. + + // ========================================== + // Jimaku + // Jimaku API configuration and defaults. + // ========================================== + "jimaku": { + "apiBaseUrl": "https://jimaku.cc", // Api base url setting. + "languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none + "maxEntryResults": 10 // Maximum Jimaku search results returned. + }, // Jimaku API configuration and defaults. + + // ========================================== + // YouTube Subtitle Generation + // Defaults for subminer YouTube subtitle extraction/transcription mode. + // ========================================== + "youtubeSubgen": { + "mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off + "whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine. + "whisperModel": "", // Path to whisper model used for fallback transcription. + "primarySubLanguages": [ + "ja", + "jpn" + ] // Comma-separated primary subtitle language priority used by the launcher. + }, // Defaults for subminer YouTube subtitle extraction/transcription mode. + + // ========================================== + // Anilist + // Anilist API credentials and update behavior. + // ========================================== + "anilist": { + "enabled": false, // Enable AniList post-watch progress updates. Values: true | false + "accessToken": "" // Optional explicit AniList access token override; leave empty to use locally stored token from setup. + }, // Anilist API credentials and update behavior. + + // ========================================== + // Jellyfin + // Optional Jellyfin integration for auth, browsing, and playback launch. + // Access token is stored in local encrypted token storage after login/setup. + // jellyfin.accessToken remains an optional explicit override in config. + // ========================================== + "jellyfin": { + "enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false + "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). + "username": "", // Default Jellyfin username used during CLI login. + "deviceId": "subminer", // Device id setting. + "clientName": "SubMiner", // Client name setting. + "clientVersion": "0.1.0", // Client version setting. + "defaultLibraryId": "", // Optional default Jellyfin library ID for item listing. + "remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false + "remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false + "autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false + "remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions. + "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false + "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. + "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false + "directPlayContainers": [ + "mkv", + "mp4", + "webm", + "mov", + "flac", + "mp3", + "aac" + ], // Container allowlist for direct play decisions. + "transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable. + }, // Optional Jellyfin integration for auth, browsing, and playback launch. + + // ========================================== + // Discord Rich Presence + // Optional Discord Rich Presence activity card updates for current playback/study session. + // Uses official SubMiner Discord app assets for polished card visuals. + // ========================================== + "discordPresence": { + "enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false + "updateIntervalMs": 3000, // Minimum interval between presence payload updates. + "debounceMs": 750 // Debounce delay used to collapse bursty presence updates. + }, // Optional Discord Rich Presence activity card updates for current playback/study session. + + // ========================================== + // Immersion Tracking + // Enable/disable immersion tracking. + // Set dbPath to override the default sqlite database location. + // Policy tuning is available for queue, flush, and retention values. + // ========================================== + "immersionTracking": { + "enabled": true, // Enable immersion tracking for mined subtitle metadata. Values: true | false + "dbPath": "", // Optional SQLite database path for immersion tracking. Empty value uses the default app data path. + "batchSize": 25, // Buffered telemetry/event writes per SQLite transaction. + "flushIntervalMs": 500, // Max delay before queue flush in milliseconds. + "queueCap": 1000, // In-memory write queue cap before overflow policy applies. + "payloadCapBytes": 256, // Max JSON payload size per event before truncation. + "maintenanceIntervalMs": 86400000, // Maintenance cadence (prune + rollup + vacuum checks). + "retention": { + "eventsDays": 7, // Raw event retention window in days. + "telemetryDays": 30, // Telemetry retention window in days. + "dailyRollupsDays": 365, // Daily rollup retention window in days. + "monthlyRollupsDays": 1825, // Monthly rollup retention window in days. + "vacuumIntervalDays": 7 // Minimum days between VACUUM runs. + } // Retention setting. + } // Enable/disable immersion tracking. +} diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts new file mode 100644 index 0000000..84f2c71 --- /dev/null +++ b/docs/.vitepress/config.ts @@ -0,0 +1,98 @@ +const repositoryName = process.env.GITHUB_REPOSITORY?.split('/')[1]; +const base = process.env.GITHUB_ACTIONS && repositoryName ? `/${repositoryName}/` : '/'; + +export default { + title: 'SubMiner Docs', + description: + 'SubMiner: an MPV immersion-mining overlay with Yomitan and AnkiConnect integration.', + base, + head: [ + ['link', { rel: 'icon', href: '/favicon.ico', sizes: 'any' }], + [ + 'link', + { + rel: 'icon', + type: 'image/png', + href: '/favicon-32x32.png', + sizes: '32x32', + }, + ], + [ + 'link', + { + rel: 'icon', + type: 'image/png', + href: '/favicon-16x16.png', + sizes: '16x16', + }, + ], + [ + 'link', + { + rel: 'apple-touch-icon', + href: '/apple-touch-icon.png', + sizes: '180x180', + }, + ], + ], + appearance: 'dark', + cleanUrls: true, + lastUpdated: true, + srcExclude: ['subagents/**'], + markdown: { + theme: { + light: 'catppuccin-latte', + dark: 'catppuccin-macchiato', + }, + }, + themeConfig: { + logo: { + light: '/assets/SubMiner.png', + dark: '/assets/SubMiner.png', + }, + siteTitle: 'SubMiner Docs', + nav: [ + { text: 'Home', link: '/' }, + { text: 'Get Started', link: '/installation' }, + { text: 'Mining', link: '/mining-workflow' }, + { text: 'Configuration', link: '/configuration' }, + { text: 'Troubleshooting', link: '/troubleshooting' }, + ], + sidebar: [ + { + text: 'Getting Started', + items: [ + { text: 'Overview', link: '/' }, + { text: 'Installation', link: '/installation' }, + { text: 'Launcher Script', link: '/launcher-script' }, + { text: 'Usage', link: '/usage' }, + { text: 'Mining Workflow', link: '/mining-workflow' }, + ], + }, + { + text: 'Reference', + items: [ + { text: 'Configuration', link: '/configuration' }, + { text: 'Keyboard Shortcuts', link: '/shortcuts' }, + { text: 'Anki Integration', link: '/anki-integration' }, + { text: 'Jellyfin Integration', link: '/jellyfin-integration' }, + { text: 'Immersion Tracking', link: '/immersion-tracking' }, + { text: 'JLPT Vocabulary', link: '/jlpt-vocab-bundle' }, + { text: 'MPV Plugin', link: '/mpv-plugin' }, + { text: 'Troubleshooting', link: '/troubleshooting' }, + ], + }, + { + text: 'Development', + items: [ + { text: 'Building & Testing', link: '/development' }, + { text: 'Architecture', link: '/architecture' }, + ], + }, + ], + search: { + provider: 'local', + }, + socialLinks: [{ icon: 'github', link: 'https://github.com/ksyasuda/SubMiner' }], + }, +}; diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts new file mode 100644 index 0000000..ee87a73 --- /dev/null +++ b/docs/.vitepress/theme/index.ts @@ -0,0 +1,194 @@ +import DefaultTheme from 'vitepress/theme'; +import { useRoute } from 'vitepress'; +import { nextTick, onMounted, watch } from 'vue'; +import '@catppuccin/vitepress/theme/macchiato/mauve.css'; +import './mermaid-modal.css'; + +let mermaidLoader: Promise | null = null; +const MERMAID_MODAL_ID = 'mermaid-diagram-modal'; + +function closeMermaidModal() { + if (typeof document === 'undefined') { + return; + } + + const modal = document.getElementById(MERMAID_MODAL_ID); + if (!modal) { + return; + } + + modal.classList.remove('is-open'); + document.body.classList.remove('mermaid-modal-open'); +} + +function ensureMermaidModal(): HTMLDivElement { + const existing = document.getElementById(MERMAID_MODAL_ID); + if (existing) { + return existing as HTMLDivElement; + } + + const modal = document.createElement('div'); + modal.id = MERMAID_MODAL_ID; + modal.className = 'mermaid-modal'; + modal.innerHTML = ` +
+ + `; + + modal.addEventListener('click', (event) => { + const target = event.target as HTMLElement | null; + if (!target) { + return; + } + + if (target.closest('[data-mermaid-close="true"]') || target.closest('.mermaid-modal__close')) { + closeMermaidModal(); + } + }); + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && modal.classList.contains('is-open')) { + closeMermaidModal(); + } + }); + + document.body.appendChild(modal); + return modal; +} + +function openMermaidModal(sourceNode: HTMLElement) { + if (typeof document === 'undefined') { + return; + } + + const modal = ensureMermaidModal(); + const content = modal.querySelector('.mermaid-modal__content'); + if (!content) { + return; + } + + content.replaceChildren(sourceNode.cloneNode(true)); + modal.classList.add('is-open'); + document.body.classList.add('mermaid-modal-open'); +} + +function attachMermaidInteractions(nodes: HTMLElement[]) { + for (const node of nodes) { + if (node.dataset.mermaidInteractive === 'true') { + continue; + } + + const svg = node.querySelector('svg'); + if (!svg) { + continue; + } + + node.classList.add('mermaid-interactive'); + node.setAttribute('role', 'button'); + node.setAttribute('tabindex', '0'); + node.setAttribute('aria-label', 'Open Mermaid diagram in full view'); + + const open = () => openMermaidModal(svg); + node.addEventListener('click', open); + node.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + open(); + } + }); + + node.dataset.mermaidInteractive = 'true'; + } +} + +async function getMermaid() { + if (!mermaidLoader) { + mermaidLoader = import('mermaid').then((module) => { + const mermaid = module.default; + mermaid.initialize({ + startOnLoad: false, + securityLevel: 'loose', + theme: 'base', + themeVariables: { + background: '#24273a', + primaryColor: '#363a4f', + primaryTextColor: '#cad3f5', + primaryBorderColor: '#c6a0f6', + secondaryColor: '#494d64', + secondaryTextColor: '#cad3f5', + secondaryBorderColor: '#b7bdf8', + tertiaryColor: '#5b6078', + tertiaryTextColor: '#cad3f5', + tertiaryBorderColor: '#8aadf4', + lineColor: '#939ab7', + textColor: '#cad3f5', + mainBkg: '#363a4f', + nodeBorder: '#c6a0f6', + clusterBkg: '#1e2030', + clusterBorder: '#494d64', + edgeLabelBackground: '#24273a', + labelTextColor: '#cad3f5', + }, + }); + return mermaid; + }); + } + return mermaidLoader; +} + +async function renderMermaidBlocks() { + if (typeof document === 'undefined') { + return; + } + const blocks = Array.from(document.querySelectorAll('div.language-mermaid')); + if (blocks.length === 0) { + return; + } + + const mermaid = await getMermaid(); + const nodes: HTMLElement[] = []; + + for (const block of blocks) { + if (block.dataset.mermaidRendered === 'true') { + continue; + } + const code = block.querySelector('pre code'); + const source = code?.textContent?.trim(); + if (!source) { + continue; + } + + const mount = document.createElement('div'); + mount.className = 'mermaid'; + mount.textContent = source; + + block.replaceChildren(mount); + block.dataset.mermaidRendered = 'true'; + nodes.push(mount); + } + + if (nodes.length > 0) { + await mermaid.run({ nodes }); + attachMermaidInteractions(nodes); + } +} + +export default { + ...DefaultTheme, + setup() { + const route = useRoute(); + const render = () => { + nextTick(() => { + renderMermaidBlocks().catch((error) => { + console.error('Failed to render Mermaid diagram:', error); + }); + }); + }; + + onMounted(render); + watch(() => route.path, render); + }, +}; diff --git a/docs/.vitepress/theme/mermaid-modal.css b/docs/.vitepress/theme/mermaid-modal.css new file mode 100644 index 0000000..0c844d2 --- /dev/null +++ b/docs/.vitepress/theme/mermaid-modal.css @@ -0,0 +1,69 @@ +.mermaid-interactive { + cursor: zoom-in; +} + +.mermaid-interactive:focus-visible { + outline: 2px solid var(--vp-c-brand-1); + outline-offset: 4px; + border-radius: 6px; +} + +.mermaid-modal { + position: fixed; + inset: 0; + z-index: 200; + display: none; +} + +.mermaid-modal.is-open { + display: block; +} + +.mermaid-modal__backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.72); +} + +.mermaid-modal__dialog { + position: relative; + z-index: 1; + margin: 4vh auto; + width: min(96vw, 1800px); + max-height: 92vh; + border: 1px solid var(--vp-c-border); + border-radius: 12px; + background: var(--vp-c-bg); + box-shadow: var(--vp-shadow-4); + overflow: hidden; +} + +.mermaid-modal__close { + display: block; + margin-left: auto; + margin-right: 16px; + margin-top: 12px; + border: 1px solid var(--vp-c-border); + border-radius: 6px; + padding: 4px 10px; + background: var(--vp-c-bg-soft); + color: var(--vp-c-text-1); + font-size: 14px; +} + +.mermaid-modal__content { + overflow: auto; + max-height: calc(92vh - 56px); + padding: 8px 16px 16px; +} + +.mermaid-modal__content svg { + max-width: none; + width: max-content; + height: auto; + min-width: 100%; +} + +body.mermaid-modal-open { + overflow: hidden; +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..34845a0 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,35 @@ +# Documentation + +SubMiner documentation is built with [VitePress](https://vitepress.dev/). + +## Local Docs Site + +```bash +make docs-dev # Dev server at http://localhost:5173 +make docs # Build static output +make docs-preview # Preview built site at http://localhost:4173 +``` + +## Pages + +### Getting Started + +- [Installation](/installation) — Requirements, Linux/macOS/Windows install, mpv plugin setup +- [Usage](/usage) — `subminer` wrapper + subcommands (`jellyfin`, `yt`, `doctor`, `config`, `mpv`, `texthooker`, `app`), mpv plugin, keybindings +- [Mining Workflow](/mining-workflow) — End-to-end sentence mining guide, overlay layers, card creation + +### Reference + +- [Configuration](/configuration) — Full config file reference and option details +- [Keyboard Shortcuts](/shortcuts) — All global, overlay, mining, and plugin chord shortcuts in one place +- [Anki Integration](/anki-integration) — AnkiConnect setup, field mapping, media generation, field grouping +- [Jellyfin Integration](/jellyfin-integration) — Optional Jellyfin auth, cast discovery, remote control, and playback launch +- [Immersion Tracking](/immersion-tracking) — SQLite schema, retention/rollup policies, query templates, and extension points +- [JLPT Vocabulary](/jlpt-vocab-bundle) — Bundled term-meta bank for JLPT level underlining and frequency highlighting +- [MPV Plugin](/mpv-plugin) — Chord keybindings, subminer.conf options, script messages +- [Troubleshooting](/troubleshooting) — Common issues and solutions by category + +### Development + +- [Building & Testing](/development) — Build commands, test suites, contributor notes, environment variables +- [Architecture](/architecture) — Service-oriented design, composition model, renderer module layout diff --git a/docs/anki-integration.md b/docs/anki-integration.md new file mode 100644 index 0000000..51a0e8f --- /dev/null +++ b/docs/anki-integration.md @@ -0,0 +1,258 @@ +# 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. + +## 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. + +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. + +## How Polling Works + +SubMiner polls AnkiConnect at a regular interval (default: 3 seconds, configurable via `ankiConnect.pollingRate`) to detect new cards. When it finds a card that was added since the last poll: + +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. Fills the translation field from the secondary subtitle or AI. +5. Writes metadata to the miscInfo field. + +Polling uses the query `"deck:" added:1` to find recently added cards. If no deck is configured, it searches all decks. + +## Field Mapping + +SubMiner maps its data to your Anki note fields. Configure these under `ankiConnect.fields`: + +```jsonc +"ankiConnect": { + "fields": { + "audio": "ExpressionAudio", // audio clip from the video + "image": "Picture", // screenshot or animated clip + "sentence": "Sentence", // subtitle text + "miscInfo": "MiscInfo", // metadata (filename, timestamp) + "translation": "SelectionText" // secondary sub or AI translation + } +} +``` + +Field names must match your Anki note type exactly (case-sensitive). If a configured field does not exist on the note type, SubMiner skips it without error. + +### Minimal Config + +If you only want sentence and audio on your cards: + +```jsonc +"ankiConnect": { + "enabled": true, + "fields": { + "sentence": "Sentence", + "audio": "ExpressionAudio" + } +} +``` + +## Media Generation + +SubMiner uses FFmpeg to generate audio and image media from the video. FFmpeg must be installed and on `PATH`. + +### Audio + +Audio is extracted from the video file using the subtitle's start and end timestamps, with configurable padding added before and after. + +```jsonc +"ankiConnect": { + "media": { + "generateAudio": true, + "audioPadding": 0.5, // seconds before and after subtitle timing + "maxMediaDuration": 30 // cap total duration in seconds + } +} +``` + +Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream. + +The audio is uploaded to Anki's media folder and inserted as `[sound:audio_.mp3]`. + +### 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": null, // optional, preserves aspect ratio + "imageMaxHeight": null + } +} +``` + +### Animated Clips (AVIF) + +Instead of a static screenshot, SubMiner can generate an animated AVIF covering the subtitle duration. + +```jsonc +"ankiConnect": { + "media": { + "generateImage": true, + "imageType": "avif", + "animatedFps": 10, + "animatedMaxWidth": 640, + "animatedMaxHeight": null, + "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. + +### 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 + "notificationType": "osd" // "osd", "system", "both", or "none" + } +} +``` + +## AI Translation + +SubMiner can auto-translate the mined sentence and fill the translation field. By default, if a secondary subtitle track is available, its text is used. When AI is enabled, SubMiner calls an LLM API instead. + +```jsonc +"ankiConnect": { + "ai": { + "enabled": true, + "alwaysUseAiTranslation": false, // true = ignore secondary sub + "apiKey": "sk-...", + "model": "openai/gpt-4o-mini", + "baseUrl": "https://openrouter.ai/api", + "targetLanguage": "English", + "systemPrompt": "You are a translation engine. Return only the translation." + } +} +``` + +Translation priority: + +1. If `alwaysUseAiTranslation` is `true`, always call the AI API. +2. If a secondary subtitle is available, use it as the translation. +3. If AI is enabled and no secondary subtitle exists, call the AI API. +4. Otherwise, leave the field empty. + +## 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. + +```jsonc +"ankiConnect": { + "isLapis": { + "enabled": true, + "sentenceCardModel": "Japanese sentences" + } +} +``` + +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. + +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. + +## Field Grouping (Kiku) + +When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types like [Kiku](https://github.com/youyoumu/kiku) that support grouped sentence/audio/image fields. + +```jsonc +"ankiConnect": { + "isKiku": { + "enabled": true, + "fieldGrouping": "manual", // "auto", "manual", or "disabled" + "deleteDuplicateInAuto": true // delete new card after auto-merge + } +} +``` + +### Modes + +**Disabled** (`"disabled"`): No duplicate detection. Each card is independent. + +**Auto** (`"auto"`): When a duplicate expression is found, SubMiner merges the new card into the existing one automatically. Both sentences, audio clips, and images are preserved. If `deleteDuplicateInAuto` is true, the new card is deleted after merging. + +**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. + +### What Gets Merged + +| Field | Merge behavior | +| -------- | -------------------------------------------------------------- | +| Sentence | Both sentences preserved, labeled `[Original]` / `[Duplicate]` | +| Audio | Both `[sound:...]` entries kept | +| Image | Both images kept | + +### Keyboard Shortcuts in the Modal + +| Key | Action | +| --------- | ---------------------------------- | +| `1` / `2` | Select card 1 or card 2 to keep | +| `Enter` | Confirm selection | +| `Esc` | Cancel (keep both cards unchanged) | + +## Full Config Example + +```jsonc +{ + "ankiConnect": { + "enabled": true, + "url": "http://127.0.0.1:8765", + "pollingRate": 3000, + "fields": { + "audio": "ExpressionAudio", + "image": "Picture", + "sentence": "Sentence", + "miscInfo": "MiscInfo", + "translation": "SelectionText", + }, + "media": { + "generateAudio": true, + "generateImage": true, + "imageType": "static", + "imageFormat": "jpg", + "imageQuality": 92, + "audioPadding": 0.5, + "maxMediaDuration": 30, + }, + "behavior": { + "overwriteAudio": true, + "overwriteImage": true, + "mediaInsertMode": "append", + "autoUpdateNewCards": true, + "notificationType": "osd", + }, + "ai": { + "enabled": false, + "apiKey": "", + "model": "openai/gpt-4o-mini", + "baseUrl": "https://openrouter.ai/api", + "targetLanguage": "English", + }, + "isKiku": { + "enabled": false, + "fieldGrouping": "disabled", + "deleteDuplicateInAuto": true, + }, + "isLapis": { + "enabled": false, + "sentenceCardModel": "Japanese sentences", + }, + }, +} +``` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..90084da --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,359 @@ +# Architecture + +SubMiner is split into 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.lua`) for player-side controls and IPC handoff. + +Within the desktop app, `src/main.ts` is a composition root that wires small runtime/domain modules plus core services. + +## 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 + +```text +launcher/ # Standalone CLI launcher wrapper and mpv helpers + commands/ # Command modules (doctor/config/mpv/jellyfin/playback/app passthrough) + config/ # Launcher config parsers + CLI parser builder + main.ts # Launcher entrypoint and command dispatch +plugin/ + subminer.lua # mpv plugin (auto-start, IPC, AniSkip + hover controls) +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 + app-lifecycle.ts # App lifecycle + app-ready runtime runner factories + 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 + 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 interaction modules + modals/ # Jimaku/Kiku/subsync/runtime-options/session-help modals + positioning/ # Invisible-layer layout + offset controllers + window-trackers/ # Backend-specific tracker implementations (Hyprland, Sway, X11, macOS) + jimaku/ # Jimaku API integration helpers + subsync/ # Subtitle sync (alass/ffsubsync) helpers + subtitle/ # Subtitle processing utilities + tokenizers/ # Tokenizer implementations + token-mergers/ # Token merge strategies + translators/ # AI translation providers +``` + +### Service Layer (`src/core/services/`) + +- **Overlay/window runtime:** `overlay-manager.ts`, `overlay-window.ts`, `overlay-window-geometry.ts`, `overlay-visibility.ts`, `overlay-bridge.ts`, `overlay-runtime-init.ts`, `overlay-content-measurement.ts`, `overlay-drop.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 +- **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` +- **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) + +### Renderer Layer (`src/renderer/`) + +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 + 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 + positioning/ + controller.ts # Position controller orchestration + invisible-layout*.ts # Invisible layer layout computations + position-state.ts # Position state helpers + handlers/ + keyboard.ts # Keybindings, chord handling, modal key routing + mouse.ts # Hover/drag behavior, selection + observer wiring + 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 + 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.lua` runs inside mpv and handles IPC startup checks, overlay toggles, hover-token messages, and AniSkip intro-skip UX. + +## Flow Diagram + +The main process has three layers: `main.ts` delegates to composition modules that wire together domain services. Three overlay windows (visible, invisible, secondary) run in separate Electron renderer processes, connected through `preload.ts`. External runtimes (launcher CLI and mpv plugin) operate independently and communicate via IPC socket or CLI passthrough. + +```mermaid +flowchart LR + classDef entry fill:#c6a0f6,stroke:#494d64,color:#24273a,stroke-width:2px,font-weight:bold + classDef comp fill:#b7bdf8,stroke:#494d64,color:#24273a,stroke-width:1.5px + classDef svc fill:#8aadf4,stroke:#494d64,color:#24273a,stroke-width:1.5px + classDef bridge fill:#f5a97f,stroke:#494d64,color:#24273a,stroke-width:1.5px + classDef rend fill:#8bd5ca,stroke:#494d64,color:#24273a,stroke-width:1.5px + classDef ext fill:#a6da95,stroke:#494d64,color:#24273a,stroke-width:1.5px + classDef extrt fill:#eed49f,stroke:#494d64,color:#24273a,stroke-width:1.5px + + subgraph ExtRt["External Runtimes"] + Launcher["launcher/
CLI dispatch"]:::extrt + Plugin["subminer.lua
mpv plugin"]:::extrt + end + + subgraph Ext["External Systems"] + mpvExt["mpv player"]:::ext + AnkiExt["AnkiConnect"]:::ext + JimakuExt["Jimaku API"]:::ext + TrackerExt["Window Tracker
Hyprland · Sway
X11 · macOS"]:::ext + AnilistExt["AniList API"]:::ext + JellyfinExt["Jellyfin"]:::ext + DiscordExt["Discord RPC"]:::ext + end + + Main["main.ts
composition root"]:::entry + + subgraph Comp["Composition — src/main/"] + Startup["Startup & Lifecycle
startup · app-lifecycle
startup-lifecycle · state"]:::comp + Wiring["Runtime Wiring
ipc-runtime · cli-runtime
overlay-runtime"]:::comp + Composers["Composers
mpv · anilist
jellyfin"]:::comp + end + + subgraph Svc["Services — src/core/services/"] + Mpv["MPV Stack
transport · protocol
properties · metrics"]:::svc + Overlay["Overlay Manager
window · geometry
visibility · bridge"]:::svc + Mining["Mining & Subtitles
mining · field-grouping
subtitle-ws · tokenizer"]:::svc + Integrations["Integrations
jimaku · subsync
texthooker · yomitan"]:::svc + Tracking["Tracking
anilist · jellyfin
immersion · discord"]:::svc + Config["Config & Runtime
hot-reload
runtime-options"]:::svc + end + + Bridge(["preload.ts
Electron IPC"]):::bridge + + subgraph Rend["Renderer — src/renderer/"] + Visible["Visible window
Yomitan lookups"]:::rend + Invisible["Invisible window
mpv positioning"]:::rend + Secondary["Secondary window
subtitle bar"]:::rend + UI["subtitle-render
positioning
handlers · modals"]:::rend + end + + Launcher -->|"CLI"| Main + Plugin -->|"IPC"| mpvExt + + Main --> Comp + Comp --> Svc + + mpvExt <-->|"JSON socket"| Mpv + AnkiExt <-->|"HTTP"| Mining + JimakuExt <-->|"HTTP"| Integrations + TrackerExt <-->|"platform"| Overlay + AnilistExt <-->|"HTTP"| Tracking + JellyfinExt <-->|"HTTP"| Tracking + DiscordExt <-->|"RPC"| Integrations + + Overlay & Mining --> Bridge + Bridge --> Visible + Bridge --> Invisible + Bridge --> Secondary + Visible & Invisible & Secondary --> UI + + style Comp fill:#363a4f,stroke:#494d64,color:#cad3f5 + style Svc fill:#363a4f,stroke:#494d64,color:#cad3f5 + style Rend fill:#363a4f,stroke:#494d64,color:#cad3f5 + style Ext fill:#363a4f,stroke:#494d64,color:#cad3f5 + style ExtRt fill:#363a4f,stroke:#494d64,color:#cad3f5 +``` + +## Composition Pattern + +Most runtime code follows a dependency-injection pattern: + +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. + +The composition root (`src/main.ts`) delegates to focused modules in `src/main/` and `src/main/runtime/composers/`: + +- `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 + +Composer modules share contract conventions via `src/main/runtime/composers/contracts.ts`: + +- composer input surfaces are declared with `ComposerInputs` so required dependencies cannot be omitted at compile time +- composer outputs are declared with `ComposerOutputs` to keep result contracts explicit and stable +- builder return payload extraction should use shared type helpers instead of inline ad-hoc inference + +This keeps side effects explicit and makes behavior easy to unit-test with fakes. + +Additional conventions in the current code: + +- `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. + +## 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 26 properties), and initializes the `RuntimeOptionsManager`, `SubtitleTimingTracker`, and `ImmersionTrackerService`. +- **Overlay runtime:** `initializeOverlayRuntime()` creates three overlay windows — **visible** (interactive Yomitan lookups), **invisible** (mpv-matched subtitle positioning), and **secondary** (secondary subtitle bar, top 20% via `splitOverlayGeometryForSecondaryBar`) — then registers global shortcuts and sets initial bounds from the window tracker. +- **Background warmups:** Non-critical services are launched asynchronously: MeCab tokenizer check, Yomitan extension load, JLPT + frequency dictionary prewarm, optional Jellyfin remote session, Discord presence service, and AniList token refresh. +- **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 `SubtitlePipeline` (normalize → tokenize → merge), and results broadcast to all overlay windows. +- **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, and cleans Anki/AniList state. + +```mermaid +flowchart LR + classDef start fill:#c6a0f6,stroke:#494d64,color:#24273a,stroke-width:2px,font-weight:bold + classDef phase fill:#b7bdf8,stroke:#494d64,color:#24273a,stroke-width:1.5px + classDef decision fill:#f5a97f,stroke:#494d64,color:#24273a,stroke-width:1.5px + classDef init fill:#8aadf4,stroke:#494d64,color:#24273a,stroke-width:1.5px + classDef runtime fill:#8bd5ca,stroke:#494d64,color:#24273a,stroke-width:1.5px + classDef shutdown fill:#ed8796,stroke:#494d64,color:#24273a,stroke-width:1.5px + classDef warmup fill:#eed49f,stroke:#494d64,color:#24273a,stroke-width:1.5px + + CLI["CLI args &
environment"]:::start + CLI --> Proto["Module-level init
register protocols
construct services
wire deps"]:::phase + Proto --> Parse["startup.ts
parse argv
detect backend"]:::phase + Parse --> GenCheck{"--generate
-config?"}:::decision + GenCheck -->|"yes"| GenExit["Write template
& exit"]:::phase + GenCheck -->|"no"| Lock["app-lifecycle.ts
single-instance lock
lifecycle hooks"]:::phase + + Lock -->|"app.whenReady()"| Ready["composeAppReady
Runtime()"]:::phase + + Ready --> Config["Config reload
keybindings
log level"]:::init + Ready --> MpvInit["MpvIpcClient
connect socket
subscribe 26 props"]:::init + Ready --> Platform["RuntimeOptions
timing tracker
immersion tracker"]:::init + + Config --> OverlayInit + MpvInit --> OverlayInit + Platform --> OverlayInit + + OverlayInit["initializeOverlay
Runtime()"]:::phase + + OverlayInit --> VisWin["Visible window
Yomitan lookups"]:::init + OverlayInit --> InvWin["Invisible window
mpv positioning"]:::init + OverlayInit --> SecWin["Secondary window
subtitle bar"]:::init + OverlayInit --> Shortcuts["Register global
shortcuts"]:::init + + VisWin --> Warmups + InvWin --> Warmups + SecWin --> Warmups + Shortcuts --> Warmups + + Warmups["Background
warmups"]:::phase + + Warmups --> W1["MeCab"]:::warmup + Warmups --> W2["Yomitan"]:::warmup + Warmups --> W3["JLPT + freq
dictionaries"]:::warmup + Warmups --> W4["Jellyfin"]:::warmup + Warmups --> W5["Discord"]:::warmup + Warmups --> W6["AniList"]:::warmup + + W1 & W2 & W3 & W4 & W5 & W6 --> Loop + + subgraph Loop["Runtime — event-driven"] + direction TB + MpvEvt["mpv events: subtitle · timing · metrics"]:::runtime + IpcEvt["IPC: renderer requests · CLI commands"]:::runtime + ExtEvt["Shortcuts · config hot-reload"]:::runtime + MpvEvt & IpcEvt & ExtEvt --> Route["Route via composers"]:::runtime + Route --> Process["SubtitlePipeline
normalize → tokenize → merge"]:::runtime + Process --> Broadcast["Update AppState
broadcast to windows"]:::runtime + end + + Loop -->|"quit signal"| Quit["will-quit"]:::shutdown + + Quit --> T1["Tray · config watcher
global shortcuts"]:::shutdown + Quit --> T2["WebSocket · texthooker
mpv socket · OSD log"]:::shutdown + Quit --> T3["Window tracker
Yomitan parser"]:::shutdown + Quit --> T4["Immersion tracker
Jellyfin · Discord
Anki · AniList"]:::shutdown + + style Loop fill:#363a4f,stroke:#494d64,color:#cad3f5 +``` + +## 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. +- 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. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..5d031cb --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,904 @@ +# Configuration + +Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` when `XDG_CONFIG_HOME` is unset). + +## Quick Start + +For most users, start with this minimal configuration: + +```json +{ + "ankiConnect": { + "enabled": true, + "deck": "YourDeckName", + "fields": { + "sentence": "Sentence", + "audio": "Audio", + "image": "Image" + } + } +} +``` + +Then customize as needed using the sections below. + +## Configuration File + +See [config.example.jsonc](/config.example.jsonc) for a comprehensive example configuration file with all available options, default values, and detailed comments. Only include the options you want to customize in your config file. + +Generate a fresh default config from the centralized config registry: + +```bash +SubMiner.AppImage --generate-config +SubMiner.AppImage --generate-config --config-path /tmp/subminer.jsonc +SubMiner.AppImage --generate-config --backup-overwrite +``` + +- `--generate-config` writes a default JSONC config template. +- JSONC config supports comments and trailing commas. +- If the target file exists, SubMiner prompts to create a timestamped backup and overwrite. +- In non-interactive shells, use `--backup-overwrite` to explicitly back up and overwrite. + +Malformed config syntax (invalid JSON/JSONC) is startup-blocking: SubMiner shows a clear parse error with the config path and asks you to fix the file and restart. + +For valid JSON/JSONC with invalid option values, SubMiner uses warn-and-fallback behavior: it logs the bad key/value and continues with the default for that option. + +### Hot-Reload Behavior + +SubMiner watches the active config file (`config.jsonc` or `config.json`) while running and applies supported updates automatically. + +Hot-reloadable fields: + +- `subtitleStyle` +- `keybindings` +- `shortcuts` +- `secondarySub.defaultMode` +- `ankiConnect.ai` + +When these values change, SubMiner applies them live. Invalid config edits are rejected and the previous valid runtime config remains active. + +Restart-required changes: + +- Any other config sections still require restart. +- SubMiner shows an on-screen/system notification listing restart-required sections when they change. + +### Configuration Options Overview + +The configuration file includes several main sections: + +- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media +- [**Auto-Start Overlay**](#auto-start-overlay) - Automatically show overlay on MPV connection +- [**Visible Overlay Subtitle Binding**](#visible-overlay-subtitle-binding) - Link visible overlay toggles to MPV subtitle visibility +- [**Auto Subtitle Sync**](#auto-subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync` +- [**Invisible Overlay**](#invisible-overlay) - Startup visibility behavior for the invisible mining layer +- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults +- [**AniList**](#anilist) - Optional post-watch progress updates +- [**Jellyfin**](#jellyfin) - Optional Jellyfin auth, library listing, and playback launch +- [**Discord Rich Presence**](#discord-rich-presence) - Optional Discord activity card updates +- [**Keybindings**](#keybindings) - MPV command shortcuts +- [**Runtime Option Palette**](#runtime-option-palette) - Live, session-only option toggles +- [**Secondary Subtitles**](#secondary-subtitles) - Dual subtitle track support +- [**Shortcuts Configuration**](#shortcuts-configuration) - Overlay keyboard shortcuts +- [**Subtitle Position**](#subtitle-position) - Overlay vertical positioning +- [**Subtitle Style**](#subtitle-style) - Appearance customization +- [**Texthooker**](#texthooker) - Control browser opening behavior +- [**WebSocket Server**](#websocket-server) - Built-in subtitle broadcasting server +- [**Immersion Tracking**](#immersion-tracking) - Track subtitle sessions and mining activity in SQLite +- [**YouTube Subtitle Generation**](#youtube-subtitle-generation) - Launcher defaults for yt-dlp + local whisper fallback + +### AnkiConnect + +Enable automatic Anki card creation and updates with media generation: + +```json +{ + "ankiConnect": { + "enabled": true, + "url": "http://127.0.0.1:8765", + "pollingRate": 3000, + "tags": ["SubMiner"], + "deck": "Learning::Japanese", + "fields": { + "audio": "ExpressionAudio", + "image": "Picture", + "sentence": "Sentence", + "miscInfo": "MiscInfo", + "translation": "SelectionText" + }, + "ai": { + "enabled": false, + "alwaysUseAiTranslation": false, + "apiKey": "", + "model": "openai/gpt-4o-mini", + "baseUrl": "https://openrouter.ai/api", + "targetLanguage": "English", + "systemPrompt": "You are a translation engine. Return only the translated text with no explanations." + }, + "media": { + "generateAudio": true, + "generateImage": true, + "imageType": "static", + "imageFormat": "jpg", + "imageQuality": 92, + "imageMaxWidth": 1280, + "imageMaxHeight": 720, + "animatedFps": 10, + "animatedMaxWidth": 640, + "animatedMaxHeight": 360, + "animatedCrf": 35, + "audioPadding": 0.5, + "fallbackDuration": 3, + "maxMediaDuration": 30 + }, + "behavior": { + "autoUpdateNewCards": true, + "overwriteAudio": true, + "overwriteImage": true + }, + "metadata": { + "pattern": "[SubMiner] %f (%t)" + }, + "isLapis": { + "enabled": true, + "sentenceCardModel": "Japanese sentences" + }, + "isKiku": { + "enabled": false, + "fieldGrouping": "disabled", + "deleteDuplicateInAuto": true + } + } +} +``` + +This example is intentionally compact. The option table below documents available `ankiConnect` settings and behavior. + +**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation. + +| Option | Values | Description | +| --------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `false`) | +| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) | +| `pollingRate` | number (ms) | How often to check for new cards (default: `3000`) | +| `tags` | array of strings | Tags automatically added to cards mined/updated by SubMiner (default: `['SubMiner']`; set `[]` to disable automatic tagging). | +| `deck` | string | Anki deck to monitor for new cards | +| `ankiConnect.nPlusOne.decks` | array of strings | Decks used for N+1 known-word cache lookups. When omitted/empty, falls back to `ankiConnect.deck`. | +| `fields.audio` | string | Card field for audio files (default: `ExpressionAudio`) | +| `fields.image` | string | Card field for images (default: `Picture`) | +| `fields.sentence` | string | Card field for sentences (default: `Sentence`) | +| `fields.miscInfo` | string | Card field for metadata (default: `"MiscInfo"`, set to `null` to disable) | +| `fields.translation` | string | Card field for sentence-card translation/back text (default: `SelectionText`) | +| `ai.enabled` | `true`, `false` | Use AI translation for sentence cards. Also auto-attempted when secondary subtitle is missing. | +| `ai.alwaysUseAiTranslation` | `true`, `false` | When `true`, always use AI translation even if secondary subtitles exist. When `false`, AI is used only when no secondary subtitle exists. | +| `ai.apiKey` | string | API key for your OpenAI-compatible endpoint (required for translation). | +| `ai.model` | string | Model id for your OpenAI-compatible endpoint (default: `openai/gpt-4o-mini`). | +| `ai.baseUrl` | string (URL) | OpenAI-compatible API base URL; accepts with or without `/v1`. | +| `ai.targetLanguage` | string | Target language name used in translation prompt (default: `English`). | +| `ai.systemPrompt` | string | System prompt used for translation (default returns translation text only). | +| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) | +| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) | +| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) | +| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) | +| `media.imageQuality` | number (1-100) | Image quality for JPG/WebP; PNG ignores this (default: `92`) | +| `media.imageMaxWidth` | number (px) | Optional max width for static screenshots. Unset keeps source width. | +| `media.imageMaxHeight` | number (px) | Optional max height for static screenshots. Unset keeps source height. | +| `media.animatedFps` | number (1-60) | FPS for animated AVIF (default: `10`) | +| `media.animatedMaxWidth` | number (px) | Max width for animated AVIF (default: `640`) | +| `media.animatedMaxHeight` | number (px) | Optional max height for animated AVIF. Unset keeps source aspect-constrained height. | +| `media.animatedCrf` | number (0-63) | CRF quality for AVIF; lower = higher quality (default: `35`) | +| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) | +| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) | +| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) | +| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode` (default: `true`) | +| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) | +| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) | +| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) | +| `ankiConnect.nPlusOne.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) | +| `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). | +| `ankiConnect.nPlusOne.knownWord` | hex color string | Legacy known-word color kept for backward compatibility (default: `"#a6da95"`). | +| `ankiConnect.nPlusOne.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. | +| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). | +| `ankiConnect.nPlusOne.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) | +| `ankiConnect.nPlusOne.decks` | array of strings | Decks used by known-word cache refresh. Leave empty for compatibility with legacy `deck` scope. | +| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) | +| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) | +| `metadata.pattern` | string | Format pattern for metadata: `%f`=filename, `%F`=filename+ext, `%t`=time | +| `isLapis` | object | Lapis/shared sentence-card config: `{ enabled, sentenceCardModel }`. Sentence/audio field names are fixed to `Sentence` and `SentenceAudio`. | +| `isKiku` | object | Kiku-only config: `{ enabled, fieldGrouping, deleteDuplicateInAuto }` (shared sentence/audio/model settings are inherited from `isLapis`) | + +**Kiku / Lapis Note Type Support:** + +SubMiner supports the [Lapis](https://github.com/donkuri/lapis) and [Kiku](https://kiku.youyoumu.my.id/) note types. Both `isLapis.enabled` and `isKiku.enabled` can be true; Kiku takes precedence for grouping behavior, while sentence-card model/field settings come from `isLapis`. + +When enabled, sentence cards automatically set `IsSentenceCard` to `"x"` and populate the `Expression` field. Audio cards set `IsAudioCard` to `"x"`. + +Kiku extends Lapis with **field grouping** — when a duplicate card is detected (same Word/Expression), SubMiner merges the two cards' content into one using Kiku's `data-group-id` HTML structure, organizing each mining instance into separate pages within the note. + +### N+1 Word Highlighting + +When `ankiConnect.nPlusOne.highlightEnabled` is enabled, SubMiner builds a local cache of known words from Anki to highlight already learned tokens in subtitle rendering. + +Known-word cache policy: + +- Initial sync runs when the integration starts if the cache is missing or stale. +- `ankiConnect.nPlusOne.refreshMinutes` controls the minimum time between refreshes; between refreshes, cached words are reused without querying Anki. +- `ankiConnect.nPlusOne.nPlusOne` sets the color for the single target token when exactly one eligible unknown word exists. +- `ankiConnect.nPlusOne.minSentenceWords` sets the minimum token count required in a sentence for N+1 highlighting (default: `3`). +- `ankiConnect.nPlusOne.knownWord` sets the legacy known-word highlight color for tokens already in Anki. +- `ankiConnect.nPlusOne.decks` accepts one or more decks. If empty, it uses the legacy single `ankiConnect.deck` value as scope. +- Cache state is persisted to `known-words-cache.json` under the app `userData` directory. +- The cache is automatically invalidated when the configured scope changes (for example, when deck changes). +- Cache lookups are in-memory. By default, token headwords are matched against cached `Expression` / `Word` values; set `ankiConnect.nPlusOne.matchMode` to `"surface"` for raw subtitle text matching. +- `ankiConnect.behavior.nPlusOne*` legacy keys (`nPlusOneHighlightEnabled`, `nPlusOneRefreshMinutes`, `nPlusOneMatchMode`) are deprecated and only kept for backward compatibility. +- Legacy top-level `ankiConnect` migration keys (for example `audioField`, `generateAudio`, `imageType`) are compatibility-only, validated before mapping, and ignored with a warning when invalid. +- If AnkiConnect is unreachable, the cache remains in its previous state and an on-screen/system status message is shown. +- Known-word sync activity is logged at `INFO`/`DEBUG` level with the `anki` logger scope and includes scope, notes returned, and word counts. + +To refresh roughly once per day, set: + +```json +{ + "ankiConnect": { + "nPlusOne": { + "highlightEnabled": true, + "refreshMinutes": 1440 + } + } +} +``` + +### Field Grouping Modes + +| Mode | Behavior | +| ---------- | -------------------------------------------------------------------------------------------------------------------------- | +| `auto` | Automatically merges the new card's content into the original; duplicate deletion is controlled by `deleteDuplicateInAuto` | +| `manual` | Shows an overlay popup to choose which card to keep and whether to delete the duplicate after merge | +| `disabled` | No field grouping; duplicate cards are left as-is | + +`deleteDuplicateInAuto` controls whether `auto` mode deletes the duplicate after merge (default: `true`). In `manual` mode, the popup asks each time whether to delete the duplicate. + + + +Open demo in a new tab + +**Image Quality Notes:** + +- `imageQuality` affects JPG and WebP only; PNG is lossless and ignores this setting +- JPG quality is mapped to FFmpeg's scale (2-31, lower = better) +- WebP quality uses FFmpeg's native 0-100 scale + +### Manual Card Update Shortcuts + +When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but not automatically updated. Use these keyboard shortcuts for manual control: + +| Shortcut | Action | +| -------------- | ------------------------------------------------------------------------------------------------------------------ | +| `Ctrl+C` | Copy the current subtitle line to clipboard (preserves line breaks) | +| `Ctrl+Shift+C` | Enter multi-copy mode. Press `1-9` to copy that many recent lines, or `Esc` to cancel. Timeout: 3 seconds | +| `Ctrl+V` | Update the last added Anki card using subtitles from clipboard | +| `Ctrl+G` | Trigger Kiku duplicate field grouping for the last added card (only when `behavior.autoUpdateNewCards` is `false`) | +| `Ctrl+S` | Create a sentence card from the current subtitle line | +| `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel | +| `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) | +| `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) | +| `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) | +| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) | + +**Multi-line copy workflow:** + +1. Press `Ctrl+Shift+C` +2. Press a number key (`1-9`) within 3 seconds +3. The specified number of most recent subtitle lines are copied +4. Press `Ctrl+V` to update the last added card with the copied lines + +These shortcuts are only active when the overlay window is visible and automatically disabled when hidden. + +### Session help modal + +The session help modal is opened with `Y-H` by default (falls back to `Y-K` if needed) and shows the current session keybindings and color legend. + +You can filter the modal quickly with `/`: + +- Type any part of the action name or shortcut in the search bar. +- Search is case-insensitive and ignores spaces/punctuation (`+`, `-`, `_`, `/`) so `ctrl w`, `ctrl+w`, and `ctrl+s` all match. +- Results are filtered across active MPV shortcuts, configured overlay shortcuts, and color legend items. + +While the modal is open: + +- `Esc`: close the modal (or clear the filter when text is entered) +- `↑/↓`, `j/k`: move selection +- Mouse/trackpad: click to select and activate rows + +The list is generated at runtime from: + +- Your active mpv keybindings (`keybindings`). +- Your configured overlay shortcuts (`shortcuts`, including runtime-loaded config values). +- Current subtitle color settings from `subtitleStyle`. + +When config hot-reload updates shortcut/keybinding/style values, close and reopen the help modal to refresh the displayed entries. + +### Auto-Start Overlay + +Control whether the overlay automatically becomes visible when it connects to mpv: + +```json +{ + "auto_start_overlay": false +} +``` + +| Option | Values | Description | +| -------------------- | --------------- | ------------------------------------------------------ | +| `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `false`) | + +The mpv plugin controls startup per layer via `auto_start_visible_overlay` and `auto_start_invisible_overlay` in `subminer.conf` (`platform-default` for invisible means hidden on Linux, visible on macOS/Windows). + +### Visible Overlay Subtitle Binding + +Control whether toggling the visible overlay also toggles MPV subtitle visibility: + +```json +{ + "bind_visible_overlay_to_mpv_sub_visibility": true +} +``` + +| Option | Values | Description | +| -------------------------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `bind_visible_overlay_to_mpv_sub_visibility` | `true`, `false` | When `true` (default), visible overlay hides MPV primary/secondary subtitles and restores them when hidden. When `false`, visible overlay toggles do not change MPV subtitle visibility. | + +### Auto Subtitle Sync + +Sync the active subtitle track using `alass` (preferred) or `ffsubsync`: + +```json +{ + "subsync": { + "defaultMode": "auto", + "alass_path": "", + "ffsubsync_path": "", + "ffmpeg_path": "" + } +} +``` + +| Option | Values | Description | +| ---------------- | -------------------- | ----------------------------------------------------------------------------------------------------------- | +| `defaultMode` | `"auto"`, `"manual"` | `auto`: try `alass` against secondary subtitle, then fallback to `ffsubsync`; `manual`: open overlay picker | +| `alass_path` | string path | Path to `alass` executable. Empty or `null` falls back to `/usr/bin/alass`. | +| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` falls back to `/usr/bin/ffsubsync`. | +| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. | + +Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`. +Customize it there, or set it to `null` to disable. + +### Invisible Overlay + +SubMiner includes a second subtitle mining layer that can be visually invisible while still interactive for Yomitan lookups. + +- `invisibleOverlay.startupVisibility` values: + +1. `"platform-default"`: hidden on Wayland, visible on Windows/macOS/other sessions. +2. `"visible"`: always shown on startup. +3. `"hidden"`: always hidden on startup. + +Invisible subtitle positioning can be adjusted directly in the invisible layer: + +- `Ctrl/Cmd+Shift+P` toggles position edit mode. +- Use arrow keys to move the invisible subtitle text. +- Press `Enter` or `Ctrl/Cmd+S` to save, or `Esc` to cancel. +- This edit-mode shortcut is fixed (not currently configurable in `shortcuts`/`keybindings`). + +### Jimaku + +Configure Jimaku API access and defaults: + +```json +{ + "jimaku": { + "apiKey": "YOUR_API_KEY", + "apiKeyCommand": "cat ~/.jimaku_key", + "apiBaseUrl": "https://jimaku.cc", + "languagePreference": "ja", + "maxEntryResults": 10 + } +} +``` + +Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry delay from the API response. + +Set `openBrowser` to `false` to only print the URL without opening a browser. + +### AniList + +AniList integration is opt-in and disabled by default. Enable it to allow SubMiner to update watched episode progress after playback. + +```json +{ + "anilist": { + "enabled": true, + "accessToken": "" + } +} +``` + +| Option | Values | Description | +| ------------- | --------------- | ----------------------------------------------------------------------- | +| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | +| `accessToken` | string | Optional explicit AniList access token override (default: empty string) | + +When `enabled` is `true` and `accessToken` is empty, SubMiner opens an AniList setup helper window. Keep `enabled` as `false` to disable all AniList setup/update behavior. + +Current post-watch behavior: + +- SubMiner attempts an update near episode completion (`>=85%` watched and at least `10` minutes watched). +- Episode/title detection is `guessit`-first with fallback to SubMiner's filename parser. +- If `guessit` is unavailable, updates still work via fallback parsing but title matching can be less accurate. +- If embedded AniList auth UI fails to render, SubMiner opens the authorize URL in your default browser and shows fallback instructions in-app. +- Failed updates are retried with a persistent backoff queue in the background. + +Setup flow details: + +1. Set `anilist.enabled` to `true`. +2. Leave `anilist.accessToken` empty and restart SubMiner (or run `--anilist-setup`) to trigger setup. +3. Approve access in AniList. +4. Callback flow returns to SubMiner via `subminer://anilist-setup?...`, and SubMiner stores the token automatically. + +Token + detection notes: + +- `anilist.accessToken` can be set directly in config; when blank, SubMiner uses the locally stored encrypted token from setup. +- Detection quality is best when `guessit` is installed and available on `PATH`. +- When `guessit` cannot parse or is missing, SubMiner falls back automatically to internal filename parsing. + +AniList CLI commands: + +- `--anilist-status`: print current AniList token resolution state and retry queue counters. +- `--anilist-logout`: clear stored AniList token from local persisted state. +- `--anilist-setup`: open AniList setup/auth flow helper window. +- `--anilist-retry-queue`: process one ready retry queue item immediately. + +### Jellyfin + +Jellyfin integration is optional and disabled by default. When enabled, SubMiner can authenticate, list libraries/items, and resolve direct/transcoded playback URLs for mpv launch. + +```json +{ + "jellyfin": { + "enabled": true, + "serverUrl": "http://127.0.0.1:8096", + "username": "", + "remoteControlEnabled": true, + "remoteControlAutoConnect": true, + "autoAnnounce": false, + "remoteControlDeviceName": "SubMiner", + "defaultLibraryId": "", + "directPlayPreferred": true, + "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], + "transcodeVideoCodec": "h264" + } +} +``` + +| Option | Values | Description | +| -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------ | +| `enabled` | `true`, `false` | Enable Jellyfin integration and CLI commands (default: `false`) | +| `serverUrl` | string (URL) | Jellyfin server base URL | +| `username` | string | Default username used by `--jellyfin-login` | +| `deviceId` | string | Client device id sent in auth headers (default: `subminer`) | +| `clientName` | string | Client name sent in auth headers (default: `SubMiner`) | +| `clientVersion` | string | Client version sent in auth headers (default: `0.1.0`) | +| `defaultLibraryId` | string | Default library id for `--jellyfin-items` when CLI value is omitted | +| `remoteControlEnabled` | `true`, `false` | Enable Jellyfin cast/remote-control session support | +| `remoteControlAutoConnect` | `true`, `false` | Auto-connect Jellyfin remote session on app startup (requires `jellyfin.enabled` and `remoteControlEnabled`) | +| `autoAnnounce` | `true`, `false` | Auto-run cast-target visibility announce check on connect (default: `false`) | +| `remoteControlDeviceName` | string | Device name shown in Jellyfin cast/device lists | +| `pullPictures` | `true`, `false` | Enable poster/icon fetching for launcher Jellyfin pickers | +| `iconCacheDir` | string | Cache directory for launcher-fetched Jellyfin poster icons | +| `directPlayPreferred` | `true`, `false` | Prefer direct stream URLs before transcoding | +| `directPlayContainers` | string[] | Container allowlist for direct play decisions | +| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) | + +Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. + +Launcher subcommands: + +- `subminer jellyfin` (or `subminer jf`) opens setup. +- `subminer jellyfin -l --server ... --username ... --password ...` logs in. +- `subminer jellyfin --logout` clears stored credentials. +- `subminer jellyfin -p` opens play picker. +- `subminer jellyfin -d` starts cast discovery mode. + +See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to-device guide. + +Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enabled`, `jellyfin.remoteControlEnabled`, and `jellyfin.remoteControlAutoConnect`. + +### Discord Rich Presence + +Discord Rich Presence is optional and disabled by default. When enabled, SubMiner publishes a polished activity card that reflects current media title, playback state, and session timer. + +```json +{ + "discordPresence": { + "enabled": true, + "updateIntervalMs": 3000, + "debounceMs": 750 + } +} +``` + +| Option | Values | Description | +| ------------------ | --------------- | ---------------------------------------------------------- | +| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `false`) | +| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds | +| `debounceMs` | number | Debounce window for bursty playback events in milliseconds | + +Setup steps: + +1. Set `discordPresence.enabled` to `true`. +2. Restart SubMiner. + +SubMiner uses a fixed official activity card style for all users: + +- Details: current media title while playing (fallback: `Mining and crafting (Anki cards)` when idle/disconnected) +- State: `Playing mm:ss / mm:ss` or `Paused mm:ss / mm:ss` (fallback: `Idle`) +- Large image key/text: `subminer-logo` / `SubMiner` +- Small image key/text: `study` / `Sentence Mining` +- No activity button by default + +Troubleshooting: + +- If the card does not appear, verify Discord desktop app is running. +- If images do not render, confirm asset keys exactly match uploaded Discord asset names. +- If Discord is closed/not installed/disconnects, SubMiner continues running and quietly skips presence updates. + +### Keybindings + +Add a `keybindings` array to configure keyboard shortcuts that send commands to mpv: + +See `config.example.jsonc` for detailed configuration options and more examples. + +**Default keybindings:** + +| Key | Command | Description | +| ----------------- | -------------------------- | ------------------------------------- | +| `Space` | `["cycle", "pause"]` | Toggle pause | +| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds | +| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds | +| `ArrowUp` | `["seek", 60]` | Seek forward 60 seconds | +| `ArrowDown` | `["seek", -60]` | Seek backward 60 seconds | +| `Shift+KeyH` | `["sub-seek", -1]` | Jump to previous subtitle | +| `Shift+KeyL` | `["sub-seek", 1]` | Jump to next subtitle | +| `Ctrl+Shift+KeyH` | `["__replay-subtitle"]` | Replay current subtitle, pause at end | +| `Ctrl+Shift+KeyL` | `["__play-next-subtitle"]` | Play next subtitle, pause at end | +| `KeyQ` | `["quit"]` | Quit mpv | +| `Ctrl+KeyW` | `["quit"]` | Quit mpv | + +**Custom keybindings example:** + +```json +{ + "keybindings": [ + { "key": "ArrowRight", "command": ["seek", 5] }, + { "key": "ArrowLeft", "command": ["seek", -5] }, + { "key": "Shift+ArrowRight", "command": ["seek", 30] }, + { "key": "KeyR", "command": ["script-binding", "immersive/auto-replay"] }, + { "key": "KeyA", "command": ["script-message", "ankiconnect-add-note"] } + ] +} +``` + +**Key format:** Use `KeyboardEvent.code` values (`Space`, `ArrowRight`, `KeyR`, etc.) with optional modifiers (`Ctrl+`, `Alt+`, `Shift+`, `Meta+`). + +**Disable a default binding:** Set command to `null`: + +```json +{ "key": "Space", "command": null } +``` + +**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:[:next|prev]` cycles a runtime option value. + +**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.) + +**See `config.example.jsonc`** for more keybinding examples and configuration options. + +### Runtime Option Palette + +Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart. + +Current runtime options: + +- `ankiConnect.behavior.autoUpdateNewCards` (`On` / `Off`) +- `ankiConnect.isKiku.fieldGrouping` (`auto` / `manual` / `disabled`) + +Default shortcut: `Ctrl+Shift+O` + +Palette controls: + +- `Arrow Up/Down`: select option +- `Arrow Left/Right`: change selected value +- `Enter`: apply selected value +- `Esc`: close + +### Secondary Subtitles + +Display a second subtitle track (e.g., English alongside Japanese) in the overlay: + +See `config.example.jsonc` for detailed configuration options. + +```json +{ + "secondarySub": { + "secondarySubLanguages": ["eng", "en"], + "autoLoadSecondarySub": true, + "defaultMode": "hover" + } +} +``` + +| Option | Values | Description | +| ----------------------- | ---------------------------------- | ------------------------------------------------------ | +| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`) | +| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track | +| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) | + +**Display modes:** + +- **hidden** — Secondary subtitles not shown +- **visible** — Always visible at top of overlay +- **hover** — Only visible when hovering over the subtitle area (default) + +**See `config.example.jsonc`** for additional secondary subtitle configuration options. + +### Shortcuts Configuration + +Customize or disable the overlay keyboard shortcuts: + +See `config.example.jsonc` for detailed configuration options. + +```json +{ + "shortcuts": { + "toggleVisibleOverlayGlobal": "Alt+Shift+O", + "toggleInvisibleOverlayGlobal": "Alt+Shift+I", + "copySubtitle": "CommandOrControl+C", + "copySubtitleMultiple": "CommandOrControl+Shift+C", + "updateLastCardFromClipboard": "CommandOrControl+V", + "triggerFieldGrouping": "CommandOrControl+G", + "triggerSubsync": "Ctrl+Alt+S", + "mineSentence": "CommandOrControl+S", + "mineSentenceMultiple": "CommandOrControl+Shift+S", + "markAudioCard": "CommandOrControl+Shift+A", + "openRuntimeOptions": "CommandOrControl+Shift+O", + "openJimaku": "Ctrl+Shift+J", + "multiCopyTimeoutMs": 3000 + } +} +``` + +| Option | Values | Description | +| ------------------------------ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) | +| `toggleInvisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling invisible interactive overlay (default: `"Alt+Shift+I"`) | +| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) | +| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) | +| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) | +| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when `behavior.autoUpdateNewCards` is `false`) | +| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) | +| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) | +| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) | +| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) | +| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) | +| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) | +| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) | +| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) | + +**See `config.example.jsonc`** for the complete list of shortcut configuration options. + +Set any shortcut to `null` to disable it. + +Feature-dependent shortcuts/keybindings only run when their related integration is enabled. For example, Anki/Kiku shortcuts require `ankiConnect.enabled` (and Kiku-specific behavior where applicable), and Jellyfin remote startup behavior requires Jellyfin to be enabled. + +### Subtitle Position + +Set the initial vertical subtitle position (measured from the bottom of the screen): + +```json +{ + "subtitlePosition": { + "yPercent": 10 + } +} +``` + +| Option | Values | Description | +| ---------- | ---------------- | ---------------------------------------------------------------------- | +| `yPercent` | number (0 - 100) | Distance from the bottom as a percent of screen height (default: `10`) | + +### Subtitle Style + +Customize the appearance of primary and secondary subtitles: + +See `config.example.jsonc` for detailed configuration options. + +```json +{ + "subtitleStyle": { + "fontFamily": "Noto Sans CJK JP Regular, Noto Sans CJK JP, Arial Unicode MS, Arial, sans-serif", + "fontSize": 35, + "fontColor": "#cad3f5", + "fontWeight": "normal", + "fontStyle": "normal", + "backgroundColor": "rgb(30, 32, 48, 0.88)", + "secondary": { + "fontSize": 24, + "fontColor": "#ffffff", + "backgroundColor": "transparent" + } + } +} +``` + +| Option | Values | Description | +| ---------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------- | +| `fontFamily` | string | CSS font-family value (default: `"Noto Sans CJK JP Regular, ..."`) | +| `fontSize` | number (px) | Font size in pixels (default: `35`) | +| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) | +| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"normal"`) | +| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) | +| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) | +| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) | +| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. | +| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) | +| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use the built-in bundled dictionary search paths. | +| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) | +| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) | +| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode | +| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode | +| `nPlusOneColor` | string | Existing n+1 highlight color (default: `#c6a0f6`) | +| `knownWordColor` | string | Existing known-word highlight color (default: `#a6da95`) | +| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) | +| `secondary` | object | Override any of the above for secondary subtitles (optional) | + +JLPT underlining is powered by offline term-meta bank files at runtime. See [`docs/jlpt-vocab-bundle.md`](jlpt-vocab-bundle.md) for required files, source/version refresh steps, and deterministic fallback behavior. + +Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`. + +Lookup behavior: + +- Set `frequencyDictionary.sourcePath` to a directory containing `term_meta_bank_*.json` for a fully custom source. +- If `sourcePath` is missing or empty, SubMiner uses bundled defaults from `vendor/jiten_freq_global` (packaged under `/jiten_freq_global` in distribution builds). +- In both cases, only terms with a valid `frequencyRank` are used; everything else falls back to no highlighting. + +In `single` mode all highlights use `singleColor`; in `banded` mode tokens map to five ascending color bands from most common to least common inside the topX window. + +Secondary subtitle defaults: `fontSize: 24`, `fontColor: "#ffffff"`, `backgroundColor: "transparent"`. Any property not set in `secondary` falls back to the CSS defaults. + +**See `config.example.jsonc`** for the complete list of subtitle style configuration options. + +`jlptColors` keys are: + +| Key | Default | Description | +| ---- | --------- | ----------------------- | +| `N1` | `#ed8796` | JLPT N1 underline color | +| `N2` | `#f5a97f` | JLPT N2 underline color | +| `N3` | `#f9e2af` | JLPT N3 underline color | +| `N4` | `#a6e3a1` | JLPT N4 underline color | +| `N5` | `#8aadf4` | JLPT N5 underline color | + +### Texthooker + +Control whether the browser opens automatically when texthooker starts: + +See `config.example.jsonc` for detailed configuration options. + +```json +{ + "texthooker": { + "openBrowser": true + } +} +``` + +### WebSocket Server + +The overlay includes a built-in WebSocket server that broadcasts subtitle text to connected clients (such as texthooker-ui) for external processing. + +By default, the server uses "auto" mode: it starts automatically unless [mpv_websocket](https://github.com/kuroahna/mpv_websocket) is detected at `~/.config/mpv/mpv_websocket`. If you have mpv_websocket installed, the built-in server is skipped to avoid conflicts. + +See `config.example.jsonc` for detailed configuration options. + +```json +{ + "websocket": { + "enabled": "auto", + "port": 6677 + } +} +``` + +| Option | Values | Description | +| --------- | ------------------------- | -------------------------------------------------------- | +| `enabled` | `true`, `false`, `"auto"` | `"auto"` (default) disables if mpv_websocket is detected | +| `port` | number | WebSocket server port (default: 6677) | + +### Immersion Tracking + +Enable or disable local immersion analytics stored in SQLite for mined subtitles and media sessions: + +```json +{ + "immersionTracking": { + "enabled": true, + "dbPath": "", + "batchSize": 25, + "flushIntervalMs": 500, + "queueCap": 1000, + "payloadCapBytes": 256, + "maintenanceIntervalMs": 86400000, + "retention": { + "eventsDays": 7, + "telemetryDays": 30, + "dailyRollupsDays": 365, + "monthlyRollupsDays": 1825, + "vacuumIntervalDays": 7 + } + } +} +``` + +| Option | Values | Description | +| ------------------------------ | ----------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `enabled` | `true`, `false` | Enable immersion tracking. Defaults to `true`. | +| `dbPath` | string | Optional SQLite database path. Leave empty to use default app-data path at `/immersion.sqlite`. | +| `batchSize` | integer (`1`-`10000`) | Buffered writes per transaction. Default `25`. | +| `flushIntervalMs` | integer (`50`-`60000`) | Maximum queue delay before flush. Default `500ms`. | +| `queueCap` | integer (`100`-`100000`) | In-memory queue cap. Overflow drops oldest writes. Default `1000`. | +| `payloadCapBytes` | integer (`64`-`8192`) | Event payload byte cap before truncation marker. Default `256`. | +| `maintenanceIntervalMs` | integer (`60000`-`604800000`) | Prune + rollup maintenance cadence. Default `86400000` (24h). | +| `retention.eventsDays` | integer (`1`-`3650`) | Raw event retention window. Default `7` days. | +| `retention.telemetryDays` | integer (`1`-`3650`) | Telemetry retention window. Default `30` days. | +| `retention.dailyRollupsDays` | integer (`1`-`36500`) | Daily rollup retention window. Default `365` days. | +| `retention.monthlyRollupsDays` | integer (`1`-`36500`) | Monthly rollup retention window. Default `1825` days (~5 years). | +| `retention.vacuumIntervalDays` | integer (`1`-`3650`) | Minimum spacing between `VACUUM` passes. Default `7` days. | + +When `dbPath` is blank or omitted, SubMiner writes telemetry and session summaries to the default app-data location: + +```text +/immersion.sqlite +``` + +Set `dbPath` only if you want to relocate the database (for backup, syncing, or inspection workflows). The database is created when tracking starts for the first time. + +See [Immersion Tracking Storage](/immersion-tracking) for schema details, query templates, retention/rollup behavior, and backend portability notes. + +### YouTube Subtitle Generation + +Set defaults used by the `subminer` launcher for YouTube subtitle extraction/transcription: + +```json +{ + "youtubeSubgen": { + "mode": "automatic", + "whisperBin": "/path/to/whisper-cli", + "whisperModel": "/path/to/ggml-model.bin", + "primarySubLanguages": ["ja", "jpn"] + } +} +``` + +| Option | Values | Description | +| --------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mode` | `"automatic"`, `"preprocess"`, `"off"` | `automatic`: play immediately and load generated subtitles in background; `preprocess`: generate before playback; `off`: disable launcher generation. | +| `whisperBin` | string path | Path to `whisper.cpp` CLI binary used as fallback transcription engine. | +| `whisperModel` | string path | Path to whisper model used by fallback transcription. | +| `primarySubLanguages` | string[] | Primary subtitle language priority for YouTube subtitle generation (default `["ja", "jpn"]`). | + +YouTube language targets are derived from subtitle config: + +- primary track: `youtubeSubgen.primarySubLanguages` (falls back to `["ja","jpn"]`) +- secondary track: `secondarySub.secondarySubLanguages` (falls back to English when empty) + +Precedence for launcher defaults is: CLI flag > environment variable > `config.jsonc` > built-in default. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..1a8b3ba --- /dev/null +++ b/docs/development.md @@ -0,0 +1,170 @@ +# Development + +## Prerequisites + +- [Bun](https://bun.sh) + +## Setup + +```bash +git clone --recurse-submodules https://github.com/ksyasuda/SubMiner.git +cd SubMiner +# if you cloned without --recurse-submodules: +git submodule update --init --recursive + +make deps +# or manually: +bun install +(cd vendor/texthooker-ui && bun install --frozen-lockfile) +``` + +## Building + +```bash +# TypeScript compile (fast, for development) +bun run build + +# Generate launcher wrapper artifact +make build-launcher +# output: dist/launcher/subminer + +# Full platform build (includes texthooker-ui + AppImage/DMG) +make build + +# Platform-specific builds +make build-linux # Linux AppImage +make build-macos # macOS DMG + ZIP (signed) +make build-macos-unsigned # macOS DMG + ZIP (unsigned) +``` + +## Launcher Artifact Workflow + +- Source of truth: `launcher/*.ts` +- Generated output: `dist/launcher/subminer` +- Do not hand-edit generated launcher output. +- Repo-root `./subminer` is a stale artifact path and is rejected by verification checks. +- Install targets (`make install-linux`, `make install-macos`) copy from `dist/launcher/subminer`. + +Verify the workflow: + +```bash +make build-launcher +dist/launcher/subminer --help >/dev/null +bash scripts/verify-generated-launcher.sh +``` + +## Running Locally + +```bash +bun run dev # builds + launches with --start --dev +electron . --start --dev --log-level debug # equivalent Electron launch with verbose logging +electron . --background # tray/background mode, minimal default logging +make dev-start # build + launch via Makefile +``` + +## Testing + +CI-equivalent local gate: + +```bash +bun run tsc --noEmit +bun run test:fast +bun run test:launcher:smoke:src +bun run build +bun run test:smoke:dist +bun run docs:build +``` + +Common focused commands: + +```bash +bun run test:config # Source-level config schema/validation tests +bun run test:launcher # Launcher regression tests (config discovery + command routing) +bun run test:launcher:smoke:src # Launcher e2e smoke: launcher -> mpv IPC -> overlay start/stop wiring +bun run test:core # Source-level core regression tests (default lane) +bun run test:fast # Source-level config + core lane (no build prerequisite) +``` + +Dist-level tests are now an explicit smoke lane used to validate compiled/runtime assumptions. + +Launcher smoke artifacts are written to `.tmp/launcher-smoke` locally and uploaded by CI/release workflows when the smoke step fails. + +Smoke and optional deep dist commands: + +```bash +bun run build # compile dist artifacts +bun run test:smoke:dist # explicit smoke scope for compiled runtime +bun run test:config:dist # optional full dist config suite +bun run test:core:dist # optional full dist core suite +``` + +`bun run test:subtitle` and `bun run test:subtitle:dist` are currently placeholders and do not run an active suite. + +## Config Generation + +```bash +# Generate default config to ~/.config/SubMiner/config.jsonc +make generate-config + +# Regenerate the repo's config.example.jsonc from centralized defaults +make generate-example-config +# or: bun run generate:config-example +``` + +## Documentation Site + +The docs use [VitePress](https://vitepress.dev/): + +```bash +make docs-dev # Dev server at http://localhost:5173 +make docs # Build static output +make docs-preview # Preview built site at http://localhost:4173 +``` + +## Makefile Reference + +Run `make help` for a full list of targets. Key ones: + +| Target | Description | +| ---------------------- | ---------------------------------------------------------------- | +| `make build` | Build platform package for detected OS | +| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` | +| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) | +| `make install-plugin` | Install mpv Lua plugin and config | +| `make deps` | Install JS dependencies (root + texthooker-ui) | +| `make generate-config` | Generate default config from centralized registry | +| `make docs-dev` | Run VitePress dev server | + +## Contributor Notes + +- To add/change a config default, edit the matching domain file in `src/config/definitions/defaults-*.ts`. +- To add/change config option metadata, edit the matching domain file in `src/config/definitions/options-*.ts`. +- To add/change generated config template blocks/comments, update `src/config/definitions/template-sections.ts`. +- Keep `src/config/definitions.ts` as the composed public API (`DEFAULT_CONFIG`, registries, template export) that wires domain modules together. +- Overlay window/visibility state is owned by `src/core/services/overlay-manager.ts`. +- Runtime architecture/module-boundary conventions are documented in [Architecture](/architecture); keep contributor changes aligned with that canonical guide. +- Linux packaged desktop launches pass `--background` using electron-builder `build.linux.executableArgs` in `package.json`. +- Prefer direct inline deps objects in `src/main/` modules for simple pass-through wiring. +- Add a helper/adapter service only when it performs meaningful adaptation, validation, or reuse (not identity mapping). + +## Environment Variables + +| Variable | Description | +| ---------------------------------- | ------------------------------------------------------------------------------ | +| `SUBMINER_APPIMAGE_PATH` | Override SubMiner app binary path for launcher playback commands | +| `SUBMINER_BINARY_PATH` | Alias for `SUBMINER_APPIMAGE_PATH` | +| `SUBMINER_ROFI_THEME` | Override rofi theme path for launcher picker | +| `SUBMINER_LOG_LEVEL` | Override app logger level (`debug`, `info`, `warn`, `error`) | +| `SUBMINER_MPV_LOG` | Override mpv/app shared log file path | +| `SUBMINER_YT_SUBGEN_MODE` | Override `youtubeSubgen.mode` for launcher | +| `SUBMINER_WHISPER_BIN` | Override `youtubeSubgen.whisperBin` for launcher | +| `SUBMINER_WHISPER_MODEL` | Override `youtubeSubgen.whisperModel` for launcher | +| `SUBMINER_YT_SUBGEN_OUT_DIR` | Override generated subtitle output directory | +| `SUBMINER_YT_SUBGEN_AUDIO_FORMAT` | Override extraction format used for whisper fallback | +| `SUBMINER_YT_SUBGEN_KEEP_TEMP` | Set to `1` to keep temporary subtitle-generation workspace | +| `SUBMINER_JIMAKU_API_KEY` | Override Jimaku API key for launcher subtitle downloads | +| `SUBMINER_JIMAKU_API_KEY_COMMAND` | Command used to resolve Jimaku API key at runtime | +| `SUBMINER_JIMAKU_API_BASE_URL` | Override Jimaku API base URL | +| `SUBMINER_JELLYFIN_ACCESS_TOKEN` | Override Jellyfin access token (used before stored encrypted session fallback) | +| `SUBMINER_JELLYFIN_USER_ID` | Optional Jellyfin user ID override | +| `SUBMINER_SKIP_MACOS_HELPER_BUILD` | Set to `1` to skip building the macOS helper binary during `bun run build` | diff --git a/docs/immersion-tracking.md b/docs/immersion-tracking.md new file mode 100644 index 0000000..3c23166 --- /dev/null +++ b/docs/immersion-tracking.md @@ -0,0 +1,150 @@ +# Immersion Tracking Storage + +SubMiner stores immersion analytics in local SQLite (`immersion.sqlite`) by default. + +## Runtime Model + +- Write path is asynchronous and queue-backed. +- Hot paths (subtitle parsing/render/token flows) enqueue telemetry/events and never await SQLite writes. +- Queue overflow policy is deterministic: drop oldest queued writes, keep newest. +- Flush policy defaults to `25` writes or `500ms` max delay. +- SQLite pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=2500`. + +## Schema (v1) + +Schema versioning table: + +- `imm_schema_version(schema_version PK, applied_at_ms)` + +Core entities: + +- `imm_videos`: video key/title/source metadata + optional media metadata fields +- `imm_sessions`: session UUID, video reference, timing/status fields +- `imm_session_telemetry`: high-frequency session aggregates over time +- `imm_session_events`: event stream with compact numeric event types + +Rollups: + +- `imm_daily_rollups` +- `imm_monthly_rollups` + +Primary index coverage: + +- session-by-video/time: `idx_sessions_video_started` +- session-by-status/time: `idx_sessions_status_started` +- timeline reads: `idx_telemetry_session_sample` +- event timeline/type reads: `idx_events_session_ts`, `idx_events_type_ts` +- rollup reads: `idx_rollups_day_video`, `idx_rollups_month_video` + +## Retention and Maintenance Defaults + +- Raw events: `7d` +- Telemetry: `30d` +- Daily rollups: `365d` +- Monthly rollups: `5y` +- Maintenance cadence: startup + every `24h` +- Vacuum cadence: idle weekly (`7d` minimum spacing) + +Retention cleanup and rollup refresh stay in service maintenance orchestration + `src/core/services/immersion-tracker/maintenance.ts`. + +## Configurable Policy Knobs + +All knobs are under `immersionTracking` in config: + +- `batchSize` +- `flushIntervalMs` +- `queueCap` +- `payloadCapBytes` +- `maintenanceIntervalMs` +- `retention.eventsDays` +- `retention.telemetryDays` +- `retention.dailyRollupsDays` +- `retention.monthlyRollupsDays` +- `retention.vacuumIntervalDays` + +These map directly to runtime tracker policy and allow tuning without code changes. + +## Query Templates + +Timeline for one session: + +```sql +SELECT + sample_ms, + total_watched_ms, + active_watched_ms, + lines_seen, + words_seen, + tokens_seen, + cards_mined +FROM imm_session_telemetry +WHERE session_id = ? +ORDER BY sample_ms DESC +LIMIT ?; +``` + +Session throughput summary: + +```sql +SELECT + s.session_id, + s.video_id, + s.started_at_ms, + s.ended_at_ms, + COALESCE(SUM(t.active_watched_ms), 0) AS active_watched_ms, + COALESCE(SUM(t.words_seen), 0) AS words_seen, + COALESCE(SUM(t.cards_mined), 0) AS cards_mined, + CASE + WHEN COALESCE(SUM(t.active_watched_ms), 0) > 0 + THEN COALESCE(SUM(t.words_seen), 0) / (COALESCE(SUM(t.active_watched_ms), 0) / 60000.0) + ELSE NULL + END AS words_per_min, + CASE + WHEN COALESCE(SUM(t.active_watched_ms), 0) > 0 + THEN (COALESCE(SUM(t.cards_mined), 0) * 60.0) / (COALESCE(SUM(t.active_watched_ms), 0) / 60000.0) + ELSE NULL + END AS cards_per_hour +FROM imm_sessions s +LEFT JOIN imm_session_telemetry t ON t.session_id = s.session_id +GROUP BY s.session_id +ORDER BY s.started_at_ms DESC +LIMIT ?; +``` + +Daily rollups: + +```sql +SELECT + rollup_day, + video_id, + total_sessions, + total_active_min, + total_lines_seen, + total_words_seen, + total_tokens_seen, + total_cards, + cards_per_hour, + words_per_min, + lookup_hit_rate +FROM imm_daily_rollups +ORDER BY rollup_day DESC, video_id DESC +LIMIT ?; +``` + +Monthly rollups: + +```sql +SELECT + rollup_month, + video_id, + total_sessions, + total_active_min, + total_lines_seen, + total_words_seen, + total_tokens_seen, + total_cards +FROM imm_monthly_rollups +ORDER BY rollup_month DESC, video_id DESC +LIMIT ?; +``` + diff --git a/docs/index.assets.test.ts b/docs/index.assets.test.ts new file mode 100644 index 0000000..d27bfd4 --- /dev/null +++ b/docs/index.assets.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; + +const docsIndexPath = new URL('./index.md', import.meta.url); +const docsIndexContents = readFileSync(docsIndexPath, 'utf8'); + +test('docs demo media uses shared cache-busting asset version token', () => { + expect(docsIndexContents).toMatch(/const demoAssetVersion = ['"][^'"]+['"]/); + expect(docsIndexContents).toContain(':poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`"'); + expect(docsIndexContents).toContain(''); + expect(docsIndexContents).toContain(''); + expect(docsIndexContents).toContain(''); + expect(docsIndexContents).toContain('SubMiner demo GIF fallback'); +}); diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..335d430 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,240 @@ +--- +layout: home + +title: SubMiner +titleTemplate: Immersion Mining Workflow for MPV + +hero: + name: SubMiner + text: Immersion Mining for MPV + tagline: Watch media, mine vocabulary, and build cards without leaving the scene. + image: + src: /assets/SubMiner.png + alt: SubMiner logo + actions: + - theme: brand + text: Install + link: /installation + - theme: alt + text: Explore workflow + link: /mining-workflow + +features: + - icon: + src: /assets/mpv.svg + alt: mpv icon + title: Built for mpv + details: Tracks subtitles through mpv IPC in real time, with a single launch path and no external bridge services. + - icon: + src: /assets/yomitan-icon.svg + alt: Yomitan logo + title: Yomitan Integration + details: Keep your flow moving with instant word lookups and context-aware card creation directly from subtitles. + - icon: + src: /assets/anki-card.svg + alt: Anki card icon + title: Anki Card Enrichment + details: Auto-fills card fields with subtitle sentence, clipping, image, and translation so you can focus on learning. + - icon: + src: /assets/dual-layer.svg + alt: Dual layer icon + title: Three-Plane Overlay Stack + details: Secondary context plane + visible interactive layer + invisible interaction plane, each with independent behavior and startup state. + - icon: + src: /assets/highlight.svg + alt: Highlight icon + title: N+1 Highlighting + details: Surfaces known words from your deck so unknown targets stand out during immersion sessions. + - icon: + src: /assets/tokenization.svg + alt: Tokenization icon + title: Immersion Tracking + details: Captures subtitle and mining telemetry to SQLite, with daily/monthly rollups for progress clarity. + - icon: + src: /assets/subtitle-download.svg + alt: Subtitle download icon + title: Subtitle Download & Sync + details: Pull and synchronize subtitles with Jimaku plus alass/ffsubsync in one cohesive workflow. + - icon: + src: /assets/keyboard.svg + alt: Keyboard icon + title: Keyboard-Driven + details: Run lookups, mining actions, clipping, and workflow toggles with one configurable shortcut surface. + - icon: + src: /assets/texthooker.svg + alt: Texthooker icon + title: Texthooker & WebSocket + details: Stream subtitles in real time to browser tools via local WebSocket and keep your stack integrated. +--- + + + + + + diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..7061095 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,228 @@ +# Installation + +## Requirements + +### System Dependencies + +| Dependency | Required | Notes | +| -------------------- | ---------- | -------------------------------------------------------- | +| Bun | Yes | Required for `subminer` wrapper and source workflows | +| mpv | Yes | Must support IPC sockets (`--input-ipc-server`) | +| ffmpeg | For media | Audio extraction and screenshot generation | +| MeCab + mecab-ipadic | No | Optional fallback tokenizer for Japanese | +| fuse2 | Linux only | Required for AppImage | +| yt-dlp | No | Recommended for YouTube playback and subtitle extraction | + +### Platform-Specific + +**Linux** — one of the following compositors: + +- Hyprland (uses `hyprctl`) +- Sway (uses `swaymsg`) +- X11 (uses `xdotool` and `xwininfo`) + +**macOS** — macOS 10.13 or later. Accessibility permission required for window tracking. + +### Optional Tools + +| Tool | Purpose | +| ----------------- | ------------------------------------------------------------- | +| fzf | Terminal-based video picker (default) | +| rofi | GUI-based video picker | +| chafa | Thumbnail previews in fzf | +| ffmpegthumbnailer | Generate video thumbnails for picker | +| guessit | Better AniSkip title/season/episode parsing for file playback | +| alass | Subtitle sync engine (preferred) | +| ffsubsync | Subtitle sync engine (fallback) | + +## Linux + +### AppImage (Recommended) + +Download the latest AppImage from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest): + +```bash +# Download and install AppImage +wget https://github.com/ksyasuda/SubMiner/releases/download/v0.1.0/SubMiner-0.1.0.AppImage -O ~/.local/bin/SubMiner.AppImage +chmod +x ~/.local/bin/SubMiner.AppImage + +# Download subminer wrapper script +wget https://github.com/ksyasuda/SubMiner/releases/download/v0.1.0/subminer -O ~/.local/bin/subminer +chmod +x ~/.local/bin/subminer +``` + +The `subminer` wrapper uses a Bun shebang (`#!/usr/bin/env bun`), so [Bun](https://bun.sh) must be installed and available on `PATH`. + +### From Source + +```bash +git clone https://github.com/ksyasuda/SubMiner.git +cd SubMiner +make build +make build-launcher + +# Install platform artifacts (wrapper + theme + AppImage) +make install +``` + +`make build-launcher` generates the wrapper at `dist/launcher/subminer`. The checked-in launcher source remains `launcher/*.ts`. +Do not use a repo-root `./subminer` artifact when building from source; workflow checks enforce `dist/launcher/subminer` as the only generated path. + +## macOS + +### DMG (Recommended) + +Download the **DMG** artifact from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest). Open it and drag `SubMiner.app` into `/Applications`. + +A **ZIP** artifact is also available as a fallback — unzip and drag `SubMiner.app` into `/Applications`. + +Install dependencies using Homebrew: + +```bash +brew install mpv mecab mecab-ipadic +``` + +### From Source (macOS) + +```bash +git clone https://github.com/ksyasuda/SubMiner.git +cd SubMiner +bun install +cd vendor/texthooker-ui && bun install --frozen-lockfile && bun run build && cd ../.. +bun run build:mac +``` + +The built app will be available in the `release` directory (`.dmg` and `.zip`). + +For unsigned local builds: + +```bash +bun run build:mac:unsigned +``` + +### Accessibility Permission + +After launching SubMiner for the first time, grant accessibility permission: + +1. Open **System Preferences** → **Security & Privacy** → **Privacy** tab +2. Select **Accessibility** from the left sidebar +3. Add SubMiner to the list + +Without this permission, window tracking will not work and the overlay won't follow the mpv window. + +### macOS Usage Notes + +**Launching MPV with IPC:** + +```bash +mpv --input-ipc-server=/tmp/subminer-socket video.mkv +``` + +**Config location:** `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`). + +**MeCab paths (Homebrew):** + +- Apple Silicon (M1/M2): `/opt/homebrew/bin/mecab` +- Intel: `/usr/local/bin/mecab` + +Ensure `mecab` is available on your PATH when launching SubMiner. + +**Fullscreen:** The overlay should appear correctly in fullscreen. If you encounter issues, check that accessibility permissions are granted. + +**mpv plugin binary path:** + +```ini +binary_path=/Applications/SubMiner.app/Contents/MacOS/subminer +``` + +## Windows + +Windows support is available through the mpv plugin. Set the binary and socket path in `subminer.conf`: + +```ini +binary_path=C:\\Program Files\\subminer\\subminer.exe +socket_path=\\\\.\\pipe\\subminer-socket +``` + +Launch mpv with: + +```bash +mpv --input-ipc-server=\\\\.\\pipe\\subminer-socket video.mkv +``` + +## MPV Plugin (Optional) + +The Lua plugin provides in-player keybindings to control the overlay from mpv. It communicates with SubMiner by invoking the binary with CLI flags. + +::: warning Important +mpv must be launched with `--input-ipc-server=/tmp/subminer-socket` for SubMiner to connect. +::: + +```bash +# Option 1: install from release assets bundle +wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets-0.1.0.tar.gz -O /tmp/subminer-assets.tar.gz +tar -xzf /tmp/subminer-assets.tar.gz -C /tmp +mkdir -p ~/.config/SubMiner +cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc +cp /tmp/plugin/subminer.lua ~/.config/mpv/scripts/ +cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/ + +# Option 2: from source checkout +# make install-plugin +``` + +## Rofi Theme (Optional) + +SubMiner ships a default rofi theme at `assets/themes/subminer.rasi`. + +Install path (default auto-detected by `subminer`): + +- Linux: `~/.local/share/SubMiner/themes/subminer.rasi` +- macOS: `~/Library/Application Support/SubMiner/themes/subminer.rasi` + +```bash +mkdir -p ~/.local/share/SubMiner/themes +cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi +``` + +Override with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`. + +All keybindings use a `y` chord prefix — press `y`, then the second key: + +| Chord | Action | +| ----- | ------------------------------------- | +| `y-y` | Open SubMiner menu (fuzzy-searchable) | +| `y-s` | Start overlay | +| `y-S` | Stop overlay | +| `y-t` | Toggle visible overlay | +| `y-i` | Toggle invisible overlay | +| `y-I` | Show invisible overlay | +| `y-u` | Hide invisible overlay | +| `y-o` | Open Yomitan settings | +| `y-r` | Restart overlay | +| `y-c` | Check overlay status | + +See [MPV Plugin](/mpv-plugin) for the full configuration reference, script messages, and binary auto-detection details. + +## Verify Installation + +After installing, confirm SubMiner is working: + +```bash +# Start the overlay (connects to mpv IPC) +subminer --start video.mkv + +# Useful launch modes for troubleshooting +subminer --log-level debug video.mkv +SubMiner.AppImage --start --log-level debug + +# Or with direct AppImage control +SubMiner.AppImage --background # Background tray service mode +SubMiner.AppImage --start +SubMiner.AppImage --start --dev +SubMiner.AppImage --help # Show all CLI options +``` + +You should see the overlay appear over mpv. If subtitles are loaded in the video, they will appear as interactive text in the overlay. + +Next: [Usage](/usage) — learn about the `subminer` wrapper, keybindings, and YouTube playback. diff --git a/docs/jellyfin-integration.md b/docs/jellyfin-integration.md new file mode 100644 index 0000000..e295020 --- /dev/null +++ b/docs/jellyfin-integration.md @@ -0,0 +1,158 @@ +# Jellyfin Integration + +SubMiner includes an optional Jellyfin CLI integration for: + +- authenticating against a server +- listing libraries and media items +- launching item playback in the connected mpv instance +- receiving Jellyfin remote cast-to-device playback events in-app +- opening an in-app setup window for server/user/password input + +## Requirements + +- Jellyfin server URL and user credentials +- For `--jellyfin-play`: connected mpv IPC socket (`--start` or existing mpv plugin workflow) + +## Setup + +1. Set base config values (`config.jsonc`): + +```jsonc +{ + "jellyfin": { + "enabled": true, + "serverUrl": "http://127.0.0.1:8096", + "username": "your-user", + "remoteControlEnabled": true, + "remoteControlAutoConnect": true, + "autoAnnounce": false, + "remoteControlDeviceName": "SubMiner", + "defaultLibraryId": "", + "pullPictures": false, + "iconCacheDir": "/tmp/subminer-jellyfin-icons", + "directPlayPreferred": true, + "directPlayContainers": ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"], + "transcodeVideoCodec": "h264", + }, +} +``` + +2. Authenticate: + +```bash +subminer jellyfin +subminer jellyfin -l \ + --server http://127.0.0.1:8096 \ + --username your-user \ + --password 'your-password' +``` + +3. List libraries: + +```bash +SubMiner.AppImage --jellyfin-libraries +``` + +Launcher wrapper equivalent for interactive playback flow: + +```bash +subminer jellyfin -p +``` + +Launcher wrapper for Jellyfin cast discovery mode (foreground app process): + +```bash +subminer jellyfin -d +``` + +`subminer jf ...` is an alias for `subminer jellyfin ...`. + +To clear saved session credentials: + +```bash +subminer jellyfin --logout +``` + +4. List items in a library: + +```bash +SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search term +``` + +5. Start playback: + +```bash +SubMiner.AppImage --start +SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID +``` + +Optional stream overrides: + +- `--jellyfin-audio-stream-index N` +- `--jellyfin-subtitle-stream-index N` + +## Playback Behavior + +- Direct play is attempted first when: + - `jellyfin.directPlayPreferred=true` + - media source supports direct stream + - source container matches `jellyfin.directPlayContainers` +- If direct play is not selected/available, SubMiner requests a Jellyfin transcoded stream (`master.m3u8`) using `jellyfin.transcodeVideoCodec`. +- Resume position (`PlaybackPositionTicks`) is applied via mpv seek. +- Media title is set in mpv as `[Jellyfin/] `. + +## Cast To Device Mode (jellyfin-mpv-shim style) + +When SubMiner is running with a valid Jellyfin session, it can appear as a +remote playback target in Jellyfin's cast-to-device menu. + +### Requirements + +- `jellyfin.enabled=true` +- valid `jellyfin.serverUrl` and Jellyfin auth session (env override or stored login session) +- `jellyfin.remoteControlEnabled=true` (default) +- `jellyfin.remoteControlAutoConnect=true` (default) +- `jellyfin.autoAnnounce=false` by default (`true` enables auto announce/visibility check logs on connect) + +### Behavior + +- SubMiner connects to Jellyfin remote websocket and posts playback capabilities. +- `Play` events open media in mpv with the same defaults used by `--jellyfin-play`. +- If mpv IPC is not connected at cast time, SubMiner auto-launches mpv in idle mode with SubMiner defaults and retries playback. +- `Playstate` events map to mpv pause/resume/seek/stop controls. +- Stream selection commands (`SetAudioStreamIndex`, `SetSubtitleStreamIndex`) are mapped to mpv track selection. +- SubMiner reports start/progress/stop timeline updates back to Jellyfin so now-playing and resume state stay synchronized. +- `--jellyfin-remote-announce` forces an immediate capability re-broadcast and logs whether server sessions can see the device. + +### Troubleshooting + +- Device not visible in Jellyfin cast menu: + - ensure SubMiner is running + - ensure session token is valid (`--jellyfin-login` again if needed) + - ensure `remoteControlEnabled` and `remoteControlAutoConnect` are true +- Cast command received but playback does not start: + - verify mpv IPC can connect (`--start` flow) + - verify item is playable from normal `--jellyfin-play --jellyfin-item-id ...` +- Frequent reconnects: + - check Jellyfin server/network stability and token expiration + +## Failure Handling + +User-visible errors are shown through CLI logs and mpv OSD for: + +- invalid credentials +- expired/invalid token +- server/network errors +- missing library/item identifiers +- no playable source +- mpv not connected for playback + +## Security Notes and Limitations + +- Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted token storage after login/setup. +- Optional environment overrides are supported: `SUBMINER_JELLYFIN_ACCESS_TOKEN` and `SUBMINER_JELLYFIN_USER_ID`. +- Treat both token storage and config files as secrets and avoid committing them. +- Password is used only for login and is not stored. +- Optional setup UI is available via `--jellyfin`; all actions are also available via CLI flags. +- `subminer` wrapper uses Jellyfin subcommands (`subminer jellyfin ...`, alias `subminer jf ...`). Use `SubMiner.AppImage` for direct `--jellyfin-libraries` and `--jellyfin-items`. +- For direct app CLI usage (`SubMiner.AppImage ...`), `--jellyfin-server` can override server URL for login/play flows without editing config. diff --git a/docs/jlpt-vocab-bundle.md b/docs/jlpt-vocab-bundle.md new file mode 100644 index 0000000..a24c59a --- /dev/null +++ b/docs/jlpt-vocab-bundle.md @@ -0,0 +1,45 @@ +# JLPT Vocabulary Bundle (Offline) + +## Bundle location + +SubMiner expects the JLPT term-meta bank files to be available locally at: + +- `vendor/yomitan-jlpt-vocab` + +At runtime, SubMiner also searches these derived locations: + +- `vendor/yomitan-jlpt-vocab` +- `vendor/yomitan-jlpt-vocab/vendor/yomitan-jlpt-vocab` +- `vendor/yomitan-jlpt-vocab/yomitan-jlpt-vocab` + +and user-data/config fallback paths (see `getJlptDictionarySearchPaths` in `src/main.ts`). + +## Required files + +The expected files are: + +- `term_meta_bank_1.json` +- `term_meta_bank_2.json` +- `term_meta_bank_3.json` +- `term_meta_bank_4.json` +- `term_meta_bank_5.json` + +Each bank maps terms to frequency metadata; only entries with a `frequency.displayValue` are considered for JLPT tagging. + +SubMiner also reuses the same `term_meta_bank_*.json` format for frequency-based subtitle highlighting. The default frequency source is now bundled as `vendor/jiten_freq_global`, so users can enable `subtitleStyle.frequencyDictionary` without extra setup. + +## Source and update process + +For reproducible updates: + +1. Obtain the JLPT term-meta bank archive from the same upstream source that supplies the bundled Yomitan dictionary data. +2. Extract the five `term_meta_bank_*.json` files. +3. Place them into `vendor/yomitan-jlpt-vocab/`. +4. Commit the update with the source URL/version in the task notes. + +This repository currently ships the folder path in `electron-builder` `extraResources` as: +`vendor/yomitan-jlpt-vocab -> yomitan-jlpt-vocab`. + +## Fallback Behavior + +If bank files are missing, malformed, or lack expected metadata, SubMiner skips them gracefully. When no usable entries are found, JLPT underlining is silently disabled and subtitle rendering remains unchanged. diff --git a/docs/launcher-script.md b/docs/launcher-script.md new file mode 100644 index 0000000..0b74d2a --- /dev/null +++ b/docs/launcher-script.md @@ -0,0 +1,98 @@ +# Launcher Script + +The `subminer` wrapper script is an all-in-one launcher that handles video selection, mpv startup, and overlay management. It's a Bun script distributed alongside the AppImage. + +## Video Picker + +When you run `subminer` without specifying a file, it opens an interactive video picker. By default it uses **fzf** in the terminal; pass `-R` to use **rofi** instead. + +### fzf (default) + +```bash +subminer # pick from current directory +subminer -d ~/Videos # pick from a specific directory +subminer -r -d ~/Anime # recursive search +``` + +fzf shows video files in a fuzzy-searchable list. If `chafa` is installed, you get thumbnail previews in the right pane. Thumbnails are sourced from the freedesktop thumbnail cache first, then generated on the fly with `ffmpegthumbnailer` or `ffmpeg` as fallback. + +| Optional tool | Purpose | +| --------------------- | -------------------------------- | +| `chafa` | Render thumbnails in the terminal | +| `ffmpegthumbnailer` | Generate thumbnails on the fly | + +### rofi + +```bash +subminer -R # rofi picker, current directory +subminer -R -d ~/Videos # rofi picker, specific directory +subminer -R -r -d ~/Anime # rofi picker, recursive +``` + +rofi shows a GUI menu with icon thumbnails when available. SubMiner ships a custom rofi theme that can be installed from the release assets: + +```bash +mkdir -p ~/.local/share/SubMiner/themes +cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi +``` + +The theme is auto-detected from these paths (first match wins): + +- `$SUBMINER_ROFI_THEME` environment variable (absolute path) +- `$XDG_DATA_HOME/SubMiner/themes/subminer.rasi` (default: `~/.local/share/SubMiner/themes/subminer.rasi`) +- `/usr/local/share/SubMiner/themes/subminer.rasi` +- `/usr/share/SubMiner/themes/subminer.rasi` +- macOS: `~/Library/Application Support/SubMiner/themes/subminer.rasi` + +Override with the `SUBMINER_ROFI_THEME` environment variable: + +```bash +SUBMINER_ROFI_THEME=/path/to/custom-theme.rasi subminer -R +``` + +## Common Commands + +```bash +subminer video.mkv # play a specific file +subminer --start video.mkv # play + explicitly start overlay +subminer https://youtu.be/... # YouTube playback (requires yt-dlp) +subminer ytsearch:"jp news" # YouTube search +``` + +## Subcommands + +| Subcommand | Purpose | +| ------------------------- | ---------------------------------------------- | +| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login) | +| `subminer yt` / `youtube` | YouTube shorthand (`-o`, `-m`) | +| `subminer doctor` | Dependency + config + socket diagnostics | +| `subminer config path` | Print active config file path | +| `subminer config show` | Print active config contents | +| `subminer mpv status` | Check mpv socket readiness | +| `subminer mpv socket` | Print active socket path | +| `subminer mpv idle` | Launch detached idle mpv instance | +| `subminer texthooker` | Launch texthooker-only mode | +| `subminer app` | Pass arguments directly to SubMiner binary | + +Use `subminer <subcommand> -h` for command-specific help. + +## Options + +| Flag | Description | +| -------------------- | -------------------------------------------- | +| `-d, --directory` | Video search directory (default: cwd) | +| `-r, --recursive` | Search directories recursively | +| `-R, --rofi` | Use rofi instead of fzf | +| `-S, --start` | Start overlay after mpv launches | +| `-T, --no-texthooker`| Disable texthooker server | +| `-p, --profile` | mpv profile name (default: `subminer`) | +| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`) | +| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) | +| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) | + +## Logging + +- Default log level is `info` +- `--background` mode defaults to `warn` unless `--log-level` is explicitly set +- `--dev` / `--debug` control app behavior, not logging verbosity — use `--log-level` for that + diff --git a/docs/mining-workflow.md b/docs/mining-workflow.md new file mode 100644 index 0000000..b156dc2 --- /dev/null +++ b/docs/mining-workflow.md @@ -0,0 +1,252 @@ +# Mining Workflow + +This guide walks through the sentence mining loop — from watching a video to creating Anki cards with audio, screenshots, and context. + +## Overview + +SubMiner runs as a transparent overlay on top of mpv. As subtitles play, the overlay displays them as interactive text. You click a word to look it up with Yomitan, then create an Anki card with a single action. SubMiner automatically attaches the sentence, audio clip, and screenshot. + +```text +Watch video → See subtitle → Click word → Yomitan lookup → Add to Anki + ↓ + SubMiner auto-fills: + sentence, audio, image, translation +``` + +## Subtitle Delivery Path (Startup + Runtime) + +SubMiner now prioritizes subtitle responsiveness over heavy initialization: + +1. The first subtitle render is **plain text first** (no tokenization wait). +2. Tokenized enrichment (word spans, known-word flags, JLPT/frequency metadata) is applied right after parsing completes. +3. Under rapid subtitle churn, SubMiner uses a **latest-only tokenization queue** so stale lines are dropped instead of building lag. +4. MeCab, Yomitan extension load, and dictionary prewarm run as background warmups after overlay initialization. + +This keeps early playback snappy and avoids mpv-side sluggishness while startup work completes. + +## The Three Overlay Planes + +SubMiner uses three overlay planes, each serving a different purpose. + +### Visible Overlay + +The visible overlay renders subtitles as tokenized, clickable word spans. Each word is a separate element with reading and headword data attached. This plane is styled independently from mpv subtitles and supports: + +- Word-level click targets for Yomitan lookup +- Right-click to pause/resume +- Right-click + drag to reposition subtitles +- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options +- **N+1 highlighting** — known words from your Anki deck are visually highlighted + +Toggle with `Alt+Shift+O` (global) or `y-t` (mpv plugin). + +### Secondary Subtitle Plane + +The secondary plane is a compact top-strip layer for translation and context visibility while keeping primary reading flow below. It mirrors your configured secondary subtitle preference and can be independently shown or hidden. + +It is controlled by `secondarySub` configuration and shares lifecycle with the overlay stack. + +### Invisible Overlay + +The invisible overlay is a transparent layer aligned with mpv's own subtitle rendering. It uses mpv's subtitle metrics (font size, margins, position, scaling) to map click targets accurately. + +This layer still supports: + +- Word-level click-through lookups over the text region +- Optional manual position fine-tuning in pixel mode +- Independent toggle behavior with global shortcuts + +Position edit mode is available via `Ctrl/Cmd+Shift+P`, then arrow keys / `hjkl` to nudge position; `Shift` moves faster. Save with `Enter` or `Ctrl+S`, cancel with `Esc`. + +Toggle controls: + +- `Alt+Shift+O` / `y-t`: visible overlay +- `Alt+Shift+I` / `y-i`: invisible overlay +- Secondary plane visibility is controlled via `secondarySub` config and matching global shortcuts. + +## Looking Up Words + +### On the Visible Overlay + +1. Hover over the subtitle area — the overlay activates pointer events. +2. Click a word. SubMiner selects it using Unicode-aware word boundary detection (`Intl.Segmenter`). +3. Yomitan detects the text selection and opens its popup with dictionary results. +4. From the Yomitan popup, you can add the word directly to Anki. + +### On the Invisible Overlay + +1. The invisible layer sits over mpv's own subtitle text. +2. Click on any word in the subtitle — SubMiner maps your click position to the underlying text. +3. On macOS, word selection happens automatically on hover. +4. Yomitan popup appears for lookup and card creation. + +## Creating Anki Cards + +There are three ways to create cards, depending on your workflow. + +### 1. Auto-Update from Yomitan + +This is the most common flow. Yomitan creates a card in Anki, and SubMiner detects it via polling and enriches it automatically. + +1. Click a word → Yomitan popup appears. +2. Click the Anki icon in Yomitan to add the word. +3. SubMiner detects the new card (polls AnkiConnect every 3 seconds by default). +4. SubMiner updates the card with: + - **Sentence**: The current subtitle line. + - **Audio**: Extracted from the video using the subtitle's start/end timing (plus configurable padding). + - **Image**: A screenshot or animated clip from the current playback position. + - **Translation**: From the secondary subtitle track, or generated via AI if configured. + - **MiscInfo**: Metadata like filename and timestamp. + +Configure which fields to fill in `ankiConnect.fields`. See [Anki Integration](/anki-integration) for details. + +### 2. Manual Update from Clipboard + +If you prefer a hands-on approach (animecards-style), you can copy the current subtitle to the clipboard and then paste it onto the last-added Anki card: + +1. Add a word via Yomitan as usual. +2. Press `Ctrl/Cmd+C` to copy the current subtitle line to the clipboard. + - For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1`–`9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard. +3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation — the same fields auto-update would fill. + +This is useful when auto-update polling is disabled or when you want explicit control over which subtitle line gets attached to the card. + +| Shortcut | Action | Config key | +| --------------------------- | ----------------------------------------- | ------------------------------------- | +| `Ctrl/Cmd+C` | Copy current subtitle | `shortcuts.copySubtitle` | +| `Ctrl/Cmd+Shift+C` + digit | Copy multiple recent lines | `shortcuts.copySubtitleMultiple` | +| `Ctrl/Cmd+V` | Update last card from clipboard | `shortcuts.updateLastCardFromClipboard` | + +### 3. Mine Sentence (Hotkey) + +Create a standalone sentence card without going through Yomitan: + +- **Mine current sentence**: `Ctrl/Cmd+S` (configurable via `shortcuts.mineSentence`) +- **Mine multiple lines**: `Ctrl/Cmd+Shift+S` followed by a digit 1–9 to select how many recent subtitle lines to combine. + +The sentence card uses the note type configured in `isLapis.sentenceCardModel` and always maps sentence/audio to `Sentence` and `SentenceAudio`. + +### 4. Mark as Audio Card + +After adding a word via Yomitan, press the audio card shortcut to overwrite the audio with a longer clip spanning the full subtitle timing. + +## Secondary Subtitles + +SubMiner can display a secondary subtitle track (typically English) alongside the primary Japanese subtitles. This is useful for: + +- Quick comprehension checks without leaving the mining flow. +- Auto-populating the translation field on mined cards. + +### Display Modes + +Cycle through modes with the configured shortcut: + +- **Hidden**: Secondary subtitle not shown. +- **Visible**: Always displayed below the primary subtitle. +- **Hover**: Only shown when you hover over the primary subtitle. + +When a card is created, SubMiner uses the secondary subtitle text as the translation field value (unless AI translation is configured to override it). + +## Field Grouping (Kiku) + +If you mine the same word from different sentences, SubMiner can merge the cards instead of creating duplicates. This feature is designed for use with [Kiku](https://github.com/youyoumu/kiku) and similar note types that support grouped fields. + +### How It Works + +1. You add a word via Yomitan. +2. SubMiner detects the new card and checks if a card with the same expression already exists. +3. If a duplicate is found: + - **Auto mode** (`fieldGrouping: "auto"`): Merges automatically. Both sentences, audio clips, and images are combined into the existing card. The duplicate is optionally deleted. + - **Manual mode** (`fieldGrouping: "manual"`): A modal appears showing both cards side by side. You choose which card to keep and preview the merged result before confirming. + +### What Gets Merged + +- **Sentence fields**: Both sentences kept, marked with `[Original]` and `[Duplicate]`. +- **Audio fields**: Both audio clips preserved as separate `[sound:...]` entries. +- **Image fields**: Both images preserved. + +Configure in `ankiConnect.isKiku`. See [Anki Integration](/anki-integration#field-grouping-kiku) for the full reference. + +## Jimaku Subtitle Search + +SubMiner integrates with [Jimaku](https://jimaku.cc) to search and download subtitle files for anime directly from the overlay. + +1. Open the Jimaku modal via the configured shortcut (`Ctrl+Shift+J` by default). +2. SubMiner auto-fills the search from the current video filename (title, season, episode). +3. Browse matching entries and select a subtitle file to download. +4. The subtitle is loaded into mpv as a new track. + +Requires an internet connection. An API key (`jimaku.apiKey`) is optional but recommended for higher rate limits. + +## Texthooker + +SubMiner runs a local HTTP server at `http://127.0.0.1:5174` (configurable port) that serves a texthooker UI. This allows external tools — such as a browser-based Yomitan instance — to receive subtitle text in real time. + +The texthooker page displays the current subtitle and updates as new lines arrive. This is useful if you prefer to do lookups in a browser rather than through the overlay's built-in Yomitan. + +## Subtitle Sync (Subsync) + +If your subtitle file is out of sync with the audio, SubMiner can resynchronize it using [alass](https://github.com/kaegi/alass) or [ffsubsync](https://github.com/smacke/ffsubsync). + +1. Open the subsync modal from the overlay. +2. Select the sync engine (alass or ffsubsync). +3. For alass, select a reference subtitle track from the video. +4. SubMiner runs the sync and reloads the corrected subtitle. + +Install the sync tools separately — see [Troubleshooting](/troubleshooting#subtitle-sync-subsync) if the tools are not found. + +## N+1 Word Highlighting + +When enabled, SubMiner highlights words you already know in your Anki deck, making it easier to spot new (N+1) vocabulary during immersion. + +### How It Works + +1. SubMiner periodically syncs with Anki to build a local cache of known words (expressions/headwords from your configured decks) +2. As subtitles appear, known words are visually highlighted in the visible overlay +3. Unknown words remain unhighlighted — these are your potential mining targets + +### Enabling N+1 Mode + +```json +{ + "ankiConnect": { + "nPlusOne": { + "highlightEnabled": true, + "refreshMinutes": 1440, + "matchMode": "headword", + "minSentenceWords": 3, + "decks": ["Learning::Japanese"] + } + } +} +``` + +| Option | Description | +| ------------------ | ----------------------------------------------------------------------------------- | +| `highlightEnabled` | Turn on/off the highlighting feature | +| `refreshMinutes` | How often to refresh the known-word cache (default: 1440 = daily) | +| `matchMode` | `"headword"` (dictionary form) or `"surface"` (exact text match) | +| `minSentenceWords` | Minimum sentence length in tokens required to allow N+1 highlighting (default: `3`) | +| `decks` | Which Anki decks to consider "known" (empty = uses `ankiConnect.deck`) | + +### Use Cases + +- **Immersion tracking**: Quickly identify which sentences contain only known words vs. those with new vocabulary +- **Mining focus**: Target sentences with exactly one unknown word (true N+1) +- **Progress visualization**: See your growing vocabulary visually represented in real content + +### Immersion Tracking Storage + +Immersion data is persisted to SQLite when enabled in `immersionTracking`: + +```json +{ + "immersionTracking": { + "enabled": true, + "dbPath": "" + } +} +``` + +- `dbPath` can be empty (default) to use SubMiner’s app-data `immersion.sqlite`. +- Set an explicit path to move the database (for backups, cloud syncing, or easier inspection). diff --git a/docs/mpv-plugin.md b/docs/mpv-plugin.md new file mode 100644 index 0000000..bb37208 --- /dev/null +++ b/docs/mpv-plugin.md @@ -0,0 +1,229 @@ +# MPV Plugin + +The SubMiner mpv plugin (`subminer.lua`) provides in-player keybindings to control the overlay without leaving mpv. It communicates with SubMiner by invoking the AppImage (or binary) with CLI flags. + +## Installation + +```bash +# From release bundle: +wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets-0.1.0.tar.gz -O /tmp/subminer-assets.tar.gz +tar -xzf /tmp/subminer-assets.tar.gz -C /tmp +mkdir -p ~/.config/SubMiner +cp /tmp/config.example.jsonc ~/.config/SubMiner/config.jsonc +cp /tmp/plugin/subminer.lua ~/.config/mpv/scripts/ +cp /tmp/plugin/subminer.conf ~/.config/mpv/script-opts/ +# Or from source checkout: make install-plugin +``` + +mpv must have IPC enabled for SubMiner to connect: + +```ini +# ~/.config/mpv/mpv.conf +input-ipc-server=/tmp/subminer-socket +``` + +## Keybindings + +All keybindings use a `y` chord prefix — press `y`, then the second key: + +| Chord | Action | +| ----- | ------------------------ | +| `y-y` | Open menu | +| `y-s` | Start overlay | +| `y-S` | Stop overlay | +| `y-t` | Toggle visible overlay | +| `y-i` | Toggle invisible overlay | +| `y-I` | Show invisible overlay | +| `y-u` | Hide invisible overlay | +| `y-o` | Open settings window | +| `y-r` | Restart overlay | +| `y-c` | Check status | +| `y-k` | Skip intro (AniSkip) | + +## Menu + +Press `y-y` to open an interactive menu in mpv's OSD: + +```text +SubMiner: +1. Start overlay +2. Stop overlay +3. Toggle overlay +4. Toggle invisible overlay +5. Open options +6. Restart overlay +7. Check status +``` + +Select an item by pressing its number. + +## Configuration + +Create or edit `~/.config/mpv/script-opts/subminer.conf`: + +```ini +# Path to SubMiner binary. Leave empty for auto-detection. +binary_path= + +# MPV IPC socket path. Must match input-ipc-server in mpv.conf. +socket_path=/tmp/subminer-socket + +# Enable the texthooker WebSocket server. +texthooker_enabled=yes + +# Port for the texthooker server. +texthooker_port=5174 + +# Window manager backend: auto, hyprland, sway, x11, macos. +backend=auto + +# Start the overlay automatically when a file is loaded. +auto_start=no + +# Show the visible overlay on auto-start. +auto_start_visible_overlay=no + +# Invisible overlay startup: platform-default, visible, hidden. +# platform-default = hidden on Linux, visible on macOS/Windows. +auto_start_invisible_overlay=platform-default + +# Show OSD messages for overlay status changes. +osd_messages=yes + +# Logging level: debug, info, warn, error. +log_level=info + +# Enable AniSkip intro detection/markers. +aniskip_enabled=yes + +# Optional title override (launcher fills from guessit when available). +aniskip_title= + +# Optional season override (launcher fills from guessit when available). +aniskip_season= + +# Optional MAL ID override. Leave blank to resolve from media title. +aniskip_mal_id= + +# Optional episode override. Leave blank to detect from filename/title. +aniskip_episode= + +# Show OSD skip button while inside intro range. +aniskip_show_button=yes + +# OSD label + keybinding for intro skip action. +aniskip_button_text=You can skip by pressing %s +aniskip_button_key=y-k +aniskip_button_duration=3 +``` + +### Option Reference + +| Option | Default | Values | Description | +| ------------------------------ | ---------------------- | ------------------------------------------ | -------------------------------- | +| `binary_path` | `""` (auto-detect) | file path | Path to SubMiner binary | +| `socket_path` | `/tmp/subminer-socket` | file path | MPV IPC socket path | +| `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server | +| `texthooker_port` | `5174` | 1–65535 | Texthooker server port | +| `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend | +| `auto_start` | `no` | `yes` / `no` | Auto-start overlay on file load | +| `auto_start_visible_overlay` | `no` | `yes` / `no` | Show visible layer on auto-start | +| `auto_start_invisible_overlay` | `platform-default` | `platform-default`, `visible`, `hidden` | Invisible layer on auto-start | +| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages | +| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity | +| `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection | +| `aniskip_title` | `""` | string | Override title used for lookup | +| `aniskip_season` | `""` | numeric season | Optional season hint | +| `aniskip_mal_id` | `""` | numeric MAL id | Skip title lookup; use fixed id | +| `aniskip_episode` | `""` | numeric episode | Skip episode parsing; use fixed | +| `aniskip_show_button` | `yes` | `yes` / `no` | Show in-range intro skip prompt | +| `aniskip_button_text` | `You can skip by pressing %s` | string | OSD prompt format (`%s`=key) | +| `aniskip_button_key` | `y-k` | mpv key chord | Primary key for intro skip action (`y-k` always works as fallback) | +| `aniskip_button_duration` | `3` | float seconds | OSD hint duration | + +## Binary Auto-Detection + +When `binary_path` is empty, the plugin searches platform-specific locations: + +**Linux:** + +1. `~/.local/bin/SubMiner.AppImage` +2. `/opt/SubMiner/SubMiner.AppImage` +3. `/usr/local/bin/SubMiner` +4. `/usr/bin/SubMiner` + +**macOS:** + +1. `/Applications/SubMiner.app/Contents/MacOS/SubMiner` +2. `~/Applications/SubMiner.app/Contents/MacOS/SubMiner` + +**Windows:** + +1. `C:\Program Files\SubMiner\SubMiner.exe` +2. `C:\Program Files (x86)\SubMiner\SubMiner.exe` +3. `C:\SubMiner\SubMiner.exe` + +## Backend Detection + +When `backend=auto`, the plugin detects the window manager: + +1. **macOS** — detected via platform or `OSTYPE`. +2. **Hyprland** — detected via `HYPRLAND_INSTANCE_SIGNATURE`. +3. **Sway** — detected via `SWAYSOCK`. +4. **X11** — detected via `XDG_SESSION_TYPE=x11` or `DISPLAY`. +5. **Fallback** — defaults to X11 with a warning. + +## Script Messages + +The plugin can be controlled from other mpv scripts or the mpv command line using script messages: + +``` +script-message subminer-start +script-message subminer-stop +script-message subminer-toggle +script-message subminer-toggle-invisible +script-message subminer-show-invisible +script-message subminer-hide-invisible +script-message subminer-menu +script-message subminer-options +script-message subminer-restart +script-message subminer-status +script-message subminer-aniskip-refresh +script-message subminer-skip-intro +``` + +The `subminer-start` message accepts overrides: + +``` +script-message subminer-start backend=hyprland socket=/custom/path texthooker=no log-level=debug +``` + +`log-level` here controls only logging verbosity passed to SubMiner. +`--debug` is a separate app/dev-mode flag in the main CLI and should not be used here for logging. + +## AniSkip Intro Skip + +- On file load, plugin resolves title + episode, resolves MAL id, then calls AniSkip API. +- When launched via `subminer`, launcher runs `guessit` first (file targets) and passes title/season/episode to the plugin; fallback is filename-derived title. +- Install `guessit` for best detection quality (`python3 -m pip install --user guessit`). +- If OP interval exists, plugin adds `AniSkip Intro Start` and `AniSkip Intro End` chapters. +- At intro start, plugin shows an OSD hint for the first 3 seconds (`You can skip by pressing y-k` by default). +- Use `script-message subminer-aniskip-refresh` after changing media metadata/options to retry lookup. + +## Lifecycle + +- **File loaded**: If `auto_start=yes`, the plugin starts the overlay and applies visibility preferences after a short delay. +- **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server. +- **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first. + +## Using with the `subminer` Wrapper + +The `subminer` wrapper script handles mpv launch, socket setup, and overlay lifecycle automatically. You do not need the plugin if you always use the wrapper. + +The plugin is useful when you: + +- Launch mpv from other tools (file managers, media centers). +- Want on-demand overlay control without the wrapper. +- Use mpv's built-in file browser or playlist features. + +You can install both — the plugin provides chord keybindings for convenience, while the wrapper handles the full lifecycle. diff --git a/docs/public/apple-touch-icon.png b/docs/public/apple-touch-icon.png new file mode 100644 index 0000000..7d70768 Binary files /dev/null and b/docs/public/apple-touch-icon.png differ diff --git a/docs/public/assets/SubMiner.png b/docs/public/assets/SubMiner.png new file mode 100644 index 0000000..ed163b0 Binary files /dev/null and b/docs/public/assets/SubMiner.png differ diff --git a/docs/public/assets/anki-card.svg b/docs/public/assets/anki-card.svg new file mode 100644 index 0000000..bf9d4aa --- /dev/null +++ b/docs/public/assets/anki-card.svg @@ -0,0 +1,15 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none"> + <defs> + <linearGradient id="ac" x1="6" y1="6" x2="36" y2="42" gradientUnits="userSpaceOnUse"> + <stop stop-color="#34d399"/> + <stop offset="1" stop-color="#059669"/> + </linearGradient> + </defs> + <rect x="12" y="5" width="24" height="34" rx="3" fill="#059669" opacity="0.18"/> + <rect x="8" y="9" width="24" height="34" rx="3" fill="url(#ac)"/> + <rect x="13" y="18" width="14" height="2.5" rx="1.25" fill="white" opacity="0.85"/> + <rect x="13" y="24" width="10" height="2.5" rx="1.25" fill="white" opacity="0.4"/> + <rect x="13" y="30" width="12" height="2.5" rx="1.25" fill="white" opacity="0.4"/> + <path d="M39.5 8l1.8 4.2 4.2 1.8-4.2 1.8L39.5 20l-1.8-4.2L33.5 14l4.2-1.8z" fill="#34d399"/> + <path d="M36 27l1 2.3 2.3 1-2.3 1L36 33.5l-1-2.2-2.3-1 2.3-1z" fill="#34d399" opacity="0.45"/> +</svg> diff --git a/docs/public/assets/demo-poster.jpg b/docs/public/assets/demo-poster.jpg new file mode 100644 index 0000000..ad030fd Binary files /dev/null and b/docs/public/assets/demo-poster.jpg differ diff --git a/docs/public/assets/dual-layer.svg b/docs/public/assets/dual-layer.svg new file mode 100644 index 0000000..c1ba38c --- /dev/null +++ b/docs/public/assets/dual-layer.svg @@ -0,0 +1,15 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none"> + <defs> + <linearGradient id="dl" x1="4" y1="24" x2="44" y2="24" gradientUnits="userSpaceOnUse"> + <stop stop-color="#818cf8"/> + <stop offset="1" stop-color="#6366f1"/> + </linearGradient> + </defs> + <rect x="4" y="6" width="40" height="14" rx="4" fill="#818cf8" opacity="0.12"/> + <rect x="4" y="6" width="40" height="14" rx="4" stroke="#818cf8" stroke-width="1.5" stroke-dasharray="4 3" fill="none" opacity="0.55"/> + <rect x="10" y="11" width="20" height="3" rx="1.5" fill="#818cf8" opacity="0.35"/> + <line x1="24" y1="22" x2="24" y2="26" stroke="#a5b4fc" stroke-width="1.5" stroke-linecap="round" opacity="0.5"/> + <path d="M21.5 24.5L24 27l2.5-2.5" stroke="#a5b4fc" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.5"/> + <rect x="4" y="28" width="40" height="14" rx="4" fill="url(#dl)"/> + <rect x="10" y="33" width="20" height="3" rx="1.5" fill="white" opacity="0.85"/> +</svg> diff --git a/docs/public/assets/highlight.svg b/docs/public/assets/highlight.svg new file mode 100644 index 0000000..98edbaa --- /dev/null +++ b/docs/public/assets/highlight.svg @@ -0,0 +1,13 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none"> + <defs> + <linearGradient id="hl" x1="20" y1="14" x2="38" y2="34" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fbbf24"/> + <stop offset="1" stop-color="#f59e0b"/> + </linearGradient> + </defs> + <rect x="2" y="17" width="10" height="14" rx="3" fill="#fbbf24" opacity="0.3"/> + <rect x="14" y="17" width="7" height="14" rx="3" fill="#fbbf24" opacity="0.3"/> + <rect x="23" y="13" width="13" height="22" rx="3.5" fill="url(#hl)"/> + <rect x="38" y="17" width="8" height="14" rx="3" fill="#fbbf24" opacity="0.3"/> + <path d="M28.2 4l1 2.4 2.4 1-2.4 1-1 2.4-1-2.4-2.4-1 2.4-1z" fill="#fbbf24" opacity="0.7"/> +</svg> diff --git a/docs/public/assets/keyboard.svg b/docs/public/assets/keyboard.svg new file mode 100644 index 0000000..8148d13 --- /dev/null +++ b/docs/public/assets/keyboard.svg @@ -0,0 +1,21 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none"> + <defs> + <linearGradient id="kb" x1="2" y1="10" x2="46" y2="42" gradientUnits="userSpaceOnUse"> + <stop stop-color="#c084fc"/> + <stop offset="1" stop-color="#7c3aed"/> + </linearGradient> + </defs> + <rect x="2" y="12" width="44" height="30" rx="5" fill="url(#kb)" opacity="0.12"/> + <rect x="2" y="12" width="44" height="30" rx="5" stroke="url(#kb)" stroke-width="1.5" fill="none"/> + <rect x="6" y="16" width="8" height="6" rx="2" fill="url(#kb)"/> + <rect x="16" y="16" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/> + <rect x="26" y="16" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/> + <rect x="36" y="16" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/> + <rect x="6" y="24" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/> + <rect x="16" y="24" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/> + <rect x="26" y="24" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/> + <rect x="36" y="24" width="8" height="6" rx="2" fill="url(#kb)"/> + <rect x="6" y="32" width="8" height="6" rx="2" fill="url(#kb)" opacity="0.35"/> + <rect x="16" y="32" width="16" height="6" rx="2" fill="url(#kb)" opacity="0.35"/> + <rect x="34" y="32" width="10" height="6" rx="2" fill="url(#kb)" opacity="0.35"/> +</svg> diff --git a/docs/public/assets/kiku-integration-poster.jpg b/docs/public/assets/kiku-integration-poster.jpg new file mode 100644 index 0000000..9f23cd0 Binary files /dev/null and b/docs/public/assets/kiku-integration-poster.jpg differ diff --git a/docs/public/assets/kiku-integration.webm b/docs/public/assets/kiku-integration.webm new file mode 100644 index 0000000..bb995f0 Binary files /dev/null and b/docs/public/assets/kiku-integration.webm differ diff --git a/docs/public/assets/minecard-poster.jpg b/docs/public/assets/minecard-poster.jpg new file mode 100644 index 0000000..a1e8139 Binary files /dev/null and b/docs/public/assets/minecard-poster.jpg differ diff --git a/docs/public/assets/minecard.gif b/docs/public/assets/minecard.gif new file mode 100644 index 0000000..a288825 Binary files /dev/null and b/docs/public/assets/minecard.gif differ diff --git a/docs/public/assets/minecard.mkv b/docs/public/assets/minecard.mkv new file mode 100644 index 0000000..6b1cff6 Binary files /dev/null and b/docs/public/assets/minecard.mkv differ diff --git a/docs/public/assets/minecard.mp4 b/docs/public/assets/minecard.mp4 new file mode 100644 index 0000000..3017ec2 Binary files /dev/null and b/docs/public/assets/minecard.mp4 differ diff --git a/docs/public/assets/minecard.png b/docs/public/assets/minecard.png new file mode 100644 index 0000000..3d8c767 Binary files /dev/null and b/docs/public/assets/minecard.png differ diff --git a/docs/public/assets/minecard.webm b/docs/public/assets/minecard.webm new file mode 100644 index 0000000..ae930fe Binary files /dev/null and b/docs/public/assets/minecard.webm differ diff --git a/docs/public/assets/mpv.svg b/docs/public/assets/mpv.svg new file mode 100644 index 0000000..5e7355e --- /dev/null +++ b/docs/public/assets/mpv.svg @@ -0,0 +1,86 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + width="64" + height="64" + viewBox="0 0 63.999999 63.999999" + id="svg2" + version="1.1" + inkscape:version="0.91 r13725" + sodipodi:docname="mpv.svg"> + <defs + id="defs4" /> + <sodipodi:namedview + id="base" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageopacity="0.0" + inkscape:pageshadow="2" + inkscape:zoom="5.3710484" + inkscape:cx="10.112865" + inkscape:cy="18.643164" + inkscape:document-units="px" + inkscape:current-layer="layer1" + showgrid="false" + units="px" + inkscape:window-width="1920" + inkscape:window-height="1016" + inkscape:window-x="0" + inkscape:window-y="27" + inkscape:window-maximized="1" /> + <metadata + id="metadata7"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <g + inkscape:label="Layer 1" + inkscape:groupmode="layer" + id="layer1" + transform="translate(0,-988.3622)"> + <circle + style="opacity:1;fill:#e5e5e5;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.10161044;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:1;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.99215686" + id="path4380" + cx="32" + cy="1020.3622" + r="27.949194" /> + <circle + style="opacity:1;fill:#672168;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.0988237;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:1;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.99215686" + id="path4390" + cx="32.727058" + cy="1019.5079" + r="25.950588" /> + <circle + style="opacity:1;fill:#420143;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:1;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.99215686" + id="path4400" + cx="34.224396" + cy="1017.7957" + r="20" /> + <path + style="fill:#dddbdd;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="m 44.481446,1020.4807 a 12.848894,12.848894 0 0 1 -12.84889,12.8489 12.848894,12.848894 0 0 1 -12.8489,-12.8489 12.848894,12.848894 0 0 1 12.8489,-12.8489 12.848894,12.848894 0 0 1 12.84889,12.8489 z" + id="path4412" + inkscape:connector-curvature="0" /> + <path + style="fill:#691f69;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.1;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" + d="m 28.374316,1014.709 0,11.4502 9.21608,-5.8647 z" + id="path4426" + inkscape:connector-curvature="0" /> + </g> +</svg> diff --git a/docs/public/assets/subtitle-download.svg b/docs/public/assets/subtitle-download.svg new file mode 100644 index 0000000..76f7dc9 --- /dev/null +++ b/docs/public/assets/subtitle-download.svg @@ -0,0 +1,16 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none"> + <defs> + <linearGradient id="sd" x1="4" y1="4" x2="44" y2="44" gradientUnits="userSpaceOnUse"> + <stop stop-color="#22d3ee"/> + <stop offset="1" stop-color="#0891b2"/> + </linearGradient> + </defs> + <rect x="8" y="4" width="24" height="32" rx="3" fill="url(#sd)" opacity="0.15"/> + <rect x="8" y="4" width="24" height="32" rx="3" stroke="url(#sd)" stroke-width="1.5" fill="none"/> + <rect x="13" y="12" width="14" height="2.5" rx="1.25" fill="#22d3ee" opacity="0.5"/> + <rect x="13" y="18" width="10" height="2.5" rx="1.25" fill="#22d3ee" opacity="0.35"/> + <rect x="13" y="24" width="12" height="2.5" rx="1.25" fill="#22d3ee" opacity="0.35"/> + <line x1="38" y1="16" x2="38" y2="32" stroke="url(#sd)" stroke-width="2.5" stroke-linecap="round"/> + <path d="M33 28l5 5 5-5" stroke="url(#sd)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/> + <line x1="33" y1="40" x2="43" y2="40" stroke="url(#sd)" stroke-width="2" stroke-linecap="round" opacity="0.5"/> +</svg> diff --git a/docs/public/assets/texthooker.svg b/docs/public/assets/texthooker.svg new file mode 100644 index 0000000..4da1998 --- /dev/null +++ b/docs/public/assets/texthooker.svg @@ -0,0 +1,19 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none"> + <defs> + <linearGradient id="th" x1="4" y1="6" x2="44" y2="42" gradientUnits="userSpaceOnUse"> + <stop stop-color="#f97316"/> + <stop offset="1" stop-color="#c2410c"/> + </linearGradient> + </defs> + <rect x="4" y="6" width="30" height="36" rx="4" fill="url(#th)" opacity="0.12"/> + <rect x="4" y="6" width="30" height="36" rx="4" stroke="url(#th)" stroke-width="1.5" fill="none"/> + <rect x="9" y="14" width="14" height="2.5" rx="1.25" fill="#f97316" opacity="0.6"/> + <rect x="9" y="20" width="18" height="2.5" rx="1.25" fill="#f97316" opacity="0.4"/> + <rect x="9" y="26" width="12" height="2.5" rx="1.25" fill="#f97316" opacity="0.4"/> + <rect x="9" y="32" width="16" height="2.5" rx="1.25" fill="#f97316" opacity="0.4"/> + <circle cx="40" cy="18" r="3.5" fill="url(#th)" opacity="0.8"/> + <circle cx="40" cy="30" r="3.5" fill="url(#th)" opacity="0.8"/> + <line x1="36" y1="18" x2="34" y2="18" stroke="url(#th)" stroke-width="1.5" stroke-linecap="round" opacity="0.6"/> + <line x1="36" y1="30" x2="34" y2="30" stroke="url(#th)" stroke-width="1.5" stroke-linecap="round" opacity="0.6"/> + <line x1="40" y1="21.5" x2="40" y2="26.5" stroke="url(#th)" stroke-width="1.5" stroke-linecap="round" opacity="0.5"/> +</svg> diff --git a/docs/public/assets/tokenization.svg b/docs/public/assets/tokenization.svg new file mode 100644 index 0000000..b74b8db --- /dev/null +++ b/docs/public/assets/tokenization.svg @@ -0,0 +1,16 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none"> + <defs> + <linearGradient id="tk" x1="0" y1="14" x2="48" y2="34" gradientUnits="userSpaceOnUse"> + <stop stop-color="#22d3ee"/> + <stop offset="1" stop-color="#0891b2"/> + </linearGradient> + </defs> + <rect x="2" y="12" width="12" height="24" rx="3.5" fill="url(#tk)"/> + <rect x="18" y="12" width="12" height="24" rx="3.5" fill="url(#tk)"/> + <rect x="34" y="12" width="12" height="24" rx="3.5" fill="url(#tk)"/> + <line x1="15.5" y1="10" x2="15.5" y2="38" stroke="#22d3ee" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="3 3" opacity="0.45"/> + <line x1="32.5" y1="10" x2="32.5" y2="38" stroke="#22d3ee" stroke-width="1.5" stroke-linecap="round" stroke-dasharray="3 3" opacity="0.45"/> + <rect x="5" y="22" width="6" height="2.5" rx="1.25" fill="white" opacity="0.7"/> + <rect x="21" y="22" width="6" height="2.5" rx="1.25" fill="white" opacity="0.7"/> + <rect x="37" y="22" width="6" height="2.5" rx="1.25" fill="white" opacity="0.7"/> +</svg> diff --git a/docs/public/assets/video.svg b/docs/public/assets/video.svg new file mode 100644 index 0000000..8436c6e --- /dev/null +++ b/docs/public/assets/video.svg @@ -0,0 +1,12 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none"> + <defs> + <linearGradient id="vd" x1="4" y1="10" x2="44" y2="38" gradientUnits="userSpaceOnUse"> + <stop stop-color="#fb7185"/> + <stop offset="1" stop-color="#e11d48"/> + </linearGradient> + </defs> + <rect x="4" y="10" width="40" height="28" rx="4" fill="url(#vd)" opacity="0.15"/> + <rect x="4" y="10" width="40" height="28" rx="4" stroke="url(#vd)" stroke-width="1.5" fill="none"/> + <path d="M20 18l12 6-12 6z" fill="url(#vd)"/> + <rect x="10" y="32" width="22" height="2.5" rx="1.25" fill="white" opacity="0.4"/> +</svg> diff --git a/docs/public/assets/yomitan-icon.svg b/docs/public/assets/yomitan-icon.svg new file mode 100644 index 0000000..3756901 --- /dev/null +++ b/docs/public/assets/yomitan-icon.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><defs><linearGradient id="a" x1="11.876" x2="4.014" y1="4.073" y2="11.935" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#bc00ff" stop-opacity=".941" style="stop-color:#bc00ff;stop-opacity:1"/><stop offset="1" stop-color="#00b9fe"/></linearGradient></defs><rect width="16" height="16" fill="url(#a)" rx="1.625" ry="1.625"/><path d="M2 2v2h3v3H2v2h3v3H2v2h5V2Zm7 0v2h5V2Zm0 5v2h5V7Zm0 5v2h5v-2z" shape-rendering="crispEdges" style="fill:#fff"/></svg> diff --git a/docs/public/config.example.jsonc b/docs/public/config.example.jsonc new file mode 100644 index 0000000..f7f4926 --- /dev/null +++ b/docs/public/config.example.jsonc @@ -0,0 +1,340 @@ +/** + * SubMiner Example Configuration File + * + * This file is auto-generated from src/config/definitions.ts. + * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed. + */ +{ + + // ========================================== + // Overlay Auto-Start + // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. + // ========================================== + "auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false + + // ========================================== + // Visible Overlay Subtitle Binding + // Control whether visible overlay toggles also toggle MPV subtitle visibility. + // When enabled, visible overlay hides MPV subtitles; when disabled, MPV subtitles are left unchanged. + // ========================================== + "bind_visible_overlay_to_mpv_sub_visibility": true, // Link visible overlay toggles to MPV subtitle visibility (primary and secondary). Values: true | false + + // ========================================== + // Texthooker Server + // Control whether browser opens automatically for texthooker. + // ========================================== + "texthooker": { + "openBrowser": true // Open browser setting. Values: true | false + }, // Control whether browser opens automatically for texthooker. + + // ========================================== + // WebSocket Server + // Built-in WebSocket server broadcasts subtitle text to connected clients. + // Auto mode disables built-in server if mpv_websocket is detected. + // ========================================== + "websocket": { + "enabled": "auto", // Built-in subtitle websocket server mode. Values: auto | true | false + "port": 6677 // Built-in subtitle websocket server port. + }, // Built-in WebSocket server broadcasts subtitle text to connected clients. + + // ========================================== + // Logging + // Controls logging verbosity. + // Set to debug for full runtime diagnostics. + // ========================================== + "logging": { + "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error + }, // Controls logging verbosity. + + // ========================================== + // Keyboard Shortcuts + // Overlay keyboard shortcuts. Set a shortcut to null to disable. + // Hot-reload: shortcut changes apply live and update the session help modal on reopen. + // ========================================== + "shortcuts": { + "toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting. + "toggleInvisibleOverlayGlobal": "Alt+Shift+I", // Toggle invisible overlay global setting. + "copySubtitle": "CommandOrControl+C", // Copy subtitle setting. + "copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting. + "updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting. + "triggerFieldGrouping": "CommandOrControl+G", // Trigger field grouping setting. + "triggerSubsync": "Ctrl+Alt+S", // Trigger subsync setting. + "mineSentence": "CommandOrControl+S", // Mine sentence setting. + "mineSentenceMultiple": "CommandOrControl+Shift+S", // Mine sentence multiple setting. + "multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes. + "toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting. + "markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting. + "openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting. + "openJimaku": "Ctrl+Shift+J" // Open jimaku setting. + }, // Overlay keyboard shortcuts. Set a shortcut to null to disable. + + // ========================================== + // Invisible Overlay + // Startup behavior for the invisible interactive subtitle mining layer. + // Invisible subtitle position edit mode: Ctrl/Cmd+Shift+P to toggle, arrow keys to move, Enter or Ctrl/Cmd+S to save, Esc to cancel. + // This edit-mode shortcut is fixed and is not currently configurable. + // ========================================== + "invisibleOverlay": { + "startupVisibility": "platform-default" // Startup visibility setting. + }, // Startup behavior for the invisible interactive subtitle mining layer. + + // ========================================== + // Keybindings (MPV Commands) + // Extra keybindings that are merged with built-in defaults. + // Set command to null to disable a default keybinding. + // Hot-reload: keybinding changes apply live and update the session help modal on reopen. + // ========================================== + "keybindings": [], // Extra keybindings that are merged with built-in defaults. + + // ========================================== + // Secondary Subtitles + // Dual subtitle track options. + // Used by subminer YouTube subtitle generation as secondary language preferences. + // Hot-reload: defaultMode updates live while SubMiner is running. + // ========================================== + "secondarySub": { + "secondarySubLanguages": [], // Secondary sub languages setting. + "autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false + "defaultMode": "hover" // Default mode setting. + }, // Dual subtitle track options. + + // ========================================== + // Auto Subtitle Sync + // Subsync engine and executable paths. + // ========================================== + "subsync": { + "defaultMode": "auto", // Subsync default mode. Values: auto | manual + "alass_path": "", // Alass path setting. + "ffsubsync_path": "", // Ffsubsync path setting. + "ffmpeg_path": "" // Ffmpeg path setting. + }, // Subsync engine and executable paths. + + // ========================================== + // Subtitle Position + // Initial vertical subtitle position from the bottom. + // ========================================== + "subtitlePosition": { + "yPercent": 10 // Y percent setting. + }, // Initial vertical subtitle position from the bottom. + + // ========================================== + // Subtitle Appearance + // Primary and secondary subtitle styling. + // Hot-reload: subtitle style changes apply live without restarting SubMiner. + // ========================================== + "subtitleStyle": { + "enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false + "preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false + "hoverTokenColor": "#c6a0f6", // Hex color used for hovered subtitle token highlight in mpv. + "fontFamily": "M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif", // Font family setting. + "fontSize": 35, // Font size setting. + "fontColor": "#cad3f5", // Font color setting. + "fontWeight": "normal", // Font weight setting. + "fontStyle": "normal", // Font style setting. + "backgroundColor": "rgb(30, 32, 48, 0.88)", // Background color setting. + "nPlusOneColor": "#c6a0f6", // N plus one color setting. + "knownWordColor": "#a6da95", // Known word color setting. + "jlptColors": { + "N1": "#ed8796", // N1 setting. + "N2": "#f5a97f", // N2 setting. + "N3": "#f9e2af", // N3 setting. + "N4": "#a6e3a1", // N4 setting. + "N5": "#8aadf4" // N5 setting. + }, // Jlpt colors setting. + "frequencyDictionary": { + "enabled": false, // Enable frequency-dictionary-based highlighting based on token rank. Values: true | false + "sourcePath": "", // Optional absolute path to a frequency dictionary directory. If empty, built-in discovery search paths are used. + "topX": 1000, // Only color tokens with frequency rank <= topX (default: 1000). + "mode": "single", // single: use one color for all matching tokens. banded: use color ramp by frequency band. Values: single | banded + "singleColor": "#f5a97f", // Color used when frequencyDictionary.mode is `single`. + "bandedColors": [ + "#ed8796", + "#f5a97f", + "#f9e2af", + "#a6e3a1", + "#8aadf4" + ] // Five colors used for rank bands when mode is `banded` (from most common to least within topX). + }, // Frequency dictionary setting. + "secondary": { + "fontSize": 24, // Font size setting. + "fontColor": "#ffffff", // Font color setting. + "backgroundColor": "transparent", // Background color setting. + "fontWeight": "normal", // Font weight setting. + "fontStyle": "normal", // Font style setting. + "fontFamily": "M PLUS 1, Noto Sans CJK JP Regular, Noto Sans CJK JP, Hiragino Sans, Hiragino Kaku Gothic ProN, Yu Gothic, Arial Unicode MS, Arial, sans-serif" // Font family setting. + } // Secondary setting. + }, // Primary and secondary subtitle styling. + + // ========================================== + // AnkiConnect Integration + // Automatic Anki updates and media generation options. + // Hot-reload: AI translation settings update live while SubMiner is running. + // Most other AnkiConnect settings still require restart. + // ========================================== + "ankiConnect": { + "enabled": false, // Enable AnkiConnect integration. Values: true | false + "url": "http://127.0.0.1:8765", // Url setting. + "pollingRate": 3000, // Polling interval in milliseconds. + "tags": [ + "SubMiner" + ], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging. + "fields": { + "audio": "ExpressionAudio", // Audio setting. + "image": "Picture", // Image setting. + "sentence": "Sentence", // Sentence setting. + "miscInfo": "MiscInfo", // Misc info setting. + "translation": "SelectionText" // Translation setting. + }, // Fields setting. + "ai": { + "enabled": false, // Enabled setting. Values: true | false + "alwaysUseAiTranslation": false, // Always use ai translation setting. Values: true | false + "apiKey": "", // Api key setting. + "model": "openai/gpt-4o-mini", // Model setting. + "baseUrl": "https://openrouter.ai/api", // Base url setting. + "targetLanguage": "English", // Target language setting. + "systemPrompt": "You are a translation engine. Return only the translated text with no explanations." // System prompt setting. + }, // Ai setting. + "media": { + "generateAudio": true, // Generate audio setting. Values: true | false + "generateImage": true, // Generate image setting. Values: true | false + "imageType": "static", // Image type setting. + "imageFormat": "jpg", // Image format setting. + "imageQuality": 92, // Image quality setting. + "animatedFps": 10, // Animated fps setting. + "animatedMaxWidth": 640, // Animated max width setting. + "animatedCrf": 35, // Animated crf setting. + "audioPadding": 0.5, // Audio padding setting. + "fallbackDuration": 3, // Fallback duration setting. + "maxMediaDuration": 30 // Max media duration setting. + }, // Media setting. + "behavior": { + "overwriteAudio": true, // Overwrite audio setting. Values: true | false + "overwriteImage": true, // Overwrite image setting. Values: true | false + "mediaInsertMode": "append", // Media insert mode setting. + "highlightWord": true, // Highlight word setting. Values: true | false + "notificationType": "osd", // Notification type setting. + "autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false + }, // Behavior setting. + "nPlusOne": { + "highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false + "refreshMinutes": 1440, // Minutes between known-word cache refreshes. + "matchMode": "headword", // Known-word matching strategy for N+1 highlighting. Values: headword | surface + "decks": [], // Decks used for N+1 known-word cache scope. Supports one or more deck names. + "minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3). + "nPlusOne": "#c6a0f6", // Color used for the single N+1 target token highlight. + "knownWord": "#a6da95" // Color used for legacy known-word highlights. + }, // N plus one setting. + "metadata": { + "pattern": "[SubMiner] %f (%t)" // Pattern setting. + }, // Metadata setting. + "isLapis": { + "enabled": false, // Enabled setting. Values: true | false + "sentenceCardModel": "Japanese sentences" // Sentence card model setting. + }, // Is lapis setting. + "isKiku": { + "enabled": false, // Enabled setting. Values: true | false + "fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled + "deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false + } // Is kiku setting. + }, // Automatic Anki updates and media generation options. + + // ========================================== + // Jimaku + // Jimaku API configuration and defaults. + // ========================================== + "jimaku": { + "apiBaseUrl": "https://jimaku.cc", // Api base url setting. + "languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none + "maxEntryResults": 10 // Maximum Jimaku search results returned. + }, // Jimaku API configuration and defaults. + + // ========================================== + // YouTube Subtitle Generation + // Defaults for subminer YouTube subtitle extraction/transcription mode. + // ========================================== + "youtubeSubgen": { + "mode": "automatic", // YouTube subtitle generation mode for the launcher script. Values: automatic | preprocess | off + "whisperBin": "", // Path to whisper.cpp CLI used as fallback transcription engine. + "whisperModel": "", // Path to whisper model used for fallback transcription. + "primarySubLanguages": [ + "ja", + "jpn" + ] // Comma-separated primary subtitle language priority used by the launcher. + }, // Defaults for subminer YouTube subtitle extraction/transcription mode. + + // ========================================== + // Anilist + // Anilist API credentials and update behavior. + // ========================================== + "anilist": { + "enabled": false, // Enable AniList post-watch progress updates. Values: true | false + "accessToken": "" // Optional explicit AniList access token override; leave empty to use locally stored token from setup. + }, // Anilist API credentials and update behavior. + + // ========================================== + // Jellyfin + // Optional Jellyfin integration for auth, browsing, and playback launch. + // Access token is stored in local encrypted token storage after login/setup. + // jellyfin.accessToken remains an optional explicit override in config. + // ========================================== + "jellyfin": { + "enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false + "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). + "username": "", // Default Jellyfin username used during CLI login. + "deviceId": "subminer", // Device id setting. + "clientName": "SubMiner", // Client name setting. + "clientVersion": "0.1.0", // Client version setting. + "defaultLibraryId": "", // Optional default Jellyfin library ID for item listing. + "remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false + "remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false + "autoAnnounce": false, // When enabled, automatically trigger remote announce/visibility check on websocket connect. Values: true | false + "remoteControlDeviceName": "SubMiner", // Device name reported for Jellyfin remote control sessions. + "pullPictures": false, // Enable Jellyfin poster/icon fetching for launcher menus. Values: true | false + "iconCacheDir": "/tmp/subminer-jellyfin-icons", // Directory used by launcher for cached Jellyfin poster icons. + "directPlayPreferred": true, // Try direct play before server-managed transcoding when possible. Values: true | false + "directPlayContainers": [ + "mkv", + "mp4", + "webm", + "mov", + "flac", + "mp3", + "aac" + ], // Container allowlist for direct play decisions. + "transcodeVideoCodec": "h264" // Preferred transcode video codec when direct play is unavailable. + }, // Optional Jellyfin integration for auth, browsing, and playback launch. + + // ========================================== + // Discord Rich Presence + // Optional Discord Rich Presence activity card updates for current playback/study session. + // Uses official SubMiner Discord app assets for polished card visuals. + // ========================================== + "discordPresence": { + "enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false + "updateIntervalMs": 3000, // Minimum interval between presence payload updates. + "debounceMs": 750 // Debounce delay used to collapse bursty presence updates. + }, // Optional Discord Rich Presence activity card updates for current playback/study session. + + // ========================================== + // Immersion Tracking + // Enable/disable immersion tracking. + // Set dbPath to override the default sqlite database location. + // Policy tuning is available for queue, flush, and retention values. + // ========================================== + "immersionTracking": { + "enabled": true, // Enable immersion tracking for mined subtitle metadata. Values: true | false + "dbPath": "", // Optional SQLite database path for immersion tracking. Empty value uses the default app data path. + "batchSize": 25, // Buffered telemetry/event writes per SQLite transaction. + "flushIntervalMs": 500, // Max delay before queue flush in milliseconds. + "queueCap": 1000, // In-memory write queue cap before overflow policy applies. + "payloadCapBytes": 256, // Max JSON payload size per event before truncation. + "maintenanceIntervalMs": 86400000, // Maintenance cadence (prune + rollup + vacuum checks). + "retention": { + "eventsDays": 7, // Raw event retention window in days. + "telemetryDays": 30, // Telemetry retention window in days. + "dailyRollupsDays": 365, // Daily rollup retention window in days. + "monthlyRollupsDays": 1825, // Monthly rollup retention window in days. + "vacuumIntervalDays": 7 // Minimum days between VACUUM runs. + } // Retention setting. + } // Enable/disable immersion tracking. +} diff --git a/docs/public/favicon-16x16.png b/docs/public/favicon-16x16.png new file mode 100644 index 0000000..213e5f2 Binary files /dev/null and b/docs/public/favicon-16x16.png differ diff --git a/docs/public/favicon-32x32.png b/docs/public/favicon-32x32.png new file mode 100644 index 0000000..b576da5 Binary files /dev/null and b/docs/public/favicon-32x32.png differ diff --git a/docs/public/favicon.ico b/docs/public/favicon.ico new file mode 100644 index 0000000..dcdbe5d Binary files /dev/null and b/docs/public/favicon.ico differ diff --git a/docs/reports/2026-02-22-task-100-dead-code-report.md b/docs/reports/2026-02-22-task-100-dead-code-report.md new file mode 100644 index 0000000..daf3a6d --- /dev/null +++ b/docs/reports/2026-02-22-task-100-dead-code-report.md @@ -0,0 +1,54 @@ +# TASK-100 Dead Code Report (2026-02-22) + +## Baseline Verification + +- `bun run build` -> PASS +- `bun run test:fast` -> PASS + +## Discovery Commands + +- `tsc --noEmit --noUnusedLocals --noUnusedParameters` +- `bunx ts-prune -p tsconfig.json` + +## Triage + +### Remove + +- `src/anki-connect.ts` - removed unused `url` instance field. +- `src/anki-integration.ts` - removed unused wrappers: `poll`, `showProgressTick`, `refreshMiscInfoField`. +- `src/anki-integration/card-creation.ts` - removed unused `MediaGenerator` import. +- `src/anki-integration/ui-feedback.ts` - removed unused callback parameter in `withUpdateProgress`. +- `src/core/services/anki-jimaku-ipc.ts` - removed unused `JimakuDownloadQuery` import. +- `src/core/services/immersion-tracker-service.ts` - removed unused fields `lastMaintenanceMs`, `lastQueueWriteAtMs`; removed unused `runRollupMaintenance` wrapper. +- `src/core/services/ipc-command.ts` - removed unused `RuntimeOptionValue` import. +- `src/renderer/positioning/position-state.ts` - removed unused `ctx` parameter from `getPersistedOffset`. +- `src/tokenizers/index.ts` - removed unused exported helpers `getRegisteredTokenizerProviderIds`, `createTokenizerProvider`. +- `src/token-mergers/index.ts` - removed unused exported helpers `getRegisteredTokenMergerProviderIds`, `createTokenMergerProvider`. +- `src/core/utils/index.ts` - removed unused barrel re-exports `asBoolean`, `asFiniteNumber`, `asString`. + +### Keep (intentional / out-of-scope) + +- `src/main/runtime/composers/composer-contracts.type-test.ts` private `_` type aliases remain; they are compile-time contract assertions. +- `src/main.ts` large unused-import cluster from ongoing composer/runtime decomposition kept for separate focused task to avoid behavior risk. +- Broad `ts-prune` type-export findings in `src/types.ts` and multiple domain modules kept; many are declaration-surface exports and module-local false positives. + +## Complexity Delta + +- Removed 13 confirmed dead declarations/imports/helpers. +- Removed 4 unused exported entrypoints from provider registries/util barrel. +- `tsc --noEmit --noUnusedLocals --noUnusedParameters` diagnostics reduced to `39` lines; remaining diagnostics are concentrated in `src/main.ts` plus intentional type-test aliases. + +## Regression Safety / Tests + +- `bun test src/anki-integration.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/ipc.test.ts` + - partial pass; direct IPC test invocation hit Electron ESM test harness issue (`Export named 'ipcMain' not found`) unrelated to cleanup. +- Required task gates: + - `bun run build` -> PASS + - `bun run test:core:src` -> PASS + - `bun run test:config:src` -> PASS + - `bun run check:file-budgets` -> PASS (warning mode, no strict hotspot violations) + +## Remaining Candidates + +- Continue with dedicated `src/main.ts` dead-import cleanup once runtime composer migration settles. +- Revisit `ts-prune` findings with a declaration-aware filter to separate true dead exports from public API type surfaces. diff --git a/docs/shortcuts.md b/docs/shortcuts.md new file mode 100644 index 0000000..4ce1285 --- /dev/null +++ b/docs/shortcuts.md @@ -0,0 +1,133 @@ +# Keyboard Shortcuts + +All shortcuts are configurable in `config.jsonc` under `shortcuts` and `keybindings`. Set any shortcut to `null` to disable it. + +## Global Shortcuts + +These work system-wide regardless of which window has focus. + +| Shortcut | Action | Configurable | +| ------------- | ------------------------ | ---------------------------------------- | +| `Alt+Shift+O` | Toggle visible overlay | `shortcuts.toggleVisibleOverlayGlobal` | +| `Alt+Shift+I` | Toggle invisible overlay | `shortcuts.toggleInvisibleOverlayGlobal` | +| `Alt+Shift+Y` | Open Yomitan settings | Fixed (not configurable) | + +::: tip +Global shortcuts are registered with the OS. If they conflict with another application, update them in `shortcuts` config and restart SubMiner. +::: + +## Mining Shortcuts + +These work when the overlay window has focus. + +| Shortcut | Action | Config key | +| ------------------ | ----------------------------------------------- | --------------------------------------- | +| `Ctrl/Cmd+S` | Mine current subtitle as sentence card | `shortcuts.mineSentence` | +| `Ctrl/Cmd+Shift+S` | Mine multiple lines (press 1–9 to select count) | `shortcuts.mineSentenceMultiple` | +| `Ctrl/Cmd+C` | Copy current subtitle text | `shortcuts.copySubtitle` | +| `Ctrl/Cmd+Shift+C` | Copy multiple lines (press 1–9 to select count) | `shortcuts.copySubtitleMultiple` | +| `Ctrl/Cmd+V` | Update last Anki card from clipboard text | `shortcuts.updateLastCardFromClipboard` | +| `Ctrl/Cmd+G` | Trigger field grouping (Kiku merge check) | `shortcuts.triggerFieldGrouping` | +| `Ctrl/Cmd+Shift+A` | Mark last card as audio card | `shortcuts.markAudioCard` | + +The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcuts.multiCopyTimeoutMs`). Press `1`–`9` to select how many recent subtitle lines to combine. + +## Overlay Controls + +These control playback and subtitle display. They require overlay window focus. + +| Shortcut | Action | +| -------------------- | -------------------------------------------------- | +| `Space` | Toggle mpv pause | +| `ArrowRight` | Seek forward 5 seconds | +| `ArrowLeft` | Seek backward 5 seconds | +| `ArrowUp` | Seek forward 60 seconds | +| `ArrowDown` | Seek backward 60 seconds | +| `Shift+H` | Jump to previous subtitle | +| `Shift+L` | Jump to next subtitle | +| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) | +| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) | +| `Q` | Quit mpv | +| `Ctrl+W` | Quit mpv | +| `Right-click` | Toggle pause (outside subtitle area) | +| `Right-click + drag` | Reposition subtitles (on subtitle area) | +| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist | + +These keybindings can be overridden or disabled via the `keybindings` config array. + +## Subtitle & Feature Shortcuts + +| Shortcut | Action | Config key | +| ------------------ | -------------------------------------------------------- | ------------------------------ | +| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` | +| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | +| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | +| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | + +## Invisible Subtitle Position Edit Mode + +Enter edit mode to fine-tune invisible overlay alignment with mpv's native subtitles. + +| Shortcut | Action | +| --------------------- | -------------------------------- | +| `Ctrl/Cmd+Shift+P` | Toggle position edit mode | +| `ArrowKeys` or `hjkl` | Nudge position by 1 px | +| `Shift+Arrow` | Nudge position by 4 px | +| `Enter` or `Ctrl+S` | Save position and exit edit mode | +| `Esc` | Cancel and discard changes | + +## MPV Plugin Chords + +When the mpv plugin is installed, all commands use a `y` chord prefix — press `y`, then the second key within 1 second. + +| Chord | Action | +| ----- | --------------------------------------- | +| `y-y` | Open SubMiner menu (OSD) | +| `y-s` | Start overlay | +| `y-S` | Stop overlay | +| `y-t` | Toggle visible overlay | +| `y-i` | Toggle invisible overlay | +| `y-I` | Show invisible overlay | +| `y-u` | Hide invisible overlay | +| `y-o` | Open Yomitan settings | +| `y-r` | Restart overlay | +| `y-c` | Check overlay status | + +When the overlay has focus, press `y` then `d` to toggle DevTools (debugging helper). + +## Drag-and-Drop + +| Gesture | Action | +| ------------------------- | ------------------------------------------------ | +| Drop file(s) onto overlay | Replace current mpv playlist with dropped files | +| `Shift` + drop file(s) | Append all dropped files to current mpv playlist | + +## Customizing Shortcuts + +All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts), for example `"CommandOrControl+Shift+M"`. Use `null` to disable a shortcut. + +```jsonc +{ + "shortcuts": { + "mineSentence": "CommandOrControl+S", + "copySubtitle": "CommandOrControl+C", + "toggleVisibleOverlayGlobal": "Alt+Shift+O", + "toggleInvisibleOverlayGlobal": "Alt+Shift+I", + "openJimaku": null, // disabled + }, +} +``` + +The `keybindings` array overrides or extends the overlay's built-in key handling for mpv commands: + +```jsonc +{ + "keybindings": [ + { "key": "f", "command": ["cycle", "fullscreen"] }, + { "key": "m", "command": ["cycle", "mute"] }, + { "key": "Space", "command": null }, // disable default Space → pause + ], +} +``` + +Both `shortcuts` and `keybindings` are [hot-reloadable](/configuration#hot-reload-behavior) — changes take effect without restarting SubMiner. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..39ce9ca --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,202 @@ +# Troubleshooting + +Common issues and how to resolve them. + +## MPV Connection + +**Overlay starts but shows no subtitles** + +SubMiner connects to mpv via a Unix socket (or named pipe on Windows). If the socket does not exist or the path does not match, the overlay will appear but subtitles will never arrive. + +- Ensure mpv is running with `--input-ipc-server=/tmp/subminer-socket`. +- If you use a custom socket path, set it in both your mpv config and SubMiner config (`mpvSocketPath`). +- The `subminer` wrapper script sets the socket automatically when it launches mpv. If you launch mpv yourself, the `--input-ipc-server` flag is required. + +SubMiner retries the connection automatically with increasing delays (200 ms, 500 ms, 1 s, 2 s on first connect; 1 s, 2 s, 5 s, 10 s on reconnect). If mpv exits and restarts, the overlay reconnects without needing a restart. + +## Logging and App Mode + +- Default log output is `info`. +- Use `--log-level` for more/less output. +- Use `--dev`/`--debug` only to force app/dev mode (for example to get dev behavior from the overlay/app); they do not change log verbosity. +- You can combine both, for example `SubMiner.AppImage --start --dev --log-level debug`, when you need maximum diagnostics. + +**"Failed to parse MPV message"** + +Logged when a malformed JSON line arrives from the mpv socket. Usually harmless — SubMiner skips the bad line and continues. If it happens constantly, check that nothing else is writing to the same socket path. + +## AnkiConnect + +**"AnkiConnect: unable to connect"** + +SubMiner polls AnkiConnect at `http://127.0.0.1:8765` (configurable via `ankiConnect.url`). This error means Anki is not running or the AnkiConnect add-on is not installed. + +- Install the [AnkiConnect](https://ankiweb.net/shared/info/2055492159) add-on in Anki. +- Make sure Anki is running before you start mining. +- If you changed the AnkiConnect port, update `ankiConnect.url` in your config. + +SubMiner retries with exponential backoff (up to 5 s) and suppresses repeated error logs after 5 consecutive failures. When Anki comes back, you will see "AnkiConnect connection restored". + +**Cards are created but fields are empty** + +Field names in your config must match your Anki note type exactly (case-sensitive). Check `ankiConnect.fields` — for example, if your note type uses `SentenceAudio` but your config says `Audio`, the field will not be populated. + +See [Anki Integration](/anki-integration) for the full field mapping reference. + +**"Update failed" OSD message** + +Shown when SubMiner tries to update a card that no longer exists, or when AnkiConnect rejects the update. Common causes: + +- The card was deleted in Anki between polling and update. +- The note type changed and a mapped field no longer exists. + +## Overlay + +**Overlay does not appear** + +- Confirm SubMiner is running: `SubMiner.AppImage --start` or check for the process. +- On Linux, the overlay requires a compositor. Hyprland and Sway are supported natively; X11 requires `xdotool` and `xwininfo`. +- On macOS, grant Accessibility permission to SubMiner in System Settings > Privacy & Security > Accessibility. + +**Overlay appears but clicks pass through / cannot interact** + +- On Linux, mouse passthrough can be unreliable — this is a known Electron/platform limitation. The overlay keeps pointer events enabled by default on Linux. +- On macOS/Windows, `setIgnoreMouseEvents` toggles automatically. If clicks stop working, toggle the overlay off and back on (`Alt+Shift+O`). +- Make sure you are hovering over the subtitle area — the overlay only becomes interactive when the cursor is over subtitle text. + +**Overlay briefly freezes after a modal/runtime error** + +- Renderer errors now trigger an automatic recovery path. You should see a short toast ("Renderer error recovered. Overlay is still running."). +- Recovery closes any open modal and restores click-through/shortcuts automatically without interrupting mpv playback. +- If errors keep recurring, toggle the overlay's DevTools using overlay chord `y` then `d` (or global `F12`) and inspect the `renderer overlay recovery` error payload for stack trace + modal/subtitle context. + +**Overlay is on the wrong monitor or position** + +SubMiner positions the overlay by tracking the mpv window. If tracking fails: + +- Hyprland: Ensure `hyprctl` is available. +- Sway: Ensure `swaymsg` is available. +- X11: Ensure `xdotool` and `xwininfo` are installed. + +If the overlay position is slightly off, use invisible subtitle position edit mode (`Ctrl/Cmd+Shift+P`) to fine-tune the offset with arrow keys, then save with `Enter` or `Ctrl+S`. + +## Yomitan + +**"Yomitan extension not found in any search path"** + +SubMiner bundles Yomitan and searches for it in these locations (in order): + +1. `vendor/yomitan` (relative to executable) +2. `<resources>/yomitan` (Electron resources path) +3. `/usr/share/SubMiner/yomitan` +4. `~/.config/SubMiner/extensions/yomitan` + +If you installed from the AppImage and see this error, the package may be incomplete. Re-download the AppImage or place the Yomitan extension manually in `~/.config/SubMiner/extensions/yomitan`. + +**Yomitan popup does not appear when clicking words** + +- Verify Yomitan loaded successfully — check the terminal output for "Loaded Yomitan extension". +- Yomitan requires dictionaries to be installed. Open Yomitan settings (`Alt+Shift+Y` or `SubMiner.AppImage --settings`) and confirm at least one dictionary is imported. +- If the overlay shows subtitles but words are not clickable, the tokenizer may have failed. See the MeCab section below. + +## MeCab / Tokenization + +**"MeCab not found on system"** + +This is informational, not an error. SubMiner tokenization is driven by Yomitan's internal parser. MeCab availability checks may still run for auxiliary token metadata, but MeCab is not used as a tokenization fallback path. + +To install MeCab: + +- **Arch Linux**: `sudo pacman -S mecab mecab-ipadic` +- **Ubuntu/Debian**: `sudo apt install mecab libmecab-dev mecab-ipadic-utf8` +- **macOS**: `brew install mecab mecab-ipadic` + +**Words are not segmented correctly** + +Japanese word boundaries depend on Yomitan parser output. If segmentation seems wrong: + +- Verify Yomitan dictionaries are installed and active. +- Note that CJK characters without spaces are segmented using parser heuristics, which is not always perfect. + +## Media Generation + +**"FFmpeg not found"** + +SubMiner uses FFmpeg to extract audio clips and generate screenshots. Install it: + +- **Arch Linux**: `sudo pacman -S ffmpeg` +- **Ubuntu/Debian**: `sudo apt install ffmpeg` +- **macOS**: `brew install ffmpeg` + +Without FFmpeg, card creation still works but audio and image fields will be empty. + +**Audio or screenshot generation hangs** + +Media generation has a 30-second timeout (60 seconds for animated AVIF). If your video file is on a slow network mount or the codec requires software decoding, generation may time out. Try: + +- Using a local copy of the video file. +- Reducing `media.imageQuality` or switching from `avif` to `static` image type. +- Checking that `media.maxMediaDuration` is not set too high. + +## Shortcuts + +**"Failed to register global shortcut"** + +Global shortcuts (`Alt+Shift+O`, `Alt+Shift+I`, `Alt+Shift+Y`) may conflict with other applications or desktop environment keybindings. + +- Check your DE/WM keybinding settings for conflicts. +- Change the shortcuts in your config under `shortcuts.toggleVisibleOverlayGlobal`, `shortcuts.toggleInvisibleOverlayGlobal`. +- On Wayland, global shortcut registration has limitations depending on the compositor. + +**Overlay keybindings not working** + +Overlay-local shortcuts (Space, arrow keys, etc.) only work when the overlay window has focus. Click on the overlay or use the global shortcut to toggle it to give it focus. + +## Subtitle Timing + +**"Subtitle timing not found; copy again while playing"** + +This OSD message appears when you try to mine a sentence but SubMiner has no timing data for the current subtitle. Causes: + +- The video is paused and no subtitle has been received yet. +- The subtitle track changed and timing data was cleared. +- You are using an external subtitle file that mpv has not fully loaded. + +Resume playback and wait for the next subtitle to appear, then try mining again. + +## Subtitle Sync (Subsync) + +**"Configured alass executable not found"** + +Install alass or configure the path: + +- **Arch Linux (AUR)**: `yay -S alass-git` +- Set the path: `subsync.alass_path` in your config. + +**"Subtitle synchronization failed"** + +SubMiner tries alass first, then falls back to ffsubsync. If both fail: + +- Ensure the reference subtitle track exists in the video (alass requires a source track). +- Check that `ffmpeg` is available (used to extract the internal subtitle track). +- Try running the sync tool manually to see detailed error output. + +## Jimaku + +**"Jimaku request failed" or HTTP 429** + +The Jimaku API has rate limits. If you see 429 errors, wait for the retry duration shown in the OSD message and try again. If you have a Jimaku API key, set it in `jimaku.apiKey` or `jimaku.apiKeyCommand` to get higher rate limits. + +## Platform-Specific + +### Linux + +- **Wayland (Hyprland/Sway)**: Window tracking uses compositor-specific commands. If `hyprctl` or `swaymsg` are not on `PATH`, tracking will fail silently. +- **X11**: Requires `xdotool` and `xwininfo`. If missing, the overlay cannot track the mpv window position. +- **Mouse passthrough**: On Linux, Electron's mouse passthrough is unreliable. SubMiner keeps pointer events enabled, meaning you may need to toggle the overlay off to interact with mpv controls underneath. + +### macOS + +- **Accessibility permission**: Required for window tracking. Grant it in System Settings > Privacy & Security > Accessibility. +- **Font rendering**: macOS uses a 0.87x font compensation factor for subtitle alignment between mpv and the overlay. If text alignment looks off, adjust the invisible subtitle offset. +- **Gatekeeper**: If macOS blocks SubMiner, right-click the app and select "Open" to bypass the warning, or remove the quarantine attribute: `xattr -d com.apple.quarantine /path/to/SubMiner.app` diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..38de2e1 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,215 @@ +# Usage + +There are two ways to use SubMiner — the `subminer` wrapper script or the mpv plugin: + +| Approach | Best For | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **subminer script** | All-in-one solution. Handles video selection, launches MPV with the correct socket, and manages app commands. Overlay start is explicit (`--start`, `-S`, or `y-s`). | +| **MPV plugin** | When you launch MPV yourself or from other tools. Provides in-MPV chord keybindings (e.g. `y-y` for menu) to control visible and invisible overlay layers. Requires `--input-ipc-server=/tmp/subminer-socket`. | + +You can use both together—install the plugin for on-demand control, but use `subminer` when you want the streamlined workflow. + +`subminer` is implemented as a Bun script and runs directly via shebang (no `bun run` needed), for example: `subminer --start video.mkv`. + +## Live Config Reload + +While SubMiner is running, it watches your active config file and applies safe updates automatically. + +Live-updated settings: + +- `subtitleStyle` +- `keybindings` +- `shortcuts` +- `secondarySub.defaultMode` +- `ankiConnect.ai` + +Invalid config edits are rejected; SubMiner keeps the previous valid runtime config and shows an error notification. +For restart-required sections, SubMiner shows a restart-needed notification. + +## Commands + +```bash +# Browse and play videos +subminer # Current directory (uses fzf) +subminer -R # Use rofi instead of fzf +subminer -d ~/Videos # Specific directory +subminer -r -d ~/Anime # Recursive search +subminer video.mkv # Play specific file +subminer --start video.mkv # Play + explicitly start overlay +subminer https://youtu.be/... # Play a YouTube URL +subminer ytsearch:"jp news" # Play first YouTube search result +subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging +subminer --log-level warn video.mkv # Set logging level explicitly + +# Options +subminer -T video.mkv # Disable texthooker server +subminer -b x11 video.mkv # Force X11 backend +subminer video.mkv # Uses mpv profile "subminer" by default +subminer -p gpu-hq video.mkv # Override mpv profile +subminer jellyfin # Open Jellyfin setup window (subcommand form) +subminer jellyfin -l --server http://127.0.0.1:8096 --username me --password 'secret' +subminer jellyfin --logout # Clear stored Jellyfin token/session data +subminer jellyfin -p # Interactive Jellyfin library/item picker + playback +subminer jellyfin -d # Jellyfin cast-discovery mode (foreground app) +subminer doctor # Dependency + config + socket diagnostics +subminer config path # Print active config path +subminer config show # Print active config contents +subminer mpv socket # Print active mpv socket path +subminer mpv status # Exit 0 if socket is ready, else exit 1 +subminer mpv idle # Launch detached idle mpv with SubMiner defaults +subminer texthooker # Launch texthooker-only mode +subminer app --anilist # Pass args directly to SubMiner binary (example: AniList login flow) +subminer yt -o ~/subs https://youtu.be/... # YouTube subcommand: output directory shortcut +subminer yt --mode preprocess --whisper-bin /path/to/whisper-cli --whisper-model /path/to/model.bin https://youtu.be/... # Pre-generate subtitle tracks before playback + +# Direct AppImage control +SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs) +SubMiner.AppImage --start --texthooker # Start overlay with texthooker +SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window) +SubMiner.AppImage --stop # Stop overlay +SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility +SubMiner.AppImage --start --toggle-invisible-overlay # Start MPV IPC + toggle invisible layer +SubMiner.AppImage --show-visible-overlay # Force show visible overlay +SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay +SubMiner.AppImage --show-invisible-overlay # Force show invisible overlay +SubMiner.AppImage --hide-invisible-overlay # Force hide invisible overlay +SubMiner.AppImage --start --dev # Enable app/dev mode only +SubMiner.AppImage --start --debug # Alias for --dev +SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode +SubMiner.AppImage --settings # Open Yomitan settings +SubMiner.AppImage --jellyfin # Open Jellyfin setup window +SubMiner.AppImage --jellyfin-login --jellyfin-server http://127.0.0.1:8096 --jellyfin-username me --jellyfin-password 'secret' +SubMiner.AppImage --jellyfin-logout # Clear stored Jellyfin token/session data +SubMiner.AppImage --jellyfin-libraries +SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search anime --jellyfin-limit 20 +SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID --jellyfin-audio-stream-index 1 --jellyfin-subtitle-stream-index 2 # Requires connected mpv IPC (--start or plugin workflow) +SubMiner.AppImage --jellyfin-remote-announce # Force cast-target capability announce + visibility check +SubMiner.AppImage --help # Show all options +``` + +### Logging and App Mode + +- `--log-level` controls logger verbosity. +- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases. +- `--background` defaults to quieter logging (`warn`) unless `--log-level` is set. +- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop`. +- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`). +- Use both when needed, for example `SubMiner.AppImage --start --dev --log-level debug`. + +### Launcher Subcommands + +- `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases. +- `subminer yt` / `subminer youtube`: YouTube-focused shorthand flags (`-o`, `-m`). +- `subminer doctor`: health checks for core dependencies and runtime paths. +- `subminer config`: config helpers (`path`, `show`). +- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`). +- `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`). +- `subminer app` / `subminer bin`: direct passthrough to the SubMiner binary/AppImage. +- Subcommand help pages are available (for example `subminer jellyfin -h`, `subminer yt -h`). + +Use subcommands for Jellyfin/YouTube command families (`subminer jellyfin ...`, `subminer yt ...`). +Top-level launcher flags like `--jellyfin-*` and `--yt-subgen-*` are intentionally rejected. + +### MPV Profile Example (mpv.conf) + +`subminer` passes the following MPV options directly on launch by default: + +- `--input-ipc-server=/tmp/subminer-socket` (or your configured socket path) +- `--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us` +- `--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us` +- `--sub-auto=fuzzy` +- `--sub-file-paths=.;subs;subtitles` +- `--sid=auto` +- `--secondary-sid=auto` +- `--secondary-sub-visibility=no` + +You can define a matching profile in `~/.config/mpv/mpv.conf` for consistency when launching `mpv` manually or from other tools. `subminer` launches with `--profile=subminer` by default (or override with `subminer -p <profile> ...`): + +```ini +[subminer] +# IPC socket (must match SubMiner config) +input-ipc-server=/tmp/subminer-socket + +# Prefer JP/EN audio + subtitle language variants +alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us +slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us + +# Auto-load external subtitles +sub-auto=fuzzy +sub-file-paths=.;subs;subtitles + +# Select primary + secondary subtitle tracks automatically +sid=auto +secondary-sid=auto +secondary-sub-visibility=no +``` + +`secondary-slang` is not an mpv option; use `slang` with `sid=auto` / `secondary-sid=auto` instead. + +### YouTube Playback + +`subminer` accepts direct URLs (for example, YouTube links) and `ytsearch:` targets, and forwards them to mpv. + +Notes: + +- Install `yt-dlp` so mpv can resolve YouTube streams and subtitle tracks reliably. +- `subminer` supports three subtitle-generation modes for YouTube URLs: + - `automatic` (default): starts playback immediately, generates subtitles in the background, and loads them into mpv when ready. + - `preprocess`: generates subtitles first, then starts playback with generated `.srt` files attached. + - `off`: disables launcher generation and leaves subtitle handling to mpv/yt-dlp. +- Primary subtitle target languages come from `youtubeSubgen.primarySubLanguages` (defaults to `["ja","jpn"]`). +- Secondary target languages come from `secondarySub.secondarySubLanguages` (defaults to English if unset). +- `subminer` prefers subtitle tracks from yt-dlp first, then falls back to local `whisper.cpp` (`whisper-cli`) when tracks are missing. +- Whisper translation fallback currently only supports English secondary targets; non-English secondary targets rely on yt-dlp subtitle availability. +- Configure defaults in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`) under `youtubeSubgen` and `secondarySub`, or override mode/tool paths via CLI flags/environment variables. + +## Keybindings + +### Global Shortcuts + +| Keybind | Action | +| ------------- | ------------------------ | +| `Alt+Shift+O` | Toggle visible overlay | +| `Alt+Shift+I` | Toggle invisible overlay | +| `Alt+Shift+Y` | Open Yomitan settings | + +`Alt+Shift+Y` is a fixed global shortcut; it is not part of `shortcuts` config. + +### Overlay Controls (Configurable) + +| Input | Action | +| -------------------- | -------------------------------------------------- | +| `Space` | Toggle MPV pause | +| `ArrowRight` | Seek forward 5 seconds | +| `ArrowLeft` | Seek backward 5 seconds | +| `ArrowUp` | Seek forward 60 seconds | +| `ArrowDown` | Seek backward 60 seconds | +| `Shift+H` | Jump to previous subtitle | +| `Shift+L` | Jump to next subtitle | +| `Ctrl+Shift+H` | Replay current subtitle (play to end, then pause) | +| `Ctrl+Shift+L` | Play next subtitle (jump, play to end, then pause) | +| `Q` | Quit mpv | +| `Ctrl+W` | Quit mpv | +| `Right-click` | Toggle MPV pause (outside subtitle area) | +| `Right-click + drag` | Move subtitle position (on subtitle) | +| `Ctrl/Cmd+Shift+P` | Toggle invisible subtitle position edit mode | +| `Arrow keys` | Move invisible subtitles while edit mode is active | +| `Enter` / `Ctrl+S` | Save invisible subtitle position in edit mode | +| `Esc` | Cancel invisible subtitle position edit mode | +| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist | + +These keybindings only work when the overlay window has focus. See [Configuration](/configuration) for customization. + +### Drag-and-drop Queueing + +- Drag and drop one or more video files onto the overlay to replace current playback (`loadfile ... replace` for first file, then append remainder). +- Hold `Shift` while dropping to append all dropped files to the current MPV playlist. + +## How It Works + +1. MPV runs with an IPC socket at `/tmp/subminer-socket` +2. The overlay connects and subscribes to subtitle changes +3. Subtitles are tokenized with Yomitan's internal parser +4. Words are displayed as clickable spans +5. Clicking a word triggers Yomitan popup for dictionary lookup +6. Texthooker server runs at `http://127.0.0.1:5174` for external tools