Compare commits

..
Author SHA1 Message Date
sudacode c149746548 fix(stats): preserve pruned first_seen and stamp seen_ms at observation
- Record occurrence seen_ms from the observation time instead of flush time
- On session delete, only move first_seen when removed timestamps prove it was deleted; keep earlier dates from retention-pruned or legacy history
- Exact refresh fallback keeps subtraction-exact frequency and preserved first_seen
- Add tests for pruned history, legacy rows, and delayed flushes; update docs and change fragments
2026-10-02 22:19:28 -07:00
sudacode 009519a006 fix(stats): surface activity calendar refresh failures on Overview
- Show a retryable alert when the streak calendar refresh fails
- Use a calendar-specific message while overview totals still refresh
- Cover calendar failure in the Overview refresh/retry tests
2026-10-02 19:32:43 -07:00
sudacode 6e9ec3722e fix(stats): keep Overview usable when refresh fails 2026-10-02 11:14:14 -07:00
sudacode f94eb84179 fix(stats): refresh Overview data after deleting sessions
- Add a refresh key to OverviewTab that refetches overview, streak calendar, and known-words summary after single, day, or anime session deletes
- Reset loading/error state in useStreakCalendar on refetch
- Add OverviewTab tests covering each delete mode and a failed delete
- Document the automatic refresh and add changelog fragments
2026-10-02 10:51:01 -07:00
sudacode 0b02f4ec7b fix(stats): drop first_seen repair from lexical rollup rebuild
A stale first_seen is indistinguishable from real history whose occurrences
retention pruned, and frequency does not reliably separate them (it diverges
from surviving occurrences on databases with neither retention nor
pre-occurrence history), so the guarded repair either did nothing or risked
overwriting correct dates. The delete path already keeps first_seen exact for
new deletes; the rebuild now repairs only last_seen.
2026-10-01 23:59:51 -07:00
sudacode bf2ea99178 fix(stats): keep first_seen for words with retention-pruned history
- Only repair stale first_seen when frequency matches surviving occurrences
- Rows whose older lines were pruned (or predate occurrence tables) keep their original first_seen
- Extend rollup rebuild test with a retention-pruned word
2026-10-01 23:27:40 -07:00
sudacode ce680b746a fix(stats): move first_seen correctly when deleting earliest session
- Decide first_seen movement against the surviving min seen_ms, since
  stored bounds are stamped at queue time and may predate seen_ms
- Extend v3 rollup repair to fix stale first_seen as well as last_seen,
  allowing slack for queue-to-flush lag
2026-10-01 23:12:09 -07:00
sudacode 4677f5624b fix(stats): count mined cards once and keep lexical stats exact after deletes
- Card creation now owns mined-card counting; remove the outer single and
  multi-line mining increments that double counted each card.
- Preserve fractional first_seen/last_seen precision when session deletes
  compare and refresh lexical aggregates.
- Derive vocabulary_visible from the stored word row on every write so the
  new-word charts agree with the filtered vocabulary summary.
- Bump the lexical rollup version so existing databases re-derive visibility
  and repair stale last_seen values once in the background worker.
2026-10-01 22:56:38 -07:00
16 changed files with 99 additions and 420 deletions

No files matched your search

