diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9cf19e2..8ece256 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## v0.9.3 (2026-03-25)
+
+### Changed
+- Launcher: Moved YouTube primary subtitle language defaults to `youtube.primarySubLanguages`.
+- Launcher: Removed the placeholder YouTube subtitle retime step and now uses downloaded primary subtitle tracks directly, so there is no fake path rewrite before playback/sidebar loading.
+- YouTube: Removed the `src/core/services/youtube/retime` helper and its tests after retiring the internal retime strategy.
+- Docs: Clarified optional `alass` / `ffsubsync` subtitle-sync requirements and setup steps, including fallback behavior when sync tools are absent.
+- Launcher: Removed the old `youtubeSubgen.primarySubLanguages` config path from the generated config and docs.
+
## v0.9.2 (2026-03-25)
### Fixed
diff --git a/README.md b/README.md
index 4db4de0..1a4c2e3 100644
--- a/README.md
+++ b/README.md
@@ -84,7 +84,7 @@ Local stats dashboard — watch time, anime library, vocabulary growth, mining t
| alass / ffsubsync |
- Automatic subtitle retiming |
+ Automatic subtitle retiming — requires alass or ffsubsync on your PATH (optional; subtitle syncing is disabled without them) |
| WebSocket |
@@ -105,7 +105,7 @@ Local stats dashboard — watch time, anime library, vocabulary growth, mining t
| | Required | Optional |
| -------------- | --------------------------------------- | -------------------------------------- |
| **Player** | [`mpv`](https://mpv.io) with IPC socket | — |
-| **Processing** | `ffmpeg`, `mecab` + `mecab-ipadic` | `guessit` (AniSkip) |
+| **Processing** | `ffmpeg`, `mecab` + `mecab-ipadic` | `guessit` (AniSkip), `alass` / `ffsubsync` (subtitle sync) |
| **Media** | — | `yt-dlp`, `chafa`, `ffmpegthumbnailer` |
| **Selection** | — | `fzf` / `rofi` |
@@ -125,6 +125,8 @@ Local stats dashboard — watch time, anime library, vocabulary growth, mining t
paru -S --needed mpv ffmpeg mecab-git mecab-ipadic
# Optional
paru -S --needed yt-dlp fzf rofi chafa ffmpegthumbnailer xdotool xorg-xwininfo
+# Optional: subtitle sync (install at least one for subtitle syncing to work)
+paru -S --needed alass python-ffsubsync
# X11 / XWAYLAND
paru -S --needed xdotool xorg-xwininfo
```
@@ -138,6 +140,9 @@ paru -S --needed xdotool xorg-xwininfo
brew install mpv ffmpeg mecab mecab-ipadic
# Optional
brew install yt-dlp fzf rofi chafa ffmpegthumbnailer
+# Optional: subtitle sync (install at least one for subtitle syncing to work)
+brew install alass
+pip install ffsubsync
```
Grant Accessibility permission to SubMiner in **System Settings > Privacy & Security > Accessibility**.
diff --git a/backlog/tasks/task-236 - Gate-Jimaku-and-SubSync-modal-actions-when-setup-is-missing.md b/backlog/tasks/task-236 - Gate-Jimaku-and-SubSync-modal-actions-when-setup-is-missing.md
new file mode 100644
index 0000000..4e0824f
--- /dev/null
+++ b/backlog/tasks/task-236 - Gate-Jimaku-and-SubSync-modal-actions-when-setup-is-missing.md
@@ -0,0 +1,29 @@
+---
+id: TASK-236
+title: Gate Jimaku and SubSync modal actions when setup is missing
+status: To Do
+assignee: []
+created_date: '2026-03-26 05:48'
+labels:
+ - ui
+ - setup-validation
+dependencies: []
+---
+
+## Description
+
+
+Add safeguards in the Jimaku and SubSync modals so users cannot proceed with unsupported flows when required setup is missing. SubSync should clearly block use when alass/ffsubsync detection fails. Jimaku should surface a visible warning when no API key is configured and prevent proceeding with actions that require it.
+
+
+## Acceptance Criteria
+
+- [ ] #1 SubSync modal detects availability of alass and ffsubsync before enabling related action option
+- [ ] #2 When only one of alass/ffsubsync is available, only the available path is selectable and clearly labeled; unavailable options are visually disabled
+- [ ] #3 When neither alass nor ffsubsync are available, the unsupported action option is disabled and/or hidden, and cannot navigate to next step
+- [ ] #4 If a user tries to proceed while detection says unavailable, submission is blocked with explanatory inline feedback
+- [ ] #5 Jimaku modal detects missing API key (or invalid/missing key) and shows an immediate warning on search results or related UI area
+- [ ] #6 When Jimaku API key is absent, actions that require key-based operations are disabled or blocked and cannot be submitted
+- [ ] #7 All new/updated UX states include clear copy explaining what to fix (e.g., install binary, add API key, restart if needed)
+- [ ] #8 Add or update tests for setup detection and blocked-state behavior in relevant modal/components
+
diff --git a/backlog/tasks/task-237 - Improve-config-validation-error-reporting-and-logging.md b/backlog/tasks/task-237 - Improve-config-validation-error-reporting-and-logging.md
new file mode 100644
index 0000000..3fc5342
--- /dev/null
+++ b/backlog/tasks/task-237 - Improve-config-validation-error-reporting-and-logging.md
@@ -0,0 +1,34 @@
+---
+id: TASK-237
+title: Improve config validation error reporting and logging
+status: To Do
+assignee: []
+created_date: '2026-03-26 05:51'
+labels:
+ - errors
+ - config
+ - validation
+ - ux
+dependencies: []
+references:
+ - /docs/README.md
+ - /docs/workflow/verification.md
+---
+
+## Description
+
+
+Replace raw error body system notifications during config validation with clearer, user-friendly summaries while retaining full technical detail in logs. The flow should surface what is wrong, where, and how to fix it without overloading the user with raw stack traces, and write structured details to console/file logs.
+
+
+## Acceptance Criteria
+
+- [ ] #1 When config validation fails, show a user-facing notification with cleaned, human-readable summary instead of dumping raw error text directly
+- [ ] #2 Notification content includes actionable context (what field/setting failed, expected format/type, and next steps where possible)
+- [ ] #3 Raw technical error details are preserved in console logs in a consistently formatted, presentable way
+- [ ] #4 Config validation failures also write to persistent log file output in the same presentable format
+- [ ] #5 When validation fails repeatedly or with multiple errors, aggregate and group errors for easier reading instead of showing one opaque blob
+- [ ] #6 Warning/error notification should map to the specific invalid config section so users can jump to/identify what to fix
+- [ ] #7 Add/update tests (unit/integration) that assert notification formatting and logging behavior for at least one malformed config case
+- [ ] #8 No sensitive data (API keys/secrets) is written to logs/notifications when sanitizing errors
+
diff --git a/changes/youtube-primary-subtitle-config.md b/changes/youtube-primary-subtitle-config.md
deleted file mode 100644
index dca64da..0000000
--- a/changes/youtube-primary-subtitle-config.md
+++ /dev/null
@@ -1,5 +0,0 @@
-type: changed
-area: launcher
-
-Moved YouTube primary subtitle language defaults to `youtube.primarySubLanguages`.
-Removed the old `youtubeSubgen.primarySubLanguages` config path from the generated config and docs.
diff --git a/package.json b/package.json
index ac7e95c..8820663 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "subminer",
- "version": "0.9.2",
+ "version": "0.9.3",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
diff --git a/src/core/services/youtube/retime.test.ts b/src/core/services/youtube/retime.test.ts
deleted file mode 100644
index fe6485c..0000000
--- a/src/core/services/youtube/retime.test.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import test from 'node:test';
-import assert from 'node:assert/strict';
-import fs from 'node:fs';
-import os from 'node:os';
-import path from 'node:path';
-import { retimeYoutubeSubtitle } from './retime';
-
-test('retimeYoutubeSubtitle uses the downloaded subtitle path as-is', async () => {
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-retime-'));
- try {
- const primaryPath = path.join(root, 'primary.vtt');
- const referencePath = path.join(root, 'reference.vtt');
- fs.writeFileSync(primaryPath, 'WEBVTT\n', 'utf8');
- fs.writeFileSync(referencePath, 'WEBVTT\n', 'utf8');
-
- const result = await retimeYoutubeSubtitle({
- primaryPath,
- secondaryPath: referencePath,
- });
-
- assert.equal(result.ok, true);
- assert.equal(result.strategy, 'none');
- assert.equal(result.path, primaryPath);
- assert.equal(result.message, 'Using downloaded subtitle as-is (no automatic retime enabled)');
- assert.equal(fs.readFileSync(result.path, 'utf8'), 'WEBVTT\n');
- } finally {
- fs.rmSync(root, { recursive: true, force: true });
- }
-});
diff --git a/src/core/services/youtube/retime.ts b/src/core/services/youtube/retime.ts
deleted file mode 100644
index a1dd29c..0000000
--- a/src/core/services/youtube/retime.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-export async function retimeYoutubeSubtitle(input: {
- primaryPath: string;
- secondaryPath: string | null;
-}): Promise<{ ok: boolean; path: string; strategy: 'none' | 'alass' | 'ffsubsync'; message: string }> {
- return {
- ok: true,
- path: input.primaryPath,
- strategy: 'none',
- message: `Using downloaded subtitle as-is${input.secondaryPath ? ' (no automatic retime enabled)' : ''}`,
- };
-}
diff --git a/src/main.ts b/src/main.ts
index 51b9ae6..0a5bc98 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -317,7 +317,6 @@ import {
acquireYoutubeSubtitleTracks,
} from './core/services/youtube/generate';
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
-import { retimeYoutubeSubtitle } from './core/services/youtube/retime';
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
import { startStatsServer } from './core/services/stats-server';
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js';
@@ -824,17 +823,6 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
probeYoutubeTracks: (url: string) => probeYoutubeTracks(url),
acquireYoutubeSubtitleTrack: (input) => acquireYoutubeSubtitleTrack(input),
acquireYoutubeSubtitleTracks: (input) => acquireYoutubeSubtitleTracks(input),
- retimeYoutubePrimaryTrack: async ({ primaryTrack, primaryPath, secondaryTrack, secondaryPath }) => {
- if (primaryTrack.kind !== 'auto') {
- return primaryPath;
- }
- const result = await retimeYoutubeSubtitle({
- primaryPath,
- secondaryPath: secondaryTrack ? secondaryPath : null,
- });
- logger.info(`Using YouTube subtitle path: ${result.path} (${result.strategy})`);
- return result.path;
- },
openPicker: async (payload) => {
return await openYoutubeTrackPicker(
{
diff --git a/src/main/runtime/youtube-flow.test.ts b/src/main/runtime/youtube-flow.test.ts
index b9e2b84..b0125d3 100644
--- a/src/main/runtime/youtube-flow.test.ts
+++ b/src/main/runtime/youtube-flow.test.ts
@@ -46,7 +46,6 @@ test('youtube flow can open a manual picker session and load the selected subtit
acquireYoutubeSubtitleTrack: async ({ track }) => ({
path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`,
}),
- retimeYoutubePrimaryTrack: async ({ primaryPath }) => `${primaryPath}.retimed`,
openPicker: async (payload) => {
openedPayloads.push(payload);
queueMicrotask(() => {
@@ -75,7 +74,7 @@ test('youtube flow can open a manual picker session and load the selected subtit
lang: 'ja-orig',
title: 'primary',
external: true,
- 'external-filename': '/tmp/auto-ja-orig.vtt.retimed',
+ 'external-filename': '/tmp/auto-ja-orig.vtt',
},
{
type: 'sub',
@@ -135,7 +134,7 @@ test('youtube flow can open a manual picker session and load the selected subtit
commands.some(
(command) =>
command[0] === 'sub-add' &&
- command[1] === '/tmp/auto-ja-orig.vtt.retimed' &&
+ command[1] === '/tmp/auto-ja-orig.vtt' &&
command[2] === 'select',
),
);
@@ -157,7 +156,7 @@ test('youtube flow can open a manual picker session and load the selected subtit
),
),
);
- assert.deepEqual(refreshedSidebarSources, ['/tmp/auto-ja-orig.vtt.retimed']);
+ assert.deepEqual(refreshedSidebarSources, ['/tmp/auto-ja-orig.vtt']);
assert.deepEqual(focusOverlayCalls, ['focus-overlay']);
});
@@ -179,7 +178,6 @@ test('youtube flow retries secondary after partial batch subtitle failure', asyn
acquireSingleCalls.push(track.id);
return { path: `/tmp/${track.id}.vtt` };
},
- retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
@@ -281,7 +279,6 @@ test('youtube flow reports probe failure through the configured reporter in manu
},
acquireYoutubeSubtitleTracks: async () => new Map(),
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/unused.vtt' }),
- retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async () => true,
pauseMpv: () => {},
resumeMpv: () => {},
@@ -322,7 +319,6 @@ test('youtube flow does not report failure when subtitle track binds before cue
}),
acquireYoutubeSubtitleTracks: async () => new Map(),
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }),
- retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
@@ -389,7 +385,6 @@ test('youtube flow does not fail when mpv reports sub-text as unavailable after
}),
acquireYoutubeSubtitleTracks: async () => new Map(),
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }),
- retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
@@ -464,7 +459,6 @@ test('youtube flow retries secondary subtitle selection until mpv reports the ex
acquireYoutubeSubtitleTrack: async ({ track }) => ({
path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`,
}),
- retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
@@ -568,7 +562,6 @@ test('youtube flow reuses the matching existing manual secondary track instead o
acquireYoutubeSubtitleTrack: async ({ track }) => ({
path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`,
}),
- retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
@@ -678,7 +671,6 @@ test('youtube flow leaves non-authoritative youtube subtitle tracks untouched af
acquireYoutubeSubtitleTrack: async ({ track }) => ({
path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`,
}),
- retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
@@ -772,7 +764,6 @@ test('youtube flow reuses existing manual youtube subtitle tracks when both requ
}
throw new Error('should not download secondary track when manual english already exists');
},
- retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
@@ -871,7 +862,6 @@ test('youtube flow waits for manual youtube tracks to appear before falling back
}
throw new Error('should not download secondary track when manual english appears in mpv');
},
- retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
@@ -982,7 +972,6 @@ test('youtube flow reuses manual youtube tracks even when mpv exposes external f
}
throw new Error('should not download secondary track when existing manual english track is reusable');
},
- retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async () => false,
pauseMpv: () => {},
resumeMpv: () => {},
@@ -1102,7 +1091,6 @@ test('youtube flow falls back to existing auto secondary track when auto seconda
}
return { path: '/tmp/auto-ja-orig.ja-orig.vtt' };
},
- retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async () => false,
pauseMpv: () => {},
resumeMpv: () => {},
diff --git a/src/main/runtime/youtube-flow.ts b/src/main/runtime/youtube-flow.ts
index aff758f..db72b76 100644
--- a/src/main/runtime/youtube-flow.ts
+++ b/src/main/runtime/youtube-flow.ts
@@ -26,13 +26,6 @@ type YoutubeFlowDeps = {
probeYoutubeTracks: (url: string) => Promise;
acquireYoutubeSubtitleTrack: typeof acquireYoutubeSubtitleTrack;
acquireYoutubeSubtitleTracks: typeof acquireYoutubeSubtitleTracks;
- retimeYoutubePrimaryTrack: (input: {
- targetUrl: string;
- primaryTrack: YoutubeTrackOption;
- primaryPath: string;
- secondaryTrack: YoutubeTrackOption | null;
- secondaryPath: string | null;
- }) => Promise;
openPicker: YoutubeFlowOpenPicker;
pauseMpv: () => void;
resumeMpv: () => void;
@@ -624,14 +617,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
track: input.primaryTrack,
})
).path;
- primarySidebarPath = await deps.retimeYoutubePrimaryTrack({
- targetUrl: input.url,
- primaryTrack: input.primaryTrack,
- primaryPath: primaryInjectedPath,
- secondaryTrack: input.secondaryTrack,
- secondaryPath: null,
- });
- primaryInjectedPath = primarySidebarPath;
+ primarySidebarPath = primaryInjectedPath;
} else {
const acquired = await acquireSelectedTracks({
targetUrl: input.url,
@@ -640,13 +626,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
secondaryTrack: existingSecondaryTrackId === null ? input.secondaryTrack : null,
secondaryFailureLabel: input.secondaryFailureLabel,
});
- primarySidebarPath = await deps.retimeYoutubePrimaryTrack({
- targetUrl: input.url,
- primaryTrack: input.primaryTrack,
- primaryPath: acquired.primaryPath,
- secondaryTrack: input.secondaryTrack,
- secondaryPath: acquired.secondaryPath,
- });
+ primarySidebarPath = acquired.primaryPath;
primaryInjectedPath = primarySidebarPath;
secondaryInjectedPath = acquired.secondaryPath;
}