Compare commits

..
2 Commits
33 changed files with 1569 additions and 79 deletions
+4 -2
View File
@@ -32,9 +32,11 @@ jobs:
- name: Guard stable docs tag shape - name: Guard stable docs tag shape
id: tag_guard id: tag_guard
if: github.ref_type == 'tag' if: github.ref_type == 'tag'
env:
TAG_NAME: ${{ github.ref_name }}
run: | run: |
if [[ ! "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then if [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::notice::Skipping non-stable docs tag ${{ github.ref_name }}" echo "::notice::Skipping non-stable docs tag $TAG_NAME"
echo "stable_tag=false" >> "$GITHUB_OUTPUT" echo "stable_tag=false" >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi fi
+15 -8
View File
@@ -297,15 +297,22 @@ jobs:
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Verify committed prerelease notes - name: Verify committed prerelease notes
env:
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: | run: |
if [ ! -s release/prerelease-notes.md ]; then if [ ! -s release/prerelease-notes.md ]; then
echo "::error::release/prerelease-notes.md is missing or empty. Run 'bun run changelog:prerelease-notes --version <version>' locally and commit the file before tagging." echo "::error::release/prerelease-notes.md is missing or empty. Run 'bun run changelog:prerelease-notes --version <version>' locally and commit the file before tagging."
exit 1 exit 1
fi fi
if ! bun run changelog:check-prerelease-notes --version "$RELEASE_VERSION"; then
echo "::error::release/prerelease-notes.md was not generated for $RELEASE_VERSION. Rerun 'bun run changelog:prerelease-notes --version $RELEASE_VERSION' locally, commit, and retag."
exit 1
fi
- name: Publish Prerelease - name: Publish Prerelease
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: | run: |
set -euo pipefail set -euo pipefail
@@ -327,27 +334,27 @@ jobs:
exit 1 exit 1
fi fi
if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then
gh release edit "${{ steps.version.outputs.VERSION }}" \ gh release edit "$RELEASE_VERSION" \
--draft \ --draft \
--prerelease \ --prerelease \
--title "${{ steps.version.outputs.VERSION }}" \ --title "$RELEASE_VERSION" \
--notes-file release/prerelease-notes.md --notes-file release/prerelease-notes.md
else else
gh release create "${{ steps.version.outputs.VERSION }}" \ gh release create "$RELEASE_VERSION" \
--draft \ --draft \
--latest=false \ --latest=false \
--prerelease \ --prerelease \
--title "${{ steps.version.outputs.VERSION }}" \ --title "$RELEASE_VERSION" \
--notes-file release/prerelease-notes.md --notes-file release/prerelease-notes.md
fi fi
for asset in "${artifacts[@]}"; do for asset in "${artifacts[@]}"; do
gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber gh release upload "$RELEASE_VERSION" "$asset" --clobber
done done
gh release edit "${{ steps.version.outputs.VERSION }}" \ gh release edit "$RELEASE_VERSION" \
--draft=false \ --draft=false \
--prerelease \ --prerelease \
--title "${{ steps.version.outputs.VERSION }}" \ --title "$RELEASE_VERSION" \
--notes-file release/prerelease-notes.md --notes-file release/prerelease-notes.md
+24 -13
View File
@@ -296,33 +296,40 @@ jobs:
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Guard against pending changelog fragments - name: Guard against pending changelog fragments
env:
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: | run: |
if find changes -maxdepth 1 -name '*.md' -not -name README.md -print -quit | grep -q .; then if find changes -maxdepth 1 -name '*.md' -not -name README.md -print -quit | grep -q .; then
echo "::error::Pending changelog fragments detected. Run 'bun run changelog:build --version ${{ steps.version.outputs.VERSION }}' locally and commit the polished CHANGELOG.md before tagging. CI no longer auto-builds the changelog because the polish step requires the local 'claude' CLI." echo "::error::Pending changelog fragments detected. Run 'bun run changelog:build --version $RELEASE_VERSION' locally and commit the polished CHANGELOG.md before tagging. CI no longer auto-builds the changelog because the polish step requires the local 'claude' CLI."
exit 1 exit 1
fi fi
- name: Verify changelog is ready for tagged release - name: Verify changelog is ready for tagged release
run: bun run changelog:check --version "${{ steps.version.outputs.VERSION }}" env:
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: bun run changelog:check --version "$RELEASE_VERSION"
- name: Generate release notes from changelog - name: Generate release notes from changelog
run: bun run changelog:release-notes --version "${{ steps.version.outputs.VERSION }}" env:
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: bun run changelog:release-notes --version "$RELEASE_VERSION"
- name: Publish Release - name: Publish Release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: | run: |
set -euo pipefail set -euo pipefail
if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then
# Do not pass the prerelease flag here; gh defaults to a normal release. # Do not pass the prerelease flag here; gh defaults to a normal release.
gh release edit "${{ steps.version.outputs.VERSION }}" \ gh release edit "$RELEASE_VERSION" \
--draft=false \ --draft=false \
--title "${{ steps.version.outputs.VERSION }}" \ --title "$RELEASE_VERSION" \
--notes-file release/release-notes.md --notes-file release/release-notes.md
else else
gh release create "${{ steps.version.outputs.VERSION }}" \ gh release create "$RELEASE_VERSION" \
--title "${{ steps.version.outputs.VERSION }}" \ --title "$RELEASE_VERSION" \
--notes-file release/release-notes.md --notes-file release/release-notes.md
fi fi
@@ -345,7 +352,7 @@ jobs:
fi fi
for asset in "${artifacts[@]}"; do for asset in "${artifacts[@]}"; do
gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber gh release upload "$RELEASE_VERSION" "$asset" --clobber
done done
aur-publish: aur-publish:
@@ -421,9 +428,10 @@ jobs:
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true' if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: | run: |
set -euo pipefail set -euo pipefail
version="${{ steps.version.outputs.VERSION }}" version="$RELEASE_VERSION"
install -dm755 .tmp/aur-release-assets install -dm755 .tmp/aur-release-assets
gh release download "$version" \ gh release download "$version" \
--dir .tmp/aur-release-assets \ --dir .tmp/aur-release-assets \
@@ -433,15 +441,17 @@ jobs:
- name: Update AUR packaging metadata - name: Update AUR packaging metadata
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true' if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
env:
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: | run: |
set -euo pipefail set -euo pipefail
version_no_v="${{ steps.version.outputs.VERSION }}" version_no_v="$RELEASE_VERSION"
version_no_v="${version_no_v#v}" version_no_v="${version_no_v#v}"
cp packaging/aur/subminer-bin/PKGBUILD aur-subminer-bin/PKGBUILD cp packaging/aur/subminer-bin/PKGBUILD aur-subminer-bin/PKGBUILD
cp packaging/aur/subminer-bin/.SRCINFO aur-subminer-bin/.SRCINFO cp packaging/aur/subminer-bin/.SRCINFO aur-subminer-bin/.SRCINFO
bash scripts/update-aur-package.sh \ bash scripts/update-aur-package.sh \
--pkg-dir aur-subminer-bin \ --pkg-dir aur-subminer-bin \
--version "${{ steps.version.outputs.VERSION }}" \ --version "$RELEASE_VERSION" \
--appimage ".tmp/aur-release-assets/SubMiner-${version_no_v}.AppImage" \ --appimage ".tmp/aur-release-assets/SubMiner-${version_no_v}.AppImage" \
--wrapper ".tmp/aur-release-assets/subminer" \ --wrapper ".tmp/aur-release-assets/subminer" \
--assets ".tmp/aur-release-assets/subminer-assets.tar.gz" --assets ".tmp/aur-release-assets/subminer-assets.tar.gz"
@@ -451,6 +461,7 @@ jobs:
working-directory: aur-subminer-bin working-directory: aur-subminer-bin
env: env:
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
RELEASE_VERSION: ${{ steps.version.outputs.VERSION }}
run: | run: |
set -euo pipefail set -euo pipefail
if git diff --quiet -- PKGBUILD .SRCINFO; then if git diff --quiet -- PKGBUILD .SRCINFO; then
@@ -460,7 +471,7 @@ jobs:
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add PKGBUILD .SRCINFO git add PKGBUILD .SRCINFO
git commit -m "Update to ${{ steps.version.outputs.VERSION }}" git commit -m "Update to $RELEASE_VERSION"
attempts=3 attempts=3
for attempt in $(seq 1 "$attempts"); do for attempt in $(seq 1 "$attempts"); do
+1
View File
@@ -49,6 +49,7 @@ How fragments turn into a release:
Prerelease notes: Prerelease notes:
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md` - prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md`
- from the second prerelease of a base version onward, the notes also open with a `## Changes since <previous tag>` section generated from the fragment diff against the previous beta/RC tag; keep fragment edits meaningful. Editorial-only rewording is filtered out of that section, while genuinely changed behavior and deleted fragments (reverted changes) are reported
- existing prerelease notes are a reviewed baseline; later prerelease runs should replace stale beta/RC wording with the current outcome instead of appending fix churn - existing prerelease notes are a reviewed baseline; later prerelease runs should replace stale beta/RC wording with the current outcome instead of appending fix churn
- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md` - prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md`
- the final stable release is the point where `bun run changelog:build` consumes fragments into the stable changelog and release notes - the final stable release is the point where `bun run changelog:build` consumes fragments into the stable changelog and release notes
@@ -0,0 +1,4 @@
type: fixed
area: subtitles
- Prevented embedded subtitle parsing from starving network playback: mounted SMB/NFS media now uses deduplicated mpv live text, while duplicate extraction requests for local media share one ffmpeg process.
+5
View File
@@ -0,0 +1,5 @@
type: changed
area: release
- Prerelease notes now open with a "Changes since" section that lists only what changed compared to the previous beta/RC of the same version, above the cumulative highlights.
- CI now rejects prerelease tags whose committed notes were generated for a different beta/RC, instead of silently shipping stale notes.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Secondary subtitle overlays now show every rendered line instead of clipping text after roughly four lines.
+3 -1
View File
@@ -108,9 +108,11 @@ The secondary bar is a compact top-strip region in the same overlay window. It s
- Quick comprehension checks without leaving the mining flow. - Quick comprehension checks without leaving the mining flow.
- Auto-populating the translation field on mined cards - when a card is created, SubMiner uses the secondary subtitle text as the translation field value (unless AI translation is configured to override it). - Auto-populating the translation field on mined cards - when a card is created, SubMiner uses the secondary subtitle text as the translation field value (unless AI translation is configured to override it).
For local media, SubMiner can parse supported embedded secondary tracks into timed cues. For remote URLs and files on network mounts, it uses mpv's live secondary subtitle text instead of scanning the media with ffmpeg.
It is controlled by `secondarySub` configuration and shares its lifecycle with the main overlay window. Cycle which track feeds it with `Shift+J`. It is controlled by `secondarySub` configuration and shares its lifecycle with the main overlay window. Cycle which track feeds it with `Shift+J`.
SubMiner collapses duplicate ASS layers in parsed secondary tracks. Long lines repeated as dialogue and positioned signs are treated as the same line when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts, such as translated timetables, are excluded instead of being concatenated into the secondary bar. When SubMiner must use mpv's live text as a fallback, it still filters full-line duplicates while preserving short repeated dialogue. SubMiner collapses duplicate ASS layers in parsed secondary tracks. Exact repeated lines collapse at any length, while distinct simultaneous short lines remain separate. Long dialogue and positioned-sign copies also collapse when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts, such as translated timetables, are excluded instead of being concatenated into the secondary bar.
### Display Modes ### Display Modes
+11 -6
View File
@@ -58,12 +58,15 @@
`latest*.yml` and `*.blockmap` files under `release/`. `latest*.yml` and `*.blockmap` files under `release/`.
5. Commit the prerelease prep (package.json version bump + the generated 5. Commit the prerelease prep (package.json version bump + the generated
`release/prerelease-notes.md`). CI does not regenerate notes — it uses the `release/prerelease-notes.md`). CI does not regenerate notes — it uses the
committed file — so review it before committing. If you add more committed file — so review it before committing. Rerun
`changes/*.md` fragments for a later beta/RC, rerun `bun run changelog:prerelease-notes --version <version>` for every later
`bun run changelog:prerelease-notes --version <version>`; the generator uses beta/RC, even if no fragments changed: the notes carry a hidden
the existing prerelease notes as the baseline only when their hidden `prerelease-version` marker and CI rejects the tag when the marker does not
`prerelease-base-version` marker matches the current base version, and asks match it (verify locally with
Claude to merge only the new fragment material. Do not run `bun run changelog:check-prerelease-notes --version <version>`). The
generator reuses the existing notes as the cumulative baseline when their
marker (or legacy `prerelease-base-version` marker) matches the current base
version, and asks Claude to merge only the new fragment material. Do not run
`bun run changelog:build`. `bun run changelog:build`.
6. Tag the commit: `git tag v<version>`. 6. Tag the commit: `git tag v<version>`.
7. Push commit + tag. 7. Push commit + tag.
@@ -78,6 +81,8 @@ Notes:
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night. - Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
- `changelog:check` now rejects tag/package version mismatches. - `changelog:check` now rejects tag/package version mismatches.
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over. - `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. When that file already exists, the generator includes it in the Claude prompt so later beta/RC notes reuse the reviewed text instead of starting over.
- From the second prerelease of a base version onward, the notes open with a `## Changes since <previous tag>` section above the cumulative `## Highlights`. The generator locates the newest preceding beta/RC tag for the same base version (semver order: all betas before all RCs), diffs `changes/*.md` between that tag and the working tree, and asks Claude to describe only the behavioral beta-to-beta differences — added fragments as new changes, modified fragments by their before/after difference (editorial-only edits are dropped), deleted fragments as removed/reverted changes. If no fragments changed (for example a packaging-only rebuild), the section states that explicitly without a Claude call. The delta section carries no separate contributor attribution; `## What's Changed` stays cumulative like `## Highlights`.
- `changelog:check-prerelease-notes --version <version>` verifies the committed notes' `prerelease-version` marker matches the version being tagged; the prerelease workflow runs it and fails the release on stale notes.
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely. - `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
- `release/release-notes.md` (and `release/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free. - `release/release-notes.md` (and `release/prerelease-notes.md`) include GitHub-style attribution after `## Highlights`: a `## What's Changed` list crediting each released fragment as `by @<author> in #<pr>`, plus a `## New Contributors` section for first-time authors. Attribution is resolved per fragment via `git log` (the commit that added the fragment) + `gh api .../commits/<sha>/pulls`, with one `gh` search per author for the first-contribution check. It needs `gh` installed and authenticated; if `gh` is unavailable or a lookup fails, the generator warns and emits notes without the attribution sections rather than failing. The CHANGELOG itself stays attribution-free.
- The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag. - The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
@@ -97,11 +97,12 @@ coming and prefetching would otherwise idle for the rest of the cue.
## Secondary Subtitle Flow ## Secondary Subtitle Flow
- `secondary-sub-text` remains the immediate fallback, so unreadable and remote subtitle sources - `secondary-sub-text` remains the immediate fallback, so unreadable subtitle sources, remote URLs,
still appear without waiting for file resolution. and files on network mounts still appear without waiting for file resolution. Embedded-track
- Parsed secondary text and the live fallback share a flattened-line identity for long lines. This extraction is skipped for those sources to avoid competing with playback for network bandwidth.
removes dialogue/sign repetitions that differ only in whitespace or terminal punctuation while - Parsed secondary text and the live fallback remove exact repeated lines at any length. A
retaining short repeated lines that can represent authored dialogue without source metadata. flattened-line identity also removes long dialogue/sign repetitions that differ only in
whitespace or terminal punctuation, while distinct simultaneous short lines remain separate.
- `secondary-subtitle-track.ts` resolves `secondary-sid` against mpv's track list. External tracks - `secondary-subtitle-track.ts` resolves `secondary-sid` against mpv's track list. External tracks
are read directly; supported embedded text tracks are extracted through the same ffmpeg-backed are read directly; supported embedded text tracks are extracted through the same ffmpeg-backed
source resolver used by primary subtitle prefetching. source resolver used by primary subtitle prefetching.
+1
View File
@@ -32,6 +32,7 @@
"changelog:pr-check": "bun run scripts/build-changelog.ts pr-check", "changelog:pr-check": "bun run scripts/build-changelog.ts pr-check",
"changelog:release-notes": "bun run scripts/build-changelog.ts release-notes", "changelog:release-notes": "bun run scripts/build-changelog.ts release-notes",
"changelog:prerelease-notes": "bun run scripts/build-changelog.ts prerelease-notes", "changelog:prerelease-notes": "bun run scripts/build-changelog.ts prerelease-notes",
"changelog:check-prerelease-notes": "bun run scripts/build-changelog.ts check-prerelease-notes",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .", "format:check": "prettier --check .",
"format:src": "bash scripts/prettier-scope.sh --write", "format:src": "bash scripts/prettier-scope.sh --write",
+378 -6
View File
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
@@ -583,7 +584,7 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
const outputPath = writePrereleaseNotesForVersion({ const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot, cwd: projectRoot,
version: '0.11.3-beta.1', version: '0.11.3-beta.1',
deps: { runClaude: stub.runClaude }, deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
}); });
assert.equal(outputPath, path.join(projectRoot, 'release', 'prerelease-notes.md')); assert.equal(outputPath, path.join(projectRoot, 'release', 'prerelease-notes.md'));
@@ -605,7 +606,8 @@ test('writePrereleaseNotesForVersion writes cumulative beta notes without mutati
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m); assert.match(prereleaseNotes, /^> This is a prerelease build for testing\./m);
assert.match(prereleaseNotes, /<!-- prerelease-base-version: 0\.11\.3 -->/); assert.match(prereleaseNotes, /<!-- prerelease-version: 0\.11\.3-beta\.1 -->/);
assert.doesNotMatch(prereleaseNotes, /## Changes since /);
assert.match(prereleaseNotes, /## Highlights\n### Added\n- Polished: added entry\./); assert.match(prereleaseNotes, /## Highlights\n### Added\n- Polished: added entry\./);
assert.match(prereleaseNotes, /### Fixed\n- Polished: fixed entry\./); assert.match(prereleaseNotes, /### Fixed\n- Polished: fixed entry\./);
assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/); assert.match(prereleaseNotes, /## Installation\n\nSee the README and docs\/installation guide/);
@@ -668,7 +670,7 @@ test('writePrereleaseNotesForVersion reuses existing prerelease notes when addin
const outputPath = writePrereleaseNotesForVersion({ const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot, cwd: projectRoot,
version: '0.11.3-beta.2', version: '0.11.3-beta.2',
deps: { runClaude: stub.runClaude }, deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
}); });
assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call'); assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
@@ -723,7 +725,7 @@ test('writePrereleaseNotesForVersion ignores unmarked prerelease notes from an o
const outputPath = writePrereleaseNotesForVersion({ const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot, cwd: projectRoot,
version: '0.17.0-beta.1', version: '0.17.0-beta.1',
deps: { runClaude: stub.runClaude }, deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
}); });
assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call'); assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
@@ -790,7 +792,7 @@ test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease b
writePrereleaseNotesForVersion({ writePrereleaseNotesForVersion({
cwd: projectRoot, cwd: projectRoot,
version: '0.12.0-beta.2', version: '0.12.0-beta.2',
deps: { runClaude: stub.runClaude }, deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
}); });
assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call'); assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
@@ -830,7 +832,7 @@ test('writePrereleaseNotesForVersion supports rc prereleases', async () => {
const outputPath = writePrereleaseNotesForVersion({ const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot, cwd: projectRoot,
version: '0.11.3-rc.1', version: '0.11.3-rc.1',
deps: { runClaude: stub.runClaude }, deps: { runClaude: stub.runClaude, listPrereleaseTags: () => [] },
}); });
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8'); const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
@@ -1447,3 +1449,373 @@ test('writeChangelogArtifacts strips <details> blocks from release notes when re
fs.rmSync(workspace, { recursive: true, force: true }); fs.rmSync(workspace, { recursive: true, force: true });
} }
}); });
test('selectPreviousPrereleaseTag orders betas before rcs and filters other base versions', async () => {
const { selectPreviousPrereleaseTag } = await loadModule();
const tags = [
'v0.19.4-beta.1',
'v0.19.4-beta.3',
'v0.19.4-beta.2',
'v0.19.3-beta.9',
'v0.19.4-rc.1',
'not-a-tag',
];
assert.equal(selectPreviousPrereleaseTag(tags, '0.19.4-beta.1'), null);
assert.equal(selectPreviousPrereleaseTag(tags, '0.19.4-beta.2'), 'v0.19.4-beta.1');
assert.equal(selectPreviousPrereleaseTag(tags, '0.19.4-beta.4'), 'v0.19.4-beta.3');
assert.equal(selectPreviousPrereleaseTag(tags, '0.19.4-rc.1'), 'v0.19.4-beta.3');
assert.equal(selectPreviousPrereleaseTag(tags, '0.19.4-rc.2'), 'v0.19.4-rc.1');
// Regenerating notes for an already-tagged version must not pick itself.
assert.equal(selectPreviousPrereleaseTag(tags, '0.19.4-beta.3'), 'v0.19.4-beta.2');
assert.equal(selectPreviousPrereleaseTag(['v0.19.3-beta.1'], '0.19.4-beta.2'), null);
});
test('writePrereleaseNotesForVersion adds a delta section generated from fragment diffs', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-delta-section');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.12.0-beta.2' }, null, 2),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: fixed', 'area: overlay', '', '- Fixed overlay focus and macOS helper.'].join('\n'),
'utf8',
);
try {
const stub = recordingRunClaude((input) =>
input.includes('MODIFIED FRAGMENT')
? '- Fixed the macOS helper deployment target for older systems.'
: '### Fixed\n- Overlay: cumulative fixed entry.',
);
const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.12.0-beta.2',
deps: {
runClaude: stub.runClaude,
listPrereleaseTags: () => ['v0.12.0-beta.1'],
resolveFragmentDelta: (_cwd, previousTag) => {
assert.equal(previousTag, 'v0.12.0-beta.1');
return [
{
path: 'changes/002.md',
status: 'added',
after: 'type: fixed\narea: macos\n\n- Fixed helper deployment target.',
},
{
path: 'changes/001.md',
status: 'modified',
before: '- Fixed overlay focus.',
after: '- Fixed overlay focus and macOS helper.',
},
{
path: 'changes/003.md',
status: 'deleted',
before: 'type: added\narea: stats\n\n- Reverted experimental stats view.',
},
];
},
},
});
assert.equal(stub.calls.length, 2, 'delta and cumulative polish are separate Claude calls');
const deltaPrompt = stub.calls[0]!.input;
assert.match(deltaPrompt, /ADDED FRAGMENT changes\/002\.md/);
assert.match(deltaPrompt, /MODIFIED FRAGMENT changes\/001\.md/);
assert.match(deltaPrompt, /BEFORE:\n- Fixed overlay focus\./);
assert.match(deltaPrompt, /AFTER:\n- Fixed overlay focus and macOS helper\./);
assert.match(deltaPrompt, /DELETED FRAGMENT changes\/003\.md/);
assert.match(deltaPrompt, /If the edit is editorial/);
assert.match(deltaPrompt, /removed or reverted/);
assert.match(deltaPrompt, /No user-facing changes since v0\.12\.0-beta\.1\./);
assert.equal(modeFromPrompt(stub.calls[1]!.input), 'release-notes');
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(
prereleaseNotes,
/<!-- prerelease-version: 0\.12\.0-beta\.2; since: v0\.12\.0-beta\.1 -->/,
);
const deltaIndex = prereleaseNotes.indexOf('## Changes since v0.12.0-beta.1');
const highlightsIndex = prereleaseNotes.indexOf('## Highlights');
assert.ok(deltaIndex !== -1, 'delta section heading should be present');
assert.ok(deltaIndex < highlightsIndex, 'delta section should precede Highlights');
assert.match(prereleaseNotes, /- Fixed the macOS helper deployment target for older systems\./);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writePrereleaseNotesForVersion renders a fallback delta line when no fragments changed', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-empty-delta');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.12.0-beta.3' }, null, 2),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: fixed', 'area: overlay', '', '- Fixed overlay focus.'].join('\n'),
'utf8',
);
try {
const stub = defaultStubClaude();
const outputPath = writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.12.0-beta.3',
deps: {
runClaude: stub.runClaude,
listPrereleaseTags: () => ['v0.12.0-beta.1', 'v0.12.0-beta.2'],
resolveFragmentDelta: () => [],
},
});
assert.equal(stub.calls.length, 1, 'empty delta must not spend a Claude call');
const prereleaseNotes = fs.readFileSync(outputPath, 'utf8');
assert.match(
prereleaseNotes,
/## Changes since v0\.12\.0-beta\.2\n\n- No changelog fragment changes since v0\.12\.0-beta\.2; this build contains packaging or internal-only updates\./,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writePrereleaseNotesForVersion rejects non-bullet delta output from Claude', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-delta-invalid-output');
const projectRoot = path.join(workspace, 'SubMiner');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.12.0-beta.2' }, null, 2),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: fixed', 'area: overlay', '', '- Fixed overlay focus.'].join('\n'),
'utf8',
);
try {
const stub = recordingRunClaude(() => 'Here are the changes:\n- One change.');
assert.throws(
() =>
writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.12.0-beta.2',
deps: {
runClaude: stub.runClaude,
listPrereleaseTags: () => ['v0.12.0-beta.1'],
resolveFragmentDelta: () => [
{ path: 'changes/001.md', status: 'added', after: '- Fixed overlay focus.' },
],
},
}),
/delta output must contain only Markdown bullets/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('writePrereleaseNotesForVersion strips the stale delta section from the reused baseline', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-reuse-strips-delta');
const projectRoot = path.join(workspace, 'SubMiner');
const existingNotes = [
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
'',
'<!-- prerelease-version: 0.12.0-beta.2; since: v0.12.0-beta.1 -->',
'',
'## Changes since v0.12.0-beta.1',
'',
'- Stale beta-to-beta delta bullet.',
'',
'## Highlights',
'### Added',
'- Overlay: Previous beta entry.',
'',
'## Installation',
'',
'See the README and docs/installation guide for full setup steps.',
'',
].join('\n');
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.12.0-beta.3' }, null, 2),
'utf8',
);
fs.writeFileSync(path.join(projectRoot, 'release', 'prerelease-notes.md'), existingNotes, 'utf8');
fs.writeFileSync(
path.join(projectRoot, 'changes', '001.md'),
['type: added', 'area: overlay', '', '- Added overlay coverage.'].join('\n'),
'utf8',
);
try {
const stub = defaultStubClaude();
writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.12.0-beta.3',
deps: {
runClaude: stub.runClaude,
listPrereleaseTags: () => [],
resolveFragmentDelta: () => [],
},
});
assert.equal(stub.calls.length, 1);
const prompt = stub.calls[0]!.input;
assert.match(prompt, /EXISTING PRERELEASE NOTES/);
assert.match(prompt, /Overlay: Previous beta entry\./);
assert.doesNotMatch(prompt, /Stale beta-to-beta delta bullet\./);
assert.doesNotMatch(prompt, /## Changes since /);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('verifyPrereleaseNotesMatchVersion accepts matching notes and rejects stale or legacy markers', async () => {
const { verifyPrereleaseNotesMatchVersion } = await loadModule();
const workspace = createWorkspace('verify-prerelease-notes');
const projectRoot = path.join(workspace, 'SubMiner');
const notesPath = path.join(projectRoot, 'release', 'prerelease-notes.md');
fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.12.0-beta.2' }, null, 2),
'utf8',
);
try {
assert.throws(
() => verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: '0.12.0-beta.2' }),
/Missing .*prerelease-notes\.md/,
);
fs.writeFileSync(
notesPath,
'<!-- prerelease-version: 0.12.0-beta.2; since: v0.12.0-beta.1 -->\n\n## Highlights\n',
'utf8',
);
verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: '0.12.0-beta.2' });
verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: 'v0.12.0-beta.2' });
fs.writeFileSync(
notesPath,
'<!-- prerelease-version: 0.12.0-beta.1 -->\n\n## Highlights\n',
'utf8',
);
assert.throws(
() => verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: '0.12.0-beta.2' }),
/generated for 0\.12\.0-beta\.1 but this release is 0\.12\.0-beta\.2/,
);
fs.writeFileSync(
notesPath,
'<!-- prerelease-base-version: 0.12.0 -->\n\n## Highlights\n',
'utf8',
);
assert.throws(
() => verifyPrereleaseNotesMatchVersion({ cwd: projectRoot, version: '0.12.0-beta.2' }),
/missing or legacy prerelease-version marker/,
);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('default git tag listing and fragment delta resolution work against a real repository', async () => {
const { writePrereleaseNotesForVersion } = await loadModule();
const workspace = createWorkspace('prerelease-git-defaults');
const projectRoot = path.join(workspace, 'SubMiner');
const git = (...args: string[]): void => {
execFileSync('git', args, { cwd: projectRoot, stdio: 'ignore' });
};
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.1' }, null, 2),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', 'kept.md'),
['type: added', 'area: overlay', '', '- Kept change.'].join('\n'),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', 'edited.md'),
['type: fixed', 'area: launcher', '', '- Original launcher fix.'].join('\n'),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'changes', 'removed.md'),
['type: added', 'area: stats', '', '- Reverted stats change.'].join('\n'),
'utf8',
);
try {
git('init', '--quiet');
git('-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'add', '.');
git('-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'beta.1');
git('tag', 'v0.11.3-beta.1');
fs.writeFileSync(
path.join(projectRoot, 'changes', 'edited.md'),
['type: fixed', 'area: launcher', '', '- Broader launcher fix.'].join('\n'),
'utf8',
);
fs.rmSync(path.join(projectRoot, 'changes', 'removed.md'));
fs.writeFileSync(
path.join(projectRoot, 'changes', 'new.md'),
['type: added', 'area: anki', '', '- New anki change.'].join('\n'),
'utf8',
);
fs.writeFileSync(
path.join(projectRoot, 'package.json'),
JSON.stringify({ name: 'subminer', version: '0.11.3-beta.2' }, null, 2),
'utf8',
);
const stub = recordingRunClaude((input) =>
input.includes('PREVIOUS_TAG:') ? '- Delta bullet.' : defaultPolishedBody(input),
);
writePrereleaseNotesForVersion({
cwd: projectRoot,
version: '0.11.3-beta.2',
deps: { runClaude: stub.runClaude },
});
assert.equal(stub.calls.length, 2);
const deltaPrompt = stub.calls[0]!.input;
assert.match(deltaPrompt, /PREVIOUS_TAG: v0\.11\.3-beta\.1/);
assert.match(deltaPrompt, /ADDED FRAGMENT changes\/new\.md/);
assert.match(deltaPrompt, /- New anki change\./);
assert.match(deltaPrompt, /MODIFIED FRAGMENT changes\/edited\.md/);
assert.match(deltaPrompt, /- Original launcher fix\./);
assert.match(deltaPrompt, /- Broader launcher fix\./);
assert.match(deltaPrompt, /DELETED FRAGMENT changes\/removed\.md/);
assert.match(deltaPrompt, /- Reverted stats change\./);
assert.doesNotMatch(deltaPrompt, /kept\.md/);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
});
+304 -6
View File
@@ -18,6 +18,15 @@ type Contribution = {
// and the GitHub API. // and the GitHub API.
type ResolveContributions = (fragmentPaths: string[], cwd: string) => Contribution[]; type ResolveContributions = (fragmentPaths: string[], cwd: string) => Contribution[];
// One changelog fragment's change between the previous prerelease tag and the
// working tree. `before` is the content at the tag, `after` the current content.
export type FragmentDeltaEntry = {
path: string;
status: 'added' | 'modified' | 'deleted';
before?: string;
after?: string;
};
type ChangelogFsDeps = { type ChangelogFsDeps = {
existsSync?: (candidate: string) => boolean; existsSync?: (candidate: string) => boolean;
mkdirSync?: (candidate: string, options: { recursive: true }) => void; mkdirSync?: (candidate: string, options: { recursive: true }) => void;
@@ -28,6 +37,8 @@ type ChangelogFsDeps = {
log?: (message: string) => void; log?: (message: string) => void;
runClaude?: RunClaude; runClaude?: RunClaude;
resolveContributions?: ResolveContributions; resolveContributions?: ResolveContributions;
listPrereleaseTags?: (cwd: string, baseVersion: string) => string[];
resolveFragmentDelta?: (cwd: string, previousTag: string) => FragmentDeltaEntry[];
}; };
type PolishMode = 'changelog' | 'release-notes'; type PolishMode = 'changelog' | 'release-notes';
@@ -103,16 +114,57 @@ function resolvePrereleaseBaseVersion(version: string): string {
return match[1]!; return match[1]!;
} }
function renderPrereleaseBaseVersionMarker(version: string): string { // The marker records which exact prerelease the committed notes were generated
return `<!-- prerelease-base-version: ${resolvePrereleaseBaseVersion(version)} -->`; // for (and which prior tag the delta section compares against), so CI can
// reject notes that were prepared for a different beta/RC.
function renderPrereleaseVersionMarker(version: string, previousTag: string | null): string {
const since = previousTag ? `; since: ${previousTag}` : '';
return `<!-- prerelease-version: ${normalizeVersion(version)}${since} -->`;
} }
export function extractPrereleaseVersionMarker(notes: string): string | null {
return (
/<!--\s*prerelease-version:\s*(\d+\.\d+\.\d+-(?:beta|rc)\.\d+)(?:;\s*since:\s*\S+)?\s*-->/u.exec(
notes,
)?.[1] ?? null
);
}
// Legacy marker written before the per-version marker existed. Still accepted
// when deciding whether existing notes can seed the cumulative baseline.
function extractPrereleaseBaseVersionMarker(notes: string): string | null { function extractPrereleaseBaseVersionMarker(notes: string): string | null {
const fullVersion = extractPrereleaseVersionMarker(notes);
if (fullVersion) {
return resolvePrereleaseBaseVersion(fullVersion);
}
return /<!--\s*prerelease-base-version:\s*(\d+\.\d+\.\d+)\s*-->/u.exec(notes)?.[1] ?? null; return /<!--\s*prerelease-base-version:\s*(\d+\.\d+\.\d+)\s*-->/u.exec(notes)?.[1] ?? null;
} }
const DELTA_SECTION_HEADING_PREFIX = '## Changes since ';
// Removes the previous run's "Changes since" section so the cumulative baseline
// fed back to Claude never carries a stale beta-to-beta delta.
function stripDeltaSection(notes: string): string {
const lines = notes.split(/\r?\n/);
const start = lines.findIndex((line) => line.startsWith(DELTA_SECTION_HEADING_PREFIX));
if (start === -1) {
return notes;
}
let end = lines.length;
for (let index = start + 1; index < lines.length; index += 1) {
if (lines[index]!.startsWith('## ')) {
end = index;
break;
}
}
return [...lines.slice(0, start), ...lines.slice(end)].join('\n');
}
function stripPrereleaseMetadata(notes: string): string { function stripPrereleaseMetadata(notes: string): string {
return notes.replace(/<!--\s*prerelease-base-version:\s*\d+\.\d+\.\d+\s*-->\s*/u, '').trim(); return notes
.replace(/<!--\s*prerelease-version:[^>]*-->\s*/u, '')
.replace(/<!--\s*prerelease-base-version:\s*\d+\.\d+\.\d+\s*-->\s*/u, '')
.trim();
} }
function resolveReusablePrereleaseNotes(notes: string, version: string): string | undefined { function resolveReusablePrereleaseNotes(notes: string, version: string): string | undefined {
@@ -120,7 +172,124 @@ function resolveReusablePrereleaseNotes(notes: string, version: string): string
if (existingBaseVersion !== resolvePrereleaseBaseVersion(version)) { if (existingBaseVersion !== resolvePrereleaseBaseVersion(version)) {
return undefined; return undefined;
} }
return stripPrereleaseMetadata(notes); return stripPrereleaseMetadata(stripDeltaSection(notes));
}
type ParsedPrereleaseTag = {
tag: string;
base: string;
channel: 'beta' | 'rc';
iteration: number;
};
function parsePrereleaseTag(tag: string): ParsedPrereleaseTag | null {
const match = /^v?(\d+\.\d+\.\d+)-(beta|rc)\.(\d+)$/u.exec(tag.trim());
if (!match) {
return null;
}
return {
tag: tag.trim(),
base: match[1]!,
channel: match[2] as 'beta' | 'rc',
iteration: Number.parseInt(match[3]!, 10),
};
}
// Semver prerelease order: every beta sorts before every rc, then numerically.
function comparePrereleaseTags(a: ParsedPrereleaseTag, b: ParsedPrereleaseTag): number {
if (a.channel !== b.channel) {
return a.channel === 'beta' ? -1 : 1;
}
return a.iteration - b.iteration;
}
// Picks the newest prerelease tag for the same base version that strictly
// precedes the version being released. Returns null for the first prerelease.
export function selectPreviousPrereleaseTag(tags: string[], version: string): string | null {
const current = parsePrereleaseTag(normalizeVersion(version));
if (!current) {
return null;
}
const candidates = tags
.map(parsePrereleaseTag)
.filter((parsed): parsed is ParsedPrereleaseTag => parsed !== null)
.filter((parsed) => parsed.base === current.base)
.filter((parsed) => comparePrereleaseTags(parsed, current) < 0)
.sort(comparePrereleaseTags);
return candidates[candidates.length - 1]?.tag ?? null;
}
function defaultListPrereleaseTags(cwd: string, baseVersion: string): string[] {
return execFileSync('git', ['tag', '--list', `v${baseVersion}-beta.*`, `v${baseVersion}-rc.*`], {
cwd,
encoding: 'utf8',
})
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
// Diffs changes/*.md between the previous prerelease tag and the working tree.
// Renamed fragments are treated as modifications of the new path.
//
// Like every other path in this script, git paths are resolved against `cwd`,
// which is the project root and also the repository root. Callers that point
// `cwd` elsewhere already fail earlier and loudly, when package.json and
// changes/ come back missing.
function defaultResolveFragmentDelta(cwd: string, previousTag: string): FragmentDeltaEntry[] {
const output = execFileSync(
'git',
['diff', '--name-status', '--find-renames', previousTag, '--', 'changes'],
{ cwd, encoding: 'utf8' },
);
const showAtTag = (fragmentPath: string): string =>
execFileSync('git', ['show', `${previousTag}:${fragmentPath}`], { cwd, encoding: 'utf8' });
const readCurrent = (fragmentPath: string): string =>
fs.readFileSync(path.join(cwd, fragmentPath), 'utf8');
const entries: FragmentDeltaEntry[] = [];
for (const line of output.split(/\r?\n/)) {
if (!line.trim()) {
continue;
}
const [status = '', ...paths] = line.split('\t');
const oldPath = paths[0] ?? '';
const newPath = paths[paths.length - 1] ?? '';
if (!isFragmentPath(newPath) && !isFragmentPath(oldPath)) {
continue;
}
if (status.startsWith('A')) {
entries.push({ path: newPath, status: 'added', after: readCurrent(newPath) });
} else if (status.startsWith('D')) {
entries.push({ path: oldPath, status: 'deleted', before: showAtTag(oldPath) });
} else if (status.startsWith('M') || status.startsWith('R')) {
entries.push({
path: newPath,
status: 'modified',
before: showAtTag(oldPath),
after: readCurrent(newPath),
});
}
}
// git diff misses fragments that exist only in the working tree; treat
// untracked fragments as additions so a pre-commit run still sees them.
const untracked = execFileSync(
'git',
['ls-files', '--others', '--exclude-standard', '--', 'changes'],
{ cwd, encoding: 'utf8' },
)
.split(/\r?\n/)
.map((line) => line.trim())
.filter((candidate) => candidate && isFragmentPath(candidate));
for (const fragmentPath of untracked) {
entries.push({ path: fragmentPath, status: 'added', after: readCurrent(fragmentPath) });
}
return entries;
} }
function verifyRequestedVersionMatchesPackageVersion( function verifyRequestedVersionMatchesPackageVersion(
@@ -615,7 +784,7 @@ function polishFragmentsWithClaude(
? [ ? [
'## Existing Prerelease Notes', '## 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. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, 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') ].join('\n')
: ''; : '';
@@ -627,6 +796,75 @@ function polishFragmentsWithClaude(
return validatePolishedOutput(output, mode, hasInternalFragments); return validatePolishedOutput(output, mode, hasInternalFragments);
} }
const DELTA_PROMPT_INSTRUCTIONS = `You are writing the "changes since the previous prerelease" section of a prerelease notes file for SubMiner, an Electron app for Japanese sentence mining.
You will receive changelog fragment diffs between the previous prerelease tag and the current build. Fragments are engineer-written release-note sources; a fragment diff is a proxy for what changed, not proof of a behavior change.
Rules:
1. Output Markdown bullets ONLY. No headings, no preamble, no commentary. Every line must be a top-level "- " bullet or an indented nested bullet.
2. Describe only what changed for users between the two prerelease builds, in user-facing language. Drop implementation jargon, file paths, and PR numbers.
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 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.
The input begins below.
`;
function serializeFragmentDeltaForPrompt(
delta: FragmentDeltaEntry[],
version: string,
previousTag: string,
): string {
const header = [`VERSION: ${version}`, `PREVIOUS_TAG: ${previousTag}`];
const blocks = delta.map((entry) => {
if (entry.status === 'added') {
return [`ADDED FRAGMENT ${entry.path}`, entry.after ?? ''].join('\n');
}
if (entry.status === 'deleted') {
return [`DELETED FRAGMENT ${entry.path}`, entry.before ?? ''].join('\n');
}
return [
`MODIFIED FRAGMENT ${entry.path}`,
'BEFORE:',
entry.before ?? '',
'AFTER:',
entry.after ?? '',
].join('\n');
});
return [...header, '', ...blocks].join('\n\n');
}
function validateDeltaOutput(output: string): string {
const trimmed = output.trim();
if (!trimmed) {
throw new Error('claude returned empty output for the prerelease delta section.');
}
const invalidLine = trimmed.split(/\r?\n/).find((line) => line.trim() && !/^\s*- /.test(line));
if (invalidLine !== undefined) {
throw new Error(
`claude delta output must contain only Markdown bullets. Offending line:\n${invalidLine}`,
);
}
return trimmed;
}
function buildDeltaSectionWithClaude(
delta: FragmentDeltaEntry[],
options: { version: string; previousTag: string; deps?: ChangelogFsDeps },
): string {
const runClaude = options.deps?.runClaude ?? defaultRunClaude;
const prompt =
DELTA_PROMPT_INSTRUCTIONS.replace('PREVIOUS_TAG', options.previousTag) +
serializeFragmentDeltaForPrompt(delta, options.version, options.previousTag);
return validateDeltaOutput(runClaude(prompt, CLAUDE_CLI_ARGS));
}
function stripDetailsBlocks(body: string): string { function stripDetailsBlocks(body: string): string {
return body.replace(/<details>[\s\S]*?<\/details>\s*/gm, '').trim(); return body.replace(/<details>[\s\S]*?<\/details>\s*/gm, '').trim();
} }
@@ -709,15 +947,18 @@ function renderReleaseNotes(
contributions?: Contribution[]; contributions?: Contribution[];
contributorSections?: string[]; contributorSections?: string[];
metadata?: string[]; metadata?: string[];
deltaSection?: string[];
}, },
): string { ): string {
const prefix = options?.disclaimer ? [options.disclaimer, ''] : []; const prefix = options?.disclaimer ? [options.disclaimer, ''] : [];
const metadata = options?.metadata?.length ? [...options.metadata, ''] : []; const metadata = options?.metadata?.length ? [...options.metadata, ''] : [];
const deltaSection = options?.deltaSection?.length ? [...options.deltaSection, ''] : [];
const contributorSections = const contributorSections =
options?.contributorSections ?? renderContributorsSections(options?.contributions ?? []); options?.contributorSections ?? renderContributorsSections(options?.contributions ?? []);
return [ return [
...prefix, ...prefix,
...metadata, ...metadata,
...deltaSection,
'## Highlights', '## Highlights',
changes, changes,
'', '',
@@ -748,6 +989,7 @@ function writeReleaseNotesFile(
contributions?: Contribution[]; contributions?: Contribution[];
contributorSections?: string[]; contributorSections?: string[];
metadata?: string[]; metadata?: string[];
deltaSection?: string[];
}, },
): string { ): string {
const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync; const mkdirSync = deps?.mkdirSync ?? fs.mkdirSync;
@@ -1079,6 +1321,26 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
throw new Error('No changelog fragments found in changes/.'); throw new Error('No changelog fragments found in changes/.');
} }
const listPrereleaseTags = options?.deps?.listPrereleaseTags ?? defaultListPrereleaseTags;
const previousTag = selectPreviousPrereleaseTag(
listPrereleaseTags(cwd, resolvePrereleaseBaseVersion(version)),
version,
);
// Later betas/RCs get a "Changes since <previous tag>" section on top of the
// cumulative Highlights, generated from the fragment diff between the
// previous prerelease tag and the working tree.
let deltaSection: string[] = [];
if (previousTag) {
const resolveFragmentDelta = options?.deps?.resolveFragmentDelta ?? defaultResolveFragmentDelta;
const delta = resolveFragmentDelta(cwd, previousTag);
const deltaBody =
delta.length === 0
? `- No changelog fragment changes since ${previousTag}; this build contains packaging or internal-only updates.`
: buildDeltaSectionWithClaude(delta, { version, previousTag, deps: options?.deps });
deltaSection = [`${DELTA_SECTION_HEADING_PREFIX}${previousTag}`, '', deltaBody];
}
const prereleaseNotesPath = path.join(cwd, PRERELEASE_NOTES_PATH); const prereleaseNotesPath = path.join(cwd, PRERELEASE_NOTES_PATH);
const existingReleaseNotes = existsSync(prereleaseNotesPath) const existingReleaseNotes = existsSync(prereleaseNotesPath)
? resolveReusablePrereleaseNotes(readFileSync(prereleaseNotesPath, 'utf8'), version) ? resolveReusablePrereleaseNotes(readFileSync(prereleaseNotesPath, 'utf8'), version)
@@ -1095,10 +1357,41 @@ export function writePrereleaseNotesForVersion(options?: ChangelogOptions): stri
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.', '> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
outputPath: PRERELEASE_NOTES_PATH, outputPath: PRERELEASE_NOTES_PATH,
contributions, contributions,
metadata: [renderPrereleaseBaseVersionMarker(version)], metadata: [renderPrereleaseVersionMarker(version, previousTag)],
deltaSection,
}); });
} }
// CI gate: the committed prerelease notes must carry a marker generated for
// exactly the version being tagged, so stale beta.N-1 notes can't ship.
export function verifyPrereleaseNotesMatchVersion(options?: ChangelogOptions): void {
verifyRequestedVersionMatchesPackageVersion(options ?? {});
const cwd = options?.cwd ?? process.cwd();
const existsSync = options?.deps?.existsSync ?? fs.existsSync;
const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync;
const version = resolveVersion(options ?? {});
if (!isSupportedPrereleaseVersion(version)) {
throw new Error(
`Unsupported prerelease version (${version}). Expected x.y.z-beta.N or x.y.z-rc.N.`,
);
}
const prereleaseNotesPath = path.join(cwd, PRERELEASE_NOTES_PATH);
if (!existsSync(prereleaseNotesPath)) {
throw new Error(
`Missing ${prereleaseNotesPath}. Run 'bun run changelog:prerelease-notes --version ${version}' and commit the file before tagging.`,
);
}
const markerVersion = extractPrereleaseVersionMarker(readFileSync(prereleaseNotesPath, 'utf8'));
if (markerVersion !== version) {
throw new Error(
`release/prerelease-notes.md was generated for ${markerVersion ?? 'an unknown version (missing or legacy prerelease-version marker)'} but this release is ${version}. Rerun 'bun run changelog:prerelease-notes --version ${version}' and commit the result.`,
);
}
}
function parseCliArgs(argv: string[]): { function parseCliArgs(argv: string[]): {
baseRef?: string; baseRef?: string;
cwd?: string; cwd?: string;
@@ -1206,6 +1499,11 @@ function main(): void {
return; return;
} }
if (command === 'check-prerelease-notes') {
verifyPrereleaseNotesMatchVersion(options);
return;
}
if (command === 'docs') { if (command === 'docs') {
generateDocsChangelog(options); generateDocsChangelog(options);
return; return;
+10 -4
View File
@@ -588,9 +588,10 @@ import {
import { buildSubtitleSidebarSourceKey } from './main/runtime/subtitle-prefetch-source'; import { buildSubtitleSidebarSourceKey } from './main/runtime/subtitle-prefetch-source';
import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init'; import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init';
import { import {
createCachedInternalSubtitleTrackExtractor,
loadSubtitleSourceText, loadSubtitleSourceText,
extractInternalSubtitleTrackToTempFile,
} from './main/runtime/internal-subtitle-extraction'; } from './main/runtime/internal-subtitle-extraction';
import { createRemoteMediaPathDetector } from './main/runtime/network-media-path';
import { applyCharacterDictionarySelection } from './main/character-dictionary-selection'; import { applyCharacterDictionarySelection } from './main/character-dictionary-selection';
import { getSubsyncConfig } from './subsync/utils'; import { getSubsyncConfig } from './subsync/utils';
@@ -2054,10 +2055,13 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
} }
}, },
}); });
const cachedInternalSubtitleTrackExtractor = createCachedInternalSubtitleTrackExtractor();
const detectRemoteMediaPath = createRemoteMediaPathDetector();
const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler({ const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler({
getFfmpegPath: () => configService.getConfig().subsync.ffmpeg_path.trim() || 'ffmpeg', getFfmpegPath: () => configService.getConfig().subsync.ffmpeg_path.trim() || 'ffmpeg',
isRemoteMediaPath: detectRemoteMediaPath,
extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) => extractInternalSubtitleTrack: (ffmpegPath, videoPath, track) =>
extractInternalSubtitleTrackToTempFile(ffmpegPath, videoPath, track), cachedInternalSubtitleTrackExtractor.extract(ffmpegPath, videoPath, track),
logDebug: (message) => logger.debug(message), logDebug: (message) => logger.debug(message),
}); });
@@ -2086,8 +2090,8 @@ const refreshSubtitlePrefetchFromActiveTrackHandler =
// Remote media has no extractable on-disk track to fall back to, so a transient // Remote media has no extractable on-disk track to fall back to, so a transient
// resolve miss (sid briefly 'no', a cycle onto an embedded stream track) would // resolve miss (sid briefly 'no', a cycle onto an embedded stream track) would
// otherwise drop a working cue list for the rest of the episode. // otherwise drop a working cue list for the rest of the episode.
shouldKeepExistingCuesOnMissingSource: (videoPath) => shouldKeepExistingCuesOnMissingSource: async (videoPath) =>
isYoutubeMediaPath(videoPath) || isRemoteMediaPath(videoPath), isYoutubeMediaPath(videoPath) || (await detectRemoteMediaPath(videoPath)),
subtitlePrefetchInitController, subtitlePrefetchInitController,
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input), resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
logDebug: (message) => logger.debug(message), logDebug: (message) => logger.debug(message),
@@ -3962,6 +3966,7 @@ const {
appState.yomitanSettingsWindow = null; appState.yomitanSettingsWindow = null;
}, },
stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(), stopJellyfinRemoteSession: () => stopJellyfinRemoteSession(),
cleanupInternalSubtitleTrackCache: () => cachedInternalSubtitleTrackExtractor.clear(),
cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(), cleanupYoutubeSubtitleTempDirs: () => youtubeFlowRuntime.cleanupSubtitleTempDirs(),
cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(), cleanupYoutubeMediaCache: () => youtubeMediaCache.cleanup(),
cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(), cleanupJellyfinSubtitleCache: () => cleanupJellyfinSubtitleCache(),
@@ -4522,6 +4527,7 @@ const {
appState.activeParsedSubtitleMediaPath, appState.activeParsedSubtitleMediaPath,
); );
if ((normalizedPath || null) !== previousPath) { if ((normalizedPath || null) !== previousPath) {
cachedInternalSubtitleTrackExtractor.clear();
secondarySubtitleTrackController.reset(); secondarySubtitleTrackController.reset();
const resetSubtitlePayload = { text: '', tokens: null }; const resetSubtitlePayload = { text: '', tokens: null };
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary; const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
+19 -1
View File
@@ -183,7 +183,10 @@ test('remote media keeps parsed cues when the active subtitle source cannot be r
)?.groups?.body; )?.groups?.body;
assert.ok(actionBlock); assert.ok(actionBlock);
assert.match(actionBlock, /isYoutubeMediaPath\(videoPath\) \|\| isRemoteMediaPath\(videoPath\)/); assert.match(
actionBlock,
/isYoutubeMediaPath\(videoPath\) \|\| \(await detectRemoteMediaPath\(videoPath\)\)/,
);
}); });
test('jellyfin subtitle preload seeds the tokenization prefetch directly', () => { test('jellyfin subtitle preload seeds the tokenization prefetch directly', () => {
@@ -860,3 +863,18 @@ test('subtitle sidebar snapshot prefers cached YouTube parsed cues before active
snapshotBlock.indexOf('resolveActiveSubtitleSidebarSourceHandler'), snapshotBlock.indexOf('resolveActiveSubtitleSidebarSourceHandler'),
); );
}); });
test('main process guards internal subtitle extraction with the remote media detector', () => {
const source = readMainSource();
const resolverWiring = source.match(
/const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler\(\{(?<body>[\s\S]*?)\n\}\);/,
)?.groups?.body;
assert.ok(resolverWiring);
assert.match(source, /const detectRemoteMediaPath = createRemoteMediaPathDetector\(\);/);
assert.match(resolverWiring, /isRemoteMediaPath:\s*detectRemoteMediaPath/);
assert.match(
resolverWiring,
/extractInternalSubtitleTrack:[\s\S]*cachedInternalSubtitleTrackExtractor\.extract/,
);
});
@@ -43,6 +43,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'), destroyYomitanSettingsWindow: () => calls.push('destroy-yomitan-settings-window'),
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'), clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'), cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
@@ -50,10 +51,11 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
}); });
cleanup(); cleanup();
assert.equal(calls.length, 34); assert.equal(calls.length, 35);
assert.equal(calls[0], 'destroy-tray'); assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence'); assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.includes('cleanup-jellyfin-subtitles')); assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
assert.ok(calls.includes('cleanup-internal-subtitles'));
assert.ok(calls.includes('clear-windows-visible-overlay-poll')); assert.ok(calls.includes('clear-windows-visible-overlay-poll'));
assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts')); assert.ok(calls.includes('clear-linux-mpv-fullscreen-overlay-refresh-timeouts'));
assert.ok(calls.includes('cleanup-youtube-subtitles')); assert.ok(calls.includes('cleanup-youtube-subtitles'));
@@ -97,6 +99,7 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
calls.push('stop-jellyfin-remote'); calls.push('stop-jellyfin-remote');
throw new Error('stop failed'); throw new Error('stop failed');
}, },
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'), cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
@@ -104,7 +107,11 @@ test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping
}); });
assert.throws(() => cleanup(), /stop failed/); assert.throws(() => cleanup(), /stop failed/);
assert.deepEqual(calls, ['stop-jellyfin-remote', 'cleanup-jellyfin-subtitles']); assert.deepEqual(calls, [
'stop-jellyfin-remote',
'cleanup-jellyfin-subtitles',
'cleanup-internal-subtitles',
]);
}); });
test('should restore windows on activate requires initialized runtime and no windows', () => { test('should restore windows on activate requires initialized runtime and no windows', () => {
+6 -1
View File
@@ -29,6 +29,7 @@ export function createOnWillQuitCleanupHandler(deps: {
destroyYomitanSettingsWindow: () => void; destroyYomitanSettingsWindow: () => void;
clearYomitanSettingsWindow: () => void; clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void; stopJellyfinRemoteSession: () => void;
cleanupInternalSubtitleTrackCache: () => void;
cleanupYoutubeSubtitleTempDirs: () => void; cleanupYoutubeSubtitleTempDirs: () => void;
cleanupYoutubeMediaCache: () => void; cleanupYoutubeMediaCache: () => void;
cleanupJellyfinSubtitleCache: () => void; cleanupJellyfinSubtitleCache: () => void;
@@ -67,7 +68,11 @@ export function createOnWillQuitCleanupHandler(deps: {
try { try {
deps.stopJellyfinRemoteSession(); deps.stopJellyfinRemoteSession();
} finally { } finally {
deps.cleanupJellyfinSubtitleCache(); try {
deps.cleanupJellyfinSubtitleCache();
} finally {
deps.cleanupInternalSubtitleTrackCache();
}
} }
deps.cleanupYoutubeSubtitleTempDirs(); deps.cleanupYoutubeSubtitleTempDirs();
deps.cleanupYoutubeMediaCache(); deps.cleanupYoutubeMediaCache();
@@ -72,6 +72,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'), clearYomitanSettingsWindow: () => calls.push('clear-yomitan-settings-window'),
stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'), stopJellyfinRemoteSession: () => calls.push('stop-jellyfin-remote'),
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'), cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'), cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'), cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
@@ -95,6 +96,7 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
assert.ok(calls.includes('destroy-first-run-window')); assert.ok(calls.includes('destroy-first-run-window'));
assert.ok(calls.includes('destroy-yomitan-settings-window')); assert.ok(calls.includes('destroy-yomitan-settings-window'));
assert.ok(calls.includes('stop-jellyfin-remote')); assert.ok(calls.includes('stop-jellyfin-remote'));
assert.ok(calls.includes('cleanup-internal-subtitles'));
assert.ok(calls.includes('cleanup-youtube-subtitles')); assert.ok(calls.includes('cleanup-youtube-subtitles'));
assert.ok(calls.includes('cleanup-youtube-media')); assert.ok(calls.includes('cleanup-youtube-media'));
assert.ok(calls.includes('cleanup-jellyfin-subtitles')); assert.ok(calls.includes('cleanup-jellyfin-subtitles'));
@@ -152,6 +154,7 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
getYomitanSettingsWindow: () => null, getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {}, clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {}, stopJellyfinRemoteSession: () => {},
cleanupInternalSubtitleTrackCache: () => {},
cleanupYoutubeSubtitleTempDirs: () => {}, cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {}, cleanupYoutubeMediaCache: () => {},
cleanupJellyfinSubtitleCache: () => {}, cleanupJellyfinSubtitleCache: () => {},
@@ -204,6 +207,7 @@ test('cleanup deps builder skips global shortcut cleanup before app ready', () =
getYomitanSettingsWindow: () => null, getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {}, clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {}, stopJellyfinRemoteSession: () => {},
cleanupInternalSubtitleTrackCache: () => {},
cleanupYoutubeSubtitleTempDirs: () => {}, cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {}, cleanupYoutubeMediaCache: () => {},
cleanupJellyfinSubtitleCache: () => {}, cleanupJellyfinSubtitleCache: () => {},
@@ -58,6 +58,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
clearYomitanSettingsWindow: () => void; clearYomitanSettingsWindow: () => void;
stopJellyfinRemoteSession: () => void; stopJellyfinRemoteSession: () => void;
cleanupInternalSubtitleTrackCache: () => void;
cleanupYoutubeSubtitleTempDirs: () => void; cleanupYoutubeSubtitleTempDirs: () => void;
cleanupYoutubeMediaCache: () => void; cleanupYoutubeMediaCache: () => void;
cleanupJellyfinSubtitleCache: () => void; cleanupJellyfinSubtitleCache: () => void;
@@ -144,6 +145,7 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
}, },
clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(), clearYomitanSettingsWindow: () => deps.clearYomitanSettingsWindow(),
stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(), stopJellyfinRemoteSession: () => deps.stopJellyfinRemoteSession(),
cleanupInternalSubtitleTrackCache: () => deps.cleanupInternalSubtitleTrackCache(),
cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(), cleanupYoutubeSubtitleTempDirs: () => deps.cleanupYoutubeSubtitleTempDirs(),
cleanupYoutubeMediaCache: () => deps.cleanupYoutubeMediaCache(), cleanupYoutubeMediaCache: () => deps.cleanupYoutubeMediaCache(),
cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(), cleanupJellyfinSubtitleCache: () => deps.cleanupJellyfinSubtitleCache(),
@@ -49,6 +49,7 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
getYomitanSettingsWindow: () => null, getYomitanSettingsWindow: () => null,
clearYomitanSettingsWindow: () => {}, clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: async () => {}, stopJellyfinRemoteSession: async () => {},
cleanupInternalSubtitleTrackCache: () => {},
cleanupYoutubeSubtitleTempDirs: () => {}, cleanupYoutubeSubtitleTempDirs: () => {},
cleanupYoutubeMediaCache: () => {}, cleanupYoutubeMediaCache: () => {},
cleanupJellyfinSubtitleCache: () => {}, cleanupJellyfinSubtitleCache: () => {},
@@ -6,6 +6,7 @@ import process from 'node:process';
import test from 'node:test'; import test from 'node:test';
import { import {
buildFfmpegSubtitleExtractionArgs, buildFfmpegSubtitleExtractionArgs,
createCachedInternalSubtitleTrackExtractor,
extractInternalSubtitleTrackToTempFile, extractInternalSubtitleTrackToTempFile,
parseTrackId, parseTrackId,
} from './internal-subtitle-extraction'; } from './internal-subtitle-extraction';
@@ -22,6 +23,65 @@ test('parseTrackId rejects negative track ids', () => {
assert.equal(parseTrackId(' -2 '), null); assert.equal(parseTrackId(' -2 '), null);
}); });
test('cached internal subtitle extraction shares concurrent and repeated track requests', async () => {
let extractionCalls = 0;
let cleanupCalls = 0;
let resolveExtraction:
| ((result: { path: string; cleanup: () => Promise<void> }) => void)
| undefined;
const firstExtraction = new Promise<{ path: string; cleanup: () => Promise<void> }>((resolve) => {
resolveExtraction = resolve;
});
const extractor = createCachedInternalSubtitleTrackExtractor({
extract: async () => {
extractionCalls += 1;
if (extractionCalls === 1) {
return firstExtraction;
}
return {
path: `/tmp/subtitle-${extractionCalls}.ass`,
cleanup: async () => {
cleanupCalls += 1;
},
};
},
});
const request = () =>
extractor.extract('ffmpeg', '/Volumes/media/episode.mkv', {
'ff-index': 3,
codec: 'ass',
});
const concurrent = Array.from({ length: 6 }, request);
assert.equal(extractionCalls, 1);
if (!resolveExtraction) {
throw new Error('extraction did not start');
}
resolveExtraction({
path: '/tmp/subtitle-1.ass',
cleanup: async () => {
cleanupCalls += 1;
},
});
const results = await Promise.all(concurrent);
assert.deepEqual(
results.map((result) => result?.path),
Array.from({ length: 6 }, () => '/tmp/subtitle-1.ass'),
);
await Promise.all(results.map((result) => result?.cleanup()));
assert.equal(cleanupCalls, 0);
assert.equal((await request())?.path, '/tmp/subtitle-1.ass');
assert.equal(extractionCalls, 1);
extractor.clear();
await new Promise((resolve) => setImmediate(resolve));
assert.equal(cleanupCalls, 1);
assert.equal((await request())?.path, '/tmp/subtitle-2.ass');
assert.equal(extractionCalls, 2);
});
test('extractInternalSubtitleTrackToTempFile times out stalled ffmpeg process', async () => { test('extractInternalSubtitleTrackToTempFile times out stalled ffmpeg process', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-ffmpeg-timeout-')); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-ffmpeg-timeout-'));
const videoPath = path.join(root, 'video.mkv'); const videoPath = path.join(root, 'video.mkv');
@@ -35,6 +35,17 @@ export type MpvSubtitleTrackLike = {
'external-filename'?: unknown; 'external-filename'?: unknown;
}; };
export type ExtractedInternalSubtitleTrack = {
path: string;
cleanup: () => Promise<void>;
};
export type InternalSubtitleTrackExtractor = (
ffmpegPath: string,
videoPath: string,
track: MpvSubtitleTrackLike,
) => Promise<ExtractedInternalSubtitleTrack | null>;
const DEFAULT_EXTRACTION_TIMEOUT_MS = 30_000; const DEFAULT_EXTRACTION_TIMEOUT_MS = 30_000;
export function parseTrackId(value: unknown): number | null { export function parseTrackId(value: unknown): number | null {
@@ -80,7 +91,7 @@ export async function extractInternalSubtitleTrackToTempFile(
videoPath: string, videoPath: string,
track: MpvSubtitleTrackLike, track: MpvSubtitleTrackLike,
options: { extractionTimeoutMs?: number; spawnArgsOverride?: string[] } = {}, options: { extractionTimeoutMs?: number; spawnArgsOverride?: string[] } = {},
): Promise<{ path: string; cleanup: () => Promise<void> } | null> { ): Promise<ExtractedInternalSubtitleTrack | null> {
const ffIndex = parseTrackId(track['ff-index']); const ffIndex = parseTrackId(track['ff-index']);
const codec = typeof track.codec === 'string' ? track.codec : null; const codec = typeof track.codec === 'string' ? track.codec : null;
const extension = codecToExtension(codec ?? undefined); const extension = codecToExtension(codec ?? undefined);
@@ -145,3 +156,69 @@ export async function extractInternalSubtitleTrackToTempFile(
}, },
}; };
} }
type CachedExtraction = {
promise: Promise<ExtractedInternalSubtitleTrack | null>;
};
function buildCachedExtractionKey(
ffmpegPath: string,
videoPath: string,
track: MpvSubtitleTrackLike,
): string {
const codec = typeof track.codec === 'string' ? track.codec : null;
return JSON.stringify([ffmpegPath, videoPath, parseTrackId(track['ff-index']), codec]);
}
const releaseCachedExtraction = async (): Promise<void> => {};
/**
* Owns extracted subtitle files for the active media and shares one extraction between callers.
* Caller cleanup releases only its view; clear removes the owned files on media changes or quit.
*/
export function createCachedInternalSubtitleTrackExtractor(
deps: { extract?: InternalSubtitleTrackExtractor } = {},
): {
extract: InternalSubtitleTrackExtractor;
clear: () => void;
} {
const extractTrack = deps.extract ?? extractInternalSubtitleTrackToTempFile;
const extractions = new Map<string, CachedExtraction>();
const extract: InternalSubtitleTrackExtractor = async (ffmpegPath, videoPath, track) => {
const key = buildCachedExtractionKey(ffmpegPath, videoPath, track);
let cached = extractions.get(key);
if (!cached) {
const next: CachedExtraction = {
promise: extractTrack(ffmpegPath, videoPath, track),
};
cached = next;
extractions.set(key, next);
void next.promise.catch(() => {
if (extractions.get(key) === next) {
extractions.delete(key);
}
});
}
const result = await cached.promise;
if (extractions.get(key) !== cached || !result) {
return null;
}
return {
path: result.path,
cleanup: releaseCachedExtraction,
};
};
const clear = (): void => {
const staleExtractions = [...extractions.values()];
extractions.clear();
for (const extraction of staleExtractions) {
void extraction.promise.then((result) => result?.cleanup()).catch(() => undefined);
}
};
return { extract, clear };
}
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createRemoteMediaPathDetector } from './network-media-path';
test('remote media detector recognizes mounted network filesystems', async () => {
const detectRemoteMedia = createRemoteMediaPathDetector({
platform: 'darwin',
readMountOutput: async () =>
[
'/dev/disk3s5 on /System/Volumes/Data (apfs, local, journaled)',
'//viewer@media/jellyfin on /Volumes/jellyfin (smbfs, nodev, nosuid)',
].join('\n'),
});
assert.equal(await detectRemoteMedia('/Volumes/jellyfin/movie.mkv'), true);
assert.equal(await detectRemoteMedia('/Volumes/jellyfin-another/movie.mkv'), false);
assert.equal(await detectRemoteMedia('/Users/viewer/movie.mkv'), false);
});
test('remote media detector recognizes Linux network mount output', async () => {
const detectRemoteMedia = createRemoteMediaPathDetector({
platform: 'linux',
readMountOutput: async () =>
'//media/jellyfin on /mnt/Jellyfin\\040Media type cifs (rw,relatime)',
});
assert.equal(await detectRemoteMedia('/mnt/Jellyfin Media/movie.mkv'), true);
});
test('remote media detector shares its mount lookup between concurrent callers', async () => {
let mountReads = 0;
const detectRemoteMedia = createRemoteMediaPathDetector({
platform: 'darwin',
readMountOutput: async () => {
mountReads += 1;
return '//viewer@media/jellyfin on /Volumes/jellyfin (smbfs, nodev, nosuid)';
},
});
const results = await Promise.all(
Array.from({ length: 6 }, () => detectRemoteMedia('/Volumes/jellyfin/movie.mkv')),
);
assert.deepEqual(
results,
Array.from({ length: 6 }, () => true),
);
assert.equal(mountReads, 1);
});
test('remote media detector recognizes URLs and Windows UNC paths without reading mounts', async () => {
let mountReads = 0;
const detectRemoteMedia = createRemoteMediaPathDetector({
platform: 'win32',
readMountOutput: async () => {
mountReads += 1;
return '';
},
});
assert.equal(await detectRemoteMedia('https://media.example/movie.mkv'), true);
assert.equal(await detectRemoteMedia('\\\\media-server\\jellyfin\\movie.mkv'), true);
assert.equal(mountReads, 0);
});
+142
View File
@@ -0,0 +1,142 @@
import { execFile } from 'node:child_process';
import path from 'node:path';
import process from 'node:process';
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
const DEFAULT_MOUNT_CACHE_TTL_MS = 5_000;
const NETWORK_FILESYSTEM_TYPES = new Set([
'9p',
'afpfs',
'cifs',
'davfs',
'davfs2',
'fuse.sshfs',
'nfs',
'nfs4',
'smbfs',
'sshfs',
'webdav',
]);
function isRemoteUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
function decodeMountPath(value: string): string {
return value.replace(/\\([0-7]{3})/g, (_match, digits: string) =>
String.fromCharCode(Number.parseInt(digits, 8)),
);
}
function parseNetworkMountPaths(output: string): string[] {
const networkMountPaths: string[] = [];
for (const line of output.split('\n')) {
const optionsStart = line.lastIndexOf(' (');
if (optionsStart < 0) continue;
let mountDescription = line.slice(0, optionsStart);
const options = line.slice(optionsStart + 2, line.indexOf(')', optionsStart));
const linuxTypeSeparator = mountDescription.lastIndexOf(' type ');
const filesystemType = (
linuxTypeSeparator >= 0
? mountDescription.slice(linuxTypeSeparator + ' type '.length)
: (options.split(',').at(0) ?? '')
)
.trim()
.toLowerCase();
if (!NETWORK_FILESYSTEM_TYPES.has(filesystemType)) continue;
if (linuxTypeSeparator >= 0) {
mountDescription = mountDescription.slice(0, linuxTypeSeparator);
}
const mountSeparator = mountDescription.indexOf(' on ');
if (mountSeparator < 0) continue;
networkMountPaths.push(
path.posix.normalize(decodeMountPath(mountDescription.slice(mountSeparator + 4).trim())),
);
}
return networkMountPaths;
}
function readMountOutput(platform: NodeJS.Platform): Promise<string> {
if (platform === 'win32') return Promise.resolve('');
const command = platform === 'darwin' ? '/sbin/mount' : 'mount';
return new Promise((resolve, reject) => {
execFile(
command,
[],
{ encoding: 'utf8', timeout: 1_000, maxBuffer: 1024 * 1024 },
(error, stdout) => {
if (error) {
reject(error);
return;
}
resolve(stdout);
},
);
});
}
function isPathWithinMount(filePath: string, mountPath: string): boolean {
const relativePath = path.posix.relative(mountPath, filePath);
return (
relativePath === '' ||
(relativePath !== '..' &&
!relativePath.startsWith(`..${path.posix.sep}`) &&
!path.posix.isAbsolute(relativePath))
);
}
export type RemoteMediaPathDetector = (mediaPath: string) => Promise<boolean>;
export function createRemoteMediaPathDetector(
deps: {
platform?: NodeJS.Platform;
readMountOutput?: () => Promise<string>;
now?: () => number;
mountCacheTtlMs?: number;
} = {},
): RemoteMediaPathDetector {
const platform = deps.platform ?? process.platform;
const getMountOutput = deps.readMountOutput ?? (() => readMountOutput(platform));
const now = deps.now ?? Date.now;
const mountCacheTtlMs = deps.mountCacheTtlMs ?? DEFAULT_MOUNT_CACHE_TTL_MS;
let mountCache: { expiresAt: number; networkMountPaths: Promise<readonly string[]> } | undefined;
const getNetworkMountPaths = (): Promise<readonly string[]> => {
const currentTime = now();
if (mountCache && currentTime < mountCache.expiresAt) {
return mountCache.networkMountPaths;
}
const networkMountPaths = getMountOutput()
.then(parseNetworkMountPaths)
.catch(() => []);
mountCache = {
expiresAt: currentTime + mountCacheTtlMs,
networkMountPaths,
};
return networkMountPaths;
};
return async (mediaPath): Promise<boolean> => {
const source = mediaPath.trim();
if (!source) return false;
if (isRemoteUrl(source)) return true;
const filePath = resolveSubtitleSourcePath(source);
if (platform === 'win32') {
return filePath.startsWith('\\\\');
}
if (!path.posix.isAbsolute(filePath)) return false;
const networkMountPaths = await getNetworkMountPaths();
const normalizedPath = path.posix.normalize(filePath);
return networkMountPaths.some((mountPath) => isPathWithinMount(normalizedPath, mountPath));
};
}
@@ -101,6 +101,32 @@ test('subtitle prefetch runtime preserves parsed cues when YouTube active track
assert.deepEqual(calls, []); assert.deepEqual(calls, []);
}); });
test('subtitle prefetch runtime preserves parsed cues when a network mount source is unresolved', async () => {
const calls: string[] = [];
const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => (name === 'path' ? '/Volumes/jellyfin/movie.mkv' : null),
}),
getLastObservedTimePos: () => 12,
subtitlePrefetchInitController: {
cancelPendingInit: () => {
calls.push('cancel');
},
initSubtitlePrefetch: async () => {
calls.push('init');
},
},
resolveActiveSubtitleSidebarSource: async () => null,
shouldKeepExistingCuesOnMissingSource: async (videoPath) =>
videoPath.startsWith('/Volumes/jellyfin/'),
});
await refresh();
assert.deepEqual(calls, []);
});
test('subtitle prefetch runtime does not extract internal subtitle tracks from remote media urls', async () => { test('subtitle prefetch runtime does not extract internal subtitle tracks from remote media urls', async () => {
let extracted = false; let extracted = false;
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({ const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
@@ -131,6 +157,34 @@ test('subtitle prefetch runtime does not extract internal subtitle tracks from r
assert.equal(extracted, false); assert.equal(extracted, false);
}); });
test('subtitle prefetch runtime does not extract internal subtitle tracks from network mounts', async () => {
let extracted = false;
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
getFfmpegPath: () => 'ffmpeg-custom',
isRemoteMediaPath: async (videoPath) => videoPath.startsWith('/Volumes/jellyfin/'),
extractInternalSubtitleTrack: async () => {
extracted = true;
return null;
},
});
const resolved = await resolveSource({
currentExternalFilenameRaw: null,
currentTrackRaw: {
type: 'sub',
id: 3,
'ff-index': 7,
codec: 'ass',
},
trackListRaw: [],
sidRaw: 3,
videoPath: '/Volumes/jellyfin/movie.mkv',
});
assert.equal(resolved, null);
assert.equal(extracted, false);
});
test('subtitle prefetch refresh logs a warning when source resolution throws', async () => { test('subtitle prefetch refresh logs a warning when source resolution throws', async () => {
const warnings: string[] = []; const warnings: string[] = [];
const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({ const refresh = createRefreshSubtitlePrefetchFromActiveTrackHandler({
@@ -17,6 +17,8 @@ type ActiveSubtitleSidebarSource = {
cleanup?: () => Promise<void>; cleanup?: () => Promise<void>;
}; };
type RemoteMediaPathDetector = (mediaPath: string) => boolean | Promise<boolean>;
function parseTrackId(value: unknown): number | null { function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) { if (typeof value === 'number' && Number.isInteger(value)) {
return value; return value;
@@ -28,7 +30,7 @@ function parseTrackId(value: unknown): number | null {
return null; return null;
} }
function isRemoteMediaPath(value: string): boolean { function isRemoteMediaUrl(value: string): boolean {
try { try {
const url = new URL(value); const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:'; return url.protocol === 'http:' || url.protocol === 'https:';
@@ -86,6 +88,7 @@ function getActiveSubtitleTrack(
export function createResolveActiveSubtitleSidebarSourceHandler(deps: { export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
getFfmpegPath: () => string; getFfmpegPath: () => string;
isRemoteMediaPath?: RemoteMediaPathDetector;
extractInternalSubtitleTrack: ( extractInternalSubtitleTrack: (
ffmpegPath: string, ffmpegPath: string,
videoPath: string, videoPath: string,
@@ -126,7 +129,8 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
return { path: externalFilename, sourceKey: externalFilename }; return { path: externalFilename, sourceKey: externalFilename };
} }
if (isRemoteMediaPath(input.videoPath)) { const isRemoteMediaPath = deps.isRemoteMediaPath ?? isRemoteMediaUrl;
if (await isRemoteMediaPath(input.videoPath)) {
deps.logDebug?.('[subtitle-prefetch] skipping internal subtitle extraction for remote media'); deps.logDebug?.('[subtitle-prefetch] skipping internal subtitle extraction for remote media');
return null; return null;
} }
@@ -156,7 +160,7 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
requestProperty: (name: string) => Promise<unknown>; requestProperty: (name: string) => Promise<unknown>;
} | null; } | null;
getLastObservedTimePos: () => number; getLastObservedTimePos: () => number;
shouldKeepExistingCuesOnMissingSource?: (videoPath: string) => boolean; shouldKeepExistingCuesOnMissingSource?: (videoPath: string) => boolean | Promise<boolean>;
subtitlePrefetchInitController: SubtitlePrefetchInitController; subtitlePrefetchInitController: SubtitlePrefetchInitController;
resolveActiveSubtitleSidebarSource: ( resolveActiveSubtitleSidebarSource: (
input: Parameters<ReturnType<typeof createResolveActiveSubtitleSidebarSourceHandler>>[0], input: Parameters<ReturnType<typeof createResolveActiveSubtitleSidebarSourceHandler>>[0],
@@ -195,7 +199,7 @@ export function createRefreshSubtitlePrefetchFromActiveTrackHandler(deps: {
videoPath, videoPath,
}); });
if (!resolvedSource) { if (!resolvedSource) {
if (deps.shouldKeepExistingCuesOnMissingSource?.(videoPath) === true) { if ((await deps.shouldKeepExistingCuesOnMissingSource?.(videoPath)) === true) {
deps.logDebug?.( deps.logDebug?.(
'[subtitle-prefetch] no active subtitle source resolved; keeping existing cues', '[subtitle-prefetch] no active subtitle source resolved; keeping existing cues',
); );
+38
View File
@@ -2,9 +2,17 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import {
jobSteps,
readWorkflow,
stepRunsCommand,
stepsMissingEnvDeclaration,
templateExpressionsInRunBodies,
} from './workflow-test-helpers';
const prereleaseWorkflowPath = resolve(__dirname, '../.github/workflows/prerelease.yml'); const prereleaseWorkflowPath = resolve(__dirname, '../.github/workflows/prerelease.yml');
const prereleaseWorkflow = readFileSync(prereleaseWorkflowPath, 'utf8').replace(/\r\n/g, '\n'); const prereleaseWorkflow = readFileSync(prereleaseWorkflowPath, 'utf8').replace(/\r\n/g, '\n');
const parsedPrereleaseWorkflow = readWorkflow(prereleaseWorkflowPath);
const packageJsonPath = resolve(__dirname, '../package.json'); const packageJsonPath = resolve(__dirname, '../package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
scripts: Record<string, string>; scripts: Record<string, string>;
@@ -122,3 +130,33 @@ test('prerelease workflow does not publish to AUR', () => {
assert.doesNotMatch(prereleaseWorkflow, /AUR_SSH_PRIVATE_KEY/); assert.doesNotMatch(prereleaseWorkflow, /AUR_SSH_PRIVATE_KEY/);
assert.doesNotMatch(prereleaseWorkflow, /scripts\/update-aur-package\.sh/); assert.doesNotMatch(prereleaseWorkflow, /scripts\/update-aur-package\.sh/);
}); });
test('prerelease workflow rejects committed notes generated for a different beta or rc', () => {
assert.equal(
packageJson.scripts['changelog:check-prerelease-notes'],
'bun run scripts/build-changelog.ts check-prerelease-notes',
);
// Matched at command positions only, so commenting the check out or quoting it
// inside an echo fails the test rather than silently satisfying it.
const steps = jobSteps(parsedPrereleaseWorkflow, 'release');
const checkIndex = steps.findIndex((step) =>
stepRunsCommand(
step,
/^bun run changelog:check-prerelease-notes --version "\$RELEASE_VERSION"/,
),
);
const publishIndex = steps.findIndex((step) =>
stepRunsCommand(step, /^gh release (create|edit)\b/),
);
assert.notEqual(checkIndex, -1);
assert.notEqual(publishIndex, -1);
// Stale notes are already published if the check runs after the release.
assert.ok(checkIndex < publishIndex);
});
test('prerelease workflow keeps tag-derived values out of shell bodies', () => {
assert.deepEqual(templateExpressionsInRunBodies(parsedPrereleaseWorkflow), []);
assert.deepEqual(stepsMissingEnvDeclaration(parsedPrereleaseWorkflow, 'RELEASE_VERSION'), []);
});
+19 -1
View File
@@ -2,11 +2,18 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import {
readWorkflow,
stepsMissingEnvDeclaration,
templateExpressionsInRunBodies,
} from './workflow-test-helpers';
const releaseWorkflowPath = resolve(__dirname, '../.github/workflows/release.yml'); const releaseWorkflowPath = resolve(__dirname, '../.github/workflows/release.yml');
const releaseWorkflow = readFileSync(releaseWorkflowPath, 'utf8'); const releaseWorkflow = readFileSync(releaseWorkflowPath, 'utf8');
const docsPagesWorkflowPath = resolve(__dirname, '../.github/workflows/docs-pages.yml'); const docsPagesWorkflowPath = resolve(__dirname, '../.github/workflows/docs-pages.yml');
const docsPagesWorkflow = readFileSync(docsPagesWorkflowPath, 'utf8'); const docsPagesWorkflow = readFileSync(docsPagesWorkflowPath, 'utf8');
const parsedReleaseWorkflow = readWorkflow(releaseWorkflowPath);
const parsedDocsPagesWorkflow = readWorkflow(docsPagesWorkflowPath);
const makefilePath = resolve(__dirname, '../Makefile'); const makefilePath = resolve(__dirname, '../Makefile');
const makefile = readFileSync(makefilePath, 'utf8'); const makefile = readFileSync(makefilePath, 'utf8');
const packageJsonPath = resolve(__dirname, '../package.json'); const packageJsonPath = resolve(__dirname, '../package.json');
@@ -249,7 +256,7 @@ test('release workflow publishes subminer-bin to AUR from tagged release artifac
releaseWorkflow, releaseWorkflow,
/cp packaging\/aur\/subminer-bin\/\.SRCINFO aur-subminer-bin\/\.SRCINFO/, /cp packaging\/aur\/subminer-bin\/\.SRCINFO aur-subminer-bin\/\.SRCINFO/,
); );
assert.match(releaseWorkflow, /version_no_v="\$\{\{ steps\.version\.outputs\.VERSION \}\}"/); assert.match(releaseWorkflow, /version_no_v="\$RELEASE_VERSION"/);
assert.match(releaseWorkflow, /SubMiner-\$\{version_no_v\}\.AppImage/); assert.match(releaseWorkflow, /SubMiner-\$\{version_no_v\}\.AppImage/);
assert.doesNotMatch( assert.doesNotMatch(
releaseWorkflow, releaseWorkflow,
@@ -278,3 +285,14 @@ test('Makefile uninstall targets remove bundled runtime plugin app-data copies',
assert.match(makefile, /Removed:[\s\S]*\$\(LINUX_DATA_DIR\)\/plugin\/subminer/); assert.match(makefile, /Removed:[\s\S]*\$\(LINUX_DATA_DIR\)\/plugin\/subminer/);
assert.match(makefile, /Removed:[\s\S]*\$\(MACOS_DATA_DIR\)\/plugin\/subminer/); assert.match(makefile, /Removed:[\s\S]*\$\(MACOS_DATA_DIR\)\/plugin\/subminer/);
}); });
test('release and docs workflows keep tag-derived values out of shell bodies', () => {
assert.deepEqual(templateExpressionsInRunBodies(parsedReleaseWorkflow), []);
assert.deepEqual(templateExpressionsInRunBodies(parsedDocsPagesWorkflow), []);
assert.deepEqual(stepsMissingEnvDeclaration(parsedReleaseWorkflow, 'RELEASE_VERSION'), []);
assert.deepEqual(stepsMissingEnvDeclaration(parsedDocsPagesWorkflow, 'TAG_NAME'), []);
// The docs tag guard must test the shell variable, not an interpolated value
// that would be substituted into the condition before the shell reads it.
assert.match(docsPagesWorkflow, /if \[\[ ! "\$TAG_NAME" =~/);
});
-4
View File
@@ -1928,10 +1928,6 @@ body.layer-modal #overlay {
text-align: center; text-align: center;
font-size: 24px; font-size: 24px;
line-height: 1.5; line-height: 1.5;
/* Backstop: pathological tracks (karaoke typesetting, sign spam) must never grow
the hover-pause band beyond a top strip. ~4 lines at line-height 1.5. */
max-height: 6em;
overflow: hidden;
color: #ffffff; color: #ffffff;
-webkit-text-stroke: 0.45px rgba(0, 0, 0, 0.7); -webkit-text-stroke: 0.45px rgba(0, 0, 0, 0.7);
paint-order: stroke fill; paint-order: stroke fill;
+19 -9
View File
@@ -1424,11 +1424,8 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
); );
}); });
test('prepareSecondarySubtitleLines preserves short stacks without layer metadata', () => { test('prepareSecondarySubtitleLines collapses exact short copies in stacks', () => {
assert.deepEqual(prepareSecondarySubtitleLines('Your\\NYour\\NYour\\NYour\\Nmosaic'), [ assert.deepEqual(prepareSecondarySubtitleLines('Your\\NYour\\NYour\\NYour\\Nmosaic'), [
'Your',
'Your',
'Your',
'Your', 'Your',
'mosaic', 'mosaic',
]); ]);
@@ -1438,6 +1435,15 @@ test('prepareSecondarySubtitleLines preserves short stacks without layer metadat
]); ]);
}); });
test('prepareSecondarySubtitleLines collapses exact short sign copies beside dialogue', () => {
const liveText = "And for today's sports festival...\nEntrance\nEntrance";
assert.deepEqual(prepareSecondarySubtitleLines(liveText), [
"And for today's sports festival...",
'Entrance',
]);
});
test('prepareSecondarySubtitleLines collapses karaoke syllable spam into one deduped line', () => { test('prepareSecondarySubtitleLines collapses karaoke syllable spam into one deduped line', () => {
// Karaoke-typeset OP/ED: one ASS event per syllable, duplicated across layers, // Karaoke-typeset OP/ED: one ASS event per syllable, duplicated across layers,
// joined with \N by mpv's secondary-sub-text. // joined with \N by mpv's secondary-sub-text.
@@ -1448,10 +1454,10 @@ test('prepareSecondarySubtitleLines collapses karaoke syllable spam into one ded
assert.deepEqual(prepareSecondarySubtitleLines(karaoke), ['ya This no ma ups']); assert.deepEqual(prepareSecondarySubtitleLines(karaoke), ['ya This no ma ups']);
}); });
test('prepareSecondarySubtitleLines preserves repeated short dialogue without layer metadata', () => { test('prepareSecondarySubtitleLines collapses exact repeated short lines', () => {
const dialogue = ['Wait', 'Wait', 'Wait']; const dialogue = ['Wait', 'Wait', 'Wait'];
assert.deepEqual(prepareSecondarySubtitleLines(dialogue.join('\\N')), dialogue); assert.deepEqual(prepareSecondarySubtitleLines(dialogue.join('\\N')), ['Wait']);
}); });
test('prepareSecondarySubtitleLines collapses punctuation variants of a full-sentence fallback', () => { test('prepareSecondarySubtitleLines collapses punctuation variants of a full-sentence fallback', () => {
@@ -1469,6 +1475,10 @@ test('prepareSecondarySubtitleLines preserves short simultaneous dialogue withou
assert.deepEqual(prepareSecondarySubtitleLines(dialogue.join('\\N')), dialogue); assert.deepEqual(prepareSecondarySubtitleLines(dialogue.join('\\N')), dialogue);
}); });
test('prepareSecondarySubtitleLines preserves distinct short lines with internal whitespace', () => {
assert.deepEqual(prepareSecondarySubtitleLines('AB\\NA B'), ['AB', 'A B']);
});
test('prepareSecondarySubtitleLines keeps normal dialogue lines intact', () => { test('prepareSecondarySubtitleLines keeps normal dialogue lines intact', () => {
const dialogue = ' I never expected this. \\N\\N But here we are. '; const dialogue = ' I never expected this. \\N\\N But here we are. ';
@@ -1490,13 +1500,13 @@ test('prepareSecondarySubtitleLines strips ASS override tags and handles empty i
assert.deepEqual(prepareSecondarySubtitleLines('{\\an8}'), []); assert.deepEqual(prepareSecondarySubtitleLines('{\\an8}'), []);
}); });
test('secondary subtitle root CSS caps height so hover-pause band stays a top strip', () => { test('secondary subtitle root CSS does not clip long subtitle stacks', () => {
const srcCssPath = path.join(process.cwd(), 'src', 'renderer', 'style.css'); const srcCssPath = path.join(process.cwd(), 'src', 'renderer', 'style.css');
const cssText = fs.readFileSync(srcCssPath, 'utf-8'); const cssText = fs.readFileSync(srcCssPath, 'utf-8');
const secondaryRootBlock = extractClassBlock(cssText, '#secondarySubRoot'); const secondaryRootBlock = extractClassBlock(cssText, '#secondarySubRoot');
assert.match(secondaryRootBlock, /max-height:\s*6em;/); assert.doesNotMatch(secondaryRootBlock, /max-height\s*:/);
assert.match(secondaryRootBlock, /overflow:\s*hidden;/); assert.doesNotMatch(secondaryRootBlock, /overflow\s*:\s*hidden/);
}); });
test('applySubtitleStyle sets known-word maturity color variables', () => { test('applySubtitleStyle sets known-word maturity color variables', () => {
+10 -5
View File
@@ -667,12 +667,17 @@ function isKaraokeLikeLineSet(lines: string[]): boolean {
} }
function collapseFullLineFallbackCopies(lines: string[]): string[] { function collapseFullLineFallbackCopies(lines: string[]): string[] {
const seen = new Set<string>(); const seenExact = new Set<string>();
const seenFlattened = new Set<string>();
return lines.filter((line) => { return lines.filter((line) => {
const identity = flattenedSecondarySubtitleLineIdentity(line); const exactIdentity = line.normalize('NFKC');
if (!identity) return true; if (seenExact.has(exactIdentity)) return false;
if (seen.has(identity)) return false; seenExact.add(exactIdentity);
seen.add(identity);
const flattenedIdentity = flattenedSecondarySubtitleLineIdentity(line);
if (!flattenedIdentity) return true;
if (seenFlattened.has(flattenedIdentity)) return false;
seenFlattened.add(flattenedIdentity);
return true; return true;
}); });
} }
+97
View File
@@ -0,0 +1,97 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
commandPositions,
executableRunLines,
stepRunsCommand,
stepsMissingEnvDeclaration,
templateExpressionsInRunBodies,
} from './workflow-test-helpers';
const runs = (run: string): boolean => stepRunsCommand({ run }, /^bun run verify --flag "\$VALUE"/);
test('stepRunsCommand matches a command that actually executes', () => {
assert.equal(runs('bun run verify --flag "$VALUE"'), true);
assert.equal(runs('if ! bun run verify --flag "$VALUE"; then\nexit 1\nfi'), true);
assert.equal(runs('set -e && bun run verify --flag "$VALUE"'), true);
assert.equal(runs(' bun run verify --flag "$VALUE" || exit 1'), true);
});
test('stepRunsCommand rejects commands that are only mentioned, not run', () => {
assert.equal(runs('# bun run verify --flag "$VALUE"'), false);
assert.equal(runs('echo \'bun run verify --flag "$VALUE"\''), false);
assert.equal(runs("printf '%s\\n' 'bun run verify --flag \"$VALUE\"'"), false);
assert.equal(runs('echo "run: bun run verify --flag \\"$VALUE\\"" >> notes.txt'), false);
// A different argument list is a different command.
assert.equal(runs('bun run verify'), false);
});
test('stepRunsCommand ignores separators inside quotes and inline comments', () => {
assert.equal(runs('echo \'note; bun run verify --flag "$VALUE"\''), false);
assert.equal(runs('echo "note && bun run verify --flag \\"$VALUE\\""'), false);
assert.equal(runs("printf '%s\\n' 'a | bun run verify --flag \"$VALUE\"'"), false);
assert.equal(runs('if false; then # bun run verify --flag "$VALUE"'), false);
// A trailing comment does not hide the command in front of it.
assert.equal(runs('bun run verify --flag "$VALUE" # keep this'), true);
// A pipe is a real separator; a redirect is not.
assert.equal(runs('cat notes | bun run verify --flag "$VALUE"'), true);
assert.equal(stepRunsCommand({ run: 'gh release view "$V" 2>&1 | tee log' }, /^tee\b/), true);
});
test('stepRunsCommand treats backslash-escaped separators as literal text', () => {
assert.equal(runs(String.raw`echo foo \; bun run verify --flag "$VALUE"`), false);
assert.equal(runs(String.raw`echo foo \| bun run verify --flag "$VALUE"`), false);
assert.equal(runs(String.raw`find . -exec bun run verify --flag "$VALUE" \;`), false);
// An escape does not swallow a following real separator.
assert.equal(runs(String.raw`echo a\b; bun run verify --flag "$VALUE"`), true);
});
test('commandPositions splits on separators and strips control-flow prefixes', () => {
assert.deepEqual(
commandPositions({ run: 'if gh release view "$V"; then\ngh release edit "$V"\nfi' }),
['gh release view "$V"', 'then', 'gh release edit "$V"', 'fi'],
);
});
test('executableRunLines drops blank and comment-only lines', () => {
assert.deepEqual(executableRunLines({ run: '\n# a comment\n \nreal command\n' }), [
'real command',
]);
});
test('templateExpressionsInRunBodies reports every expression spelling in a run body', () => {
const workflow = {
jobs: {
release: {
steps: [
{ name: 'Safe', env: { V: '${{ steps.version.outputs.VERSION }}' }, run: 'echo "$V"' },
{ name: 'Dotted', run: 'echo "${{ steps.version.outputs.VERSION }}"' },
{ name: 'Bracketed', run: 'echo "${{ steps.version.outputs[\'VERSION\'] }}"' },
{ name: 'Github', run: 'echo "${{ github[\'ref_name\'] }}"' },
],
},
},
};
assert.deepEqual(templateExpressionsInRunBodies(workflow), [
'release/Dotted: ${{ steps.version.outputs.VERSION }}',
"release/Bracketed: ${{ steps.version.outputs['VERSION'] }}",
"release/Github: ${{ github['ref_name'] }}",
]);
});
test('stepsMissingEnvDeclaration finds shell reads with no matching env entry', () => {
const workflow = {
jobs: {
release: {
steps: [
{ name: 'Declared', env: { TAG: 'x' }, run: 'echo "$TAG"' },
{ name: 'Undeclared', run: 'echo "${TAG}"' },
{ name: 'Unrelated', run: 'echo "$TAGGED"' },
],
},
},
};
assert.deepEqual(stepsMissingEnvDeclaration(workflow, 'TAG'), ['release/Undeclared']);
});
+169
View File
@@ -0,0 +1,169 @@
import { readFileSync } from 'node:fs';
export type WorkflowStep = {
name?: string;
run?: string;
env?: Record<string, unknown>;
};
export type ParsedWorkflow = {
jobs?: Record<string, { steps?: WorkflowStep[] } | undefined>;
};
// Workflow tests only ever run under `bun test`, which parses YAML natively.
function parseWorkflowYaml(source: string): ParsedWorkflow {
const bunRuntime = globalThis as typeof globalThis & {
Bun?: { YAML?: { parse?: (input: string) => unknown } };
};
const parse = bunRuntime.Bun?.YAML?.parse;
if (!parse) {
throw new Error('Bun.YAML.parse is unavailable; workflow tests must run under bun.');
}
return parse(source) as ParsedWorkflow;
}
export function readWorkflow(workflowPath: string): ParsedWorkflow {
return parseWorkflowYaml(readFileSync(workflowPath, 'utf8'));
}
// Steps of one job, in declaration order. Throws on an unknown job so a renamed
// job fails loudly instead of silently emptying an ordering assertion.
export function jobSteps(workflow: ParsedWorkflow, jobName: string): WorkflowStep[] {
const job = workflow.jobs?.[jobName];
if (!job) {
throw new Error(`Workflow has no job named ${jobName}.`);
}
return job.steps ?? [];
}
function allSteps(workflow: ParsedWorkflow): Array<{ job: string; step: WorkflowStep }> {
return Object.entries(workflow.jobs ?? {}).flatMap(([job, definition]) =>
(definition?.steps ?? []).map((step) => ({ job, step })),
);
}
// Lines of a step's shell body that actually execute. Comments are dropped so a
// commented-out command cannot satisfy a "this step runs X" assertion.
export function executableRunLines(step: WorkflowStep): string[] {
return (typeof step.run === 'string' ? step.run.split('\n') : [])
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'));
}
// Leading shell keywords and operators that can precede a real command.
const COMMAND_PREFIX = /^(?:if|elif|while|until|then|else|do|!|&&|\|\||\(|\{)\s+/;
// Splits one shell line on command separators, tracking quotes so a separator
// inside a string is not treated as a command break, and stopping at an
// unquoted inline comment.
function splitCommandSeparators(line: string): string[] {
const segments: string[] = [];
let current = '';
let quote: "'" | '"' | null = null;
for (let index = 0; index < line.length; index += 1) {
const char = line[index]!;
if (quote) {
current += char;
if (char === '\\' && quote === '"' && index + 1 < line.length) {
current += line[index + 1]!;
index += 1;
} else if (char === quote) {
quote = null;
}
continue;
}
// An unquoted backslash escapes the next character, so `\;` is literal text
// rather than a separator. Checked before comments and separators.
if (char === '\\' && index + 1 < line.length) {
current += char + line[index + 1]!;
index += 1;
continue;
}
if (char === "'" || char === '"') {
quote = char;
current += char;
continue;
}
// An unquoted # starts a comment when it opens a word; the rest is inert.
if (char === '#' && (current === '' || /\s$/.test(current))) {
break;
}
const next = line[index + 1];
if (char === ';') {
segments.push(current);
current = '';
continue;
}
if ((char === '&' || char === '|') && next === char) {
segments.push(current);
current = '';
index += 1;
continue;
}
// A lone pipe separates commands; a redirect such as 2>&1 does not.
if (char === '|' && !/[0-9<>&]$/.test(current)) {
segments.push(current);
current = '';
continue;
}
current += char;
}
segments.push(current);
return segments;
}
// Command positions within a step's shell body: each line split on separators,
// with control-flow prefixes stripped. A pattern anchored with ^ therefore
// matches only where a command actually starts, so text quoted inside an
// `echo`/`printf` argument is not mistaken for the command running.
export function commandPositions(step: WorkflowStep): string[] {
return executableRunLines(step).flatMap((line) =>
splitCommandSeparators(line)
.map((segment) => {
let candidate = segment.trim();
let stripped = candidate.replace(COMMAND_PREFIX, '');
while (stripped !== candidate) {
candidate = stripped;
stripped = candidate.replace(COMMAND_PREFIX, '');
}
return candidate;
})
.filter(Boolean),
);
}
// Whether a step actually executes a command matching the pattern. Anchor the
// pattern with ^ so it has to match at a command position.
export function stepRunsCommand(step: WorkflowStep, pattern: RegExp): boolean {
return commandPositions(step).some((position) => pattern.test(position));
}
// GitHub substitutes ${{ }} into a run script before the shell parses it, so any
// value used that way is executed as script rather than read as data. Reporting
// every expression (rather than allow-listing known-safe ones) also covers
// alternate spellings such as ${{ steps.version.outputs['VERSION'] }}.
export function templateExpressionsInRunBodies(workflow: ParsedWorkflow): string[] {
return allSteps(workflow).flatMap(({ job, step }) =>
(typeof step.run === 'string' ? (step.run.match(/\$\{\{[\s\S]*?\}\}/g) ?? []) : []).map(
(expression) => `${job}/${step.name ?? '<unnamed>'}: ${expression}`,
),
);
}
// Steps whose shell body reads $NAME without the step declaring it in env, which
// would silently expand to an empty string at run time.
export function stepsMissingEnvDeclaration(workflow: ParsedWorkflow, name: string): string[] {
const reference = new RegExp(`\\$${name}\\b|\\$\\{${name}\\b`);
return allSteps(workflow)
.filter(({ step }) => typeof step.run === 'string' && reference.test(step.run))
.filter(({ step }) => !Object.prototype.hasOwnProperty.call(step.env ?? {}, name))
.map(({ job, step }) => `${job}/${step.name ?? '<unnamed>'}`);
}