Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed7d3f4c3d | ||
|
|
509dc5bf7f | ||
|
|
0a0aa3ec98
|
||
|
|
b87cc3cfdd
|
||
|
|
8cb3c8c90a
|
||
|
|
03ea903927
|
||
|
|
3aea42e6f8
|
||
|
|
88bb3edfa4
|
||
|
|
9445aef004
|
||
|
|
c01bcd9d0f
|
||
|
|
695613b1e7
|
||
|
|
20797772f8 | ||
|
|
72c312810a
|
||
|
|
b61f050e78
|
||
|
|
2a77ba9dd4 |
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
name: subminer-release
|
||||||
|
description: Prepare, cut, publish, or repair SubMiner stable and prerelease releases. Use for hands-on release work; do not use for general release questions.
|
||||||
|
---
|
||||||
|
|
||||||
|
# SubMiner release
|
||||||
|
|
||||||
|
Carry out the requested release phase using the repository's current release process.
|
||||||
|
|
||||||
|
## Source of truth
|
||||||
|
|
||||||
|
Read `docs/RELEASING.md` completely before changing files or release state. Treat it as canonical. Read `changes/README.md` when the work touches change fragments or generated release notes.
|
||||||
|
|
||||||
|
Do not copy release commands or policy into this skill. If this skill disagrees with the release guide, follow the guide and reconcile the skill before handoff.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Identify whether the request is for a stable release, prerelease, release preparation, publication, or repair.
|
||||||
|
2. Inspect the current branch, worktree status, package version, pending change fragments, relevant tags, and latest CI state before making changes.
|
||||||
|
3. Follow the matching procedure in `docs/RELEASING.md` in order. Review generated changelog and release-note Markdown before it can be committed or published.
|
||||||
|
4. Run every required gate for the requested release phase. Do not treat a cheaper test lane as a substitute for the documented release gate.
|
||||||
|
5. Before a stable tag, confirm the package and tag versions match and no pending `changes/*.md` fragments remain. Preserve fragments for prereleases as documented.
|
||||||
|
6. Report the resulting version, completed checks, local commit and tag state, remote publication state, skipped platform checks, and any remaining manual work.
|
||||||
|
|
||||||
|
## Authorization boundaries
|
||||||
|
|
||||||
|
- A request to prepare a release stops before commit, tag, push, or remote publication unless the user also authorizes those actions.
|
||||||
|
- A clear request to cut or publish a release includes the documented commit, tag, and push steps. Ask before the first remote mutation when the wording is ambiguous.
|
||||||
|
- Do not edit an existing GitHub release, publish to the AUR, change secrets, or alter signing configuration unless the user explicitly requests that operation.
|
||||||
|
- Do not switch branches without consent.
|
||||||
|
|
||||||
|
## Stop conditions
|
||||||
|
|
||||||
|
Stop and report the blocker when required CI or a release gate fails, authentication is missing, versions disagree, required artifacts are absent, or the worktree contains unexpected changes that overlap the release. Do not tag or publish a partially verified release.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -274,7 +274,8 @@ jobs:
|
|||||||
config.example.jsonc \
|
config.example.jsonc \
|
||||||
plugin/subminer \
|
plugin/subminer \
|
||||||
plugin/subminer.conf \
|
plugin/subminer.conf \
|
||||||
assets/themes/subminer.rasi
|
assets/themes/subminer.rasi \
|
||||||
|
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer
|
||||||
|
|
||||||
- name: Generate checksums
|
- name: Generate checksums
|
||||||
run: |
|
run: |
|
||||||
@@ -296,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
|
||||||
|
|
||||||
@@ -326,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
|
||||||
|
|||||||
@@ -273,7 +273,8 @@ jobs:
|
|||||||
config.example.jsonc \
|
config.example.jsonc \
|
||||||
plugin/subminer \
|
plugin/subminer \
|
||||||
plugin/subminer.conf \
|
plugin/subminer.conf \
|
||||||
assets/themes/subminer.rasi
|
assets/themes/subminer.rasi \
|
||||||
|
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer
|
||||||
|
|
||||||
- name: Generate checksums
|
- name: Generate checksums
|
||||||
run: |
|
run: |
|
||||||
@@ -295,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
|
||||||
|
|
||||||
@@ -344,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:
|
||||||
@@ -420,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 \
|
||||||
@@ -432,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"
|
||||||
@@ -450,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
|
||||||
@@ -459,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
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ tests/*
|
|||||||
!.agents/skills/
|
!.agents/skills/
|
||||||
.agents/skills/*
|
.agents/skills/*
|
||||||
!.agents/skills/subminer-change-verification/
|
!.agents/skills/subminer-change-verification/
|
||||||
|
!.agents/skills/subminer-release/
|
||||||
!.agents/skills/subminer-scrum-master/
|
!.agents/skills/subminer-scrum-master/
|
||||||
.agents/skills/subminer-change-verification/*
|
.agents/skills/subminer-change-verification/*
|
||||||
!.agents/skills/subminer-change-verification/SKILL.md
|
!.agents/skills/subminer-change-verification/SKILL.md
|
||||||
@@ -56,6 +57,8 @@ tests/*
|
|||||||
.agents/skills/subminer-change-verification/scripts/*
|
.agents/skills/subminer-change-verification/scripts/*
|
||||||
!.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
|
!.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
|
||||||
!.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
|
!.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
|
||||||
|
.agents/skills/subminer-release/*
|
||||||
|
!.agents/skills/subminer-release/SKILL.md
|
||||||
.agents/skills/subminer-scrum-master/*
|
.agents/skills/subminer-scrum-master/*
|
||||||
!.agents/skills/subminer-scrum-master/SKILL.md
|
!.agents/skills/subminer-scrum-master/SKILL.md
|
||||||
favicon.png
|
favicon.png
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
APP_NAME := subminer
|
APP_NAME := subminer
|
||||||
THEME_SOURCE := assets/themes/subminer.rasi
|
THEME_SOURCE := assets/themes/subminer.rasi
|
||||||
|
THUMBNAILER_SOURCE := assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer
|
||||||
LAUNCHER_OUT := dist/launcher/$(APP_NAME)
|
LAUNCHER_OUT := dist/launcher/$(APP_NAME)
|
||||||
THEME_FILE := subminer.rasi
|
THEME_FILE := subminer.rasi
|
||||||
|
THUMBNAILER_FILE := subminer-ffmpegthumbnailer.thumbnailer
|
||||||
|
|
||||||
# Default install prefix for the wrapper script.
|
# Default install prefix for the wrapper script.
|
||||||
PREFIX ?= $(HOME)/.local
|
PREFIX ?= $(HOME)/.local
|
||||||
@@ -221,11 +223,13 @@ docs-dev: ensure-bun
|
|||||||
|
|
||||||
|
|
||||||
install-linux: build-launcher
|
install-linux: build-launcher
|
||||||
@printf '%s\n' "[INFO] Installing Linux wrapper/theme artifacts"
|
@printf '%s\n' "[INFO] Installing Linux wrapper/support artifacts"
|
||||||
@install -d "$(BINDIR)"
|
@install -d "$(BINDIR)"
|
||||||
@install -m 0755 "$(LAUNCHER_OUT)" "$(BINDIR)/$(APP_NAME)"
|
@install -m 0755 "$(LAUNCHER_OUT)" "$(BINDIR)/$(APP_NAME)"
|
||||||
@install -d "$(LINUX_DATA_DIR)/themes"
|
@install -d "$(LINUX_DATA_DIR)/themes"
|
||||||
@install -m 0644 "./$(THEME_SOURCE)" "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
|
@install -m 0644 "./$(THEME_SOURCE)" "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
|
||||||
|
@install -d "$(LINUX_DATA_DIR)/thumbnailers"
|
||||||
|
@install -m 0644 "./$(THUMBNAILER_SOURCE)" "$(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)"
|
||||||
@install -d "$(LINUX_DATA_DIR)/plugin/subminer"
|
@install -d "$(LINUX_DATA_DIR)/plugin/subminer"
|
||||||
@cp -R ./plugin/subminer/. "$(LINUX_DATA_DIR)/plugin/subminer/"
|
@cp -R ./plugin/subminer/. "$(LINUX_DATA_DIR)/plugin/subminer/"
|
||||||
@if [ -n "$(APPIMAGE_SRC)" ]; then \
|
@if [ -n "$(APPIMAGE_SRC)" ]; then \
|
||||||
@@ -234,7 +238,7 @@ install-linux: build-launcher
|
|||||||
printf '%s\n' "[WARN] No release/SubMiner-*.AppImage found; skipping AppImage install"; \
|
printf '%s\n' "[WARN] No release/SubMiner-*.AppImage found; skipping AppImage install"; \
|
||||||
printf '%s\n' " Build one with: make build"; \
|
printf '%s\n' " Build one with: make build"; \
|
||||||
fi
|
fi
|
||||||
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
|
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)"
|
||||||
|
|
||||||
install-macos: build-launcher
|
install-macos: build-launcher
|
||||||
@printf '%s\n' "[INFO] Installing macOS wrapper/theme/app artifacts"
|
@printf '%s\n' "[INFO] Installing macOS wrapper/theme/app artifacts"
|
||||||
@@ -275,8 +279,9 @@ uninstall:
|
|||||||
uninstall-linux:
|
uninstall-linux:
|
||||||
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
|
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
|
||||||
@rm -f "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
|
@rm -f "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
|
||||||
|
@rm -f "$(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)"
|
||||||
@rm -rf "$(LINUX_DATA_DIR)/plugin/subminer"
|
@rm -rf "$(LINUX_DATA_DIR)/plugin/subminer"
|
||||||
@printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(BINDIR)/SubMiner.AppImage" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/plugin/subminer"
|
@printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(BINDIR)/SubMiner.AppImage" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)" " $(LINUX_DATA_DIR)/plugin/subminer"
|
||||||
|
|
||||||
uninstall-macos:
|
uninstall-macos:
|
||||||
@rm -f "$(BINDIR)/subminer"
|
@rm -f "$(BINDIR)/subminer"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersio
|
|||||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||||
[](https://www.typescriptlang.org)
|
[](https://www.typescriptlang.org)
|
||||||
|
|
||||||
[](https://github.com/user-attachments/assets/89e61895-e2b7-4b47-8d50-a35afe4132b2)
|
[](https://github.com/user-attachments/assets/7abab8a9-4e4e-4f06-9f3c-9783e15a3807)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 23 MiB |
|
Before Width: | Height: | Size: 303 KiB |
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 3.8 MiB |
@@ -0,0 +1,4 @@
|
|||||||
|
[Thumbnailer Entry]
|
||||||
|
TryExec=ffmpegthumbnailer
|
||||||
|
Exec=ffmpegthumbnailer -i %i -o %o -s %s -f
|
||||||
|
MimeType=video/matroska;video/matroska-3d;video/x-matroska;video/x-matroska-3d;
|
||||||
@@ -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,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: character dictionary
|
||||||
|
|
||||||
|
- Reuse character dictionaries after MeCab completes without finding any name splits instead of regenerating character data and portraits on every launch.
|
||||||
|
- Restore inline character portraits when a cached portrait index finishes loading after subtitles have already been tokenized.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- The macOS window-tracking helper is now built for macOS 12.0+, so the overlay attaches to mpv on older systems (previously the helper required the macOS version of the build machine and crashed on e.g. Ventura, leaving the overlay stuck on "Overlay loading").
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: subtitles
|
area: subtitles
|
||||||
|
|
||||||
- Primary ASS subtitles now use the active parsed cue when it fully accounts for mpv's live text, preventing fill, border, blur, and shadow copies of the same full-span lyric from appearing repeatedly while preserving unmatched overlapping dialogue and signs.
|
- Primary and secondary ASS subtitles now collapse layered and whitespace variants of full-span lyrics, including when playback starts or seeks into a line, reconstruct fragment-only karaoke per style, preserve authored stack order, keep canonical signs visible for their complete generated animation, navigate song lyrics by sanitized lines instead of generated animation events, and keep sidebar selections on the requested overlapping lyric while preserving unmatched dialogue and signs.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: overlay
|
area: overlay
|
||||||
|
|
||||||
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Live mpv text remains the fallback for unreadable tracks.
|
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Fragmented ASS karaoke keeps spaces authored at event boundaries instead of joining every word together. Long ASS lines repeated as dialogue and positioned signs are also collapsed when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts no longer become one concatenated secondary line. Live mpv text remains the fallback for unreadable tracks and applies full-line duplicate filtering before display.
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
type: docs
|
|
||||||
area: documentation
|
|
||||||
|
|
||||||
- Hid the unfinished feature demos page from the documentation sidebar while keeping its direct URL available.
|
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: launcher
|
||||||
|
|
||||||
|
- Fixed missing MKV thumbnails in the Linux rofi picker when system thumbnailer registrations only advertise legacy Matroska MIME aliases.
|
||||||
@@ -5,7 +5,7 @@ Short recordings of SubMiner's key features and integrations from real playback
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { withBase } from 'vitepress';
|
import { withBase } from 'vitepress';
|
||||||
|
|
||||||
const v = '20260301-1';
|
const v = '20260819-1';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
## Anki Card Mining & Enrichment
|
## Anki Card Mining & Enrichment
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ For internal architecture/workflow guidance, use `docs/README.md` at the repo ro
|
|||||||
|
|
||||||
- [Bun](https://bun.sh)
|
- [Bun](https://bun.sh)
|
||||||
- A system `lua` interpreter for `bun run test:launcher` / `bun run test:plugin:src`
|
- A system `lua` interpreter for `bun run test:launcher` / `bun run test:plugin:src`
|
||||||
- macOS builds compile a Swift helper via `scripts/build-macos-helper.sh` (skip with `SUBMINER_SKIP_MACOS_HELPER_BUILD=1`)
|
- macOS builds compile a Swift helper via `scripts/prepare-build-assets.mjs` (skip with `SUBMINER_SKIP_MACOS_HELPER_BUILD=1`)
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ features:
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { withBase } from 'vitepress';
|
import { withBase } from 'vitepress';
|
||||||
|
|
||||||
const demoAssetVersion = '20260223-2';
|
const demoAssetVersion = '20260819-1';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="landing-shell">
|
<div class="landing-shell">
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ subminer -u
|
|||||||
subminer --update
|
subminer --update
|
||||||
```
|
```
|
||||||
|
|
||||||
SubMiner verifies AppImage, launcher, and Linux support-asset downloads against `SHA256SUMS.txt`. On Linux those support assets include the launcher-managed runtime plugin copy under `SubMiner/plugin/subminer` plus the rofi theme at `SubMiner/themes/subminer.rasi`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself.
|
SubMiner verifies AppImage, launcher, and Linux support-asset downloads against `SHA256SUMS.txt`. On Linux those support assets include the launcher-managed runtime plugin copy under `SubMiner/plugin/subminer`, the rofi theme at `SubMiner/themes/subminer.rasi`, and the scoped Matroska thumbnailer registration under `SubMiner/thumbnailers`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself.
|
||||||
|
|
||||||
The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. On Linux it replaces the running `.AppImage` in place via `electron-updater` and refreshes the managed support assets from `subminer-assets.tar.gz`; AppImages managed by a system package (for example the AUR `/opt/SubMiner/SubMiner.AppImage`) are skipped so the package manager stays in charge.
|
The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. On Linux it replaces the running `.AppImage` in place via `electron-updater` and refreshes the managed support assets from `subminer-assets.tar.gz`; AppImages managed by a system package (for example the AUR `/opt/SubMiner/SubMiner.AppImage`) are skipped so the package manager stays in charge.
|
||||||
|
|
||||||
@@ -404,7 +404,7 @@ SubMiner is an overlay that sits on top of mpv. It connects to mpv through an IP
|
|||||||
|
|
||||||
The `subminer` launcher handles mpv IPC socket setup automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or `\\.\pipe\subminer-socket` on Windows) - without it the overlay starts but subtitles won't appear.
|
The `subminer` launcher handles mpv IPC socket setup automatically. If you launch mpv yourself or from another tool, you must pass `--input-ipc-server=/tmp/subminer-socket` (or `\\.\pipe\subminer-socket` on Windows) - without it the overlay starts but subtitles won't appear.
|
||||||
|
|
||||||
The bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. On Linux, the `subminer` launcher now checks for its managed runtime plugin copy and rofi theme before every mpv-managed launch and installs those support assets from the bundled app automatically if either one is missing. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
|
The bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. On Linux, the `subminer` launcher checks for its managed runtime plugin copy, rofi theme, and scoped thumbnailer registration before every mpv-managed launch and installs those support assets from the bundled app automatically if one is missing. For a rofi picker launch, this check runs before the picker opens. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
|
||||||
|
|
||||||
## Platform Notes
|
## Platform Notes
|
||||||
|
|
||||||
@@ -456,18 +456,20 @@ sudo chmod +x /usr/local/bin/subminer
|
|||||||
|
|
||||||
### Linux Support Assets
|
### Linux Support Assets
|
||||||
|
|
||||||
SubMiner ships the Linux rofi theme plus the launcher-managed runtime plugin copy in `subminer-assets.tar.gz`:
|
SubMiner ships the Linux rofi theme, scoped Matroska thumbnailer registration, and launcher-managed runtime plugin copy in `subminer-assets.tar.gz`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
||||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
||||||
mkdir -p ~/.local/share/SubMiner/themes
|
mkdir -p ~/.local/share/SubMiner/themes
|
||||||
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
||||||
|
mkdir -p ~/.local/share/SubMiner/thumbnailers
|
||||||
|
cp /tmp/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer ~/.local/share/SubMiner/thumbnailers/
|
||||||
mkdir -p ~/.local/share/SubMiner/plugin
|
mkdir -p ~/.local/share/SubMiner/plugin
|
||||||
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer
|
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer
|
||||||
```
|
```
|
||||||
|
|
||||||
`subminer -u` and the tray updater keep those Linux support assets in sync automatically once the `SubMiner` data dir exists. Normal Linux launcher playback also auto-installs the managed runtime plugin copy and rofi theme from the bundled app if either support asset is missing, so manual extraction is mainly useful for pre-seeding or custom setups.
|
`subminer -u` and the tray updater keep those Linux support assets in sync automatically once the `SubMiner` data dir exists. Normal Linux launcher playback also auto-installs all three assets from the bundled app if one is missing, so manual extraction is mainly useful for pre-seeding or custom setups. Rofi receives the SubMiner data path through its process-local `XDG_DATA_DIRS`, so the thumbnailer registration does not change the desktop-wide configuration.
|
||||||
|
|
||||||
Override the theme path with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`.
|
Override the theme path with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`.
|
||||||
|
|
||||||
|
|||||||
@@ -34,18 +34,22 @@ subminer -R -r -d ~/Anime # rofi picker, recursive
|
|||||||
subminer -R /directory # rofi picker, directory shortcut
|
subminer -R /directory # rofi picker, directory shortcut
|
||||||
```
|
```
|
||||||
|
|
||||||
rofi shows a GUI menu with icon thumbnails when available. SubMiner ships the rofi theme plus the Linux launcher-managed runtime plugin copy in the release assets tarball:
|
rofi shows a GUI menu with icon thumbnails when available. SubMiner ships the rofi theme, a scoped `ffmpegthumbnailer` MIME registration, and the Linux launcher-managed runtime plugin copy in the release assets tarball:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
||||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
||||||
mkdir -p ~/.local/share/SubMiner/themes
|
mkdir -p ~/.local/share/SubMiner/themes
|
||||||
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
cp /tmp/assets/themes/subminer.rasi ~/.local/share/SubMiner/themes/subminer.rasi
|
||||||
|
mkdir -p ~/.local/share/SubMiner/thumbnailers
|
||||||
|
cp /tmp/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer ~/.local/share/SubMiner/thumbnailers/
|
||||||
mkdir -p ~/.local/share/SubMiner/plugin
|
mkdir -p ~/.local/share/SubMiner/plugin
|
||||||
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer
|
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer
|
||||||
```
|
```
|
||||||
|
|
||||||
Once the `SubMiner` data dir exists, `subminer -u` refreshes both assets automatically. Normal Linux launcher playback also checks for the managed runtime plugin copy and rofi theme before mpv launch and installs them from the bundled app automatically if either one is missing.
|
Once the `SubMiner` data dir exists, `subminer -u` refreshes these assets automatically. Normal Linux launcher playback checks for all three assets and installs them from the bundled app when one is missing. For `subminer -R`, this repair runs before rofi opens.
|
||||||
|
|
||||||
|
When `ffmpegthumbnailer` is installed, SubMiner prepends its own data directory to `XDG_DATA_DIRS` for the rofi process only. This lets rofi recognize the canonical Matroska MIME types used by newer GLib versions without changing the desktop-wide MIME or thumbnailer configuration. An existing registration in your own `$XDG_DATA_HOME/thumbnailers` still takes priority.
|
||||||
|
|
||||||
The theme is auto-detected from these paths (first match wins):
|
The theme is auto-detected from these paths (first match wins):
|
||||||
|
|
||||||
|
|||||||
@@ -108,8 +108,12 @@ 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. 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
|
||||||
|
|
||||||
Both the primary and secondary subtitle bars share the same three visibility modes, and each can be changed independently at runtime:
|
Both the primary and secondary subtitle bars share the same three visibility modes, and each can be changed independently at runtime:
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 23 MiB |
|
Before Width: | Height: | Size: 303 KiB |
|
Before Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 3.8 MiB |
@@ -9,7 +9,7 @@ The sidebar is enabled by default. Set `subtitleSidebar.enabled` to `false` if y
|
|||||||
When SubMiner parses the active subtitle source into a cue list, the sidebar becomes available. Toggle it with the `\` key (configurable via `subtitleSidebar.toggleKey`). While open:
|
When SubMiner parses the active subtitle source into a cue list, the sidebar becomes available. Toggle it with the `\` key (configurable via `subtitleSidebar.toggleKey`). While open:
|
||||||
|
|
||||||
- The active cue is highlighted and kept in view as playback advances (when `autoScroll` is `true`).
|
- The active cue is highlighted and kept in view as playback advances (when `autoScroll` is `true`).
|
||||||
- Clicking any cue seeks mpv to that timestamp.
|
- Clicking any cue seeks mpv into that line. For overlapping ASS karaoke, SubMiner moves past the previous line's exit animation when the selected cue has enough time remaining.
|
||||||
- The sidebar stays synchronized with the overlay - media transitions and subtitle source changes update both simultaneously.
|
- The sidebar stays synchronized with the overlay - media transitions and subtitle source changes update both simultaneously.
|
||||||
|
|
||||||
For typeset ASS karaoke and animated signs, SubMiner collapses generated animation frames and repeated full-line color phases before they reach the sidebar. It recovers a clean complete line from a matching timed authoring comment or from full-line events surrounding generated fragments. Ordinary ASS comments, editor notes, alternate lines, repeated dialogue, and separately positioned signs remain distinct.
|
For typeset ASS karaoke and animated signs, SubMiner collapses generated animation frames and repeated full-line color phases before they reach the sidebar. It recovers a clean complete line from a matching timed authoring comment or from full-line events surrounding generated fragments. Ordinary ASS comments, editor notes, alternate lines, repeated dialogue, and separately positioned signs remain distinct.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Subtitle Overlay Priming
|
# Subtitle Overlay Priming
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-08-18
|
Last verified: 2026-08-19
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
||||||
|
|
||||||
@@ -71,11 +71,24 @@ coming and prefetching would otherwise idle for the rest of the cue.
|
|||||||
|
|
||||||
- Primary live text first resolves recovered canonical ASS animations. Otherwise, when
|
- Primary live text first resolves recovered canonical ASS animations. Otherwise, when
|
||||||
every live mpv line matches an active parsed cue, it uses the parsed cue text so exact
|
every live mpv line matches an active parsed cue, it uses the parsed cue text so exact
|
||||||
full-span style layers appear once instead of repeating for fill, border, blur, and
|
full-span style layers appear once instead of repeating for fill, border, blur, shadow,
|
||||||
shadow events. Any unmatched live line keeps the complete live stack, preserving
|
or equivalent whitespace variants. Any unmatched live line keeps the complete live
|
||||||
dialogue or signs that overlap a lyric.
|
stack, preserving dialogue or signs that overlap a lyric.
|
||||||
- A tokenization cache miss emits the plain cue synchronously. Tokenization remains serialized so
|
- A tokenization cache miss emits the plain cue synchronously. Tokenization remains serialized so
|
||||||
live work does not contend for Yomitan state.
|
live work does not contend for Yomitan state.
|
||||||
|
- The initial `time-pos`, explicit renderer seeks, and later seek-like jumps reprocess mpv's
|
||||||
|
current raw `sub-text` after the new playback time is stored. Explicit intent matters because
|
||||||
|
adjacent subtitle jumps can be shorter than the general seek-distance threshold. This corrects
|
||||||
|
ASS cleanup when mpv delivered the destination subtitle before the destination timestamp.
|
||||||
|
- Renderer `sub-seek` commands use the active parsed cue list when available. Simultaneous cues
|
||||||
|
share one boundary, overlapping lyrics advance from the latest active boundary, and mpv's native
|
||||||
|
command remains the fallback when no parsed destination exists. This prevents generated karaoke
|
||||||
|
frames from consuming next/previous subtitle presses.
|
||||||
|
- Subtitle sidebar selections seek past the preceding sanitized cue's overlapping exit span when
|
||||||
|
the selected cue has enough time remaining. This keeps direct row selection on the requested
|
||||||
|
karaoke line while clamping the seek inside that cue.
|
||||||
|
- If startup paints raw text before embedded ASS parsing finishes, parsed cue arrival may replace
|
||||||
|
that provisional line. The one-prime-per-media guard still suppresses identical repeats.
|
||||||
- If a newer cue arrives while an older line is still tokenizing, the newer plain cue or empty
|
- If a newer cue arrives while an older line is still tokenizing, the newer plain cue or empty
|
||||||
clear payload is emitted immediately. The older tokenization result is dropped before it can
|
clear payload is emitted immediately. The older tokenization result is dropped before it can
|
||||||
replace the current cue.
|
replace the current cue.
|
||||||
@@ -84,17 +97,36 @@ 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
|
||||||
|
extraction is skipped for those sources to avoid competing with playback for network bandwidth.
|
||||||
|
- Parsed secondary text and the live fallback remove exact repeated lines at any length. A
|
||||||
|
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.
|
||||||
- The selected source is parsed with `parseSubtitleCues()`, including metadata-aware ASS duplicate
|
- The selected source is parsed with `parseSubtitleCues()`, including metadata-aware ASS duplicate
|
||||||
and animation collapse. Playback `time-pos` selects the active parsed cue after applying
|
and animation collapse. Playback `time-pos` selects the active parsed cue after applying
|
||||||
`secondary-sub-delay`.
|
`secondary-sub-delay`.
|
||||||
|
- Fragment reconstruction marks positioned parts that span multiple vertical rows as a grid.
|
||||||
|
Secondary text omits those grids instead of flattening a translated table or schedule into one
|
||||||
|
synthetic line. Reconstructed single-line karaoke remains eligible for display.
|
||||||
- The resolved text is stored in `mpvClient.currentSecondarySubText` before it is broadcast. The
|
- The resolved text is stored in `mpvClient.currentSecondarySubText` before it is broadcast. The
|
||||||
overlay, mining, timing tracker, and immersion statistics therefore consume the same secondary
|
overlay, mining, timing tracker, and immersion statistics therefore consume the same secondary
|
||||||
text when a readable source is available.
|
text when a readable source is available.
|
||||||
|
- Simultaneous parsed cues use whitespace-insensitive identity, so ASS layers that vary only
|
||||||
|
between ordinary, hard, or ideographic spaces appear once.
|
||||||
|
- Simultaneous ASS lines are flattened in top-to-bottom positioned order, falling back to their
|
||||||
|
authored source order when no usable position exists.
|
||||||
|
- Fragment-only ASS karaoke is reconstructed per style before publication. Explicit spaces
|
||||||
|
survive concatenation, while scripts that discarded their word boundaries remain compact
|
||||||
|
instead of gaining false spaces between syllables. Short runs qualify only when overlapping
|
||||||
|
positioned events also show changing overrides or repeated layer copies; an English or romaji
|
||||||
|
style name alone never turns ordinary dialogue into a lyric.
|
||||||
|
- Recovered canonical ASS text remains active for the generated animation envelope. For
|
||||||
|
reconstructed lyric styles, the longest-lived active line wins over brief entrance and exit
|
||||||
|
fragments from the same style.
|
||||||
- Media and `secondary-sid` changes clear the previous parsed state before refreshing the source;
|
- Media and `secondary-sid` changes clear the previous parsed state before refreshing the source;
|
||||||
track-list changes refresh without discarding an unchanged source. Observed
|
track-list changes refresh without discarding an unchanged source. Observed
|
||||||
`secondary-sub-delay` changes retime the active parsed cue without rereading the file. If loading,
|
`secondary-sub-delay` changes retime the active parsed cue without rereading the file. If loading,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Read when: finding internal docs or checking verification status
|
|||||||
| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
|
| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
|
||||||
| Workflow index | `docs/workflow/README.md` | active | 2026-08-13 | execution map |
|
| Workflow index | `docs/workflow/README.md` | active | 2026-08-13 | execution map |
|
||||||
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
|
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
|
||||||
| Agent skills | `docs/workflow/agent-skills.md` | active | 2026-08-13 | repo-local workflow skill ownership |
|
| Agent skills | `docs/workflow/agent-skills.md` | active | 2026-08-23 | repo-local workflow skill ownership |
|
||||||
| Verification guide | `docs/workflow/verification.md` | active | 2026-08-13 | maintained verification lanes |
|
| Verification guide | `docs/workflow/verification.md` | active | 2026-08-13 | maintained verification lanes |
|
||||||
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
|
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Agent Skills
|
# Agent Skills
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-08-13
|
Last verified: 2026-08-23
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: using, adding, or changing a repo-local agent workflow skill
|
Read when: using, adding, or changing a repo-local agent workflow skill
|
||||||
|
|
||||||
@@ -12,6 +12,9 @@ Read when: using, adding, or changing a repo-local agent workflow skill
|
|||||||
- `.agents/skills/subminer-change-verification/`
|
- `.agents/skills/subminer-change-verification/`
|
||||||
- Selects the cheapest sufficient repo-native verification lane.
|
- Selects the cheapest sufficient repo-native verification lane.
|
||||||
- Defers command ownership to `package.json` and `docs/workflow/verification.md`.
|
- Defers command ownership to `package.json` and `docs/workflow/verification.md`.
|
||||||
|
- `.agents/skills/subminer-release/`
|
||||||
|
- Prepares, cuts, publishes, or repairs stable and prerelease releases.
|
||||||
|
- Defers release procedure and policy to `docs/RELEASING.md`.
|
||||||
|
|
||||||
Repo-local workflows stay as standalone skills. Do not add plugin packaging, marketplace metadata, or compatibility shims unless the workflow is intentionally being distributed beyond this repository.
|
Repo-local workflows stay as standalone skills. Do not add plugin packaging, marketplace metadata, or compatibility shims unless the workflow is intentionally being distributed beyond this repository.
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
type HistorySeriesEntry,
|
type HistorySeriesEntry,
|
||||||
} from '../history.js';
|
} from '../history.js';
|
||||||
import type { Args } from '../types.js';
|
import type { Args } from '../types.js';
|
||||||
|
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
export type HistorySessionAction = 'previous' | 'replay' | 'next' | 'browse' | 'quit';
|
export type HistorySessionAction = 'previous' | 'replay' | 'next' | 'browse' | 'quit';
|
||||||
@@ -333,6 +334,13 @@ export async function runHistoryCommand(
|
|||||||
const { args, scriptPath } = context;
|
const { args, scriptPath } = context;
|
||||||
|
|
||||||
checkPickerDependencies(args);
|
checkPickerDependencies(args);
|
||||||
|
if (args.useRofi) {
|
||||||
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
|
appPath: context.appPath ?? undefined,
|
||||||
|
scriptPath,
|
||||||
|
logLevel: args.logLevel,
|
||||||
|
});
|
||||||
|
}
|
||||||
const themePath = args.useRofi ? findRofiTheme(scriptPath) : null;
|
const themePath = args.useRofi ? findRofiTheme(scriptPath) : null;
|
||||||
|
|
||||||
const dbPath = resolveImmersionDbPath();
|
const dbPath = resolveImmersionDbPath();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { fail } from '../log.js';
|
|||||||
import { runAppCommandWithInherit } from '../mpv.js';
|
import { runAppCommandWithInherit } from '../mpv.js';
|
||||||
import { commandExists } from '../util.js';
|
import { commandExists } from '../util.js';
|
||||||
import { runJellyfinPlayMenu } from '../jellyfin.js';
|
import { runJellyfinPlayMenu } from '../jellyfin.js';
|
||||||
|
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
|
||||||
import { shouldForwardLogLevel } from '../types.js';
|
import { shouldForwardLogLevel } from '../types.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
@@ -64,6 +65,13 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
|||||||
if (args.useRofi && !commandExists('rofi')) {
|
if (args.useRofi && !commandExists('rofi')) {
|
||||||
fail('rofi not found. Install rofi or omit -R for fzf.');
|
fail('rofi not found. Install rofi or omit -R for fzf.');
|
||||||
}
|
}
|
||||||
|
if (args.useRofi) {
|
||||||
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
|
appPath,
|
||||||
|
scriptPath,
|
||||||
|
logLevel: args.logLevel,
|
||||||
|
});
|
||||||
|
}
|
||||||
await runJellyfinPlayMenu(appPath, args, scriptPath, mpvSocketPath);
|
await runJellyfinPlayMenu(appPath, args, scriptPath, mpvSocketPath);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -496,3 +496,39 @@ test('playback command ensures Linux runtime plugin before mpv launch', async ()
|
|||||||
|
|
||||||
assert.deepEqual(calls, ['plugin', 'startMpv']);
|
assert.deepEqual(calls, ['plugin', 'startMpv']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('rofi playback repairs support assets before opening the picker', async () => {
|
||||||
|
const context = createContext();
|
||||||
|
context.args = {
|
||||||
|
...context.args,
|
||||||
|
target: '',
|
||||||
|
targetKind: '',
|
||||||
|
useRofi: true,
|
||||||
|
};
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
await runPlaybackCommandWithDeps(context, {
|
||||||
|
ensurePlaybackSetupReady: async () => {},
|
||||||
|
ensureRuntimePluginReady: async () => {
|
||||||
|
calls.push('assets');
|
||||||
|
},
|
||||||
|
chooseTarget: async () => {
|
||||||
|
calls.push('picker');
|
||||||
|
return { target: '/tmp/movie.mkv', kind: 'file' };
|
||||||
|
},
|
||||||
|
checkPickerDependencies: () => {},
|
||||||
|
checkDependencies: () => {},
|
||||||
|
registerCleanup: () => {},
|
||||||
|
startMpv: async () => {
|
||||||
|
calls.push('startMpv');
|
||||||
|
},
|
||||||
|
waitForUnixSocketReady: async () => true,
|
||||||
|
startOverlay: async () => {},
|
||||||
|
launchAppCommandDetached: () => {},
|
||||||
|
log: () => {},
|
||||||
|
cleanupPlaybackSession: async () => {},
|
||||||
|
getMpvProc: () => null,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['assets', 'picker', 'startMpv']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
chooseTarget,
|
chooseTarget,
|
||||||
|
checkPickerDependencies,
|
||||||
checkDependencies,
|
checkDependencies,
|
||||||
registerCleanup,
|
registerCleanup,
|
||||||
startMpv,
|
startMpv,
|
||||||
@@ -177,6 +178,7 @@ type PlaybackCommandDeps = {
|
|||||||
args: Args,
|
args: Args,
|
||||||
scriptPath: string,
|
scriptPath: string,
|
||||||
) => Promise<{ target: string; kind: 'file' | 'url' } | null>;
|
) => Promise<{ target: string; kind: 'file' | 'url' } | null>;
|
||||||
|
checkPickerDependencies?: (args: Args) => void;
|
||||||
checkDependencies: (args: Args) => void;
|
checkDependencies: (args: Args) => void;
|
||||||
registerCleanup: (context: LauncherCommandContext) => void;
|
registerCleanup: (context: LauncherCommandContext) => void;
|
||||||
startMpv: typeof startMpv;
|
startMpv: typeof startMpv;
|
||||||
@@ -201,7 +203,18 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
await deps.ensurePlaybackSetupReady(context);
|
await deps.ensurePlaybackSetupReady(context);
|
||||||
|
|
||||||
if (!args.target) {
|
if (!args.target) {
|
||||||
checkPickerDependencies(args);
|
(deps.checkPickerDependencies ?? checkPickerDependencies)(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtimeAssetsReady = false;
|
||||||
|
const ensureRuntimeAssetsReady = async (): Promise<void> => {
|
||||||
|
if (runtimeAssetsReady) return;
|
||||||
|
await deps.ensureRuntimePluginReady(context);
|
||||||
|
runtimeAssetsReady = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!args.target && args.useRofi) {
|
||||||
|
await ensureRuntimeAssetsReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetChoice = await deps.chooseTarget(args, scriptPath);
|
const targetChoice = await deps.chooseTarget(args, scriptPath);
|
||||||
@@ -266,7 +279,7 @@ export async function runPlaybackCommandWithDeps(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await deps.ensureRuntimePluginReady(context);
|
await ensureRuntimeAssetsReady();
|
||||||
|
|
||||||
await deps.startMpv(
|
await deps.startMpv(
|
||||||
selectedTarget.target,
|
selectedTarget.target,
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
|
|||||||
launcher: { status: 'updated' },
|
launcher: { status: 'updated' },
|
||||||
supportAssets: [
|
supportAssets: [
|
||||||
{ status: 'updated', component: 'theme', message: 'Installed theme.' },
|
{ status: 'updated', component: 'theme', message: 'Installed theme.' },
|
||||||
|
{
|
||||||
|
status: 'updated',
|
||||||
|
component: 'thumbnailer',
|
||||||
|
message: 'Installed rofi thumbnailer.',
|
||||||
|
},
|
||||||
{ status: 'skipped', component: 'plugin', message: 'Plugin already up to date.' },
|
{ status: 'skipped', component: 'plugin', message: 'Plugin already up to date.' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -52,6 +57,7 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
|
|||||||
'info:AppImage update: updated',
|
'info:AppImage update: updated',
|
||||||
'info:Launcher update: updated',
|
'info:Launcher update: updated',
|
||||||
'info:Support assets (theme) update: updated - Installed theme.',
|
'info:Support assets (theme) update: updated - Installed theme.',
|
||||||
|
'info:Support assets (thumbnailer) update: updated - Installed rofi thumbnailer.',
|
||||||
'info:Support assets (plugin) update: skipped - Plugin already up to date.',
|
'info:Support assets (plugin) update: skipped - Plugin already up to date.',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ import {
|
|||||||
parseSha256Sums,
|
parseSha256Sums,
|
||||||
type FetchLike,
|
type FetchLike,
|
||||||
} from '../../src/main/runtime/update/release-assets.js';
|
} from '../../src/main/runtime/update/release-assets.js';
|
||||||
import { updateSupportAssetsFromRelease } from '../../src/main/runtime/update/support-assets.js';
|
import {
|
||||||
|
updateSupportAssetsFromRelease,
|
||||||
|
type SupportAssetsUpdateResult,
|
||||||
|
} from '../../src/main/runtime/update/support-assets.js';
|
||||||
|
|
||||||
type UpdateCommandResponse = {
|
type UpdateCommandResponse = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -36,15 +39,14 @@ type DirectReleaseUpdateRequest = {
|
|||||||
channel: UpdateChannel;
|
channel: UpdateChannel;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DirectSupportAssetsUpdateResult = Omit<SupportAssetsUpdateResult, 'status'> & {
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
type DirectReleaseUpdateResult = {
|
type DirectReleaseUpdateResult = {
|
||||||
appImage: { status: string; command?: string; message?: string };
|
appImage: { status: string; command?: string; message?: string };
|
||||||
launcher: { status: string; command?: string; message?: string };
|
launcher: { status: string; command?: string; message?: string };
|
||||||
supportAssets: Array<{
|
supportAssets: DirectSupportAssetsUpdateResult[];
|
||||||
status: string;
|
|
||||||
component?: 'theme' | 'plugin';
|
|
||||||
command?: string;
|
|
||||||
message?: string;
|
|
||||||
}>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type UpdateCommandDeps = {
|
type UpdateCommandDeps = {
|
||||||
@@ -129,12 +131,7 @@ function readUpdateChannel(root: Record<string, unknown> | null): UpdateChannel
|
|||||||
|
|
||||||
function logUpdateResult(
|
function logUpdateResult(
|
||||||
label: string,
|
label: string,
|
||||||
result: {
|
result: DirectSupportAssetsUpdateResult,
|
||||||
status: string;
|
|
||||||
component?: 'theme' | 'plugin';
|
|
||||||
command?: string;
|
|
||||||
message?: string;
|
|
||||||
},
|
|
||||||
configuredLogLevel: NonNullable<LauncherCommandContext['args']['logLevel']>,
|
configuredLogLevel: NonNullable<LauncherCommandContext['args']['logLevel']>,
|
||||||
deps: Pick<UpdateCommandDeps, 'log'>,
|
deps: Pick<UpdateCommandDeps, 'log'>,
|
||||||
): void {
|
): void {
|
||||||
|
|||||||
@@ -73,20 +73,21 @@ function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// On Linux the playback path runs `ensureLinuxRuntimePluginAvailable`, which —
|
// On Linux the playback path runs `ensureLinuxRuntimePluginAvailable`, which
|
||||||
// when the runtime plugin/theme are missing — spawns the app with
|
// spawns the app with `--ensure-linux-runtime-plugin-assets` when managed
|
||||||
// `--ensure-linux-runtime-plugin-assets` and polls up to 30s
|
// support assets are missing and polls up to 30s
|
||||||
// (RESPONSE_TIMEOUT_MS) for an install response. A fake app that just exits
|
// (RESPONSE_TIMEOUT_MS) for an install response. A fake app that just exits
|
||||||
// never writes that response, so the launcher hangs and the test times out on
|
// never writes that response, so the launcher hangs and the test times out on
|
||||||
// Linux CI (the preflight is a no-op on macOS/Windows). This shell prelude makes
|
// Linux CI (the preflight is a no-op on macOS/Windows). This shell prelude makes
|
||||||
// the fake app install the managed plugin/theme and write the response, matching
|
// the fake app install the managed support assets and write the response, matching
|
||||||
// launcher/smoke.e2e.test.ts. Prepend it to each fake app that reaches playback.
|
// launcher/smoke.e2e.test.ts. Prepend it to each fake app that reaches playback.
|
||||||
const RUNTIME_PLUGIN_PREFLIGHT_SH = `if [ "$1" = "--ensure-linux-runtime-plugin-assets" ]; then
|
const RUNTIME_PLUGIN_PREFLIGHT_SH = `if [ "$1" = "--ensure-linux-runtime-plugin-assets" ]; then
|
||||||
data="\${XDG_DATA_HOME:-$HOME/.local/share}/SubMiner"
|
data="\${XDG_DATA_HOME:-$HOME/.local/share}/SubMiner"
|
||||||
mkdir -p "$data/plugin/subminer" "$data/themes"
|
mkdir -p "$data/plugin/subminer" "$data/themes" "$data/thumbnailers"
|
||||||
printf -- '-- test plugin\\n' > "$data/plugin/subminer/main.lua"
|
printf -- '-- test plugin\\n' > "$data/plugin/subminer/main.lua"
|
||||||
printf 'test=true\\n' > "$data/plugin/subminer.conf"
|
printf 'test=true\\n' > "$data/plugin/subminer.conf"
|
||||||
printf '/* test theme */\\n' > "$data/themes/subminer.rasi"
|
printf '/* test theme */\\n' > "$data/themes/subminer.rasi"
|
||||||
|
printf '[Thumbnailer Entry]\\n' > "$data/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer"
|
||||||
if [ "$2" = "--ensure-linux-runtime-plugin-assets-response-path" ] && [ -n "$3" ]; then
|
if [ "$2" = "--ensure-linux-runtime-plugin-assets-response-path" ] && [ -n "$3" ]; then
|
||||||
mkdir -p "$(dirname "$3")"
|
mkdir -p "$(dirname "$3")"
|
||||||
printf '{"ok":true,"status":"installed","path":"%s"}' "$data/plugin/subminer/main.lua" > "$3"
|
printf '{"ok":true,"status":"installed","path":"%s"}' "$data/plugin/subminer/main.lua" > "$3"
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import assert from 'node:assert/strict';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { findRofiTheme, formatRofiPrompt } from './picker';
|
import {
|
||||||
|
findRofiTheme,
|
||||||
|
findRofiThumbnailerDataRoot,
|
||||||
|
formatRofiPrompt,
|
||||||
|
prependXdgDataDir,
|
||||||
|
} from './picker';
|
||||||
|
|
||||||
// ── formatRofiPrompt: spacing between prompt and input field ──────────────────
|
// ── formatRofiPrompt: spacing between prompt and input field ──────────────────
|
||||||
|
|
||||||
@@ -23,6 +28,7 @@ test('formatRofiPrompt leaves an empty prompt empty', () => {
|
|||||||
// ── findRofiTheme: Linux packaged path discovery ──────────────────────────────
|
// ── findRofiTheme: Linux packaged path discovery ──────────────────────────────
|
||||||
|
|
||||||
const ROFI_THEME_FILE = 'subminer.rasi';
|
const ROFI_THEME_FILE = 'subminer.rasi';
|
||||||
|
const ROFI_THUMBNAILER_FILE = 'subminer-ffmpegthumbnailer.thumbnailer';
|
||||||
|
|
||||||
function makeFile(filePath: string): void {
|
function makeFile(filePath: string): void {
|
||||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
@@ -121,3 +127,42 @@ test('findRofiTheme resolves ~/.local/share/SubMiner/themes/subminer.rasi when X
|
|||||||
fs.rmSync(baseDir, { recursive: true, force: true });
|
fs.rmSync(baseDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('findRofiThumbnailerDataRoot resolves the managed XDG data root', () => {
|
||||||
|
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-'));
|
||||||
|
const originalXdgDataHome = process.env.XDG_DATA_HOME;
|
||||||
|
try {
|
||||||
|
process.env.XDG_DATA_HOME = xdgDataHome;
|
||||||
|
const dataRoot = path.join(xdgDataHome, 'SubMiner');
|
||||||
|
makeFile(path.join(dataRoot, 'thumbnailers', ROFI_THUMBNAILER_FILE));
|
||||||
|
|
||||||
|
const result = withPlatform('linux', () => findRofiThumbnailerDataRoot('/usr/bin/subminer'));
|
||||||
|
assert.equal(result, dataRoot);
|
||||||
|
} finally {
|
||||||
|
if (originalXdgDataHome === undefined) {
|
||||||
|
delete process.env.XDG_DATA_HOME;
|
||||||
|
} else {
|
||||||
|
process.env.XDG_DATA_HOME = originalXdgDataHome;
|
||||||
|
}
|
||||||
|
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findRofiThumbnailerDataRoot is Linux-only', () => {
|
||||||
|
assert.equal(
|
||||||
|
withPlatform('darwin', () => findRofiThumbnailerDataRoot('/usr/bin/subminer')),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prependXdgDataDir preserves existing roots and avoids duplicates', () => {
|
||||||
|
const root = '/tmp/subminer-data';
|
||||||
|
assert.equal(
|
||||||
|
prependXdgDataDir(root, `/opt/share${path.delimiter}${root}${path.delimiter}/usr/share`),
|
||||||
|
`${root}${path.delimiter}/opt/share${path.delimiter}/usr/share`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
prependXdgDataDir(root),
|
||||||
|
`${root}${path.delimiter}/usr/local/share${path.delimiter}/usr/share`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -159,6 +159,9 @@ interface RofiIconEntry {
|
|||||||
iconPath?: string;
|
iconPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ROFI_THUMBNAILER_FILE = 'subminer-ffmpegthumbnailer.thumbnailer';
|
||||||
|
const DEFAULT_XDG_DATA_DIRS = ['/usr/local/share', '/usr/share'];
|
||||||
|
|
||||||
function showRofiIconMenu(
|
function showRofiIconMenu(
|
||||||
entries: RofiIconEntry[],
|
entries: RofiIconEntry[],
|
||||||
prompt: string,
|
prompt: string,
|
||||||
@@ -389,6 +392,47 @@ export function findRofiTheme(scriptPath: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findRofiThumbnailerDataRoot(scriptPath: string): string | null {
|
||||||
|
if (process.platform !== 'linux') return null;
|
||||||
|
|
||||||
|
const scriptDir = path.dirname(realpathMaybe(scriptPath));
|
||||||
|
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local/share');
|
||||||
|
const roots = [
|
||||||
|
path.join(xdgDataHome, 'SubMiner'),
|
||||||
|
path.posix.join('/usr/local/share/SubMiner'),
|
||||||
|
path.posix.join('/usr/share/SubMiner'),
|
||||||
|
path.join(scriptDir, 'assets'),
|
||||||
|
path.join(scriptDir, '..', 'assets'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const root of roots) {
|
||||||
|
if (fs.existsSync(path.join(root, 'thumbnailers', ROFI_THUMBNAILER_FILE))) {
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prependXdgDataDir(dataRoot: string, currentValue?: string): string {
|
||||||
|
const currentDirs = currentValue
|
||||||
|
? currentValue.split(path.delimiter).filter(Boolean)
|
||||||
|
: DEFAULT_XDG_DATA_DIRS;
|
||||||
|
return [dataRoot, ...currentDirs.filter((candidate) => candidate !== dataRoot)].join(
|
||||||
|
path.delimiter,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRofiThumbnailEnvironment(scriptPath: string): NodeJS.ProcessEnv {
|
||||||
|
if (!commandExists('ffmpegthumbnailer')) return process.env;
|
||||||
|
const dataRoot = findRofiThumbnailerDataRoot(scriptPath);
|
||||||
|
if (!dataRoot) return process.env;
|
||||||
|
return {
|
||||||
|
...process.env,
|
||||||
|
XDG_DATA_DIRS: prependXdgDataDir(dataRoot, process.env.XDG_DATA_DIRS),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function showRofiMenu(
|
export function showRofiMenu(
|
||||||
videos: string[],
|
videos: string[],
|
||||||
dir: string,
|
dir: string,
|
||||||
@@ -420,6 +464,7 @@ export function showRofiMenu(
|
|||||||
const result = spawnSync('rofi', args, {
|
const result = spawnSync('rofi', args, {
|
||||||
input: buildRofiMenu(videos, dir, recursive),
|
input: buildRofiMenu(videos, dir, recursive),
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
|
env: buildRofiThumbnailEnvironment(scriptPath),
|
||||||
stdio: ['pipe', 'pipe', 'ignore'],
|
stdio: ['pipe', 'pipe', 'ignore'],
|
||||||
});
|
});
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
import {
|
import {
|
||||||
ensureLinuxRuntimePluginAvailable,
|
ensureLinuxRuntimePluginAvailable,
|
||||||
installManagedPluginAssetsViaApp,
|
installManagedPluginAssetsViaApp,
|
||||||
@@ -31,7 +33,7 @@ test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', asyn
|
|||||||
assert.deepEqual(calls, []);
|
assert.deepEqual(calls, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ensureLinuxRuntimePluginAvailable skips install when installed global plugin and managed theme exist', async () => {
|
test('ensureLinuxRuntimePluginAvailable skips install when plugin, theme, and thumbnailer exist', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
await ensureLinuxRuntimePluginAvailable({
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
@@ -52,13 +54,17 @@ test('ensureLinuxRuntimePluginAvailable skips install when installed global plug
|
|||||||
calls.push('theme');
|
calls.push('theme');
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
isManagedThumbnailerAvailable: () => {
|
||||||
|
calls.push('thumbnailer');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
log: () => {},
|
log: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(calls, ['detect', 'theme']);
|
assert.deepEqual(calls, ['detect', 'theme', 'thumbnailer']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ensureLinuxRuntimePluginAvailable skips install when managed runtime path and theme already resolve', async () => {
|
test('ensureLinuxRuntimePluginAvailable skips install when all managed assets resolve', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
await ensureLinuxRuntimePluginAvailable({
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
@@ -80,14 +86,19 @@ test('ensureLinuxRuntimePluginAvailable skips install when managed runtime path
|
|||||||
calls.push('theme');
|
calls.push('theme');
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
isManagedThumbnailerAvailable: () => {
|
||||||
|
calls.push('thumbnailer');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
log: () => {},
|
log: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(calls, ['detect', 'resolve', 'theme']);
|
assert.deepEqual(calls, ['detect', 'resolve', 'theme', 'thumbnailer']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme is missing', async () => {
|
test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme is missing', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
let themeAvailable = false;
|
||||||
|
|
||||||
await ensureLinuxRuntimePluginAvailable({
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
@@ -102,10 +113,15 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme
|
|||||||
},
|
},
|
||||||
isManagedThemeAvailable: () => {
|
isManagedThemeAvailable: () => {
|
||||||
calls.push('theme');
|
calls.push('theme');
|
||||||
return false;
|
return themeAvailable;
|
||||||
|
},
|
||||||
|
isManagedThumbnailerAvailable: () => {
|
||||||
|
calls.push('thumbnailer');
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
installManagedPluginAssets: async () => {
|
installManagedPluginAssets: async () => {
|
||||||
calls.push('install');
|
calls.push('install');
|
||||||
|
themeAvailable = true;
|
||||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||||
},
|
},
|
||||||
log: (level, _configured, message) => {
|
log: (level, _configured, message) => {
|
||||||
@@ -117,13 +133,68 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme
|
|||||||
'detect',
|
'detect',
|
||||||
'resolve',
|
'resolve',
|
||||||
'theme',
|
'theme',
|
||||||
'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
|
'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
|
||||||
'install',
|
'install',
|
||||||
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
|
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
|
||||||
'resolve',
|
'resolve',
|
||||||
|
'theme',
|
||||||
|
'thumbnailer',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAvailable installs managed assets when thumbnailer is missing', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
let thumbnailerAvailable = false;
|
||||||
|
|
||||||
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
|
platform: 'linux',
|
||||||
|
xdgDataHome: '/tmp/xdg-data',
|
||||||
|
detectInstalledPlugin: () => true,
|
||||||
|
resolveRuntimePluginPath: () => '/tmp/plugin/main.lua',
|
||||||
|
isManagedThemeAvailable: () => true,
|
||||||
|
isManagedThumbnailerAvailable: () => thumbnailerAvailable,
|
||||||
|
installManagedPluginAssets: async () => {
|
||||||
|
calls.push('install');
|
||||||
|
thumbnailerAvailable = true;
|
||||||
|
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||||
|
},
|
||||||
|
log: (_level, _configured, message) => {
|
||||||
|
calls.push(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
|
||||||
|
'install',
|
||||||
|
'Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAvailable retains an installed plugin after installing support assets', async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
let thumbnailerAvailable = false;
|
||||||
|
|
||||||
|
await ensureLinuxRuntimePluginAvailable({
|
||||||
|
platform: 'linux',
|
||||||
|
xdgDataHome: '/tmp/xdg-data',
|
||||||
|
detectInstalledPlugin: () => true,
|
||||||
|
resolveRuntimePluginPath: () => {
|
||||||
|
calls.push('resolve');
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
isManagedThemeAvailable: () => true,
|
||||||
|
isManagedThumbnailerAvailable: () => thumbnailerAvailable,
|
||||||
|
installManagedPluginAssets: async () => {
|
||||||
|
calls.push('install');
|
||||||
|
thumbnailerAvailable = true;
|
||||||
|
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['install']);
|
||||||
|
});
|
||||||
|
|
||||||
test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => {
|
test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
let resolveCount = 0;
|
let resolveCount = 0;
|
||||||
@@ -137,6 +208,8 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves
|
|||||||
calls.push(`resolve:${resolveCount}`);
|
calls.push(`resolve:${resolveCount}`);
|
||||||
return resolveCount === 1 ? null : '/tmp/plugin/main.lua';
|
return resolveCount === 1 ? null : '/tmp/plugin/main.lua';
|
||||||
},
|
},
|
||||||
|
isManagedThemeAvailable: () => true,
|
||||||
|
isManagedThumbnailerAvailable: () => true,
|
||||||
installManagedPluginAssets: async () => {
|
installManagedPluginAssets: async () => {
|
||||||
calls.push('install');
|
calls.push('install');
|
||||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||||
@@ -148,9 +221,9 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves
|
|||||||
|
|
||||||
assert.deepEqual(calls, [
|
assert.deepEqual(calls, [
|
||||||
'resolve:1',
|
'resolve:1',
|
||||||
'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
|
'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
|
||||||
'install',
|
'install',
|
||||||
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
|
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
|
||||||
'resolve:2',
|
'resolve:2',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -191,6 +264,60 @@ test('ensureLinuxRuntimePluginAvailable fails when runtime path remains unresolv
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAvailable fails when thumbnailer remains missing after install', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
ensureLinuxRuntimePluginAvailable({
|
||||||
|
platform: 'linux',
|
||||||
|
xdgDataHome: '/tmp/xdg-data',
|
||||||
|
detectInstalledPlugin: () => true,
|
||||||
|
resolveRuntimePluginPath: () => '/tmp/plugin/main.lua',
|
||||||
|
isManagedThemeAvailable: () => true,
|
||||||
|
isManagedThumbnailerAvailable: () => false,
|
||||||
|
installManagedPluginAssets: async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 'installed',
|
||||||
|
path: '/tmp/plugin/main.lua',
|
||||||
|
}),
|
||||||
|
log: () => {},
|
||||||
|
}),
|
||||||
|
/thumbnailer=.*subminer-ffmpegthumbnailer\.thumbnailer/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLinuxRuntimePluginAvailable rejects a thumbnailer directory before and after install', async () => {
|
||||||
|
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-thumbnailer-directory-'));
|
||||||
|
const thumbnailerPath = path.join(
|
||||||
|
xdgDataHome,
|
||||||
|
'SubMiner',
|
||||||
|
'thumbnailers',
|
||||||
|
'subminer-ffmpegthumbnailer.thumbnailer',
|
||||||
|
);
|
||||||
|
fs.mkdirSync(thumbnailerPath, { recursive: true });
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
ensureLinuxRuntimePluginAvailable({
|
||||||
|
platform: 'linux',
|
||||||
|
xdgDataHome,
|
||||||
|
detectInstalledPlugin: () => true,
|
||||||
|
isManagedThemeAvailable: () => true,
|
||||||
|
installManagedPluginAssets: async () => {
|
||||||
|
calls.push('install');
|
||||||
|
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
}),
|
||||||
|
/thumbnailer=.*subminer-ffmpegthumbnailer\.thumbnailer/i,
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls, ['install']);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(xdgDataHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('installManagedPluginAssetsViaApp returns launch errors without waiting for a response file', async () => {
|
test('installManagedPluginAssetsViaApp returns launch errors without waiting for a response file', async () => {
|
||||||
let waited = false;
|
let waited = false;
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type EnsureLinuxRuntimePluginAvailableOptions = {
|
|||||||
detectInstalledPlugin?: () => boolean;
|
detectInstalledPlugin?: () => boolean;
|
||||||
resolveRuntimePluginPath?: () => string | null;
|
resolveRuntimePluginPath?: () => string | null;
|
||||||
isManagedThemeAvailable?: () => boolean;
|
isManagedThemeAvailable?: () => boolean;
|
||||||
|
isManagedThumbnailerAvailable?: () => boolean;
|
||||||
installManagedPluginAssets?: () => Promise<EnsureLinuxRuntimePluginAssetsResult>;
|
installManagedPluginAssets?: () => Promise<EnsureLinuxRuntimePluginAssetsResult>;
|
||||||
log?: PreflightLog;
|
log?: PreflightLog;
|
||||||
};
|
};
|
||||||
@@ -48,6 +49,14 @@ function resolveConfiguredLogLevel(
|
|||||||
return logLevel ?? 'warn';
|
return logLevel ?? 'warn';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRegularFile(filePath: string): boolean {
|
||||||
|
try {
|
||||||
|
return fs.statSync(filePath).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function waitForInstallResponse(
|
async function waitForInstallResponse(
|
||||||
responsePath: string,
|
responsePath: string,
|
||||||
): Promise<RuntimePluginPreflightResponse | null> {
|
): Promise<RuntimePluginPreflightResponse | null> {
|
||||||
@@ -170,15 +179,17 @@ export async function ensureLinuxRuntimePluginAvailable(
|
|||||||
});
|
});
|
||||||
const isManagedThemeAvailable =
|
const isManagedThemeAvailable =
|
||||||
options.isManagedThemeAvailable ?? (() => fs.existsSync(managedPaths.themePath));
|
options.isManagedThemeAvailable ?? (() => fs.existsSync(managedPaths.themePath));
|
||||||
|
const isManagedThumbnailerAvailable =
|
||||||
|
options.isManagedThumbnailerAvailable ?? (() => isRegularFile(managedPaths.thumbnailerPath));
|
||||||
const runtimePluginAvailable = installedPluginAvailable || Boolean(resolveRuntimePluginPath());
|
const runtimePluginAvailable = installedPluginAvailable || Boolean(resolveRuntimePluginPath());
|
||||||
if (runtimePluginAvailable && isManagedThemeAvailable()) {
|
if (runtimePluginAvailable && isManagedThemeAvailable() && isManagedThumbnailerAvailable()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
log(
|
log(
|
||||||
'info',
|
'info',
|
||||||
configuredLogLevel,
|
configuredLogLevel,
|
||||||
'Linux runtime support assets missing; installing managed plugin/theme assets.',
|
'Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
|
||||||
);
|
);
|
||||||
const installManagedPluginAssets =
|
const installManagedPluginAssets =
|
||||||
options.installManagedPluginAssets ??
|
options.installManagedPluginAssets ??
|
||||||
@@ -207,16 +218,21 @@ export async function ensureLinuxRuntimePluginAvailable(
|
|||||||
log(
|
log(
|
||||||
'info',
|
'info',
|
||||||
configuredLogLevel,
|
configuredLogLevel,
|
||||||
`Managed Linux runtime support assets installed: plugin=${installResult.path ?? 'unknown path'} theme=${managedPaths.themePath}`,
|
`Managed Linux runtime support assets installed: plugin=${installResult.path ?? 'unknown path'} theme=${managedPaths.themePath} thumbnailer=${managedPaths.thumbnailerPath}`,
|
||||||
);
|
);
|
||||||
const runtimePluginPath = resolveRuntimePluginPath();
|
const runtimePluginAvailableAfterInstall =
|
||||||
if (runtimePluginPath) {
|
installedPluginAvailable || Boolean(resolveRuntimePluginPath());
|
||||||
|
if (
|
||||||
|
runtimePluginAvailableAfterInstall &&
|
||||||
|
isManagedThemeAvailable() &&
|
||||||
|
isManagedThumbnailerAvailable()
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
`Linux managed runtime plugin assets could not be installed. ` +
|
`Linux managed runtime plugin assets could not be installed. ` +
|
||||||
`Checked path: ${managedPaths.pluginEntrypointPath}. ` +
|
`Checked paths: plugin=${managedPaths.pluginEntrypointPath} theme=${managedPaths.themePath} thumbnailer=${managedPaths.thumbnailerPath}. ` +
|
||||||
'Launch aborted before starting mpv.';
|
'Launch aborted before starting mpv.';
|
||||||
log('warn', configuredLogLevel, message);
|
log('warn', configuredLogLevel, message);
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
|
|||||||
@@ -165,11 +165,14 @@ if (entry.argv.includes('--ensure-linux-runtime-plugin-assets')) {
|
|||||||
const pluginDir = path.join(dataDir, 'plugin', 'subminer');
|
const pluginDir = path.join(dataDir, 'plugin', 'subminer');
|
||||||
const pluginConfigPath = path.join(dataDir, 'plugin', 'subminer.conf');
|
const pluginConfigPath = path.join(dataDir, 'plugin', 'subminer.conf');
|
||||||
const themePath = path.join(dataDir, 'themes', 'subminer.rasi');
|
const themePath = path.join(dataDir, 'themes', 'subminer.rasi');
|
||||||
|
const thumbnailerPath = path.join(dataDir, 'thumbnailers', 'subminer-ffmpegthumbnailer.thumbnailer');
|
||||||
fs.mkdirSync(pluginDir, { recursive: true });
|
fs.mkdirSync(pluginDir, { recursive: true });
|
||||||
fs.mkdirSync(path.dirname(themePath), { recursive: true });
|
fs.mkdirSync(path.dirname(themePath), { recursive: true });
|
||||||
|
fs.mkdirSync(path.dirname(thumbnailerPath), { recursive: true });
|
||||||
fs.writeFileSync(path.join(pluginDir, 'main.lua'), '-- smoke plugin\\n');
|
fs.writeFileSync(path.join(pluginDir, 'main.lua'), '-- smoke plugin\\n');
|
||||||
fs.writeFileSync(pluginConfigPath, 'smoke=true\\n');
|
fs.writeFileSync(pluginConfigPath, 'smoke=true\\n');
|
||||||
fs.writeFileSync(themePath, '/* smoke theme */\\n');
|
fs.writeFileSync(themePath, '/* smoke theme */\\n');
|
||||||
|
fs.writeFileSync(thumbnailerPath, '[Thumbnailer Entry]\\n');
|
||||||
if (responsePath) {
|
if (responsePath) {
|
||||||
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
||||||
fs.writeFileSync(responsePath, JSON.stringify({ ok: true, status: 'installed', path: path.join(pluginDir, 'main.lua') }));
|
fs.writeFileSync(responsePath, JSON.stringify({ ok: true, status: 'installed', path: path.join(pluginDir, 'main.lua') }));
|
||||||
@@ -620,11 +623,22 @@ test(
|
|||||||
);
|
);
|
||||||
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
|
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
|
||||||
if (process.platform === 'linux') {
|
if (process.platform === 'linux') {
|
||||||
assert.match(result.stdout, /managed plugin\/theme assets/i);
|
assert.match(result.stdout, /managed plugin\/theme\/thumbnailer assets/i);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
fs.existsSync(path.join(smokeCase.xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi')),
|
fs.existsSync(path.join(smokeCase.xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi')),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.existsSync(
|
||||||
|
path.join(
|
||||||
|
smokeCase.xdgDataHome,
|
||||||
|
'SubMiner',
|
||||||
|
'thumbnailers',
|
||||||
|
'subminer-ffmpegthumbnailer.thumbnailer',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.19.4-beta.1",
|
"version": "0.19.4-beta.4",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ package() {
|
|||||||
"${pkgdir}/usr/share/SubMiner/plugin/subminer.conf"
|
"${pkgdir}/usr/share/SubMiner/plugin/subminer.conf"
|
||||||
install -Dm644 "${srcdir}/assets/themes/subminer.rasi" \
|
install -Dm644 "${srcdir}/assets/themes/subminer.rasi" \
|
||||||
"${pkgdir}/usr/share/SubMiner/themes/subminer.rasi"
|
"${pkgdir}/usr/share/SubMiner/themes/subminer.rasi"
|
||||||
|
install -Dm644 "${srcdir}/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer" \
|
||||||
|
"${pkgdir}/usr/share/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer"
|
||||||
|
|
||||||
install -dm755 "${pkgdir}/usr/share/SubMiner/plugin/subminer"
|
install -dm755 "${pkgdir}/usr/share/SubMiner/plugin/subminer"
|
||||||
cp -a "${srcdir}/plugin/subminer/." "${pkgdir}/usr/share/SubMiner/plugin/subminer/"
|
cp -a "${srcdir}/plugin/subminer/." "${pkgdir}/usr/share/SubMiner/plugin/subminer/"
|
||||||
|
|||||||
@@ -4,37 +4,46 @@
|
|||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
### Added
|
### Added
|
||||||
- **Library Merge and Move**
|
|
||||||
- Duplicate library cards for the same show can now be combined: select cards in the library grid, choose "Merge Selected," and pick which entry to keep. Sessions, mined cards, and watch time move over, and future episodes stay matched to the merged card.
|
- Library Merge & Reassignment
|
||||||
- Episodes can be reassigned to a different library entry with a "→" button on the episode row, fixing cases where a file lands under the wrong title. Manual assignments survive later filename parsing, Jellyfin refreshes, and season repair.
|
- Duplicate library cards for the same show can be combined: select entries in "Select" mode and use "Merge Selected" to combine their sessions, mined cards, and watch time onto one card.
|
||||||
- Exact AniList matches with compatible seasons now merge automatically, while fuzzy matches show up as a dismissible "Possible duplicate" prompt instead of merging without confirmation.
|
- Episodes can be moved to a different entry with a per-episode "→" button, fixing stray files that split off their own entry; manual assignments now survive later filename parsing, Jellyfin refreshes, and season repair.
|
||||||
|
- Exact AniList matches with compatible seasons merge automatically, while likely (fuzzy) matches surface as a dismissible "Possible duplicate" suggestion instead of merging silently.
|
||||||
|
|
||||||
|
- Duplicate Line Cleanup
|
||||||
|
- The Vocabulary tab's new **Duplicates** button scans a chosen time window for the repeated-line bursts described under Fixed below and collapses each burst to a single line once confirmed.
|
||||||
|
- A matching `subminer stats cleanup --duplicate-lines` command (with `--dry-run` and `--lookback-days <n>`) is available from the terminal.
|
||||||
|
- Only the affected subtitle lines and the vocabulary counts they inflated are touched; watch time and lines-seen totals are left as recorded.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Anki Audio Generation on Network Drives**
|
|
||||||
- Fixed sentence-audio generation timing out on slow network-mounted video files with many subtitle and font-attachment streams.
|
|
||||||
- Extraction now uses bounded probing and a two-minute budget, and failures show a clear error instead of a cryptic one.
|
|
||||||
- **Duplicate Subtitle Line Stats**
|
|
||||||
- Fixed karaoke openings and animated signs (which record one subtitle event per animation frame) inflating word and kanji counts and skewing "Top Repeated Words." Ordinary repeated dialogue and rewatches are unaffected.
|
|
||||||
- Already-inflated stats can be cleaned up with the new "Duplicates" button in the Vocabulary tab, or `subminer stats cleanup --duplicate-lines` (supports `--dry-run` and `--lookback-days`). Only the affected subtitle lines and vocabulary counts are touched; watch time and lines-seen totals are untouched.
|
|
||||||
- **Overlay Modals on macOS and Windows**
|
|
||||||
- Fixed overlay modals and the stats window opening on the wrong macOS Space, or forcing a Space switch, when mpv is fullscreen. They now open above fullscreen mpv on its current Space.
|
|
||||||
- Modals are now prewarmed on macOS and Windows so shortcuts open them promptly, and Windows keeps the hidden modal responsive between sessions.
|
|
||||||
- **Wayland File Drag-and-Drop**
|
|
||||||
- Fixed dragging subtitle and video files from file managers like Thunar onto the overlay on native Wayland; dropped files are now resolved and sent to mpv.
|
|
||||||
- **Windows Mouse Lag**
|
|
||||||
- Fixed system-wide mouse lag while SubMiner is running on Windows, caused by a global mouse hook and blocking window lookups during click-through tracking.
|
|
||||||
- **Mining Clip Accuracy**
|
|
||||||
- Fixed mined audio and animated image clips sometimes capturing the wrong subtitle line when audio extraction was slow. The clip range is now locked in at the moment of lookup, so audio and image clips always match.
|
|
||||||
- **Linux Notifications**
|
|
||||||
- Character dictionary progress notifications on Linux now update in place instead of flickering off and back on with every status change.
|
|
||||||
- **Stats Delete Performance**
|
|
||||||
- Fixed stats deletes freezing the dashboard; deletes now reliably run off the main thread, with automatic retry if the delete worker crashes.
|
|
||||||
- Deletes, library merges/moves, and AniList reassignments are now much faster because totals are updated incrementally instead of rebuilt from scratch, and no longer erase lifetime totals older than the recent session history.
|
|
||||||
- Session deletes on large libraries dropped from minutes to milliseconds.
|
|
||||||
|
|
||||||
### Docs
|
- Subtitle Duplication from Karaoke & Animated Signs
|
||||||
- **Feature Demos Page**
|
- Typeset ASS karaoke and animated signs no longer flood the overlay, subtitle sidebar, immersion history, mined cards, or stats with repeated glyph fragments or per-frame duplicates; the complete authored line is recovered instead, without merging genuinely repeated dialogue or separately positioned signs.
|
||||||
- Hidden the unfinished feature demos page from the documentation sidebar; it's still reachable by direct URL.
|
- Fragmented karaoke now preserves the spaces the author placed between words instead of joining them together, and lyric transitions (including seeking into the middle of a line) resolve to the clean line instead of a stray entrance or exit frame.
|
||||||
|
- The secondary overlay shares the same deduplication logic as the primary overlay, including collapsing lines that differ only by whitespace or trailing punctuation, and sidebar navigation moves between clean lyric lines while keeping the right line selected.
|
||||||
|
|
||||||
|
- Anki Media Generation
|
||||||
|
- Sentence-audio generation no longer times out on slow network-mounted video files with many subtitle and font streams, and a failed extraction now reports a clear error instead of a raw `ENOENT`.
|
||||||
|
- Mined audio and animated AVIF clips now capture the subtitle line you actually mined, instead of whatever line happened to be on screen once slow audio extraction finished.
|
||||||
|
|
||||||
|
- Character Dictionary Performance & Notifications
|
||||||
|
- Character dictionary generation, merged rebuilds, and imports no longer freeze the app on large dictionaries, and cached results (including character portraits) are reused across launches instead of regenerating everything every time.
|
||||||
|
- Portraits also now display correctly if their cache finishes loading after subtitles have already started showing.
|
||||||
|
- Desktop progress notifications, including on Linux AppImage installs, now update in place instead of flickering closed and reopening.
|
||||||
|
|
||||||
|
- Overlay Reliability
|
||||||
|
- Overlay modals (settings, stats, etc.) now open promptly on the first shortcut press, including on repeated sessions on Windows, and appear above fullscreen mpv on macOS instead of switching Spaces or opening off-screen.
|
||||||
|
- The macOS window-tracking helper is now built for macOS 12.0+, so the overlay attaches to mpv on older systems like Ventura instead of crashing and getting stuck on "Overlay loading."
|
||||||
|
- The overlay no longer gets stuck on "Overlay loading" indefinitely if mpv's connection stalls; it now retries and shows an actionable error after 30 seconds.
|
||||||
|
- Fixed native Wayland drag-and-drop from file managers like Thunar, and fixed system-wide mouse lag on Windows caused by the overlay's click-through handling.
|
||||||
|
|
||||||
|
- Stats Dashboard
|
||||||
|
- Deletes, library merges, video moves, and AniList reassignments no longer freeze the stats dashboard or rebuild lifetime totals from scratch; large deletes that used to take minutes now finish in milliseconds.
|
||||||
|
- Vocabulary totals and charts now count all tracked vocabulary instead of just the first page, and new-word history uses corrected daily rollups.
|
||||||
|
- Calendar labels respect time zones west of UTC, and vocabulary cards refresh automatically after editing the word exclusion list (with a Retry option if a load fails).
|
||||||
|
|
||||||
|
- Linux Launcher Thumbnails
|
||||||
|
- Fixed missing MKV thumbnails in the Linux rofi picker when the system thumbnailer only registers legacy Matroska MIME aliases.
|
||||||
|
|
||||||
## What's Changed
|
## What's Changed
|
||||||
|
|
||||||
@@ -47,6 +56,13 @@
|
|||||||
- fix(overlay): support native Wayland file drag-and-drop by @ksyasuda in #199
|
- fix(overlay): support native Wayland file drag-and-drop by @ksyasuda in #199
|
||||||
- fix(overlay): keep macOS modal windows on fullscreen Spaces by @ksyasuda in #200
|
- fix(overlay): keep macOS modal windows on fullscreen Spaces by @ksyasuda in #200
|
||||||
- fix(overlay): prevent Windows mouse lag during click-through tracking by @ksyasuda in #201
|
- fix(overlay): prevent Windows mouse lag during click-through tracking by @ksyasuda in #201
|
||||||
|
- fix(stats): report complete vocabulary totals and new-word history by @ksyasuda in #202
|
||||||
|
- fix(mpv): recover from stalled IPC connects by @ksyasuda in #204
|
||||||
|
- fix(dictionary): prevent freezes and restore AppImage notifications by @ksyasuda in #205
|
||||||
|
- fix(subtitles): recover canonical lines from ASS animation by @ksyasuda in #207
|
||||||
|
- fix(overlay): deduplicate secondary subtitle rendering by @ksyasuda in #208
|
||||||
|
- fix(launcher): restore Matroska thumbnails in Linux rofi picker by @ksyasuda in #210
|
||||||
|
- fix(character-dictionary): cache completed MeCab refreshes by @ksyasuda in #212
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Build macOS window tracking helper binary
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
SWIFT_SOURCE="$SCRIPT_DIR/get-mpv-window-macos.swift"
|
|
||||||
OUTPUT_DIR="$SCRIPT_DIR/../dist/scripts"
|
|
||||||
OUTPUT_BINARY="$OUTPUT_DIR/get-mpv-window-macos"
|
|
||||||
OUTPUT_SOURCE_COPY="$OUTPUT_DIR/get-mpv-window-macos.swift"
|
|
||||||
|
|
||||||
fallback_to_source() {
|
|
||||||
echo "Falling back to source fallback: $OUTPUT_SOURCE_COPY"
|
|
||||||
mkdir -p "$OUTPUT_DIR"
|
|
||||||
cp "$SWIFT_SOURCE" "$OUTPUT_SOURCE_COPY"
|
|
||||||
}
|
|
||||||
|
|
||||||
build_swift_helper() {
|
|
||||||
echo "Compiling macOS window tracking helper..."
|
|
||||||
if ! command -v swiftc >/dev/null 2>&1; then
|
|
||||||
echo "swiftc not found in PATH; skipping compilation."
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! swiftc -O "$SWIFT_SOURCE" -o "$OUTPUT_BINARY"; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
chmod +x "$OUTPUT_BINARY"
|
|
||||||
echo "✓ Built $OUTPUT_BINARY"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Optional skip flag for non-macOS CI/dev environments
|
|
||||||
if [[ "${SUBMINER_SKIP_MACOS_HELPER_BUILD:-}" == "1" ]]; then
|
|
||||||
echo "Skipping macOS helper build (SUBMINER_SKIP_MACOS_HELPER_BUILD=1)"
|
|
||||||
fallback_to_source
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Only build on macOS
|
|
||||||
if [[ "$(uname)" != "Darwin" ]]; then
|
|
||||||
echo "Skipping macOS helper build (not on macOS)"
|
|
||||||
fallback_to_source
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create output directory
|
|
||||||
mkdir -p "$OUTPUT_DIR"
|
|
||||||
|
|
||||||
# Compile Swift script to binary, fallback to source if unavailable or compilation fails
|
|
||||||
if ! build_swift_helper; then
|
|
||||||
fallback_to_source
|
|
||||||
fi
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
FILE="${1:-}"
|
||||||
|
|
||||||
|
if [[ ! -f "$FILE" ]]; then
|
||||||
|
printf 'Not a file: %s\n' "${FILE:-<missing>}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! mpv --no-config --no-terminal --msg-level=all=no --vo=null --ao=null --frames=1 -- "$FILE"; then
|
||||||
|
printf 'Not playable by mpv: %s\n' "$FILE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec subminer app --dev --launch-mpv "$FILE"
|
||||||
@@ -19,7 +19,8 @@ Options:
|
|||||||
-w, --webp Generate animated WebP preview
|
-w, --webp Generate animated WebP preview
|
||||||
|
|
||||||
Encoding profile:
|
Encoding profile:
|
||||||
- Crop: 1920x1080 at x=760 y=200
|
- Crop: mpv region at 1920x1080, x=760 y=205 on a 3440x1440 canvas
|
||||||
|
- Output size: 1920x1080
|
||||||
- MP4: H.264 + AAC
|
- MP4: H.264 + AAC
|
||||||
- WebM: AV1/VP9 + Opus at 30 fps
|
- WebM: AV1/VP9 + Opus at 30 fps
|
||||||
USAGE
|
USAGE
|
||||||
@@ -148,7 +149,8 @@ pick_webp_encoder() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
crop_vf="crop=1920:1080:760:205"
|
# OBS may resize the 3440x1440 canvas, so scale the mpv bounds with the input.
|
||||||
|
crop_vf="crop=1920*iw/3440:1080*ih/1440:760*iw/3440:205*ih/1440,scale=1920:1080:flags=lanczos"
|
||||||
webm_vf="${crop_vf},fps=30"
|
webm_vf="${crop_vf},fps=30"
|
||||||
|
|
||||||
echo "Generating MP4: $mp4_out"
|
echo "Generating MP4: $mp4_out"
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function toBashPath(filePath: string): string {
|
|||||||
return `${drive.toUpperCase()}:/${rest}`;
|
return `${drive.toUpperCase()}:/${rest}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('mkv-to-readme-video accepts libwebp_anim when libwebp is unavailable', () => {
|
test('mkv-to-readme-video builds every output with the scaled mpv crop', () => {
|
||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const binDir = path.join(root, 'bin');
|
const binDir = path.join(root, 'bin');
|
||||||
const inputPath = path.join(root, 'sample.mkv');
|
const inputPath = path.join(root, 'sample.mkv');
|
||||||
@@ -104,5 +104,9 @@ touch "$output"
|
|||||||
|
|
||||||
const ffmpegLog = fs.readFileSync(ffmpegLogPath, 'utf8');
|
const ffmpegLog = fs.readFileSync(ffmpegLogPath, 'utf8');
|
||||||
assert.match(ffmpegLog, /-c:v libwebp_anim/);
|
assert.match(ffmpegLog, /-c:v libwebp_anim/);
|
||||||
|
const scaledCropUses = ffmpegLog.match(
|
||||||
|
/-vf crop=1920\*iw\/3440:1080\*ih\/1440:760\*iw\/3440:205\*ih\/1440,scale=1920:1080:flags=lanczos/g,
|
||||||
|
);
|
||||||
|
assert.equal(scaledCropUses?.length, 4);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -52,6 +53,16 @@ function fallbackToMacosSource() {
|
|||||||
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
|
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pin the minimum macOS to the app's own floor (Electron's `minos`). Without an
|
||||||
|
// explicit target, swiftc stamps the build machine's OS version as the binary's
|
||||||
|
// minimum and the helper fails to load on older systems (#213). The arch stays
|
||||||
|
// the host's, matching the single-arch app electron-builder packages here.
|
||||||
|
const MACOS_HELPER_DEPLOYMENT_TARGET = '12.0';
|
||||||
|
|
||||||
|
function macosHelperTarget() {
|
||||||
|
return `${os.arch() === 'x64' ? 'x86_64' : 'arm64'}-apple-macos${MACOS_HELPER_DEPLOYMENT_TARGET}`;
|
||||||
|
}
|
||||||
|
|
||||||
function shouldSkipMacosHelperBuild() {
|
function shouldSkipMacosHelperBuild() {
|
||||||
return process.env.SUBMINER_SKIP_MACOS_HELPER_BUILD === '1';
|
return process.env.SUBMINER_SKIP_MACOS_HELPER_BUILD === '1';
|
||||||
}
|
}
|
||||||
@@ -72,9 +83,13 @@ function buildMacosHelper() {
|
|||||||
ensureDir(scriptsOutputDir);
|
ensureDir(scriptsOutputDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
execFileSync('swiftc', ['-O', macosHelperSourcePath, '-o', macosHelperBinaryPath], {
|
execFileSync(
|
||||||
stdio: 'inherit',
|
'swiftc',
|
||||||
});
|
['-O', '-target', macosHelperTarget(), macosHelperSourcePath, '-o', macosHelperBinaryPath],
|
||||||
|
{
|
||||||
|
stdio: 'inherit',
|
||||||
|
},
|
||||||
|
);
|
||||||
fs.chmodSync(macosHelperBinaryPath, 0o755);
|
fs.chmodSync(macosHelperBinaryPath, 0o755);
|
||||||
process.stdout.write(`Built macOS helper: ${macosHelperBinaryPath}\n`);
|
process.stdout.write(`Built macOS helper: ${macosHelperBinaryPath}\n`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ test('macOS helper build creates dist scripts directory before swiftc output', (
|
|||||||
const buildFunctionIndex = source.indexOf('function buildMacosHelper()');
|
const buildFunctionIndex = source.indexOf('function buildMacosHelper()');
|
||||||
assert.notEqual(buildFunctionIndex, -1);
|
assert.notEqual(buildFunctionIndex, -1);
|
||||||
|
|
||||||
const swiftcIndex = source.indexOf("execFileSync('swiftc'", buildFunctionIndex);
|
const swiftcIndex = source.indexOf("'swiftc'", buildFunctionIndex);
|
||||||
assert.notEqual(swiftcIndex, -1);
|
assert.notEqual(swiftcIndex, -1);
|
||||||
|
|
||||||
const ensureDirIndex = source.lastIndexOf('ensureDir(scriptsOutputDir)', swiftcIndex);
|
const ensureDirIndex = source.lastIndexOf('ensureDir(scriptsOutputDir)', swiftcIndex);
|
||||||
@@ -18,3 +18,10 @@ test('macOS helper build creates dist scripts directory before swiftc output', (
|
|||||||
'buildMacosHelper must create dist/scripts before swiftc writes the helper binary',
|
'buildMacosHelper must create dist/scripts before swiftc writes the helper binary',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression guard for #213: an untargeted swiftc stamps the build machine's OS
|
||||||
|
// version as the helper's minimum, so released builds refuse to load on older macOS.
|
||||||
|
test('macOS helper is compiled with an explicit deployment target', () => {
|
||||||
|
assert.match(source, /-target/);
|
||||||
|
assert.match(source, /apple-macos\$\{MACOS_HELPER_DEPLOYMENT_TARGET\}/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
|
|||||||
pkgbuild,
|
pkgbuild,
|
||||||
/^\s*install -Dm755 "\$\{srcdir\}\/subminer-\$\{pkgver\}" "\$\{pkgdir\}\/usr\/bin\/subminer"$/m,
|
/^\s*install -Dm755 "\$\{srcdir\}\/subminer-\$\{pkgver\}" "\$\{pkgdir\}\/usr\/bin\/subminer"$/m,
|
||||||
);
|
);
|
||||||
|
assert.match(pkgbuild, /assets\/thumbnailers\/subminer-ffmpegthumbnailer\.thumbnailer/);
|
||||||
assert.match(srcinfo, /^\tpkgver = 0\.6\.3$/m);
|
assert.match(srcinfo, /^\tpkgver = 0\.6\.3$/m);
|
||||||
assert.match(srcinfo, /^\tprovides = subminer=0\.6\.3$/m);
|
assert.match(srcinfo, /^\tprovides = subminer=0\.6\.3$/m);
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ export {
|
|||||||
} from './tokenizer/yomitan-parser-runtime';
|
} from './tokenizer/yomitan-parser-runtime';
|
||||||
export { syncYomitanDefaultAnkiServer } from './tokenizer/yomitan-parser-runtime';
|
export { syncYomitanDefaultAnkiServer } from './tokenizer/yomitan-parser-runtime';
|
||||||
export { createSubtitleProcessingController } from './subtitle-processing-controller';
|
export { createSubtitleProcessingController } from './subtitle-processing-controller';
|
||||||
|
export {
|
||||||
|
resolveSanitizedSubtitleSeekCommand,
|
||||||
|
subtitleCueListSeekTime,
|
||||||
|
subtitleCueSeekTime,
|
||||||
|
} from './subtitle-cue-navigation';
|
||||||
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
|
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
|
||||||
export { createJlptVocabularyLookup } from './jlpt-vocab';
|
export { createJlptVocabularyLookup } from './jlpt-vocab';
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const MIN_FLATTENED_DUPLICATE_LENGTH = 16;
|
||||||
|
const TERMINAL_SENTENCE_PUNCTUATION = /[.!?。!?…⋯]+$/gu;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identifies long lines that become duplicates when positioned ASS events are
|
||||||
|
* flattened into the secondary subtitle bar. Short dialogue stays distinct.
|
||||||
|
*/
|
||||||
|
export function flattenedSecondarySubtitleLineIdentity(text: string): string | null {
|
||||||
|
const identity = text
|
||||||
|
.normalize('NFKC')
|
||||||
|
.replace(/\s+/gu, '')
|
||||||
|
.replace(TERMINAL_SENTENCE_PUNCTUATION, '');
|
||||||
|
|
||||||
|
return identity.length >= MIN_FLATTENED_DUPLICATE_LENGTH ? identity : null;
|
||||||
|
}
|
||||||
@@ -149,8 +149,8 @@ function collectRepeatedPhaseRuns(cues: AnnotatedSubtitleCue[]): RepeatedPhaseRu
|
|||||||
const isFlush =
|
const isFlush =
|
||||||
Math.abs(next.startTime - current.endTime) <= DUPLICATE_CUE_GAP_TOLERANCE_SECONDS;
|
Math.abs(next.startTime - current.endTime) <= DUPLICATE_CUE_GAP_TOLERANCE_SECONDS;
|
||||||
if (
|
if (
|
||||||
first.source === 'canonical-ass' ||
|
first.source !== undefined ||
|
||||||
next.source === 'canonical-ass' ||
|
next.source !== undefined ||
|
||||||
next.text !== first.text ||
|
next.text !== first.text ||
|
||||||
assStyleKey(next) !== styleKey ||
|
assStyleKey(next) !== styleKey ||
|
||||||
!isFlush
|
!isFlush
|
||||||
@@ -223,7 +223,7 @@ function countFramesShorterThan(run: AnnotatedSubtitleCue[], maxSeconds: number)
|
|||||||
* anything wrapped in `\t(...)`), an animated `Effect` column, or a value that actually
|
* anything wrapped in `\t(...)`), an animated `Effect` column, or a value that actually
|
||||||
* changes from event to event, which is how per-frame typesetting is authored.
|
* changes from event to event, which is how per-frame typesetting is authored.
|
||||||
*/
|
*/
|
||||||
export function hasAssAnimationEvidence(run: AnnotatedSubtitleCue[]): boolean {
|
export function hasAssAnimationEvidence(run: readonly AnnotatedSubtitleCue[]): boolean {
|
||||||
if (run.every((cue) => hasAssTemporalOverride(cue.overrides))) {
|
if (run.every((cue) => hasAssTemporalOverride(cue.overrides))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
resolveSanitizedSubtitleSeekCommand,
|
||||||
|
subtitleCueListSeekTime,
|
||||||
|
subtitleCueSeekTime,
|
||||||
|
} from './subtitle-cue-navigation';
|
||||||
|
|
||||||
|
test('next subtitle navigation skips generated ASS events and seeks to the next sanitized cue', () => {
|
||||||
|
const cues = [
|
||||||
|
{
|
||||||
|
startTime: 10,
|
||||||
|
endTime: 13,
|
||||||
|
text: 'first lyric',
|
||||||
|
source: 'canonical-ass' as const,
|
||||||
|
animationStartTime: 9.7,
|
||||||
|
animationEndTime: 13.4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
startTime: 13,
|
||||||
|
endTime: 16,
|
||||||
|
text: 'second lyric',
|
||||||
|
source: 'canonical-ass' as const,
|
||||||
|
animationStartTime: 12.7,
|
||||||
|
animationEndTime: 16.4,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), [
|
||||||
|
'seek',
|
||||||
|
13.08,
|
||||||
|
'absolute+exact',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('next subtitle navigation treats simultaneous sanitized cues as one line boundary', () => {
|
||||||
|
const cues = [
|
||||||
|
{ startTime: 10, endTime: 13, text: 'romaji' },
|
||||||
|
{ startTime: 10.02, endTime: 13, text: 'English' },
|
||||||
|
{ startTime: 13, endTime: 16, text: 'next romaji' },
|
||||||
|
{ startTime: 13.02, endTime: 16, text: 'Next English' },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1), [
|
||||||
|
'seek',
|
||||||
|
13.08,
|
||||||
|
'absolute+exact',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('next subtitle navigation advances past the latest overlapping lyric', () => {
|
||||||
|
const cues = [
|
||||||
|
{ startTime: 10, endTime: 14, text: 'exiting lyric' },
|
||||||
|
{ startTime: 13, endTime: 16, text: 'current lyric' },
|
||||||
|
{ startTime: 16, endTime: 19, text: 'next lyric' },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2), [
|
||||||
|
'seek',
|
||||||
|
16.08,
|
||||||
|
'absolute+exact',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('previous subtitle navigation leaves the current cue and seeks to the prior cue', () => {
|
||||||
|
const cues = [
|
||||||
|
{ startTime: 10, endTime: 12, text: 'first line' },
|
||||||
|
{ startTime: 13, endTime: 16, text: 'current line' },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5), [
|
||||||
|
'seek',
|
||||||
|
10.08,
|
||||||
|
'absolute+exact',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('subtitle navigation falls back when no sanitized destination exists', () => {
|
||||||
|
const cues = [{ startTime: 10, endTime: 13, text: 'only line' }];
|
||||||
|
|
||||||
|
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), null);
|
||||||
|
assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar cue seeks share the boundary-safe sanitized cue timestamp', () => {
|
||||||
|
assert.equal(subtitleCueSeekTime({ startTime: 1, endTime: 2, text: 'line' }), 1.08);
|
||||||
|
assert.equal(subtitleCueSeekTime({ startTime: 1, endTime: 1.04, text: 'short' }), 1.03);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar cue selection clears an overlapping previous lyric', () => {
|
||||||
|
const cues = [
|
||||||
|
{ startTime: 1, endTime: 3.4, text: 'previous lyric' },
|
||||||
|
{ startTime: 3, endTime: 5, text: 'selected lyric' },
|
||||||
|
];
|
||||||
|
|
||||||
|
assert.equal(subtitleCueListSeekTime(cues, cues[1]!), 3.48);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sidebar cue selection remains inside a short cue when overlap cannot be cleared', () => {
|
||||||
|
const cues = [
|
||||||
|
{ startTime: 1, endTime: 3.4, text: 'previous lyric' },
|
||||||
|
{ startTime: 3, endTime: 3.2, text: 'selected lyric' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const seekTime = subtitleCueListSeekTime(cues, cues[1]!);
|
||||||
|
assert.ok(seekTime >= 3.19);
|
||||||
|
assert.ok(seekTime < cues[1]!.endTime);
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { SubtitleCue } from './subtitle-cue-parser';
|
||||||
|
|
||||||
|
const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05;
|
||||||
|
const CUE_BOUNDARY_SEEK_OFFSET_SECONDS = 0.08;
|
||||||
|
const CUE_END_GUARD_SECONDS = 0.01;
|
||||||
|
|
||||||
|
type CueGroup = {
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
cue: SubtitleCue;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isValidCue(cue: SubtitleCue): boolean {
|
||||||
|
return (
|
||||||
|
Number.isFinite(cue.startTime) && Number.isFinite(cue.endTime) && cue.endTime > cue.startTime
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupCueBoundaries(cues: readonly SubtitleCue[]): CueGroup[] {
|
||||||
|
const sorted = cues.filter(isValidCue).sort((left, right) => {
|
||||||
|
return left.startTime - right.startTime || left.endTime - right.endTime;
|
||||||
|
});
|
||||||
|
const groups: CueGroup[] = [];
|
||||||
|
|
||||||
|
for (const cue of sorted) {
|
||||||
|
const current = groups.at(-1);
|
||||||
|
if (current && cue.startTime - current.startTime <= CUE_START_GROUP_TOLERANCE_SECONDS) {
|
||||||
|
current.endTime = Math.max(current.endTime, cue.endTime);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
groups.push({ startTime: cue.startTime, endTime: cue.endTime, cue });
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A small offset avoids asking mpv to render exactly on a subtitle boundary. */
|
||||||
|
export function subtitleCueSeekTime(cue: SubtitleCue): number {
|
||||||
|
return Math.max(
|
||||||
|
cue.startTime,
|
||||||
|
Math.min(cue.endTime - CUE_END_GUARD_SECONDS, cue.startTime + CUE_BOUNDARY_SEEK_OFFSET_SECONDS),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose a stable point inside a selected cue. Karaoke lines can overlap while the
|
||||||
|
* previous line animates out, so a sidebar selection should clear that overlap when
|
||||||
|
* the selected cue has enough time remaining.
|
||||||
|
*/
|
||||||
|
export function subtitleCueListSeekTime(
|
||||||
|
cues: readonly SubtitleCue[],
|
||||||
|
selectedCue: SubtitleCue,
|
||||||
|
): number {
|
||||||
|
const groups = groupCueBoundaries(cues);
|
||||||
|
const selectedGroupIndex = groups.findIndex(
|
||||||
|
(group) =>
|
||||||
|
selectedCue.startTime >= group.startTime &&
|
||||||
|
selectedCue.startTime - group.startTime <= CUE_START_GROUP_TOLERANCE_SECONDS,
|
||||||
|
);
|
||||||
|
const previousGroupEndTime =
|
||||||
|
selectedGroupIndex > 0 ? groups[selectedGroupIndex - 1]?.endTime : undefined;
|
||||||
|
if (previousGroupEndTime === undefined || previousGroupEndTime <= selectedCue.startTime) {
|
||||||
|
return subtitleCueSeekTime(selectedCue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(
|
||||||
|
selectedCue.startTime,
|
||||||
|
Math.min(
|
||||||
|
selectedCue.endTime - CUE_END_GUARD_SECONDS,
|
||||||
|
previousGroupEndTime + CUE_BOUNDARY_SEEK_OFFSET_SECONDS,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate mpv subtitle-line navigation onto parsed cues. Generated ASS karaoke can
|
||||||
|
* contain hundreds of subtitle events for one visible line, while the parsed list has
|
||||||
|
* already collapsed those events into the authored lines the user expects to navigate.
|
||||||
|
*/
|
||||||
|
export function resolveSanitizedSubtitleSeekCommand(
|
||||||
|
command: readonly (string | number)[],
|
||||||
|
cues: readonly SubtitleCue[],
|
||||||
|
currentTimeSec: number,
|
||||||
|
): (string | number)[] | null {
|
||||||
|
if (
|
||||||
|
command.length < 2 ||
|
||||||
|
command[0] !== 'sub-seek' ||
|
||||||
|
(command[1] !== -1 && command[1] !== 1) ||
|
||||||
|
!Number.isFinite(currentTimeSec)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = groupCueBoundaries(cues);
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeIndex = -1;
|
||||||
|
for (const [index, group] of groups.entries()) {
|
||||||
|
if (group.startTime <= currentTimeSec && group.endTime > currentTimeSec) {
|
||||||
|
activeIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let destination: CueGroup | undefined;
|
||||||
|
if (command[1] === 1) {
|
||||||
|
destination =
|
||||||
|
activeIndex >= 0
|
||||||
|
? groups[activeIndex + 1]
|
||||||
|
: groups.find((group) => group.startTime > currentTimeSec);
|
||||||
|
} else if (activeIndex >= 0) {
|
||||||
|
destination = groups[activeIndex - 1];
|
||||||
|
} else {
|
||||||
|
for (let index = groups.length - 1; index >= 0; index -= 1) {
|
||||||
|
const group = groups[index]!;
|
||||||
|
if (group.startTime < currentTimeSec) {
|
||||||
|
destination = group;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!destination) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return ['seek', subtitleCueSeekTime(destination.cue), 'absolute+exact'];
|
||||||
|
}
|
||||||
@@ -570,6 +570,63 @@ test('parseSubtitleCues does not promote a short animated fragment as a complete
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues keeps short animated English dialogue as separate cues', () => {
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:02.00,English Dialogue,,0,0,0,,{\\t(0,100,\\fscx110)}Hi',
|
||||||
|
'Dialogue: 0,0:00:02.00,0:00:03.00,English Dialogue,,0,0,0,,{\\t(0,100,\\fscx110)}No',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||||
|
{ startTime: 1, endTime: 2, text: 'Hi' },
|
||||||
|
{ startTime: 2, endTime: 3, text: 'No' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues does not reconstruct an already canonical English cue', () => {
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Comment: 0,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\move(100,100,120,100)}POOF',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:01.04,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 1 1)}POOF',
|
||||||
|
'Dialogue: 0,0:00:01.04,0:00:01.08,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 2 2)}POOF',
|
||||||
|
'Dialogue: 0,0:00:01.08,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 3 3)}POOF',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||||
|
{
|
||||||
|
startTime: 1,
|
||||||
|
endTime: 3,
|
||||||
|
text: 'POOF',
|
||||||
|
source: 'canonical-ass',
|
||||||
|
animationStartTime: 1,
|
||||||
|
animationEndTime: 3,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues reconstructs a short positioned fragment without a lyric style name', () => {
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:03.00,Karaoke,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx110)}Oh',
|
||||||
|
'Dialogue: 1,0:00:01.00,0:00:03.00,Karaoke,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx110)}Oh',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||||
|
{
|
||||||
|
startTime: 1,
|
||||||
|
endTime: 3,
|
||||||
|
text: 'Oh',
|
||||||
|
source: 'reconstructed-ass',
|
||||||
|
animationStartTime: 1,
|
||||||
|
animationEndTime: 3,
|
||||||
|
assStyle: 'Karaoke',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('parseSubtitleCues ignores timed comments without a matching animated dialogue cluster', () => {
|
test('parseSubtitleCues ignores timed comments without a matching animated dialogue cluster', () => {
|
||||||
const content = [
|
const content = [
|
||||||
'[Events]',
|
'[Events]',
|
||||||
|
|||||||
@@ -8,19 +8,27 @@ import {
|
|||||||
} from './ass-text';
|
} from './ass-text';
|
||||||
import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedup';
|
import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedup';
|
||||||
|
|
||||||
|
export type AssCueLayout =
|
||||||
|
| { kind: 'positioned'; sourceOrder: number; y: number }
|
||||||
|
| { kind: 'fragment-grid'; sourceOrder: number }
|
||||||
|
| { kind: 'source-order'; sourceOrder: number };
|
||||||
|
|
||||||
export interface SubtitleCue {
|
export interface SubtitleCue {
|
||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
text: string;
|
text: string;
|
||||||
/** A complete authored line recovered from matching generated ASS animation events. */
|
/** How a complete line was recovered from generated ASS animation events. */
|
||||||
source?: 'canonical-ass';
|
source?: 'canonical-ass' | 'reconstructed-ass';
|
||||||
/**
|
/**
|
||||||
* Full span of the generated animation events a canonical cue replaced. Entrance and
|
* Full span of the generated animation events a recovered cue replaced. Entrance and
|
||||||
* exit frames routinely run past the authored `startTime`/`endTime`, so live-text
|
* exit frames can run past canonical authored timing.
|
||||||
* matching must use this envelope while display and history keep the authored timing.
|
|
||||||
*/
|
*/
|
||||||
animationStartTime?: number;
|
animationStartTime?: number;
|
||||||
animationEndTime?: number;
|
animationEndTime?: number;
|
||||||
|
/** ASS style retained only for fragment-reconstructed lines. */
|
||||||
|
assStyle?: string;
|
||||||
|
/** Authored ASS ordering metadata used when flattening simultaneous positioned cues. */
|
||||||
|
assLayout?: AssCueLayout;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,7 +37,7 @@ export interface SubtitleCue {
|
|||||||
* override commands it carries, whether the `Effect` column was set -- to tell a karaoke
|
* override commands it carries, whether the `Effect` column was set -- to tell a karaoke
|
||||||
* burst apart from two characters saying the same word in turn. None of it is meaningful
|
* burst apart from two characters saying the same word in turn. None of it is meaningful
|
||||||
* outside the parser, so the public API exposes only timing, text, and the optional
|
* outside the parser, so the public API exposes only timing, text, and the optional
|
||||||
* canonical-source marker used by live subtitle consumers.
|
* recovery marker used by live subtitle consumers.
|
||||||
*/
|
*/
|
||||||
export interface AnnotatedSubtitleCue extends SubtitleCue {
|
export interface AnnotatedSubtitleCue extends SubtitleCue {
|
||||||
/** Text exactly as authored, override blocks and all. */
|
/** Text exactly as authored, override blocks and all. */
|
||||||
@@ -75,15 +83,55 @@ function parseTimestamp(
|
|||||||
* line breaks, matching what mpv hands over for the same line played live. No layer
|
* line breaks, matching what mpv hands over for the same line played live. No layer
|
||||||
* downstream decodes ASS again.
|
* downstream decodes ASS again.
|
||||||
*/
|
*/
|
||||||
|
function decodeSubtitleCueText(text: string): string {
|
||||||
|
return assToPlainText(text, '\n').replace(HTML_SUBTITLE_TAG_PATTERN, '');
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeSubtitleCueText(text: string): string {
|
function sanitizeSubtitleCueText(text: string): string {
|
||||||
return assToPlainText(text, '\n').replace(HTML_SUBTITLE_TAG_PATTERN, '').trim();
|
return decodeSubtitleCueText(text).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachAssLayout<T extends SubtitleCue>(cue: T, assLayout: AssCueLayout | undefined): T {
|
||||||
|
if (assLayout) {
|
||||||
|
Object.defineProperty(cue, 'assLayout', { value: assLayout, enumerable: false });
|
||||||
|
}
|
||||||
|
return cue;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
|
||||||
return cues.map(({ startTime, endTime, text, source, animationStartTime, animationEndTime }) =>
|
return cues.map(
|
||||||
source
|
({
|
||||||
? { startTime, endTime, text, source, animationStartTime, animationEndTime }
|
startTime,
|
||||||
: { startTime, endTime, text },
|
endTime,
|
||||||
|
text,
|
||||||
|
source,
|
||||||
|
animationStartTime,
|
||||||
|
animationEndTime,
|
||||||
|
style,
|
||||||
|
assLayout,
|
||||||
|
}) => {
|
||||||
|
const common = {
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
if (source === 'reconstructed-ass') {
|
||||||
|
return attachAssLayout(
|
||||||
|
{
|
||||||
|
...common,
|
||||||
|
source,
|
||||||
|
animationStartTime,
|
||||||
|
animationEndTime,
|
||||||
|
assStyle: style,
|
||||||
|
},
|
||||||
|
assLayout,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return attachAssLayout(
|
||||||
|
source ? { ...common, source, animationStartTime, animationEndTime } : common,
|
||||||
|
assLayout,
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +207,11 @@ const MIN_CANONICAL_ANIMATION_EVENTS = 3;
|
|||||||
// A tiny animated fragment can itself be composed from still smaller glyph events. It is
|
// A tiny animated fragment can itself be composed from still smaller glyph events. It is
|
||||||
// not enough evidence that the fragment represents an authored line boundary.
|
// not enough evidence that the fragment represents an authored line boundary.
|
||||||
const MIN_CANONICAL_DIALOGUE_TEXT_LENGTH = 4;
|
const MIN_CANONICAL_DIALOGUE_TEXT_LENGTH = 4;
|
||||||
|
const MIN_FRAGMENT_LINE_EVENTS = 8;
|
||||||
|
const MIN_FRAGMENT_LINE_PARTS = 4;
|
||||||
|
const MAX_FRAGMENT_MEDIAN_LENGTH = 4;
|
||||||
|
const MAX_FRAGMENT_LINE_TIMING_VARIANCE_SECONDS = 2;
|
||||||
|
const MAX_FRAGMENT_LINE_VERTICAL_SPAN = 48;
|
||||||
|
|
||||||
function parseAssTimestamp(raw: string): number | null {
|
function parseAssTimestamp(raw: string): number | null {
|
||||||
const match = ASS_TIMING_PATTERN.exec(raw.trim());
|
const match = ASS_TIMING_PATTERN.exec(raw.trim());
|
||||||
@@ -296,6 +349,240 @@ function isRepeatedFragmentCopy(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasRelaxedAssFragmentEvidence(events: readonly AnnotatedSubtitleCue[]): boolean {
|
||||||
|
if (events.length < 2 || !events.every((event) => fragmentPlacementAnchors(event).size > 0)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestStart = events.reduce(
|
||||||
|
(latest, event) => Math.max(latest, event.startTime),
|
||||||
|
-Infinity,
|
||||||
|
);
|
||||||
|
const earliestEnd = events.reduce(
|
||||||
|
(earliest, event) => Math.min(earliest, event.endTime),
|
||||||
|
Infinity,
|
||||||
|
);
|
||||||
|
if (latestStart >= earliestEnd) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = events[0]!;
|
||||||
|
const hasChangingOverrides = events.some(
|
||||||
|
(event) => event.overrideSignature !== first.overrideSignature,
|
||||||
|
);
|
||||||
|
const hasPositionedLayerCopy = events.some((event, index) =>
|
||||||
|
events
|
||||||
|
.slice(0, index)
|
||||||
|
.some(
|
||||||
|
(previous) =>
|
||||||
|
compactCueMatchText(previous) === compactCueMatchText(event) &&
|
||||||
|
isRepeatedFragmentCopy(previous, event),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return hasChangingOverrides || hasPositionedLayerCopy;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssFragmentPart {
|
||||||
|
cue: AnnotatedSubtitleCue;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconstructedAssFragmentLayout(
|
||||||
|
parts: readonly AssFragmentPart[],
|
||||||
|
owner: AnnotatedSubtitleCue,
|
||||||
|
): AssCueLayout | undefined {
|
||||||
|
let positionedPartCount = 0;
|
||||||
|
let minimumY = Infinity;
|
||||||
|
let maximumY = -Infinity;
|
||||||
|
for (const part of parts) {
|
||||||
|
const layout = part.cue.assLayout;
|
||||||
|
if (layout?.kind !== 'positioned') continue;
|
||||||
|
positionedPartCount += 1;
|
||||||
|
minimumY = Math.min(minimumY, layout.y);
|
||||||
|
maximumY = Math.max(maximumY, layout.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
positionedPartCount >= MIN_FRAGMENT_LINE_PARTS &&
|
||||||
|
maximumY - minimumY > MAX_FRAGMENT_LINE_VERTICAL_SPAN
|
||||||
|
) {
|
||||||
|
return { kind: 'fragment-grid', sourceOrder: owner.order };
|
||||||
|
}
|
||||||
|
return owner.assLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssFragmentTimingCluster {
|
||||||
|
events: AnnotatedSubtitleCue[];
|
||||||
|
minStartTime: number;
|
||||||
|
maxStartTime: number;
|
||||||
|
minEndTime: number;
|
||||||
|
maxEndTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToFragmentTimingCluster(
|
||||||
|
cluster: AssFragmentTimingCluster,
|
||||||
|
cue: AnnotatedSubtitleCue,
|
||||||
|
): void {
|
||||||
|
cluster.events.push(cue);
|
||||||
|
cluster.minStartTime = Math.min(cluster.minStartTime, cue.startTime);
|
||||||
|
cluster.maxStartTime = Math.max(cluster.maxStartTime, cue.startTime);
|
||||||
|
cluster.minEndTime = Math.min(cluster.minEndTime, cue.endTime);
|
||||||
|
cluster.maxEndTime = Math.max(cluster.maxEndTime, cue.endTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fragmentTimingDistance(
|
||||||
|
cluster: AssFragmentTimingCluster,
|
||||||
|
cue: AnnotatedSubtitleCue,
|
||||||
|
): number {
|
||||||
|
const nextMinStart = Math.min(cluster.minStartTime, cue.startTime);
|
||||||
|
const nextMaxStart = Math.max(cluster.maxStartTime, cue.startTime);
|
||||||
|
const nextMinEnd = Math.min(cluster.minEndTime, cue.endTime);
|
||||||
|
const nextMaxEnd = Math.max(cluster.maxEndTime, cue.endTime);
|
||||||
|
if (
|
||||||
|
nextMaxStart - nextMinStart > MAX_FRAGMENT_LINE_TIMING_VARIANCE_SECONDS ||
|
||||||
|
nextMaxEnd - nextMinEnd > MAX_FRAGMENT_LINE_TIMING_VARIANCE_SECONDS
|
||||||
|
) {
|
||||||
|
return Infinity;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
Math.abs(cue.startTime - (cluster.minStartTime + cluster.maxStartTime) / 2) +
|
||||||
|
Math.abs(cue.endTime - (cluster.minEndTime + cluster.maxEndTime) / 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clusterAssFragmentEvents(
|
||||||
|
events: readonly AnnotatedSubtitleCue[],
|
||||||
|
): AssFragmentTimingCluster[] {
|
||||||
|
const clusters: AssFragmentTimingCluster[] = [];
|
||||||
|
for (const cue of events) {
|
||||||
|
let nearest: AssFragmentTimingCluster | null = null;
|
||||||
|
let nearestDistance = Infinity;
|
||||||
|
for (const cluster of clusters) {
|
||||||
|
const distance = fragmentTimingDistance(cluster, cue);
|
||||||
|
if (distance < nearestDistance) {
|
||||||
|
nearest = cluster;
|
||||||
|
nearestDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (nearest) {
|
||||||
|
addToFragmentTimingCluster(nearest, cue);
|
||||||
|
} else {
|
||||||
|
clusters.push({
|
||||||
|
events: [cue],
|
||||||
|
minStartTime: cue.startTime,
|
||||||
|
maxStartTime: cue.startTime,
|
||||||
|
minEndTime: cue.endTime,
|
||||||
|
maxEndTime: cue.endTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clusters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeSingleAssFragment(cue: AnnotatedSubtitleCue): string | null {
|
||||||
|
const visibleLines = decodeSubtitleCueText(cue.rawText)
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.trim().length > 0);
|
||||||
|
return visibleLines.length === 1 ? visibleLines[0]! : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconstructAssFragmentLine(
|
||||||
|
events: readonly AnnotatedSubtitleCue[],
|
||||||
|
): AnnotatedSubtitleCue | null {
|
||||||
|
const hasRelaxedEvidence = hasRelaxedAssFragmentEvidence(events);
|
||||||
|
const minimumEvents = hasRelaxedEvidence ? 2 : MIN_FRAGMENT_LINE_EVENTS;
|
||||||
|
if (events.length < minimumEvents || !hasAssAnimationEvidence(events)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: AssFragmentPart[] = [];
|
||||||
|
for (const cue of events) {
|
||||||
|
const text = decodeSingleAssFragment(cue);
|
||||||
|
if (text === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const compactText = compactAssMatchText(text);
|
||||||
|
const isLayerCopy = parts.some(
|
||||||
|
(part) =>
|
||||||
|
compactAssMatchText(part.text) === compactText && isRepeatedFragmentCopy(part.cue, cue),
|
||||||
|
);
|
||||||
|
if (!isLayerCopy) {
|
||||||
|
parts.push({ cue, text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minimumParts = hasRelaxedEvidence ? 1 : MIN_FRAGMENT_LINE_PARTS;
|
||||||
|
if (parts.length < minimumParts || (!hasRelaxedEvidence && parts.length === events.length)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const lengths = parts
|
||||||
|
.map((part) => compactAssMatchText(part.text).length)
|
||||||
|
.sort((left, right) => left - right);
|
||||||
|
if ((lengths[Math.floor(lengths.length / 2)] ?? Infinity) > MAX_FRAGMENT_MEDIAN_LENGTH) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = parts
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join('')
|
||||||
|
.trim();
|
||||||
|
if (!text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const owner = parts[0]!.cue;
|
||||||
|
const animationStartTime = earliestStartTime(events);
|
||||||
|
const animationEndTime = latestEndTime(events);
|
||||||
|
return {
|
||||||
|
...owner,
|
||||||
|
startTime: animationStartTime,
|
||||||
|
endTime: animationEndTime,
|
||||||
|
text,
|
||||||
|
rawText: text,
|
||||||
|
source: 'reconstructed-ass',
|
||||||
|
animationStartTime,
|
||||||
|
animationEndTime,
|
||||||
|
assLayout: reconstructedAssFragmentLayout(parts, owner),
|
||||||
|
overrides: [],
|
||||||
|
overrideSignature: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function recoverFragmentOnlyAssLines(dialogue: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
|
||||||
|
const groups = new Map<string, AnnotatedSubtitleCue[]>();
|
||||||
|
for (const cue of dialogue) {
|
||||||
|
if (cue.source !== undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = assEventGroupKey(cue);
|
||||||
|
const group = groups.get(key);
|
||||||
|
if (group) {
|
||||||
|
group.push(cue);
|
||||||
|
} else {
|
||||||
|
groups.set(key, [cue]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recovered: AnnotatedSubtitleCue[] = [];
|
||||||
|
const suppressed = new Set<AnnotatedSubtitleCue>();
|
||||||
|
for (const events of groups.values()) {
|
||||||
|
for (const cluster of clusterAssFragmentEvents(events)) {
|
||||||
|
const line = reconstructAssFragmentLine(cluster.events);
|
||||||
|
if (!line) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
recovered.push(line);
|
||||||
|
cluster.events.forEach((event) => suppressed.add(event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (recovered.length === 0) {
|
||||||
|
return dialogue;
|
||||||
|
}
|
||||||
|
return [...dialogue.filter((cue) => !suppressed.has(cue)), ...recovered].sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.startTime - right.startTime || left.endTime - right.endTime || left.order - right.order,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function groupConsecutiveAssFragments(events: readonly AnnotatedSubtitleCue[]): FragmentGroup[] {
|
function groupConsecutiveAssFragments(events: readonly AnnotatedSubtitleCue[]): FragmentGroup[] {
|
||||||
const groups: FragmentGroup[] = [];
|
const groups: FragmentGroup[] = [];
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
@@ -507,6 +794,37 @@ function recoverCanonicalAssEvents({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseAssCoordinate(value: string | undefined): number | null {
|
||||||
|
if (!value?.trim()) return null;
|
||||||
|
const coordinate = Number(value.trim());
|
||||||
|
return Number.isFinite(coordinate) ? coordinate : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAssCueLayout(
|
||||||
|
overrides: readonly AssOverrideCommand[],
|
||||||
|
sourceOrder: number,
|
||||||
|
): AssCueLayout {
|
||||||
|
let y: number | null = null;
|
||||||
|
for (const command of overrides) {
|
||||||
|
if (command.animated) continue;
|
||||||
|
const name = command.name.toLowerCase();
|
||||||
|
const args = command.args.split(',');
|
||||||
|
if (name === 'pos') {
|
||||||
|
y = parseAssCoordinate(args[1]) ?? y;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (name !== 'move') continue;
|
||||||
|
const startY = parseAssCoordinate(args[1]);
|
||||||
|
const endY = parseAssCoordinate(args[3]);
|
||||||
|
if (startY !== null && endY !== null) {
|
||||||
|
y = (startY + endY) / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return y === null
|
||||||
|
? { kind: 'source-order', sourceOrder }
|
||||||
|
: { kind: 'positioned', sourceOrder, y };
|
||||||
|
}
|
||||||
|
|
||||||
function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||||
const cues: AnnotatedSubtitleCue[] = [];
|
const cues: AnnotatedSubtitleCue[] = [];
|
||||||
const comments: AnnotatedSubtitleCue[] = [];
|
const comments: AnnotatedSubtitleCue[] = [];
|
||||||
@@ -535,6 +853,10 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
|||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
|
// Event text can end in an authored space. Fragmented karaoke commonly uses that
|
||||||
|
// space to retain word boundaries when its separately positioned events are joined
|
||||||
|
// back into a line, so only remove indentation before slicing the event fields.
|
||||||
|
const eventLine = line.trimStart();
|
||||||
|
|
||||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||||
inEventsSection = trimmed.toLowerCase() === '[events]';
|
inEventsSection = trimmed.toLowerCase() === '[events]';
|
||||||
@@ -565,9 +887,9 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventPrefix = trimmed.startsWith(ASS_DIALOGUE_PREFIX)
|
const eventPrefix = eventLine.startsWith(ASS_DIALOGUE_PREFIX)
|
||||||
? ASS_DIALOGUE_PREFIX
|
? ASS_DIALOGUE_PREFIX
|
||||||
: trimmed.startsWith(ASS_COMMENT_PREFIX)
|
: eventLine.startsWith(ASS_COMMENT_PREFIX)
|
||||||
? ASS_COMMENT_PREFIX
|
? ASS_COMMENT_PREFIX
|
||||||
: null;
|
: null;
|
||||||
if (!eventPrefix) {
|
if (!eventPrefix) {
|
||||||
@@ -578,7 +900,7 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fields = trimmed.slice(eventPrefix.length).split(',');
|
const fields = eventLine.slice(eventPrefix.length).split(',');
|
||||||
if (
|
if (
|
||||||
fieldIndex.start >= fields.length ||
|
fieldIndex.start >= fields.length ||
|
||||||
fieldIndex.end >= fields.length ||
|
fieldIndex.end >= fields.length ||
|
||||||
@@ -615,6 +937,7 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
|||||||
overrides,
|
overrides,
|
||||||
overrideSignature: assOverrideSignature(overrides),
|
overrideSignature: assOverrideSignature(overrides),
|
||||||
order: eventOrder,
|
order: eventOrder,
|
||||||
|
assLayout: buildAssCueLayout(overrides, eventOrder),
|
||||||
};
|
};
|
||||||
eventOrder += 1;
|
eventOrder += 1;
|
||||||
if (eventPrefix === ASS_COMMENT_PREFIX) {
|
if (eventPrefix === ASS_COMMENT_PREFIX) {
|
||||||
@@ -628,7 +951,7 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
||||||
return recoverCanonicalAssEvents(parseAnnotatedAssEvents(content));
|
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(parseAnnotatedAssEvents(content)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseAssCues(content: string): SubtitleCue[] {
|
export function parseAssCues(content: string): SubtitleCue[] {
|
||||||
|
|||||||
@@ -312,6 +312,7 @@ import {
|
|||||||
promoteSettingsWindowAboveOverlay,
|
promoteSettingsWindowAboveOverlay,
|
||||||
registerGlobalShortcuts as registerGlobalShortcutsCore,
|
registerGlobalShortcuts as registerGlobalShortcutsCore,
|
||||||
replayCurrentSubtitleRuntime,
|
replayCurrentSubtitleRuntime,
|
||||||
|
resolveSanitizedSubtitleSeekCommand,
|
||||||
resolveJellyfinPlaybackPlanRuntime,
|
resolveJellyfinPlaybackPlanRuntime,
|
||||||
runStartupBootstrapRuntime,
|
runStartupBootstrapRuntime,
|
||||||
saveJellyfinSubtitleDelay,
|
saveJellyfinSubtitleDelay,
|
||||||
@@ -587,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';
|
||||||
|
|
||||||
@@ -1958,6 +1960,31 @@ let linuxVisibleOverlayOwnerBindingKey: string | null = null;
|
|||||||
let linuxVisibleOverlayWindowModeSwitchToken = 0;
|
let linuxVisibleOverlayWindowModeSwitchToken = 0;
|
||||||
let subtitleSidebarRequestedOpen = false;
|
let subtitleSidebarRequestedOpen = false;
|
||||||
const SEEK_THRESHOLD_SECONDS = 3;
|
const SEEK_THRESHOLD_SECONDS = 3;
|
||||||
|
const EXPLICIT_SEEK_INTENT_TTL_MS = 2000;
|
||||||
|
let explicitSeekIntentExpiresAtMs = 0;
|
||||||
|
|
||||||
|
function isExplicitMpvSeekCommand(command: readonly (string | number)[]): boolean {
|
||||||
|
return command[0] === 'seek' || command[0] === 'sub-seek';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendRendererMpvCommand(rawCommand: (string | number)[]): void {
|
||||||
|
const command =
|
||||||
|
resolveSanitizedSubtitleSeekCommand(
|
||||||
|
rawCommand,
|
||||||
|
appState.activeParsedSubtitleCues,
|
||||||
|
appState.mpvClient?.currentTimePos ?? Number.NaN,
|
||||||
|
) ?? rawCommand;
|
||||||
|
if (isExplicitMpvSeekCommand(command)) {
|
||||||
|
explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS;
|
||||||
|
}
|
||||||
|
sendMpvCommandRuntime(appState.mpvClient, command);
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeExplicitSeekIntent(): boolean {
|
||||||
|
const pending = explicitSeekIntentExpiresAtMs >= Date.now();
|
||||||
|
explicitSeekIntentExpiresAtMs = 0;
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({
|
const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({
|
||||||
getCurrentMediaPath: () => appState.currentMediaPath,
|
getCurrentMediaPath: () => appState.currentMediaPath,
|
||||||
@@ -2028,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),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2060,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),
|
||||||
@@ -2623,6 +2653,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
|||||||
const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({
|
const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({
|
||||||
userDataPath: USER_DATA_PATH,
|
userDataPath: USER_DATA_PATH,
|
||||||
getCurrentMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
|
getCurrentMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
|
||||||
|
onIndexReady: () => refreshCurrentSubtitleAnnotations(),
|
||||||
|
onIndexReadyError: (error) =>
|
||||||
|
logger.warn(
|
||||||
|
'Failed to refresh subtitle annotations after character portrait index became ready.',
|
||||||
|
error,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lets the Yomitan scan runtime skip name lookups at positions where no
|
// Lets the Yomitan scan runtime skip name lookups at positions where no
|
||||||
@@ -3930,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(),
|
||||||
@@ -4047,7 +4084,7 @@ const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => {
|
|||||||
ensureImmersionTrackerStarted();
|
ensureImmersionTrackerStarted();
|
||||||
appState.immersionTracker?.recordCardsMined(count, noteIds);
|
appState.immersionTracker?.recordCardsMined(count, noteIds);
|
||||||
};
|
};
|
||||||
const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => {
|
function refreshCurrentSubtitleAnnotations(): void {
|
||||||
const hasCurrentSubtitle = appState.currentSubText.trim().length > 0;
|
const hasCurrentSubtitle = appState.currentSubText.trim().length > 0;
|
||||||
if (hasCurrentSubtitle) {
|
if (hasCurrentSubtitle) {
|
||||||
subtitlePrefetchService?.pause();
|
subtitlePrefetchService?.pause();
|
||||||
@@ -4058,7 +4095,7 @@ const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => {
|
|||||||
// Idle controller: no settle is coming to release the pause above.
|
// Idle controller: no settle is coming to release the pause above.
|
||||||
subtitlePrefetchService?.resume();
|
subtitlePrefetchService?.resume();
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
let hasAttemptedImmersionTrackerStartup = false;
|
let hasAttemptedImmersionTrackerStartup = false;
|
||||||
const ensureImmersionTrackerStarted = (): void => {
|
const ensureImmersionTrackerStarted = (): void => {
|
||||||
if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) {
|
if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) {
|
||||||
@@ -4490,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;
|
||||||
@@ -4574,6 +4612,7 @@ const {
|
|||||||
reportJellyfinRemoteProgress: (forceImmediate) => {
|
reportJellyfinRemoteProgress: (forceImmediate) => {
|
||||||
void reportJellyfinRemoteProgress(forceImmediate);
|
void reportJellyfinRemoteProgress(forceImmediate);
|
||||||
},
|
},
|
||||||
|
consumeExplicitSeek: () => consumeExplicitSeekIntent(),
|
||||||
onTimePosUpdate: (time) => {
|
onTimePosUpdate: (time) => {
|
||||||
const delta = time - lastObservedTimePos;
|
const delta = time - lastObservedTimePos;
|
||||||
if (subtitlePrefetchService && (delta > SEEK_THRESHOLD_SECONDS || delta < 0)) {
|
if (subtitlePrefetchService && (delta > SEEK_THRESHOLD_SECONDS || delta < 0)) {
|
||||||
@@ -5081,9 +5120,7 @@ function initializeOverlayRuntime(): void {
|
|||||||
overlayModalRuntime.primeModalWindow();
|
overlayModalRuntime.primeModalWindow();
|
||||||
}
|
}
|
||||||
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
||||||
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
|
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAnnotations);
|
||||||
refreshCurrentSubtitleAfterKnownWordUpdate,
|
|
||||||
);
|
|
||||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
||||||
syncOverlayMpvSubtitleSuppression();
|
syncOverlayMpvSubtitleSuppression();
|
||||||
}
|
}
|
||||||
@@ -5481,8 +5518,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text),
|
showPlaybackFeedback: (text: string) => showConfiguredPlaybackFeedback(text),
|
||||||
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
replayCurrentSubtitle: () => replayCurrentSubtitleRuntime(appState.mpvClient),
|
||||||
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
playNextSubtitle: () => playNextSubtitleRuntime(appState.mpvClient),
|
||||||
sendMpvCommand: (rawCommand: (string | number)[]) =>
|
sendMpvCommand: (rawCommand: (string | number)[]) => sendRendererMpvCommand(rawCommand),
|
||||||
sendMpvCommandRuntime(appState.mpvClient, rawCommand),
|
|
||||||
getMpvClient: () => appState.mpvClient,
|
getMpvClient: () => appState.mpvClient,
|
||||||
isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected),
|
isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected),
|
||||||
hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null,
|
hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null,
|
||||||
@@ -5876,7 +5912,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
|||||||
appState.ankiIntegration = integration;
|
appState.ankiIntegration = integration;
|
||||||
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
||||||
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
|
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
|
||||||
refreshCurrentSubtitleAfterKnownWordUpdate,
|
refreshCurrentSubtitleAnnotations,
|
||||||
);
|
);
|
||||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
|
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
|
||||||
consumePendingSubtitleMiningContext,
|
consumePendingSubtitleMiningContext,
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nameSplitTokenizerAvailable = isNameSplitTokenizerAvailable();
|
const nameSplitTokenizerAvailable = isNameSplitTokenizerAvailable();
|
||||||
const resolvedNameSplits = nameSplitTokenizerAvailable
|
const nameSplitResolution = nameSplitTokenizerAvailable
|
||||||
? await resolveJapaneseNameSplits(
|
? await resolveJapaneseNameSplits(
|
||||||
characters,
|
characters,
|
||||||
deps.tokenizeJapaneseName!,
|
deps.tokenizeJapaneseName!,
|
||||||
@@ -466,8 +466,8 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const nameSplitSource =
|
const resolvedNameSplits = nameSplitResolution?.splits;
|
||||||
resolvedNameSplits && resolvedNameSplits.size > 0 ? 'mecab' : 'heuristic';
|
const nameSplitSource = nameSplitResolution?.kind === 'complete' ? 'mecab' : 'heuristic';
|
||||||
|
|
||||||
progress?.onGenerateProgress?.({
|
progress?.onGenerateProgress?.({
|
||||||
mediaId,
|
mediaId,
|
||||||
|
|||||||
@@ -198,6 +198,66 @@ test('createCharacterDictionaryImageLookup can scope duplicate names to the curr
|
|||||||
assert.equal(scoped.alt, 'Kazuma');
|
assert.equal(scoped.alt, 'Kazuma');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('createCharacterDictionaryImageLookup reports and retries a failed index-ready callback', async () => {
|
||||||
|
const outputDir = makeTempDir();
|
||||||
|
const snapshot: CharacterDictionarySnapshot = {
|
||||||
|
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
|
||||||
|
mediaId: 21858,
|
||||||
|
mediaTitle: 'Little Witch Academia',
|
||||||
|
entryCount: 1,
|
||||||
|
updatedAt: 1_700_000_000_000,
|
||||||
|
termEntries: [
|
||||||
|
[
|
||||||
|
'ダイアナ',
|
||||||
|
'だいあな',
|
||||||
|
'name primary',
|
||||||
|
'',
|
||||||
|
75,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'structured-content',
|
||||||
|
content: {
|
||||||
|
tag: 'img',
|
||||||
|
path: 'img/m21858-c81709.png',
|
||||||
|
alt: 'ダイアナ・キャベンディッシュ',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
0,
|
||||||
|
'',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
images: [{ path: 'img/m21858-c81709.png', dataBase64: PNG_1X1_BASE64 }],
|
||||||
|
};
|
||||||
|
await writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
|
||||||
|
const callbackError = new Error('annotation refresh failed');
|
||||||
|
const reportingError = new Error('error reporter failed');
|
||||||
|
let readyCount = 0;
|
||||||
|
const reportedErrors: unknown[] = [];
|
||||||
|
const lookup = createCharacterDictionaryImageLookup({
|
||||||
|
outputDir,
|
||||||
|
onIndexReady: () => {
|
||||||
|
readyCount += 1;
|
||||||
|
if (readyCount === 1) {
|
||||||
|
throw callbackError;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onIndexReadyError: (error) => {
|
||||||
|
reportedErrors.push(error);
|
||||||
|
throw reportingError;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(lookup.get('ダイアナ', snapshot.mediaId), null);
|
||||||
|
await waitForRefresh(() => (reportedErrors.length === 1 ? true : null));
|
||||||
|
assert.ok(lookup.get('ダイアナ', snapshot.mediaId));
|
||||||
|
|
||||||
|
assert.equal(readyCount, 2);
|
||||||
|
assert.deepEqual(reportedErrors, [callbackError]);
|
||||||
|
lookup.get('ダイアナ', snapshot.mediaId);
|
||||||
|
assert.equal(readyCount, 2);
|
||||||
|
});
|
||||||
|
|
||||||
test('createCharacterDictionaryImageLookup does not fall back globally on scoped miss', async () => {
|
test('createCharacterDictionaryImageLookup does not fall back globally on scoped miss', async () => {
|
||||||
const outputDir = makeTempDir();
|
const outputDir = makeTempDir();
|
||||||
const snapshot: CharacterDictionarySnapshot = {
|
const snapshot: CharacterDictionarySnapshot = {
|
||||||
|
|||||||
@@ -218,6 +218,8 @@ export function createCharacterDictionaryImageLookup(deps: {
|
|||||||
userDataPath?: string;
|
userDataPath?: string;
|
||||||
outputDir?: string;
|
outputDir?: string;
|
||||||
getCurrentMediaId?: () => number | null | undefined;
|
getCurrentMediaId?: () => number | null | undefined;
|
||||||
|
onIndexReady?: () => void;
|
||||||
|
onIndexReadyError?: (error: unknown) => void;
|
||||||
}): {
|
}): {
|
||||||
get: (term: string, mediaId?: number | null) => CharacterNameImage | null;
|
get: (term: string, mediaId?: number | null) => CharacterNameImage | null;
|
||||||
invalidate: () => void;
|
invalidate: () => void;
|
||||||
@@ -229,6 +231,24 @@ export function createCharacterDictionaryImageLookup(deps: {
|
|||||||
let index = new Map<string, CharacterNameImage>();
|
let index = new Map<string, CharacterNameImage>();
|
||||||
let indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
|
let indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
|
||||||
let refreshInFlight = false;
|
let refreshInFlight = false;
|
||||||
|
let indexReadyDeliveryPending = false;
|
||||||
|
|
||||||
|
function deliverIndexReadyIfPending(): void {
|
||||||
|
if (!indexReadyDeliveryPending || !deps.onIndexReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
indexReadyDeliveryPending = false;
|
||||||
|
try {
|
||||||
|
deps.onIndexReady();
|
||||||
|
} catch (error) {
|
||||||
|
indexReadyDeliveryPending = true;
|
||||||
|
try {
|
||||||
|
deps.onIndexReadyError?.(error);
|
||||||
|
} catch {
|
||||||
|
// Error reporting must not reject the detached index refresh task.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Rebuilding means re-reading every cached snapshot (potentially GBs of JSON), which used to run
|
// Rebuilding means re-reading every cached snapshot (potentially GBs of JSON), which used to run
|
||||||
// synchronously inside a lookup and froze the whole app right after a snapshot changed. Lookups
|
// synchronously inside a lookup and froze the whole app right after a snapshot changed. Lookups
|
||||||
@@ -241,6 +261,7 @@ export function createCharacterDictionaryImageLookup(deps: {
|
|||||||
signature = '';
|
signature = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
deliverIndexReadyIfPending();
|
||||||
const nextSignature = getSnapshotDirectorySignature(outputDir);
|
const nextSignature = getSnapshotDirectorySignature(outputDir);
|
||||||
if (nextSignature === signature || refreshInFlight) {
|
if (nextSignature === signature || refreshInFlight) {
|
||||||
return;
|
return;
|
||||||
@@ -262,6 +283,8 @@ export function createCharacterDictionaryImageLookup(deps: {
|
|||||||
index = nextIndex;
|
index = nextIndex;
|
||||||
indexByMediaId = nextIndexByMediaId;
|
indexByMediaId = nextIndexByMediaId;
|
||||||
signature = nextSignature;
|
signature = nextSignature;
|
||||||
|
indexReadyDeliveryPending = deps.onIndexReady !== undefined;
|
||||||
|
deliverIndexReadyIfPending();
|
||||||
} finally {
|
} finally {
|
||||||
refreshInFlight = false;
|
refreshInFlight = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ test('resolveJapaneseNameSplits splits a single-kanji surname via person-name PO
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(splits.get('東紫乃'), { family: '東', given: '紫乃' });
|
assert.equal(splits.kind, 'complete');
|
||||||
|
assert.deepEqual(splits.splits.get('東紫乃'), { family: '東', given: '紫乃' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveJapaneseNameSplits corrects a hint-length-misleading surname boundary', async () => {
|
test('resolveJapaneseNameSplits corrects a hint-length-misleading surname boundary', async () => {
|
||||||
@@ -64,7 +65,8 @@ test('resolveJapaneseNameSplits corrects a hint-length-misleading surname bounda
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(splits.get('渡辺真奈美'), { family: '渡辺', given: '真奈美' });
|
assert.equal(splits.kind, 'complete');
|
||||||
|
assert.deepEqual(splits.splits.get('渡辺真奈美'), { family: '渡辺', given: '真奈美' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveJapaneseNameSplits falls back to hint readings when POS tags are generic', async () => {
|
test('resolveJapaneseNameSplits falls back to hint readings when POS tags are generic', async () => {
|
||||||
@@ -85,7 +87,8 @@ test('resolveJapaneseNameSplits falls back to hint readings when POS tags are ge
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(splits.get('鈴木みゆ'), { family: '鈴木', given: 'みゆ' });
|
assert.equal(splits.kind, 'complete');
|
||||||
|
assert.deepEqual(splits.splits.get('鈴木みゆ'), { family: '鈴木', given: 'みゆ' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the name', async () => {
|
test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the name', async () => {
|
||||||
@@ -96,7 +99,8 @@ test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(splits.size, 0);
|
assert.equal(splits.kind, 'complete');
|
||||||
|
assert.equal(splits.splits.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', async () => {
|
test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', async () => {
|
||||||
@@ -117,7 +121,8 @@ test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', asyn
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(splits.size, 0);
|
assert.equal(splits.kind, 'complete');
|
||||||
|
assert.equal(splits.splits.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveJapaneseNameSplits survives tokenizer failures', async () => {
|
test('resolveJapaneseNameSplits survives tokenizer failures', async () => {
|
||||||
@@ -130,7 +135,8 @@ test('resolveJapaneseNameSplits survives tokenizer failures', async () => {
|
|||||||
(message) => warnings.push(message),
|
(message) => warnings.push(message),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(splits.size, 0);
|
assert.equal(splits.kind, 'incomplete');
|
||||||
|
assert.equal(splits.splits.size, 0);
|
||||||
assert.equal(warnings.length, 1);
|
assert.equal(warnings.length, 1);
|
||||||
assert.match(warnings[0]!, /mecab unavailable/);
|
assert.match(warnings[0]!, /mecab unavailable/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import type {
|
|||||||
ResolvedNameSplit,
|
ResolvedNameSplit,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
export type JapaneseNameSplitResolution =
|
||||||
|
| { kind: 'complete'; splits: Map<string, ResolvedNameSplit> }
|
||||||
|
| { kind: 'incomplete'; splits: Map<string, ResolvedNameSplit> };
|
||||||
|
|
||||||
const NAME_SEPARATOR_PATTERN = /[\s ・・·•]/;
|
const NAME_SEPARATOR_PATTERN = /[\s ・・·•]/;
|
||||||
|
|
||||||
function joinSurfaces(tokens: NameSplitToken[]): string {
|
function joinSurfaces(tokens: NameSplitToken[]): string {
|
||||||
@@ -87,8 +91,9 @@ export async function resolveJapaneseNameSplits(
|
|||||||
tokenize: NameSplitTokenizer,
|
tokenize: NameSplitTokenizer,
|
||||||
logWarn?: (message: string) => void,
|
logWarn?: (message: string) => void,
|
||||||
onCharacterResolved?: (completed: number, total: number) => void,
|
onCharacterResolved?: (completed: number, total: number) => void,
|
||||||
): Promise<Map<string, ResolvedNameSplit>> {
|
): Promise<JapaneseNameSplitResolution> {
|
||||||
const splits = new Map<string, ResolvedNameSplit>();
|
const splits = new Map<string, ResolvedNameSplit>();
|
||||||
|
let tokenizerFailed = false;
|
||||||
let resolvedCharacters = 0;
|
let resolvedCharacters = 0;
|
||||||
for (const character of characters) {
|
for (const character of characters) {
|
||||||
const familyHintReading = buildReadingFromHint(character.lastNameHint?.trim() || '');
|
const familyHintReading = buildReadingFromHint(character.lastNameHint?.trim() || '');
|
||||||
@@ -99,12 +104,17 @@ export async function resolveJapaneseNameSplits(
|
|||||||
try {
|
try {
|
||||||
tokens = await tokenize(name);
|
tokens = await tokenize(name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
tokenizerFailed = true;
|
||||||
logWarn?.(
|
logWarn?.(
|
||||||
`[dictionary] name split tokenization failed for "${name}": ${(err as Error).message}`,
|
`[dictionary] name split tokenization failed for "${name}": ${(err as Error).message}`,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!tokens || tokens.length < 2 || joinSurfaces(tokens) !== name) continue;
|
if (!tokens) {
|
||||||
|
tokenizerFailed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tokens.length < 2 || joinSurfaces(tokens) !== name) continue;
|
||||||
const splitIndex =
|
const splitIndex =
|
||||||
splitIndexFromPersonNamePos(tokens) ??
|
splitIndexFromPersonNamePos(tokens) ??
|
||||||
splitIndexFromHintReadings(tokens, familyHintReading, givenHintReading);
|
splitIndexFromHintReadings(tokens, familyHintReading, givenHintReading);
|
||||||
@@ -118,5 +128,5 @@ export async function resolveJapaneseNameSplits(
|
|||||||
resolvedCharacters += 1;
|
resolvedCharacters += 1;
|
||||||
onCharacterResolved?.(resolvedCharacters, characters.length);
|
onCharacterResolved?.(resolvedCharacters, characters.length);
|
||||||
}
|
}
|
||||||
return splits;
|
return tokenizerFailed ? { kind: 'incomplete', splits } : { kind: 'complete', splits };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import test from 'node:test';
|
|||||||
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
|
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
|
||||||
import { getSnapshotPath, writeSnapshot } from './cache';
|
import { getSnapshotPath, writeSnapshot } from './cache';
|
||||||
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
|
||||||
import type { CharacterDictionarySnapshot } from './types';
|
import type { CharacterDictionarySnapshot, NameSplitTokenizer } from './types';
|
||||||
|
|
||||||
const GRAPHQL_URL = 'https://graphql.anilist.co';
|
const GRAPHQL_URL = 'https://graphql.anilist.co';
|
||||||
const PNG_1X1 = Buffer.from(
|
const PNG_1X1 = Buffer.from(
|
||||||
@@ -121,7 +121,12 @@ test('generateForCurrentMedia refreshes same-version snapshots missing images wh
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async () => {
|
async function runNameSplitRefreshScenario(tokenizeJapaneseName: NameSplitTokenizer): Promise<{
|
||||||
|
characterPageRequests: number;
|
||||||
|
firstResultFromCache: boolean;
|
||||||
|
refreshedNameSplitSource: CharacterDictionarySnapshot['nameSplitSource'];
|
||||||
|
secondResultFromCache: boolean;
|
||||||
|
}> {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
const outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||||
await writeSnapshot(getSnapshotPath(outputDir, 130298), {
|
await writeSnapshot(getSnapshotPath(outputDir, 130298), {
|
||||||
@@ -172,7 +177,6 @@ test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable'
|
|||||||
}) as typeof globalThis.fetch;
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let tokenizerCalls = 0;
|
|
||||||
const runtime = createCharacterDictionaryRuntimeService({
|
const runtime = createCharacterDictionaryRuntimeService({
|
||||||
userDataPath,
|
userDataPath,
|
||||||
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||||
@@ -185,29 +189,54 @@ test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable'
|
|||||||
source: 'fallback',
|
source: 'fallback',
|
||||||
}),
|
}),
|
||||||
getNameMatchImagesEnabled: () => false,
|
getNameMatchImagesEnabled: () => false,
|
||||||
tokenizeJapaneseName: async () => {
|
tokenizeJapaneseName,
|
||||||
tokenizerCalls += 1;
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
getJapaneseNameTokenizerAvailable: () => true,
|
getJapaneseNameTokenizerAvailable: () => true,
|
||||||
now: () => 1_700_000_000_500,
|
now: () => 1_700_000_000_500,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runtime.generateForCurrentMedia();
|
const firstResult = await runtime.generateForCurrentMedia();
|
||||||
const refreshedSnapshot = JSON.parse(
|
const refreshedSnapshot = JSON.parse(
|
||||||
fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'),
|
fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'),
|
||||||
) as CharacterDictionarySnapshot;
|
) as CharacterDictionarySnapshot;
|
||||||
|
const secondResult = await runtime.generateForCurrentMedia();
|
||||||
|
|
||||||
assert.equal(result.fromCache, false);
|
return {
|
||||||
assert.equal(refreshedSnapshot.nameSplitSource, 'heuristic');
|
characterPageRequests,
|
||||||
|
firstResultFromCache: firstResult.fromCache,
|
||||||
const retriedResult = await runtime.generateForCurrentMedia();
|
refreshedNameSplitSource: refreshedSnapshot.nameSplitSource,
|
||||||
assert.equal(retriedResult.fromCache, false);
|
secondResultFromCache: secondResult.fromCache,
|
||||||
assert.equal(characterPageRequests, 2);
|
};
|
||||||
assert.equal(tokenizerCalls, 2);
|
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async () => {
|
||||||
|
let tokenizerCalls = 0;
|
||||||
|
const result = await runNameSplitRefreshScenario(async () => {
|
||||||
|
tokenizerCalls += 1;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.firstResultFromCache, false);
|
||||||
|
assert.equal(result.refreshedNameSplitSource, 'heuristic');
|
||||||
|
assert.equal(result.secondResultFromCache, false);
|
||||||
|
assert.equal(result.characterPageRequests, 2);
|
||||||
|
assert.equal(tokenizerCalls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateForCurrentMedia caches completed MeCab refreshes with no resolved splits', async () => {
|
||||||
|
let tokenizerCalls = 0;
|
||||||
|
const result = await runNameSplitRefreshScenario(async () => {
|
||||||
|
tokenizerCalls += 1;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.firstResultFromCache, false);
|
||||||
|
assert.equal(result.refreshedNameSplitSource, 'mecab');
|
||||||
|
assert.equal(result.secondResultFromCache, true);
|
||||||
|
assert.equal(result.characterPageRequests, 1);
|
||||||
|
assert.equal(tokenizerCalls, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('generateForCurrentMedia keeps mecab-split snapshots when MeCab is available', async () => {
|
test('generateForCurrentMedia keeps mecab-split snapshots when MeCab is available', async () => {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
@@ -482,10 +485,10 @@ test('Linux visible overlay recreation avoids display fallback before tracked ge
|
|||||||
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
|
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('known-word updates invalidate prefetched tokenizations before refreshing current subtitle', () => {
|
test('subtitle annotation updates invalidate prefetched tokenizations before refreshing current subtitle', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const actionBlock = source.match(
|
const actionBlock = source.match(
|
||||||
/const refreshCurrentSubtitleAfterKnownWordUpdate = \(\): void => \{(?<body>[\s\S]*?)\n\};/,
|
/function refreshCurrentSubtitleAnnotations\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||||
)?.groups?.body;
|
)?.groups?.body;
|
||||||
|
|
||||||
assert.ok(actionBlock);
|
assert.ok(actionBlock);
|
||||||
@@ -503,6 +506,20 @@ test('known-word updates invalidate prefetched tokenizations before refreshing c
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('character portrait index readiness refreshes cached subtitle annotations', () => {
|
||||||
|
const source = readMainSource();
|
||||||
|
const lookupDeps = source.match(
|
||||||
|
/const characterDictionaryImageLookup = createCharacterDictionaryImageLookup\(\{(?<body>[\s\S]*?)\n\}\);/,
|
||||||
|
)?.groups?.body;
|
||||||
|
|
||||||
|
assert.ok(lookupDeps);
|
||||||
|
assert.match(lookupDeps, /onIndexReady: \(\) => refreshCurrentSubtitleAnnotations\(\),/);
|
||||||
|
assert.match(
|
||||||
|
lookupDeps,
|
||||||
|
/onIndexReadyError: \(error\) =>[\s\S]*?logger\.warn\([\s\S]*?character portrait index became ready\.[\s\S]*?error,/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('subtitle processing controller resumes prefetch on settle, not on its emits', () => {
|
test('subtitle processing controller resumes prefetch on settle, not on its emits', () => {
|
||||||
const source = readMainSource();
|
const source = readMainSource();
|
||||||
const depsBlock = source.match(
|
const depsBlock = source.match(
|
||||||
@@ -846,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', () => {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
|
||||||
import { createSubtitleProcessingController } from '../../core/services/subtitle-processing-controller';
|
import { createSubtitleProcessingController } from '../../core/services/subtitle-processing-controller';
|
||||||
import type { SubtitleData } from '../../types';
|
import type { SubtitleData } from '../../types';
|
||||||
import {
|
import {
|
||||||
@@ -211,6 +212,69 @@ test('primeCurrentSubtitleForAutoplay emits raw first paint on cache miss before
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parsed cues replace a duplicate raw autoplay subtitle that was already primed', async () => {
|
||||||
|
const rawText = 'ジグザグな道を抜け\nジグザグな道を抜け';
|
||||||
|
const correctedText = 'ジグザグな道を抜け';
|
||||||
|
const mediaPath = '/media/video.mkv';
|
||||||
|
let currentSubText = '';
|
||||||
|
const emitted: string[] = [];
|
||||||
|
const client = {
|
||||||
|
connected: true,
|
||||||
|
currentVideoPath: mediaPath,
|
||||||
|
currentTimePos: 90,
|
||||||
|
currentSubText: rawText,
|
||||||
|
requestProperty: async (name: string) => {
|
||||||
|
if (name === 'sub-text') return rawText;
|
||||||
|
if (name === 'time-pos') return 90;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const cues = parseSubtitleCues(
|
||||||
|
[
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
`Dialogue: 1,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,${correctedText}`,
|
||||||
|
`Dialogue: 0,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,${correctedText}`,
|
||||||
|
].join('\n'),
|
||||||
|
'startup-ending.ass',
|
||||||
|
);
|
||||||
|
let activeCues = cues.slice(0, 0);
|
||||||
|
const runtime = createAutoplaySubtitlePrimingRuntime({
|
||||||
|
getCurrentMediaPath: () => mediaPath,
|
||||||
|
getMpvClient: () => client,
|
||||||
|
setCurrentSubText: (text) => {
|
||||||
|
currentSubText = text;
|
||||||
|
},
|
||||||
|
getCurrentSubText: () => currentSubText,
|
||||||
|
getCurrentSubtitleData: () => null,
|
||||||
|
getActiveParsedSubtitleCues: () => activeCues,
|
||||||
|
setActiveParsedSubtitleMediaPath: () => {},
|
||||||
|
subtitleProcessingController: {
|
||||||
|
consumeCachedSubtitle: () => null,
|
||||||
|
onSubtitleChange: () => true,
|
||||||
|
refreshCurrentSubtitle: () => true,
|
||||||
|
notePlainSubtitleEmitted: () => {},
|
||||||
|
},
|
||||||
|
emitSubtitlePayload: (payload) => emitted.push(payload.text),
|
||||||
|
getSubtitlePrefetchService: () => null,
|
||||||
|
getLastObservedTimePos: () => 90,
|
||||||
|
getVisibleOverlayVisible: () => true,
|
||||||
|
emitSecondarySubtitle: () => {},
|
||||||
|
initSubtitlePrefetch: async () => {},
|
||||||
|
refreshSubtitlePrefetchFromActiveTrack: async () => {},
|
||||||
|
logDebug: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.primeCurrentSubtitleForAutoplay(mediaPath);
|
||||||
|
assert.equal(currentSubText, rawText);
|
||||||
|
|
||||||
|
activeCues = cues;
|
||||||
|
await runtime.primeAutoplaySubtitleFromParsedCues(mediaPath, cues);
|
||||||
|
|
||||||
|
assert.equal(currentSubText, correctedText);
|
||||||
|
assert.deepEqual(emitted, [rawText, correctedText]);
|
||||||
|
});
|
||||||
|
|
||||||
// Driven by the real processing controller rather than a stub: the failure this
|
// Driven by the real processing controller rather than a stub: the failure this
|
||||||
// covers is a disagreement between the priming path and the controller's own
|
// covers is a disagreement between the priming path and the controller's own
|
||||||
// staleness rules, which a hand-written stub cannot reproduce.
|
// staleness rules, which a hand-written stub cannot reproduce.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type AutoplaySubtitlePrimingMpvClient = {
|
|||||||
requestProperty: (name: string) => Promise<unknown>;
|
requestProperty: (name: string) => Promise<unknown>;
|
||||||
currentVideoPath?: string;
|
currentVideoPath?: string;
|
||||||
currentTimePos?: number;
|
currentTimePos?: number;
|
||||||
|
currentSubText?: string;
|
||||||
currentSecondarySubText?: string;
|
currentSecondarySubText?: string;
|
||||||
setCurrentSecondarySubText?: (text: string) => void;
|
setCurrentSecondarySubText?: (text: string) => void;
|
||||||
};
|
};
|
||||||
@@ -107,11 +108,19 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
|||||||
autoplaySubtitlePrimedMediaPath = null;
|
autoplaySubtitlePrimedMediaPath = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean {
|
function emitAutoplayPrimedSubtitle(
|
||||||
|
mediaPath: string,
|
||||||
|
text: string,
|
||||||
|
options: { replaceExisting?: boolean } = {},
|
||||||
|
): boolean {
|
||||||
if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) {
|
if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) {
|
if (autoplaySubtitlePrimedMediaPath === mediaPath) {
|
||||||
|
if (!options.replaceExisting || deps.getCurrentSubText() === text) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,11 +261,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
|||||||
mediaPath: string,
|
mediaPath: string,
|
||||||
cues: SubtitleCue[],
|
cues: SubtitleCue[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (
|
if (cues.length === 0 || !isCurrentAutoplayMediaPath(mediaPath)) {
|
||||||
cues.length === 0 ||
|
|
||||||
autoplaySubtitlePrimedMediaPath === mediaPath ||
|
|
||||||
!isCurrentAutoplayMediaPath(mediaPath)
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,16 +270,21 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
|||||||
const currentTimeSeconds = Number(
|
const currentTimeSeconds = Number(
|
||||||
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
|
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
|
||||||
);
|
);
|
||||||
|
const resolvedTimeSeconds = Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0;
|
||||||
const cue = selectAutoplayStartupCue(
|
const cue = selectAutoplayStartupCue(
|
||||||
cues,
|
cues,
|
||||||
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
|
resolvedTimeSeconds,
|
||||||
AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS,
|
AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS,
|
||||||
);
|
);
|
||||||
if (!cue) {
|
const liveText = client?.currentSubText ?? '';
|
||||||
|
const text = liveText.trim()
|
||||||
|
? resolvePrimarySubtitleText({ liveText, currentTimeSec: resolvedTimeSeconds, cues })
|
||||||
|
: (cue?.text ?? '');
|
||||||
|
if (!text) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
emitAutoplayPrimedSubtitle(mediaPath, cue.text);
|
emitAutoplayPrimedSubtitle(mediaPath, text, { replaceExisting: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearScheduledSubtitlePrefetchRefresh(): void {
|
function clearScheduledSubtitlePrefetchRefresh(): void {
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ import {
|
|||||||
resolveManagedLinuxRuntimePluginPaths,
|
resolveManagedLinuxRuntimePluginPaths,
|
||||||
} from './linux-runtime-plugin-assets';
|
} from './linux-runtime-plugin-assets';
|
||||||
|
|
||||||
|
const THUMBNAILER_RELATIVE_PATH = path.join(
|
||||||
|
'thumbnailers',
|
||||||
|
'subminer-ffmpegthumbnailer.thumbnailer',
|
||||||
|
);
|
||||||
|
|
||||||
|
function writeThumbnailer(rootDir: string, content = '[Thumbnailer Entry]\n'): string {
|
||||||
|
const thumbnailerPath = path.join(rootDir, THUMBNAILER_RELATIVE_PATH);
|
||||||
|
fs.mkdirSync(path.dirname(thumbnailerPath), { recursive: true });
|
||||||
|
fs.writeFileSync(thumbnailerPath, content);
|
||||||
|
return thumbnailerPath;
|
||||||
|
}
|
||||||
|
|
||||||
async function withTempDir<T>(fn: (dir: string) => Promise<T> | T): Promise<T> {
|
async function withTempDir<T>(fn: (dir: string) => Promise<T> | T): Promise<T> {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-linux-plugin-assets-test-'));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-linux-plugin-assets-test-'));
|
||||||
try {
|
try {
|
||||||
@@ -48,6 +60,7 @@ test('resolveManagedLinuxRuntimePluginPaths resolves XDG data target paths', ()
|
|||||||
pluginEntrypointPath: '/tmp/xdg-data/SubMiner/plugin/subminer/main.lua',
|
pluginEntrypointPath: '/tmp/xdg-data/SubMiner/plugin/subminer/main.lua',
|
||||||
pluginConfigPath: '/tmp/xdg-data/SubMiner/plugin/subminer.conf',
|
pluginConfigPath: '/tmp/xdg-data/SubMiner/plugin/subminer.conf',
|
||||||
themePath: '/tmp/xdg-data/SubMiner/themes/subminer.rasi',
|
themePath: '/tmp/xdg-data/SubMiner/themes/subminer.rasi',
|
||||||
|
thumbnailerPath: '/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,6 +92,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin dir, config, and ro
|
|||||||
await withTempDir(async (tempDir) => {
|
await withTempDir(async (tempDir) => {
|
||||||
const sourceRoot = path.join(tempDir, 'source', 'plugin');
|
const sourceRoot = path.join(tempDir, 'source', 'plugin');
|
||||||
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
|
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
|
||||||
|
const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets'));
|
||||||
const targetRoot = path.join(tempDir, 'xdg-data', 'SubMiner', 'plugin');
|
const targetRoot = path.join(tempDir, 'xdg-data', 'SubMiner', 'plugin');
|
||||||
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
|
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
|
||||||
fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true });
|
fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true });
|
||||||
@@ -94,6 +108,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin dir, config, and ro
|
|||||||
pluginDirSource: path.join(sourceRoot, 'subminer'),
|
pluginDirSource: path.join(sourceRoot, 'subminer'),
|
||||||
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
|
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
|
||||||
themeSourcePath,
|
themeSourcePath,
|
||||||
|
thumbnailerSourcePath,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,6 +132,19 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin dir, config, and ro
|
|||||||
),
|
),
|
||||||
'/* theme */\n',
|
'/* theme */\n',
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(
|
||||||
|
tempDir,
|
||||||
|
'xdg-data',
|
||||||
|
'SubMiner',
|
||||||
|
'thumbnailers',
|
||||||
|
'subminer-ffmpegthumbnailer.thumbnailer',
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
'[Thumbnailer Entry]\n',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,6 +152,7 @@ test('ensureLinuxRuntimePluginAssets installs managed theme when plugin assets a
|
|||||||
await withTempDir(async (tempDir) => {
|
await withTempDir(async (tempDir) => {
|
||||||
const sourceRoot = path.join(tempDir, 'source', 'plugin');
|
const sourceRoot = path.join(tempDir, 'source', 'plugin');
|
||||||
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
|
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
|
||||||
|
const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets'));
|
||||||
const xdgDataHome = path.join(tempDir, 'xdg-data');
|
const xdgDataHome = path.join(tempDir, 'xdg-data');
|
||||||
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
|
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
|
||||||
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
|
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
|
||||||
@@ -143,6 +172,7 @@ test('ensureLinuxRuntimePluginAssets installs managed theme when plugin assets a
|
|||||||
pluginDirSource: path.join(sourceRoot, 'subminer'),
|
pluginDirSource: path.join(sourceRoot, 'subminer'),
|
||||||
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
|
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
|
||||||
themeSourcePath,
|
themeSourcePath,
|
||||||
|
thumbnailerSourcePath,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,6 +199,7 @@ test('ensureLinuxRuntimePluginAssets installs managed theme when plugin assets a
|
|||||||
test('ensureLinuxRuntimePluginAssets installs managed theme without resolving plugin sources when plugin assets already exist', async () => {
|
test('ensureLinuxRuntimePluginAssets installs managed theme without resolving plugin sources when plugin assets already exist', async () => {
|
||||||
await withTempDir(async (tempDir) => {
|
await withTempDir(async (tempDir) => {
|
||||||
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
|
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
|
||||||
|
const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets'));
|
||||||
const xdgDataHome = path.join(tempDir, 'xdg-data');
|
const xdgDataHome = path.join(tempDir, 'xdg-data');
|
||||||
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
|
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
|
||||||
fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true });
|
fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true });
|
||||||
@@ -183,6 +214,7 @@ test('ensureLinuxRuntimePluginAssets installs managed theme without resolving pl
|
|||||||
xdgDataHome,
|
xdgDataHome,
|
||||||
resolveBundledAssets: () => ({
|
resolveBundledAssets: () => ({
|
||||||
themeSourcePath,
|
themeSourcePath,
|
||||||
|
thumbnailerSourcePath,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -250,6 +282,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin assets without reso
|
|||||||
path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'),
|
path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'),
|
||||||
'/* existing theme */\n',
|
'/* existing theme */\n',
|
||||||
);
|
);
|
||||||
|
const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets'));
|
||||||
|
|
||||||
const result = await ensureLinuxRuntimePluginAssets({
|
const result = await ensureLinuxRuntimePluginAssets({
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
@@ -258,6 +291,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin assets without reso
|
|||||||
resolveBundledAssets: () => ({
|
resolveBundledAssets: () => ({
|
||||||
pluginDirSource: path.join(sourceRoot, 'subminer'),
|
pluginDirSource: path.join(sourceRoot, 'subminer'),
|
||||||
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
|
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
|
||||||
|
thumbnailerSourcePath,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -332,6 +366,7 @@ test('ensureLinuxRuntimePluginAssets returns already-present when managed assets
|
|||||||
path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'),
|
path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'),
|
||||||
'/* theme */\n',
|
'/* theme */\n',
|
||||||
);
|
);
|
||||||
|
writeThumbnailer(path.join(xdgDataHome, 'SubMiner'));
|
||||||
|
|
||||||
const result = await ensureLinuxRuntimePluginAssets({
|
const result = await ensureLinuxRuntimePluginAssets({
|
||||||
platform: 'linux',
|
platform: 'linux',
|
||||||
@@ -369,6 +404,7 @@ test('ensureLinuxRuntimePluginAssets leaves no final target tree on failed insta
|
|||||||
await withTempDir(async (tempDir) => {
|
await withTempDir(async (tempDir) => {
|
||||||
const sourceRoot = path.join(tempDir, 'source', 'plugin');
|
const sourceRoot = path.join(tempDir, 'source', 'plugin');
|
||||||
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
|
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
|
||||||
|
const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets'));
|
||||||
const xdgDataHome = path.join(tempDir, 'xdg-data');
|
const xdgDataHome = path.join(tempDir, 'xdg-data');
|
||||||
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
|
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
|
||||||
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
|
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
|
||||||
@@ -385,6 +421,7 @@ test('ensureLinuxRuntimePluginAssets leaves no final target tree on failed insta
|
|||||||
pluginDirSource: path.join(sourceRoot, 'subminer'),
|
pluginDirSource: path.join(sourceRoot, 'subminer'),
|
||||||
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
|
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
|
||||||
themeSourcePath,
|
themeSourcePath,
|
||||||
|
thumbnailerSourcePath,
|
||||||
}),
|
}),
|
||||||
copyFile: async () => {
|
copyFile: async () => {
|
||||||
throw new Error('copy failed');
|
throw new Error('copy failed');
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface ManagedLinuxRuntimePluginPaths {
|
|||||||
pluginEntrypointPath: string;
|
pluginEntrypointPath: string;
|
||||||
pluginConfigPath: string;
|
pluginConfigPath: string;
|
||||||
themePath: string;
|
themePath: string;
|
||||||
|
thumbnailerPath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnsureLinuxRuntimePluginAssetsResult {
|
export interface EnsureLinuxRuntimePluginAssetsResult {
|
||||||
@@ -23,6 +24,7 @@ interface RuntimePluginAssetSources {
|
|||||||
pluginDirSource?: string;
|
pluginDirSource?: string;
|
||||||
pluginConfigSource?: string;
|
pluginConfigSource?: string;
|
||||||
themeSourcePath?: string;
|
themeSourcePath?: string;
|
||||||
|
thumbnailerSourcePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RuntimePluginDirentLike {
|
interface RuntimePluginDirentLike {
|
||||||
@@ -72,6 +74,11 @@ export function resolveManagedLinuxRuntimePluginPaths(options: {
|
|||||||
pluginEntrypointPath: pathModule.join(pluginDir, 'main.lua'),
|
pluginEntrypointPath: pathModule.join(pluginDir, 'main.lua'),
|
||||||
pluginConfigPath: pathModule.join(rootDir, 'subminer.conf'),
|
pluginConfigPath: pathModule.join(rootDir, 'subminer.conf'),
|
||||||
themePath: pathModule.join(dataDir, 'themes', 'subminer.rasi'),
|
themePath: pathModule.join(dataDir, 'themes', 'subminer.rasi'),
|
||||||
|
thumbnailerPath: pathModule.join(
|
||||||
|
dataDir,
|
||||||
|
'thumbnailers',
|
||||||
|
'subminer-ffmpegthumbnailer.thumbnailer',
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,11 +102,12 @@ async function copyDirectoryRecursive(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveBundledThemePath(options: {
|
function resolveBundledAssetPath(options: {
|
||||||
dirname: string;
|
dirname: string;
|
||||||
appPath: string;
|
appPath: string;
|
||||||
resourcesPath: string;
|
resourcesPath: string;
|
||||||
existsSync: (candidate: string) => boolean;
|
existsSync: (candidate: string) => boolean;
|
||||||
|
relativePath: string;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
const roots = [
|
const roots = [
|
||||||
path.join(options.resourcesPath, 'assets'),
|
path.join(options.resourcesPath, 'assets'),
|
||||||
@@ -111,7 +119,7 @@ function resolveBundledThemePath(options: {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (const root of roots) {
|
for (const root of roots) {
|
||||||
const candidate = path.join(root, 'themes', 'subminer.rasi');
|
const candidate = path.join(root, options.relativePath);
|
||||||
if (options.existsSync(candidate)) return candidate;
|
if (options.existsSync(candidate)) return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,16 +137,25 @@ function resolveBundledAssetsDefault(
|
|||||||
existsSync,
|
existsSync,
|
||||||
});
|
});
|
||||||
|
|
||||||
const themeSourcePath = resolveBundledThemePath({
|
const themeSourcePath = resolveBundledAssetPath({
|
||||||
dirname: __dirname,
|
dirname: __dirname,
|
||||||
appPath: process.execPath,
|
appPath: process.execPath,
|
||||||
resourcesPath,
|
resourcesPath,
|
||||||
existsSync,
|
existsSync,
|
||||||
|
relativePath: path.join('themes', 'subminer.rasi'),
|
||||||
|
});
|
||||||
|
const thumbnailerSourcePath = resolveBundledAssetPath({
|
||||||
|
dirname: __dirname,
|
||||||
|
appPath: process.execPath,
|
||||||
|
resourcesPath,
|
||||||
|
existsSync,
|
||||||
|
relativePath: path.join('thumbnailers', 'subminer-ffmpegthumbnailer.thumbnailer'),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(pluginAssets ?? {}),
|
...(pluginAssets ?? {}),
|
||||||
...(themeSourcePath ? { themeSourcePath } : {}),
|
...(themeSourcePath ? { themeSourcePath } : {}),
|
||||||
|
...(thumbnailerSourcePath ? { thumbnailerSourcePath } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +195,8 @@ export async function ensureLinuxRuntimePluginAssets(
|
|||||||
const pluginAssetsExist =
|
const pluginAssetsExist =
|
||||||
existsSync(managedPaths.pluginEntrypointPath) && existsSync(managedPaths.pluginConfigPath);
|
existsSync(managedPaths.pluginEntrypointPath) && existsSync(managedPaths.pluginConfigPath);
|
||||||
const themeExists = existsSync(managedPaths.themePath);
|
const themeExists = existsSync(managedPaths.themePath);
|
||||||
if (pluginAssetsExist && themeExists) {
|
const thumbnailerExists = existsSync(managedPaths.thumbnailerPath);
|
||||||
|
if (pluginAssetsExist && themeExists && thumbnailerExists) {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 'already-present',
|
status: 'already-present',
|
||||||
@@ -193,6 +211,7 @@ export async function ensureLinuxRuntimePluginAssets(
|
|||||||
|
|
||||||
const shouldInstallPluginAssets = !pluginAssetsExist;
|
const shouldInstallPluginAssets = !pluginAssetsExist;
|
||||||
const shouldInstallTheme = !themeExists;
|
const shouldInstallTheme = !themeExists;
|
||||||
|
const shouldInstallThumbnailer = !thumbnailerExists;
|
||||||
if (
|
if (
|
||||||
shouldInstallPluginAssets &&
|
shouldInstallPluginAssets &&
|
||||||
(!bundledAssets.pluginDirSource || !bundledAssets.pluginConfigSource)
|
(!bundledAssets.pluginDirSource || !bundledAssets.pluginConfigSource)
|
||||||
@@ -210,6 +229,13 @@ export async function ensureLinuxRuntimePluginAssets(
|
|||||||
error: 'Bundled Linux runtime theme asset was not found.',
|
error: 'Bundled Linux runtime theme asset was not found.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (shouldInstallThumbnailer && !bundledAssets.thumbnailerSourcePath) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 'failed',
|
||||||
|
error: 'Bundled Linux rofi thumbnailer asset was not found.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const stagingSuffix = `${process.pid}-${Date.now()}`;
|
const stagingSuffix = `${process.pid}-${Date.now()}`;
|
||||||
const stagedPluginDir = pathModule.join(managedPaths.rootDir, `.subminer-stage-${stagingSuffix}`);
|
const stagedPluginDir = pathModule.join(managedPaths.rootDir, `.subminer-stage-${stagingSuffix}`);
|
||||||
@@ -221,9 +247,14 @@ export async function ensureLinuxRuntimePluginAssets(
|
|||||||
pathModule.dirname(managedPaths.themePath),
|
pathModule.dirname(managedPaths.themePath),
|
||||||
`.subminer.rasi-stage-${stagingSuffix}`,
|
`.subminer.rasi-stage-${stagingSuffix}`,
|
||||||
);
|
);
|
||||||
|
const stagedThumbnailerPath = pathModule.join(
|
||||||
|
pathModule.dirname(managedPaths.thumbnailerPath),
|
||||||
|
`.subminer-ffmpegthumbnailer.thumbnailer-stage-${stagingSuffix}`,
|
||||||
|
);
|
||||||
let pluginDirInstalled = false;
|
let pluginDirInstalled = false;
|
||||||
let pluginConfigInstalled = false;
|
let pluginConfigInstalled = false;
|
||||||
let themeInstalled = false;
|
let themeInstalled = false;
|
||||||
|
let thumbnailerInstalled = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (shouldInstallPluginAssets) {
|
if (shouldInstallPluginAssets) {
|
||||||
@@ -249,6 +280,14 @@ export async function ensureLinuxRuntimePluginAssets(
|
|||||||
await mkdir(pathModule.dirname(managedPaths.themePath), { recursive: true });
|
await mkdir(pathModule.dirname(managedPaths.themePath), { recursive: true });
|
||||||
await copyFile(themeSourcePath, stagedThemePath);
|
await copyFile(themeSourcePath, stagedThemePath);
|
||||||
}
|
}
|
||||||
|
if (shouldInstallThumbnailer) {
|
||||||
|
const thumbnailerSourcePath = bundledAssets.thumbnailerSourcePath;
|
||||||
|
if (!thumbnailerSourcePath) {
|
||||||
|
throw new Error('Bundled Linux rofi thumbnailer asset was not found.');
|
||||||
|
}
|
||||||
|
await mkdir(pathModule.dirname(managedPaths.thumbnailerPath), { recursive: true });
|
||||||
|
await copyFile(thumbnailerSourcePath, stagedThumbnailerPath);
|
||||||
|
}
|
||||||
if (shouldInstallPluginAssets) {
|
if (shouldInstallPluginAssets) {
|
||||||
await rm(managedPaths.pluginDir, { recursive: true, force: true });
|
await rm(managedPaths.pluginDir, { recursive: true, force: true });
|
||||||
await rm(managedPaths.pluginConfigPath, { force: true });
|
await rm(managedPaths.pluginConfigPath, { force: true });
|
||||||
@@ -262,6 +301,11 @@ export async function ensureLinuxRuntimePluginAssets(
|
|||||||
await rename(stagedThemePath, managedPaths.themePath);
|
await rename(stagedThemePath, managedPaths.themePath);
|
||||||
themeInstalled = true;
|
themeInstalled = true;
|
||||||
}
|
}
|
||||||
|
if (shouldInstallThumbnailer) {
|
||||||
|
await rm(managedPaths.thumbnailerPath, { force: true });
|
||||||
|
await rename(stagedThumbnailerPath, managedPaths.thumbnailerPath);
|
||||||
|
thumbnailerInstalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -278,9 +322,13 @@ export async function ensureLinuxRuntimePluginAssets(
|
|||||||
if (themeInstalled) {
|
if (themeInstalled) {
|
||||||
await rm(managedPaths.themePath, { force: true }).catch(() => {});
|
await rm(managedPaths.themePath, { force: true }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
if (thumbnailerInstalled) {
|
||||||
|
await rm(managedPaths.thumbnailerPath, { force: true }).catch(() => {});
|
||||||
|
}
|
||||||
await rm(stagedPluginDir, { recursive: true, force: true }).catch(() => {});
|
await rm(stagedPluginDir, { recursive: true, force: true }).catch(() => {});
|
||||||
await rm(stagedPluginConfigPath, { force: true }).catch(() => {});
|
await rm(stagedPluginConfigPath, { force: true }).catch(() => {});
|
||||||
await rm(stagedThemePath, { force: true }).catch(() => {});
|
await rm(stagedThemePath, { force: true }).catch(() => {});
|
||||||
|
await rm(stagedThumbnailerPath, { force: true }).catch(() => {});
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
|
|||||||
@@ -358,6 +358,30 @@ test('time-pos handler forces Jellyfin progress when mpv position jumps', () =>
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('time-pos handler treats an explicit short jump as a seek', () => {
|
||||||
|
const updateKinds: string[] = [];
|
||||||
|
let explicitSeekPending = false;
|
||||||
|
const timeHandler = createHandleMpvTimePosChangeHandler({
|
||||||
|
recordPlaybackPosition: () => {},
|
||||||
|
reportJellyfinRemoteProgress: () => {},
|
||||||
|
refreshDiscordPresence: () => {},
|
||||||
|
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||||
|
consumeExplicitSeek: () => {
|
||||||
|
const pending = explicitSeekPending;
|
||||||
|
explicitSeekPending = false;
|
||||||
|
return pending;
|
||||||
|
},
|
||||||
|
onTimePosUpdate: (_time, kind) => updateKinds.push(kind),
|
||||||
|
});
|
||||||
|
|
||||||
|
timeHandler({ time: 10 });
|
||||||
|
explicitSeekPending = true;
|
||||||
|
timeHandler({ time: 11.5 });
|
||||||
|
timeHandler({ time: 11.6 });
|
||||||
|
|
||||||
|
assert.deepEqual(updateKinds, ['initial', 'seek', 'playback']);
|
||||||
|
});
|
||||||
|
|
||||||
test('time-pos handler passes fresh playback time to AniList post-watch', async () => {
|
test('time-pos handler passes fresh playback time to AniList post-watch', async () => {
|
||||||
const watchedSeconds: unknown[] = [];
|
const watchedSeconds: unknown[] = [];
|
||||||
const timeHandler = createHandleMpvTimePosChangeHandler({
|
const timeHandler = createHandleMpvTimePosChangeHandler({
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ type AnilistPostWatchRunOptions = {
|
|||||||
watchedSeconds?: number;
|
watchedSeconds?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TimePosUpdateKind = 'initial' | 'playback' | 'seek';
|
||||||
|
|
||||||
/** Jump size that marks a time-pos change as a seek rather than normal playback. */
|
/** Jump size that marks a time-pos change as a seek rather than normal playback. */
|
||||||
export const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5;
|
export const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5;
|
||||||
|
|
||||||
@@ -138,12 +140,20 @@ export function createHandleMpvTimePosChangeHandler(deps: {
|
|||||||
refreshDiscordPresence: () => void;
|
refreshDiscordPresence: () => void;
|
||||||
maybeRunAnilistPostWatchUpdate?: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
maybeRunAnilistPostWatchUpdate?: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
||||||
logError?: (message: string, error: unknown) => void;
|
logError?: (message: string, error: unknown) => void;
|
||||||
onTimePosUpdate?: (time: number) => void;
|
onTimePosUpdate?: (time: number, kind: TimePosUpdateKind) => void;
|
||||||
|
consumeExplicitSeek?: () => boolean;
|
||||||
}) {
|
}) {
|
||||||
let lastObservedTime: number | null = null;
|
let lastObservedTime: number | null = null;
|
||||||
|
|
||||||
return ({ time }: { time: number }): void => {
|
return ({ time }: { time: number }): void => {
|
||||||
const forceImmediate = isSeekLikeTimeChange(lastObservedTime, time);
|
const explicitSeek = deps.consumeExplicitSeek?.() ?? false;
|
||||||
|
const updateKind: TimePosUpdateKind =
|
||||||
|
lastObservedTime === null
|
||||||
|
? 'initial'
|
||||||
|
: explicitSeek || isSeekLikeTimeChange(lastObservedTime, time)
|
||||||
|
? 'seek'
|
||||||
|
: 'playback';
|
||||||
|
const forceImmediate = updateKind === 'seek';
|
||||||
if (Number.isFinite(time)) {
|
if (Number.isFinite(time)) {
|
||||||
lastObservedTime = time;
|
lastObservedTime = time;
|
||||||
}
|
}
|
||||||
@@ -153,7 +163,7 @@ export function createHandleMpvTimePosChangeHandler(deps: {
|
|||||||
void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => {
|
void deps.maybeRunAnilistPostWatchUpdate?.({ watchedSeconds: time }).catch((error) => {
|
||||||
deps.logError?.('AniList post-watch update failed unexpectedly', error);
|
deps.logError?.('AniList post-watch update failed unexpectedly', error);
|
||||||
});
|
});
|
||||||
deps.onTimePosUpdate?.(time);
|
deps.onTimePosUpdate?.(time, updateKind);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
|
||||||
import { createBindMpvMainEventHandlersHandler } from './mpv-main-event-bindings';
|
import { createBindMpvMainEventHandlersHandler } from './mpv-main-event-bindings';
|
||||||
|
import { resolvePrimarySubtitleText } from './primary-subtitle-text';
|
||||||
|
|
||||||
test('main mpv event binder wires callbacks through to runtime deps', () => {
|
test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||||
const handlers = new Map<string, (payload: unknown) => void>();
|
const handlers = new Map<string, (payload: unknown) => void>();
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
let currentTime = 0;
|
||||||
|
const seekLiveText = '少しだけ好きになる\n少しだけ好きになる';
|
||||||
|
const seekCues = parseSubtitleCues(
|
||||||
|
[
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 1,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,少しだけ好きになる',
|
||||||
|
'Dialogue: 0,0:01:29.00,0:01:32.00,EDJP,,0,0,0,,少しだけ好きになる',
|
||||||
|
].join('\n'),
|
||||||
|
'seek-ending.ass',
|
||||||
|
);
|
||||||
|
|
||||||
const bind = createBindMpvMainEventHandlersHandler({
|
const bind = createBindMpvMainEventHandlersHandler({
|
||||||
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
|
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
|
||||||
@@ -27,6 +40,9 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
|||||||
calls.push(`post-watch:${options?.watchedSeconds ?? 'none'}`);
|
calls.push(`post-watch:${options?.watchedSeconds ?? 'none'}`);
|
||||||
},
|
},
|
||||||
logSubtitleTimingError: () => calls.push('subtitle-error'),
|
logSubtitleTimingError: () => calls.push('subtitle-error'),
|
||||||
|
resolveSubtitleText: (liveText) =>
|
||||||
|
resolvePrimarySubtitleText({ liveText, currentTimeSec: currentTime, cues: seekCues }),
|
||||||
|
getCurrentLiveSubtitleText: () => seekLiveText,
|
||||||
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
|
setCurrentSubText: (text) => calls.push(`set-sub:${text}`),
|
||||||
getImmediateSubtitlePayload: (text) => ({ text, tokens: [] }),
|
getImmediateSubtitlePayload: (text) => ({ text, tokens: [] }),
|
||||||
broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`),
|
broadcastSubtitle: (payload) => calls.push(`broadcast-sub:${payload.text}`),
|
||||||
@@ -60,6 +76,9 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
|||||||
recordMediaDuration: (duration) => calls.push(`duration:${duration}`),
|
recordMediaDuration: (duration) => calls.push(`duration:${duration}`),
|
||||||
reportJellyfinRemoteProgress: (forceImmediate) =>
|
reportJellyfinRemoteProgress: (forceImmediate) =>
|
||||||
calls.push(`progress:${forceImmediate ? 'force' : 'normal'}`),
|
calls.push(`progress:${forceImmediate ? 'force' : 'normal'}`),
|
||||||
|
onTimePosUpdate: (time) => {
|
||||||
|
currentTime = time;
|
||||||
|
},
|
||||||
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
|
recordPauseState: (paused) => calls.push(`pause:${paused ? 'yes' : 'no'}`),
|
||||||
|
|
||||||
updateSubtitleRenderMetrics: () => calls.push('subtitle-metrics'),
|
updateSubtitleRenderMetrics: () => calls.push('subtitle-metrics'),
|
||||||
@@ -83,7 +102,13 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
|||||||
handlers.get('media-path-change')?.({ path: '' });
|
handlers.get('media-path-change')?.({ path: '' });
|
||||||
handlers.get('media-title-change')?.({ title: 'Episode 1' });
|
handlers.get('media-title-change')?.({ title: 'Episode 1' });
|
||||||
handlers.get('subtitle-timing')?.({ text: 'timed line', start: 899, end: 901 });
|
handlers.get('subtitle-timing')?.({ text: 'timed line', start: 899, end: 901 });
|
||||||
|
handlers.get('subtitle-change')?.({ text: seekLiveText });
|
||||||
|
handlers.get('time-pos-change')?.({ time: 90 });
|
||||||
|
assert.ok(calls.includes('set-sub:少しだけ好きになる'));
|
||||||
|
|
||||||
handlers.get('time-pos-change')?.({ time: 2.5 });
|
handlers.get('time-pos-change')?.({ time: 2.5 });
|
||||||
|
handlers.get('subtitle-change')?.({ text: seekLiveText });
|
||||||
|
handlers.get('time-pos-change')?.({ time: 90 });
|
||||||
handlers.get('pause-change')?.({ paused: true });
|
handlers.get('pause-change')?.({ paused: true });
|
||||||
|
|
||||||
assert.ok(calls.includes('set-sub:line'));
|
assert.ok(calls.includes('set-sub:line'));
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
|
|
||||||
setCurrentSubText: (text: string) => void;
|
setCurrentSubText: (text: string) => void;
|
||||||
resolveSubtitleText?: (text: string) => string;
|
resolveSubtitleText?: (text: string) => string;
|
||||||
|
getCurrentLiveSubtitleText?: () => string;
|
||||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||||
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
||||||
broadcastSubtitle: (payload: SubtitleData) => void;
|
broadcastSubtitle: (payload: SubtitleData) => void;
|
||||||
@@ -78,6 +79,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
recordMediaDuration: (durationSec: number) => void;
|
recordMediaDuration: (durationSec: number) => void;
|
||||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
||||||
onTimePosUpdate?: (time: number) => void;
|
onTimePosUpdate?: (time: number) => void;
|
||||||
|
consumeExplicitSeek?: () => boolean;
|
||||||
onFullscreenChange?: (fullscreen: boolean) => void;
|
onFullscreenChange?: (fullscreen: boolean) => void;
|
||||||
recordPauseState: (paused: boolean) => void;
|
recordPauseState: (paused: boolean) => void;
|
||||||
|
|
||||||
@@ -171,7 +173,15 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
|||||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||||
maybeRunAnilistPostWatchUpdate: (options) => deps.maybeRunAnilistPostWatchUpdate(options),
|
maybeRunAnilistPostWatchUpdate: (options) => deps.maybeRunAnilistPostWatchUpdate(options),
|
||||||
logError: (message, error) => deps.logSubtitleTimingError(message, error),
|
logError: (message, error) => deps.logSubtitleTimingError(message, error),
|
||||||
onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time),
|
consumeExplicitSeek: deps.consumeExplicitSeek,
|
||||||
|
onTimePosUpdate: (time, updateKind) => {
|
||||||
|
deps.onTimePosUpdate?.(time);
|
||||||
|
if (updateKind === 'playback') return;
|
||||||
|
const liveText = deps.getCurrentLiveSubtitleText?.();
|
||||||
|
if (liveText !== undefined) {
|
||||||
|
handleMpvSubtitleChange({ text: liveText });
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const handleMpvPauseChange = createHandleMpvPauseChangeHandler({
|
const handleMpvPauseChange = createHandleMpvPauseChangeHandler({
|
||||||
recordPauseState: (paused) => deps.recordPauseState(paused),
|
recordPauseState: (paused) => deps.recordPauseState(paused),
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
overlayRuntimeInitialized: boolean;
|
overlayRuntimeInitialized: boolean;
|
||||||
mpvClient: {
|
mpvClient: {
|
||||||
connected?: boolean;
|
connected?: boolean;
|
||||||
|
currentSubText?: string;
|
||||||
currentSecondarySubText?: string;
|
currentSecondarySubText?: string;
|
||||||
currentTimePos?: number;
|
currentTimePos?: number;
|
||||||
requestProperty?: (name: string) => Promise<unknown>;
|
requestProperty?: (name: string) => Promise<unknown>;
|
||||||
@@ -85,6 +86,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
resetAnilistMediaGuessState: () => void;
|
resetAnilistMediaGuessState: () => void;
|
||||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
||||||
onTimePosUpdate?: (time: number) => void;
|
onTimePosUpdate?: (time: number) => void;
|
||||||
|
consumeExplicitSeek?: () => boolean;
|
||||||
onFullscreenChange?: (fullscreen: boolean) => void;
|
onFullscreenChange?: (fullscreen: boolean) => void;
|
||||||
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
|
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
|
||||||
refreshDiscordPresence: () => void;
|
refreshDiscordPresence: () => void;
|
||||||
@@ -160,6 +162,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos),
|
currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos),
|
||||||
cues: deps.appState.activeParsedSubtitleCues,
|
cues: deps.appState.activeParsedSubtitleCues,
|
||||||
}),
|
}),
|
||||||
|
getCurrentLiveSubtitleText: () => deps.appState.mpvClient?.currentSubText ?? '',
|
||||||
recordImmersionSubtitleLine: (text: string, start: number, end: number) => {
|
recordImmersionSubtitleLine: (text: string, start: number, end: number) => {
|
||||||
deps.ensureImmersionTrackerInitialized();
|
deps.ensureImmersionTrackerInitialized();
|
||||||
const tracker = deps.appState.immersionTracker;
|
const tracker = deps.appState.immersionTracker;
|
||||||
@@ -332,6 +335,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
},
|
},
|
||||||
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
|
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
|
||||||
deps.reportJellyfinRemoteProgress(forceImmediate),
|
deps.reportJellyfinRemoteProgress(forceImmediate),
|
||||||
|
consumeExplicitSeek: deps.consumeExplicitSeek,
|
||||||
onTimePosUpdate: (time: number) => {
|
onTimePosUpdate: (time: number) => {
|
||||||
// Timing history is a viewing log: after a real backward seek, a rewatched
|
// Timing history is a viewing log: after a real backward seek, a rewatched
|
||||||
// canonical line should enter it again. Immersion stats keep their
|
// canonical line should enter it again. Immersion stats keep their
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
};
|
||||||
|
}
|
||||||