+1 -1
View File
@@ -467,7 +467,7 @@
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
"http-cache-semantics": ["http-cache-semantics@4.3.0", "", {}, "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA=="],
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
+1 -1
View File
@@ -42,7 +42,7 @@ How fragments turn into a release:
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
- The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries.
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use one item per feature or area, inline by default: `- **Name**: one or two short sentences.` Items with several separate changes may use a few short nested bullets instead of a run-on sentence. Items say what changed and how to reach it; implementation details, edge cases, and step-by-step usage belong in the docs, not the release body.
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use short top-level items with one nested bullet per distinct change, instead of packing a release's worth of detail into a single paragraph bullet. An item with only one thing to say stays inline on the top-level bullet. Release notes and prerelease notes additionally cover user benefit and any useful action note in their nested bullets.
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
-4
View File
@@ -1,4 +0,0 @@
type: internal
area: release
- Release notes, prerelease notes, and `CHANGELOG.md` are now generated as short items per feature or area, nesting only when an item has several separate changes.
@@ -1,4 +0,0 @@
type: fixed
area: youtube
- The subtitle generation modal now works during Windows YouTube playback, where mpv plays a resolved stream URL instead of the YouTube page; it no longer asks you to open a local file or YouTube video, and generated subtitles load into the playing video.
@@ -1,4 +0,0 @@
type: fixed
area: youtube
- Fixed Windows YouTube playback dropping the rest of mpv's queue when it advanced to a video queued from the YouTube browser window. The direct stream now replaces only that playlist entry.
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.20.1-beta.2",
"version": "0.20.1-beta.1",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
+49 -55
View File
@@ -1,76 +1,73 @@
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
<!-- prerelease-version: 0.20.1-beta.2; since: v0.20.1-beta.1 -->
## Changes since v0.20.1-beta.1
- Upgraded the desktop runtime to Electron 43.7.7.
- SubMiner now refuses to start on an unsupported Electron runtime, or after an Electron major or minor downgrade, so your Yomitan profile data stays safe; patch downgrades are still allowed.
- Automatic character-dictionary updates now stop if a Yomitan profile that had dictionaries suddenly reports none.
- Development launches now use a separate `SubMiner-dev` profile unless you explicitly ask for the production profile.
- Sentence mining (single and multi-line) no longer counts each card twice in session and lifetime stats.
- Deleting sessions now updates word and kanji "first seen" and "last seen" dates correctly, and retention pruning keeps historical discovery dates and counts.
- Words hidden by the vocabulary filter are no longer counted in the new-words charts, so the charts now match the vocabulary summary.
- A one-time background repair on next launch fixes stale "last seen" dates and existing chart mismatches.
- Overview totals, charts, the activity calendar, and the vocabulary summary now refresh after you delete sessions, and a failed refresh keeps the dashboard visible with a retry option.
- Subtitle generation now works during Windows YouTube playback and loads the generated subtitles into the playing video.
- On Windows, moving on to a video queued from the YouTube browser window no longer drops the rest of mpv's queue.
- The stats docs now cover the automatic Overview refresh after deletions and how discovery dates are kept through retention pruning.
<!-- prerelease-version: 0.20.1-beta.1 -->
## Highlights
### Added
- **YouTube Browser Window**:
- Open it with `subminer youtube` (or `subminer yt`), or pick **Browse YouTube** from the tray. It stays logged in across restarts.
- Clicking a video plays it in mpv. Middle-click, Shift-click, Ctrl-click, or the right-click menu adds it to mpv's playlist.
- Queued videos get the same Japanese subtitle setup when they start.
- Closing the window quits SubMiner if you started it with `subminer youtube`. If a video is still playing, SubMiner waits until mpv closes.
- Open a YouTube browser with `subminer youtube` (or `subminer yt`), or pick **Browse YouTube** from the tray. It stays logged in to YouTube across restarts.
- Clicking a video plays it in mpv instead of on the page.
- Middle-click, Shift-click, Ctrl-click, or the right-click menu adds a video to mpv's playlist.
- When a queued video starts, it gets the same Japanese subtitle setup.
- If you started SubMiner with `subminer youtube`, closing the YouTube window quits SubMiner. If a video is still playing, SubMiner quits after mpv closes.
- **Whisper Subtitles for YouTube**:
- Set `youtube.subtitleSource` to `whisper` to make subtitles with Whisper instead of downloading YouTube's captions.
- The video stays paused while Whisper works, and progress shows in the subtitle generation modal. You can close the modal to keep watching, or cancel.
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos, including on Windows.
- Set `youtube.subtitleSource` to `whisper` to make subtitles with Whisper instead of downloading YouTube's captions. The default is still `youtube`.
- The video stays paused while Whisper works, and progress shows in the subtitle generation modal. Close the modal to keep watching, or cancel to carry on without subtitles.
- The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos with either `youtube.subtitleSource` setting.
- Whisper downloads only a small audio stream and deletes it when generation ends, or when you switch videos, close mpv, or quit.
- **Mouse and Scroll Wheel Bindings**:
- You can now use scroll wheel keys (`WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`) in `keybindings` and record them in the settings key editor.
- mpv's mouse button and wheel bindings now work while the cursor is over the overlay. This matters most on Hyprland, where these bindings did nothing before.
- When a binding conflicts, SubMiner's own bindings and right-click pause take priority over mpv's.
- **Session Help Commands**: During playback, press `Enter` or double-click a row in session help to run that command. Hovering over a row makes it the target for `Enter`.
- `keybindings` now accepts scroll wheel keys (`WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`), with or without modifiers. You can also record them in the settings key editor.
- Mouse button and scroll wheel bindings from mpv now work while the cursor is over the overlay. This covers your `input.conf` and mpv's defaults, such as double-click for fullscreen, the wheel for volume, and back/forward for the playlist.
- Hyprland users benefit most: the overlay always takes input there, so these bindings did nothing before.
- SubMiner's own bindings and right-click pause still win over mpv's.
- **Session Help Commands**:
- During playback, press `Enter` or double-click a row in session help to run that command.
- Hovering over a row makes it the target for `Enter`.
- Session help opened from the tray with no video loaded is still read-only.
### Changed
- **Release Assets**: Releases no longer include package-size JSON reports.
- **Release Assets**: Releases no longer include package-size JSON reports. Package contents are still validated before release.
### Fixed
- **Jellyfin Subtitles**:
- The Japanese track is selected, and annotations start, as soon as it downloads. SubMiner no longer waits for the other tracks.
- Japanese and English tracks are now picked automatically on episodes with image-based subtitles (PGS, DVD, DVB).
- If one track fails to download, the other tracks are still selected.
- **Jellyfin Audio Track**: On direct-play streams, timing review previews and mined card audio now use the audio track you're playing.
- The Japanese subtitle track is selected, and annotations start, as soon as that track downloads. SubMiner no longer waits for every other track.
- Subtitle tracks now download in parallel, so a slow embedded track no longer holds up the main subtitles.
- Episodes with image-based subtitles (PGS, DVD, DVB) now select the Japanese and English tracks automatically.
- If one subtitle track fails to download, the other tracks are still selected.
- **Jellyfin Audio Track**: On direct-play Jellyfin streams, timing review previews and mined card audio now use the audio track you're playing, not the one Jellyfin marks as default.
- **Media Timing Review**:
- Trim handles are now thin lines centered on the clip edges.
- Original subtitle boundaries are now plain orange bars with no labels.
- The preview cursor no longer stutters on macOS with Bluetooth headphones such as AirPods.
- **Hyprland Fullscreen**: The overlay stays visible when mpv enters or leaves fullscreen. With `mpv.backend: x11`, subtitles and the sidebar stay clickable after going fullscreen.
- **mpv Key Bindings in the Overlay**: mpv key bindings, such as `9`/`0` for volume, now work while the overlay has focus, even when the overlay loaded before mpv connected.
- **YouTube Japanese Subtitles**: Some videos also list a machine-translated Japanese track. On those, Japanese subtitles no longer fail with "HTTP 429". If YouTube refuses a direct download, SubMiner retries with yt-dlp.
- **Stats Accuracy**:
- Sentence mining no longer counts each card twice in session and lifetime stats.
- Overview totals, charts, the activity calendar, and the vocabulary summary now update after you delete sessions.
- Word and kanji dates stay correct after deleting sessions. New-word charts now match the vocabulary summary.
- **Dictionary Safety**: Automatic character dictionary changes now stop if a Yomitan profile that had dictionaries suddenly reports none. SubMiner also refuses to load Yomitan data after a downgrade to an older Electron version.
- **AppImage in Sandboxes**: The AppImage now opens its window in sandboxes that mount the image themselves, such as `firejail --appimage`.
- **Ctrl+C Crash**: Stopping the terminal command that started SubMiner with Ctrl+C no longer crashes it with a "write EPIPE" error.
- **Security Updates**: Electron is updated to 43.7.7 and undici to 7.29.1, which fixes known security advisories.
- Trim handles are now thin lines centered on the clip edges. The start and end of the clip are easier to see, and less of the waveform is hidden.
- The original subtitle boundaries are now plain orange bars without text labels, so the waveform stays visible.
- On macOS with Bluetooth headphones such as AirPods, the preview cursor now moves smoothly. Before, it stuttered and jumped to the end partway through. This affected Opus audio, including Jellyfin streams.
- **Hyprland Fullscreen**:
- The subtitle overlay now stays visible when mpv enters or leaves fullscreen, so focus changes no longer cancel fullscreen. Sway still refreshes the overlay as before.
- With `mpv.backend: x11`, subtitles and the sidebar no longer stop responding to clicks after mpv goes fullscreen.
- **mpv Key Bindings in the Overlay**:
- mpv key bindings, such as `9`/`0` for volume, now work while the overlay has focus.
- They used to do nothing if the overlay loaded before SubMiner connected to mpv, which was common with `mpv.backend: x11`. The overlay now reloads mpv's bindings once mpv connects.
- **YouTube Japanese Subtitles**:
- Some videos also list a machine-translated Japanese track. On those, Japanese subtitles no longer fail with "HTTP 429", because SubMiner now picks the real Japanese track.
- If YouTube refuses a direct subtitle download, SubMiner retries with yt-dlp.
- **AppImage in Sandboxes**: The AppImage now opens its window when started in a sandbox that mounts the image itself, such as `firejail --appimage`. Before, it exited without opening a window.
- **Ctrl+C Crash**: Stopping the terminal command that started SubMiner with Ctrl+C no longer crashes SubMiner with a "write EPIPE" error.
- **Security Updates**: Electron is updated to 42.10.0 and undici to 7.29.1 to fix known security advisories.
### Docs
- **Docs Site Rewrite**: Pages are shorter and easier to scan. The configuration reference now has a short explanation and a table of keys and defaults for each config block. Pages that no longer matched current behavior are fixed.
- **Arch Linux Install**: The MeCab install command now uses the AUR package `mecab-git`. The README now matches the installation guide.
- **Stats**: The docs now cover the Overview refreshing after you delete sessions, and how first-seen dates are kept when old sessions are cleaned up.
- **Docs Site Rewrite**:
- Pages are shorter and easier to scan. Each one starts with setup and usage, and reference details are in compact tables.
- The configuration reference now gives each config block a short explanation and a table of keys and defaults.
- Pages that no longer matched how SubMiner works have been corrected.
- **Arch Linux Install**:
- The MeCab install command is fixed. MeCab is only in the AUR, as `mecab-git`.
- The README's requirements and quick start now match the installation guide.
## What's Changed
- fix(storage): protect Yomitan profiles across Electron runtime changes by @ksyasuda in #211
- fix(jellyfin): select subtitles faster and survive failed track downloads by @ksyasuda in #270
- fix(jellyfin): use the current audio track for media generation by @ksyasuda in #271
- feat(youtube): add YouTube browser window and Whisper subtitle source by @ksyasuda in #273
@@ -80,17 +77,14 @@
- fix(linux): skip AppImage mount keepalive on sandbox squashfs mounts by @ksyasuda in #277
- fix(overlay): reload imported mpv keys after mpv connects by @ksyasuda in #278
- feat(overlay): forward mpv mouse button and wheel bindings by @ksyasuda in #279
- fix(stats): count mined cards once and keep lexical stats exact after deletes by @ksyasuda in #280
- fix(youtube): keep mpv queue when advancing to a queued video on Windows by @ksyasuda in #281
- fix(subtitles): allow Whisper generation during Windows YouTube stream playback by @ksyasuda in #282
## Installation
See the README and docs/installation guide for full setup steps.
See the [installation guide](https://docs.subminer.moe/installation) for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- Linux: `SubMiner-*.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
- Optional extras: `subminer-assets.tar.gz`, the `subminer` launcher, and the Windows `subminer.cmd` launcher
+17 -12
View File
@@ -44,12 +44,20 @@ function fragmentTypesInPrompt(input: string): string[] {
.map((line) => line.slice('type: '.length).trim());
}
function assertPromptRequestsConciseItems(input: string): void {
assert.match(input, /Default to inline/);
assert.match(input, /Use nested bullets only when an item has several distinct changes/);
assert.match(input, /Never write paragraph-length bullets/);
assert.match(input, /Merge aggressively: one item per feature or area/);
assert.match(input, /Release notes summarize; the docs hold the details/);
function assertPromptRequestsNestedBullets(input: string): void {
assert.match(input, /In both modes, split every item into one nested bullet per distinct change/);
assert.match(input, /Never stack several distinct changes into one long paragraph-shaped bullet/);
assert.match(input, /Keep nested bullets short, concrete, and readable by non-technical users/);
assert.match(input, /Avoid paragraph-style release-note bullets/);
}
function assertReleaseNotesPromptRequestsNestedBullets(input: string): void {
assertPromptRequestsNestedBullets(input);
assert.match(
input,
/In MODE: release-notes, nested bullets should also cover user benefit and any user action/,
);
assert.match(input, /Do not require the exact nested labels/);
}
function defaultPolishedBody(input: string): string {
@@ -446,13 +454,14 @@ test('writeChangelogArtifacts prompts Claude to summarize the final stable outco
prompt,
/Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
);
assertPromptRequestsConciseItems(prompt);
assertPromptRequestsNestedBullets(prompt);
}
const releaseNotesPrompt = stub.calls.find(
(call) => modeFromPrompt(call.input) === 'release-notes',
);
assert.ok(releaseNotesPrompt, 'expected a release-notes Claude invocation');
assertReleaseNotesPromptRequestsNestedBullets(releaseNotesPrompt.input);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
@@ -808,11 +817,7 @@ test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease b
prompt,
/Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
);
assert.match(
prompt,
/shorten long baseline bullets and collapse nested lists that do not need to be nested/,
);
assertPromptRequestsConciseItems(prompt);
assertReleaseNotesPromptRequestsNestedBullets(prompt);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
+16 -24
View File
@@ -476,29 +476,21 @@ You will receive a list of FRAGMENT entries below. Each fragment has metadata (t
</details>
Do not include the Internal section at all in MODE: release-notes; internal fragments will not be present in the input for that mode.
4. Start every item with a short bold feature/area name in title case and a colon. Pick the name from the fragment's bullet content, not the raw 'area:' slug.
- Default to inline: put one or two short sentences right after the colon. If the important details fit in two short sentences, stay inline.
- Use nested bullets only when an item has several distinct changes a user would care about separately and inline text would turn into a long run-on sentence. Then leave the top-level bullet as just the name and indent two to four nested bullets by two spaces.
- Each nested bullet is a single short sentence, roughly 20 words or fewer. Never write paragraph-length bullets. Cut minor details rather than adding sentences.
5. Keep items brief. Release notes summarize; the docs hold the details.
- Say what changed for the user and how to reach it (shortcut, command, setting, or menu). Stop there.
- Leave out how it works internally, edge cases, precedence rules, defaults, fallbacks, tool discovery, migration mechanics, and step-by-step usage.
- Name a config key only when users must set it to use the change or to keep their current behavior.
- Write in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers.
- Merge aggressively: one item per feature or area. If five fragments touch Jellyfin subtitles, audio, and auth, write one Jellyfin item, not five. Group small unrelated fixes for the same area into one item.
4. Each top-level change item should:
- Lead with a short feature/area name in title case. Pick the name from the fragment's bullet content, not the raw 'area:' slug.
- Be written in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers.
- Be merged with related bullets when possible. If five fragments all touch Windows overlay z-order/focus/restore, write one or two bullets that summarize the overall improvement instead of five.
- Drop bullets that only describe PR housekeeping, CodeRabbit follow-ups, or test-only changes that don't affect users.
- Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules, including any action users must take. Do not soften or omit them.
6. Match the length and density of this example:
### Added
- **Subtitle Selection Modal**: Optional modal for picking primary and secondary subtitle tracks. Enable it in Settings under Behavior, then press g followed by s.
### Changed
- **Faster Sync**: Sync between macOS and Linux machines now uses compressed, incremental rsync transfers. Windows and machines without rsync fall back to compressed scp.
### Fixed
- **Subtitle Sidebar**: Space keeps its playback action after clicking a cue, and the sidebar no longer jumps away from the current position during subtitle gaps.
- **Jellyfin 12**:
- Playback, subtitles, artwork, and "Play on SubMiner" now work on Jellyfin 12.
- Casting no longer drops after about a minute.
- Mined cards get the episode title again.
- Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules. Do not soften or omit them.
5. In both modes, split every item into one nested bullet per distinct change. Write a short bold name on the top-level bullet, then indent the details two spaces:
- **Playlist Browser**:
- Saved shows now open without rescanning the library.
- The picker remembers the last folder you browsed between launches.
Each nested bullet covers exactly one change, behavior, or user-visible outcome. Never stack several distinct changes into one long paragraph-shaped bullet.
Aim for two to five nested bullets per item. When an item genuinely has only one thing to say, put it inline on the top-level bullet ("- **Playlist Browser**: Saved shows now open without rescanning the library.") instead of emitting a single nested bullet.
Keep nested bullets short, concrete, and readable by non-technical users. Avoid paragraph-style release-note bullets.
Bullets inside the Internal section may stay single-level.
6. In MODE: release-notes, nested bullets should also cover user benefit and any user action or compatibility note when useful. Do not require the exact nested labels; natural phrasing is fine. Omit the action bullet when no action is needed.
7. Do not invent features. Every bullet must be grounded in the input fragments.
8. Do not include the version heading (## v...) — that wrapper is added by the caller.
@@ -799,7 +791,7 @@ function polishFragmentsWithClaude(
? [
'## Existing Prerelease Notes',
'',
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Existing prerelease notes are a baseline, not an immutable changelog. Reuse reviewed highlight bullets when they still describe the current outcome, but replace stale beta or RC wording when new fragments supersede it. Merge in only new or changed fragment material, and deduplicate instead of restating existing bullets. The output rules above win over the baseline format: shorten long baseline bullets and collapse nested lists that do not need to be nested. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, any "Changes since" section, or the Installation or Assets sections.',
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Existing prerelease notes are a baseline, not an immutable changelog. Reuse reviewed highlight bullets when they still describe the current outcome, but replace stale beta or RC wording when new fragments supersede it. Merge in only new or changed fragment material, and deduplicate instead of restating existing bullets. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, any "Changes since" section, or the Installation or Assets sections.',
'',
].join('\n')
: '';
@@ -822,7 +814,7 @@ Rules:
3. ADDED fragments describe changes that are new in this build; summarize them.
4. MODIFIED fragments include BEFORE and AFTER content. Describe only the behavioral difference between them. If the edit is editorial (rewording, deduplication, reformatting, reconciling stale phrasing) with no user-visible behavior change, omit it entirely.
5. DELETED fragments mean the described change was removed or reverted before this build; say so explicitly.
6. Keep each bullet to one short, concrete sentence. Use nested bullets sparingly.
6. Keep bullets short and concrete. Use nested bullets sparingly.
7. Do not invent changes. Every bullet must be grounded in the diffs.
8. If no bullet survives rules 2-5, output exactly this single line:
- No user-facing changes since PREVIOUS_TAG.
-2
View File
@@ -6810,8 +6810,6 @@ const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
'youtube-whisper.ja.srt',
),
}),
resolveYoutubeSourceUrl: (mediaPath) =>
youtubePlaybackRuntime.getYoutubeSourceUrlForStream(mediaPath),
getMpvClient: () => appState.mpvClient,
onProgress: (progress) => {
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
@@ -368,10 +368,7 @@ test('cancelling a YouTube Whisper job resolves to null, and failures throw', as
);
});
function youtubeFixture(
generateYoutube: SubtitleGenerationRuntimeDeps['generateYoutube'],
overrides: Partial<SubtitleGenerationRuntimeDeps> = {},
) {
function youtubeFixture(generateYoutube: SubtitleGenerationRuntimeDeps['generateYoutube']) {
const player = {
path: 'https://www.youtube.com/watch?v=abcdefghijk&pp=search',
paused: false,
@@ -388,7 +385,7 @@ function youtubeFixture(
return { error: 'success' };
},
};
const { runtime } = fixture({ getMpvClient: () => client, generateYoutube, ...overrides });
const { runtime } = fixture({ getMpvClient: () => client, generateYoutube });
return { runtime, player, commands };
}
@@ -446,63 +443,3 @@ test('switching videos cancels a YouTube job from the modal without loading its
false,
);
});
// Windows plays YouTube through a resolved googlevideo stream, so mpv's path is not a YouTube URL.
const STREAM_URL = 'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777';
const OTHER_STREAM_URL = 'https://rr2---sn.example.googlevideo.com/videoplayback?expire=1888888888';
const resolveStreamSource = (mediaPath: string): string | null =>
mediaPath === STREAM_URL
? 'https://www.youtube.com/watch?v=abcdefghijk'
: mediaPath === OTHER_STREAM_URL
? 'https://www.youtube.com/watch?v=zyxwvutsrqp'
: null;
test('the modal generates for a YouTube video playing through a resolved stream URL', async () => {
let jobSignal: AbortSignal | null = null;
const { runtime, player, commands } = youtubeFixture(
async (input) => {
jobSignal = input.signal;
assert.equal(input.url, 'https://www.youtube.com/watch?v=abcdefghijk');
// Reloading the same video's stream must not cancel the job.
runtime.handleMediaPathChange(STREAM_URL);
return '/tmp/subs/youtube-whisper.ja.srt';
},
{ resolveYoutubeSourceUrl: resolveStreamSource },
);
player.path = STREAM_URL;
assert.equal(
(await runtime.getStatus()).mediaPath,
'https://www.youtube.com/watch?v=abcdefghijk',
);
assert.deepEqual(await runtime.start(), {
ok: true,
outputPath: '/tmp/subs/youtube-whisper.ja.srt',
message: 'Japanese subtitles generated and loaded.',
});
assert.equal((jobSignal as AbortSignal | null)?.aborted, false);
assert.deepEqual(
commands.find((command) => command[0] === 'sub-add'),
['sub-add', '/tmp/subs/youtube-whisper.ja.srt', 'select', 'Generated Japanese', 'ja'],
);
});
test('switching to another resolved YouTube stream cancels the modal job', async () => {
let jobSignal: AbortSignal | null = null;
const { runtime, player } = youtubeFixture(
(input) =>
new Promise((_, reject) => {
jobSignal = input.signal;
input.signal.addEventListener('abort', () => reject(new Error('Aborted')), { once: true });
}),
{ resolveYoutubeSourceUrl: resolveStreamSource },
);
player.path = STREAM_URL;
const job = runtime.start();
while (!jobSignal) await new Promise((resolve) => setImmediate(resolve));
player.path = OTHER_STREAM_URL;
runtime.handleMediaPathChange(player.path);
assert.deepEqual(await job, { ok: false, message: 'Cancelled.' });
});
@@ -51,11 +51,6 @@ export interface SubtitleGenerationRuntimeDeps {
signal: AbortSignal;
onProgress: (progress: SubtitleGenerationProgress) => void;
}) => Promise<string>;
/**
* Maps a playing stream URL back to its YouTube page URL. Windows YouTube playback opens a
* resolved direct stream in mpv, so mpv's path alone does not identify the video.
*/
resolveYoutubeSourceUrl?: (mediaPath: string) => string | null;
}
async function currentLocalMedia(client: GenerationMpvClient | null): Promise<string | null> {
@@ -67,6 +62,12 @@ async function currentLocalMedia(client: GenerationMpvClient | null): Promise<st
return typeof directory === 'string' ? path.resolve(directory, media) : null;
}
async function currentYoutubeMedia(client: GenerationMpvClient | null): Promise<string | null> {
if (!client?.connected) return null;
const media = await client.requestProperty('path');
return typeof media === 'string' ? toYoutubeWatchUrl(media) : null;
}
async function loadGeneratedSubtitles(client: GenerationMpvClient, outputPath: string) {
const loaded = await client.request([
'sub-add',
@@ -135,15 +136,6 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
: '',
};
}
// The YouTube page behind a resolved stream URL, or the media path itself.
function youtubeSource(mediaPath: string | null | undefined): string | null | undefined {
return (mediaPath && deps.resolveYoutubeSourceUrl?.(mediaPath)) || mediaPath;
}
async function currentYoutubeMedia(client: GenerationMpvClient | null): Promise<string | null> {
if (!client?.connected) return null;
const media = await client.requestProperty('path');
return typeof media === 'string' ? toYoutubeWatchUrl(youtubeSource(media)) : null;
}
const report = (update: SubtitleGenerationProgress) => {
progress = update;
deps.onProgress(update);
@@ -254,7 +246,7 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
/** Stops a YouTube job once its video is no longer playing, which also deletes its audio. */
handleMediaPathChange(mediaPath: string | null | undefined): void {
if (!youtubeJobUrl) return;
if (extractYoutubeVideoId(youtubeSource(mediaPath)) !== extractYoutubeVideoId(youtubeJobUrl))
if (extractYoutubeVideoId(mediaPath) !== extractYoutubeVideoId(youtubeJobUrl))
controller?.abort();
},
async setVadEnabled(enabled: boolean): Promise<SubtitleGenerationStatus> {
@@ -298,116 +298,3 @@ test('prepare youtube playback accepts a fresh-start path change when the direct
'replace',
]);
});
test('prepare youtube playback swaps a queued entry for its direct stream without clearing the playlist', async () => {
const commands: Array<Array<string>> = [];
const directUrl = 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc';
const observedPaths = ['https://www.youtube.com/watch?v=queued2', directUrl];
let requestCount = 0;
const prepare = createPrepareYoutubePlaybackInMpvHandler({
requestPath: async () => {
const value = observedPaths[Math.min(requestCount, observedPaths.length - 1)] ?? null;
requestCount += 1;
return value;
},
requestProperty: async (name) => {
if (name === 'playlist') {
return [
{ filename: 'https://www.youtube.com/watch?v=queued1' },
{ filename: 'https://www.youtube.com/watch?v=queued2', current: true, playing: true },
{ filename: 'https://www.youtube.com/watch?v=queued3' },
];
}
if (name === 'track-list') return [{ type: 'video', id: 1 }];
return null;
},
sendMpvCommand: (command) => commands.push(command),
wait: createWaitStub(),
});
const ok = await prepare({
url: directUrl,
sourceUrl: 'https://www.youtube.com/watch?v=queued2',
timeoutMs: 1500,
pollIntervalMs: 1,
});
assert.equal(ok, true);
assert.deepEqual(commands.slice(4), [
['loadfile', directUrl, 'append'],
['playlist-move', '3', '2'],
['playlist-remove', '1'],
]);
});
test('prepare youtube playback replaces the playlist when the current entry is a different video', async () => {
const commands: Array<Array<string>> = [];
const directUrl = 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc';
let nowTick = 0;
const prepare = createPrepareYoutubePlaybackInMpvHandler({
requestPath: async () => 'https://www.youtube.com/watch?v=other',
requestProperty: async (name) => {
if (name === 'playlist') {
return [
{ filename: 'https://www.youtube.com/watch?v=other', current: true, playing: true },
];
}
return [];
},
sendMpvCommand: (command) => commands.push(command),
wait: createWaitStub(),
now: () => {
nowTick += 100;
return nowTick;
},
});
await prepare({
url: directUrl,
sourceUrl: 'https://www.youtube.com/watch?v=picked',
timeoutMs: 200,
pollIntervalMs: 1,
});
assert.deepEqual(commands.slice(4), [['loadfile', directUrl, 'replace']]);
});
test('prepare youtube playback does not touch the playlist when the playing entry is another video', async () => {
const commands: Array<Array<string>> = [];
const directUrl = 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc';
let nowTick = 0;
const prepare = createPrepareYoutubePlaybackInMpvHandler({
// mpv's path still reports the queued video, but it is mid-advance: the selected entry moved
// on and the playing entry is another queued video.
requestPath: async () => 'https://www.youtube.com/watch?v=queued2',
requestProperty: async (name) => {
if (name === 'playlist') {
return [
{ filename: 'https://www.youtube.com/watch?v=queued1', playing: true },
{ filename: 'https://www.youtube.com/watch?v=queued2', current: true },
{ filename: 'https://www.youtube.com/watch?v=queued3' },
];
}
return [];
},
sendMpvCommand: (command) => commands.push(command),
wait: createWaitStub(),
now: () => {
nowTick += 100;
return nowTick;
},
});
await prepare({
url: directUrl,
sourceUrl: 'https://www.youtube.com/watch?v=queued2',
timeoutMs: 200,
pollIntervalMs: 1,
});
assert.equal(
commands.some((command) => command[0] === 'playlist-remove'),
false,
);
assert.deepEqual(commands.slice(4), [['loadfile', directUrl, 'replace']]);
});
+1 -59
View File
@@ -2,11 +2,6 @@ import { isYoutubeMediaPath } from './youtube-playback';
type YoutubePlaybackLaunchInput = {
url: string;
/**
* YouTube page URL `url` was resolved from (Windows loads a direct stream URL). Lets a playlist
* entry for the same video be swapped in place instead of replacing the whole playlist.
*/
sourceUrl?: string;
timeoutMs?: number;
pollIntervalMs?: number;
};
@@ -86,52 +81,6 @@ function hasPlayableMediaTracks(trackListRaw: unknown): boolean {
});
}
/** Index of the entry mpv is actually playing, or null while it is between entries. */
function findPlayingPlaylistEntry(playlist: unknown[]): { index: number; filename: string } | null {
const index = playlist.findIndex(
(entry) =>
typeof entry === 'object' &&
entry !== null &&
(entry as Record<string, unknown>).playing === true,
);
if (index < 0) return null;
const filename = (playlist[index] as Record<string, unknown>).filename;
return typeof filename === 'string' ? { index, filename } : null;
}
/**
* Swaps mpv's playing playlist entry for `targetUrl` while keeping the rest of the playlist, e.g.
* a queued YouTube watch URL replaced by its resolved direct stream on Windows. Uses only
* long-standing playlist commands (no `loadfile insert-*`, which needs mpv 0.38+).
* Returns false unless the playing entry is confirmed to be `sourceUrl`'s video, so the caller
* falls back to `replace` instead of removing some other queued entry.
*/
async function swapCurrentPlaylistEntry(
deps: YoutubePlaybackLaunchDeps,
targetUrl: string,
sourceUrl: string,
): Promise<boolean> {
if (!deps.requestProperty) return false;
let playlist: unknown;
try {
// One snapshot so the position, count, and entry identity agree with each other.
playlist = await deps.requestProperty('playlist');
} catch {
return false;
}
if (!Array.isArray(playlist)) return false;
const playing = findPlayingPlaylistEntry(playlist);
if (!playing || !targetsSameYoutubeVideo(playing.filename, sourceUrl)) return false;
// The appended entry lands at index `count`; move it right after the playing entry, then remove
// the playing entry so mpv advances onto it.
const count = playlist.length;
deps.sendMpvCommand(['loadfile', targetUrl, 'append']);
deps.sendMpvCommand(['playlist-move', String(count), String(playing.index + 1)]);
deps.sendMpvCommand(['playlist-remove', String(playing.index)]);
return true;
}
function sendPlaybackPrepCommands(sendMpvCommand: (command: Array<string>) => void): void {
sendMpvCommand(['set_property', 'pause', 'yes']);
sendMpvCommand(['set_property', 'sub-auto', 'no']);
@@ -171,14 +120,7 @@ export function createPrepareYoutubePlaybackInMpvHandler(deps: YoutubePlaybackLa
// Keep polling; mpv can report the target path before tracks are ready.
}
} else {
const sourceUrl = input.sourceUrl?.trim() ?? '';
const swapped =
sourceUrl !== '' &&
targetsSameYoutubeVideo(previousPath, sourceUrl) &&
(await swapCurrentPlaylistEntry(deps, targetUrl, sourceUrl));
if (!swapped) {
deps.sendMpvCommand(['loadfile', targetUrl, 'replace']);
}
deps.sendMpvCommand(['loadfile', targetUrl, 'replace']);
}
const deadline = now() + timeoutMs;
@@ -112,8 +112,8 @@ test('youtube playback runtime resolves the socket path lazily for windows start
calls.push(`wait-connected:${timeoutMs}`);
return true;
},
prepareYoutubePlaybackInMpv: async ({ url, sourceUrl }) => {
calls.push(`prepare:${url}:${sourceUrl}`);
prepareYoutubePlaybackInMpv: async ({ url }) => {
calls.push(`prepare:${url}`);
return true;
},
runYoutubePlaybackFlow: async ({ url }) => {
@@ -143,51 +143,6 @@ test('youtube playback runtime resolves the socket path lazily for windows start
});
assert.ok(calls.some((entry) => entry.includes('--input-ipc-server=/tmp/updated.sock')));
// The page URL rides along so prepare can swap a queued entry for the direct stream in place.
assert.ok(calls.includes('prepare:https://example.com/direct:https://youtu.be/demo'));
});
test('youtube playback runtime maps resolved windows streams back to their page urls', async () => {
const streamFor = (url: string) =>
`https://rr1---sn.example.googlevideo.com/videoplayback?src=${encodeURIComponent(url)}`;
const firstUrl = 'https://www.youtube.com/watch?v=abcdefghijk';
const secondUrl = 'https://www.youtube.com/watch?v=bcdefghijkl';
const runtime = createYoutubePlaybackRuntime({
platform: 'win32',
directPlaybackFormat: 'b',
mpvYtdlFormat: 'bestvideo+bestaudio',
autoLaunchTimeoutMs: 2_000,
connectTimeoutMs: 1_000,
getSocketPath: () => '/tmp/mpv.sock',
getMpvConnected: () => true,
invalidatePendingAutoplayReadyFallbacks: () => {},
setAppOwnedFlowInFlight: () => {},
ensureYoutubePlaybackRuntimeReady: async () => {},
resolveYoutubePlaybackUrl: async (url) => streamFor(url),
launchWindowsMpv: async () => ({ ok: false }),
waitForYoutubeMpvConnected: async () => true,
// The second video never loads, so the first stream keeps playing.
prepareYoutubePlaybackInMpv: async ({ url }) => url === streamFor(firstUrl),
runYoutubePlaybackFlow: async () => {},
logInfo: () => {},
logWarn: () => {},
schedule: () => 1 as never,
clearScheduled: () => {},
});
assert.equal(runtime.getYoutubeSourceUrlForStream(streamFor(firstUrl)), null);
await runtime.runYoutubePlaybackFlow({ url: firstUrl, source: 'second-instance' });
await runtime
.runYoutubePlaybackFlow({ url: secondUrl, source: 'second-instance' })
.catch(() => {});
assert.equal(runtime.getYoutubeSourceUrlForStream(streamFor(firstUrl)), firstUrl);
assert.equal(runtime.getYoutubeSourceUrlForStream(streamFor(secondUrl)), secondUrl);
assert.equal(
runtime.getYoutubeSourceUrlForStream('https://rr2---sn.example.googlevideo.com/other'),
null,
);
assert.equal(runtime.getYoutubeSourceUrlForStream('/video/episode.mkv'), null);
});
test('youtube playback runtime starts media cache without blocking the subtitle flow', async () => {
+2 -13
View File
@@ -22,7 +22,7 @@ export type YoutubePlaybackRuntimeDeps = {
resolveYoutubePlaybackUrl: (url: string, format: string) => Promise<string>;
launchWindowsMpv: (playbackUrl: string, args: string[]) => Promise<LaunchResult>;
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
prepareYoutubePlaybackInMpv: (request: { url: string; sourceUrl: string }) => Promise<boolean>;
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>;
startYoutubeMediaCache?: (url: string) => void | Promise<void>;
runYoutubePlaybackFlow: (request: { url: string }) => Promise<void>;
logInfo: (message: string) => void;
@@ -35,10 +35,6 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
let quitOnDisconnectArmed = false;
let quitOnDisconnectArmTimer: ReturnType<typeof setTimeout> | null = null;
let playbackFlowGeneration = 0;
// Windows mpv plays resolved stream URLs, so remember which YouTube page each came from. Kept for
// every resolved stream: a later failed load must not orphan the stream still playing, and mpv
// can return to an earlier swapped-in playlist entry.
const directPlaybackSources = new Map<string, string>();
const clearYoutubePlayQuitOnDisconnectArmTimer = (): void => {
if (quitOnDisconnectArmTimer) {
@@ -69,7 +65,6 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
request.url,
deps.directPlaybackFormat,
);
directPlaybackSources.set(playbackUrl, request.url);
deps.logInfo('Resolved direct YouTube playback URL for Windows MPV startup.');
} catch (error) {
deps.logWarn(
@@ -127,10 +122,7 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
}, 3000);
}
const mediaReady = await deps.prepareYoutubePlaybackInMpv({
url: playbackUrl,
sourceUrl: request.url,
});
const mediaReady = await deps.prepareYoutubePlaybackInMpv({ url: playbackUrl });
if (!mediaReady) {
throw new Error('Timed out waiting for mpv to load the requested YouTube URL.');
}
@@ -164,8 +156,5 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
clearYoutubePlayQuitOnDisconnectArmTimer,
getQuitOnDisconnectArmed: (): boolean => quitOnDisconnectArmed,
runYoutubePlaybackFlow,
/** YouTube page URL for a stream URL this runtime resolved for Windows playback, else null. */
getYoutubeSourceUrlForStream: (mediaPath: string | null | undefined): string | null =>
directPlaybackSources.get(mediaPath?.trim() ?? '') ?? null,
};
}