chore: prepare v0.9.3 release

This commit is contained in:
2026-03-25 23:58:31 -07:00
parent 242402b253
commit 4c95b57885
11 changed files with 85 additions and 97 deletions

View File

@@ -1,5 +1,14 @@
# Changelog # 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) ## v0.9.2 (2026-03-25)
### Fixed ### Fixed

View File

@@ -84,7 +84,7 @@ Local stats dashboard — watch time, anime library, vocabulary growth, mining t
</tr> </tr>
<tr> <tr>
<td><b>alass / ffsubsync</b></td> <td><b>alass / ffsubsync</b></td>
<td>Automatic subtitle retiming</td> <td>Automatic subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td>
</tr> </tr>
<tr> <tr>
<td><b>WebSocket</b></td> <td><b>WebSocket</b></td>
@@ -105,7 +105,7 @@ Local stats dashboard — watch time, anime library, vocabulary growth, mining t
| | Required | Optional | | | Required | Optional |
| -------------- | --------------------------------------- | -------------------------------------- | | -------------- | --------------------------------------- | -------------------------------------- |
| **Player** | [`mpv`](https://mpv.io) with IPC socket | — | | **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` | | **Media** | — | `yt-dlp`, `chafa`, `ffmpegthumbnailer` |
| **Selection** | — | `fzf` / `rofi` | | **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 paru -S --needed mpv ffmpeg mecab-git mecab-ipadic
# Optional # Optional
paru -S --needed yt-dlp fzf rofi chafa ffmpegthumbnailer xdotool xorg-xwininfo 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 # X11 / XWAYLAND
paru -S --needed xdotool xorg-xwininfo paru -S --needed xdotool xorg-xwininfo
``` ```
@@ -138,6 +140,9 @@ paru -S --needed xdotool xorg-xwininfo
brew install mpv ffmpeg mecab mecab-ipadic brew install mpv ffmpeg mecab mecab-ipadic
# Optional # Optional
brew install yt-dlp fzf rofi chafa ffmpegthumbnailer 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**. Grant Accessibility permission to SubMiner in **System Settings > Privacy & Security > Accessibility**.

View File

@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #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
<!-- AC:END -->

View File

@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #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
<!-- AC:END -->

View File

@@ -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.

View File

@@ -1,6 +1,6 @@
{ {
"name": "subminer", "name": "subminer",
"version": "0.9.2", "version": "0.9.3",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"main": "dist/main-entry.js", "main": "dist/main-entry.js",

View File

@@ -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 });
}
});

View File

@@ -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)' : ''}`,
};
}

View File

