mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-01 06:12:07 -07:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
09b11b689f
|
|||
|
6aadde4577
|
|||
|
6e3f072fdc
|
|||
|
233bde5861
|
|||
|
a5faef5aee
|
|||
|
3502cdc607
|
|||
| d51e7fe401 | |||
|
f9a4039ad2
|
|||
|
8e5c21b443
|
|||
|
55b350c3a2
|
|||
|
54324df3be
|
65
.github/workflows/release.yml
vendored
65
.github/workflows/release.yml
vendored
@@ -409,33 +409,64 @@ jobs:
|
||||
id: version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate AUR SSH secret
|
||||
- name: Check AUR publish prerequisites
|
||||
id: aur_prereqs
|
||||
env:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${AUR_SSH_PRIVATE_KEY}" ]; then
|
||||
echo "Missing required secret: AUR_SSH_PRIVATE_KEY"
|
||||
exit 1
|
||||
echo "::warning::Missing AUR_SSH_PRIVATE_KEY; skipping automated AUR publish."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Configure SSH for AUR
|
||||
id: aur_ssh
|
||||
if: steps.aur_prereqs.outputs.skip != 'true'
|
||||
env:
|
||||
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -dm700 ~/.ssh
|
||||
printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur
|
||||
chmod 600 ~/.ssh/aur
|
||||
ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts
|
||||
chmod 644 ~/.ssh/known_hosts
|
||||
if install -dm700 ~/.ssh \
|
||||
&& printf '%s\n' "${AUR_SSH_PRIVATE_KEY}" > ~/.ssh/aur \
|
||||
&& chmod 600 ~/.ssh/aur \
|
||||
&& ssh-keyscan aur.archlinux.org >> ~/.ssh/known_hosts \
|
||||
&& chmod 644 ~/.ssh/known_hosts; then
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::warning::Unable to configure SSH for AUR; skipping automated AUR publish."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Clone AUR repo
|
||||
id: aur_clone
|
||||
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true'
|
||||
env:
|
||||
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
|
||||
run: git clone ssh://aur@aur.archlinux.org/subminer-bin.git aur-subminer-bin
|
||||
run: |
|
||||
set -euo pipefail
|
||||
attempts=3
|
||||
for attempt in $(seq 1 "$attempts"); do
|
||||
if git clone ssh://aur@aur.archlinux.org/subminer-bin.git aur-subminer-bin; then
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
rm -rf aur-subminer-bin
|
||||
|
||||
if [ "$attempt" -lt "$attempts" ]; then
|
||||
sleep $((attempt * 15))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "::warning::Unable to clone subminer-bin from AUR after ${attempts} attempts; skipping automated AUR publish."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download release assets for AUR
|
||||
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
@@ -449,6 +480,7 @@ jobs:
|
||||
--pattern "subminer-assets.tar.gz"
|
||||
|
||||
- name: Update AUR packaging metadata
|
||||
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version_no_v="${{ steps.version.outputs.VERSION }}"
|
||||
@@ -463,6 +495,7 @@ jobs:
|
||||
--assets ".tmp/aur-release-assets/subminer-assets.tar.gz"
|
||||
|
||||
- name: Commit and push AUR update
|
||||
if: steps.aur_prereqs.outputs.skip != 'true' && steps.aur_ssh.outputs.skip != 'true' && steps.aur_clone.outputs.skip != 'true'
|
||||
working-directory: aur-subminer-bin
|
||||
env:
|
||||
GIT_SSH_COMMAND: ssh -i ~/.ssh/aur -o IdentitiesOnly=yes
|
||||
@@ -476,4 +509,16 @@ jobs:
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add PKGBUILD .SRCINFO
|
||||
git commit -m "Update to ${{ steps.version.outputs.VERSION }}"
|
||||
git push origin HEAD:master
|
||||
|
||||
attempts=3
|
||||
for attempt in $(seq 1 "$attempts"); do
|
||||
if git push origin HEAD:master; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$attempt" -lt "$attempts" ]; then
|
||||
sleep $((attempt * 15))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "::warning::Unable to push the AUR update after ${attempts} attempts; GitHub release is published, but subminer-bin needs manual follow-up."
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
- AniList: Stopped post-watch tracking from sending a second progress update when the current episode was already satisfied by a ready retry item in the same watch-completion pass.
|
||||
|
||||
## v0.10.0 (2026-03-29)
|
||||
|
||||
### Changed
|
||||
|
||||
18
README.md
18
README.md
@@ -63,6 +63,12 @@ Local stats dashboard — watch time, anime library, vocabulary growth, mining t
|
||||
|
||||
<br>
|
||||
|
||||
### Playlist Browser
|
||||
|
||||
Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback.
|
||||
|
||||
<br>
|
||||
|
||||
### Integrations
|
||||
|
||||
<table>
|
||||
@@ -102,12 +108,12 @@ Local stats dashboard — watch time, anime library, vocabulary growth, mining t
|
||||
|
||||
## Requirements
|
||||
|
||||
| | Required | Optional |
|
||||
| -------------- | --------------------------------------- | -------------------------------------- |
|
||||
| **Player** | [`mpv`](https://mpv.io) with IPC socket | — |
|
||||
| | Required | Optional |
|
||||
| -------------- | --------------------------------------- | ---------------------------------------------------------- |
|
||||
| **Player** | [`mpv`](https://mpv.io) with IPC socket | — |
|
||||
| **Processing** | `ffmpeg`, `mecab` + `mecab-ipadic` | `guessit` (AniSkip), `alass` / `ffsubsync` (subtitle sync) |
|
||||
| **Media** | — | `yt-dlp`, `chafa`, `ffmpegthumbnailer` |
|
||||
| **Selection** | — | `fzf` / `rofi` |
|
||||
| **Media** | — | `yt-dlp`, `chafa`, `ffmpegthumbnailer` |
|
||||
| **Selection** | — | `fzf` / `rofi` |
|
||||
|
||||
> [!NOTE]
|
||||
> [`bun`](https://bun.sh) is required if building from source or using the CLI wrapper: `subminer`. Pre-built releases (AppImage, DMG, installer) do not require it.
|
||||
@@ -230,8 +236,6 @@ subminer stats -b # stats daemon in background
|
||||
subminer stats -s # stop background stats daemon
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
Full guides on configuration, Anki setup, Jellyfin, immersion tracking, and more: **[docs.subminer.moe](https://docs.subminer.moe)**
|
||||
|
||||
@@ -5,7 +5,7 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-22 21:25'
|
||||
updated_date: '2026-03-24 06:44'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- stats
|
||||
- immersion-tracker
|
||||
@@ -21,6 +21,7 @@ references:
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker-service.test.ts
|
||||
priority: medium
|
||||
ordinal: 178500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,7 +5,7 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-26 03:59'
|
||||
updated_date: '2026-03-26 04:01'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- review-comments
|
||||
- coderabbit
|
||||
@@ -18,6 +18,7 @@ references:
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/main/runtime/youtube-playback-launch.ts
|
||||
priority: medium
|
||||
ordinal: 177500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,7 +5,7 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-26 04:30'
|
||||
updated_date: '2026-03-26 04:31'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- review-comments
|
||||
- coderabbit
|
||||
@@ -13,6 +13,7 @@ dependencies: []
|
||||
references:
|
||||
- /Users/sudacode/projects/japanese/SubMiner/src/main.ts
|
||||
priority: medium
|
||||
ordinal: 176500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -4,7 +4,7 @@ title: Introduce domain type entrypoints and shrink src/types.ts import surface
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-26 20:49'
|
||||
updated_date: '2026-03-27 00:14'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- tech-debt
|
||||
- types
|
||||
@@ -18,6 +18,7 @@ references:
|
||||
- docs/architecture/README.md
|
||||
parent_task_id: TASK-238
|
||||
priority: medium
|
||||
ordinal: 174500
|
||||
---
|
||||
|
||||
## Description
|
||||
@@ -27,7 +28,6 @@ priority: medium
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Domain-focused type modules exist for the main clusters currently mixed together in `src/types.ts` (for example Anki, config/runtime, subtitle/media, and integration/runtime-option types).
|
||||
- [x] #2 `src/types.ts` becomes a thinner compatibility layer or barrel instead of the sole source of truth for every shared type.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: TASK-238.4
|
||||
title: Decompose character dictionary runtime into fetch, build, and cache modules
|
||||
title: 'Decompose character dictionary runtime into fetch, build, and cache modules'
|
||||
status: Done
|
||||
updated_date: '2026-03-27 00:20'
|
||||
assignee: []
|
||||
created_date: '2026-03-26 20:49'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- tech-debt
|
||||
- runtime
|
||||
@@ -19,6 +19,7 @@ references:
|
||||
- docs/architecture/README.md
|
||||
parent_task_id: TASK-238
|
||||
priority: medium
|
||||
ordinal: 173500
|
||||
---
|
||||
|
||||
## Description
|
||||
@@ -28,7 +29,6 @@ priority: medium
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 AniList fetch/parsing logic, dictionary-entry building, and snapshot/cache/zip persistence no longer live in one giant file.
|
||||
- [x] #2 The public runtime API stays behavior-compatible for current callers.
|
||||
|
||||
@@ -5,7 +5,7 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-26 20:49'
|
||||
updated_date: '2026-03-27 00:00'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- tech-debt
|
||||
- stats
|
||||
@@ -20,6 +20,7 @@ references:
|
||||
- src/core/services/immersion-tracker-service.ts
|
||||
parent_task_id: TASK-238
|
||||
priority: medium
|
||||
ordinal: 175500
|
||||
---
|
||||
|
||||
## Description
|
||||
@@ -29,7 +30,6 @@ priority: medium
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Query responsibilities are grouped into focused modules such as library/session detail, vocabulary/kanji detail, and maintenance/cleanup helpers.
|
||||
- [x] #2 The stats server and immersion tracker service depend on stable exported query surfaces instead of one monolithic file.
|
||||
|
||||
@@ -4,7 +4,7 @@ title: Extract remaining inline runtime logic and composer gaps from src/main.ts
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-27 00:00'
|
||||
updated_date: '2026-03-27 22:13'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- tech-debt
|
||||
- runtime
|
||||
@@ -24,6 +24,7 @@ references:
|
||||
- src/main/runtime/composers
|
||||
parent_task_id: TASK-238
|
||||
priority: high
|
||||
ordinal: 172500
|
||||
---
|
||||
|
||||
## Description
|
||||
@@ -33,7 +34,6 @@ priority: high
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 `runYoutubePlaybackFlow`, `maybeSignalPluginAutoplayReady`, `refreshSubtitlePrefetchFromActiveTrack`, `publishDiscordPresence`, and `handleModalInputStateChange` no longer live as substantial inline logic in `src/main.ts`.
|
||||
- [x] #2 The large subtitle/prefetch, stats startup, and overlay visibility dependency groupings are wrapped behind named composer helpers instead of remaining inline in `src/main.ts`.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
id: TASK-238.7
|
||||
title: Split src/main.ts into boot-phase services, runtimes, and handlers
|
||||
title: 'Split src/main.ts into boot-phase services, runtimes, and handlers'
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-27 00:00'
|
||||
updated_date: '2026-03-27 22:45'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- tech-debt
|
||||
- runtime
|
||||
@@ -21,6 +21,7 @@ references:
|
||||
- src/main/runtime/composers
|
||||
parent_task_id: TASK-238
|
||||
priority: high
|
||||
ordinal: 171500
|
||||
---
|
||||
|
||||
## Description
|
||||
@@ -30,7 +31,6 @@ After the remaining inline runtime logic and composer gaps are extracted, `src/m
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Service instantiation lives in a dedicated boot module instead of a large inline setup block in `src/main.ts`.
|
||||
- [x] #2 Domain runtime composition lives in a dedicated boot module, separate from lifecycle and handler dispatch.
|
||||
|
||||
@@ -4,7 +4,7 @@ title: Fix stats server Bun fallback in coverage lane
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-29 07:31'
|
||||
updated_date: '2026-03-29 07:37'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- ci
|
||||
- bug
|
||||
@@ -13,6 +13,7 @@ dependencies: []
|
||||
references:
|
||||
- 'PR #36'
|
||||
priority: high
|
||||
ordinal: 170500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -4,13 +4,14 @@ title: Migrate Discord Rich Presence to maintained RPC wrapper
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-29 08:17'
|
||||
updated_date: '2026-03-29 08:22'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- dependency
|
||||
- discord
|
||||
- presence
|
||||
dependencies: []
|
||||
priority: medium
|
||||
ordinal: 169500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,13 +5,14 @@ status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-29 10:01'
|
||||
updated_date: '2026-03-29 10:10'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels: []
|
||||
dependencies: []
|
||||
references:
|
||||
- src/core/services/subtitle-cue-parser.ts
|
||||
- src/renderer/modals/subtitle-sidebar.ts
|
||||
- src/core/services/subtitle-cue-parser.test.ts
|
||||
ordinal: 168500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -4,7 +4,7 @@ title: Fix macOS visible overlay toggle getting immediately restored
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-29 10:03'
|
||||
updated_date: '2026-03-29 22:14'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels: []
|
||||
dependencies: []
|
||||
references:
|
||||
@@ -13,6 +13,7 @@ references:
|
||||
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/cli-command.ts
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/main/overlay-visibility-runtime.ts
|
||||
ordinal: 165500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -4,7 +4,7 @@ title: Fix AniList token persistence on setup login
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-29 10:08'
|
||||
updated_date: '2026-03-29 19:42'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- anilist
|
||||
- bug
|
||||
@@ -15,6 +15,7 @@ documentation:
|
||||
- src/main/runtime/anilist-token-refresh.ts
|
||||
- docs-site/anilist-integration.md
|
||||
priority: high
|
||||
ordinal: 166500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -5,7 +5,7 @@ status: Done
|
||||
assignee:
|
||||
- '@codex'
|
||||
created_date: '2026-03-29 10:10'
|
||||
updated_date: '2026-03-29 10:23'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- bug
|
||||
- macos
|
||||
@@ -24,6 +24,7 @@ references:
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/renderer/overlay-mouse-ignore.test.ts
|
||||
priority: high
|
||||
ordinal: 167500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -4,11 +4,12 @@ title: 'Docs: add subtitle sidebar and Jimaku integration pages'
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-29 22:36'
|
||||
updated_date: '2026-03-29 22:38'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- docs
|
||||
dependencies: []
|
||||
priority: medium
|
||||
ordinal: 164500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
id: TASK-252
|
||||
title: Harden AUR publish release step against transient SSH failures
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-29 23:46'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- release
|
||||
- ci
|
||||
- aur
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 163500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Make tagged releases resilient when the automated AUR update hits transient SSH disconnects from GitHub-hosted runners. The GitHub Release should still complete successfully, while AUR publish should retry a few times and downgrade persistent AUR failures to warnings instead of failing the entire release workflow.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Tagged release workflow retries the AUR clone/push path with bounded backoff when AUR SSH disconnects transiently.
|
||||
- [x] #2 Persistent AUR publish failure does not fail the overall tagged release workflow or block GitHub Release publication.
|
||||
- [x] #3 Release documentation notes that AUR publish is best-effort and may need manual follow-up when retries are exhausted.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Updated .github/workflows/release.yml so AUR secret/configure/clone/push failures downgrade to warnings, clone/push retry three times with linear backoff, and the GitHub Release path remains green.
|
||||
|
||||
Documented AUR publish as best-effort in docs/RELEASING.md and added changes/253-aur-release-best-effort.md for PR changelog compliance.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
id: TASK-253
|
||||
title: Fix animated AVIF lead-in alignment with sentence audio
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-30 01:59'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels: []
|
||||
dependencies: []
|
||||
references:
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/anki-integration/animated-image-sync.ts
|
||||
- /Users/sudacode/projects/japanese/SubMiner/src/anki-integration.ts
|
||||
- /Users/sudacode/projects/japanese/SubMiner/src/core/services/stats-server.ts
|
||||
- /Users/sudacode/projects/japanese/SubMiner/src/media-generator.ts
|
||||
ordinal: 162500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Animated AVIF cards currently freeze only for the existing word-audio duration. Because generated sentence audio starts with configured audio padding before the spoken subtitle begins, animation motion can begin early instead of lining up with the spoken sentence. Update the shared lead-in calculation so animated motion begins when sentence speech begins after the chosen word audio finishes.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Animated AVIF lead-in calculation includes both the chosen word-audio duration and the generated sentence-audio start offset so motion begins with spoken sentence audio
|
||||
- [x] #2 Shared animated-image sync behavior is applied consistently across the Anki note update, card creation, and stats server media-generation paths
|
||||
- [x] #3 Regression tests cover the corrected lead-in timing calculation and fail before the fix
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
Approved plan:
|
||||
1. Add a failing unit test proving animated-image lead-in must include sentence-audio start offset in addition to chosen word-audio duration.
|
||||
2. Update shared animated-image lead-in resolution to add the configured sentence-audio offset used by generated sentence audio.
|
||||
3. Thread the shared calculation through note update, card creation, and stats-server generation paths without duplicating timing logic.
|
||||
4. Run targeted tests first, then the relevant fast verification lane for touched files.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
User approved implementation on 2026-03-29 local time. Root cause: lead-in omitted sentence-audio padding offset, so AVIF motion began before spoken sentence audio.
|
||||
|
||||
Implemented shared animated-image lead-in fix in src/anki-integration/animated-image-sync.ts by adding the same sentence-audio start offset used by generated audio (`audioPadding`) after summing the chosen word-audio durations.
|
||||
|
||||
Added regression coverage in src/anki-integration/animated-image-sync.test.ts for explicit `audioPadding` lead-in alignment and kept the zero-padding case covered.
|
||||
|
||||
Verification passed: `bun test src/anki-integration/animated-image-sync.test.ts src/anki-integration/note-update-workflow.test.ts src/media-generator.test.ts`, `bun run typecheck`, `bun run test:fast`, `bun run test:env`, `bun run build`, `bun run test:smoke:dist`.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Fixed animated AVIF lead-in alignment so motion starts when the spoken sentence starts, not at the padded beginning of the generated sentence-audio clip. The shared resolver in `src/anki-integration/animated-image-sync.ts` now adds the configured/default `audioPadding` offset after summing the selected word-audio durations, which keeps note update, card creation, and stats-server generation paths aligned through the same logic.
|
||||
|
||||
Added regression coverage in `src/anki-integration/animated-image-sync.test.ts` for both zero-padding and explicit padding cases to prove the lead-in math matches sentence-audio timing.
|
||||
|
||||
Verification:
|
||||
- `bun test src/anki-integration/animated-image-sync.test.ts src/anki-integration/note-update-workflow.test.ts src/media-generator.test.ts`
|
||||
- `bun run typecheck`
|
||||
- `bun run test:fast`
|
||||
- `bun run test:env`
|
||||
- `bun run build`
|
||||
- `bun run test:smoke:dist`
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
id: TASK-254
|
||||
title: Fix AniList token persistence when safe storage is unavailable
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-30 02:10'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- bug
|
||||
- anilist
|
||||
dependencies: []
|
||||
references:
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/core/services/anilist/anilist-token-store.ts
|
||||
- /Users/sudacode/projects/japanese/SubMiner/src/main/runtime/anilist-setup.ts
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/main/runtime/anilist-token-refresh.ts
|
||||
priority: high
|
||||
ordinal: 161500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
AniList login currently appears to succeed during setup, but some environments cannot persist the token because Electron safeStorage is unavailable or unusable. On the next app start, AniList tracking cannot load the token and re-prompts the user to set up AniList again. Align AniList token persistence with the intended login UX so a token the user already saved is reused on later sessions.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Saved encrypted AniList token is reused on app-ready startup without reopening setup.
|
||||
- [x] #2 AniList startup no longer attempts to open the setup BrowserWindow before Electron is ready.
|
||||
- [x] #3 AniList auth/runtime tests cover stored-token reuse and the missing-token startup path that previously triggered pre-ready setup attempts.
|
||||
- [x] #4 AniList token storage remains encrypted-only; no plaintext fallback is introduced.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add regression tests for AniList startup auth refresh so a stored encrypted token is reused without opening setup, and for the missing-token path so setup opening is deferred safely until the app can actually show a window.
|
||||
2. Update AniList startup/auth runtime to separate token resolution from setup-window prompting, and gate prompting on app readiness instead of attempting BrowserWindow creation during early startup.
|
||||
3. Preserve encrypted-only storage semantics in anilist-token-store; do not add plaintext fallback. If stored-token load fails, keep logging/diagnostics intact.
|
||||
4. Run targeted AniList runtime/token tests, then summarize root cause and verification results.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Investigated AniList auth persistence flow. Current setup path treats callback token as saved even when anilist-token-store refuses persistence because safeStorage is unavailable. Jellyfin token store already uses plaintext fallback in this environment class, which is a likely model for the AniList fix.
|
||||
|
||||
Confirmed from local logs that safeStorage was explicitly unavailable on 2026-03-23 due macOS Keychain lookup failure with NSOSStatusErrorDomain Code=-128 userCanceledErr. Current environment also has an encrypted AniList token file at /Users/sudacode/.config/SubMiner/anilist-token-store.json updated 2026-03-29 18:49, so safeStorage did work recently for save. Repeated AniList setup prompts on 2026-03-29/30 correlate more strongly with startup auth flow deciding no token is available and opening setup immediately; logs show repeated 'Loaded AniList manual token entry page' and several 'Failed to refresh AniList client secret state during startup' errors with 'Cannot create BrowserWindow before app is ready'. No recent log lines indicate safeStorage.isEncryptionAvailable() false after 2026-03-23.
|
||||
|
||||
Implemented encrypted-only startup fix by adding an allowSetupPrompt control to AniList token refresh and disabling setup-window prompting for the early pre-ready startup refresh in main.ts. App-ready reloadConfig still performs the normal prompt-capable refresh after Electron is ready. Added regression tests for stored-token reuse and prompt suppression when startup explicitly disables prompting.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Root cause was a redundant early AniList auth refresh during startup. The app refreshed AniList auth once before Electron was ready and again during app-ready config reload. When the early refresh could not resolve a token, it tried to open the AniList setup window immediately, which produced the observed 'Cannot create BrowserWindow before app is ready' failures and repeated setup prompts. The fix keeps token storage encrypted-only, teaches AniList auth refresh to optionally suppress setup-window prompting, and uses that suppression for the early startup refresh. App-ready startup still performs the normal prompt-capable refresh once Electron is ready, so saved encrypted tokens are reused without reopening setup and missing-token setup only happens at a safe point. Verified with targeted AniList auth tests, typecheck, test:fast, test:env, build, and test:smoke:dist.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
id: TASK-255
|
||||
title: Add overlay playlist browser modal for sibling video files and mpv queue
|
||||
status: Done
|
||||
assignee:
|
||||
- '@codex'
|
||||
created_date: '2026-03-30 05:46'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- feature
|
||||
- overlay
|
||||
- mpv
|
||||
- launcher
|
||||
dependencies: []
|
||||
ordinal: 180500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Add an in-session overlay modal that opens from a keybinding during active playback and lets the user browse video files from the current file's parent directory alongside the active mpv playlist. The modal should sort local files in best-effort episode order, highlight the current item, and allow keyboard/mouse interaction to add files into the mpv queue, remove queued items, and reorder queued items without leaving playback.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 An overlay modal can be opened during active playback from a dedicated keybinding and closed without disrupting existing modal behavior.
|
||||
- [x] #2 The modal shows video files from the current media file's parent directory in best-effort episode order and highlights the current file when present.
|
||||
- [x] #3 The modal shows the active mpv playlist/queue with enough metadata to identify the current item and queued order.
|
||||
- [x] #4 The user can add a directory file to the mpv playlist, remove playlist items, and reorder playlist items from the modal using both mouse and keyboard interactions.
|
||||
- [x] #5 Modal state stays in sync after playlist mutations so the rendered queue reflects mpv's current playlist order.
|
||||
- [x] #6 Feature coverage includes automated tests for ordering/playlist behavior and docs or shortcut/help updates for the new modal.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add playlist-browser domain types, IPC channels, overlay modal registration, special command, and default keybinding for Ctrl+Alt+P.
|
||||
2. Write failing tests for best-effort episode sorting and main playlist-browser runtime snapshot/mutation behavior.
|
||||
3. Implement playlist-browser main/runtime helpers for local sibling video discovery, mpv playlist normalization, and append/play/remove/move operations with refreshed snapshots.
|
||||
4. Wire preload and main-process IPC handlers that expose snapshot and mutation methods to the renderer.
|
||||
5. Write failing renderer and keyboard tests for modal open/close, split-pane interaction, keyboard controls, and degraded states.
|
||||
6. Implement playlist-browser modal markup, DOM/state, renderer composition, keyboard routing, and session-help labeling.
|
||||
7. Run targeted test lanes first, then the maintained verification gate relevant to the touched surfaces; update task notes/criteria as checks pass.
|
||||
|
||||
2026-03-30 CodeRabbit follow-up: 1) add failing runtime coverage for unreadable playlist-browser file stat failures, 2) add failing renderer coverage for stale snapshot UI reset on refresh failure/close, 3) add failing renderer coverage to block playlist-browser open when another modal already owns the overlay, 4) implement minimal fixes, 5) rerun targeted tests plus typecheck for touched surfaces.
|
||||
|
||||
2026-03-30 current CodeRabbit round: verify 4 unresolved threads, ignore already-fixed outdated dblclick thread if current code matches, add failing-first coverage for selection preservation / timestamp fixture consistency / string test-clock alignment, implement minimal fixes, rerun targeted tests plus typecheck.
|
||||
|
||||
2026-03-30 latest CodeRabbit round on PR #37: 1) add failing coverage for negative fractional numeric __subminerTestNowMs input so nowMs() matches the string-backed path, 2) add failing coverage that playlist-browser modal tests restore absent window/document globals without leaving undefined-valued properties behind, 3) refactor repeated playlist-browser modal test harness into a shared setup/teardown fixture while preserving assertions, 4) implement minimal fixes, 5) rerun touched tests plus typecheck.
|
||||
|
||||
2026-03-30 latest CodeRabbit follow-up after ff760ea: tighten the new cleanup regression so env.restore() always runs under assertion failure, and make the keydown test's append mock return a post-append mutated snapshot before exercising Ctrl+ArrowDown. Re-run targeted playlist-browser tests plus typecheck.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Implemented overlay playlist browser modal with split directory/playlist panes, Ctrl+Alt+P keybinding, main/preload IPC, mpv queue mutations, and best-effort sibling episode sorting.
|
||||
|
||||
Added tests for sort/runtime logic, IPC wiring, keyboard routing, and playlist-browser modal behavior.
|
||||
|
||||
Verification: `bun run typecheck` passed; targeted playlist-browser and IPC tests passed; `bun run build` passed; `bun run test:smoke:dist` passed.
|
||||
|
||||
Repo gate blockers outside this feature: `bun run test:fast` hits existing Bun `node:test` NotImplementedError cases plus unrelated immersion-tracker failures; `bun run test:env` fails in existing immersion-tracker sqlite tests.
|
||||
|
||||
2026-03-30: Fixed playlist-browser local playback regression where subtitle track IDs leaked across episode jumps. `playPlaylistBrowserIndexRuntime` now reapplies local subtitle auto-selection defaults (`sub-auto=fuzzy`, `sid=auto`, `secondary-sid=auto`) before `playlist-play-index` for local filesystem targets only; remote playlist entries remain untouched. Added runtime regression tests for both paths.
|
||||
|
||||
2026-03-30: Follow-up subtitle regression fix. Pre-jump `sid=auto` was ineffective because mpv resolved it against the current episode before `playlist-play-index`. Local playlist jumps now set `sub-auto=fuzzy`, switch episodes, then schedule a delayed rearm of `sid=auto` and `secondary-sid=auto` so selection happens against the new file's tracks. Added failing-first runtime coverage for delayed local rearm and remote no-op behavior.
|
||||
|
||||
2026-03-30: Cleaned up playlist-browser runtime local-play subtitle-rearm flow by extracting focused helpers without changing behavior. Added public docs/readme coverage for the default `Ctrl+Alt+P` playlist browser keybinding and modal, plus changelog fragment `changes/260-playlist-browser.md`. Verification: `bun test src/main/runtime/playlist-browser-runtime.test.ts`, `bun run typecheck`, `bun run docs:test`, `bun run docs:build`, `bun run changelog:lint`, `bun run build`.
|
||||
|
||||
2026-03-30: Pulled unresolved CodeRabbit review threads for PR #37. Actionable set is three items: unreadable-file stat error handling in playlist-browser runtime, stale playlist-browser DOM after failed refresh/close, and missing modal-ownership guard before opening the playlist-browser overlay. Proceeding test-first for each.
|
||||
|
||||
2026-03-30: Addressed current CodeRabbit follow-up findings for PR #37. Fixed playlist-browser unreadable-file stat handling, stale playlist-browser DOM reset on refresh failure/close, modal-ownership guard before opening the playlist-browser overlay, async rejection surfacing for PLAYLIST_BROWSER_OPEN IPC commands, overlay bootstrap before playlist-browser open dispatch, texthooker option normalization in the mpv plugin, and superseded local subtitle-rearm suppression. Added targeted regressions plus new playlist-browser-open helper coverage. Verification: `bun test src/main/runtime/playlist-browser-runtime.test.ts src/main/runtime/playlist-browser-open.test.ts src/core/services/ipc-command.test.ts src/renderer/modals/playlist-browser.test.ts`, `lua scripts/test-plugin-start-gate.lua`, `bun run typecheck`, `bun run build`.
|
||||
|
||||
Addressed CodeRabbit follow-ups on the playlist browser PR: clamped stale playingIndex values, failed mutation paths when MPV rejects send(), added temp-dir cleanup in runtime tests, and blocked action-button dblclick bubbling in the renderer. Verification: `bun run typecheck`, `bun run build`, `bun test src/main/runtime/playlist-browser-runtime.test.ts src/renderer/modals/playlist-browser.test.ts`.
|
||||
|
||||
Additional follow-up: moved playlist-browser keydown handling ahead of keyboard-driven lookup controls so KeyH/ArrowLeft/ArrowRight and related chords are routed to the modal first. Verification refreshed with `bun test src/main/runtime/playlist-browser-runtime.test.ts src/renderer/modals/playlist-browser.test.ts src/renderer/handlers/keyboard.test.ts`, `bun run typecheck`, and `bun run build`.
|
||||
|
||||
Split playlist-browser UI row rendering into `src/renderer/modals/playlist-browser-renderer.ts` and left `src/renderer/modals/playlist-browser.ts` as the controller/wiring layer. Moved playlist-browser IPC/runtime wiring into `src/main/runtime/playlist-browser-ipc.ts` and collapsed the `src/main.ts` registration block to use that helper. Verification after refactor: `bun run typecheck`, `bun run build`, `bun test src/main/runtime/playlist-browser-runtime.test.ts src/renderer/modals/playlist-browser.test.ts src/renderer/handlers/keyboard.test.ts`.
|
||||
|
||||
2026-03-30 PR #37 unresolved CodeRabbit threads currently reduce to three likely-actionable items plus one outdated renderer dblclick thread to verify against HEAD before touching code.
|
||||
|
||||
2026-03-30 Addressed latest unresolved CodeRabbit items on PR #37: preserved playlist-browser selection across mutation snapshots, taught nowMs() to honor string-backed test clocks so it stays aligned with currentDbTimestamp(), and normalized maintenance test timestamp fixtures to toDbTimestamp(). The older playlist-browser dblclick thread remains unresolved in GitHub state but current HEAD already contains that fix in playlist-browser-renderer.ts.
|
||||
|
||||
2026-03-30 latest CodeRabbit remediation on PR #37: switched nowMs() numeric test-clock branch from Math.floor() to Math.trunc() so numeric and string-backed mock clocks agree for negative fractional values. Refactored playlist-browser modal tests onto a shared setup/teardown fixture that restores global window/document descriptors correctly, and added regression coverage that injected globals are deleted when originally absent. Verification: `bun test src/core/services/immersion-tracker/time.test.ts src/renderer/modals/playlist-browser.test.ts`, `bun run typecheck`.
|
||||
|
||||
2026-03-30 CodeRabbit follow-up: wrapped the injected-globals cleanup regression in try/finally so restore always runs, and changed the keydown test append mock to return createMutationSnapshot() before exercising Ctrl+ArrowDown. Verified with `bun test src/renderer/modals/playlist-browser.test.ts` and `bun run typecheck`.
|
||||
|
||||
2026-03-31 assessment: the playlist-browser feature is landed on `main` via `d51e7fe4 Add playlist browser overlay modal (#37)` with runtime, IPC, renderer, keybinding, and changelog/docs coverage present. Verified passes: `bun test src/main/runtime/playlist-browser-runtime.test.ts src/main/runtime/playlist-browser-open.test.ts src/main/runtime/playlist-browser-sort.test.ts src/renderer/handlers/keyboard.test.ts src/core/services/ipc.test.ts src/core/services/ipc-command.test.ts src/config/definitions/domain-registry.test.ts`.
|
||||
|
||||
Remaining action item before close: fix `src/renderer/modals/playlist-browser.test.ts` so the cleanup regression does not assume `globalThis.window` / `globalThis.document` start absent under Bun, rerun the playlist-browser modal lane (and then typecheck/build if you want the full closeout proof), then finalize the task.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
id: TASK-256
|
||||
title: Fix texthooker page live websocket connect/send regression
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-30 06:04'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- bug
|
||||
- texthooker
|
||||
- websocket
|
||||
dependencies: []
|
||||
priority: medium
|
||||
ordinal: 160500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Investigate why the bundled texthooker page loads at the local HTTP endpoint but does not reliably connect to the configured websocket feed or receive/display live subtitle lines. Identify the regression in the SubMiner startup/bootstrap or vendored texthooker client path, restore live line delivery, and cover the fix with focused regression tests and any required docs updates.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Bundled texthooker connects to the intended websocket endpoint on launch using the configured/default SubMiner startup path.
|
||||
- [x] #2 Incoming subtitle or annotation websocket messages are accepted by the bundled texthooker and rendered as live lines.
|
||||
- [x] #3 Regression coverage fails before the fix and passes after the fix for the identified breakage.
|
||||
- [x] #4 Relevant docs/config notes are updated if user-facing behavior or troubleshooting guidance changes.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add a focused CLI regression test covering `--texthooker` startup when the runtime has a resolved websocket URL, proving the handler currently starts texthooker without that URL.
|
||||
2. Extend CLI texthooker dependencies/runtime wiring so the handler can retrieve the resolved texthooker websocket URL from current config/runtime state.
|
||||
3. Update the CLI texthooker flow to pass the resolved websocket URL into texthooker startup instead of starting the HTTP server with only a port.
|
||||
4. Run focused tests for CLI command handling and texthooker bootstrap behavior; update task notes/final summary with the verified root cause and fix.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Root cause: the CLI `--texthooker` path started the HTTP texthooker server with only the port, so the served page never received `bannou-texthooker-websocketUrl` and fell back to the vendored default `ws://localhost:6677`. In environments where the regular websocket was skipped or the annotation websocket should have been used, the page stayed on `Connecting...` and never received lines.
|
||||
|
||||
Fix: added a shared `resolveTexthookerWebsocketUrl(...)` helper for websocket selection, reused it in both app-ready startup and CLI texthooker context wiring, and threaded the resolved websocket URL through `handleCliCommand` into `Texthooker.start(...)`.
|
||||
|
||||
Verification: `bun run typecheck`; focused Bun tests for texthooker bootstrap, startup, CLI command handling, and CLI context wiring; browser-level repro against a throwaway source-backed texthooker server confirmed the page bootstraps `ws://127.0.0.1:6678`, connects successfully, and renders live sample lines (`テスト一`, `テスト二`).
|
||||
|
||||
Docs: no user-facing behavior change beyond restoring the intended existing behavior, so no docs update was required.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Restored texthooker live line delivery for the CLI/startup path that launched the page without a resolved websocket URL. Shared websocket URL resolution between app-ready startup and CLI texthooker context, forwarded that URL into `Texthooker.start(...)`, added regression coverage for the CLI path, and verified both by focused tests and a browser-level throwaway server that connected on `ws://127.0.0.1:6678` and rendered live sample lines.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
id: TASK-257
|
||||
title: Fix texthooker-only mode startup to initialize websocket pipeline
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-30 06:15'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- bug
|
||||
- texthooker
|
||||
- websocket
|
||||
- startup
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 159500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Investigate and fix `--texthooker` / `subminer texthooker` startup so it launches the texthooker page without the overlay window but still initializes the runtime pieces required for live subtitle delivery. Today texthooker-only mode serves the page yet skips mpv client and websocket startup, leaving the page pointed at `ws://127.0.0.1:6678` with no listener behind it.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 `--texthooker` mode starts the texthooker page without opening the overlay window and still initializes the websocket path needed for live subtitle delivery.
|
||||
- [x] #2 Texthooker-only startup creates the mpv/websocket runtime needed for the configured annotation or subtitle websocket feed.
|
||||
- [x] #3 Regression coverage fails before the fix and passes after the fix for texthooker-only startup.
|
||||
- [x] #4 Docs/help text remain accurate for texthooker-only behavior; update docs only if wording needs correction.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Replace the existing texthooker-only startup regression test so it asserts websocket/mpv startup still happens while overlay window initialization stays skipped.
|
||||
2. Remove or narrow the early texthooker-only short-circuit in app-ready startup so runtime config, mpv client, subtitle websocket, and annotation websocket still initialize.
|
||||
3. Run focused tests plus a local process check proving `--texthooker` now opens the websocket listener expected by the served page.
|
||||
4. Update task notes/final summary with the live-process root cause (`--texthooker` serving HTML on 5174 with no 6678 listener).
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Live-process repro on the user's machine: `ps` showed the active process as `/tmp/.mount_SubMin.../SubMiner --texthooker --port 5174`. `lsof` showed 5174 listening but no listener on 6678/6677, while `curl http://127.0.0.1:5174/` confirmed the served page was correctly bootstrapped to `ws://127.0.0.1:6678`. That proved the remaining failure was startup mode, not page injection.
|
||||
|
||||
Root cause: `runAppReadyRuntime(...)` had an early `texthookerOnlyMode` return that reloaded config and handled initial args, but skipped `createMpvClient()`, subtitle websocket startup, annotation websocket startup, subtitle timing tracker creation, and the later texthooker-only branch that only skips the overlay window.
|
||||
|
||||
Fix: removed the early texthooker-only short-circuit so texthooker-only mode now runs the normal startup pipeline, then falls through to the existing `Texthooker-only mode enabled; skipping overlay window.` branch.
|
||||
|
||||
Verification: `bun run typecheck`; focused Bun tests for app-ready startup, startup bootstrap, CLI texthooker startup, and CLI context wiring. Existing local live-binary repro still reflects the old mounted AppImage until rebuilt/restarted. Current-binary workaround is to launch normal startup / `--start --texthooker` instead of plain `--texthooker`.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Fixed the second texthooker regression: plain `--texthooker` mode was serving the page but skipping mpv/websocket initialization, so the page pointed at `ws://127.0.0.1:6678` with no listener. Removed the early texthooker-only startup return, kept the later overlay-skip behavior, updated the startup regression test to require websocket/mpv initialization in texthooker-only mode, and re-verified with typecheck plus focused test coverage.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
id: TASK-258
|
||||
title: Stop plugin auto-start from spawning separate texthooker helper
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-30 06:25'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- bug
|
||||
- texthooker
|
||||
- launcher
|
||||
- plugin
|
||||
- startup
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 158500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Change the mpv/plugin auto-start path so normal SubMiner startup owns texthooker and websocket startup inside the main `--start` app instance. Keep standalone `subminer texthooker` / plain `--texthooker` available for explicit external use, but stop the plugin from spawning a second helper subprocess during regular auto-start.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Plugin auto-start includes texthooker on the main `--start` command when texthooker is enabled.
|
||||
- [x] #2 Plugin auto-start no longer spawns a separate standalone `--texthooker` helper subprocess during normal startup.
|
||||
- [x] #3 Regression coverage fails before the fix and passes after the fix for the plugin auto-start path.
|
||||
- [x] #4 Standalone external `subminer texthooker` / plain `--texthooker` entrypoints remain available for explicit helper use.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Flip the mpv/plugin start-gate regression so enabled texthooker is folded into the main `--start` command and standalone helper subprocesses are rejected.
|
||||
2. Update plugin process command construction so `start` includes `--texthooker` when enabled and the separate helper-launch path becomes a no-op for normal auto-start.
|
||||
3. Run plugin Lua regressions, adjacent launcher tests, and typecheck to verify behavior and preserve explicit standalone `--texthooker` entrypoints.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Design approved by user: normal in-app startup should own texthooker/websocket; `texthookerOnlyMode` should stay explicit external-only.
|
||||
|
||||
Root cause path: mpv/plugin auto-start in `plugin/subminer/process.lua` launched `binary_path --start ...` and then separately spawned `binary_path --texthooker --port ...`. That created the standalone helper process observed live (`SubMiner --texthooker --port 5174`) instead of relying on the normal app instance.
|
||||
|
||||
Fix: `build_command_args('start', overrides)` now appends `--texthooker` when texthooker is enabled, and the old helper-launch path is reduced to a no-op so normal auto-start remains single-process.
|
||||
|
||||
Verification: `lua scripts/test-plugin-start-gate.lua`, `lua scripts/test-plugin-process-start-retries.lua`, `bun test launcher/mpv.test.ts launcher/commands/playback-command.test.ts launcher/config/args-normalizer.test.ts`, and `bun run typecheck`. Standalone launcher/app entrypoints for explicit `subminer texthooker` / plain `--texthooker` were left untouched.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Stopped the mpv/plugin auto-start path from spawning a second standalone texthooker helper. Texthooker now rides on the main `--start` app instance for normal startup, with Lua regressions updated to require `--texthooker` on the main start command and reject separate helper subprocesses. Explicit standalone `subminer texthooker` / plain `--texthooker` entrypoints remain available.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
id: TASK-259
|
||||
title: Fix integrated --start --texthooker startup skipping texthooker server
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-30 06:48'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- bug
|
||||
- texthooker
|
||||
- startup
|
||||
dependencies: []
|
||||
priority: high
|
||||
ordinal: 157500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Integrated overlay startup with `--start --texthooker` currently takes the minimal-startup path because startup mode flags treat any `args.texthooker` as texthooker-only. That skips app-ready texthooker service startup, so no server binds on port 5174 during normal SubMiner playback launches.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 `--start --texthooker` uses full app-ready startup instead of minimal texthooker-only startup
|
||||
- [x] #2 Integrated playback launch starts the texthooker server on the configured/default port
|
||||
- [x] #3 Regression tests cover the startup-mode classification and integrated startup behavior
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Narrowed texthooker-only startup classification so integrated `--start --texthooker` no longer takes the minimal-startup path. Added CLI arg regression coverage, rebuilt the AppImage, installed it to `~/.local/bin/SubMiner.AppImage` with a timestamped backup, restarted against `/tmp/subminer-socket`, and verified listeners on 5174/6677/6678 plus browser connection state `Connected with ws://127.0.0.1:6678`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
id: TASK-260
|
||||
title: >-
|
||||
Fix macOS overlay subtitle sidebar passthrough without requiring a subtitle
|
||||
hover cycle
|
||||
status: Done
|
||||
assignee:
|
||||
- '@codex'
|
||||
created_date: '2026-03-31 00:58'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- bug
|
||||
- macos
|
||||
- overlay
|
||||
- subtitle-sidebar
|
||||
- passthrough
|
||||
dependencies: []
|
||||
references:
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/renderer/modals/subtitle-sidebar.ts
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/renderer/overlay-mouse-ignore.ts
|
||||
- /Users/sudacode/projects/japanese/SubMiner/src/renderer/handlers/mouse.ts
|
||||
- /Users/sudacode/projects/japanese/SubMiner/src/main/overlay-runtime.ts
|
||||
- >-
|
||||
/Users/sudacode/projects/japanese/SubMiner/src/core/services/overlay-visibility.ts
|
||||
documentation:
|
||||
- docs/workflow/verification.md
|
||||
priority: high
|
||||
ordinal: 156500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
On macOS, opening the overlay-layout subtitle sidebar should allow click-through outside the sidebar immediately. Users should not need to first hover subtitle content before passthrough/click-through starts working, including when no subtitle line is currently visible.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 With the overlay-layout subtitle sidebar open on macOS, areas outside the sidebar pass clicks through immediately after open without requiring a prior subtitle hover.
|
||||
- [x] #2 When no subtitle line is currently visible, opening the subtitle sidebar still leaves non-sidebar overlay regions click-through on macOS.
|
||||
- [x] #3 Regression coverage exercises the first-open/idle passthrough path so overlay interactivity does not depend on a later hover cycle.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Add/adjust focused overlay visibility regressions for the tracked macOS visible overlay so the default idle state stays click-through instead of forcing mouse interaction.
|
||||
2. Update main-process visible overlay visibility sync to keep the tracked macOS overlay passive by default and let renderer hover/sidebar state opt into interaction.
|
||||
3. Run focused verification for overlay visibility and any dependent runtime tests, then update task notes/criteria/final summary with the confirmed outcome.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Investigation points to a main-process override on macOS: renderer sidebar open path already requests mouse passthrough outside the panel, but visible-overlay visibility sync still hard-sets the tracked overlay window interactive on macOS (`mouse-ignore:false`). Window-tracker focus/visibility resync can therefore undo renderer passthrough until a later hover cycle re-applies it.
|
||||
|
||||
Added a failing regression in `src/core/services/overlay-visibility.test.ts` showing the tracked macOS visible overlay was still forced interactive by main-process visibility sync (`mouse-ignore:false`) instead of staying forwarded click-through.
|
||||
|
||||
Updated `src/core/services/overlay-visibility.ts` so tracked macOS visible overlays now default to `setIgnoreMouseEvents(true, { forward: true })`, matching the renderer-side passthrough model and preventing window-tracker/focus resync from undoing idle sidebar clickthrough.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Fixed the macOS subtitle-sidebar passthrough regression by changing tracked visible-overlay startup/visibility sync to stay click-through by default in the main process. Previously `updateVisibleOverlayVisibility` forced the macOS overlay window interactive, which could override renderer sidebar passthrough until a later hover cycle repaired it. Added a regression in `src/core/services/overlay-visibility.test.ts` and verified with `bun test src/core/services/overlay-visibility.test.ts`, `bun test src/renderer/modals/subtitle-sidebar.test.ts src/renderer/handlers/mouse.test.ts`, and `bun run typecheck`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
id: TASK-261
|
||||
title: Fix immersion tracker SQLite timestamp truncation
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-31 01:45'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- immersion-tracker
|
||||
- sqlite
|
||||
- bug
|
||||
dependencies: []
|
||||
references:
|
||||
- src/core/services/immersion-tracker
|
||||
priority: medium
|
||||
ordinal: 179500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Current-epoch millisecond values are being truncated by the libsql driver when bound as numeric parameters, which corrupts session, telemetry, lifetime, and rollup timestamps.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Current-epoch millisecond timestamps persist correctly in session, telemetry, lifetime, and rollup tables
|
||||
- [x] #2 Startup backfill and destroy/finalize flows keep retained sessions and lifetime summaries consistent
|
||||
- [x] #3 Regression tests cover the destroyed-session, startup backfill, and distinct-day/distinct-video lifetime semantics
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
2026-03-31 assessment: epoch-ms timestamp writes now route through `toDbMs()` / `toDbTimestamp()` in `src/core/services/immersion-tracker/query-shared.ts`, which avoids libsql numeric-parameter truncation by binding BigInt/string values before they hit SQLite. The fix is wired through the session, storage/telemetry, lifetime, and rollup-maintenance paths in `src/core/services/immersion-tracker/session.ts`, `src/core/services/immersion-tracker/storage.ts`, `src/core/services/immersion-tracker/lifetime.ts`, and `src/core/services/immersion-tracker/maintenance.ts`.
|
||||
|
||||
Acceptance coverage is present: `bun test src/core/services/immersion-tracker-service.test.ts` passed with explicit regressions for destroy/finalize persistence, startup backfill when retained sessions exist but lifetime tables are empty, startup reconciliation of stale active sessions, `rebuildLifetimeSummaries`, and distinct-day / distinct-video lifetime semantics. `bun test src/core/services/immersion-tracker/time.test.ts src/core/services/immersion-tracker/maintenance.test.ts` also passed.
|
||||
|
||||
Remaining action item before close: fix the two `src/main/runtime/stats-cli-command.test.ts` cleanup-lifetime assertions that currently use Bun-misparsed underscored millisecond literals (`1_710_000_000_000` evaluates to `-2147483648` under Bun 1.3.11), rerun that verification lane, then write the final summary and mark the task Done.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
id: TASK-262
|
||||
title: Fix duplicate AniList post-watch updates for watched episodes
|
||||
status: Done
|
||||
assignee:
|
||||
- codex
|
||||
created_date: '2026-03-31 19:03'
|
||||
updated_date: '2026-03-31 19:37'
|
||||
labels:
|
||||
- bug
|
||||
- anilist
|
||||
dependencies: []
|
||||
ordinal: 155500
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Watching an episode can currently produce two AniList activity updates for the same episode. The duplicate happens when the post-watch flow drains a queued retry for the current episode and then proceeds to run the live post-watch update for that same media/episode in the same pass. User report says this reproduces both when crossing the watched threshold naturally and when using the mark-watched keybinding. Fix the duplicate so one successful watch produces at most one AniList progress update for a given mediaKey/episode pair.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 A watched episode triggers at most one AniList post-watch progress update for a given media key and episode during a single post-watch pass, even if that episode already exists in the retry queue.
|
||||
- [x] #2 Both watched-threshold and manual mark-watched flows are protected by regression coverage for the duplicate-update case.
|
||||
- [x] #3 Relevant user-visible change note is added if required by repo policy.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
<!-- SECTION:PLAN:BEGIN -->
|
||||
1. Reproduce the duplicate in a unit test around `createMaybeRunAnilistPostWatchUpdateHandler` by simulating a ready retry for the same `mediaKey::episode` the live path would also submit.
|
||||
2. Fix the handler so that after processing a queued retry, it does not perform a second live update when the retry already satisfied the current attempt key.
|
||||
3. Run focused AniList runtime tests and adjacent immersion tests to confirm both threshold-driven and manual mark-watched entry points stay covered through the shared post-watch path.
|
||||
<!-- SECTION:PLAN:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Added a regression in `src/main/runtime/anilist-post-watch.test.ts` for the case where `processNextAnilistRetryUpdate()` already satisfies the current `mediaKey::episode` before the live path runs.
|
||||
|
||||
Updated `createMaybeRunAnilistPostWatchUpdateHandler` to re-check `hasAttemptedUpdateKey(attemptKey)` immediately after draining the retry queue and short-circuit before a second live AniList submission.
|
||||
|
||||
Verification: `bun test src/main/runtime/anilist-post-watch.test.ts src/main/runtime/anilist-post-watch-main-deps.test.ts`; `bun test src/core/services/immersion-tracker-service.test.ts --test-name-pattern 'recordPlaybackPosition marks watched at 85% completion|markActiveVideoWatched'`; `bun run typecheck`; `bun run changelog:lint`.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Fixed duplicate AniList post-watch submissions by short-circuiting the live update path when a ready retry item already handled the current `mediaKey::episode` in the same pass. Added a focused regression test for the retry-plus-live duplicate scenario and a changelog fragment documenting the fix.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
id: TASK-263
|
||||
title: Reuse pre-add duplicate IDs for generic Kiku field grouping
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-31 20:44'
|
||||
updated_date: '2026-03-31 20:48'
|
||||
labels:
|
||||
- anki
|
||||
- kiku
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Avoid the extra post-add duplicate lookup on the generic sentence-card creation path by capturing duplicate note IDs before add and reusing that result for Kiku field grouping. Keep Yomitan semantics aligned where practical so duplicate selection is consistent across mining paths.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Generic sentence-card creation captures duplicate note IDs before add and reuses them for Kiku field grouping instead of running the existing post-add duplicate finder
|
||||
- [x] #2 Duplicate selection remains deterministic when multiple matching notes exist
|
||||
- [x] #3 Regression tests cover the generic path duplicate reuse behavior and preserve existing non-Kiku behavior
|
||||
- [x] #4 Internal docs/config comments are updated if the behavior or operator-facing semantics changed
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
No docs update was required because this is internal duplicate-selection plumbing and does not change user-facing config surface.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Generic sentence-card creation now captures exact duplicate note IDs before add when Kiku field grouping is enabled and stores that context by created note ID. Manual field grouping reuses the tracked duplicate IDs first and deterministically picks the most recent matching note, falling back to the legacy duplicate finder only when no tracked context exists. Verified with bun test src/anki-integration/duplicate.test.ts src/anki-integration/card-creation.test.ts src/anki-integration/field-grouping.test.ts and bun run typecheck.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
id: TASK-263.1
|
||||
title: Reuse Yomitan popup duplicate IDs in SubMiner bridge
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-03-31 22:15'
|
||||
updated_date: '2026-03-31 22:21'
|
||||
labels:
|
||||
- anki
|
||||
- kiku
|
||||
- yomitan
|
||||
dependencies: []
|
||||
parent_task_id: TASK-263
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Thread Yomitan popup/search duplicate note IDs through the existing SubMiner bridge so Kiku/manual grouping can reuse the same duplicate context that already drives the Add duplicate button. Implement and test against the vendored Yomitan copy first; do not rely on upstreamed fork changes yet.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Vendored Yomitan bridge returns duplicate note IDs for popup/search mining when available
|
||||
- [x] #2 SubMiner consumes the bridged duplicate IDs and prefers them for Kiku/manual grouping on the Yomitan mining path
|
||||
- [x] #3 Regression tests cover the popup/search bridge payload and duplicate-id reuse behavior
|
||||
- [x] #4 No commit is made for vendored Yomitan-only changes in this repo state
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
Vendored files changed locally for validation only: vendor/subminer-yomitan/ext/js/display/display-anki.js, vendor/subminer-yomitan/ext/js/comm/api.js, vendor/subminer-yomitan/ext/js/comm/anki-connect.js, vendor/subminer-yomitan/ext/js/background/backend.js. Do not commit those vendor changes in this repo; port them to the fork instead.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Vendored Yomitan popup/search mining now precomputes duplicate note IDs, sends them to the SubMiner Anki proxy as private addNote metadata, and still returns note/duplicate data through the parser bridge. The proxy strips the private metadata before forwarding to upstream AnkiConnect, associates the duplicate IDs with the created note before auto-enrichment begins, and SubMiner also records the bridge result as a secondary cache path. Verified with bun test src/anki-integration/duplicate.test.ts src/anki-integration/card-creation.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/anki-connect-proxy.test.ts src/core/services/tokenizer/yomitan-parser-runtime.test.ts and bun run typecheck.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: TASK-263.2
|
||||
title: >-
|
||||
Keep Yomitan popup responsive during background add and pause/close before
|
||||
Kiku modal
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-01 00:42'
|
||||
updated_date: '2026-04-01 02:35'
|
||||
labels:
|
||||
- anki
|
||||
- yomitan
|
||||
- kiku
|
||||
- ux
|
||||
dependencies: []
|
||||
parent_task_id: TASK-263
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Make Yomitan popup add run in background without blocking popup responsiveness. Before opening Kiku field-grouping modal, pause MPV and close the Yomitan popup/parser window if open.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Definition of Done
|
||||
<!-- DOD:BEGIN -->
|
||||
- [x] #1 Popup save path returns immediately and prevents duplicate submits
|
||||
- [x] #2 Field-grouping modal request pauses MPV and closes Yomitan popup window first
|
||||
- [x] #3 Regression tests cover async save dispatch and main-side pause/close hook
|
||||
<!-- DOD:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
2026-03-31: Removed the custom pending label/gray save-button presentation from vendored Yomitan. Background add still runs asynchronously with the internal pending-save guard, so duplicate clicks are ignored while the button keeps its stock appearance.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Yomitan popup save dispatches note creation/add in the background with an internal pending-save guard so repeated clicks are ignored without blocking the popup. Before opening the Kiku field-grouping modal, the renderer now closes the visible lookup popup and pauses MPV. Follow-up UX polish removed the custom pending label/gray styling so the save button keeps Yomitan’s stock presentation while the background action runs.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: TASK-264
|
||||
title: Replace axios with native fetch across the project
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-04-01 00:44'
|
||||
labels: []
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Remove axios from the codebase and migrate all project HTTP requests to the platform fetch API, preserving existing request behavior and error handling where applicable.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 No production code paths import or depend on axios.
|
||||
- [ ] #2 All existing HTTP requests use fetch or a project-local abstraction built on fetch.
|
||||
- [ ] #3 Request behavior remains functionally equivalent for headers, query params, bodies, status handling, and abort/error cases that are currently supported.
|
||||
- [ ] #4 Tests are updated or added to cover the migrated request flows.
|
||||
- [ ] #5 Documentation is updated if any request semantics or setup steps change.
|
||||
- [ ] #6 axios is removed from project dependencies if it is no longer needed.
|
||||
<!-- AC:END -->
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
id: TASK-265
|
||||
title: Add remote backend for immersion tracking and stats (prefer Postgres)
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-04-01 00:47'
|
||||
labels: []
|
||||
dependencies: []
|
||||
references:
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker-service.ts
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker/storage.ts
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/src/core/services/immersion-tracker/sqlite.ts
|
||||
- /home/sudacode/projects/japanese/SubMiner/src/stats-daemon-runner.ts
|
||||
- /home/sudacode/projects/japanese/SubMiner/src/core/services/stats-server.ts
|
||||
- /home/sudacode/projects/japanese/SubMiner/src/main/boot/services.ts
|
||||
- /home/sudacode/projects/japanese/SubMiner/package.json
|
||||
documentation:
|
||||
- /home/sudacode/projects/japanese/SubMiner/docs/architecture/README.md
|
||||
- >-
|
||||
/home/sudacode/projects/japanese/SubMiner/docs/architecture/stats-trends-data-flow.md
|
||||
- /home/sudacode/projects/japanese/SubMiner/README.md
|
||||
- /home/sudacode/projects/japanese/SubMiner/config.example.jsonc
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Enable immersion tracking/stats to use a remote authoritative backend so multiple devices can share the same history.
|
||||
|
||||
Current state: `ImmersionTrackerService` opens a local `immersion.sqlite` file from the app data/config path, `stats-daemon-runner` points at that same local file, and `config.example.jsonc` only exposes `immersionTracking.dbPath` for a local path override. The stats API/dashboard reads from the same tracker service and assumes the local database is the source of truth.
|
||||
|
||||
Goal: add a remote backend option that avoids shared filesystem/database-file syncing between devices. Do not use SSH/rsync/shared network filesystem as the primary sync strategy for live multi-device use.
|
||||
|
||||
Backend choice: prefer Postgres if it can be integrated without a broad new dependency surface or destabilizing the current runtime; otherwise use the least invasive remote backend that can be shipped with the current stack and document the tradeoff clearly. Preserve the current local SQLite mode as the default/offline fallback if possible.
|
||||
|
||||
This ticket should cover the full product/architecture change: configuration, storage access, stats reads, startup/error handling, migration/bootstrap from existing local data, tests, and docs.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 The app can be configured to use a remote authoritative backend for immersion tracking instead of only a local `immersion.sqlite` file.
|
||||
- [ ] #2 The chosen backend persists tracker writes and serves the existing stats read models across app restarts.
|
||||
- [ ] #3 Two devices can point at the same remote backend without relying on a shared filesystem or raw SQLite file sync.
|
||||
- [ ] #4 Local SQLite remains supported as the default or fallback mode for offline use.
|
||||
- [ ] #5 If the remote backend is unavailable or misconfigured, startup/write paths fail with actionable errors instead of silent data loss.
|
||||
- [ ] #6 A migration or bootstrap path exists to move existing local immersion data into the remote backend or seed a new device from it.
|
||||
- [ ] #7 Config/examples/docs explain the backend choice, required connection/setup details, and any security/network assumptions.
|
||||
- [ ] #8 Tests cover backend selection plus at least one representative write/read path against the remote backend.
|
||||
<!-- AC:END -->
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
id: TASK-266
|
||||
title: Preserve paused state for configured subtitle-jump keybindings
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-01 03:19'
|
||||
updated_date: '2026-04-01 03:19'
|
||||
labels:
|
||||
- renderer
|
||||
- mpv
|
||||
- keybindings
|
||||
- regression
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Regression: configured overlay keybindings that forward raw mpv subtitle-jump commands (for example previous-subtitle on H) can resume playback when invoked while paused. Keyboard-driven edge jumps already preserve paused state; configured keybindings should match that behavior.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Configured subtitle-jump keybindings preserve paused playback state after backward seek
|
||||
- [x] #2 Existing keyboard-driven subtitle navigation behavior remains unchanged
|
||||
- [x] #3 Regression test covers paused configured subtitle-jump keybinding handling
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Configured overlay keybindings that forward `sub-seek` commands now re-check paused state and reapply pause after the seek when playback was already paused. This aligns raw configured subtitle-jump keybindings with the existing keyboard-driven edge-jump behavior and adds regression coverage for the paused backward-seek case.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
id: TASK-267
|
||||
title: Port validated Yomitan popup changes to fork and resync submodule
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-01 03:30'
|
||||
updated_date: '2026-04-01 03:33'
|
||||
labels:
|
||||
- yomitan
|
||||
- submodule
|
||||
- git
|
||||
- integration
|
||||
dependencies: []
|
||||
priority: high
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Take the locally validated Yomitan popup/bridge changes from the vendored copy, apply them to the standalone `../subminer-yomitan` fork, verify the fork, push the fork commit, then reset the vendored working tree in SubMiner and update the submodule pointer to the pushed fork commit.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 Standalone `../subminer-yomitan` contains the validated popup/bridge changes and passes the relevant regression test
|
||||
- [x] #2 The fork commit is pushed to its configured remote branch
|
||||
- [x] #3 SubMiner vendored Yomitan working tree is reset and the submodule pointer is updated to the pushed fork commit
|
||||
<!-- AC:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Applied the validated popup/bridge changes from the vendored Yomitan copy into `../subminer-yomitan`, added the focused async-save regression test there, installed fork deps, and verified with `npx vitest run test/display-anki-save.test.js`. Committed the fork changes as `feat: preserve async popup save state and duplicate metadata`, rebased onto the updated remote `main`, and pushed commit `69620abc` to `origin/main`. Then reset the vendored submodule working tree in SubMiner, checked it out at `69620abc`, and left the superproject with the submodule pointer updated from `3c9ee577` to `69620abc`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
id: TASK-268
|
||||
title: 'Address CodeRabbit review action items for PR #38'
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-04-01 05:35'
|
||||
updated_date: '2026-04-01 06:07'
|
||||
labels:
|
||||
- pr-review
|
||||
- coderabbit
|
||||
dependencies: []
|
||||
references:
|
||||
- 'https://github.com/ksyasuda/SubMiner/pull/38'
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Review unresolved CodeRabbit feedback on PR #38 and implement the actionable fixes without regressing duplicate grouping or popup behavior.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [x] #1 All unresolved actionable CodeRabbit review comments on PR #38 are triaged and either fixed in code or explicitly identified as non-actionable or ambiguous.
|
||||
- [x] #2 Code changes preserve duplicate grouping and popup flow behavior covered by existing or added regression tests.
|
||||
- [x] #3 Relevant local verification for the affected areas passes.
|
||||
<!-- AC:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
2026-04-01: Reopened for follow-up CodeRabbit round after commit 233bde58. Remaining actionable items: guard maxMatches <= 0 in duplicate exact-match helper and strengthen the duplicate tracking test fixture to prove deduplication as well as sorting.
|
||||
|
||||
2026-04-01: Follow-up round addressed locally. Added guard for maxMatches <= 0 in duplicate exact-match scanning and strengthened the pre-add duplicate tracking test fixture to prove deduplication as well as sorting.
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
||||
## Final Summary
|
||||
|
||||
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||
Addressed all unresolved actionable CodeRabbit comments on PR #38. Fixed duplicate tracking so empty duplicate lists are not persisted after sentence-card creation, sanitized Yomitan add-note noteId values to accept only positive integers, preserved paused playback for configured subtitle-seek keybindings when pause state is unknown, and short-circuited duplicate exact-match scanning for single-result lookups. Added regression tests for each case and verified with `bun test` on the affected suites plus `bun run typecheck`, `bun run test:fast`, `bun run test:env`, `bun run build`, and `bun run test:smoke:dist`.
|
||||
|
||||
Follow-up CodeRabbit round addressed locally: `findExactDuplicateNoteIds()` now returns early when `maxMatches <= 0`, and the sentence-card duplicate tracking regression test now uses a repeated duplicate ID to assert deduplication plus sorting. Re-verified with targeted duplicate/card tests, `bun run typecheck`, and `bun run test:fast`.
|
||||
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||
5
changes/253-aur-release-best-effort.md
Normal file
5
changes/253-aur-release-best-effort.md
Normal file
@@ -0,0 +1,5 @@
|
||||
type: internal
|
||||
area: release
|
||||
|
||||
- Retried AUR clone and push operations in the tagged release workflow.
|
||||
- Kept GitHub Releases green when AUR publish flakes and needs manual follow-up.
|
||||
5
changes/259-texthooker-integrated-startup.md
Normal file
5
changes/259-texthooker-integrated-startup.md
Normal file
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: main
|
||||
|
||||
- Keep integrated `--start --texthooker` launches on the full app-ready startup path so the texthooker page and websocket servers start together during normal playback startup.
|
||||
- Stop the mpv/plugin auto-start flow from spawning a separate standalone texthooker helper during normal `subminer <video>` launches.
|
||||
5
changes/260-playlist-browser.md
Normal file
5
changes/260-playlist-browser.md
Normal file
@@ -0,0 +1,5 @@
|
||||
type: added
|
||||
area: overlay
|
||||
|
||||
- Added a playlist browser overlay modal for browsing sibling video files and the live mpv queue during playback.
|
||||
- Added the default `Ctrl+Alt+P` keybinding to open the playlist browser and manage queue order without leaving playback.
|
||||
5
changes/261-macos-overlay-passthrough.md
Normal file
5
changes/261-macos-overlay-passthrough.md
Normal file
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Keep tracked macOS visible overlays click-through by default so subtitle sidebar passthrough works immediately without requiring a subtitle hover cycle first.
|
||||
- Add regression coverage for the macOS visible-overlay passthrough default.
|
||||
5
changes/262-anilist-post-watch-dedupe.md
Normal file
5
changes/262-anilist-post-watch-dedupe.md
Normal file
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: anilist
|
||||
|
||||
- Stop AniList post-watch from sending a second progress update when the current episode was already satisfied by a ready retry item in the same watch-completion pass.
|
||||
- Add regression coverage for the retry-queue plus live-update duplicate path.
|
||||
6
changes/267-yomitan-kiku-popup.md
Normal file
6
changes/267-yomitan-kiku-popup.md
Normal file
@@ -0,0 +1,6 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed Kiku duplicate grouping to reuse duplicate note IDs from both generic sentence-card creation and Yomitan popup mining instead of running extra duplicate scans after add.
|
||||
- Fixed the Yomitan popup mining flow to add cards in the background while keeping the stock popup progress feedback, then pause playback and close the lookup popup before the Kiku merge modal opens.
|
||||
- Fixed configured subtitle-jump keybindings so backward and forward subtitle seeks keep playback paused when invoked from a paused state.
|
||||
@@ -471,6 +471,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
||||
| `Space` | `["cycle", "pause"]` | Toggle pause |
|
||||
| `KeyJ` | `["cycle", "sid"]` | Cycle primary subtitle track |
|
||||
| `Shift+KeyJ` | `["cycle", "secondary-sid"]` | Cycle secondary subtitle track |
|
||||
| `Ctrl+Alt+KeyP` | `["__playlist-browser-open"]` | Open playlist browser |
|
||||
| `Ctrl+Alt+KeyC` | `["__youtube-picker-open"]` | Open the manual YouTube subtitle picker |
|
||||
| `ArrowRight` | `["seek", 5]` | Seek forward 5 seconds |
|
||||
| `ArrowLeft` | `["seek", -5]` | Seek backward 5 seconds |
|
||||
@@ -507,7 +508,7 @@ See `config.example.jsonc` for detailed configuration options and more examples.
|
||||
{ "key": "Space", "command": null }
|
||||
```
|
||||
|
||||
**Special commands:** Commands prefixed with `__` are handled internally by the overlay rather than sent to mpv. `__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. `__sub-delay-next-line` shifts subtitle delay so the active line aligns to the next cue start in the active subtitle source. `__sub-delay-prev-line` shifts subtitle delay so the active line aligns to the previous cue start. `__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. `__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. `__sub-delay-next-line` shifts subtitle delay so the active line aligns to the next cue start in the active subtitle source. `__sub-delay-prev-line` shifts subtitle delay so the active line aligns to the previous cue start. `__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.)
|
||||
|
||||
@@ -968,6 +969,7 @@ To refresh roughly once per day, set:
|
||||
| `disabled` | No field grouping; duplicate cards are left as-is |
|
||||
|
||||
`deleteDuplicateInAuto` controls whether `auto` mode deletes the duplicate after merge (default: `true`). In `manual` mode, the popup asks each time whether to delete the duplicate.
|
||||
When the manual merge popup opens, SubMiner pauses playback and closes any open Yomitan popup first so the merge flow can take focus.
|
||||
|
||||
<video controls playsinline preload="metadata" poster="/assets/kiku-integration-poster.jpg" style="width: 100%; max-width: 960px;">
|
||||
<source :src="'/assets/kiku-integration.webm'" type="video/webm" />
|
||||
|
||||
@@ -40,6 +40,7 @@ These control playback and subtitle display. They require overlay window focus.
|
||||
| `Space` | Toggle mpv pause |
|
||||
| `J` | Cycle primary subtitle track |
|
||||
| `Shift+J` | Cycle secondary subtitle track |
|
||||
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
|
||||
| `ArrowRight` | Seek forward 5 seconds |
|
||||
| `ArrowLeft` | Seek backward 5 seconds |
|
||||
| `ArrowUp` | Seek forward 60 seconds |
|
||||
@@ -56,7 +57,7 @@ These control playback and subtitle display. They require overlay window focus.
|
||||
| `Right-click + drag` | Reposition subtitles (on subtitle area) |
|
||||
| `Ctrl/Cmd+A` | Append clipboard video path to mpv playlist |
|
||||
|
||||
These keybindings can be overridden or disabled via the `keybindings` config array.
|
||||
These keybindings can be overridden or disabled via the `keybindings` config array. The playlist browser opens a split overlay modal with sibling video files on the left and the live mpv playlist on the right.
|
||||
|
||||
Mouse-hover playback behavior is configured separately from shortcuts: `subtitleStyle.autoPauseVideoOnHover` defaults to `true` (pause on subtitle hover, resume on leave).
|
||||
|
||||
|
||||
@@ -295,6 +295,8 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
|
||||
`Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config.
|
||||
:::
|
||||
|
||||
Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback.
|
||||
|
||||
Hovering over subtitle text pauses mpv by default; leaving resumes it. Disable with `subtitleStyle.autoPauseVideoOnHover: false`. To also pause while the Yomitan popup is open, set `subtitleStyle.autoPauseVideoOnYomitanPopup: true`.
|
||||
|
||||
### Drag-and-Drop
|
||||
|
||||
@@ -34,4 +34,5 @@ Notes:
|
||||
- Do not tag while `changes/*.md` fragments still exist.
|
||||
- If you need to repair a published release body (for example, a prior version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
|
||||
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
|
||||
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
|
||||
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
|
||||
|
||||
@@ -34,6 +34,17 @@ function M.create(ctx)
|
||||
return options_helper.coerce_bool(raw_pause_until_ready, false)
|
||||
end
|
||||
|
||||
local function resolve_texthooker_enabled(override_value)
|
||||
if override_value ~= nil then
|
||||
return options_helper.coerce_bool(override_value, false)
|
||||
end
|
||||
local raw_texthooker_enabled = opts.texthooker_enabled
|
||||
if raw_texthooker_enabled == nil then
|
||||
raw_texthooker_enabled = opts["texthooker-enabled"]
|
||||
end
|
||||
return options_helper.coerce_bool(raw_texthooker_enabled, false)
|
||||
end
|
||||
|
||||
local function resolve_pause_until_ready_timeout_seconds()
|
||||
local raw_timeout_seconds = opts.auto_start_pause_until_ready_timeout_seconds
|
||||
if raw_timeout_seconds == nil then
|
||||
@@ -191,6 +202,11 @@ function M.create(ctx)
|
||||
else
|
||||
table.insert(args, "--hide-visible-overlay")
|
||||
end
|
||||
|
||||
local texthooker_enabled = resolve_texthooker_enabled(overrides.texthooker_enabled)
|
||||
if texthooker_enabled then
|
||||
table.insert(args, "--texthooker")
|
||||
end
|
||||
end
|
||||
|
||||
return args
|
||||
@@ -242,50 +258,10 @@ function M.create(ctx)
|
||||
return overrides
|
||||
end
|
||||
|
||||
local function build_texthooker_args()
|
||||
local args = { state.binary_path, "--texthooker", "--port", tostring(opts.texthooker_port) }
|
||||
local log_level = normalize_log_level(opts.log_level)
|
||||
if log_level ~= "info" then
|
||||
table.insert(args, "--log-level")
|
||||
table.insert(args, log_level)
|
||||
end
|
||||
return args
|
||||
end
|
||||
|
||||
local function ensure_texthooker_running(callback)
|
||||
if not opts.texthooker_enabled then
|
||||
if callback then
|
||||
callback()
|
||||
return
|
||||
end
|
||||
|
||||
if state.texthooker_running then
|
||||
callback()
|
||||
return
|
||||
end
|
||||
|
||||
local args = build_texthooker_args()
|
||||
subminer_log("info", "texthooker", "Starting texthooker process: " .. table.concat(args, " "))
|
||||
state.texthooker_running = true
|
||||
|
||||
mp.command_native_async({
|
||||
name = "subprocess",
|
||||
args = args,
|
||||
playback_only = false,
|
||||
capture_stdout = true,
|
||||
capture_stderr = true,
|
||||
}, function(success, result, error)
|
||||
if not success or (result and result.status ~= 0) then
|
||||
state.texthooker_running = false
|
||||
subminer_log(
|
||||
"warn",
|
||||
"texthooker",
|
||||
"Texthooker process exited unexpectedly: " .. (error or (result and result.stderr) or "unknown error")
|
||||
)
|
||||
end
|
||||
end)
|
||||
|
||||
-- Start overlay immediately; overlay start path retries on readiness failures.
|
||||
callback()
|
||||
end
|
||||
|
||||
local function start_overlay(overrides)
|
||||
@@ -328,10 +304,7 @@ function M.create(ctx)
|
||||
return
|
||||
end
|
||||
|
||||
local texthooker_enabled = overrides.texthooker_enabled
|
||||
if texthooker_enabled == nil then
|
||||
texthooker_enabled = opts.texthooker_enabled
|
||||
end
|
||||
local texthooker_enabled = resolve_texthooker_enabled(overrides.texthooker_enabled)
|
||||
local socket_path = overrides.socket_path or opts.socket_path
|
||||
local should_pause_until_ready = (
|
||||
overrides.auto_start_trigger == true
|
||||
@@ -530,7 +503,7 @@ function M.create(ctx)
|
||||
end
|
||||
end)
|
||||
|
||||
if opts.texthooker_enabled then
|
||||
if resolve_texthooker_enabled(nil) then
|
||||
ensure_texthooker_running(function() end)
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -51,9 +51,16 @@ function ensureSubmodulePresent() {
|
||||
}
|
||||
|
||||
function getSourceState() {
|
||||
const revision = readCommand('git', ['rev-parse', 'HEAD'], submoduleDir);
|
||||
const dirty = readCommand('git', ['status', '--short', '--untracked-files=no'], submoduleDir);
|
||||
return { revision, dirty };
|
||||
try {
|
||||
const revision = readCommand('git', ['rev-parse', 'HEAD'], submoduleDir);
|
||||
const dirty = readCommand('git', ['status', '--short', '--untracked-files=no'], submoduleDir);
|
||||
return { revision, dirty };
|
||||
} catch (error) {
|
||||
if (process.env.SUBMINER_YOMITAN_ALLOW_MISSING_GIT === '1') {
|
||||
return { revision: 'unknown', dirty: '' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isBuildCurrent(force) {
|
||||
|
||||
@@ -531,6 +531,31 @@ do
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "no",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
texthooker_enabled = "no",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
media_title = "Random Movie",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for disabled texthooker auto-start scenario: " .. tostring(err))
|
||||
fire_event(recorded, "file-loaded")
|
||||
local start_call = find_start_call(recorded.async_calls)
|
||||
assert_true(start_call ~= nil, "disabled texthooker auto-start should still issue --start command")
|
||||
assert_true(not call_has_arg(start_call, "--texthooker"), "disabled texthooker should not include --texthooker on --start")
|
||||
assert_true(find_control_call(recorded.async_calls, "--texthooker") == nil, "disabled texthooker should not issue a helper texthooker command")
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
@@ -664,8 +689,8 @@ do
|
||||
fire_event(recorded, "file-loaded")
|
||||
local start_call = find_start_call(recorded.async_calls)
|
||||
assert_true(start_call ~= nil, "auto-start should issue --start command")
|
||||
local texthooker_call = find_texthooker_call(recorded.async_calls)
|
||||
assert_true(texthooker_call ~= nil, "auto-start should issue texthooker helper command when enabled")
|
||||
assert_true(call_has_arg(start_call, "--texthooker"), "auto-start should include --texthooker on the main --start command when enabled")
|
||||
assert_true(find_control_call(recorded.async_calls, "--texthooker") == nil, "auto-start should not issue a separate texthooker helper command")
|
||||
assert_true(
|
||||
call_has_arg(start_call, "--show-visible-overlay"),
|
||||
"auto-start with visible overlay enabled should include --show-visible-overlay on --start"
|
||||
@@ -678,10 +703,6 @@ do
|
||||
find_control_call(recorded.async_calls, "--show-visible-overlay") ~= nil,
|
||||
"auto-start with visible overlay enabled should issue a separate --show-visible-overlay command"
|
||||
)
|
||||
assert_true(
|
||||
find_call_index(recorded.async_calls, start_call) < find_call_index(recorded.async_calls, texthooker_call),
|
||||
"auto-start should launch --start before separate --texthooker helper startup"
|
||||
)
|
||||
assert_true(
|
||||
not has_property_set(recorded.property_sets, "pause", true),
|
||||
"auto-start visible overlay should not force pause without explicit pause-until-ready option"
|
||||
|
||||
@@ -51,6 +51,7 @@ import { KnownWordCacheManager } from './anki-integration/known-word-cache';
|
||||
import { PollingRunner } from './anki-integration/polling';
|
||||
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
|
||||
import { findDuplicateNote as findDuplicateNoteForAnkiIntegration } from './anki-integration/duplicate';
|
||||
import { findDuplicateNoteIds as findDuplicateNoteIdsForAnkiIntegration } from './anki-integration/duplicate';
|
||||
import { CardCreationService } from './anki-integration/card-creation';
|
||||
import { FieldGroupingService } from './anki-integration/field-grouping';
|
||||
import { FieldGroupingMergeCollaborator } from './anki-integration/field-grouping-merge';
|
||||
@@ -148,6 +149,7 @@ export class AnkiIntegration {
|
||||
private aiConfig: AiConfig;
|
||||
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
||||
private noteIdRedirects = new Map<number, number>();
|
||||
private trackedDuplicateNoteIds = new Map<number, number[]>();
|
||||
|
||||
constructor(
|
||||
config: AnkiConnectConfig,
|
||||
@@ -264,6 +266,9 @@ export class AnkiIntegration {
|
||||
recordCardsAdded: (count, noteIds) => {
|
||||
this.recordCardsMinedSafely(count, noteIds, 'proxy');
|
||||
},
|
||||
trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||
this.trackDuplicateNoteIdsForNote(noteId, duplicateNoteIds);
|
||||
},
|
||||
getDeck: () => this.config.deck,
|
||||
findNotes: async (query, options) =>
|
||||
(await this.client.findNotes(query, options)) as number[],
|
||||
@@ -361,6 +366,10 @@ export class AnkiIntegration {
|
||||
trackLastAddedNoteId: (noteId) => {
|
||||
this.previousNoteIds.add(noteId);
|
||||
},
|
||||
trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||
this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]);
|
||||
},
|
||||
findDuplicateNoteIds: (expression, noteInfo) => this.findDuplicateNoteIds(expression, noteInfo),
|
||||
recordCardsMinedCallback: (count, noteIds) => {
|
||||
this.recordCardsMinedSafely(count, noteIds, 'card creation');
|
||||
},
|
||||
@@ -382,6 +391,10 @@ export class AnkiIntegration {
|
||||
extractFields: (fields) => this.extractFields(fields),
|
||||
findDuplicateNote: (expression, noteId, noteInfo) =>
|
||||
this.findDuplicateNote(expression, noteId, noteInfo),
|
||||
getTrackedDuplicateNoteIds: (noteId) =>
|
||||
this.trackedDuplicateNoteIds.has(noteId)
|
||||
? [...(this.trackedDuplicateNoteIds.get(noteId) ?? [])]
|
||||
: null,
|
||||
hasAllConfiguredFields: (noteInfo, configuredFieldNames) =>
|
||||
this.hasAllConfiguredFields(noteInfo, configuredFieldNames),
|
||||
processNewCard: (noteId, options) => this.processNewCard(noteId, options),
|
||||
@@ -1042,6 +1055,10 @@ export class AnkiIntegration {
|
||||
);
|
||||
}
|
||||
|
||||
trackDuplicateNoteIdsForNote(noteId: number, duplicateNoteIds: number[]): void {
|
||||
this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]);
|
||||
}
|
||||
|
||||
private async findDuplicateNote(
|
||||
expression: string,
|
||||
excludeNoteId: number,
|
||||
@@ -1065,6 +1082,28 @@ export class AnkiIntegration {
|
||||
});
|
||||
}
|
||||
|
||||
private async findDuplicateNoteIds(
|
||||
expression: string,
|
||||
noteInfo: NoteInfo,
|
||||
): Promise<number[]> {
|
||||
return findDuplicateNoteIdsForAnkiIntegration(expression, -1, noteInfo, {
|
||||
findNotes: async (query, options) => (await this.client.findNotes(query, options)) as unknown,
|
||||
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,
|
||||
getDeck: () => this.config.deck,
|
||||
getWordFieldCandidates: () => this.getConfiguredWordFieldCandidates(),
|
||||
resolveFieldName: (info, preferredName) => this.resolveNoteFieldName(info, preferredName),
|
||||
logInfo: (message) => {
|
||||
log.info(message);
|
||||
},
|
||||
logDebug: (message) => {
|
||||
log.debug(message);
|
||||
},
|
||||
logWarn: (message, error) => {
|
||||
log.warn(message, (error as Error).message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private getPreferredSentenceAudioFieldName(): string {
|
||||
const sentenceCardConfig = this.getEffectiveSentenceCardConfig();
|
||||
return sentenceCardConfig.audioField || 'SentenceAudio';
|
||||
|
||||
@@ -19,6 +19,7 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
|
||||
media: {
|
||||
imageType: 'avif',
|
||||
syncAnimatedImageToWordAudio: true,
|
||||
audioPadding: 0,
|
||||
},
|
||||
},
|
||||
noteInfo: {
|
||||
@@ -49,6 +50,46 @@ test('resolveAnimatedImageLeadInSeconds sums configured word audio durations for
|
||||
assert.equal(leadInSeconds, 1.25);
|
||||
});
|
||||
|
||||
test('resolveAnimatedImageLeadInSeconds adds sentence audio padding to word audio duration', async () => {
|
||||
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
|
||||
config: {
|
||||
fields: {
|
||||
audio: 'ExpressionAudio',
|
||||
},
|
||||
media: {
|
||||
imageType: 'avif',
|
||||
syncAnimatedImageToWordAudio: true,
|
||||
audioPadding: 0.5,
|
||||
},
|
||||
},
|
||||
noteInfo: {
|
||||
noteId: 42,
|
||||
fields: {
|
||||
ExpressionAudio: {
|
||||
value: '[sound:word.mp3][sound:alt.ogg]',
|
||||
},
|
||||
},
|
||||
},
|
||||
resolveConfiguredFieldName: (noteInfo, ...preferredNames) => {
|
||||
for (const preferredName of preferredNames) {
|
||||
if (!preferredName) continue;
|
||||
const resolved = Object.keys(noteInfo.fields).find(
|
||||
(fieldName) => fieldName.toLowerCase() === preferredName.toLowerCase(),
|
||||
);
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
retrieveMediaFileBase64: async (filename) =>
|
||||
filename === 'word.mp3' ? 'd29yZA==' : filename === 'alt.ogg' ? 'YWx0' : '',
|
||||
probeAudioDurationSeconds: async (_buffer, filename) =>
|
||||
filename === 'word.mp3' ? 0.41 : filename === 'alt.ogg' ? 0.84 : null,
|
||||
logWarn: () => undefined,
|
||||
});
|
||||
|
||||
assert.equal(leadInSeconds, 1.75);
|
||||
});
|
||||
|
||||
test('resolveAnimatedImageLeadInSeconds falls back to zero when sync is disabled', async () => {
|
||||
const leadInSeconds = await resolveAnimatedImageLeadInSeconds({
|
||||
config: {
|
||||
|
||||
@@ -39,6 +39,14 @@ function shouldSyncAnimatedImageToWordAudio(config: Pick<AnkiConnectConfig, 'med
|
||||
return config.media?.imageType === 'avif' && config.media?.syncAnimatedImageToWordAudio !== false;
|
||||
}
|
||||
|
||||
function resolveSentenceAudioStartOffsetSeconds(config: Pick<AnkiConnectConfig, 'media'>): number {
|
||||
const configuredPadding = config.media?.audioPadding;
|
||||
if (typeof configuredPadding === 'number' && Number.isFinite(configuredPadding)) {
|
||||
return configuredPadding;
|
||||
}
|
||||
return DEFAULT_ANKI_CONNECT_CONFIG.media.audioPadding;
|
||||
}
|
||||
|
||||
export async function probeAudioDurationSeconds(
|
||||
buffer: Buffer,
|
||||
filename: string,
|
||||
@@ -127,5 +135,5 @@ export async function resolveAnimatedImageLeadInSeconds<TNoteInfo extends NoteIn
|
||||
totalLeadInSeconds += durationSeconds;
|
||||
}
|
||||
|
||||
return totalLeadInSeconds;
|
||||
return totalLeadInSeconds + resolveSentenceAudioStartOffsetSeconds(config);
|
||||
}
|
||||
|
||||
@@ -324,6 +324,123 @@ test('proxy fallback-enqueues latest note for addNote responses without note IDs
|
||||
assert.deepEqual(recordedCards, [1]);
|
||||
});
|
||||
|
||||
test('proxy tracks duplicate note ids from addNote request metadata before enrichment', async () => {
|
||||
const processed: number[] = [];
|
||||
const tracked: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
|
||||
const proxy = new AnkiConnectProxyServer({
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async (noteId) => {
|
||||
processed.push(noteId);
|
||||
},
|
||||
trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||
tracked.push({ noteId, duplicateNoteIds });
|
||||
},
|
||||
logInfo: () => undefined,
|
||||
logWarn: () => undefined,
|
||||
logError: () => undefined,
|
||||
});
|
||||
|
||||
(
|
||||
proxy as unknown as {
|
||||
maybeEnqueueFromRequest: (request: Record<string, unknown>, responseBody: Buffer) => void;
|
||||
}
|
||||
).maybeEnqueueFromRequest(
|
||||
{
|
||||
action: 'addNote',
|
||||
params: {
|
||||
note: {},
|
||||
subminerDuplicateNoteIds: [11, -1, 40, 11, 25],
|
||||
},
|
||||
},
|
||||
Buffer.from(JSON.stringify({ result: 42, error: null }), 'utf8'),
|
||||
);
|
||||
|
||||
await waitForCondition(() => processed.length === 1);
|
||||
assert.deepEqual(tracked, [{ noteId: 42, duplicateNoteIds: [11, 25, 40] }]);
|
||||
assert.deepEqual(processed, [42]);
|
||||
});
|
||||
|
||||
test('proxy strips SubMiner duplicate metadata before forwarding upstream addNote request', async () => {
|
||||
let upstreamBody = '';
|
||||
const upstream = http.createServer(async (req, res) => {
|
||||
upstreamBody = await new Promise<string>((resolve) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
});
|
||||
res.statusCode = 200;
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end(JSON.stringify({ result: 42, error: null }));
|
||||
});
|
||||
upstream.listen(0, '127.0.0.1');
|
||||
await once(upstream, 'listening');
|
||||
const upstreamAddress = upstream.address();
|
||||
assert.ok(upstreamAddress && typeof upstreamAddress === 'object');
|
||||
const upstreamPort = upstreamAddress.port;
|
||||
|
||||
const tracked: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
|
||||
const proxy = new AnkiConnectProxyServer({
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async () => undefined,
|
||||
trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||
tracked.push({ noteId, duplicateNoteIds });
|
||||
},
|
||||
logInfo: () => undefined,
|
||||
logWarn: () => undefined,
|
||||
logError: () => undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
proxy.start({
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
upstreamUrl: `http://127.0.0.1:${upstreamPort}`,
|
||||
});
|
||||
|
||||
const proxyServer = (
|
||||
proxy as unknown as {
|
||||
server: http.Server | null;
|
||||
}
|
||||
).server;
|
||||
assert.ok(proxyServer);
|
||||
if (!proxyServer.listening) {
|
||||
await once(proxyServer, 'listening');
|
||||
}
|
||||
const proxyAddress = proxyServer.address();
|
||||
assert.ok(proxyAddress && typeof proxyAddress === 'object');
|
||||
const proxyPort = proxyAddress.port;
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${proxyPort}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: 'addNote',
|
||||
version: 6,
|
||||
params: {
|
||||
note: {
|
||||
deckName: 'Mining',
|
||||
modelName: 'Sentence',
|
||||
fields: { Expression: '食べる' },
|
||||
},
|
||||
subminerDuplicateNoteIds: [18, 7],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), { result: 42, error: null });
|
||||
await waitForCondition(() => tracked.length === 1);
|
||||
assert.equal(upstreamBody.includes('subminerDuplicateNoteIds'), false);
|
||||
assert.deepEqual(tracked, [{ noteId: 42, duplicateNoteIds: [7, 18] }]);
|
||||
} finally {
|
||||
proxy.stop();
|
||||
upstream.close();
|
||||
await once(upstream, 'close');
|
||||
}
|
||||
});
|
||||
|
||||
test('proxy returns addNote response without waiting for background enrichment', async () => {
|
||||
const processed: number[] = [];
|
||||
let releaseProcessing: (() => void) | undefined;
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface AnkiConnectProxyServerDeps {
|
||||
shouldAutoUpdateNewCards: () => boolean;
|
||||
processNewCard: (noteId: number) => Promise<void>;
|
||||
recordCardsAdded?: (count: number, noteIds: number[]) => void;
|
||||
trackAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
|
||||
getDeck?: () => string | undefined;
|
||||
findNotes?: (
|
||||
query: string,
|
||||
@@ -161,6 +162,7 @@ export class AnkiConnectProxyServer {
|
||||
}
|
||||
|
||||
try {
|
||||
const forwardedBody = req.method === 'POST' ? this.getForwardRequestBody(rawBody, requestJson) : rawBody;
|
||||
const targetUrl = new URL(req.url || '/', upstreamUrl).toString();
|
||||
const contentType =
|
||||
typeof req.headers['content-type'] === 'string'
|
||||
@@ -169,7 +171,7 @@ export class AnkiConnectProxyServer {
|
||||
const upstreamResponse = await this.client.request<ArrayBuffer>({
|
||||
url: targetUrl,
|
||||
method: req.method,
|
||||
data: req.method === 'POST' ? rawBody : undefined,
|
||||
data: req.method === 'POST' ? forwardedBody : undefined,
|
||||
headers: {
|
||||
'content-type': contentType,
|
||||
},
|
||||
@@ -219,6 +221,8 @@ export class AnkiConnectProxyServer {
|
||||
return;
|
||||
}
|
||||
|
||||
this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult);
|
||||
|
||||
const noteIds =
|
||||
action === 'multi'
|
||||
? this.collectMultiResultIds(requestJson, responseResult)
|
||||
@@ -231,6 +235,77 @@ export class AnkiConnectProxyServer {
|
||||
this.enqueueNotes(noteIds);
|
||||
}
|
||||
|
||||
private maybeTrackDuplicateNoteIds(
|
||||
requestJson: Record<string, unknown>,
|
||||
action: string,
|
||||
responseResult: unknown,
|
||||
): void {
|
||||
if (action !== 'addNote') {
|
||||
return;
|
||||
}
|
||||
const duplicateNoteIds = this.getRequestDuplicateNoteIds(requestJson);
|
||||
if (duplicateNoteIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const noteId = this.collectSingleResultId(responseResult)[0];
|
||||
if (!noteId) {
|
||||
return;
|
||||
}
|
||||
this.deps.trackAddedDuplicateNoteIds?.(noteId, duplicateNoteIds);
|
||||
}
|
||||
|
||||
private getForwardRequestBody(
|
||||
rawBody: Buffer,
|
||||
requestJson: Record<string, unknown> | null,
|
||||
): Buffer {
|
||||
if (!requestJson) {
|
||||
return rawBody;
|
||||
}
|
||||
|
||||
const sanitized = this.sanitizeRequestJson(requestJson);
|
||||
if (sanitized === requestJson) {
|
||||
return rawBody;
|
||||
}
|
||||
|
||||
return Buffer.from(JSON.stringify(sanitized), 'utf8');
|
||||
}
|
||||
|
||||
private sanitizeRequestJson(requestJson: Record<string, unknown>): Record<string, unknown> {
|
||||
const action =
|
||||
typeof requestJson.action === 'string' ? requestJson.action : String(requestJson.action ?? '');
|
||||
if (action !== 'addNote') {
|
||||
return requestJson;
|
||||
}
|
||||
|
||||
const params =
|
||||
requestJson.params && typeof requestJson.params === 'object'
|
||||
? (requestJson.params as Record<string, unknown>)
|
||||
: null;
|
||||
if (!params || !Object.prototype.hasOwnProperty.call(params, 'subminerDuplicateNoteIds')) {
|
||||
return requestJson;
|
||||
}
|
||||
|
||||
const nextParams = { ...params };
|
||||
delete nextParams.subminerDuplicateNoteIds;
|
||||
return {
|
||||
...requestJson,
|
||||
params: nextParams,
|
||||
};
|
||||
}
|
||||
|
||||
private getRequestDuplicateNoteIds(requestJson: Record<string, unknown>): number[] {
|
||||
const params =
|
||||
requestJson.params && typeof requestJson.params === 'object'
|
||||
? (requestJson.params as Record<string, unknown>)
|
||||
: null;
|
||||
const rawNoteIds = Array.isArray(params?.subminerDuplicateNoteIds)
|
||||
? params.subminerDuplicateNoteIds
|
||||
: [];
|
||||
return [...new Set(rawNoteIds.filter((entry): entry is number => {
|
||||
return typeof entry === 'number' && Number.isInteger(entry) && entry > 0;
|
||||
}))].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
private requestIncludesAddAction(action: string, requestJson: Record<string, unknown>): boolean {
|
||||
if (action === 'addNote' || action === 'addNotes') {
|
||||
return true;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user