Compare commits

..
Author SHA1 Message Date
sudacode 1d1575f414 fix(anime): preserve HLS playlist rewriting and fetch timeouts
- Force identity encoding for upstream playlist responses
- Keep inactivity timeouts active while streaming response bodies
2026-08-16 14:20:08 -07:00
sudacode f6993ae507 fix(anime): harden stream proxy and session lifecycle
- Reject non-origin-form proxy targets
- Release stale anime browser sessions when senders move
2026-08-16 01:46:16 -07:00
sudacode 4300517da7 fix(anime): harden browser playback and modal lifecycle
- Abort upstream fetches when clients disconnect
- Validate embedded modal readiness before opening
- Centralize browser IPC and window lifecycle
2026-08-16 01:21:07 -07:00
sudacode 877f350353 fix(anime): keep browser state in sync and alias HLS segments
- Toggle the player Anime Browser without losing its state
- Share active playback state and support rotating fake segment extensions
2026-08-15 23:35:37 -07:00
sudacode 7a1450ddb7 feat(anime): open Anime Browser in player modal
- Add the Ctrl+Alt+A shortcut and dedicated overlay modal
- Share queue, sources, and watch history while isolating browser state
2026-08-15 21:44:52 -07:00
sudacode cc43d3007a fix(anime): alias disguised HLS segments for ffmpeg
- Expose local `.ts` aliases for `.image` MPEG-TS segments
- Cover proxy rewriting and upstream alias removal with tests
2026-08-15 21:44:51 -07:00
sudacode 333ee5eea4 fix(anime): clean up discarded queued subtitles in background
- Let queued episode discard return before subtitle preparation finishes
- Remove the redundant queued subtitle loading command
2026-08-15 21:44:51 -07:00
sudacode 935e4c145f feat(anime): append queued episodes to mpv playlist
- Resolve queued streams immediately and cache subtitles in the background
- Attach prepared tracks as mpv advances through the playlist
2026-08-15 21:44:51 -07:00
sudacode 183560d2c3 fix(macos): bring app forward when opening settings/sync/anime windows
- add activateMacOSApp to steal app focus before window.focus(), since show()/focus() only reorder windows within an already-active app
- wire activateApp through config-settings-window/runtime into the settings, sync, and anime browser window handlers
- restore the anime browser's Dock icon before showing the window instead of after, since an accessory process cannot become frontmost
2026-08-15 21:44:51 -07:00
sudacode 3ca7dcd664 feat(anime): queue episodes to play next across anime
- Episode rows gain Play/Queue actions (and matching context-menu items); queued rows show their place in line, with a queue count and Clear queue in the episode header
- Queue lives in the main process (`anime-browser-queue.ts`) so it survives the browser window closing and advances on mpv's end-file even when nobody is watching; streams resolve at play time so a signed URL cannot expire while queued
- Holds mpv's keep-open off while the queue waits and restores it once empty; queueing with nothing playing just plays immediately
- Adds anime-browser-queue and episode-queue unit tests, IPC channels/contracts, and doc updates
2026-08-15 21:44:51 -07:00
sudacode 51fc9034f7 fix(subsync): decode file:// tracks mpv reports for dropped subtitles
- Sync a subtitle dropped onto mpv without hitting "Protocol file: not supported"; decode file:// external-filename/path back to a real path for target, reference, and video
- Stream strip proxy: destroy the connection instead of retrying/502 once a response is handed off, and cap buffered playlist bodies
- Bridge installer: cap downloaded bundle size to guard against a lying/missing content-length
- Anime browser playback: bump generation on dispose so a stale in-flight playEpisode cleans up its own subtitle cache
- Fix a flaky sidecar-process test by binding to an OS-assigned port instead of pre-allocating one
2026-08-15 21:44:51 -07:00
sudacode 8c22567da9 feat(anime): show extension and repo icons in extensions panel
- Add extension-icons module (buildIconIndex, isSafeIconUrl, repoFaviconUrl, iconMonogram) with tests
- Render each row's icon with a monogram fallback while loading or missing
- Installed extensions borrow their icon from the catalogue; repo rows use the index host's favicon
- Restrict icon loads to https URLs
- Thread iconUrl through AvailableExtension and the runtime, document the feature
2026-08-15 21:44:51 -07:00
sudacode b22001f68e fix(anime): isolate mark-watched requests and batch marks atomically
- Track mark writes with their own LatestRequest so a concurrent watch-state
  refresh can't supersede them, and re-read the store after a write to settle
  stale repaints
- Wrap batch markEpisodesWatched in a single transaction so partial rows and
  their lifetime summary rebuild can't land separately; defer clearing the
  active session's markedWatched flag until after commit
2026-08-15 21:44:51 -07:00
sudacode 8d55d64ee0 feat(anime): filter episodes and mark them watched by hand
- Add a filter box above the episode list for a number, range, or name substring, with a "N of M" counter
- Read watch marks from the immersion tracker stats and show them per episode, with a watched count in the header, refreshed on window focus
- Add a right-click menu to mark one episode or a whole catch-up span (this and everything below) watched/unwatched
- Add setWatched/getWatchState IPC plumbing so a manual mark creates the stats row for an episode that was never played
2026-08-15 21:44:51 -07:00
sudacode 3dd0750b98 fix(anime): stop superseded playEpisode calls from touching mpv
- Extend the generation guard from subtitleCacheDir to the whole
  playback: a stale playEpisode call now bails out before sending mpv
  commands, showing the overlay, or reading a playback outcome that
  belongs to a newer episode
- Add a regression test where a slow-resolving first call must not
  drive mpv after a second call for a newer episode has already
  started playing
2026-08-15 21:44:51 -07:00
sudacode 8aff2ae505 fix(anime): guard playback races and harden install/extract tests
- Tag subtitle cache writes with a generation counter so overlapping playEpisode calls can't clobber the shared cache dir, and don't fail playback when track setup errors
- Ignore stale episode clicks in the detail panel via a LatestRequest guard on playback
- Extract a resetGrid helper in animeui to dedupe grid-clearing logic
- Assert reader cancellation and extracted-file writes actually happen in installer/subsync tests
2026-08-15 21:44:51 -07:00
sudacode 289c74da35 fix(anime): scope preferences, paginate sources, harden installs
- Preference store keys entries by extension package + bridge source id; legacy unscoped entries are discarded once instead of being handed to whichever extension asks first
- Source picker's "Load more" appends the next page without duplicating streamed results
- Repository index fetches and subtitle/APK downloads now time out and are size-bounded instead of hanging or growing unbounded
- APK installs are staged to a temp file and renamed into place
- Stream metadata lookup matches the requested path, not only the currently playing one
- Reworked animeui into browse-state/detail-panel/panels.css modules
- Reverted premature CHANGELOG unreleased entries; refreshed anime-browser docs
2026-08-15 21:44:51 -07:00
sudacode f5369b8b24 docs: catch up docs for anilist, anime browser, and subsync fixes
- Add changelog entries for the split-cour AniList season resolution fix and the subsync stream-track fixes
- Document anime browser language filter, stream subtitle download-to-disk behavior, and series/season/episode metadata threading
- Update domain ownership and knowledge-base catalog for the anime browser modules
- Trim the credits table and clarify the M-Extension-Server licensing note in README
2026-08-15 21:44:51 -07:00
sudacode c94830531c fix(anime): thread episode metadata through stream playback and fix seas
- Split anime browser stream titles into series/season/episode fields (episode-metadata.ts) instead of one joined string, so stats grouping, the mpv title, and the Jimaku/TsukiHime modals all get real values instead of a filename-derived "m3u8"
- Fix AniList season resolver to walk through split-cour sequels and specials without counting them as a season, and to surface rate-limit/network failures instead of falling through to a wrong air-order guess
- Add a Season field to the TsukiHime modal and include later seasons in its search query
- Rewrite WebVTT subtitle tracks as SRT before handing them to alass, since alass has no VTT support and streams serve VTT
- Update CHANGELOG and troubleshooting docs
2026-08-15 21:44:51 -07:00
sudacode 035ff8e7bf fix(subsync): resolve ffmpeg only when a track needs extraction
Stream subtitles arrive as URLs and are downloaded, never piped through ffmpeg, so demanding an ffmpeg install up front broke stream syncs (and CI, which has no ffmpeg). Resolution is now lazy, scoped to embedded tracks.
2026-08-15 21:44:51 -07:00
sudacode 4abbe8d567 fix(subsync): sync subtitles from stream URLs, auto-discover tool paths
- Download URL-loaded subtitle tracks (Aniyomi extension streams, Jellyfin) to a temp file first, reusing mpv's own request headers, instead of rejecting them with "Subtitle file not found"
- Pass mpv's headers through to ffmpeg for internal track extraction from streams too
- Auto-discover alass/ffsubsync/ffmpeg on PATH and common install prefixes when the config path is empty, instead of a hard-coded /usr/bin fallback that never exists on macOS
- Log subsync failures to the app log, not just a toast that vanishes in seconds
- Update docs-site and README credits for the new behavior
2026-08-15 21:44:51 -07:00
sudacode feb8d5a55c fix(anime): cap outcome polling and upstream stalls by wall clock
- watchPlaybackOutcome now tracks a real deadline so slow property reads eat the timeout budget instead of extending it, and a zero probe interval still terminates
- stream strip proxy aborts upstream GETs that go silent for 15s instead of hanging mpv on that segment forever
- anime browser runtime drops a stream proxy that finishes starting after its bridge already died, instead of leaking a listener pointed at nothing
2026-08-15 21:44:51 -07:00
sudacode d1d128b3f2 feat(anime): cache stream subtitles to disk for the alass reference
- Download a stream's subtitle tracks to a temp dir and load them into mpv as files instead of streaming from the source URL, so Subsync can use them as the alass reference (mirrors the Jellyfin subtitle preload)
- Detect format from content first, falling back to the URL extension
- A track that fails to download falls back to its URL so the episode still plays
- Remove the temp dir when the next episode loads or the runtime disposes
2026-08-15 21:44:51 -07:00
sudacode 2fbf27faff fix(anime): report a failed bridge kill instead of a generic timeout
- Track kill errors separately from exit/spawn so a signal failure (e.g. EPERM) surfaces its own message instead of a bare "did not exit after SIGKILL"
- Attach stop() failures as the cause on the readiness-timeout error so a surviving child's kill failure isn't dropped
- Skip stop() entirely when spawn itself failed, and stop polling once the ready deadline has passed
- Add stopTimeoutMs test hook and a test covering a kill that fails without the child exiting
2026-08-15 21:44:51 -07:00
sudacode 2e54a3ac16 style(animeui): tighten source picker and cue rail layout
- Lay source picker label + input inline instead of stacked
- Let detail cover keep natural aspect ratio instead of forced 2:3 crop
- Widen cue rail gutter to match new cue number column width
2026-08-15 21:44:51 -07:00
sudacode 891c4a13d5 fix(overlay): fall back to did-finish-load when ready-to-show never fires
A hidden overlay window stops producing frames once it is explicitly
hidden and then reloaded (the Yomitan content-script reload right after
startup), so ready-to-show never fires and the content-ready gate keeps
the window hidden forever behind the Overlay loading spinner. Mark
content ready from did-finish-load after a 1.5s grace period so the
overlay always becomes showable; ready-to-show still wins when it fires.
2026-08-15 21:44:51 -07:00
sudacode 4739c3da78 fix(anime): restart the bridge on the next request after it dies
The reported 'APK crash' turned out not to exist: repeated full scans of
all installed extensions load cleanly. The real incident was the app's
quit sequence stopping the bridge (its /stop endpoint) while requests
were in flight - and it exposed that a bridge dying for any reason left
the anime browser failing every call with a raw 'fetch failed' until
the whole app was restarted.

SidecarHandle now reports the child's exit (including to late
subscribers), and the runtime detects a death it did not order: it
drops the dead handle, closes the strip proxy, tells the UI, and the
next request transparently starts a fresh bridge. requireBridge became
an async bridge() that starts on demand, which also covers first use.

Verified live: search -> SIGKILL the JVM -> search recovers.
2026-08-15 21:44:51 -07:00
sudacode ec66f59257 fix(anime): never forward Range to the bridge so 206 replies cannot skip the strip
ffmpeg opens every HLS segment with 'Range: bytes=0-'. The bridge
answers some of those 206 (depending on its cache state), and the proxy
only rewrites full 200 bodies, so the PNG disguise passed through
untouched and whether an episode played was a coin flip: first attempts
died with "no audio or video data played", the same URL played fine
once the bridge had the segments cached.

Reproduced end to end against a private bridge + mpv driven with the
app's exact commands; with Range dropped every segment is stripped and
playback runs.
2026-08-15 21:44:51 -07:00
sudacode ec9da6bed3 fix(anime): retry cold upstream fetches in the stream strip proxy
Right after an episode resolves, the bridge (or the host behind it) can
error on the very first segment fetches and be fine a moment later; mpv
sweeps the playlist of failing segments in ~2s and gives up with "no
audio or video data played" while a manual reload of the same URL plays.
Retry a failed GET once after 400ms and log upstream error statuses so
the next failure names the real cause in the app log.
2026-08-15 21:44:51 -07:00
sudacode 7643855e61 fix(anime): strip disguised HLS segments and report real playback errors
Some hosts prepend a fake image header (a real 1x1 PNG) to every HLS
segment; ffmpeg probes the segment as a picture and mpv drops back to
idle with no window while the browser claims the episode is playing.

- Route bridge-served m3u8 streams through a local strip proxy that
  scans each segment for the first genuine MPEG-TS packet run and drops
  the junk in front of it; playlists get absolute origins rewritten so
  segment requests come back through the proxy. Non-TS bodies pass
  through untouched.
- Only report ok from playEpisode once mpv configures a video output;
  an end-file with reason error surfaces mpv's own message (e.g. "no
  audio or video data played") in the browser status bar instead of
  "Playing". New end-file event plumbed through the mpv IPC client.
2026-08-15 21:44:51 -07:00
sudacode f8a8235b7d fix(anime): pin linux-x64 bridge checksum and fetch release by tag
- Add PINNED_BUNDLE_SHA256 entry for linux-x64-bundle.zip so Linux no longer refuses to start with "No pinned checksum"
- Fetch the bridge release by its pinned tag (releases/tags/<tag>) instead of listing newest releases, so an upstream publish can't swap in an unverified asset
- Update docs and changelog to describe the pin/tag behavior
2026-08-15 21:44:51 -07:00
sudacode 7220f6fc9b feat(anime): add language filter to extensions available list
- Add a language chip row above the Available list; pick one or more languages to narrow the repo index, or "All" to clear
- Show language display names (e.g. "Japanese") instead of raw tags, and an "N of M" count on the Available heading
- Add language-filter.ts with collectLanguages/toggleLanguage/pruneSelection/filterByLanguage/languageLabel, plus tests
2026-08-15 21:44:51 -07:00
sudacode c201520da3 fix(anime): honor the sidecar readiness deadline and confirm shutdown
The capabilities probe used a fixed 5s timeout, so a readiness budget
shorter than that could be overrun by one stalled request. Pass the
remaining deadline down instead.

stop() also resolved after the SIGKILL wait even when the child had not
exited, letting a restart race a process still holding the port. Throw
in that case, and keep a failed shutdown from masking the readiness
error at startup.
2026-08-15 21:44:51 -07:00
sudacode a612d44535 fix(anime): harden the extension bridge against untrusted repos and hangs
Addresses CodeRabbit review feedback on the Anime Browser:

- reject repository package/apk names that are not plain identifiers, and
  verify the install target resolves inside the extensions directory
- count mpv's %n% option escape in UTF-8 bytes, and escape backslashes in
  header values so a trailing one cannot eat the list separator
- key the bridge extension-id cache by APK content, so an in-place upgrade
  re-uploads instead of running the previous build
- bound every bridge, release-listing, and download request with a timeout
- enforce the APK size limit while streaming rather than after buffering
- read APK bytes on demand instead of holding a base64 copy per extension
  for the lifetime of the browser
- serialize preference mutations and write the file atomically
- handle the sidecar spawn error event, and wait for the child to exit in
  stop() before returning
- report a failed Anime Browser bootstrap instead of showing the starting
  banner forever
- keep the preferences panel's save confirmation and in-flight multi-select
  edits by re-rendering only on a structural schema change