@@ -317,7 +317,6 @@ import {
acquireYoutubeSubtitleTracks, acquireYoutubeSubtitleTracks,
} from './core/services/youtube/generate'; } from './core/services/youtube/generate';
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve'; import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
import { retimeYoutubeSubtitle } from './core/services/youtube/retime';
import { probeYoutubeTracks } from './core/services/youtube/track-probe'; import { probeYoutubeTracks } from './core/services/youtube/track-probe';
import { startStatsServer } from './core/services/stats-server'; import { startStatsServer } from './core/services/stats-server';
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js'; import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js';
@@ -824,17 +823,6 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
probeYoutubeTracks: (url: string) => probeYoutubeTracks(url), probeYoutubeTracks: (url: string) => probeYoutubeTracks(url),
acquireYoutubeSubtitleTrack: (input) => acquireYoutubeSubtitleTrack(input), acquireYoutubeSubtitleTrack: (input) => acquireYoutubeSubtitleTrack(input),
acquireYoutubeSubtitleTracks: (input) => acquireYoutubeSubtitleTracks(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) => { openPicker: async (payload) => {
return await openYoutubeTrackPicker( return await openYoutubeTrackPicker(
{ {

View File

@@ -46,7 +46,6 @@ test('youtube flow can open a manual picker session and load the selected subtit
acquireYoutubeSubtitleTrack: async ({ track }) => ({ acquireYoutubeSubtitleTrack: async ({ track }) => ({
path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`, path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`,
}), }),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => `${primaryPath}.retimed`,
openPicker: async (payload) => { openPicker: async (payload) => {
openedPayloads.push(payload); openedPayloads.push(payload);
queueMicrotask(() => { queueMicrotask(() => {
@@ -75,7 +74,7 @@ test('youtube flow can open a manual picker session and load the selected subtit
lang: 'ja-orig', lang: 'ja-orig',
title: 'primary', title: 'primary',
external: true, external: true,
'external-filename': '/tmp/auto-ja-orig.vtt.retimed', 'external-filename': '/tmp/auto-ja-orig.vtt',
}, },
{ {
type: 'sub', type: 'sub',
@@ -135,7 +134,7 @@ test('youtube flow can open a manual picker session and load the selected subtit
commands.some( commands.some(
(command) => (command) =>
command[0] === 'sub-add' && command[0] === 'sub-add' &&
command[1] === '/tmp/auto-ja-orig.vtt.retimed' && command[1] === '/tmp/auto-ja-orig.vtt' &&
command[2] === 'select', 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']); assert.deepEqual(focusOverlayCalls, ['focus-overlay']);
}); });
@@ -179,7 +178,6 @@ test('youtube flow retries secondary after partial batch subtitle failure', asyn
acquireSingleCalls.push(track.id); acquireSingleCalls.push(track.id);
return { path: `/tmp/${track.id}.vtt` }; return { path: `/tmp/${track.id}.vtt` };
}, },
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => { openPicker: async (payload) => {
queueMicrotask(() => { queueMicrotask(() => {
void runtime.resolveActivePicker({ void runtime.resolveActivePicker({
@@ -281,7 +279,6 @@ test('youtube flow reports probe failure through the configured reporter in manu
}, },
acquireYoutubeSubtitleTracks: async () => new Map(), acquireYoutubeSubtitleTracks: async () => new Map(),
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/unused.vtt' }), acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/unused.vtt' }),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async () => true, openPicker: async () => true,
pauseMpv: () => {}, pauseMpv: () => {},
resumeMpv: () => {}, resumeMpv: () => {},
@@ -322,7 +319,6 @@ test('youtube flow does not report failure when subtitle track binds before cue
}), }),
acquireYoutubeSubtitleTracks: async () => new Map(), acquireYoutubeSubtitleTracks: async () => new Map(),
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }), acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => { openPicker: async (payload) => {
queueMicrotask(() => { queueMicrotask(() => {
void runtime.resolveActivePicker({ 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(), acquireYoutubeSubtitleTracks: async () => new Map(),
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }), acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => { openPicker: async (payload) => {
queueMicrotask(() => { queueMicrotask(() => {
void runtime.resolveActivePicker({ void runtime.resolveActivePicker({
@@ -464,7 +459,6 @@ test('youtube flow retries secondary subtitle selection until mpv reports the ex
acquireYoutubeSubtitleTrack: async ({ track }) => ({ acquireYoutubeSubtitleTrack: async ({ track }) => ({
path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`, path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`,
}), }),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => { openPicker: async (payload) => {
queueMicrotask(() => { queueMicrotask(() => {
void runtime.resolveActivePicker({ void runtime.resolveActivePicker({
@@ -568,7 +562,6 @@ test('youtube flow reuses the matching existing manual secondary track instead o
acquireYoutubeSubtitleTrack: async ({ track }) => ({ acquireYoutubeSubtitleTrack: async ({ track }) => ({
path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`, path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`,
}), }),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => { openPicker: async (payload) => {
queueMicrotask(() => { queueMicrotask(() => {
void runtime.resolveActivePicker({ void runtime.resolveActivePicker({
@@ -678,7 +671,6 @@ test('youtube flow leaves non-authoritative youtube subtitle tracks untouched af
acquireYoutubeSubtitleTrack: async ({ track }) => ({ acquireYoutubeSubtitleTrack: async ({ track }) => ({
path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`, path: `/tmp/${track.id.replace(/[^a-z0-9_-]+/gi, '-')}.vtt`,
}), }),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => { openPicker: async (payload) => {
queueMicrotask(() => { queueMicrotask(() => {
void runtime.resolveActivePicker({ 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'); throw new Error('should not download secondary track when manual english already exists');
}, },
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => { openPicker: async (payload) => {
queueMicrotask(() => { queueMicrotask(() => {
void runtime.resolveActivePicker({ 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'); throw new Error('should not download secondary track when manual english appears in mpv');
}, },
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => { openPicker: async (payload) => {
queueMicrotask(() => { queueMicrotask(() => {
void runtime.resolveActivePicker({ 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'); throw new Error('should not download secondary track when existing manual english track is reusable');
}, },
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async () => false, openPicker: async () => false,
pauseMpv: () => {}, pauseMpv: () => {},
resumeMpv: () => {}, 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' }; return { path: '/tmp/auto-ja-orig.ja-orig.vtt' };
}, },
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async () => false, openPicker: async () => false,
pauseMpv: () => {}, pauseMpv: () => {},
resumeMpv: () => {}, resumeMpv: () => {},

View File

@@ -26,13 +26,6 @@ type YoutubeFlowDeps = {
probeYoutubeTracks: (url: string) => Promise<YoutubeTrackProbeResult>; probeYoutubeTracks: (url: string) => Promise<YoutubeTrackProbeResult>;
acquireYoutubeSubtitleTrack: typeof acquireYoutubeSubtitleTrack; acquireYoutubeSubtitleTrack: typeof acquireYoutubeSubtitleTrack;
acquireYoutubeSubtitleTracks: typeof acquireYoutubeSubtitleTracks; acquireYoutubeSubtitleTracks: typeof acquireYoutubeSubtitleTracks;
retimeYoutubePrimaryTrack: (input: {
targetUrl: string;
primaryTrack: YoutubeTrackOption;
primaryPath: string;
secondaryTrack: YoutubeTrackOption | null;
secondaryPath: string | null;
}) => Promise<string>;
openPicker: YoutubeFlowOpenPicker; openPicker: YoutubeFlowOpenPicker;
pauseMpv: () => void; pauseMpv: () => void;
resumeMpv: () => void; resumeMpv: () => void;
@@ -624,14 +617,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
track: input.primaryTrack, track: input.primaryTrack,
}) })
).path; ).path;
primarySidebarPath = await deps.retimeYoutubePrimaryTrack({ primarySidebarPath = primaryInjectedPath;
targetUrl: input.url,
primaryTrack: input.primaryTrack,
primaryPath: primaryInjectedPath,
secondaryTrack: input.secondaryTrack,
secondaryPath: null,
});
primaryInjectedPath = primarySidebarPath;
} else { } else {
const acquired = await acquireSelectedTracks({ const acquired = await acquireSelectedTracks({
targetUrl: input.url, targetUrl: input.url,
@@ -640,13 +626,7 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
secondaryTrack: existingSecondaryTrackId === null ? input.secondaryTrack : null, secondaryTrack: existingSecondaryTrackId === null ? input.secondaryTrack : null,
secondaryFailureLabel: input.secondaryFailureLabel, secondaryFailureLabel: input.secondaryFailureLabel,
}); });
primarySidebarPath = await deps.retimeYoutubePrimaryTrack({ primarySidebarPath = acquired.primaryPath;
targetUrl: input.url,
primaryTrack: input.primaryTrack,
primaryPath: acquired.primaryPath,
secondaryTrack: input.secondaryTrack,
secondaryPath: acquired.secondaryPath,
});
primaryInjectedPath = primarySidebarPath; primaryInjectedPath = primarySidebarPath;
secondaryInjectedPath = acquired.secondaryPath; secondaryInjectedPath = acquired.secondaryPath;
} }