2026-08-15 21:44:51 -07:00
sudacode ec48f90cd4 feat(anime): add anime browser powered by Aniyomi extensions
- Add `subminer anime` / `--anime` and a tray entry to open a browser that searches installed Aniyomi extension sources, shows cover art and episodes, and plays into mpv with overlay/mining attached
- Add an Extensions tab to add repos and install/update/remove sources, and per-source settings for sources needing config
- Support searching all sources at once with streaming, per-source results and status
- Prefer Japanese audio/subtitle tracks from the source and keep the primary subtitle slot reserved for Japanese
- Fix window/tray/Dock handling so the browser and mpv can be switched between without quitting the app or losing the Dock icon
- Add anime.repos, anime.extensionsDir, anime.preferredQuality config keys (no bundled repos or discovery)
2026-08-15 21:44:51 -07:00
427 changed files with 19576 additions and 13335 deletions
-34
View File
@@ -1,34 +0,0 @@
---
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.
+2 -4
View File
@@ -32,11 +32,9 @@ 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 [[ ! "$TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then if [[ ! "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::notice::Skipping non-stable docs tag $TAG_NAME" echo "::notice::Skipping non-stable docs tag ${{ github.ref_name }}"
echo "stable_tag=false" >> "$GITHUB_OUTPUT" echo "stable_tag=false" >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi fi
+9 -17
View File
@@ -274,8 +274,7 @@ 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: |
@@ -297,22 +296,15 @@ 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
@@ -334,27 +326,27 @@ jobs:
exit 1 exit 1
fi fi
if gh release view "$RELEASE_VERSION" >/dev/null 2>&1; then if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then
gh release edit "$RELEASE_VERSION" \ gh release edit "${{ steps.version.outputs.VERSION }}" \
--draft \ --draft \
--prerelease \ --prerelease \
--title "$RELEASE_VERSION" \ --title "${{ steps.version.outputs.VERSION }}" \
--notes-file release/prerelease-notes.md --notes-file release/prerelease-notes.md
else else
gh release create "$RELEASE_VERSION" \ gh release create "${{ steps.version.outputs.VERSION }}" \
--draft \ --draft \
--latest=false \ --latest=false \
--prerelease \ --prerelease \
--title "$RELEASE_VERSION" \ --title "${{ steps.version.outputs.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 "$RELEASE_VERSION" "$asset" --clobber gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber
done done
gh release edit "$RELEASE_VERSION" \ gh release edit "${{ steps.version.outputs.VERSION }}" \
--draft=false \ --draft=false \
--prerelease \ --prerelease \
--title "$RELEASE_VERSION" \ --title "${{ steps.version.outputs.VERSION }}" \
--notes-file release/prerelease-notes.md --notes-file release/prerelease-notes.md
+14 -26
View File
@@ -273,8 +273,7 @@ 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,40 +295,33 @@ 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 $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." 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."
exit 1 exit 1
fi fi
- name: Verify changelog is ready for tagged release - name: Verify changelog is ready for tagged release
env: run: bun run changelog:check --version "${{ steps.version.outputs.VERSION }}"
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
env: run: bun run changelog:release-notes --version "${{ steps.version.outputs.VERSION }}"
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 "$RELEASE_VERSION" >/dev/null 2>&1; then if gh release view "${{ steps.version.outputs.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 "$RELEASE_VERSION" \ gh release edit "${{ steps.version.outputs.VERSION }}" \
--draft=false \ --draft=false \
--title "$RELEASE_VERSION" \ --title "${{ steps.version.outputs.VERSION }}" \
--notes-file release/release-notes.md --notes-file release/release-notes.md
else else
gh release create "$RELEASE_VERSION" \ gh release create "${{ steps.version.outputs.VERSION }}" \
--title "$RELEASE_VERSION" \ --title "${{ steps.version.outputs.VERSION }}" \
--notes-file release/release-notes.md --notes-file release/release-notes.md
fi fi
@@ -352,7 +344,7 @@ jobs:
fi fi
for asset in "${artifacts[@]}"; do for asset in "${artifacts[@]}"; do
gh release upload "$RELEASE_VERSION" "$asset" --clobber gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber
done done
aur-publish: aur-publish:
@@ -428,10 +420,9 @@ 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="$RELEASE_VERSION" version="${{ steps.version.outputs.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 \
@@ -441,17 +432,15 @@ 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="$RELEASE_VERSION" version_no_v="${{ steps.version.outputs.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 "$RELEASE_VERSION" \ --version "${{ steps.version.outputs.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"
@@ -461,7 +450,6 @@ 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
@@ -471,7 +459,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 $RELEASE_VERSION" git commit -m "Update to ${{ steps.version.outputs.VERSION }}"
attempts=3 attempts=3
for attempt in $(seq 1 "$attempts"); do for attempt in $(seq 1 "$attempts"); do
-3
View File
@@ -49,7 +49,6 @@ 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
@@ -57,8 +56,6 @@ 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
+3 -8
View File
@@ -2,10 +2,8 @@
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
@@ -223,13 +221,11 @@ docs-dev: ensure-bun
install-linux: build-launcher install-linux: build-launcher
@printf '%s\n' "[INFO] Installing Linux wrapper/support artifacts" @printf '%s\n' "[INFO] Installing Linux wrapper/theme 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 \
@@ -238,7 +234,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)" " $(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)" @printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(LINUX_DATA_DIR)/themes/$(THEME_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"
@@ -279,9 +275,8 @@ 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)/thumbnailers/$(THUMBNAILER_FILE)" " $(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"
uninstall-macos: uninstall-macos:
@rm -f "$(BINDIR)/subminer" @rm -f "$(BINDIR)/subminer"
+26 -13
View File
@@ -15,7 +15,7 @@ Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersio
[![License](https://img.shields.io/github/license/ksyasuda/SubMiner?style=flat-square&color=1a1a2e)](https://www.gnu.org/licenses/gpl-3.0) [![License](https://img.shields.io/github/license/ksyasuda/SubMiner?style=flat-square&color=1a1a2e)](https://www.gnu.org/licenses/gpl-3.0)
[![TypeScript](https://img.shields.io/badge/TypeScript-1a1a2e?style=flat-square&logo=typescript&logoColor=3178c6)](https://www.typescriptlang.org) [![TypeScript](https://img.shields.io/badge/TypeScript-1a1a2e?style=flat-square&logo=typescript&logoColor=3178c6)](https://www.typescriptlang.org)
[![SubMiner demo](./assets/minecard.webp)](https://github.com/user-attachments/assets/7abab8a9-4e4e-4f06-9f3c-9783e15a3807) [![SubMiner demo](./assets/minecard.webp)](https://github.com/user-attachments/assets/89e61895-e2b7-4b47-8d50-a35afe4132b2)
</div> </div>
@@ -86,6 +86,10 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
<td><b>Jellyfin</b></td> <td><b>Jellyfin</b></td>
<td>Browse, launch, and cast media from your Jellyfin server with setup and discovery controls in the app tray</td> <td>Browse, launch, and cast media from your Jellyfin server with setup and discovery controls in the app tray</td>
</tr> </tr>
<tr>
<td><b>Anime Browser</b></td>
<td>Search anime sources you supply as <a href="https://github.com/aniyomiorg/aniyomi">Aniyomi</a> extension APKs and play an episode in mpv with the overlay attached (<code>subminer anime</code>); SubMiner ships no repositories and bundles no sources</td>
</tr>
<tr> <tr>
<td><b>Jimaku</b></td> <td><b>Jimaku</b></td>
<td>Search and download Japanese subtitles</td> <td>Search and download Japanese subtitles</td>
@@ -256,19 +260,28 @@ Full guides on configuration, Anki setup, Jellyfin, immersion tracking, and more
SubMiner builds on the work of these open-source projects: SubMiner builds on the work of these open-source projects:
| Project | Role | | Project | Role |
| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| [ani-skip](https://github.com/synacktraa/ani-skip) | AniSkip API client for anime intro/outro skip timestamps | | [ani-skip](https://github.com/synacktraa/ani-skip) | AniSkip API client for anime intro/outro skip timestamps |
| [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script) | Inspiration for the mining workflow | | [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script) | Inspiration for the mining workflow |
| [asbplayer](https://github.com/killergerbah/asbplayer) | Inspiration for subtitle sidebar and logic for YouTube subtitle parsing | | [Aniyomi](https://github.com/aniyomiorg/aniyomi) | Anime extension API and data model the anime browser targets |
| [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) | Character name recognition in subtitles | | [asbplayer](https://github.com/killergerbah/asbplayer) | Inspiration for subtitle sidebar and logic for YouTube subtitle parsing |
| [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner) | Inspiration for Electron overlay with Yomitan integration | | [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) | Character name recognition in subtitles |
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration | | [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner) | Inspiration for Electron overlay with Yomitan integration |
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads | | [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration | | [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser | | [M-Extension-Server](https://github.com/1Selxo/M-Extension-Server) | Runs Aniyomi extension APKs off Android; the bridge the anime browser drives |
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary | | [Mangatan](https://github.com/1Selxo/Mangatan) | Reference client for the bridge protocol the anime browser speaks |
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
## License ## License
[GNU General Public License v3.0](LICENSE) [GNU General Public License v3.0](LICENSE)
The anime browser drives [M-Extension-Server](https://github.com/1Selxo/M-Extension-Server),
downloaded from its upstream releases at runtime rather than
bundled or redistributed here; its bundles carry their own dependencies, including a JRE and
GPL-3.0 NewPipe Extractor. SubMiner includes none of them and talks to the bridge
over its own HTTP protocol. It ships no extensions or repositories by default.
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

After

Width:  |  Height:  |  Size: 3.0 MiB

@@ -1,4 +0,0 @@
[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;
-1
View File
@@ -49,7 +49,6 @@ 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
+7
View File
@@ -0,0 +1,7 @@
type: fixed
area: anilist
- Resolving "season N" against AniList no longer lands one season short for franchises whose broadcast seasons are listed as several entries. AniList records the back half of a split cour ("… Season 2 Part 2", "… Cour 2", "第2クール") as its own sequel, and the resolver counted each as a season — so Mushoku Tensei season 3 resolved to the season 2 entry and Re:ZERO season 4 to season 3, which then drove the character dictionary and AniList progress updates to the wrong series. A sequel carrying a part or cour marker that matches the season it continues is now followed without advancing the season count, in both the relation walk and the air-order fallback. Titles are compared ignoring punctuation, and the marker is looked for in every title and synonym rather than just the display title. A wrong match cached before this fix is remembered in `character-dictionaries/anilist-resolution-cache.json` and needs removing (or overriding from the character dictionary picker) for the affected series.
- A special or OVA sitting in a franchise's sequel chain is walked through without counting as a season. Dr. STONE links STONE WARS to New World through a one-episode special, which made New World resolve as season 4 and every later season shift with it.
- An AniList rate limit or network failure while walking sequel relations is reported so the caller can retry, instead of falling through to the air-date fallback. That fallback is for a franchise with missing relation edges; running it after a failed lookup turned a transient 429 into a confidently wrong season.
- The shared title parser recognizes spelled-out episode labels (`Episode 4`, `第4話`) and a season named at the end of the title (`… Season 3`, `… 3rd Season`, `… S3`), which now fill the Season field instead of being searched for as part of the series name. The TsukiHime modal gained a Season field to match Jimaku, and seasons after the first are included in its search query so a later season's releases are actually found.
+32
View File
@@ -0,0 +1,32 @@
type: added
area: anime
- Added an anime browser window that searches Aniyomi extension sources, shows cover art and episode lists, and plays an episode in mpv so the overlay and mining tools attach as usual.
- Added `subminer anime` and the `--anime` flag to open the browser, plus a "Browse Anime" tray entry.
- Anime extensions are read from `<userData>/anime-extensions`; drop Aniyomi `.apk` files there to add sources.
- Added a source settings tab so extensions that need configuration (server address, credentials, quality) can be set up from the browser; values persist per extension and source, and updated extension schemas replace stale saved field definitions without losing values. Older unscoped preferences are discarded once because their package ownership cannot be proven safely.
- Added an Extensions tab for adding repository URLs and installing, updating, or removing extensions in place; extensions that fail to load are listed with the reason. Repository requests time out instead of hanging, and APK updates are staged before replacing the installed copy.
- Browse, Extensions, and Source settings are tabs, so each one gets the full window instead of sharing it with the search results.
- Repository URLs only need to be an https URL to a `.json` index; the file name is not restricted to `index.min.json`.
- The source picker offers "All sources", which searches every installed source at once. Results stream in as each source answers, with per-source progress in the status bar. Results are tagged with their source, failures do not blank the grid, and **Load more** appends later pages without duplicating streamed entries.
- The Extensions tab opens with an Installed section listing every extension on disk with Remove — including ones added by hand or whose repository has since been removed — and Update where a configured repository still carries it.
- Added `anime.repos`, `anime.extensionsDir`, and `anime.preferredQuality` config keys. SubMiner ships no extension repositories and performs no discovery.
- Anime playback targets Japanese audio: dub-labelled entries are skipped when the source offers an alternative, `alang` prefers Japanese, and the source's own audio and subtitle tracks are loaded into mpv (Japanese selected) instead of being discarded, so all of them can be switched from mpv's track menu.
- The primary subtitle slot stays reserved for Japanese: a source that only has, say, English subtitles gets them added with a normalized language tag (`English``en`) but not selected, so the regular `secondarySub` auto-load can route them to the secondary slot instead.
- HLS streams pass through a local strip proxy that removes fake image headers some hosts glue onto their video segments and gives disguised segment URLs (`.image`, `.jpg`, `.css`, and other rotating fake extensions) a media-safe local alias, so those streams play in mpv and support Anki audio and image extraction with current ffmpeg releases. Upstream responses use identity encoding so playlists remain readable for URL rewriting.
- The strip proxy retries a failed segment fetch once after a short pause and logs upstream error statuses; a host that errors on the very first fetches right after an episode resolves no longer kills the whole playback, disconnecting clients release their active upstream fetch instead of consuming the socket and bandwidth in the background, stalled response bodies retain the upstream inactivity timeout, and only origin-form requests can reach the configured local bridge.
- The strip proxy no longer forwards `Range` headers to the bridge: ffmpeg opens every HLS segment with `Range: bytes=0-`, the bridge answers some of those with 206, and a partial response bypassed the disguise strip, so whether an episode played depended on the bridge's cache state.
- A bridge that dies out from under the app (killed, crashed, or stopped mid-operation) no longer leaves the browser failing every request until an app restart: the exit is detected, surfaced in the status bar, and the bridge restarts on the next request.
- "Playing" is only reported once mpv actually configures a video output; when a stream fails to decode, the browser shows mpv's error instead of claiming playback started while no window ever appeared.
- The Linux x64 bridge bundle is verified and pinned, so the anime browser starts on Linux instead of refusing with "No pinned checksum for linux-x64-bundle.zip".
- The bridge bundle is fetched from the pinned release tag rather than whatever release is newest, so an upstream publish no longer breaks every install with a checksum mismatch.
- The Extensions tab's available list has a language chip row: pick one or more languages to narrow a repository index that otherwise lists every language it knows, or "All" to clear the filter. Rows name the language ("Japanese" instead of `ja`) and the Available heading counts what the filter leaves.
- A stream's subtitle tracks are downloaded to a temp directory and loaded into mpv as files rather than streamed from the source URL, so they can serve as the alass reference in Subsync the way Jellyfin subtitles do. The format is detected from the file's own content, a track that fails to download falls back to its URL so the episode still plays, and the directory is removed when the next episode starts or the app exits.
- An episode launched from the browser carries its series, season and episode number through the app instead of a single joined string: stats group by series (every stream previously landed in one entry named `m3u8`), rewatching reuses the existing entry, the Jimaku and TsukiHime modals prefill Title/Season/Episode from the source's own listing, AniList updates use those fields directly, and the mpv title reads `Series S03E04 - Episode Name`.
- Opening the browser shows a tray icon on every platform and — on macOS — puts the app in the Cmd+Tab switcher and Dock, so you can switch between it and mpv; the Dock icon is released again when the window closes during playback. Launching a video starts a regular SubMiner session, and in `subminer anime` standalone mode closing the window during playback no longer quits the app and kills the stream.
- The episode list has a filter box: type an episode number (`12`), a range (`12-18`), or part of an episode name to narrow a long list, with a `6 of 25` counter while it is applied. Escape inside the box clears the filter instead of leaving the detail page.
- Episodes already watched are dimmed and marked in the episode list, with a watched count in the header. The marks come from the stats history playback already writes (an episode is marked once a session passes the completion threshold), so they match the stats window and survive the stream URL changing between playbacks. They refresh when the browser window comes back to the front, and stay empty when immersion tracking is disabled.
- Right-clicking an episode opens a menu for marking it watched or unwatched by hand, plus "Mark this and N below watched/unwatched" for the episode and every episode listed under it. Sources list newest first, so a span covers the back catalogue, which is how a series watched elsewhere gets caught up. A filter never narrows what a span covers, and the status bar reports how many episodes were touched.
- Marking an episode that was never played creates its stats row, carrying the same series, season and episode fields playback would have recorded. Both stats library views join the lifetime tables, so a manual mark does not show up there as watch time nobody spent, and clearing a mark creates nothing.
- Episodes can be queued instead of replacing what is playing. Every episode row has **Play** and **Queue** buttons (clicking the row still plays now), the right-click menu offers the same two, and a queued episode shows its place in line ("next up", "#2 in queue") with a queue count and **Clear queue** in the episode header. The queue spans anime, resolves and appends each episode to mpv's real playlist as soon as it is queued while subtitle tracks cache in the background, so next/previous navigation works immediately and the next episode starts without a resolution pause when the current one ends. Queueing with nothing playing just plays.
- Added an in-player Anime Browser modal on `Ctrl+Alt+A`. The shortcut toggles it without losing its page or scroll position. It stays within the player bounds and shares the active episode, playback queue, source configuration, and watch history with the standalone browser, while each surface keeps independent search and navigation state. The modal validates its embedded page before changing overlay state, so a load setup failure leaves it closed rather than revealing a broken modal.
-5
View File
@@ -1,5 +0,0 @@
type: fixed
area: subtitles
- Typeset ASS karaoke and animated signs no longer flood the primary overlay, subtitle sidebar, immersion history, or sentence mining with repeated glyph fragments or full-line color phases. Matching timed comments and full-line boundary events recover the complete authored line without merging ordinary repeated dialogue or separately positioned signs, and dialogue spoken while a song's animation is on screen is kept intact instead of being replaced by the lyric. Entrance and exit frames that run past the authored line timing still resolve to the clean line during lyric transitions, and dialogue spoken while a song's animation is on screen enters immersion and subtitle history without the fragment lines beside it. Dense visual grids (sign walls, countdown frames, scattered glyph typesetting) stay out of the published text, while multi-row CC-style dialogue blocks and wrapped lyric rows are still published. Decorative letters that lyric effects render in symbol fonts over the syllables are dropped with the animation instead of corrupting the reconstructed line or leaking as stray cues. Karaoke highlight sweeps that repaint one syllable at a time over an already-visible lyric are suppressed instead of surfacing as rolling partial copies or lone flickering syllables beside the line, and drop-shadow glyph copies offset a few pixels from their base no longer double every syllable in the reconstructed lyric. Positioned word gaps are also recovered on lines where a single fragment carries a literal space, and between wide syllable chunks whose word gap is hidden by their own width, so reconstructed translations keep their spacing instead of running words together.
- The secondary subtitle overlay drops layered duplicate lines from animated tracks, so a short stack of repeated words collapses to its distinct lines even when the full karaoke heuristic does not apply.
@@ -1,5 +0,0 @@
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.
@@ -1,5 +0,0 @@
type: fixed
area: subtitles
- Embedded subtitle tracks on network-mounted (SMB/NFS) media are extracted and parsed again, restoring full karaoke reconstruction, sidebar cues, and mining for releases that ship subtitles only inside the container. Extraction reads the whole file once per episode (roughly 10 seconds per GB on gigabit), its timeout now accommodates large Bluray remuxes, and duplicate extraction requests share one ffmpeg process. Only true remote URLs keep the live-text-only path.
- Live subtitle text from per-glyph typeset karaoke no longer shows a wall of scattered letters in the overlays while extraction is still running or when no parsed cues exist (remote URLs, unreadable sources); the glyph wall and its typed-syllable fragments are suppressed while concurrent dialogue lines remain.
@@ -1,5 +0,0 @@
type: fixed
area: dictionary
- Character dictionary generation, merged rebuilds, and imports no longer freeze the app (and trigger the compositor's "application not responding" dialog) on large dictionaries; snapshot reads/writes, archive building, and the character image/name lookup caches now do their heavy work off the UI's critical path.
- Desktop progress notifications now update in place on Linux AppImage installs too: the AppImage's bundled libraries broke the system notify-send helper, which silently forced the flickering close-and-reopen notification fallback.
-5
View File
@@ -1,5 +0,0 @@
type: internal
area: docs
- Excluded the `/main/` and `/v/<version>/` docs trees from search indexing with a self-referential canonical, `noindex,follow`, and a matching `X-Robots-Tag` header, so crawlers spend their budget on the current docs instead of ~30 archived copies of every page.
- Restored `<lastmod>` dates in the docs sitemap, which were silently dropped because production builds render from an untracked release snapshot.
+1 -1
View File
@@ -1,5 +1,5 @@
type: fixed type: fixed
area: overlay area: overlay
- Dedicated overlay modals are prewarmed on macOS and Windows so shortcuts open them promptly on the first press. Windows now refreshes the hidden modal renderer between sessions to keep later modals interactive. On macOS, reused modals and the in-app stats window also open above fullscreen mpv on its current Space instead of appearing on another desktop or forcing a Space change. - Dedicated overlay modals are prewarmed and reused on macOS and Windows so shortcuts open them promptly on the first press. On macOS, these modals and the in-app stats window also open above fullscreen mpv on its current Space instead of appearing on another desktop or forcing a Space change.
- Updated subtitle ASS observation to mpv's current `sub-text/ass` property, removing its deprecation warning. - Updated subtitle ASS observation to mpv's current `sub-text/ass` property, removing its deprecation warning.
@@ -1,4 +0,0 @@
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").
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: overlay
- Fixed the overlay getting stuck on "Overlay loading" forever when startup stalls: mpv IPC connection attempts now time out and retry, switching sockets aborts obsolete attempts, and the plugin replaces its spinner with an actionable error if overlay content is still not ready after 30 seconds.
@@ -1,4 +0,0 @@
type: fixed
area: subtitles
- 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 +0,0 @@
type: fixed
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. Fragmented ASS karaoke keeps spaces authored at event boundaries and recovers Latin word spaces encoded only by positioned fragment gaps, including word gaps measured across wide glyphs that width normalization alone reads as ordinary letter advances. Progressive karaoke highlights, offset shadow copies, overlapping decorative glyphs, and sign textures remain suppressed, including clipped repeated-glyph mask strips without font overrides and texture payloads that switch actor or font and use nearly transparent random text. Canonical lyrics now advance when their generated entrance begins, so word-by-word opening effects appear as one sentence instead of stacked rows during the lead-in. Wrapped lyrics also remain intact when a timed token repeats at another horizontal position. 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 concatenated primary or secondary lines. Live mpv text remains the fallback for unreadable tracks and applies full-line duplicate filtering before display. A failed source refresh also clears ASS-only cleanup so fallback text from other formats stays intact.
@@ -1,4 +0,0 @@
type: fixed
area: stats
- Immersion statistics storage now applies its SQLite busy timeout before WAL setup, avoiding transient database-lock failures when worker connections overlap.
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: overlay
- Fixed system-wide mouse lag on Windows while SubMiner is running: the overlay no longer installs Electron's global mouse hook for click-through forwarding, and the mpv window tracker no longer blocks the app on repeated PowerShell command-line lookups.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: documentation
- Hid the unfinished feature demos page from the documentation sidebar while keeping its direct URL available.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed the overlay never loading (stuck on the "Overlay loading" OSD spinner) when the Yomitan content-script reload raced overlay window creation at startup, most visible when playing from the anime browser on Linux/Wayland: a hidden window stops painting after that reload, so the ready-to-show signal that gates showing the overlay never fired. Content-ready now falls back to did-finish-load after a short grace period.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: macos
- `subminer anime` (and `--settings` / `--sync`) now bring their window to the front on macOS. `show()`/`focus()` only reorder windows inside the app that is already active, so the window opened behind the terminal that launched it; SubMiner now activates itself when opening one. The anime browser also restores its Dock icon before showing rather than after, because the overlay's fullscreen transform leaves the app as an accessory process that macOS refuses to bring forward at all.
-5
View File
@@ -1,5 +0,0 @@
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.
@@ -1,4 +0,0 @@
type: fixed
area: overlay
- Secondary subtitle overlays now show every rendered line instead of clipping text after roughly four lines.
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: launcher
- Fixed missing MKV thumbnails in the Linux rofi picker when system thumbnailer registrations only advertise legacy Matroska MIME aliases.
@@ -1,8 +0,0 @@
type: fixed
area: stats
- Fixed Vocabulary totals and charts counting only the first browsing page instead of all tracked vocabulary, without delaying the rest of the page.
- New-word history now uses permanent daily lexical rollups that apply the same vocabulary filters as the totals and normalize legacy second/millisecond timestamps; versioned background rebuilds repair existing history across legacy rollup-state schemas without dropping playback writes or clearing watch-time, activity, efficiency, and library charts.
- Calendar-day chart labels now preserve the recorded local date in time zones west of UTC.
- Vocabulary summary cards and charts refresh automatically after the word exclusion list changes, and failed or unfinished loads use bounded retries before showing an inline error with a Retry control.
- Rapid exclusion edits no longer race each other; writes are sent in order so a slower earlier save cannot overwrite a newer list.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: subsync
- Subsync no longer fails with `Protocol "file:" not supported` on a subtitle that was dropped onto mpv. mpv reports such a track as a percent-encoded `file://` URL, which subsync read as a stream and tried to fetch over HTTP; the URL is now decoded back to its path, so both the retimed target and an alass reference work. A `file://` video path is treated as local too, which restores the video reference and ffsubsync for a dropped file.
+7
View File
@@ -0,0 +1,7 @@
type: fixed
area: subsync
- Subsync now works when mpv loaded a subtitle track from a URL, which is how Aniyomi extension streams and Jellyfin add theirs. The track is downloaded to a temporary file first — reusing mpv's own request headers, so authenticated and referer-gated hosts stay reachable — instead of being rejected with "Subtitle file not found: https://…". This applies to both the sync target and the alass reference, so a Jimaku or TsukiHime download can be retimed against a stream's own subtitles. Internal tracks of a stream also pass mpv's headers through to `ffmpeg`.
- Alass now works on tracks that arrive as WebVTT, which is what Aniyomi extension streams serve. Alass picks its parser from the file extension and has no WebVTT support, so it treated a `.vtt` reference as a video file and failed with "no audio stream in file". Both the target and the reference are rewritten as SRT for alass, keeping the cue text as-is, and the originals are left untouched.
- Leaving `subsync.alass_path`, `ffsubsync_path`, or `ffmpeg_path` empty now actually auto-discovers the binary, as the config help has always claimed. Previously it fell back to a hard-coded `/usr/bin/<tool>`, which does not exist on macOS and broke subsync for every default-config install there. Discovery searches `PATH` plus the usual install prefixes (a GUI launch inherits a minimal `PATH`) and accepts `alass-cli` as well as `alass`. An explicitly configured path is still used verbatim and never silently substituted.
- Subsync failures are written to the application log. Previously the only trace was an OSD toast that vanished after a few seconds, leaving nothing to diagnose from.
+18
View File
@@ -341,6 +341,12 @@
"__playlist-browser-open" "__playlist-browser-open"
] // Command setting. ] // Command setting.
}, },
{
"key": "Ctrl+Alt+KeyA", // Key setting.
"command": [
"__anime-browser-open"
] // Command setting.
},
{ {
"key": "Ctrl+Shift+KeyH", // Key setting. "key": "Ctrl+Shift+KeyH", // Key setting.
"command": [ "command": [
@@ -611,6 +617,18 @@
} // Lapis kiku setting. } // Lapis kiku setting.
}, // Automatic Anki updates and media generation options. }, // Automatic Anki updates and media generation options.
// ==========================================
// Anime Browser
// Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
// add a repository index URL here (or drop .apk files in the extensions directory) to have any.
// Hot-reload: anime changes apply the next time the anime browser opens.
// ==========================================
"anime": {
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
"repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
"preferredQuality": "" // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
}, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
// ========================================== // ==========================================
// Jimaku // Jimaku
// Jimaku API configuration and defaults. // Jimaku API configuration and defaults.
+15 -49
View File
@@ -1,4 +1,3 @@
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, statSync } from 'node:fs'; import { existsSync, readFileSync, statSync } from 'node:fs';
import { extname, join, posix, resolve, sep } from 'node:path'; import { extname, join, posix, resolve, sep } from 'node:path';
import type { DefaultTheme, HeadConfig, TransformContext, UserConfig } from 'vitepress'; import type { DefaultTheme, HeadConfig, TransformContext, UserConfig } from 'vitepress';
@@ -27,9 +26,6 @@ function optionalEnv(value: string | undefined): string | undefined {
const base = normalizeBase(optionalEnv(process.env.SUBMINER_DOCS_BASE) ?? '/'); const base = normalizeBase(optionalEnv(process.env.SUBMINER_DOCS_BASE) ?? '/');
const outDir = optionalEnv(process.env.SUBMINER_DOCS_OUT_DIR); const outDir = optionalEnv(process.env.SUBMINER_DOCS_OUT_DIR);
const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? process.cwd(); const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? process.cwd();
// The tracked `docs-site/` checkout, which stays a git working tree even when
// `docsSourceDir` points at an untracked release snapshot. Used for git lookups only.
const repoDocsDir = optionalEnv(process.env.SUBMINER_DOCS_REPO_DIR) ?? process.cwd();
const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL)); const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION); const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.18.0'; const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.18.0';
@@ -86,18 +82,15 @@ function pageToRoute(page: string): string | null {
return route ? `/${route}` : '/'; return route ? `/${route}` : '/';
} }
// Only the root channel is indexable. `main` and every /v/<version>/ archive are
// near-verbatim copies of it, so they own their URL via a self-referential canonical
// and are excluded from the index instead of being consolidated onto root. Uniform
// self-canonical plus noindex avoids mixing noindex with a cross-page canonical,
// which Google treats as a conflicting signal.
const isIndexableChannel = channel === 'stable-root';
function pageToCanonicalHref(page: string): string | null { function pageToCanonicalHref(page: string): string | null {
const route = pageToRoute(page); const route = pageToRoute(page);
if (!route) return null; if (!route) return null;
if (!isIndexableChannel) { if (channel === 'main') {
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
}
if (channel === 'stable-archive' && docsVersion !== latestStable) {
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`; return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
} }
@@ -113,9 +106,7 @@ function transformPageHead({ page }: TransformContext): HeadConfig[] {
const href = pageToCanonicalHref(page); const href = pageToCanonicalHref(page);
const head: HeadConfig[] = href ? [['link', { rel: 'canonical', href }]] : []; const head: HeadConfig[] = href ? [['link', { rel: 'canonical', href }]] : [];
// Crawlable so links still pass through, but out of the index: ~30 archived copies if (channel === 'main') {
// of every page otherwise soak up the crawl budget the current docs need.
if (!isIndexableChannel) {
head.push(['meta', { name: 'robots', content: 'noindex,follow' }]); head.push(['meta', { name: 'robots', content: 'noindex,follow' }]);
} }
@@ -296,39 +287,6 @@ const versionItems = [
})), })),
]; ];
function sitemapUrlToPage(url: string): string {
const route = url.replace(/\.html$/, '').replace(/^\/+|\/+$/g, '');
return route ? `${route}.md` : 'index.md';
}
// VitePress derives <lastmod> by running `git log` inside its source dir. Production
// builds point that at an untracked snapshot of the release tag, so the lookup comes
// back empty and the sitemap ships with no dates at all. Resolve it from the tracked
// checkout at the ref being built instead.
function lastModifiedFor(url: string): string | undefined {
const ref = docsVersion && docsVersion !== 'main' ? docsVersion : 'HEAD';
const result = spawnSync('git', ['log', '-1', '--format=%cI', ref, '--', sitemapUrlToPage(url)], {
cwd: repoDocsDir,
encoding: 'utf8',
});
return (result.status === 0 && result.stdout.trim()) || undefined;
}
// Only the root channel publishes a sitemap. Archived and `main` builds would emit
// their own copies listing the same canonical URLs, which just advertises the
// duplicate trees we are trying to keep out of the index.
const sitemap: UserConfig['sitemap'] = isIndexableChannel
? {
hostname: DOCS_HOSTNAME,
transformItems(items) {
return items
.filter((item) => item.url !== 'README' && item.url !== `${DOCS_HOSTNAME}/README`)
.map((item) => ({ ...item, lastmod: item.lastmod ?? lastModifiedFor(item.url) }));
},
}
: undefined;
const nav: DefaultTheme.NavItem[] = [ const nav: DefaultTheme.NavItem[] = [
{ text: 'Home', link: '/' }, { text: 'Home', link: '/' },
{ text: 'Get Started', link: '/installation' }, { text: 'Get Started', link: '/installation' },
@@ -368,6 +326,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
{ text: 'Anki', link: '/anki-integration' }, { text: 'Anki', link: '/anki-integration' },
{ text: 'Jellyfin', link: '/jellyfin-integration' }, { text: 'Jellyfin', link: '/jellyfin-integration' },
{ text: 'YouTube', link: '/youtube-integration' }, { text: 'YouTube', link: '/youtube-integration' },
{ text: 'Anime Browser', link: '/anime-browser' },
{ text: 'Jimaku', link: '/jimaku-integration' }, { text: 'Jimaku', link: '/jimaku-integration' },
{ text: 'TsukiHime', link: '/tsukihime-integration' }, { text: 'TsukiHime', link: '/tsukihime-integration' },
{ text: 'AniList', link: '/anilist-integration' }, { text: 'AniList', link: '/anilist-integration' },
@@ -461,7 +420,14 @@ const config: UserConfig = {
appearance: 'dark', appearance: 'dark',
cleanUrls: true, cleanUrls: true,
metaChunk: true, metaChunk: true,
sitemap, sitemap: {
hostname: DOCS_HOSTNAME,
transformItems(items) {
return items.filter(
(item) => item.url !== 'README' && item.url !== `${DOCS_HOSTNAME}/README`,
);
},
},
transformHead: transformPageHead, transformHead: transformPageHead,
lastUpdated: true, lastUpdated: true,
srcExclude: ['subagents/**', 'README.md'], srcExclude: ['subagents/**', 'README.md'],
+1 -3
View File
@@ -38,10 +38,8 @@ bun run docs:dev
The public docs root is stable-only: The public docs root is stable-only:
- `/` serves the latest stable release docs. - `/` serves the latest stable release docs.
- `/main/` serves development docs from `main`. - `/main/` serves development docs from `main` and is marked `noindex,follow`.
- `/v/<version>/` serves stable release archives. - `/v/<version>/` serves stable release archives.
- Prerelease tags do not update the docs site. - Prerelease tags do not update the docs site.
Only `/` is indexable. `/main/` and every `/v/<version>/` page carries a self-referential canonical plus `noindex,follow`, and the generated `_headers` file repeats that as an `X-Robots-Tag`. They stay crawlable so their links still resolve, but ~30 archived copies of every page would otherwise consume the crawl budget the current docs need. Only the root build emits `sitemap.xml`, and its `<lastmod>` dates come from `git log` against the tracked checkout at the released tag, because the build renders from an untracked snapshot that VitePress cannot date itself.
Keep Cloudflare Git auto-deploy disabled. The production deploy is `.github/workflows/docs-pages.yml`, which uploads `.tmp/docs-versioned-site` with `--branch main` so tag-triggered runs update Production instead of creating preview deployments. Keep Cloudflare Git auto-deploy disabled. The production deploy is `.github/workflows/docs-pages.yml`, which uploads `.tmp/docs-versioned-site` with `--branch main` so tag-triggered runs update Production instead of creating preview deployments.
+333
View File
@@ -0,0 +1,333 @@
# Anime Browser
Search anime sources, pick an episode, and play it in mpv with SubMiner's overlay
and mining tools attached — the same way a local file or a Jellyfin stream works.
Open it with `subminer anime`, with `SubMiner.AppImage --anime`, or from
**Browse Anime** in the tray menu. The window stays open while you watch, so you
can queue the next episode without reopening it.
During playback, `Ctrl+Alt+A` toggles the same browser as a modal inside the mpv
player bounds. It uses a dedicated modal surface, so it stays above fullscreen
playback and closes like the other in-player tools. Toggling it off keeps its
current page and scroll position ready for the next toggle. The standalone
window and the modal keep their own search, selected source, tab, and scroll
state, so using one does not replace or cancel what you were doing in the other.
Both surfaces use the same playback queue, source configuration, and stats
history. Queue changes and the currently playing episode appear in both
immediately, and watched marks come from the same history that playback and the
stats window update. Closing and reopening the modal therefore picks up progress
made from either browser surface.
While the window is open, SubMiner shows a tray icon and — on macOS — appears
in the Cmd+Tab switcher and the Dock (macOS ties the two together), so you can
flip between the browser and mpv. SubMiner normally hides itself from the Dock
because the subtitle overlay needs that to float above fullscreen video; it
hides again when the window closes during playback.
Launching an episode starts a full SubMiner playback session, the same as
playing a local file: the overlay and mining tools attach, and the tray icon
stays available. In standalone `subminer anime` mode, closing the window while
a video is playing leaves playback running — reopen the browser from the tray
(**Browse Anime**). The app only exits with the window when nothing is playing.
## How it works
SubMiner does not implement any anime source itself. It runs **Aniyomi extension
APKs** through a bundled JVM sidecar ([M-Extension-Server][mes]), asks the
selected extension to resolve an episode, and hands the resulting URL to mpv.
```
extension APK → bridge (JVM) → { url, headers } → mpv → SubMiner overlay
```
Because the extension resolves the stream, whichever sources you install decide
what is available. SubMiner only hosts them.
## Installing extensions
**SubMiner ships no extension repositories and bundles no sources.** There is no
default repository, no suggested list, and no discovery. Until you add one, the
browser has nothing to search — that is deliberate, and it is what keeps SubMiner
a neutral host rather than a distributor.
There are two ways to add extensions.
### From a repository
The window has three tabs — **Browse**, **Extensions**, and **Source settings**
and each one fills the window, so a long extension list is not squeezed in above
the search results.
Open the **Extensions** tab, paste a repository index URL, and choose
**Add repository**. The URL must be `https` and point at a `.json` index file —
`index.min.json` is the common Aniyomi name, but repositories are free to publish
under another one (for example `video.min.json`). Anything else is rejected
immediately rather than failing later. Everything before the file name is treated
as the repository root, so `.apk` and icon URLs are resolved relative to it.
Extensions your repositories offer but you do not have appear under
**Available**, each with **Install**. Repositories are stored in config under
`anime.repos`, so you can also manage them there and keep them in a dotfile.
Every row carries the extension's icon, as the repository publishes it, so a
site is recognisable before you read the name. A repository row shows its host's
favicon instead. Icons are the only part of a row that is fetched from the
network, and a row whose icon is missing falls back to the first letter of its
name rather than an empty box.
A repository index lists every language it knows about, which is far more than
any one person reads, so the **Available** list has a language chip row above
it. Pick one or more languages to narrow it, or **All** to clear the filter;
picking a language replaces **All** rather than sitting beside it. Rows name the
language in full ("Japanese" rather than `ja`), extensions whose sources span
languages are grouped under **Multi-language**, and the Available heading counts
how many of the offered extensions the filter leaves.
### Managing what is installed
The Extensions tab opens with an **Installed** section listing everything in the
extensions directory, with the sources each one provides and a **Remove**
button. It is built from the directory rather than from a repository, so an
extension you dropped in by hand — or one whose repository you have since
removed — is still listed and still removable. An installed extension borrows
its icon from the catalogue, so one no repository carries shows its monogram.
**Update** appears next to an extension a configured repository still carries;
it downloads the current version over the existing APK.
### From a file
Drop Aniyomi `.apk` files into the extensions directory, shown at the top of the
Extensions tab. It defaults to `<userData>/anime-extensions` — on macOS,
`~/Library/Application Support/SubMiner/anime-extensions` — and can be moved with
`anime.extensionsDir`.
A single APK may provide several sources; each appears separately in the
**Source** picker. Extensions that fail to load are listed in the Installed
section with the reason, so a broken APK is visible rather than silently
missing.
An extension that fails to load is skipped rather than blocking the others, so
one bad APK will not hide the rest.
## Searching every source at once
With more than one source installed, the **Source** picker gains an
**All sources** entry. Searching with it selected runs the query against every
installed source at once, and each source's results appear the moment that
source answers — a fast source is on screen while a slow one is still
resolving. The status bar counts sources as they finish
(`Searching… 3/5 sources · 42 results`).
Each cover is labelled with the source it came from, and opening one always
queries that source, whatever the picker says afterwards.
A source that errors is named in the status bar and the rest still show their
results; one extension that needs a login cannot blank the grid. If every
source fails, the first error is shown in full.
Typing a new search while one is still running simply starts over: results
from the superseded search are discarded, even if its sources answer late.
When a source reports another page, **Load more** appears below the covers.
It appends the next page without duplicating entries that already arrived in
the live result stream. A failed next-page request remains available to retry.
Source settings belong to a single extension, so the **Source settings** tab
asks you to pick one while **All sources** is selected.
## Finding an episode, and what you have watched
An episode list can run to hundreds of entries, so the episode header carries a
filter box:
- A number, `12`, keeps that episode. Sources that report no numbers at all
are still searched by name, so `12` also matches `Episode 12` in a title.
- A range, `12-18`, keeps the episodes between the two, in either order
(`18-12` reads the same). Episodes the source gave no number are left out of
a range.
- Anything else is a case-insensitive substring of the episode name, so `beach`
finds `OVA: Beach Special`.
The counter next to **Episodes** reads `6 of 25` while a filter is applied.
Pressing Escape in the filter box clears it; pressing it anywhere else goes back
to the results grid.
Episodes you have already watched are dimmed and marked `✓ watched`, with a
count in the header. This is not a separate list the browser keeps: it reads the
same stats history the rest of SubMiner writes to, where an episode is marked
watched once a session runs past the completion threshold. Streams are recorded
under a stable per-episode identity, so the mark survives the stream URL
changing between playbacks, and it is the same mark the stats window and
`--mark-watched` use.
Because playback marks an episode partway through the session, the marks
refresh when the browser window comes back to the front: finish an episode in
mpv, switch back, and it is marked. With
[immersion tracking](configuration.md) disabled there is no history to read, so
no episode is marked.
### Marking by hand, and catching up
Right-click an episode for:
- **Mark watched** / **Mark unwatched**: the single episode, whichever way it
is not already.
- **Mark this and N below watched** / **... unwatched**: that episode and every
episode listed below it. Sources list newest first, so "below" is the back
catalogue: right-click the last episode you saw and mark everything down to
the start, which is how you catch up a series you watched somewhere else.
A filter narrows what you are looking at, not what you mark: a span always
covers the full episode list, and the status bar says how many episodes it
touched. The oldest episode has nothing below it, so it only offers the single
entry. Escape closes the menu.
Marking an episode you have never played creates its stats row so the mark has
somewhere to live. That row carries the same series, season and episode fields
playback would have recorded, and both stats library views join the lifetime
tables, so a manually marked episode does not appear there as watch time you
never spent. Clearing a mark never creates anything.
Marks are written to the stats history, so with immersion tracking disabled
there is nowhere to write them and the status bar says so.
## Settings
| Key | Purpose |
| ------------------------ | -------------------------------------------------------------------- |
| `anime.repos` | Repository index URLs. Empty by default. |
| `anime.extensionsDir` | Where APKs are read from. Empty uses `<userData>/anime-extensions`. |
| `anime.preferredQuality` | Preferred stream label, matched as a substring (for example `1080`). |
## Source settings
Most extensions need configuration before they return anything — a server
address and credentials, a preferred quality, a language filter. Open the
**Source settings** tab to edit them. Changes save as you make them and
persist across restarts in `<userData>/anime-source-preferences.json`.
Each save is handed back to the extension, so it can react: the Jellyfin source
logs in when the address and password land, then fills in its media-library
picker. Password-like fields are masked. Because that file can hold
credentials, it is written with owner-only permissions. Values are scoped to
the exact extension package and source, so two extensions that reuse the same
internal source ID cannot read each other's settings. Preferences saved by an
older build without package ownership are discarded; re-enter those source
settings once after upgrading.
## The bridge
The first launch downloads a platform bundle (~130 MB) containing the server and
a matching Java runtime, so no system JDK is required. SubMiner pins one
upstream release tag and the SHA-256 of each asset it has verified; the download
is checked against that hash before anything runs, then unpacked into
`<userData>/anime-bridge` and reused after that. Progress appears in the banner
at the top of the window.
The bridge stays running while the window is open. Resolved video URLs point at
its own loopback proxy so the extension's cookies and headers apply, which means
those URLs stop working once it exits — the window keeps it alive for the whole
session.
If the bridge dies anyway (killed by hand, crashed, or stopped mid-operation),
the exit is detected and named in the status bar, and the next request starts a
new one. Playback already in flight still ends when its stream URL dies, but the
browser recovers without an app restart.
Two known limits:
- There is no Android WebView, so extensions that need one (typically for
Cloudflare challenges) will fail with an error from the source.
- Bundles are published for macOS (arm64, x64), Linux (x64), and Windows (x64),
but only the ones we have hashed ourselves will run: currently macOS arm64 and
Linux x64. The rest stop with "No pinned checksum for …" until a maintainer
verifies them. Other platforms are unsupported outright.
## Playback
Selecting an episode resolves the best available stream, applies the source's
required headers as mpv `http-header-fields`, and loads it. The headers are
readable back off mpv, so Anki card audio and screenshots fetch correctly too.
HLS streams are routed through a small local proxy before mpv sees them. Some
hosts disguise their video segments by prepending a fake image header (a real
1x1 PNG) so scrapers back off; Aniyomi's own player strips this, but ffmpeg
probes the segment as a picture and playback dies with "no audio or video data
played". The proxy scans each segment for the first genuine MPEG-TS packet run
and drops whatever junk sits in front of it. Segments that are not TS (fMP4,
subtitles, encryption keys) pass through untouched, and direct-file streams
skip the proxy entirely. Segment URLs disguised behind fake extensions
(`.image`, `.jpg`, `.css`, and friends) are exposed locally with a `.ts`
suffix so current ffmpeg releases accept them when Anki extracts audio,
screenshots, or animated images from the playing stream.
"Playing" in the status bar means playing: after handing mpv the stream,
SubMiner waits until mpv actually configures a video output before reporting
success. If mpv gives up instead — a dead host, an undecodable stream — the
browser shows mpv's error rather than pretending playback started (a failed
load leaves no mpv window, because the player idles windowless).
Choosing **Queue** resolves the episode and appends the playable stream to mpv's
own playlist immediately, while its subtitle tracks cache in the background.
That makes mpv's next command available at once and lets the next episode begin
automatically when the current one ends, without waiting for another source
request. Resolved stream URLs can be short-lived, so a very long queue can still
outlive what its source issued; dequeue and queue that episode again to refresh
it.
### Japanese audio, and switching tracks
Sources often return a dub and the original audio as two separate entries — or
as two audio tracks of one stream — and the dub is frequently listed first.
SubMiner always aims at the Japanese audio:
- Entries labelled as a dub are skipped as long as another entry exists. This
outranks `anime.preferredQuality`: a 1080p dub is the wrong file, not a better
one. If every entry is a dub, it still plays.
- mpv's `alang` is set to `ja,jpn,jp,japanese` before the file loads, so a
stream carrying several audio tracks starts on the Japanese one. With no
Japanese track, mpv falls back to the first one as usual.
- Any audio or subtitle tracks the extension supplies separately are added to
mpv with `audio-add` / `sub-add`, tagged with their language, and the
Japanese one is selected.
The primary subtitle slot is reserved for Japanese — it is what the overlay
mines. A source that only carries, say, English subtitles does not get them
promoted to primary; instead the track is added with a normalized language tag
(`English``en`), and the regular [dual-subtitle settings](configuration.md)
apply: with `secondarySub.autoLoadSecondarySub` enabled and the language listed
in `secondarySub.secondarySubLanguages`, it is picked up as the secondary
subtitle, exactly as it would be for a local file.
Every track is added, including the ones that are not selected, so all of them
appear in mpv's track menu and can be switched by hand while watching
(`#` cycles audio, `j` cycles subtitles by default).
The extension hands over subtitle tracks as URLs, but SubMiner downloads each
one to a temporary directory and gives mpv the local file. mpv is happy either
way; [Subsync](/troubleshooting#subtitle-sync-subsync) is not, because alass
needs a file on disk to use as the timing reference. A track that fails to
download falls back to its URL so the episode still plays, the format is
detected from the file's own content rather than its URL, and the directory is
removed when the next episode starts or the app exits.
### Series, season, and episode
The episode's identity travels with it instead of being guessed back out of the
stream URL, which carries nothing but a proxy path and a file extension. The
title and episode label from the source's own listing are split into series,
season, and episode number once, at launch, and everything downstream reads
those fields:
- mpv's title reads `Series S03E04 - Episode Name`.
- Stats group by series, and rewatching an episode reuses its entry instead of
creating a new one.
- The [Jimaku](/jimaku-integration) and [TsukiHime](/tsukihime-integration)
modals open with Title, Season, and Episode already filled in, so a subtitle
search is one keypress rather than a retype.
- [AniList](/anilist-integration) progress updates use those fields directly.
[mes]: https://github.com/1Selxo/M-Extension-Server
+2 -2
View File
@@ -74,8 +74,8 @@ src/
shared/ipc/ # Cross-process IPC channel constants + payload validators shared/ipc/ # Cross-process IPC channel constants + payload validators
renderer/ # Overlay renderer (modularized UI/runtime) renderer/ # Overlay renderer (modularized UI/runtime)
handlers/ # Keyboard/mouse/gamepad interaction modules handlers/ # Keyboard/mouse/gamepad interaction modules
modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help, modals/ # Modal flows (Anime Browser, Jimaku, Kiku, subsync, runtime options,
# changelog, character dictionary, playlist browser, subtitle # session help, changelog, character dictionary, playlist browser, subtitle
# sidebar, YouTube track picker, controller config/debug/select) # sidebar, YouTube track picker, controller config/debug/select)
positioning/ # Subtitle position controller (drag-to-reposition) positioning/ # Subtitle position controller (drag-to-reposition)
settings/ # Settings window UI (model, controls, markup) settings/ # Settings window UI (model, controls, markup)
+34 -4
View File
@@ -154,6 +154,7 @@ The configuration file includes several main sections:
**External Integrations** **External Integrations**
- [**Anime Browser**](#anime-browser) - Extension repositories and stream preferences for the anime browser
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults - [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
- [**TsukiHime**](#tsukihime) - Multi-language subtitle search and download - [**TsukiHime**](#tsukihime) - Multi-language subtitle search and download
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync` - [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
@@ -592,6 +593,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track | | `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track | | `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser | | `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
| `Ctrl+Alt+KeyA` | `["__anime-browser-open"]` | Toggle Anime Browser in the player |
| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker | | `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds | | `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds | | `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
@@ -633,7 +635,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
{ "key": "Space", "command": null } { "key": "Space", "command": null }
``` ```
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value. **Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__playlist-browser-open` opens the split-pane playlist browser for the current file's parent directory and the live mpv queue. `__anime-browser-open` opens the Anime Browser inside the player bounds. `__replay-subtitle` replays the current subtitle and pauses at its end. `__play-next-subtitle` seeks to the next subtitle, plays it, and pauses at its end. `__runtime-options-open` opens the runtime options palette. `__runtime-option-cycle:<id>[:next|prev]` cycles a runtime option value.
**Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.) **Supported commands:** Any valid mpv JSON IPC command array (`["cycle", "pause"]`, `["seek", 5]`, `["script-binding", "..."]`, etc.)
@@ -1154,6 +1156,30 @@ When the manual merge popup opens, SubMiner pauses playback and closes any open
## External Integrations ## External Integrations
### Anime Browser
Sources for the [anime browser](/anime-browser). SubMiner ships no extension repositories and bundles no sources, so these are empty until you add one:
```json
{
"anime": {
"extensionsDir": "",
"repos": [],
"preferredQuality": ""
}
}
```
| Option | Type | Default | Description |
| ------------------------ | ---------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `anime.extensionsDir` | `string` | `""` | Directory holding Aniyomi extension `.apk` files. Empty uses `<userData>/anime-extensions`. |
| `anime.repos` | `string[]` | `[]` | Extension repository index URLs. Any `https` URL ending in `.json` works; `index.min.json` is only the common name. |
| `anime.preferredQuality` | `string` | `""` | Preferred stream quality label, matched as a substring (for example `1080`). Empty keeps the source's own order. A Japanese-audio entry always outranks a higher-quality dub. |
Repositories added from the browser's Extensions tab are written back to `anime.repos`, so the list can also be kept in a dotfile. Changes apply the next time the anime browser opens.
Per-source settings (server addresses, credentials, per-extension quality or language options) are not part of `config.jsonc`. They belong to the extension, are edited in the browser's **Source settings** tab, and persist in `<userData>/anime-source-preferences.json` with owner-only permissions.
### Jimaku ### Jimaku
Configure Jimaku API access and defaults: Configure Jimaku API access and defaults:
@@ -1214,11 +1240,15 @@ Sync a subtitle track from the overlay picker using `alass` or `ffsubsync`. The
| Option | Values | Description | | Option | Values | Description |
| ---------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------- | | ---------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `alass_path` | string path | Path to `alass` executable. Empty falls back to `/usr/bin/alass`. `alass` must be installed separately. | | `alass_path` | string path | Path to `alass` executable. Empty auto-discovers `alass` or `alass-cli`. `alass` must be installed separately. |
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty falls back to `/usr/bin/ffsubsync`. `ffsubsync` must be installed separately. | | `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty auto-discovers `ffsubsync`. `ffsubsync` must be installed separately. |
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. | | `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` auto-discovers `ffmpeg`. |
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. | | `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
Auto-discovery searches `PATH`, then the usual install prefixes (`/opt/homebrew/bin`, `/usr/local/bin`, `/opt/local/bin`, `/usr/bin`, `/bin`) — a GUI launch inherits a minimal `PATH` that often omits the first two. Set the option explicitly if your binary lives elsewhere.
Subtitle tracks that mpv loaded from a URL (Aniyomi extension streams, Jellyfin) are downloaded to a temporary file first, reusing mpv's own request headers, so they can be used as either the sync target or the alass reference.
Stats dashboard sentence mining also uses `alass_path` when available to align a local English sidecar against the local Japanese sidecar before filling the card translation field. This stats-only retime writes a temporary cached copy and never edits the original subtitle files. Stats dashboard sentence mining also uses `alass_path` when available to align a local English sidecar against the local Japanese sidecar before filling the card translation field. This stats-only retime writes a temporary cached copy and never edits the original subtitle files.
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`. Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
+1 -1
View File
@@ -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 = '20260819-1'; const v = '20260301-1';
</script> </script>
## Anki Card Mining & Enrichment ## Anki Card Mining & Enrichment
+1 -1
View File
@@ -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/prepare-build-assets.mjs` (skip with `SUBMINER_SKIP_MACOS_HELPER_BUILD=1`) - macOS builds compile a Swift helper via `scripts/build-macos-helper.sh` (skip with `SUBMINER_SKIP_MACOS_HELPER_BUILD=1`)
## Setup ## Setup
+1 -5
View File
@@ -82,7 +82,7 @@ Expandable session history with new-word activity, cumulative totals, and pause/
#### Vocabulary #### Vocabulary
The summary cards show all unique vocabulary and kanji recorded in the local tracking database; **New This Week** is the only weekly figure and uses a rolling seven-day window. The word and kanji tables load first while those complete totals calculate separately. Top Repeated Words and New Words by Day use complete tracking history rather than the table's browsing page. New-word history is maintained as a permanent daily lexical rollup using the same token-visibility rules as the totals, including normalization of older timestamps stored in either seconds or milliseconds and retroactive corrections when tracked material is removed or reprocessed. On the first launch after an applicable upgrade, that history is version-rebuilt in the background and the chart refreshes when it is ready; if it remains unavailable, polling stops and an inline Retry control appears. The cards and charts also refresh automatically after the word exclusion list changes. The rest of the tab includes cross-title and frequency rank tables with Hide Known / Hide Kana filters, kanji breakdown, word exclusion list, and click-through occurrence drilldown with Mine Word / Mine Sentence / Mine Audio buttons. Top repeated words (click a bar to open the word), new-word timeline, cross-title and frequency rank tables with Hide Known / Hide Kana filters, kanji breakdown, word exclusion list, and click-through occurrence drilldown with Mine Word / Mine Sentence / Mine Audio buttons.
![Stats Vocabulary](/screenshots/stats-vocabulary.png) ![Stats Vocabulary](/screenshots/stats-vocabulary.png)
@@ -138,8 +138,6 @@ Karaoke openings and animated signs are authored as one subtitle event per anima
Recording now collapses those runs as they happen, matching what the subtitle sidebar shows: Recording now collapses those runs as they happen, matching what the subtitle sidebar shows:
- When a typeset ASS file stores a clean lyric or sign in a timed authoring comment, or in full-line events surrounding generated fragments, the matching complete line is recorded once. The repeated glyph or clip-animation frames are not recorded. Dialogue spoken while such an animation is on screen records as itself, without the fragment lines beside it.
- When karaoke styling redraws the same complete lyric across consecutive color or highlight phases, those phases are combined into one line with their full timing. Repeated ordinary dialogue remains separate.
- When the active subtitle source has been parsed, its cue list has already had duplicate events and animation bursts merged. A line landing inside a surviving cue but after that cue's start is a frame the sidebar merged away, and is not recorded. - When the active subtitle source has been parsed, its cue list has already had duplicate events and animation bursts merged. A line landing inside a surviving cue but after that cue's start is a frame the sidebar merged away, and is not recorded.
- When no parsed cue covers the live timing, including while a subtitle source is changing or shifted, the strict metadata-free rule applies: a run of identical, contiguous lines each shorter than 0.1s stops being recorded after a few frames. Runs are tracked per line of text, so dual-line karaoke (a kanji and a romaji line frame-flipped together) collapses both lines. Ordinary repeated dialogue, and lines held for a normal beat, always record. - When no parsed cue covers the live timing, including while a subtitle source is changing or shifted, the strict metadata-free rule applies: a run of identical, contiguous lines each shorter than 0.1s stops being recorded after a few frames. Runs are tracked per line of text, so dual-line karaoke (a kanji and a romaji line frame-flipped together) collapses both lines. Ordinary repeated dialogue, and lines held for a normal beat, always record.
@@ -182,7 +180,6 @@ In practice:
- Anime and episode pages keep lifetime totals from summary tables while session drill-down still reads retained sessions directly. With the current defaults, both are kept forever. - Anime and episode pages keep lifetime totals from summary tables while session drill-down still reads retained sessions directly. With the current defaults, both are kept forever.
- Trends can read the full available history because daily/monthly rollups are also kept forever by default. - Trends can read the full available history because daily/monthly rollups are also kept forever by default.
- Vocabulary and kanji totals are cumulative and not bounded by the raw session retention knobs. - Vocabulary and kanji totals are cumulative and not bounded by the raw session retention knobs.
- New-word charts use their own permanent lexical daily rollups, which are not pruned by activity-rollup retention.
## Storage / Performance Model ## Storage / Performance Model
@@ -352,7 +349,6 @@ Rollup tables:
- `imm_daily_rollups` - `imm_daily_rollups`
- `imm_monthly_rollups` - `imm_monthly_rollups`
- `imm_lexical_daily_rollups` - permanent first-discovery counts for vocabulary and kanji chart history
- `imm_rollup_state` - incremental rollup progress bookkeeping - `imm_rollup_state` - incremental rollup progress bookkeeping
Vocabulary tables: Vocabulary tables:
+1 -1
View File
@@ -88,7 +88,7 @@ features:
<script setup> <script setup>
import { withBase } from 'vitepress'; import { withBase } from 'vitepress';
const demoAssetVersion = '20260819-1'; const demoAssetVersion = '20260223-2';
</script> </script>
<div class="landing-shell"> <div class="landing-shell">
+4 -6
View File
@@ -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`, 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. 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.
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 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. 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.
## Platform Notes ## Platform Notes
@@ -456,20 +456,18 @@ sudo chmod +x /usr/local/bin/subminer
### Linux Support Assets ### Linux Support Assets
SubMiner ships the Linux rofi theme, scoped Matroska thumbnailer registration, and launcher-managed runtime plugin copy in `subminer-assets.tar.gz`: SubMiner ships the Linux rofi theme plus the 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 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. `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.
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`.
+21 -17
View File
@@ -23,12 +23,12 @@ If no files match the current episode filter, a "Show all files" button lets you
### Modal Keyboard Shortcuts ### Modal Keyboard Shortcuts
| Key | Action | | Key | Action |
| --- | --- | | ------------------------- | ---------------------------- |
| `Enter` (in text field) | Search | | `Enter` (in text field) | Search |
| `Enter` (in list) | Select entry / download file | | `Enter` (in list) | Select entry / download file |
| `Arrow Up` / `Arrow Down` | Navigate entries or files | | `Arrow Up` / `Arrow Down` | Navigate entries or files |
| `Escape` | Close modal | | `Escape` | Close modal |
## Configuration ## Configuration
@@ -41,26 +41,26 @@ Add a `jimaku` section to your `config.jsonc`:
"apiKeyCommand": "cat ~/.jimaku_key", "apiKeyCommand": "cat ~/.jimaku_key",
"apiBaseUrl": "https://jimaku.cc", "apiBaseUrl": "https://jimaku.cc",
"languagePreference": "ja", "languagePreference": "ja",
"maxEntryResults": 10 "maxEntryResults": 10,
} },
} }
``` ```
| Option | Type | Default | Description | | Option | Type | Default | Description |
| --- | --- | --- | --- | | --------------------------- | ---------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `jimaku.apiKey` | `string` | - | Jimaku API key (plaintext). Mutually exclusive with `apiKeyCommand`. | | `jimaku.apiKey` | `string` | - | Jimaku API key (plaintext). Mutually exclusive with `apiKeyCommand`. |
| `jimaku.apiKeyCommand` | `string` | - | Shell command that prints the API key to stdout. Useful for secret managers (e.g., `pass jimaku/api-key`). | | `jimaku.apiKeyCommand` | `string` | - | Shell command that prints the API key to stdout. Useful for secret managers (e.g., `pass jimaku/api-key`). |
| `jimaku.apiBaseUrl` | `string` | `"https://jimaku.cc"` | Base URL for the Jimaku API. Only change this if using a mirror or local instance. | | `jimaku.apiBaseUrl` | `string` | `"https://jimaku.cc"` | Base URL for the Jimaku API. Only change this if using a mirror or local instance. |
| `jimaku.languagePreference` | `"ja"` \| `"en"` \| `"none"` | `"ja"` | Sort subtitle files by language tag. `"ja"` pushes Japanese-tagged files to the top; `"en"` does the same for English. `"none"` preserves the API order. | | `jimaku.languagePreference` | `"ja"` \| `"en"` \| `"none"` | `"ja"` | Sort subtitle files by language tag. `"ja"` pushes Japanese-tagged files to the top; `"en"` does the same for English. `"none"` preserves the API order. |
| `jimaku.maxEntryResults` | `number` | `10` | Maximum number of anime entries returned per search. | | `jimaku.maxEntryResults` | `number` | `10` | Maximum number of anime entries returned per search. |
The keyboard shortcut is configured separately under `shortcuts`: The keyboard shortcut is configured separately under `shortcuts`:
```jsonc ```jsonc
{ {
"shortcuts": { "shortcuts": {
"openJimaku": "Ctrl+Shift+J" "openJimaku": "Ctrl+Shift+J",
} },
} }
``` ```
@@ -79,6 +79,8 @@ SubMiner extracts media info from the current video path to pre-fill the search
- **Season + episode patterns:** `S01E03`, `1x03` - **Season + episode patterns:** `S01E03`, `1x03`
- **Episode-only patterns:** `E03`, `EP03`, or dash-separated numbers like `Title - 03 -` - **Episode-only patterns:** `E03`, `EP03`, or dash-separated numbers like `Title - 03 -`
- **Spelled-out episode labels:** `Episode 4`, `第4話`
- **Season named at the end of the title:** `… Season 3`, `… 3rd Season`, `… S3`, `… 第3期` - the season goes in the Season field instead of being searched for as part of the series name
- **Season folders:** a parent directory named `Season 2` or `S2` fills in the season when the filename lacks one - **Season folders:** a parent directory named `Season 2` or `S2` fills in the season when the filename lacks one
- **Bracket tags:** `[SubGroup]`, `[1080p]`, `[HEVC]` - stripped before title extraction - **Bracket tags:** `[SubGroup]`, `[1080p]`, `[HEVC]` - stripped before title extraction
- **Year tags:** `(2024)` - stripped - **Year tags:** `(2024)` - stripped
@@ -87,6 +89,8 @@ SubMiner extracts media info from the current video path to pre-fill the search
If the parser produces a high-confidence result (title + episode both detected), the search runs automatically when the modal opens. Otherwise, you can adjust the fields manually before searching. If the parser produces a high-confidence result (title + episode both detected), the search runs automatically when the modal opens. Otherwise, you can adjust the fields manually before searching.
Episodes launched from the [anime browser](/anime-browser) skip parsing entirely: Title, Season, and Episode come from the source's own listing, which is why the search runs immediately even though the stream URL says nothing about the episode.
## Troubleshooting ## Troubleshooting
**"Jimaku API key not set"** **"Jimaku API key not set"**
+2 -6
View File
@@ -34,22 +34,18 @@ 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, a scoped `ffmpegthumbnailer` MIME registration, and 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 plus 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 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. 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.
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):
-4
View File
@@ -108,12 +108,8 @@ 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:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

After

Width:  |  Height:  |  Size: 3.0 MiB

+18
View File
@@ -341,6 +341,12 @@
"__playlist-browser-open" "__playlist-browser-open"
] // Command setting. ] // Command setting.
}, },
{
"key": "Ctrl+Alt+KeyA", // Key setting.
"command": [
"__anime-browser-open"
] // Command setting.
},
{ {
"key": "Ctrl+Shift+KeyH", // Key setting. "key": "Ctrl+Shift+KeyH", // Key setting.
"command": [ "command": [
@@ -611,6 +617,18 @@
} // Lapis kiku setting. } // Lapis kiku setting.
}, // Automatic Anki updates and media generation options. }, // Automatic Anki updates and media generation options.
// ==========================================
// Anime Browser
// Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
// add a repository index URL here (or drop .apk files in the extensions directory) to have any.
// Hot-reload: anime changes apply the next time the anime browser opens.
// ==========================================
"anime": {
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
"repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
"preferredQuality": "" // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
}, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
// ========================================== // ==========================================
// Jimaku // Jimaku
// Jimaku API configuration and defaults. // Jimaku API configuration and defaults.
+21 -49
View File
@@ -56,43 +56,34 @@ test('main docs canonical uses /main/ and emits noindex', async () => {
{ rel: 'canonical', href: 'https://docs.subminer.moe/main/' }, { rel: 'canonical', href: 'https://docs.subminer.moe/main/' },
]); ]);
expect(head).toContainEqual(['meta', { name: 'robots', content: 'noindex,follow' }]); expect(head).toContainEqual(['meta', { name: 'robots', content: 'noindex,follow' }]);
expect(mainDocsConfig.sitemap).toBeUndefined();
process.env.SUBMINER_DOCS_CHANNEL = previousChannel; process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
process.env.SUBMINER_DOCS_BASE = previousBase; process.env.SUBMINER_DOCS_BASE = previousBase;
}); });
test.each([ test('latest stable archive canonical points to root equivalent', async () => {
['latest stable', 'v0.14.0', '/v/0.14.0/', 'https://docs.subminer.moe/v/0.14.0/usage'], const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
['superseded', 'v0.12.0', '/v/0.12.0/', 'https://docs.subminer.moe/v/0.12.0/usage'], const previousBase = process.env.SUBMINER_DOCS_BASE;
])( const previousVersion = process.env.SUBMINER_DOCS_VERSION;
'%s archive keeps a self-referential canonical and stays out of the index', const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
async (_label, version, base, expectedCanonical) => { process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL; process.env.SUBMINER_DOCS_BASE = '/v/0.14.0/';
const previousBase = process.env.SUBMINER_DOCS_BASE; process.env.SUBMINER_DOCS_VERSION = 'v0.14.0';
const previousVersion = process.env.SUBMINER_DOCS_VERSION; process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE; const { default: latestArchiveConfig } = await import('./.vitepress/config?latest-archive');
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
process.env.SUBMINER_DOCS_BASE = base;
process.env.SUBMINER_DOCS_VERSION = version;
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
try {
const { default: archiveConfig } = await import(`./.vitepress/config?archive-${version}`);
const head = await archiveConfig.transformHead?.(makeTransformContext('usage.md')); const head = await latestArchiveConfig.transformHead?.(makeTransformContext('usage.md'));
expect(head).toContainEqual(['link', { rel: 'canonical', href: expectedCanonical }]); expect(head).toContainEqual([
expect(head).toContainEqual(['meta', { name: 'robots', content: 'noindex,follow' }]); 'link',
// A sitemap here would advertise the archive tree we just excluded. { rel: 'canonical', href: 'https://docs.subminer.moe/usage' },
expect(archiveConfig.sitemap).toBeUndefined(); ]);
} finally {
process.env.SUBMINER_DOCS_CHANNEL = previousChannel; process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
process.env.SUBMINER_DOCS_BASE = previousBase; process.env.SUBMINER_DOCS_BASE = previousBase;
process.env.SUBMINER_DOCS_VERSION = previousVersion; process.env.SUBMINER_DOCS_VERSION = previousVersion;
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest; process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
} });
},
);
test('stable archive theme links stay on the selected version', async () => { test('stable archive theme links stay on the selected version', async () => {
const previousCwd = process.cwd(); const previousCwd = process.cwd();
@@ -442,22 +433,3 @@ test('docs sitemap excludes duplicate README page from indexable URLs', async ()
expect(transformedItems?.map((item) => item.url)).toEqual(['', 'usage']); expect(transformedItems?.map((item) => item.url)).toEqual(['', 'usage']);
}); });
test('docs sitemap dates every URL from the tracked checkout', async () => {
const previousRepoDir = process.env.SUBMINER_DOCS_REPO_DIR;
// Production builds render from an untracked snapshot, so the date has to come from
// the real checkout rather than VitePress's own srcDir git lookup.
process.env.SUBMINER_DOCS_REPO_DIR = docsSiteDir;
try {
const { default: sitemapConfig } = await import('./.vitepress/config?sitemap-lastmod');
const items = await sitemapConfig.sitemap?.transformItems?.([{ url: '' }, { url: 'usage' }]);
expect(items).toHaveLength(2);
for (const item of items ?? []) {
expect(item.lastmod).toMatch(/^\d{4}-\d{2}-\d{2}T/);
}
} finally {
process.env.SUBMINER_DOCS_REPO_DIR = previousRepoDir;
}
});
+2 -1
View File
@@ -49,6 +49,7 @@ These control playback and subtitle display. They require overlay window focus.
| `J` | Cycle primary subtitle track | | `J` | Cycle primary subtitle track |
| `Shift+J` | Cycle secondary subtitle track | | `Shift+J` | Cycle secondary subtitle track |
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue | | `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
| `Ctrl+Alt+A` | Toggle Anime Browser inside the player |
| `ArrowRight` | Seek forward 5 seconds | | `ArrowRight` | Seek forward 5 seconds |
| `ArrowLeft` | Seek backward 5 seconds | | `ArrowLeft` | Seek backward 5 seconds |
| `ArrowUp` | Seek forward 60 seconds | | `ArrowUp` | Seek forward 60 seconds |
@@ -67,7 +68,7 @@ These control playback and subtitle display. They require overlay window focus.
| `Right-click` | Toggle pause (outside subtitle area) | | `Right-click` | Toggle pause (outside subtitle area) |
| `Right-click + drag` | Reposition subtitles (on subtitle area) | | `Right-click + drag` | Reposition subtitles (on subtitle area) |
The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V` and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right. The mpv-command rows above (`Space`, `F`, `J`, `Shift+J`, the seek/sub-seek/sub-step/sub-delay keys, replay/play-next, and quit) are merged from the `keybindings` config array and can be remapped or disabled there. `V` and the mouse actions are built-in overlay behaviors and are not part of the `keybindings` array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right. The Anime Browser shortcut toggles a dedicated overlay modal bounded to the player; the standalone Anime Browser window remains independent, while active playback is shared.
On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions. On macOS managed playback, SubMiner disables mpv's menu-bar shortcuts so configured SubMiner shortcuts like `Cmd+Shift+O` reach the mpv plugin instead of opening native mpv menu actions.
+1 -3
View File
@@ -9,11 +9,9 @@ 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 into that line. For overlapping ASS karaoke, SubMiner moves past the previous line's exit animation when the selected cue has enough time remaining. - Clicking any cue seeks mpv to that timestamp.
- 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.
The sidebar only appears when a parsed cue list is available. External subtitle sources that SubMiner cannot parse (for example, embedded ASS tracks rendered directly by mpv) will not populate the sidebar. The sidebar only appears when a parsed cue list is available. External subtitle sources that SubMiner cannot parse (for example, embedded ASS tracks rendered directly by mpv) will not populate the sidebar.
## Layout Modes ## Layout Modes
+40 -3
View File
@@ -209,21 +209,26 @@ Resume playback and wait for the next subtitle to appear, then try mining again.
Both **alass** and **ffsubsync** are optional external dependencies. Subtitle syncing requires at least one of them to be installed. Both **alass** and **ffsubsync** are optional external dependencies. Subtitle syncing requires at least one of them to be installed.
**"Configured alass executable not found"** Subsync writes to the application log under the `subsync` scope, so the full command failure — exit code, stderr, resolved file paths — is recorded there as well as on the OSD.
**"Could not find alass" / "Configured alass executable not found"**
Install alass or configure the path: Install alass or configure the path:
- **Homebrew**: `brew install alass`
- **Arch Linux (AUR)**: `paru -S alass` - **Arch Linux (AUR)**: `paru -S alass`
- **Cargo**: `cargo install alass-cli` - **Cargo**: `cargo install alass-cli`
- Set the path: `subsync.alass_path` in your config. - Set the path: `subsync.alass_path` in your config.
**"Configured ffsubsync executable not found"** Leaving the option empty searches `PATH` plus the usual install prefixes, and accepts either `alass` or `alass-cli`. Set the option explicitly when the binary lives somewhere else. The second message means the configured path itself does not exist — SubMiner never silently substitutes a different binary for one you named.
**"Could not find ffsubsync" / "Configured ffsubsync executable not found"**
Install ffsubsync or configure the path: Install ffsubsync or configure the path:
- **Arch Linux (AUR)**: `paru -S python-ffsubsync` - **Arch Linux (AUR)**: `paru -S python-ffsubsync`
- **pip**: `pip install ffsubsync` - **pip**: `pip install ffsubsync`
- Must be on `PATH` or configured via `subsync.ffsubsync_path` in your config. - Must be discoverable, or configured via `subsync.ffsubsync_path` in your config.
**"alass synchronization failed" / "ffsubsync synchronization failed"** **"alass synchronization failed" / "ffsubsync synchronization failed"**
@@ -234,6 +239,38 @@ If subtitle sync fails (the error message is prefixed with the engine name):
- Try running the sync tool manually to see detailed error output. - Try running the sync tool manually to see detailed error output.
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs). - ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
**Syncing subtitles on a stream (Aniyomi extensions, Jellyfin)**
Subtitle tracks mpv loaded from a URL are downloaded to a temporary file first, reusing mpv's own request headers, so they work as either the sync target or the alass reference. Downloading a Japanese track from Jimaku or TsukiHime while a stream is playing also works — it becomes the primary track and therefore the sync target.
Internal subtitle tracks of a stream still go through `ffmpeg`, which has to reach the origin itself. If that fails, prefer an external track or a Jimaku download as the reference.
Streams usually serve WebVTT, which alass cannot parse — it picks its parser from the file extension and treats a `.vtt` file as a video, failing with "no audio stream in file". SubMiner rewrites both the target and the reference as SRT for alass, so this is handled automatically; the retimed track mpv loads is that SRT.
## Anime Browser
See the [anime browser guide](/anime-browser) for how sources and the bridge work.
**Nothing to search**
SubMiner ships no extension repositories and bundles no sources. Until you add a repository index URL in the **Extensions** tab (or drop Aniyomi `.apk` files into the extensions directory) the browser has nothing to query.
**"No pinned checksum for …"**
The bridge bundle is verified against a SHA-256 that a maintainer has checked by hand. Bundles exist for macOS (arm64, x64), Linux (x64), and Windows (x64), but only macOS arm64 and Linux x64 are pinned so far; the rest stop with this message rather than running an unverified download. Other platforms are unsupported outright.
**A source returns nothing, or asks for a login**
Most extensions need configuration first - a server address, credentials, a preferred quality. Open the **Source settings** tab and fill them in; each save is handed straight back to the extension. Sources that need an Android WebView (typically for Cloudflare challenges) cannot work here, because the bridge has none.
**"Playback failed" with an mpv error**
"Playing" is only reported once mpv actually configures a video output, so this is a real failure rather than a silent one: a dead host, an expired stream URL, or an undecodable stream. Resolve the episode again; if it keeps failing, try another source or quality entry.
**The browser starts failing every request**
A bridge that dies (killed, crashed, stopped mid-operation) is named in the status bar and restarted on the next request. Anything already playing ends with it, since stream URLs point at its loopback proxy.
## TsukiHime ## TsukiHime
**"xz binary not found"** **"xz binary not found"**
+2 -2
View File
@@ -16,11 +16,11 @@ Unlike Jimaku, TsukiHime needs no account or API key. The only requirement is th
The integration runs through an in-overlay modal opened with `Ctrl+Shift+T` by default. The modal has two tabs that filter the subtitle tracks of the selected release by role: the first follows `secondarySub.secondarySubLanguages` (English when unset), and the second is always **Japanese**, the currently supported primary subtitle language. Tracks with no language tag stay visible on the secondary tab. The integration runs through an in-overlay modal opened with `Ctrl+Shift+T` by default. The modal has two tabs that filter the subtitle tracks of the selected release by role: the first follows `secondarySub.secondarySubLanguages` (English when unset), and the second is always **Japanese**, the currently supported primary subtitle language. Tracks with no language tag stay visible on the secondary tab.
When you open the modal, SubMiner parses the current video filename to extract a title and episode number (same parser as Jimaku - `S01E03`, `1x03`, `E03`, and dash-separated numbers all work). If the filename yields a high-confidence match, SubMiner auto-searches immediately. When you open the modal, SubMiner parses the current video filename to extract a title, season, and episode number (same parser as Jimaku - `S01E03`, `1x03`, `E03`, and dash-separated numbers all work). If the filename yields a high-confidence match, SubMiner auto-searches immediately. Episodes launched from the [anime browser](/anime-browser) fill all three fields from the source's own listing instead of from a filename.
From there: From there:
1. **Search** - SubMiner queries TsukiHime with `<title> <episode>`. Results appear as a list of releases (e.g. `[SubsPlease] ... - 28 (1080p)`), each showing size, file count, and the subtitle languages the release carries. 1. **Search** - SubMiner queries TsukiHime with `<title> <season> <episode>`. Season 1 is left out on purpose: releases of a first season almost never carry `S01` in their name, so including it would match nothing. Later seasons are included, which is what makes their releases findable at all. Results appear as a list of releases (e.g. `[SubsPlease] ... - 28 (1080p)`), each showing size, file count, and the subtitle languages the release carries.
2. **Browse releases** - Select a release to list the text subtitle tracks extracted from its files. English tracks sort first; image-based tracks (PGS/VobSub) are filtered out. 2. **Browse releases** - Select a release to list the text subtitle tracks extracted from its files. English tracks sort first; image-based tracks (PGS/VobSub) are filtered out.
3. **Download** - Selecting a track downloads the xz-compressed subtitle from TsukiHime's storage, decompresses it, saves it next to the video (or a temp directory for remote/streamed media), and loads it into mpv. Japanese tracks are selected as mpv's **primary** subtitle. Tracks from the configured secondary tab are assigned to mpv's **secondary** subtitle slot without replacing the primary. The filename carries the track's language - `<video basename>.en.<ext>` for English, `.ja` for Japanese, and so on - so mpv and media servers detect the language correctly. 3. **Download** - Selecting a track downloads the xz-compressed subtitle from TsukiHime's storage, decompresses it, saves it next to the video (or a temp directory for remote/streamed media), and loads it into mpv. Japanese tracks are selected as mpv's **primary** subtitle. Tracks from the configured secondary tab are assigned to mpv's **secondary** subtitle slot without replacing the primary. The filename carries the track's language - `<video basename>.en.<ext>` for English, `.ja` for Japanese, and so on - so mpv and media servers detect the language correctly.
+2
View File
@@ -70,6 +70,7 @@ subminer https://youtu.be/... # Play a YouTube URL
subminer stats # Open the immersion stats dashboard subminer stats # Open the immersion stats dashboard
subminer doctor # Check dependencies, config, and the mpv socket subminer doctor # Check dependencies, config, and the mpv socket
subminer settings # Open the SubMiner settings window subminer settings # Open the SubMiner settings window
subminer anime # Open the anime browser window
subminer app --setup # Re-open first-run setup subminer app --setup # Re-open first-run setup
subminer -u # Check for updates subminer -u # Check for updates
``` ```
@@ -135,6 +136,7 @@ SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
SubMiner.AppImage --yomitan # Open Yomitan settings SubMiner.AppImage --yomitan # Open Yomitan settings
SubMiner.AppImage --settings # Open the SubMiner settings window SubMiner.AppImage --settings # Open the SubMiner settings window
SubMiner.AppImage --jellyfin # Open the Jellyfin setup window SubMiner.AppImage --jellyfin # Open the Jellyfin setup window
SubMiner.AppImage --anime # Open the anime browser window
SubMiner.AppImage --dictionary # Generate a character dictionary ZIP SubMiner.AppImage --dictionary # Generate a character dictionary ZIP
SubMiner.AppImage --start --dev # Enable app/dev mode SubMiner.AppImage --start --dev # Enable app/dev mode
SubMiner.AppImage --start --log-level debug # Verbose logging without dev mode SubMiner.AppImage --start --log-level debug # Verbose logging without dev mode
+6 -11
View File
@@ -58,15 +58,12 @@
`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. Rerun committed file — so review it before committing. If you add more
`bun run changelog:prerelease-notes --version <version>` for every later `changes/*.md` fragments for a later beta/RC, rerun
beta/RC, even if no fragments changed: the notes carry a hidden `bun run changelog:prerelease-notes --version <version>`; the generator uses
`prerelease-version` marker and CI rejects the tag when the marker does not the existing prerelease notes as the baseline only when their hidden
match it (verify locally with `prerelease-base-version` marker matches the current base version, and asks
`bun run changelog:check-prerelease-notes --version <version>`). The Claude to merge only the new fragment material. Do not run
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.
@@ -81,8 +78,6 @@ 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.
@@ -64,33 +64,22 @@ External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out o
A cue parser extracts both timing and text content from subtitle files for prefetching. A cue parser extracts both timing and text content from subtitle files for prefetching.
**Parsed cue structure:** **Parsed cue structure:**
```typescript ```typescript
interface SubtitleCue { interface SubtitleCue {
startTime: number; // seconds startTime: number; // seconds
endTime: number; // seconds endTime: number; // seconds
text: string; // plain text, decoded from the source format text: string; // plain text, decoded from the source format
source?: 'canonical-ass'; // recovered authored text for generated ASS animation
animationStartTime?: number; // full generated-frame envelope; entrance/exit frames
animationEndTime?: number; // run past the authored timing, live matching uses this
} }
``` ```
**Supported formats:** **Supported formats:**
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks. - SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
- ASS: Parse the `[Events]` section, read the field order from the `Format:` row, and extract timed `Dialogue:` lines. Timed `Comment:` lines are normally ignored, but can supply canonical authored text when they match a nearby generated animation from the same style and actor. Text can itself contain commas. - ASS: Parse `[Events]` section, extract `Dialogue:` lines, read the field order from the `Format:` row, and take everything after the Text field index as the text (Text can itself contain commas).
**ASS decoding.** The parser is where ASS text is decoded, once, via `assToPlainText()` in `src/core/services/ass-text.ts`. That decoder mirrors mpv's `ass_to_plaintext` so a cue read from a file reads identically to the same line arriving live on `sub-text`: `{...}` override blocks are markup, `\pN … \p0` vector drawing runs are dropped rather than shown as text, `\N`/`\n`/`\h` are the only escapes (`\{`, `\}` and `\\` are not), and an unclosed `{` is rendered verbatim. Every layer downstream — renderer, timing tracker, tokenizer, tokenization cache keys — receives plain text and uses `normalizePlainSubtitleText()` for whitespace only, so nothing decodes the same string twice and one authored line always maps to one cache key. **ASS decoding.** The parser is where ASS text is decoded, once, via `assToPlainText()` in `src/core/services/ass-text.ts`. That decoder mirrors mpv's `ass_to_plaintext` so a cue read from a file reads identically to the same line arriving live on `sub-text`: `{...}` override blocks are markup, `\pN … \p0` vector drawing runs are dropped rather than shown as text, `\N`/`\n`/`\h` are the only escapes (`\{`, `\}` and `\\` are not), and an unclosed `{` is rendered verbatim. Every layer downstream — renderer, timing tracker, tokenizer, tokenization cache keys — receives plain text and uses `normalizePlainSubtitleText()` for whitespace only, so nothing decodes the same string twice and one authored line always maps to one cache key.
**Duplicate collapsing.** Typeset scripts emit one `Dialogue:` event per animation frame, plus layered copies of the same line. The parser collapses identical text over an identical span unconditionally, and collapses contiguous same-text runs of at least three events when the run looks like an animation. For ASS that means shared style and actor plus authoring evidence: a temporal tag (`\t`, `\move`, `\k`/`\kf`/`\ko`/`\K`, or anything wrapped in `\t(...)`), an animated `Effect` column (`Karaoke`, `Banner`, `Scroll`), or override values that change across the run. Static tags shared by every event (`\pos`, an identical `\clip`) are not evidence. SRT/VTT carry no such metadata, so there collapsing needs at least five contiguous events all under 0.1s — the frame timing left behind by ASS-to-SRT conversion. The parser keeps this authoring metadata (style, actor, layer, `Effect`, parsed override commands, source order) private; `parseSubtitleCues()` returns only `SubtitleCue`. **Duplicate collapsing.** Typeset scripts emit one `Dialogue:` event per animation frame, plus layered copies of the same line. The parser collapses identical text over an identical span unconditionally, and collapses contiguous same-text runs of at least three events when the run looks like an animation. For ASS that means shared style and actor plus authoring evidence: a temporal tag (`\t`, `\move`, `\k`/`\kf`/`\ko`/`\K`, or anything wrapped in `\t(...)`), an animated `Effect` column (`Karaoke`, `Banner`, `Scroll`), or override values that change across the run. Static tags shared by every event (`\pos`, an identical `\clip`) are not evidence. SRT/VTT carry no such metadata, so there collapsing needs at least five contiguous events all under 0.1s — the frame timing left behind by ASS-to-SRT conversion. The parser keeps this authoring metadata (style, actor, layer, `Effect`, parsed override commands, source order) private; `parseSubtitleCues()` returns only `SubtitleCue`.
ASS scripts can also redraw one complete lyric for two or more long color/highlight phases. Those flush-timed phases collapse separately from short animation frames when they share text, style, actor, and layer and carry direct animation evidence, such as temporal tags or changing non-spatial overrides. Spatial command changes do not prove a phase, so separately positioned signs remain distinct.
**Canonical animation recovery.** Some ASS producers keep the readable lyric or sign as a timed `Comment:` and generate hundreds of `Dialogue:` frames containing repeated glyphs or changing clip regions. Others retain the complete line as brief `Dialogue:` events around the generated fragments. A complete event is promoted only when nearby dialogue from the same style and actor forms a proven animation cluster and reconstructs its entire text in source order. The generated frames are then replaced by one cue marked `source: 'canonical-ass'`. This source marker lets the live primary-subtitle path prefer the clean authored text and timing for display, sidebar history, immersion recording, and mining, while unmatched editor notes and alternative translations remain ignored. Secondary selection advances to an entering canonical cue at its generated animation start when the preceding authored cue ends before the new authored span. Unrelated simultaneous cues that continue through the new span remain visible.
**Font texture cleanup.** A clipped repeated-glyph run or frequent changes to secondary alpha marks a texture seed. Clipped runs do not need a font override because some signs build their masks from ordinary `l` glyphs. The parser removes short clipped pieces that share a no-font seed's style and timing, or pieces that share a font seed's style, timing, and font even when the actor changes. It also removes positioned text layers with at least `E0` global alpha when they overlap a seed in the same style. Opaque authored sign text stays publishable when the texture switches fonts or actors around it.
#### Prefetch Service Lifecycle #### Prefetch Service Lifecycle
1. **Activation trigger:** When a subtitle track is activated (or changes), check if it's external via MPV's `track-list` property. If `external === true`, read the file via `external-filename` using the existing `loadSubtitleSourceText` infrastructure. 1. **Activation trigger:** When a subtitle track is activated (or changes), check if it's external via MPV's `track-list` property. If `external === true`, read the file via `external-filename` using the existing `loadSubtitleSourceText` infrastructure.
@@ -167,7 +156,6 @@ tokens (already have frequencyRank values from parser-level applyFrequencyRanks)
### Dependency Analysis ### Dependency Analysis
All annotations either depend on MeCab POS data or benefit from running after it: All annotations either depend on MeCab POS data or benefit from running after it:
- **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately. - **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately.
- **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data. - **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data.
- **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data. - **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data.
@@ -184,14 +172,18 @@ function annotateTokens(tokens, deps, options): MergedToken[] {
// Single pass: known word + frequency filtering + JLPT computed together // Single pass: known word + frequency filtering + JLPT computed together
const annotated = tokens.map((token) => { const annotated = tokens.map((token) => {
const isKnown = nPlusOneEnabled ? token.isKnown || computeIsKnown(token, deps) : false; const isKnown = nPlusOneEnabled
? token.isKnown || computeIsKnown(token, deps)
: false;
// Filter frequency rank using POS exclusions (rank values already set at parser level) // Filter frequency rank using POS exclusions (rank values already set at parser level)
const frequencyRank = frequencyEnabled const frequencyRank = frequencyEnabled
? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions) ? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions)
: undefined; : undefined;
const jlptLevel = jlptEnabled ? computeJlptLevel(token, deps.getJlptLevel) : undefined; const jlptLevel = jlptEnabled
? computeJlptLevel(token, deps.getJlptLevel)
: undefined;
return { ...token, isKnown, frequencyRank, jlptLevel }; return { ...token, isKnown, frequencyRank, jlptLevel };
}); });
@@ -232,7 +224,6 @@ Replace `document.createElement('span')` calls in the renderer with `templateSpa
### Current Behavior ### Current Behavior
In `renderWithTokens` (`subtitle-render.ts`), each render cycle: In `renderWithTokens` (`subtitle-render.ts`), each render cycle:
1. Clears DOM with `innerHTML = ''` 1. Clears DOM with `innerHTML = ''`
2. Creates a `DocumentFragment` 2. Creates a `DocumentFragment`
3. Calls `document.createElement('span')` for each token (~10-15 per subtitle) 3. Calls `document.createElement('span')` for each token (~10-15 per subtitle)
@@ -268,30 +259,27 @@ Full recycling (collecting old nodes, clearing attributes, reusing them) require
## Combined Impact Summary ## Combined Impact Summary
| Scenario | Before | After | Improvement | | Scenario | Before | After | Improvement |
| --------------------------------- | ---------- | ---------- | ----------- | |----------|--------|-------|-------------|
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% | | Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% | | Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% | | Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
--- ---
## Files Summary ## Files Summary
### New Files ### New Files
- `src/core/services/subtitle-prefetch.ts` - `src/core/services/subtitle-prefetch.ts`
- `src/core/services/subtitle-cue-parser.ts` - `src/core/services/subtitle-cue-parser.ts`
### Modified Files ### Modified Files
- `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`) - `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`)
- `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass) - `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass)
- `src/renderer/subtitle-render.ts` (template cloneNode) - `src/renderer/subtitle-render.ts` (template cloneNode)
- `src/main.ts` (wire up prefetch service) - `src/main.ts` (wire up prefetch service)
### Test Files ### Test Files
- New tests for subtitle cue parser (SRT, VTT, ASS formats) - New tests for subtitle cue parser (SRT, VTT, ASS formats)
- New tests for subtitle prefetch service (priority window, seek, pause/resume) - New tests for subtitle prefetch service (priority window, seek, pause/resume)
- Updated tests for annotation stage (same behavior, new implementation) - Updated tests for annotation stage (same behavior, new implementation)
+12 -1
View File
@@ -3,7 +3,7 @@
# Domain Ownership # Domain Ownership
Status: active Status: active
Last verified: 2026-07-15 Last verified: 2026-08-02
Owner: Kyle Yasuda Owner: Kyle Yasuda
Read when: you need to find the owner module for a behavior or test surface Read when: you need to find the owner module for a behavior or test surface
@@ -29,6 +29,16 @@ Read when: you need to find the owner module for a behavior or test surface
`delete-maintenance-scheduler.ts` coalesces and serializes stats deletes; the expensive work runs in `delete-maintenance-worker-thread.ts` while the tracker queues playback writes. Each batch uses one transaction, lexical update, rollup refresh, and incremental lifetime subtraction (`planLifetimeRemovals`/`applyLifetimeRemovals` in `lifetime.ts`). Merges, moves, AniList reassignments, and `stats cleanup -l` use `repairLifetimeSummariesFromMedia` (recompute from the per-video media ledger). The full lifetime rebuild survives only as the empty-table bootstrap; anywhere else it would collapse lifetime totals to the session retention window. `delete-maintenance-scheduler.ts` coalesces and serializes stats deletes; the expensive work runs in `delete-maintenance-worker-thread.ts` while the tracker queues playback writes. Each batch uses one transaction, lexical update, rollup refresh, and incremental lifetime subtraction (`planLifetimeRemovals`/`applyLifetimeRemovals` in `lifetime.ts`). Merges, moves, AniList reassignments, and `stats cleanup -l` use `repairLifetimeSummariesFromMedia` (recompute from the per-video media ledger). The full lifetime rebuild survives only as the empty-table bootstrap; anywhere else it would collapse lifetime totals to the session retention window.
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/` - AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*` - Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
- Anime browser: extension bridge client, sidecar, and stream handling in `src/anime-bridge/`;
the loopback stream proxy separates request transport/retry from response transformation in
`stream-strip-transport.ts` and `stream-strip-response.ts`;
browser window UI in `src/animeui/` (preload `src/preload-animeui.ts`); runtime wiring in
`src/main/runtime/anime-browser-application-runtime.ts`, `src/main/runtime/anime-browser-runtime.ts`,
`src/main/runtime/anime-browser-ipc-handlers.ts`, `src/main/runtime/anime-browser-sessions.ts`,
`src/main/runtime/anime-bridge-installer.ts`, and `src/main/runtime/stream-playback-metadata.ts`.
The play queue resolves episodes on click and appends them to mpv's real playlist
(`src/main/runtime/anime-browser-queue.ts`), then observes media-path changes to
attach prepared external tracks and update the browser queue state.
- Window trackers: `src/window-trackers/` - Window trackers: `src/window-trackers/`
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support - Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
in `src/core/services/stats-server/` in `src/core/services/stats-server/`
@@ -46,6 +56,7 @@ Read when: you need to find the owner module for a behavior or test surface
- Settings UI contracts: `src/types/settings.ts` - Settings UI contracts: `src/types/settings.ts`
- Session-binding contracts: `src/types/session-bindings.ts` - Session-binding contracts: `src/types/session-bindings.ts`
- Stats HTTP wire contracts: `src/types/stats-wire.ts`, `src/types/stats-http-contract.ts` - Stats HTTP wire contracts: `src/types/stats-wire.ts`, `src/types/stats-http-contract.ts`
- Anime browser contracts: `src/types/anime-browser.ts`, bridge wire types in `src/anime-bridge/types.ts`
- Compatibility-only barrel: `src/types.ts` - Compatibility-only barrel: `src/types.ts`
## Ownership Heuristics ## Ownership Heuristics
+1 -3
View File
@@ -23,9 +23,7 @@ Trend charts now consume one chart-oriented backend payload from `/api/stats/tre
- lookup rate trends - lookup rate trends
- watch-time by day-of-week/hour - watch-time by day-of-week/hour
- vocabulary-backed: - vocabulary-backed:
- new-words trend reads permanent daily lexical rollups - new-words trend
- rollup rows count only vocabulary-visible tokens and normalize mixed legacy timestamp units
- a persisted rollup version invalidates stale materializations and triggers an atomic background rebuild
## Metric Semantics ## Metric Semantics
+4 -76
View File
@@ -3,7 +3,7 @@
# Subtitle Overlay Priming # Subtitle Overlay Priming
Status: active Status: active
Last verified: 2026-08-19 Last verified: 2026-08-04
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
@@ -69,82 +69,14 @@ coming and prefetching would otherwise idle for the rest of the cue.
## Live Cue Delivery ## Live Cue Delivery
- 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
full-span style layers appear once instead of repeating for fill, border, blur, shadow,
or equivalent whitespace variants. Any unmatched live line keeps the complete live
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.
- The current cue upgrades in place when its tokens and annotations are ready. This can reflow text - The current cue upgrades in place when its tokens and annotations are ready. This can reflow text
or character images, but cue visibility does not wait for that work. or character images, but cue visibility does not wait for that work.
## Secondary Subtitle Flow
- `secondary-sub-text` remains the immediate fallback, so unreadable subtitle sources, remote URLs,
and still-extracting embedded tracks appear without waiting for file resolution. Embedded-track
extraction runs for local and network-mounted files alike (demuxing reads the whole container,
about 10 seconds per GB on gigabit, under a generous timeout); only true remote URLs skip it,
having no on-disk container to demux.
- The live fallback also suppresses per-glyph typesetting walls: when many simultaneous
one-glyph lines are present (generated karaoke lettering flattened into live text), those
lines and their short syllable companions are dropped while concurrent dialogue lines stay.
This keeps the overlay clean while extraction is still in flight and for sources that never
produce parsed cues.
- 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
are read directly; supported embedded text tracks are extracted through the same ffmpeg-backed
source resolver used by primary subtitle prefetching.
- 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
`secondary-sub-delay`.
- Fragment reconstruction marks tall multi-row positioned parts as a grid only when they read
like tiling: a couple of texts repeated across many fragments, the same text re-shown at one
spot over time (countdown/animation frames), or scattered single glyphs. Secondary text omits
those grids instead of flattening a translated table or schedule into one synthetic line.
Wrapped lyric rows, CC-style dialogue blocks, and reconstructed single-line karaoke remain
eligible for display.
- 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
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. Latin fragment typesetting with no literal spaces also recovers word
boundaries represented only by materially larger horizontal `\pos` or `\move` gaps within that
line. Unpositioned fragments stay compact instead of gaining guessed 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;
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,
extraction, or parsing fails, the controller returns to live mpv text and the renderer's
conservative short stack heuristic remains the final display fallback.
## Emitted State ## Emitted State
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`. Overlay windows and annotation - `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`. Overlay windows and annotation
@@ -152,8 +84,8 @@ coming and prefetching would otherwise idle for the rest of the cue.
- The basic subtitle websocket receives the immediate plain cue only. Because its serialized - The basic subtitle websocket receives the immediate plain cue only. Because its serialized
payload discards annotations, the later upgrade would be an identical duplicate and is skipped payload discards annotations, the later upgrade would be an identical duplicate and is skipped
when text and cue timing match. when text and cue timing match.
- Secondary priming reads mpv `secondary-sub-text` and routes it through the secondary track - Secondary priming reads mpv `secondary-sub-text`, stores it in
controller. A parsed active cue replaces the live text when the selected source is readable. `mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows.
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is - If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
written. written.
@@ -197,11 +129,7 @@ coming and prefetching would otherwise idle for the rest of the cue.
path, empty or stale bounding shapes produced invisible or clipped subtitles even though the path, empty or stale bounding shapes produced invisible or clipped subtitles even though the
overlay window remained mapped above mpv. overlay window remained mapped above mpv.
- Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and - Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and
the Linux cursor-poll fallback, not bounding-shape clipping. Note that on Windows click-through the Linux cursor-poll fallback, not bounding-shape clipping.
must go through `applyOverlayClickThrough()` (`src/core/services/overlay-click-through.ts`),
which omits `forward: true` there: Electron implements forwarding with a global low-level mouse
hook that lags mouse input system-wide whenever the main thread stalls; the Windows cursor poll
handles overlay wake-up instead.
- Visible-overlay show/reset marks Linux pointer passthrough state dirty even when the logical - Visible-overlay show/reset marks Linux pointer passthrough state dirty even when the logical
interaction state is already inactive. The next cursor-poll tick must still reapply interaction state is already inactive. The next cursor-poll tick must still reapply
`setIgnoreMouseEvents(true, { forward: true })`; otherwise a newly shown Electron overlay can keep `setIgnoreMouseEvents(true, { forward: true })`; otherwise a newly shown Electron overlay can keep
+2 -2
View File
@@ -11,7 +11,7 @@ Read when: finding internal docs or checking verification status
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| KB home | `docs/README.md` | active | 2026-05-23 | internal entrypoint | | KB home | `docs/README.md` | active | 2026-05-23 | internal entrypoint |
| Architecture index | `docs/architecture/README.md` | active | 2026-05-23 | top-level runtime map | | Architecture index | `docs/architecture/README.md` | active | 2026-05-23 | top-level runtime map |
| Domain ownership | `docs/architecture/domains.md` | active | 2026-05-23 | runtime and feature ownership | | Domain ownership | `docs/architecture/domains.md` | active | 2026-08-02 | runtime and feature ownership |
| Layering rules | `docs/architecture/layering.md` | active | 2026-05-23 | dependency direction and smells | | Layering rules | `docs/architecture/layering.md` | active | 2026-05-23 | dependency direction and smells |
| Subtitle overlay priming | `docs/architecture/subtitle-overlay-priming.md` | active | 2026-06-01 | visible-overlay subtitle startup flow | | Subtitle overlay priming | `docs/architecture/subtitle-overlay-priming.md` | active | 2026-06-01 | visible-overlay subtitle startup flow |
| KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy | | KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy |
@@ -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-23 | repo-local workflow skill ownership | | Agent skills | `docs/workflow/agent-skills.md` | active | 2026-08-13 | 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 |
+1 -4
View File
@@ -3,7 +3,7 @@
# Agent Skills # Agent Skills
Status: active Status: active
Last verified: 2026-08-23 Last verified: 2026-08-13
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,9 +12,6 @@ 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.
+11
View File
@@ -16,6 +16,10 @@ type AppCommandDeps = {
appPath: string, appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'], logLevel: LauncherCommandContext['args']['logLevel'],
) => void; ) => void;
launchAnimeBrowserDetached: (
appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'],
) => void;
}; };
const defaultAppCommandDeps: AppCommandDeps = { const defaultAppCommandDeps: AppCommandDeps = {
@@ -23,6 +27,8 @@ const defaultAppCommandDeps: AppCommandDeps = {
launchSyncUiDetached: (appPath, logLevel) => launchSyncUiDetached: (appPath, logLevel) =>
launchAppCommandDetached(appPath, ['--sync-window'], logLevel, 'sync-ui'), launchAppCommandDetached(appPath, ['--sync-window'], logLevel, 'sync-ui'),
launchAppBackgroundDetached, launchAppBackgroundDetached,
launchAnimeBrowserDetached: (appPath, logLevel) =>
launchAppCommandDetached(appPath, ['--anime'], logLevel, 'anime'),
}; };
export function runAppPassthroughCommand( export function runAppPassthroughCommand(
@@ -37,6 +43,11 @@ export function runAppPassthroughCommand(
deps.runAppCommandWithInherit(appPath, ['--settings']); deps.runAppCommandWithInherit(appPath, ['--settings']);
return true; return true;
} }
if (args.animeBrowser) {
// Detached: the browser window is long-lived and owns the bridge process.
deps.launchAnimeBrowserDetached(appPath, args.logLevel);
return true;
}
if (args.syncUi) { if (args.syncUi) {
deps.launchSyncUiDetached(appPath, args.logLevel); deps.launchSyncUiDetached(appPath, args.logLevel);
return true; return true;
@@ -207,6 +207,7 @@ test('app command starts default macOS background app detached from launcher', (
calls.push('attached'); calls.push('attached');
}, },
launchSyncUiDetached: () => calls.push('sync-ui'), launchSyncUiDetached: () => calls.push('sync-ui'),
launchAnimeBrowserDetached: () => {},
launchAppBackgroundDetached: (appPath, logLevel) => { launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`); calls.push(`detached:${appPath}:${logLevel}`);
}, },
@@ -227,6 +228,7 @@ test('app command starts default Linux background app detached from launcher', (
calls.push('attached'); calls.push('attached');
}, },
launchSyncUiDetached: () => calls.push('sync-ui'), launchSyncUiDetached: () => calls.push('sync-ui'),
launchAnimeBrowserDetached: () => {},
launchAppBackgroundDetached: (appPath, logLevel) => { launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`); calls.push(`detached:${appPath}:${logLevel}`);
}, },
@@ -248,6 +250,7 @@ test('app command keeps explicit passthrough args attached', () => {
forwarded.push(appArgs); forwarded.push(appArgs);
}, },
launchSyncUiDetached: () => detached.push('sync-ui'), launchSyncUiDetached: () => detached.push('sync-ui'),
launchAnimeBrowserDetached: () => {},
launchAppBackgroundDetached: () => { launchAppBackgroundDetached: () => {
detached.push('detached'); detached.push('detached');
}, },
@@ -266,6 +269,7 @@ test('sync UI command launches the app detached from the terminal', () => {
const handled = runAppPassthroughCommand(context, { const handled = runAppPassthroughCommand(context, {
runAppCommandWithInherit: () => calls.push('piped'), runAppCommandWithInherit: () => calls.push('piped'),
launchSyncUiDetached: (appPath, logLevel) => calls.push(`sync-ui:${appPath}:${logLevel}`), launchSyncUiDetached: (appPath, logLevel) => calls.push(`sync-ui:${appPath}:${logLevel}`),
launchAnimeBrowserDetached: () => calls.push('anime'),
launchAppBackgroundDetached: () => calls.push('detached'), launchAppBackgroundDetached: () => calls.push('detached'),
}); });
-8
View File
@@ -23,7 +23,6 @@ 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';
@@ -334,13 +333,6 @@ 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();
-8
View File
@@ -2,7 +2,6 @@ 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';
@@ -65,13 +64,6 @@ 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;
} }
+1 -36
View File
@@ -63,6 +63,7 @@ function createContext(): LauncherCommandContext {
logsExport: false, logsExport: false,
version: false, version: false,
settings: false, settings: false,
animeBrowser: false,
configPath: false, configPath: false,
configShow: false, configShow: false,
mpvIdle: false, mpvIdle: false,
@@ -496,39 +497,3 @@ 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']);
});
+2 -15
View File
@@ -157,7 +157,6 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
}); });
}, },
chooseTarget, chooseTarget,
checkPickerDependencies,
checkDependencies, checkDependencies,
registerCleanup, registerCleanup,
startMpv, startMpv,
@@ -178,7 +177,6 @@ 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;
@@ -203,18 +201,7 @@ export async function runPlaybackCommandWithDeps(
await deps.ensurePlaybackSetupReady(context); await deps.ensurePlaybackSetupReady(context);
if (!args.target) { if (!args.target) {
(deps.checkPickerDependencies ?? checkPickerDependencies)(args); 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);
@@ -279,7 +266,7 @@ export async function runPlaybackCommandWithDeps(
); );
} }
await ensureRuntimeAssetsReady(); await deps.ensureRuntimePluginReady(context);
await deps.startMpv( await deps.startMpv(
selectedTarget.target, selectedTarget.target,
-6
View File
@@ -36,11 +36,6 @@ 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.' },
], ],
}; };
@@ -57,7 +52,6 @@ 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.',
]); ]);
}); });
+13 -10
View File
@@ -21,10 +21,7 @@ import {
parseSha256Sums, parseSha256Sums,
type FetchLike, type FetchLike,
} from '../../src/main/runtime/update/release-assets.js'; } from '../../src/main/runtime/update/release-assets.js';
import { import { updateSupportAssetsFromRelease } from '../../src/main/runtime/update/support-assets.js';
updateSupportAssetsFromRelease,
type SupportAssetsUpdateResult,
} from '../../src/main/runtime/update/support-assets.js';
type UpdateCommandResponse = { type UpdateCommandResponse = {
ok: boolean; ok: boolean;
@@ -39,14 +36,15 @@ 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: DirectSupportAssetsUpdateResult[]; supportAssets: Array<{
status: string;
component?: 'theme' | 'plugin';
command?: string;
message?: string;
}>;
}; };
type UpdateCommandDeps = { type UpdateCommandDeps = {
@@ -131,7 +129,12 @@ function readUpdateChannel(root: Record<string, unknown> | null): UpdateChannel
function logUpdateResult( function logUpdateResult(
label: string, label: string,
result: DirectSupportAssetsUpdateResult, result: {
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 {
+4
View File
@@ -120,6 +120,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
logLevel: 'warn', logLevel: 'warn',
}, },
settingsInvocation: null, settingsInvocation: null,
animeInvocation: null,
mpvInvocation: null, mpvInvocation: null,
appInvocation: null, appInvocation: null,
dictionaryTriggered: false, dictionaryTriggered: false,
@@ -174,6 +175,7 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
settingsInvocation: { settingsInvocation: {
logLevel: undefined, logLevel: undefined,
}, },
animeInvocation: null,
mpvInvocation: null, mpvInvocation: null,
appInvocation: null, appInvocation: null,
dictionaryTriggered: false, dictionaryTriggered: false,
@@ -221,6 +223,7 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
action: undefined, action: undefined,
}, },
settingsInvocation: null, settingsInvocation: null,
animeInvocation: null,
mpvInvocation: null, mpvInvocation: null,
appInvocation: null, appInvocation: null,
dictionaryTriggered: false, dictionaryTriggered: false,
@@ -266,6 +269,7 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
jellyfinInvocation: null, jellyfinInvocation: null,
configInvocation: null, configInvocation: null,
settingsInvocation: null, settingsInvocation: null,
animeInvocation: null,
mpvInvocation: null, mpvInvocation: null,
appInvocation: null, appInvocation: null,
dictionaryTriggered: false, dictionaryTriggered: false,
+7
View File
@@ -170,6 +170,7 @@ export function createDefaultArgs(
version: false, version: false,
update: false, update: false,
settings: false, settings: false,
animeBrowser: false,
configPath: false, configPath: false,
configShow: false, configShow: false,
mpvIdle: false, mpvIdle: false,
@@ -355,6 +356,12 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
); );
} }
if (invocations.animeInvocation) {
if (invocations.animeInvocation.logLevel) {
parsed.logLevel = parseLogLevel(invocations.animeInvocation.logLevel);
}
parsed.animeBrowser = true;
}
if (invocations.settingsInvocation) { if (invocations.settingsInvocation) {
if (invocations.settingsInvocation.logLevel) { if (invocations.settingsInvocation.logLevel) {
parsed.logLevel = parseLogLevel(invocations.settingsInvocation.logLevel); parsed.logLevel = parseLogLevel(invocations.settingsInvocation.logLevel);
+14
View File
@@ -23,6 +23,7 @@ export interface CliInvocations {
jellyfinInvocation: JellyfinInvocation | null; jellyfinInvocation: JellyfinInvocation | null;
configInvocation: CommandActionInvocation | null; configInvocation: CommandActionInvocation | null;
settingsInvocation: CommandActionInvocation | null; settingsInvocation: CommandActionInvocation | null;
animeInvocation: CommandActionInvocation | null;
mpvInvocation: CommandActionInvocation | null; mpvInvocation: CommandActionInvocation | null;
appInvocation: { appArgs: string[] } | null; appInvocation: { appArgs: string[] } | null;
dictionaryTriggered: boolean; dictionaryTriggered: boolean;
@@ -115,6 +116,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
'doctor', 'doctor',
'config', 'config',
'settings', 'settings',
'anime',
'mpv', 'mpv',
'logs', 'logs',
'dictionary', 'dictionary',
@@ -168,6 +170,7 @@ export function parseCliPrograms(
let jellyfinInvocation: JellyfinInvocation | null = null; let jellyfinInvocation: JellyfinInvocation | null = null;
let configInvocation: CommandActionInvocation | null = null; let configInvocation: CommandActionInvocation | null = null;
let settingsInvocation: CommandActionInvocation | null = null; let settingsInvocation: CommandActionInvocation | null = null;
let animeInvocation: CommandActionInvocation | null = null;
let mpvInvocation: CommandActionInvocation | null = null; let mpvInvocation: CommandActionInvocation | null = null;
let appInvocation: { appArgs: string[] } | null = null; let appInvocation: { appArgs: string[] } | null = null;
let dictionaryTriggered = false; let dictionaryTriggered = false;
@@ -456,6 +459,16 @@ export function parseCliPrograms(
}; };
}); });
commandProgram
.command('anime')
.description('Open the anime browser window')
.option('--log-level <level>', 'Log level')
.action((options: Record<string, unknown>) => {
animeInvocation = {
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
};
});
commandProgram commandProgram
.command('mpv') .command('mpv')
.description('MPV helpers') .description('MPV helpers')
@@ -510,6 +523,7 @@ export function parseCliPrograms(
jellyfinInvocation, jellyfinInvocation,
configInvocation, configInvocation,
settingsInvocation, settingsInvocation,
animeInvocation,
mpvInvocation, mpvInvocation,
appInvocation, appInvocation,
dictionaryTriggered, dictionaryTriggered,
+1
View File
@@ -57,6 +57,7 @@ function createArgs(): Args {
logsExport: false, logsExport: false,
version: false, version: false,
settings: false, settings: false,
animeBrowser: false,
configPath: false, configPath: false,
configShow: false, configShow: false,
mpvIdle: false, mpvIdle: false,
+5 -6
View File
@@ -73,21 +73,20 @@ function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv
}; };
} }
// On Linux the playback path runs `ensureLinuxRuntimePluginAvailable`, which // On Linux the playback path runs `ensureLinuxRuntimePluginAvailable`, which
// spawns the app with `--ensure-linux-runtime-plugin-assets` when managed // when the runtime plugin/theme are missing — spawns the app with
// support assets are missing and polls up to 30s // `--ensure-linux-runtime-plugin-assets` 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 support assets and write the response, matching // the fake app install the managed plugin/theme 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" "$data/thumbnailers" mkdir -p "$data/plugin/subminer" "$data/themes"
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"
+1
View File
@@ -627,6 +627,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
logsExport: false, logsExport: false,
version: false, version: false,
settings: false, settings: false,
animeBrowser: false,
configPath: false, configPath: false,
configShow: false, configShow: false,
mpvIdle: false, mpvIdle: false,
+1 -46
View File
@@ -3,12 +3,7 @@ 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 { import { findRofiTheme, formatRofiPrompt } from './picker';
findRofiTheme,
findRofiThumbnailerDataRoot,
formatRofiPrompt,
prependXdgDataDir,
} from './picker';
// ── formatRofiPrompt: spacing between prompt and input field ────────────────── // ── formatRofiPrompt: spacing between prompt and input field ──────────────────
@@ -28,7 +23,6 @@ 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 });
@@ -127,42 +121,3 @@ 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`,
);
});
-45
View File
@@ -159,9 +159,6 @@ 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,
@@ -392,47 +389,6 @@ 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,
@@ -464,7 +420,6 @@ 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) {
+9 -136
View File
@@ -1,8 +1,6 @@
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,
@@ -33,7 +31,7 @@ test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', asyn
assert.deepEqual(calls, []); assert.deepEqual(calls, []);
}); });
test('ensureLinuxRuntimePluginAvailable skips install when plugin, theme, and thumbnailer exist', async () => { test('ensureLinuxRuntimePluginAvailable skips install when installed global plugin and managed theme exist', async () => {
const calls: string[] = []; const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({ await ensureLinuxRuntimePluginAvailable({
@@ -54,17 +52,13 @@ test('ensureLinuxRuntimePluginAvailable skips install when plugin, theme, and th
calls.push('theme'); calls.push('theme');
return true; return true;
}, },
isManagedThumbnailerAvailable: () => {
calls.push('thumbnailer');
return true;
},
log: () => {}, log: () => {},
}); });
assert.deepEqual(calls, ['detect', 'theme', 'thumbnailer']); assert.deepEqual(calls, ['detect', 'theme']);
}); });
test('ensureLinuxRuntimePluginAvailable skips install when all managed assets resolve', async () => { test('ensureLinuxRuntimePluginAvailable skips install when managed runtime path and theme already resolve', async () => {
const calls: string[] = []; const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({ await ensureLinuxRuntimePluginAvailable({
@@ -86,19 +80,14 @@ test('ensureLinuxRuntimePluginAvailable skips install when all managed assets re
calls.push('theme'); calls.push('theme');
return true; return true;
}, },
isManagedThumbnailerAvailable: () => {
calls.push('thumbnailer');
return true;
},
log: () => {}, log: () => {},
}); });
assert.deepEqual(calls, ['detect', 'resolve', 'theme', 'thumbnailer']); assert.deepEqual(calls, ['detect', 'resolve', 'theme']);
}); });
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',
@@ -113,15 +102,10 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme
}, },
isManagedThemeAvailable: () => { isManagedThemeAvailable: () => {
calls.push('theme'); calls.push('theme');
return themeAvailable; return false;
},
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) => {
@@ -133,68 +117,13 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme
'detect', 'detect',
'resolve', 'resolve',
'theme', 'theme',
'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.', 'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
'install', 'install',
'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', 'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
'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;
@@ -208,8 +137,6 @@ 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' };
@@ -221,9 +148,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/thumbnailer assets.', 'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
'install', 'install',
'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', 'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
'resolve:2', 'resolve:2',
]); ]);
}); });
@@ -264,60 +191,6 @@ 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;
+6 -22
View File
@@ -31,7 +31,6 @@ 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;
}; };
@@ -49,14 +48,6 @@ 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> {
@@ -179,17 +170,15 @@ 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() && isManagedThumbnailerAvailable()) { if (runtimePluginAvailable && isManagedThemeAvailable()) {
return; return;
} }
log( log(
'info', 'info',
configuredLogLevel, configuredLogLevel,
'Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.', 'Linux runtime support assets missing; installing managed plugin/theme assets.',
); );
const installManagedPluginAssets = const installManagedPluginAssets =
options.installManagedPluginAssets ?? options.installManagedPluginAssets ??
@@ -218,21 +207,16 @@ export async function ensureLinuxRuntimePluginAvailable(
log( log(
'info', 'info',
configuredLogLevel, configuredLogLevel,
`Managed Linux runtime support assets installed: plugin=${installResult.path ?? 'unknown path'} theme=${managedPaths.themePath} thumbnailer=${managedPaths.thumbnailerPath}`, `Managed Linux runtime support assets installed: plugin=${installResult.path ?? 'unknown path'} theme=${managedPaths.themePath}`,
); );
const runtimePluginAvailableAfterInstall = const runtimePluginPath = resolveRuntimePluginPath();
installedPluginAvailable || Boolean(resolveRuntimePluginPath()); if (runtimePluginPath) {
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 paths: plugin=${managedPaths.pluginEntrypointPath} theme=${managedPaths.themePath} thumbnailer=${managedPaths.thumbnailerPath}. ` + `Checked path: ${managedPaths.pluginEntrypointPath}. ` +
'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);
+1 -15
View File
@@ -165,14 +165,11 @@ 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') }));
@@ -623,22 +620,11 @@ 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\/thumbnailer assets/i); assert.match(result.stdout, /managed plugin\/theme 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,
);
} }
}); });
}, },
@@ -18,9 +18,6 @@ export function createImmersionDbFixture(dbPath: string): void {
db.prepare( db.prepare(
`INSERT INTO imm_rollup_state(state_key, state_value) VALUES ('last_rollup_sample_ms', 0)`, `INSERT INTO imm_rollup_state(state_key, state_value) VALUES ('last_rollup_sample_ms', 0)`,
).run(); ).run();
db.prepare(
`INSERT INTO imm_rollup_state(state_key, state_value) VALUES ('lexical_daily_rollups_version', 0)`,
).run();
db.prepare( db.prepare(
`INSERT INTO imm_lifetime_global(global_id, CREATED_DATE, LAST_UPDATE_DATE) VALUES (1, ?, ?)`, `INSERT INTO imm_lifetime_global(global_id, CREATED_DATE, LAST_UPDATE_DATE) VALUES (1, ?, ?)`,
).run(String(Date.now()), String(Date.now())); ).run(String(Date.now()), String(Date.now()));
@@ -108,36 +108,6 @@ test('fixture schema stays aligned with production sync-touched tables and index
} }
}); });
test('fixture leaves lexical rollups pending when their table is absent', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-fixture-rollup-state-'));
const fixturePath = path.join(dir, 'fixture.sqlite');
try {
createImmersionDbFixture(fixturePath);
const db = new BunDatabase(fixturePath, { readonly: true });
try {
const state = db
.query<{ state_value: string }>(
`SELECT state_value FROM imm_rollup_state
WHERE state_key = 'lexical_daily_rollups_version'`,
)
.get();
const rollupTable = db
.query<{ name: string }>(
`SELECT name FROM sqlite_schema
WHERE type = 'table' AND name = 'imm_lexical_daily_rollups'`,
)
.get();
assert.equal(state?.state_value, '0');
assert.equal(rollupTable, null);
} finally {
db.close();
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('fixture session inserts enforce foreign keys', () => { test('fixture session inserts enforce foreign keys', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-fixture-foreign-keys-')); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-fixture-foreign-keys-'));
const fixturePath = path.join(dir, 'fixture.sqlite'); const fixturePath = path.join(dir, 'fixture.sqlite');
@@ -154,7 +154,6 @@ export const IMMERSION_DB_FIXTURE_DDL = `
last_seen REAL, last_seen REAL,
frequency INTEGER, frequency INTEGER,
frequency_rank INTEGER, frequency_rank INTEGER,
vocabulary_visible INTEGER NOT NULL DEFAULT 1 CHECK(vocabulary_visible IN (0, 1)),
UNIQUE(headword, word, reading) UNIQUE(headword, word, reading)
); );
CREATE TABLE imm_kanji( CREATE TABLE imm_kanji(
+1
View File
@@ -152,6 +152,7 @@ export interface Args {
version: boolean; version: boolean;
update?: boolean; update?: boolean;
settings: boolean; settings: boolean;
animeBrowser: boolean;
configPath: boolean; configPath: boolean;
configShow: boolean; configShow: boolean;
mpvIdle: boolean; mpvIdle: boolean;
+3 -3
View File
@@ -2,7 +2,7 @@
"name": "subminer", "name": "subminer",
"productName": "SubMiner", "productName": "SubMiner",
"desktopName": "SubMiner.desktop", "desktopName": "SubMiner.desktop",
"version": "0.19.4-beta.4", "version": "0.19.3",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"main": "dist/main-entry.js", "main": "dist/main-entry.js",
@@ -21,10 +21,11 @@
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --banner='#!/usr/bin/env bun' --outfile=dist/launcher/subminer", "build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --banner='#!/usr/bin/env bun' --outfile=dist/launcher/subminer",
"build:stats": "cd stats && bun run build", "build:stats": "cd stats && bun run build",
"dev:stats": "cd stats && bun run dev", "dev:stats": "cd stats && bun run dev",
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets", "build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:animeui && bun run build:launcher && bun run build:assets",
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap", "build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
"build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap", "build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap",
"build:syncui": "esbuild src/syncui/syncui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/syncui/syncui.js --sourcemap && esbuild src/preload-syncui.ts --bundle --platform=node --format=cjs --target=node20 --external:electron --outfile=dist/preload-syncui.js --sourcemap", "build:syncui": "esbuild src/syncui/syncui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/syncui/syncui.js --sourcemap && esbuild src/preload-syncui.ts --bundle --platform=node --format=cjs --target=node20 --external:electron --outfile=dist/preload-syncui.js --sourcemap",
"build:animeui": "esbuild src/animeui/animeui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/animeui/animeui.js --sourcemap && esbuild src/preload-animeui.ts --bundle --platform=node --format=cjs --target=node20 --external:electron --outfile=dist/preload-animeui.js --sourcemap",
"changelog:build": "bun run scripts/build-changelog.ts build-release", "changelog:build": "bun run scripts/build-changelog.ts build-release",
"changelog:check": "bun run scripts/build-changelog.ts check", "changelog:check": "bun run scripts/build-changelog.ts check",
"changelog:docs": "bun run scripts/build-changelog.ts docs", "changelog:docs": "bun run scripts/build-changelog.ts docs",
@@ -32,7 +33,6 @@
"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",
-2
View File
@@ -58,8 +58,6 @@ 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/"
-26
View File
@@ -7,8 +7,6 @@ local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20
local OVERLAY_LOADING_OSD_PREFIX = "Overlay loading " local OVERLAY_LOADING_OSD_PREFIX = "Overlay loading "
local OVERLAY_LOADING_OSD_FRAMES = { "|", "/", "-", "\\" } local OVERLAY_LOADING_OSD_FRAMES = { "|", "/", "-", "\\" }
local OVERLAY_LOADING_OSD_REFRESH_SECONDS = 0.18 local OVERLAY_LOADING_OSD_REFRESH_SECONDS = 0.18
local OVERLAY_LOADING_OSD_DEADLINE_SECONDS = 30
local OVERLAY_LOADING_OSD_TIMEOUT_MESSAGE = "Overlay did not become ready; check SubMiner logs"
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..." local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready" local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 30 local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 30
@@ -267,19 +265,10 @@ function M.create(ctx)
state.overlay_loading_osd_timer = nil state.overlay_loading_osd_timer = nil
end end
local function clear_overlay_loading_osd_deadline()
local timeout = state.overlay_loading_osd_deadline
if timeout and timeout.kill then
timeout:kill()
end
state.overlay_loading_osd_deadline = nil
end
local function stop_overlay_loading_osd() local function stop_overlay_loading_osd()
state.overlay_loading_osd_active = false state.overlay_loading_osd_active = false
state.overlay_loading_osd_frame = 1 state.overlay_loading_osd_frame = 1
clear_overlay_loading_osd_timer() clear_overlay_loading_osd_timer()
clear_overlay_loading_osd_deadline()
end end
local function start_overlay_loading_osd() local function start_overlay_loading_osd()
@@ -302,21 +291,6 @@ function M.create(ctx)
end end
end) end)
end end
if type(mp.add_timeout) == "function" then
state.overlay_loading_osd_deadline = mp.add_timeout(OVERLAY_LOADING_OSD_DEADLINE_SECONDS, function()
if not state.overlay_loading_osd_active then
return
end
state.overlay_loading_osd_deadline = nil
stop_overlay_loading_osd()
subminer_log(
"warn",
"process",
"Overlay loading deadline expired before the app reported content ready"
)
show_osd(OVERLAY_LOADING_OSD_TIMEOUT_MESSAGE, { force = true })
end)
end
end end
local function disarm_auto_play_ready_gate(options) local function disarm_auto_play_ready_gate(options)

Some files were not shown because too many files have changed in this diff Show More