mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
Improve startup dictionary sync UX and default playback keybindings
- Add default `f` fullscreen overlay binding and switch default AniSkip skip key to `Tab` - Make character-dictionary auto-sync non-blocking at startup with tokenization gating for Yomitan mutations - Add ordered startup OSD progress (checking/generating/updating/importing), refresh current subtitle on sync completion, and extend regression tests
This commit is contained in:
@@ -0,0 +1,80 @@
|
|||||||
|
---
|
||||||
|
id: TASK-131
|
||||||
|
title: Make default overlay fullscreen and AniSkip end-jump keybindings easier to reach
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- codex
|
||||||
|
created_date: '2026-03-09 00:00'
|
||||||
|
updated_date: '2026-03-09 00:30'
|
||||||
|
labels:
|
||||||
|
- enhancement
|
||||||
|
- overlay
|
||||||
|
- mpv
|
||||||
|
- aniskip
|
||||||
|
dependencies: []
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Make two default keyboard actions easier to hit during playback: add `f` as the built-in overlay fullscreen toggle, and make AniSkip's default intro-end jump use `Tab`.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Default overlay keybindings include `KeyF` mapped to mpv fullscreen toggle.
|
||||||
|
- [x] #2 Default AniSkip hint/button key defaults to `Tab` and the plugin registers that binding.
|
||||||
|
- [x] #3 Automated regression coverage exists for both default bindings.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
|
||||||
|
1. Add a failing TypeScript regression proving default overlay keybindings include fullscreen on `KeyF`.
|
||||||
|
2. Add a failing Lua/plugin regression proving AniSkip defaults to `Tab`, updates the OSD hint text, and registers the expected keybinding.
|
||||||
|
3. Patch the default keybinding/config values with minimal behavior changes and keep fallback binding behavior intentional.
|
||||||
|
4. Run focused tests plus touched verification commands, then record results and a short changelog fragment.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
|
||||||
|
Added `KeyF -> ['cycle', 'fullscreen']` to the built-in overlay keybindings in `src/config/definitions/shared.ts`.
|
||||||
|
|
||||||
|
Changed the mpv plugin AniSkip default button key from `y-k` to `TAB` in both the runtime default options and the shipped `plugin/subminer.conf`. The AniSkip OSD hint now also falls back to `TAB` when no explicit key is configured.
|
||||||
|
|
||||||
|
Adjusted `plugin/subminer/ui.lua` fallback registration so the legacy `y-k` binding is only added for custom non-default AniSkip bindings, instead of always shadowing the new default.
|
||||||
|
|
||||||
|
Extended regression coverage:
|
||||||
|
|
||||||
|
- `src/config/definitions/domain-registry.test.ts` now asserts the default fullscreen binding on `KeyF`.
|
||||||
|
- `scripts/test-plugin-start-gate.lua` now isolates plugin runs correctly, records keybinding/observer registration, and asserts the default AniSkip keybinding/prompt behavior for `TAB`.
|
||||||
|
|
||||||
|
Verification:
|
||||||
|
|
||||||
|
- `bun test src/config/definitions/domain-registry.test.ts`
|
||||||
|
- `bun run test:config:src`
|
||||||
|
- `lua scripts/test-plugin-start-gate.lua`
|
||||||
|
- `bun run changelog:lint`
|
||||||
|
- `bun run typecheck`
|
||||||
|
|
||||||
|
Known unrelated verification gap:
|
||||||
|
|
||||||
|
- `bun run test:plugin:src` still fails in `scripts/test-plugin-binary-windows.lua` on this Linux host (`windows env override should resolve .exe suffix`), outside the keybinding changes in this task.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Default overlay playback now has an easier fullscreen toggle on `f`, and AniSkip's default intro-end jump now uses `Tab`. The mpv plugin hint text and registration logic were updated to match the new default, while keeping legacy `y-k` fallback behavior limited to custom non-default bindings.
|
||||||
|
|
||||||
|
Regression coverage was added for both defaults, and the plugin test harness now resets plugin bootstrap state between scenarios so keybinding assertions can run reliably.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
id: TASK-141
|
||||||
|
title: Refresh current subtitle after character dictionary sync completes
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-09 00:00'
|
||||||
|
updated_date: '2026-03-09 00:55'
|
||||||
|
labels:
|
||||||
|
- dictionary
|
||||||
|
- overlay
|
||||||
|
- bug
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/character-dictionary-auto-sync.ts
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/main.ts
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
When character dictionary auto-sync finishes after startup tokenization, invalidate cached subtitle tokenization and refresh the current subtitle so character-name highlighting catches up without waiting for the next subtitle line.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Successful character dictionary sync exposes a completion hook for main runtime follow-up.
|
||||||
|
- [x] #2 Main runtime clears Yomitan parser caches and refreshes the current subtitle after sync completion.
|
||||||
|
- [x] #3 Regression coverage verifies the sync completion callback fires on successful sync.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Observed on Bunny Girl Senpai startup: autoplay/tokenization became ready around 8s, but snapshot/import/state write completed roughly 31s after launch, leaving the current subtitle tokenized without the newly imported character dictionary. Fixed by adding an auto-sync completion hook that clears parser caches and refreshes the current subtitle.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
id: TASK-142
|
||||||
|
title: Show character dictionary auto-sync progress on OSD
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-09 01:10'
|
||||||
|
updated_date: '2026-03-09 01:10'
|
||||||
|
labels:
|
||||||
|
- dictionary
|
||||||
|
- overlay
|
||||||
|
- ux
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/character-dictionary-auto-sync.ts
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/character-dictionary-auto-sync-notifications.ts
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/main.ts
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
When character dictionary auto-sync runs for a newly opened anime, surface progress so users know why character-name lookup/highlighting is temporarily unavailable via the mpv OSD without desktop notification popups.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Character dictionary auto-sync emits progress events for syncing, importing, ready, and failure states.
|
||||||
|
- [x] #2 Main runtime routes those progress events through OSD notifications without desktop notifications.
|
||||||
|
- [x] #3 Regression coverage verifies progress events and notification routing behavior.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
|
||||||
|
OSD now shows auto-sync phase changes while the dictionary updates. Desktop notifications were removed for this path to avoid startup popup spam.
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
id: TASK-143
|
||||||
|
title: Keep character dictionary auto-sync non-blocking during startup
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-09 01:45'
|
||||||
|
updated_date: '2026-03-09 01:45'
|
||||||
|
labels:
|
||||||
|
- dictionary
|
||||||
|
- startup
|
||||||
|
- performance
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/main.ts
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/character-dictionary-auto-sync.ts
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/current-media-tokenization-gate.ts
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Keep character dictionary auto-sync running in parallel during startup without delaying playback. Only tokenization readiness should gate playback; character dictionary import/settings updates should wait until tokenization is already ready and then refresh annotations afterward.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Character dictionary snapshot/build work can run immediately during startup.
|
||||||
|
- [x] #2 Yomitan dictionary mutation work waits until current-media tokenization is ready.
|
||||||
|
- [x] #3 Regression coverage verifies auto-sync builds before the gate and only mutates Yomitan after the gate resolves.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
|
||||||
|
Added a small current-media tokenization gate in main runtime. Media changes reset the gate, the first tokenization-ready event marks it ready, and auto-sync now waits on that gate only before Yomitan dictionary inspection/import/settings updates. Snapshot generation and merged ZIP build still run immediately in parallel.
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
id: TASK-144
|
||||||
|
title: Sequence startup OSD notifications for tokenization, annotations, and character dictionary sync
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-09 10:40'
|
||||||
|
updated_date: '2026-03-09 10:40'
|
||||||
|
labels:
|
||||||
|
- startup
|
||||||
|
- overlay
|
||||||
|
- ux
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/main.ts
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/startup-osd-sequencer.ts
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/subtitle-tokenization-main-deps.ts
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/character-dictionary-auto-sync-notifications.ts
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Keep startup OSD progress ordered. While tokenization is still pending, only show the tokenization loading message. After tokenization becomes ready, show annotation loading if annotation warmup still remains. Only surface character dictionary auto-sync progress after annotation loading clears, and only if the dictionary work is still active.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Character dictionary progress stays hidden while tokenization startup loading is still active.
|
||||||
|
- [x] #2 Annotation loading OSD appears after tokenization readiness and before any later character dictionary progress.
|
||||||
|
- [x] #3 Regression coverage verifies buffered dictionary progress/failure ordering during startup.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
|
||||||
|
Added a small startup OSD sequencer in main runtime. Annotation warmup OSD now flows through that sequencer, and character dictionary sync notifications buffer until tokenization plus annotation loading clear. Buffered `ready` updates are dropped if dictionary progress finished before it ever became visible, while buffered failures still surface after annotation loading completes.
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
id: TASK-145
|
||||||
|
title: Show checking and generation OSD for character dictionary auto-sync
|
||||||
|
status: Done
|
||||||
|
assignee: []
|
||||||
|
created_date: '2026-03-09 11:20'
|
||||||
|
updated_date: '2026-03-09 11:20'
|
||||||
|
labels:
|
||||||
|
- dictionary
|
||||||
|
- overlay
|
||||||
|
- ux
|
||||||
|
dependencies: []
|
||||||
|
references:
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/main.ts
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/character-dictionary-auto-sync.ts
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/main/runtime/startup-osd-sequencer.ts
|
||||||
|
- /home/sudacode/projects/japanese/SubMiner/src/main/character-dictionary-runtime.ts
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
|
||||||
|
Surface an immediate startup OSD that the character dictionary is being checked, and show a distinct generating message only when the current AniList media actually needs a fresh snapshot build instead of reusing a cached one.
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
|
||||||
|
- [x] #1 Auto-sync emits a `checking` progress event before snapshot resolution completes.
|
||||||
|
- [x] #2 Auto-sync emits `generating` only for snapshot cache misses and keeps `updating`/`importing` as later phases.
|
||||||
|
- [x] #3 Startup OSD sequencing still prioritizes tokenization then annotation loading before buffered dictionary progress.
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
|
||||||
|
Character dictionary auto-sync now emits `Checking character dictionary...` as soon as the AniList media is resolved, then emits `Generating character dictionary...` only when the snapshot layer misses and a real rebuild begins. Cached snapshots skip the generating phase and continue straight into the later update/import flow.
|
||||||
|
|
||||||
|
Wired those progress callbacks through the character-dictionary runtime boundary, updated the startup OSD sequencer to treat checking/generating as dictionary-progress phases with the same tokenization and annotation precedence, and added regression coverage for cache-hit vs cache-miss behavior plus buffered startup ordering.
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
4
changes/task-131.md
Normal file
4
changes/task-131.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Added `f` as the default overlay fullscreen toggle and changed the default AniSkip intro-jump key to `Tab`.
|
||||||
4
changes/task-133.md
Normal file
4
changes/task-133.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: dictionary
|
||||||
|
|
||||||
|
- Aligned AniList character dictionary generation more closely with the upstream reference by preserving duplicate shared names across characters, skipping characters without native Japanese names, restoring richer character info fields, and using upstream-style role mapping plus hint-aware kanji readings.
|
||||||
4
changes/task-141.md
Normal file
4
changes/task-141.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: dictionary
|
||||||
|
|
||||||
|
- Refresh the current subtitle after character dictionary auto-sync completes so newly imported character names highlight on the active line instead of waiting for the next subtitle change.
|
||||||
4
changes/task-142.md
Normal file
4
changes/task-142.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: dictionary
|
||||||
|
|
||||||
|
- Show character dictionary auto-sync progress on the mpv OSD without sending desktop notifications.
|
||||||
4
changes/task-143.md
Normal file
4
changes/task-143.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: dictionary
|
||||||
|
|
||||||
|
- Keep character dictionary auto-sync non-blocking during startup by letting snapshot/build work run in parallel and delaying only the Yomitan import/settings phase until current-media tokenization is already ready.
|
||||||
4
changes/task-144.md
Normal file
4
changes/task-144.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: startup
|
||||||
|
|
||||||
|
- Ordered startup OSD messages so tokenization loads first, annotation loading appears next if still pending, and character dictionary sync progress waits until annotation loading finishes.
|
||||||
4
changes/task-145.md
Normal file
4
changes/task-145.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Show `Checking character dictionary...` during startup auto-sync and only show `Generating character dictionary...` when a fresh character-dictionary snapshot rebuild is actually needed.
|
||||||
@@ -66,7 +66,7 @@ aniskip_show_button=yes
|
|||||||
aniskip_button_text=You can skip by pressing %s
|
aniskip_button_text=You can skip by pressing %s
|
||||||
|
|
||||||
# Keybinding to execute intro skip when button is visible.
|
# Keybinding to execute intro skip when button is visible.
|
||||||
aniskip_button_key=y-k
|
aniskip_button_key=TAB
|
||||||
|
|
||||||
# OSD hint duration in seconds (shown during first 3s of intro).
|
# OSD hint duration in seconds (shown during first 3s of intro).
|
||||||
aniskip_button_duration=3
|
aniskip_button_duration=3
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
local M = {}
|
local M = {}
|
||||||
local matcher = require("aniskip_match")
|
local matcher = require("aniskip_match")
|
||||||
|
local DEFAULT_ANISKIP_BUTTON_KEY = "TAB"
|
||||||
|
|
||||||
function M.create(ctx)
|
function M.create(ctx)
|
||||||
local mp = ctx.mp
|
local mp = ctx.mp
|
||||||
@@ -464,7 +465,7 @@ function M.create(ctx)
|
|||||||
local intro_start = state.aniskip.intro_start or -1
|
local intro_start = state.aniskip.intro_start or -1
|
||||||
local hint_window_end = intro_start + 3
|
local hint_window_end = intro_start + 3
|
||||||
if in_intro and not state.aniskip.prompt_shown and now >= intro_start and now < hint_window_end then
|
if in_intro and not state.aniskip.prompt_shown and now >= intro_start and now < hint_window_end then
|
||||||
local key = opts.aniskip_button_key ~= "" and opts.aniskip_button_key or "y-k"
|
local key = opts.aniskip_button_key ~= "" and opts.aniskip_button_key or DEFAULT_ANISKIP_BUTTON_KEY
|
||||||
local message = string.format(opts.aniskip_button_text, key)
|
local message = string.format(opts.aniskip_button_text, key)
|
||||||
mp.osd_message(message, tonumber(opts.aniskip_button_duration) or 3)
|
mp.osd_message(message, tonumber(opts.aniskip_button_duration) or 3)
|
||||||
state.aniskip.prompt_shown = true
|
state.aniskip.prompt_shown = true
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
local M = {}
|
local M = {}
|
||||||
|
local DEFAULT_ANISKIP_BUTTON_KEY = "TAB"
|
||||||
|
|
||||||
local function normalize_socket_path_option(socket_path, default_socket_path)
|
local function normalize_socket_path_option(socket_path, default_socket_path)
|
||||||
if type(default_socket_path) ~= "string" then
|
if type(default_socket_path) ~= "string" then
|
||||||
@@ -42,7 +43,7 @@ function M.load(options_lib, default_socket_path)
|
|||||||
aniskip_payload = "",
|
aniskip_payload = "",
|
||||||
aniskip_show_button = true,
|
aniskip_show_button = true,
|
||||||
aniskip_button_text = "You can skip by pressing %s",
|
aniskip_button_text = "You can skip by pressing %s",
|
||||||
aniskip_button_key = "y-k",
|
aniskip_button_key = DEFAULT_ANISKIP_BUTTON_KEY,
|
||||||
aniskip_button_duration = 3,
|
aniskip_button_duration = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
local M = {}
|
local M = {}
|
||||||
|
local DEFAULT_ANISKIP_BUTTON_KEY = "TAB"
|
||||||
|
local LEGACY_ANISKIP_BUTTON_KEY = "y-k"
|
||||||
|
|
||||||
function M.create(ctx)
|
function M.create(ctx)
|
||||||
local mp = ctx.mp
|
local mp = ctx.mp
|
||||||
@@ -89,8 +91,11 @@ function M.create(ctx)
|
|||||||
aniskip.skip_intro_now()
|
aniskip.skip_intro_now()
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
if opts.aniskip_button_key ~= "y-k" then
|
if
|
||||||
mp.add_key_binding("y-k", "subminer-skip-intro-fallback", function()
|
opts.aniskip_button_key ~= LEGACY_ANISKIP_BUTTON_KEY
|
||||||
|
and opts.aniskip_button_key ~= DEFAULT_ANISKIP_BUTTON_KEY
|
||||||
|
then
|
||||||
|
mp.add_key_binding(LEGACY_ANISKIP_BUTTON_KEY, "subminer-skip-intro-fallback", function()
|
||||||
aniskip.skip_intro_now()
|
aniskip.skip_intro_now()
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ local function run_plugin_scenario(config)
|
|||||||
sync_calls = {},
|
sync_calls = {},
|
||||||
script_messages = {},
|
script_messages = {},
|
||||||
events = {},
|
events = {},
|
||||||
|
observers = {},
|
||||||
|
key_bindings = {},
|
||||||
osd = {},
|
osd = {},
|
||||||
logs = {},
|
logs = {},
|
||||||
property_sets = {},
|
property_sets = {},
|
||||||
@@ -41,6 +43,13 @@ local function run_plugin_scenario(config)
|
|||||||
return config.chapter_list or {}
|
return config.chapter_list or {}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function mp.get_property_number(name)
|
||||||
|
if name == "time-pos" then
|
||||||
|
return config.time_pos
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
function mp.get_script_directory()
|
function mp.get_script_directory()
|
||||||
return "plugin/subminer"
|
return "plugin/subminer"
|
||||||
end
|
end
|
||||||
@@ -123,7 +132,13 @@ local function run_plugin_scenario(config)
|
|||||||
recorded.script_messages[name] = fn
|
recorded.script_messages[name] = fn
|
||||||
end
|
end
|
||||||
|
|
||||||
function mp.add_key_binding(_keys, _name, _fn) end
|
function mp.add_key_binding(keys, name, fn)
|
||||||
|
recorded.key_bindings[#recorded.key_bindings + 1] = {
|
||||||
|
keys = keys,
|
||||||
|
name = name,
|
||||||
|
fn = fn,
|
||||||
|
}
|
||||||
|
end
|
||||||
function mp.register_event(name, fn)
|
function mp.register_event(name, fn)
|
||||||
if recorded.events[name] == nil then
|
if recorded.events[name] == nil then
|
||||||
recorded.events[name] = {}
|
recorded.events[name] = {}
|
||||||
@@ -131,7 +146,12 @@ local function run_plugin_scenario(config)
|
|||||||
recorded.events[name][#recorded.events[name] + 1] = fn
|
recorded.events[name][#recorded.events[name] + 1] = fn
|
||||||
end
|
end
|
||||||
function mp.add_hook(_name, _prio, _fn) end
|
function mp.add_hook(_name, _prio, _fn) end
|
||||||
function mp.observe_property(_name, _kind, _fn) end
|
function mp.observe_property(name, _kind, fn)
|
||||||
|
if recorded.observers[name] == nil then
|
||||||
|
recorded.observers[name] = {}
|
||||||
|
end
|
||||||
|
recorded.observers[name][#recorded.observers[name] + 1] = fn
|
||||||
|
end
|
||||||
function mp.osd_message(message, _duration)
|
function mp.osd_message(message, _duration)
|
||||||
recorded.osd[#recorded.osd + 1] = message
|
recorded.osd[#recorded.osd + 1] = message
|
||||||
end
|
end
|
||||||
@@ -213,6 +233,26 @@ local function run_plugin_scenario(config)
|
|||||||
package.loaded["mp.msg"] = nil
|
package.loaded["mp.msg"] = nil
|
||||||
package.loaded["mp.options"] = nil
|
package.loaded["mp.options"] = nil
|
||||||
package.loaded["mp.utils"] = nil
|
package.loaded["mp.utils"] = nil
|
||||||
|
package.loaded["binary"] = nil
|
||||||
|
package.loaded["bootstrap"] = nil
|
||||||
|
package.loaded["environment"] = nil
|
||||||
|
package.loaded["hover"] = nil
|
||||||
|
package.loaded["init"] = nil
|
||||||
|
package.loaded["lifecycle"] = nil
|
||||||
|
package.loaded["log"] = nil
|
||||||
|
package.loaded["messages"] = nil
|
||||||
|
package.loaded["options"] = nil
|
||||||
|
package.loaded["process"] = nil
|
||||||
|
package.loaded["state"] = nil
|
||||||
|
package.loaded["ui"] = nil
|
||||||
|
package.loaded["aniskip"] = nil
|
||||||
|
_G.__subminer_plugin_bootstrapped = nil
|
||||||
|
local original_package_config = package.config
|
||||||
|
if config.platform == "windows" then
|
||||||
|
package.config = "\\\n;\n?\n!\n-\n"
|
||||||
|
else
|
||||||
|
package.config = "/\n;\n?\n!\n-\n"
|
||||||
|
end
|
||||||
|
|
||||||
package.preload["mp"] = function()
|
package.preload["mp"] = function()
|
||||||
return mp
|
return mp
|
||||||
@@ -246,6 +286,7 @@ local function run_plugin_scenario(config)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local ok, err = pcall(dofile, "plugin/subminer/main.lua")
|
local ok, err = pcall(dofile, "plugin/subminer/main.lua")
|
||||||
|
package.config = original_package_config
|
||||||
if not ok then
|
if not ok then
|
||||||
return nil, err, recorded
|
return nil, err, recorded
|
||||||
end
|
end
|
||||||
@@ -412,6 +453,22 @@ local function fire_event(recorded, name)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function fire_observer(recorded, name, value)
|
||||||
|
local listeners = recorded.observers[name] or {}
|
||||||
|
for _, listener in ipairs(listeners) do
|
||||||
|
listener(name, value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function has_key_binding(recorded, keys, name)
|
||||||
|
for _, binding in ipairs(recorded.key_bindings or {}) do
|
||||||
|
if binding.keys == keys and binding.name == name then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
local binary_path = "/tmp/subminer-binary"
|
local binary_path = "/tmp/subminer-binary"
|
||||||
|
|
||||||
do
|
do
|
||||||
@@ -516,6 +573,38 @@ do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
do
|
||||||
|
local recorded, err = run_plugin_scenario({
|
||||||
|
process_list = "",
|
||||||
|
option_overrides = {
|
||||||
|
binary_path = binary_path,
|
||||||
|
auto_start = "no",
|
||||||
|
},
|
||||||
|
media_title = "Sample Show S01E01",
|
||||||
|
time_pos = 13,
|
||||||
|
mal_lookup_stdout = "__MAL_FOUND__",
|
||||||
|
aniskip_stdout = "__ANISKIP_FOUND__",
|
||||||
|
files = {
|
||||||
|
[binary_path] = true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert_true(recorded ~= nil, "plugin failed to load for default AniSkip keybinding scenario: " .. tostring(err))
|
||||||
|
assert_true(
|
||||||
|
has_key_binding(recorded, "TAB", "subminer-skip-intro"),
|
||||||
|
"default AniSkip keybinding should register TAB"
|
||||||
|
)
|
||||||
|
assert_true(
|
||||||
|
not has_key_binding(recorded, "y-k", "subminer-skip-intro-fallback"),
|
||||||
|
"default AniSkip keybinding should not also register legacy y-k fallback"
|
||||||
|
)
|
||||||
|
recorded.script_messages["subminer-aniskip-refresh"]()
|
||||||
|
fire_observer(recorded, "time-pos", 13)
|
||||||
|
assert_true(
|
||||||
|
has_osd_message(recorded.osd, "You can skip by pressing TAB"),
|
||||||
|
"default AniSkip prompt should mention TAB"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
local recorded, err = run_plugin_scenario({
|
local recorded, err = run_plugin_scenario({
|
||||||
process_list = "",
|
process_list = "",
|
||||||
|
|||||||
@@ -73,3 +73,10 @@ test('default keybindings include primary and secondary subtitle track cycling o
|
|||||||
assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']);
|
assert.deepEqual(keybindingMap.get('KeyJ'), ['cycle', 'sid']);
|
||||||
assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-sid']);
|
assert.deepEqual(keybindingMap.get('Shift+KeyJ'), ['cycle', 'secondary-sid']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('default keybindings include fullscreen on F', () => {
|
||||||
|
const keybindingMap = new Map(
|
||||||
|
DEFAULT_KEYBINDINGS.map((binding) => [binding.key, binding.command]),
|
||||||
|
);
|
||||||
|
assert.deepEqual(keybindingMap.get('KeyF'), ['cycle', 'fullscreen']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export const SPECIAL_COMMANDS = {
|
|||||||
|
|
||||||
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
export const DEFAULT_KEYBINDINGS: NonNullable<ResolvedConfig['keybindings']> = [
|
||||||
{ key: 'Space', command: ['cycle', 'pause'] },
|
{ key: 'Space', command: ['cycle', 'pause'] },
|
||||||
|
{ key: 'KeyF', command: ['cycle', 'fullscreen'] },
|
||||||
{ key: 'KeyJ', command: ['cycle', 'sid'] },
|
{ key: 'KeyJ', command: ['cycle', 'sid'] },
|
||||||
{ key: 'Shift+KeyJ', command: ['cycle', 'secondary-sid'] },
|
{ key: 'Shift+KeyJ', command: ['cycle', 'secondary-sid'] },
|
||||||
{ key: 'ArrowRight', command: ['seek', 5] },
|
{ key: 'ArrowRight', command: ['seek', 5] },
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ test('macOS keeps visible overlay hidden while tracker is not ready and emits on
|
|||||||
assert.ok(!calls.includes('show'));
|
assert.ok(!calls.includes('show'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('non-macOS keeps fallback visible overlay behavior when tracker is not ready', () => {
|
test('tracked non-macOS overlay stays hidden while tracker is not ready', () => {
|
||||||
const { window, calls } = createMainWindowRecorder();
|
const { window, calls } = createMainWindowRecorder();
|
||||||
let trackerWarning = false;
|
let trackerWarning = false;
|
||||||
const tracker: WindowTrackerStub = {
|
const tracker: WindowTrackerStub = {
|
||||||
@@ -116,7 +116,48 @@ test('non-macOS keeps fallback visible overlay behavior when tracker is not read
|
|||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
assert.equal(trackerWarning, true);
|
assert.equal(trackerWarning, true);
|
||||||
assert.ok(calls.includes('update-bounds'));
|
assert.ok(calls.includes('hide'));
|
||||||
|
assert.ok(!calls.includes('update-bounds'));
|
||||||
|
assert.ok(!calls.includes('show'));
|
||||||
|
assert.ok(!calls.includes('focus'));
|
||||||
|
assert.ok(!calls.includes('osd'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('untracked non-macOS overlay keeps fallback visible behavior when no tracker exists', () => {
|
||||||
|
const { window, calls } = createMainWindowRecorder();
|
||||||
|
let trackerWarning = false;
|
||||||
|
|
||||||
|
updateVisibleOverlayVisibility({
|
||||||
|
visibleOverlayVisible: true,
|
||||||
|
mainWindow: window as never,
|
||||||
|
windowTracker: null,
|
||||||
|
trackerNotReadyWarningShown: trackerWarning,
|
||||||
|
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||||
|
trackerWarning = shown;
|
||||||
|
},
|
||||||
|
updateVisibleOverlayBounds: () => {
|
||||||
|
calls.push('update-bounds');
|
||||||
|
},
|
||||||
|
ensureOverlayWindowLevel: () => {
|
||||||
|
calls.push('ensure-level');
|
||||||
|
},
|
||||||
|
syncPrimaryOverlayWindowLayer: () => {
|
||||||
|
calls.push('sync-layer');
|
||||||
|
},
|
||||||
|
enforceOverlayLayerOrder: () => {
|
||||||
|
calls.push('enforce-order');
|
||||||
|
},
|
||||||
|
syncOverlayShortcuts: () => {
|
||||||
|
calls.push('sync-shortcuts');
|
||||||
|
},
|
||||||
|
isMacOSPlatform: false,
|
||||||
|
showOverlayLoadingOsd: () => {
|
||||||
|
calls.push('osd');
|
||||||
|
},
|
||||||
|
resolveFallbackBounds: () => ({ x: 12, y: 24, width: 640, height: 360 }),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.equal(trackerWarning, false);
|
||||||
assert.ok(calls.includes('show'));
|
assert.ok(calls.includes('show'));
|
||||||
assert.ok(calls.includes('focus'));
|
assert.ok(calls.includes('focus'));
|
||||||
assert.ok(!calls.includes('osd'));
|
assert.ok(!calls.includes('osd'));
|
||||||
|
|||||||
@@ -84,20 +84,8 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.isMacOSPlatform || args.isWindowsPlatform) {
|
|
||||||
mainWindow.hide();
|
mainWindow.hide();
|
||||||
args.syncOverlayShortcuts();
|
args.syncOverlayShortcuts();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackBounds = args.resolveFallbackBounds?.();
|
|
||||||
if (!fallbackBounds) return;
|
|
||||||
|
|
||||||
args.updateVisibleOverlayBounds(fallbackBounds);
|
|
||||||
args.syncPrimaryOverlayWindowLayer('visible');
|
|
||||||
showPassiveVisibleOverlay();
|
|
||||||
args.enforceOverlayLayerOrder();
|
|
||||||
args.syncOverlayShortcuts();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setVisibleOverlayVisible(options: {
|
export function setVisibleOverlayVisible(options: {
|
||||||
|
|||||||
41
src/main.ts
41
src/main.ts
@@ -372,6 +372,9 @@ import { createMediaRuntimeService } from './main/media-runtime';
|
|||||||
import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime';
|
import { createOverlayVisibilityRuntimeService } from './main/overlay-visibility-runtime';
|
||||||
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
|
import { createCharacterDictionaryRuntimeService } from './main/character-dictionary-runtime';
|
||||||
import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync';
|
import { createCharacterDictionaryAutoSyncRuntimeService } from './main/runtime/character-dictionary-auto-sync';
|
||||||
|
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
|
||||||
|
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
|
||||||
|
import { createStartupOsdSequencer } from './main/runtime/startup-osd-sequencer';
|
||||||
import {
|
import {
|
||||||
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
|
getPreferredYomitanAnkiServerUrl as getPreferredYomitanAnkiServerUrlRuntime,
|
||||||
shouldForceOverrideYomitanAnkiServer,
|
shouldForceOverrideYomitanAnkiServer,
|
||||||
@@ -913,6 +916,10 @@ const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntim
|
|||||||
const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsHandler());
|
const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsHandler());
|
||||||
let autoPlayReadySignalMediaPath: string | null = null;
|
let autoPlayReadySignalMediaPath: string | null = null;
|
||||||
let autoPlayReadySignalGeneration = 0;
|
let autoPlayReadySignalGeneration = 0;
|
||||||
|
const currentMediaTokenizationGate = createCurrentMediaTokenizationGate();
|
||||||
|
const startupOsdSequencer = createStartupOsdSequencer({
|
||||||
|
showOsd: (message) => showMpvOsd(message),
|
||||||
|
});
|
||||||
|
|
||||||
function maybeSignalPluginAutoplayReady(
|
function maybeSignalPluginAutoplayReady(
|
||||||
payload: SubtitleData,
|
payload: SubtitleData,
|
||||||
@@ -1324,8 +1331,13 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
|||||||
profileScope: config.profileScope,
|
profileScope: config.profileScope,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getOrCreateCurrentSnapshot: () => characterDictionaryRuntime.getOrCreateCurrentSnapshot(),
|
getOrCreateCurrentSnapshot: (targetPath, progress) =>
|
||||||
|
characterDictionaryRuntime.getOrCreateCurrentSnapshot(targetPath, progress),
|
||||||
buildMergedDictionary: (mediaIds) => characterDictionaryRuntime.buildMergedDictionary(mediaIds),
|
buildMergedDictionary: (mediaIds) => characterDictionaryRuntime.buildMergedDictionary(mediaIds),
|
||||||
|
waitForYomitanMutationReady: () =>
|
||||||
|
currentMediaTokenizationGate.waitUntilReady(
|
||||||
|
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
|
||||||
|
),
|
||||||
getYomitanDictionaryInfo: async () => {
|
getYomitanDictionaryInfo: async () => {
|
||||||
await ensureYomitanExtensionLoaded();
|
await ensureYomitanExtensionLoaded();
|
||||||
return await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
|
return await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
|
||||||
@@ -1364,6 +1376,24 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
|||||||
clearSchedule: (timer) => clearTimeout(timer),
|
clearSchedule: (timer) => clearTimeout(timer),
|
||||||
logInfo: (message) => logger.info(message),
|
logInfo: (message) => logger.info(message),
|
||||||
logWarn: (message) => logger.warn(message),
|
logWarn: (message) => logger.warn(message),
|
||||||
|
onSyncStatus: (event) => {
|
||||||
|
notifyCharacterDictionaryAutoSyncStatus(event, {
|
||||||
|
getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType,
|
||||||
|
showOsd: (message) => showMpvOsd(message),
|
||||||
|
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
|
||||||
|
startupOsdSequencer,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSyncComplete: ({ mediaId, mediaTitle, changed }) => {
|
||||||
|
if (appState.yomitanParserWindow) {
|
||||||
|
clearYomitanParserCachesForWindow(appState.yomitanParserWindow);
|
||||||
|
}
|
||||||
|
subtitleProcessingController.invalidateTokenizationCache();
|
||||||
|
subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText);
|
||||||
|
logger.info(
|
||||||
|
`[dictionary:auto-sync] refreshed current subtitle after sync (AniList ${mediaId}, changed=${changed ? 'yes' : 'no'}, title=${mediaTitle})`,
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
||||||
@@ -2673,6 +2703,8 @@ const {
|
|||||||
},
|
},
|
||||||
updateCurrentMediaPath: (path) => {
|
updateCurrentMediaPath: (path) => {
|
||||||
autoPlayReadySignalMediaPath = null;
|
autoPlayReadySignalMediaPath = null;
|
||||||
|
currentMediaTokenizationGate.updateCurrentMediaPath(path);
|
||||||
|
startupOsdSequencer.reset();
|
||||||
if (path) {
|
if (path) {
|
||||||
ensureImmersionTrackerStarted();
|
ensureImmersionTrackerStarted();
|
||||||
}
|
}
|
||||||
@@ -2793,6 +2825,10 @@ const {
|
|||||||
getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled,
|
getYomitanGroupDebugEnabled: () => appState.overlayDebugVisualizationEnabled,
|
||||||
getMecabTokenizer: () => appState.mecabTokenizer,
|
getMecabTokenizer: () => appState.mecabTokenizer,
|
||||||
onTokenizationReady: (text) => {
|
onTokenizationReady: (text) => {
|
||||||
|
currentMediaTokenizationGate.markReady(
|
||||||
|
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
|
||||||
|
);
|
||||||
|
startupOsdSequencer.markTokenizationReady();
|
||||||
maybeSignalPluginAutoplayReady({ text, tokens: null }, { forceWhilePaused: true });
|
maybeSignalPluginAutoplayReady({ text, tokens: null }, { forceWhilePaused: true });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2812,6 +2848,9 @@ const {
|
|||||||
ensureFrequencyDictionaryLookup: () =>
|
ensureFrequencyDictionaryLookup: () =>
|
||||||
frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(),
|
frequencyDictionaryRuntime.ensureFrequencyDictionaryLookup(),
|
||||||
showMpvOsd: (message: string) => showMpvOsd(message),
|
showMpvOsd: (message: string) => showMpvOsd(message),
|
||||||
|
showLoadingOsd: (message: string) => startupOsdSequencer.showAnnotationLoading(message),
|
||||||
|
showLoadedOsd: (message: string) =>
|
||||||
|
startupOsdSequencer.markAnnotationLoadingComplete(message),
|
||||||
shouldShowOsdNotification: () => {
|
shouldShowOsdNotification: () => {
|
||||||
const type = getResolvedConfig().ankiConnect.behavior.notificationType;
|
const type = getResolvedConfig().ankiConnect.behavior.notificationType;
|
||||||
return type === 'osd' || type === 'both';
|
return type === 'osd' || type === 'both';
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ test('generateForCurrentMedia emits structured-content glossary so image stays w
|
|||||||
assert.equal(roleBadgeDiv.tag, 'div');
|
assert.equal(roleBadgeDiv.tag, 'div');
|
||||||
const badge = roleBadgeDiv.content as { tag: string; content: string };
|
const badge = roleBadgeDiv.content as { tag: string; content: string };
|
||||||
assert.equal(badge.tag, 'span');
|
assert.equal(badge.tag, 'span');
|
||||||
assert.equal(badge.content, 'Side Character');
|
assert.equal(badge.content, 'Main Character');
|
||||||
|
|
||||||
const descSection = children.find(
|
const descSection = children.find(
|
||||||
(c) =>
|
(c) =>
|
||||||
@@ -695,6 +695,128 @@ test('generateForCurrentMedia adds kana aliases for romanized names when native
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('generateForCurrentMedia indexes kanji family and given names using AniList first and last hints', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
if (url === GRAPHQL_URL) {
|
||||||
|
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
||||||
|
query?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body.query?.includes('Page(perPage: 10)')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
data: {
|
||||||
|
Page: {
|
||||||
|
media: [
|
||||||
|
{
|
||||||
|
id: 37450,
|
||||||
|
episodes: 13,
|
||||||
|
title: {
|
||||||
|
romaji: 'Seishun Buta Yarou wa Bunny Girl Senpai no Yume wo Minai',
|
||||||
|
english: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
native: '青春ブタ野郎はバニーガール先輩の夢を見ない',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.query?.includes('characters(page: $page')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
data: {
|
||||||
|
Media: {
|
||||||
|
title: {
|
||||||
|
romaji: 'Seishun Buta Yarou wa Bunny Girl Senpai no Yume wo Minai',
|
||||||
|
english: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
native: '青春ブタ野郎はバニーガール先輩の夢を見ない',
|
||||||
|
},
|
||||||
|
characters: {
|
||||||
|
pageInfo: { hasNextPage: false },
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
role: 'SUPPORTING',
|
||||||
|
node: {
|
||||||
|
id: 77,
|
||||||
|
description: 'Classmate.',
|
||||||
|
image: null,
|
||||||
|
name: {
|
||||||
|
first: 'Yuuma',
|
||||||
|
full: 'Yuuma Kunimi',
|
||||||
|
last: 'Kunimi',
|
||||||
|
native: '国見佑真',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runtime = createCharacterDictionaryRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getCurrentMediaPath: () => '/tmp/bunny-girl-senpai-s01e01.mkv',
|
||||||
|
getCurrentMediaTitle: () => 'Rascal Does Not Dream of Bunny Girl Senpai - S01E01',
|
||||||
|
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||||
|
guessAnilistMediaInfo: async () => ({
|
||||||
|
title: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
episode: 1,
|
||||||
|
source: 'fallback',
|
||||||
|
}),
|
||||||
|
now: () => 1_700_000_000_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runtime.generateForCurrentMedia();
|
||||||
|
const termBank = JSON.parse(
|
||||||
|
readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'),
|
||||||
|
) as Array<
|
||||||
|
[
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
number,
|
||||||
|
Array<string | Record<string, unknown>>,
|
||||||
|
number,
|
||||||
|
string,
|
||||||
|
]
|
||||||
|
>;
|
||||||
|
|
||||||
|
const familyName = termBank.find(([term]) => term === '国見');
|
||||||
|
assert.ok(familyName, 'expected kanji family-name term from AniList hints');
|
||||||
|
assert.equal(familyName[1], 'くにみ');
|
||||||
|
|
||||||
|
const givenName = termBank.find(([term]) => term === '佑真');
|
||||||
|
assert.ok(givenName, 'expected kanji given-name term from AniList hints');
|
||||||
|
assert.equal(givenName[1], 'ゆうま');
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('generateForCurrentMedia indexes AniList alternative character names for alias lookups', async () => {
|
test('generateForCurrentMedia indexes AniList alternative character names for alias lookups', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
@@ -812,6 +934,520 @@ test('generateForCurrentMedia indexes AniList alternative character names for al
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('generateForCurrentMedia skips AniList characters without a native name when other valid characters exist', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
if (url === GRAPHQL_URL) {
|
||||||
|
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
||||||
|
query?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body.query?.includes('Page(perPage: 10)')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
data: {
|
||||||
|
Page: {
|
||||||
|
media: [
|
||||||
|
{
|
||||||
|
id: 130298,
|
||||||
|
episodes: 20,
|
||||||
|
title: {
|
||||||
|
romaji: 'Kage no Jitsuryokusha ni Naritakute!',
|
||||||
|
english: 'The Eminence in Shadow',
|
||||||
|
native: '陰の実力者になりたくて!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.query?.includes('characters(page: $page')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
data: {
|
||||||
|
Media: {
|
||||||
|
title: {
|
||||||
|
english: 'The Eminence in Shadow',
|
||||||
|
},
|
||||||
|
characters: {
|
||||||
|
pageInfo: { hasNextPage: false },
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
role: 'MAIN',
|
||||||
|
node: {
|
||||||
|
id: 111,
|
||||||
|
description: 'Valid native name.',
|
||||||
|
image: null,
|
||||||
|
name: {
|
||||||
|
full: 'Alpha',
|
||||||
|
native: 'アルファ',
|
||||||
|
first: 'Alpha',
|
||||||
|
last: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'SUPPORTING',
|
||||||
|
node: {
|
||||||
|
id: 222,
|
||||||
|
description: 'Missing native name.',
|
||||||
|
image: null,
|
||||||
|
name: {
|
||||||
|
full: 'John Smith',
|
||||||
|
native: '',
|
||||||
|
first: 'John',
|
||||||
|
last: 'Smith',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runtime = createCharacterDictionaryRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||||
|
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||||
|
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||||
|
guessAnilistMediaInfo: async () => ({
|
||||||
|
title: 'The Eminence in Shadow',
|
||||||
|
episode: 5,
|
||||||
|
source: 'fallback',
|
||||||
|
}),
|
||||||
|
now: () => 1_700_000_000_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runtime.generateForCurrentMedia();
|
||||||
|
const termBank = JSON.parse(
|
||||||
|
readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'),
|
||||||
|
) as Array<
|
||||||
|
[
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
number,
|
||||||
|
Array<string | Record<string, unknown>>,
|
||||||
|
number,
|
||||||
|
string,
|
||||||
|
]
|
||||||
|
>;
|
||||||
|
|
||||||
|
assert.ok(termBank.find(([term]) => term === 'アルファ'));
|
||||||
|
assert.equal(
|
||||||
|
termBank.some(([term]) => term === 'John Smith'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateForCurrentMedia uses AniList first and last name hints to build kanji readings', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
if (url === GRAPHQL_URL) {
|
||||||
|
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
||||||
|
query?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body.query?.includes('Page(perPage: 10)')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
data: {
|
||||||
|
Page: {
|
||||||
|
media: [
|
||||||
|
{
|
||||||
|
id: 20594,
|
||||||
|
episodes: 10,
|
||||||
|
title: {
|
||||||
|
romaji: 'Kono Subarashii Sekai ni Shukufuku wo!',
|
||||||
|
english: 'KONOSUBA -God’s blessing on this wonderful world!',
|
||||||
|
native: 'この素晴らしい世界に祝福を!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.query?.includes('characters(page: $page')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
data: {
|
||||||
|
Media: {
|
||||||
|
title: {
|
||||||
|
romaji: 'Kono Subarashii Sekai ni Shukufuku wo!',
|
||||||
|
english: 'KONOSUBA -God’s blessing on this wonderful world!',
|
||||||
|
native: 'この素晴らしい世界に祝福を!',
|
||||||
|
},
|
||||||
|
characters: {
|
||||||
|
pageInfo: { hasNextPage: false },
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
role: 'MAIN',
|
||||||
|
node: {
|
||||||
|
id: 1,
|
||||||
|
description: 'The protagonist.',
|
||||||
|
image: null,
|
||||||
|
name: {
|
||||||
|
full: 'Satou Kazuma',
|
||||||
|
native: '佐藤和真',
|
||||||
|
first: '和真',
|
||||||
|
last: '佐藤',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runtime = createCharacterDictionaryRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getCurrentMediaPath: () => '/tmp/konosuba-s02e05.mkv',
|
||||||
|
getCurrentMediaTitle: () => 'Konosuba S02E05',
|
||||||
|
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||||
|
guessAnilistMediaInfo: async () => ({
|
||||||
|
title: 'Konosuba',
|
||||||
|
episode: 5,
|
||||||
|
source: 'fallback',
|
||||||
|
}),
|
||||||
|
now: () => 1_700_000_000_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runtime.generateForCurrentMedia();
|
||||||
|
const termBank = JSON.parse(
|
||||||
|
readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'),
|
||||||
|
) as Array<
|
||||||
|
[
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
number,
|
||||||
|
Array<string | Record<string, unknown>>,
|
||||||
|
number,
|
||||||
|
string,
|
||||||
|
]
|
||||||
|
>;
|
||||||
|
|
||||||
|
assert.equal(termBank.find(([term]) => term === '佐藤和真')?.[1], 'さとうかずま');
|
||||||
|
assert.equal(termBank.find(([term]) => term === '佐藤')?.[1], 'さとう');
|
||||||
|
assert.equal(termBank.find(([term]) => term === '和真')?.[1], 'かずま');
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateForCurrentMedia includes AniList gender age birthday and blood type in character information', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
if (url === GRAPHQL_URL) {
|
||||||
|
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
||||||
|
query?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body.query?.includes('Page(perPage: 10)')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
data: {
|
||||||
|
Page: {
|
||||||
|
media: [
|
||||||
|
{
|
||||||
|
id: 130298,
|
||||||
|
episodes: 20,
|
||||||
|
title: {
|
||||||
|
romaji: 'Kage no Jitsuryokusha ni Naritakute!',
|
||||||
|
english: 'The Eminence in Shadow',
|
||||||
|
native: '陰の実力者になりたくて!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.query?.includes('characters(page: $page')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
data: {
|
||||||
|
Media: {
|
||||||
|
title: {
|
||||||
|
english: 'The Eminence in Shadow',
|
||||||
|
},
|
||||||
|
characters: {
|
||||||
|
pageInfo: { hasNextPage: false },
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
role: 'SUPPORTING',
|
||||||
|
node: {
|
||||||
|
id: 123,
|
||||||
|
description: 'Second princess of Midgar.',
|
||||||
|
image: null,
|
||||||
|
gender: 'Female',
|
||||||
|
age: '15',
|
||||||
|
dateOfBirth: {
|
||||||
|
month: 9,
|
||||||
|
day: 1,
|
||||||
|
},
|
||||||
|
bloodType: 'A',
|
||||||
|
name: {
|
||||||
|
full: 'Alexia Midgar',
|
||||||
|
native: 'アレクシア・ミドガル',
|
||||||
|
first: 'Alexia',
|
||||||
|
last: 'Midgar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runtime = createCharacterDictionaryRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||||
|
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||||
|
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||||
|
guessAnilistMediaInfo: async () => ({
|
||||||
|
title: 'The Eminence in Shadow',
|
||||||
|
episode: 5,
|
||||||
|
source: 'fallback',
|
||||||
|
}),
|
||||||
|
now: () => 1_700_000_000_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runtime.generateForCurrentMedia();
|
||||||
|
const termBank = JSON.parse(
|
||||||
|
readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'),
|
||||||
|
) as Array<
|
||||||
|
[
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
number,
|
||||||
|
Array<string | Record<string, unknown>>,
|
||||||
|
number,
|
||||||
|
string,
|
||||||
|
]
|
||||||
|
>;
|
||||||
|
const alexia = termBank.find(([term]) => term === 'アレクシア');
|
||||||
|
assert.ok(alexia);
|
||||||
|
|
||||||
|
const children = (
|
||||||
|
alexia[5][0] as {
|
||||||
|
content: { content: Array<Record<string, unknown>> };
|
||||||
|
}
|
||||||
|
).content.content;
|
||||||
|
const infoSection = children.find(
|
||||||
|
(c) =>
|
||||||
|
(c as { tag?: string }).tag === 'details' &&
|
||||||
|
Array.isArray((c as { content?: unknown[] }).content) &&
|
||||||
|
(c as { content: Array<{ content?: string }> }).content[0]?.content ===
|
||||||
|
'Character Information',
|
||||||
|
) as { content: Array<Record<string, unknown>> } | undefined;
|
||||||
|
assert.ok(infoSection);
|
||||||
|
const body = infoSection.content[1] as { content: Array<{ content?: string }> };
|
||||||
|
const flattened = JSON.stringify(body.content);
|
||||||
|
|
||||||
|
assert.match(flattened, /Female|♂ Male|♀ Female/);
|
||||||
|
assert.match(flattened, /15 years/);
|
||||||
|
assert.match(flattened, /Blood Type A/);
|
||||||
|
assert.match(flattened, /Birthday: September 1/);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateForCurrentMedia preserves duplicate surface forms across different characters', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||||
|
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||||
|
if (url === GRAPHQL_URL) {
|
||||||
|
const body = JSON.parse(String(init?.body ?? '{}')) as {
|
||||||
|
query?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body.query?.includes('Page(perPage: 10)')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
data: {
|
||||||
|
Page: {
|
||||||
|
media: [
|
||||||
|
{
|
||||||
|
id: 130298,
|
||||||
|
episodes: 20,
|
||||||
|
title: {
|
||||||
|
romaji: 'Kage no Jitsuryokusha ni Naritakute!',
|
||||||
|
english: 'The Eminence in Shadow',
|
||||||
|
native: '陰の実力者になりたくて!',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.query?.includes('characters(page: $page')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
data: {
|
||||||
|
Media: {
|
||||||
|
title: {
|
||||||
|
english: 'The Eminence in Shadow',
|
||||||
|
},
|
||||||
|
characters: {
|
||||||
|
pageInfo: { hasNextPage: false },
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
role: 'MAIN',
|
||||||
|
node: {
|
||||||
|
id: 111,
|
||||||
|
description: 'First Alpha.',
|
||||||
|
image: null,
|
||||||
|
name: {
|
||||||
|
full: 'Alpha One',
|
||||||
|
native: 'アルファ',
|
||||||
|
first: 'Alpha',
|
||||||
|
last: 'One',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'MAIN',
|
||||||
|
node: {
|
||||||
|
id: 222,
|
||||||
|
description: 'Second Alpha.',
|
||||||
|
image: null,
|
||||||
|
name: {
|
||||||
|
full: 'Alpha Two',
|
||||||
|
native: 'アルファ',
|
||||||
|
first: 'Alpha',
|
||||||
|
last: 'Two',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected fetch URL: ${url}`);
|
||||||
|
}) as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runtime = createCharacterDictionaryRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||||
|
getCurrentMediaTitle: () => 'The Eminence in Shadow - S01E05',
|
||||||
|
resolveMediaPathForJimaku: (mediaPath) => mediaPath,
|
||||||
|
guessAnilistMediaInfo: async () => ({
|
||||||
|
title: 'The Eminence in Shadow',
|
||||||
|
episode: 5,
|
||||||
|
source: 'fallback',
|
||||||
|
}),
|
||||||
|
now: () => 1_700_000_000_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runtime.generateForCurrentMedia();
|
||||||
|
const termBank = JSON.parse(
|
||||||
|
readStoredZipEntry(result.zipPath, 'term_bank_1.json').toString('utf8'),
|
||||||
|
) as Array<
|
||||||
|
[
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
string,
|
||||||
|
number,
|
||||||
|
Array<string | Record<string, unknown>>,
|
||||||
|
number,
|
||||||
|
string,
|
||||||
|
]
|
||||||
|
>;
|
||||||
|
|
||||||
|
const alphaEntries = termBank.filter(([term]) => term === 'アルファ');
|
||||||
|
assert.equal(alphaEntries.length, 2);
|
||||||
|
const glossaries = alphaEntries.map((entry) =>
|
||||||
|
JSON.stringify(
|
||||||
|
(
|
||||||
|
entry[5][0] as {
|
||||||
|
content: { content: Array<Record<string, unknown>> };
|
||||||
|
}
|
||||||
|
).content.content,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.ok(glossaries.some((value) => value.includes('First Alpha.')));
|
||||||
|
assert.ok(glossaries.some((value) => value.includes('Second Alpha.')));
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data', async () => {
|
test('getOrCreateCurrentSnapshot persists and reuses normalized snapshot data', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|||||||
@@ -10,21 +10,21 @@ const ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co';
|
|||||||
const ANILIST_REQUEST_DELAY_MS = 2000;
|
const ANILIST_REQUEST_DELAY_MS = 2000;
|
||||||
const CHARACTER_IMAGE_DOWNLOAD_DELAY_MS = 250;
|
const CHARACTER_IMAGE_DOWNLOAD_DELAY_MS = 250;
|
||||||
const HONORIFIC_SUFFIXES = [
|
const HONORIFIC_SUFFIXES = [
|
||||||
'さん',
|
{ term: 'さん', reading: 'さん' },
|
||||||
'様',
|
{ term: '様', reading: 'さま' },
|
||||||
'先生',
|
{ term: '先生', reading: 'せんせい' },
|
||||||
'先輩',
|
{ term: '先輩', reading: 'せんぱい' },
|
||||||
'後輩',
|
{ term: '後輩', reading: 'こうはい' },
|
||||||
'氏',
|
{ term: '氏', reading: 'し' },
|
||||||
'君',
|
{ term: '君', reading: 'くん' },
|
||||||
'くん',
|
{ term: 'くん', reading: 'くん' },
|
||||||
'ちゃん',
|
{ term: 'ちゃん', reading: 'ちゃん' },
|
||||||
'たん',
|
{ term: 'たん', reading: 'たん' },
|
||||||
'坊',
|
{ term: '坊', reading: 'ぼう' },
|
||||||
'殿',
|
{ term: '殿', reading: 'どの' },
|
||||||
'博士',
|
{ term: '博士', reading: 'はかせ' },
|
||||||
'社長',
|
{ term: '社長', reading: 'しゃちょう' },
|
||||||
'部長',
|
{ term: '部長', reading: 'ぶちょう' },
|
||||||
] as const;
|
] as const;
|
||||||
type CharacterDictionaryRole = 'main' | 'primary' | 'side' | 'appears';
|
type CharacterDictionaryRole = 'main' | 'primary' | 'side' | 'appears';
|
||||||
|
|
||||||
@@ -45,6 +45,24 @@ type CharacterDictionarySnapshotImage = {
|
|||||||
dataBase64: string;
|
dataBase64: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CharacterBirthday = [number, number];
|
||||||
|
|
||||||
|
type JapaneseNameParts = {
|
||||||
|
hasSpace: boolean;
|
||||||
|
original: string;
|
||||||
|
combined: string;
|
||||||
|
family: string | null;
|
||||||
|
given: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NameReadings = {
|
||||||
|
hasSpace: boolean;
|
||||||
|
original: string;
|
||||||
|
full: string;
|
||||||
|
family: string;
|
||||||
|
given: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CharacterDictionarySnapshot = {
|
export type CharacterDictionarySnapshot = {
|
||||||
formatVersion: number;
|
formatVersion: number;
|
||||||
mediaId: number;
|
mediaId: number;
|
||||||
@@ -55,7 +73,7 @@ export type CharacterDictionarySnapshot = {
|
|||||||
images: CharacterDictionarySnapshotImage[];
|
images: CharacterDictionarySnapshotImage[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const CHARACTER_DICTIONARY_FORMAT_VERSION = 14;
|
const CHARACTER_DICTIONARY_FORMAT_VERSION = 15;
|
||||||
const CHARACTER_DICTIONARY_MERGED_TITLE = 'SubMiner Character Dictionary';
|
const CHARACTER_DICTIONARY_MERGED_TITLE = 'SubMiner Character Dictionary';
|
||||||
|
|
||||||
type AniListSearchResponse = {
|
type AniListSearchResponse = {
|
||||||
@@ -103,8 +121,17 @@ type AniListCharacterPageResponse = {
|
|||||||
large?: string | null;
|
large?: string | null;
|
||||||
medium?: string | null;
|
medium?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
gender?: string | null;
|
||||||
|
age?: string | number | null;
|
||||||
|
dateOfBirth?: {
|
||||||
|
month?: number | null;
|
||||||
|
day?: number | null;
|
||||||
|
} | null;
|
||||||
|
bloodType?: string | null;
|
||||||
name?: {
|
name?: {
|
||||||
|
first?: string | null;
|
||||||
full?: string | null;
|
full?: string | null;
|
||||||
|
last?: string | null;
|
||||||
native?: string | null;
|
native?: string | null;
|
||||||
alternative?: Array<string | null> | null;
|
alternative?: Array<string | null> | null;
|
||||||
} | null;
|
} | null;
|
||||||
@@ -124,11 +151,17 @@ type VoiceActorRecord = {
|
|||||||
type CharacterRecord = {
|
type CharacterRecord = {
|
||||||
id: number;
|
id: number;
|
||||||
role: CharacterDictionaryRole;
|
role: CharacterDictionaryRole;
|
||||||
|
firstNameHint: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
|
lastNameHint: string;
|
||||||
nativeName: string;
|
nativeName: string;
|
||||||
alternativeNames: string[];
|
alternativeNames: string[];
|
||||||
|
bloodType: string;
|
||||||
|
birthday: CharacterBirthday | null;
|
||||||
description: string;
|
description: string;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
|
age: string;
|
||||||
|
sex: string;
|
||||||
voiceActors: VoiceActorRecord[];
|
voiceActors: VoiceActorRecord[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -161,6 +194,16 @@ export type CharacterDictionarySnapshotResult = {
|
|||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CharacterDictionarySnapshotProgress = {
|
||||||
|
mediaId: number;
|
||||||
|
mediaTitle: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CharacterDictionarySnapshotProgressCallbacks = {
|
||||||
|
onChecking?: (progress: CharacterDictionarySnapshotProgress) => void;
|
||||||
|
onGenerating?: (progress: CharacterDictionarySnapshotProgress) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export type MergedCharacterDictionaryBuildResult = {
|
export type MergedCharacterDictionaryBuildResult = {
|
||||||
zipPath: string;
|
zipPath: string;
|
||||||
revision: string;
|
revision: string;
|
||||||
@@ -263,6 +306,16 @@ function buildReading(term: string): string {
|
|||||||
return katakanaToHiragana(compact);
|
return katakanaToHiragana(compact);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function containsKanji(value: string): boolean {
|
||||||
|
for (const char of value) {
|
||||||
|
const code = char.charCodeAt(0);
|
||||||
|
if ((code >= 0x4e00 && code <= 0x9fff) || (code >= 0x3400 && code <= 0x4dbf)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function isRomanizedName(value: string): boolean {
|
function isRomanizedName(value: string): boolean {
|
||||||
return /^[A-Za-zĀĪŪĒŌÂÊÎÔÛāīūēōâêîôû'’.\-\s]+$/.test(value);
|
return /^[A-Za-zĀĪŪĒŌÂÊÎÔÛāīūēōâêîôû'’.\-\s]+$/.test(value);
|
||||||
}
|
}
|
||||||
@@ -484,6 +537,67 @@ function romanizedTokenToKatakana(token: string): string | null {
|
|||||||
return output.length > 0 ? output : null;
|
return output.length > 0 ? output : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildReadingFromRomanized(value: string): string {
|
||||||
|
const katakana = romanizedTokenToKatakana(value);
|
||||||
|
return katakana ? katakanaToHiragana(katakana) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReadingFromHint(value: string): string {
|
||||||
|
return buildReading(value) || buildReadingFromRomanized(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreJapaneseNamePartLength(length: number): number {
|
||||||
|
if (length === 2) return 3;
|
||||||
|
if (length === 1 || length === 3) return 2;
|
||||||
|
if (length === 4) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferJapaneseNameSplitIndex(
|
||||||
|
nameOriginal: string,
|
||||||
|
firstNameHint: string,
|
||||||
|
lastNameHint: string,
|
||||||
|
): number | null {
|
||||||
|
const chars = [...nameOriginal];
|
||||||
|
if (chars.length < 2) return null;
|
||||||
|
|
||||||
|
const familyHintLength = [...buildReadingFromHint(lastNameHint)].length;
|
||||||
|
const givenHintLength = [...buildReadingFromHint(firstNameHint)].length;
|
||||||
|
const totalHintLength = familyHintLength + givenHintLength;
|
||||||
|
const defaultBoundary = Math.round(chars.length / 2);
|
||||||
|
let bestIndex: number | null = null;
|
||||||
|
let bestScore = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
for (let index = 1; index < chars.length; index += 1) {
|
||||||
|
const familyLength = index;
|
||||||
|
const givenLength = chars.length - index;
|
||||||
|
let score =
|
||||||
|
scoreJapaneseNamePartLength(familyLength) + scoreJapaneseNamePartLength(givenLength);
|
||||||
|
|
||||||
|
if (chars.length >= 4 && familyLength >= 2 && givenLength >= 2) {
|
||||||
|
score += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalHintLength > 0) {
|
||||||
|
const expectedFamilyLength = (chars.length * familyHintLength) / totalHintLength;
|
||||||
|
score -= Math.abs(familyLength - expectedFamilyLength) * 1.5;
|
||||||
|
} else {
|
||||||
|
score -= Math.abs(familyLength - defaultBoundary) * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (familyLength === givenLength) {
|
||||||
|
score += 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestIndex;
|
||||||
|
}
|
||||||
|
|
||||||
function addRomanizedKanaAliases(values: Iterable<string>): string[] {
|
function addRomanizedKanaAliases(values: Iterable<string>): string[] {
|
||||||
const aliases = new Set<string>();
|
const aliases = new Set<string>();
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
@@ -497,6 +611,166 @@ function addRomanizedKanaAliases(values: Iterable<string>): string[] {
|
|||||||
return [...aliases];
|
return [...aliases];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitJapaneseName(
|
||||||
|
nameOriginal: string,
|
||||||
|
firstNameHint?: string,
|
||||||
|
lastNameHint?: string,
|
||||||
|
): JapaneseNameParts {
|
||||||
|
const trimmed = nameOriginal.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return {
|
||||||
|
hasSpace: false,
|
||||||
|
original: '',
|
||||||
|
combined: '',
|
||||||
|
family: null,
|
||||||
|
given: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSpace = trimmed.replace(/[\s\u3000]+/g, ' ').trim();
|
||||||
|
const spaceParts = normalizedSpace.split(' ').filter((part) => part.length > 0);
|
||||||
|
if (spaceParts.length === 2) {
|
||||||
|
const family = spaceParts[0]!;
|
||||||
|
const given = spaceParts[1]!;
|
||||||
|
return {
|
||||||
|
hasSpace: true,
|
||||||
|
original: normalizedSpace,
|
||||||
|
combined: `${family}${given}`,
|
||||||
|
family,
|
||||||
|
given,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const middleDotParts = trimmed
|
||||||
|
.split(/[・・·•]/)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter((part) => part.length > 0);
|
||||||
|
if (middleDotParts.length === 2) {
|
||||||
|
const family = middleDotParts[0]!;
|
||||||
|
const given = middleDotParts[1]!;
|
||||||
|
return {
|
||||||
|
hasSpace: true,
|
||||||
|
original: trimmed,
|
||||||
|
combined: `${family}${given}`,
|
||||||
|
family,
|
||||||
|
given,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hintedFirst = firstNameHint?.trim() || '';
|
||||||
|
const hintedLast = lastNameHint?.trim() || '';
|
||||||
|
if (hintedFirst && hintedLast) {
|
||||||
|
const familyGiven = `${hintedLast}${hintedFirst}`;
|
||||||
|
if (trimmed === familyGiven) {
|
||||||
|
return {
|
||||||
|
hasSpace: true,
|
||||||
|
original: trimmed,
|
||||||
|
combined: familyGiven,
|
||||||
|
family: hintedLast,
|
||||||
|
given: hintedFirst,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const givenFamily = `${hintedFirst}${hintedLast}`;
|
||||||
|
if (trimmed === givenFamily) {
|
||||||
|
return {
|
||||||
|
hasSpace: true,
|
||||||
|
original: trimmed,
|
||||||
|
combined: givenFamily,
|
||||||
|
family: hintedFirst,
|
||||||
|
given: hintedLast,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hintedFirst && hintedLast && containsKanji(trimmed)) {
|
||||||
|
const splitIndex = inferJapaneseNameSplitIndex(trimmed, hintedFirst, hintedLast);
|
||||||
|
if (splitIndex != null) {
|
||||||
|
const chars = [...trimmed];
|
||||||
|
const family = chars.slice(0, splitIndex).join('');
|
||||||
|
const given = chars.slice(splitIndex).join('');
|
||||||
|
if (family && given) {
|
||||||
|
return {
|
||||||
|
hasSpace: true,
|
||||||
|
original: trimmed,
|
||||||
|
combined: trimmed,
|
||||||
|
family,
|
||||||
|
given,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasSpace: false,
|
||||||
|
original: trimmed,
|
||||||
|
combined: trimmed,
|
||||||
|
family: null,
|
||||||
|
given: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateNameReadings(
|
||||||
|
nameOriginal: string,
|
||||||
|
romanizedName: string,
|
||||||
|
firstNameHint?: string,
|
||||||
|
lastNameHint?: string,
|
||||||
|
): NameReadings {
|
||||||
|
const trimmed = nameOriginal.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return {
|
||||||
|
hasSpace: false,
|
||||||
|
original: '',
|
||||||
|
full: '',
|
||||||
|
family: '',
|
||||||
|
given: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameParts = splitJapaneseName(trimmed, firstNameHint, lastNameHint);
|
||||||
|
if (!nameParts.hasSpace || !nameParts.family || !nameParts.given) {
|
||||||
|
const full = containsKanji(trimmed)
|
||||||
|
? buildReadingFromRomanized(romanizedName)
|
||||||
|
: buildReading(trimmed);
|
||||||
|
return {
|
||||||
|
hasSpace: false,
|
||||||
|
original: trimmed,
|
||||||
|
full,
|
||||||
|
family: full,
|
||||||
|
given: full,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const romanizedParts = romanizedName
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter((part) => part.length > 0);
|
||||||
|
const familyFromHints = buildReadingFromHint(lastNameHint || '');
|
||||||
|
const givenFromHints = buildReadingFromHint(firstNameHint || '');
|
||||||
|
const familyRomajiFallback = romanizedParts[0] || '';
|
||||||
|
const givenRomajiFallback = romanizedParts.slice(1).join(' ');
|
||||||
|
const family =
|
||||||
|
familyFromHints ||
|
||||||
|
(containsKanji(nameParts.family)
|
||||||
|
? buildReadingFromRomanized(familyRomajiFallback)
|
||||||
|
: buildReading(nameParts.family));
|
||||||
|
const given =
|
||||||
|
givenFromHints ||
|
||||||
|
(containsKanji(nameParts.given)
|
||||||
|
? buildReadingFromRomanized(givenRomajiFallback)
|
||||||
|
: buildReading(nameParts.given));
|
||||||
|
const full =
|
||||||
|
`${family}${given}` || buildReading(trimmed) || buildReadingFromRomanized(romanizedName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasSpace: true,
|
||||||
|
original: nameParts.original,
|
||||||
|
full,
|
||||||
|
family,
|
||||||
|
given,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function expandRawNameVariants(rawName: string): string[] {
|
function expandRawNameVariants(rawName: string): string[] {
|
||||||
const trimmed = rawName.trim();
|
const trimmed = rawName.trim();
|
||||||
if (!trimmed) return [];
|
if (!trimmed) return [];
|
||||||
@@ -555,24 +829,125 @@ function buildNameTerms(character: CharacterRecord): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nativeParts = splitJapaneseName(
|
||||||
|
character.nativeName,
|
||||||
|
character.firstNameHint,
|
||||||
|
character.lastNameHint,
|
||||||
|
);
|
||||||
|
if (nativeParts.family) {
|
||||||
|
base.add(nativeParts.family);
|
||||||
|
}
|
||||||
|
if (nativeParts.given) {
|
||||||
|
base.add(nativeParts.given);
|
||||||
|
}
|
||||||
|
|
||||||
const withHonorifics = new Set<string>();
|
const withHonorifics = new Set<string>();
|
||||||
for (const entry of base) {
|
for (const entry of base) {
|
||||||
withHonorifics.add(entry);
|
withHonorifics.add(entry);
|
||||||
for (const suffix of HONORIFIC_SUFFIXES) {
|
for (const suffix of HONORIFIC_SUFFIXES) {
|
||||||
withHonorifics.add(`${entry}${suffix}`);
|
withHonorifics.add(`${entry}${suffix.term}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const alias of addRomanizedKanaAliases(withHonorifics)) {
|
for (const alias of addRomanizedKanaAliases(withHonorifics)) {
|
||||||
withHonorifics.add(alias);
|
withHonorifics.add(alias);
|
||||||
for (const suffix of HONORIFIC_SUFFIXES) {
|
for (const suffix of HONORIFIC_SUFFIXES) {
|
||||||
withHonorifics.add(`${alias}${suffix}`);
|
withHonorifics.add(`${alias}${suffix.term}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...withHonorifics].filter((entry) => entry.trim().length > 0);
|
return [...withHonorifics].filter((entry) => entry.trim().length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MONTH_NAMES: ReadonlyArray<[number, string]> = [
|
||||||
|
[1, 'January'],
|
||||||
|
[2, 'February'],
|
||||||
|
[3, 'March'],
|
||||||
|
[4, 'April'],
|
||||||
|
[5, 'May'],
|
||||||
|
[6, 'June'],
|
||||||
|
[7, 'July'],
|
||||||
|
[8, 'August'],
|
||||||
|
[9, 'September'],
|
||||||
|
[10, 'October'],
|
||||||
|
[11, 'November'],
|
||||||
|
[12, 'December'],
|
||||||
|
];
|
||||||
|
|
||||||
|
const SEX_DISPLAY: ReadonlyArray<[string, string]> = [
|
||||||
|
['m', '♂ Male'],
|
||||||
|
['f', '♀ Female'],
|
||||||
|
['male', '♂ Male'],
|
||||||
|
['female', '♀ Female'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatBirthday(birthday: CharacterBirthday | null): string {
|
||||||
|
if (!birthday) return '';
|
||||||
|
const [month, day] = birthday;
|
||||||
|
const monthName = MONTH_NAMES.find(([m]) => m === month)?.[1] || 'Unknown';
|
||||||
|
return `${monthName} ${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCharacterStats(character: CharacterRecord): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const normalizedSex = character.sex.trim().toLowerCase();
|
||||||
|
const sexDisplay = SEX_DISPLAY.find(([key]) => key === normalizedSex)?.[1];
|
||||||
|
if (sexDisplay) parts.push(sexDisplay);
|
||||||
|
if (character.age.trim()) parts.push(`${character.age.trim()} years`);
|
||||||
|
if (character.bloodType.trim()) parts.push(`Blood Type ${character.bloodType.trim()}`);
|
||||||
|
const birthday = formatBirthday(character.birthday);
|
||||||
|
if (birthday) parts.push(`Birthday: ${birthday}`);
|
||||||
|
return parts.join(' • ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReadingForTerm(
|
||||||
|
term: string,
|
||||||
|
character: CharacterRecord,
|
||||||
|
readings: NameReadings,
|
||||||
|
nameParts: JapaneseNameParts,
|
||||||
|
): string {
|
||||||
|
for (const suffix of HONORIFIC_SUFFIXES) {
|
||||||
|
if (term.endsWith(suffix.term) && term.length > suffix.term.length) {
|
||||||
|
const baseTerm = term.slice(0, -suffix.term.length);
|
||||||
|
const baseReading = buildReadingForTerm(baseTerm, character, readings, nameParts);
|
||||||
|
return baseReading ? `${baseReading}${suffix.reading}` : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const compactNative = character.nativeName.replace(/[\s\u3000]+/g, '');
|
||||||
|
const noMiddleDotsNative = compactNative.replace(/[・・·•]/g, '');
|
||||||
|
if (
|
||||||
|
term === character.nativeName ||
|
||||||
|
term === compactNative ||
|
||||||
|
term === noMiddleDotsNative ||
|
||||||
|
term === nameParts.original ||
|
||||||
|
term === nameParts.combined
|
||||||
|
) {
|
||||||
|
return readings.full;
|
||||||
|
}
|
||||||
|
|
||||||
|
const familyCompact = nameParts.family?.replace(/[・・·•]/g, '') || '';
|
||||||
|
if (nameParts.family && (term === nameParts.family || term === familyCompact)) {
|
||||||
|
return readings.family;
|
||||||
|
}
|
||||||
|
|
||||||
|
const givenCompact = nameParts.given?.replace(/[・・·•]/g, '') || '';
|
||||||
|
if (nameParts.given && (term === nameParts.given || term === givenCompact)) {
|
||||||
|
return readings.given;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compact = term.replace(/[\s\u3000]+/g, '');
|
||||||
|
if (hasKanaOnly(compact)) {
|
||||||
|
return buildReading(compact);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRomanizedName(term)) {
|
||||||
|
return buildReadingFromRomanized(term) || readings.full;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function parseCharacterDescription(raw: string): {
|
function parseCharacterDescription(raw: string): {
|
||||||
fields: Array<{ key: string; value: string }>;
|
fields: Array<{ key: string; value: string }>;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -623,16 +998,16 @@ function roleInfo(role: CharacterDictionaryRole): { tag: string; score: number }
|
|||||||
function mapRole(input: string | null | undefined): CharacterDictionaryRole {
|
function mapRole(input: string | null | undefined): CharacterDictionaryRole {
|
||||||
const value = (input || '').trim().toUpperCase();
|
const value = (input || '').trim().toUpperCase();
|
||||||
if (value === 'MAIN') return 'main';
|
if (value === 'MAIN') return 'main';
|
||||||
if (value === 'BACKGROUND') return 'appears';
|
if (value === 'SUPPORTING') return 'primary';
|
||||||
if (value === 'SUPPORTING') return 'side';
|
if (value === 'BACKGROUND') return 'side';
|
||||||
return 'primary';
|
return 'side';
|
||||||
}
|
}
|
||||||
|
|
||||||
function roleLabel(role: CharacterDictionaryRole): string {
|
function roleLabel(role: CharacterDictionaryRole): string {
|
||||||
if (role === 'main') return 'Main';
|
if (role === 'main') return 'Protagonist';
|
||||||
if (role === 'primary') return 'Primary';
|
if (role === 'primary') return 'Main Character';
|
||||||
if (role === 'side') return 'Side';
|
if (role === 'side') return 'Side Character';
|
||||||
return 'Appears';
|
return 'Minor Role';
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferImageExt(contentType: string | null): string {
|
function inferImageExt(contentType: string | null): string {
|
||||||
@@ -780,10 +1155,10 @@ function roleBadgeStyle(role: CharacterDictionaryRole): Record<string, string> {
|
|||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
};
|
};
|
||||||
if (role === 'main') return { ...base, backgroundColor: '#4a8c3f' };
|
if (role === 'main') return { ...base, backgroundColor: '#4CAF50' };
|
||||||
if (role === 'primary') return { ...base, backgroundColor: '#5c82b0' };
|
if (role === 'primary') return { ...base, backgroundColor: '#2196F3' };
|
||||||
if (role === 'side') return { ...base, backgroundColor: '#7889a0' };
|
if (role === 'side') return { ...base, backgroundColor: '#FF9800' };
|
||||||
return { ...base, backgroundColor: '#777' };
|
return { ...base, backgroundColor: '#9E9E9E' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCollapsibleSection(
|
function buildCollapsibleSection(
|
||||||
@@ -939,10 +1314,11 @@ function createDefinitionGlossary(
|
|||||||
content: {
|
content: {
|
||||||
tag: 'span',
|
tag: 'span',
|
||||||
style: roleBadgeStyle(character.role),
|
style: roleBadgeStyle(character.role),
|
||||||
content: `${roleLabel(character.role)} Character`,
|
content: roleLabel(character.role),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const statsLine = formatCharacterStats(character);
|
||||||
if (descriptionText) {
|
if (descriptionText) {
|
||||||
content.push(
|
content.push(
|
||||||
buildCollapsibleSection(
|
buildCollapsibleSection(
|
||||||
@@ -953,11 +1329,21 @@ function createDefinitionGlossary(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fields.length > 0) {
|
const fieldItems: Array<Record<string, unknown>> = [];
|
||||||
const fieldItems: Array<Record<string, unknown>> = fields.map((f) => ({
|
if (statsLine) {
|
||||||
|
fieldItems.push({
|
||||||
|
tag: 'li',
|
||||||
|
style: { fontWeight: 'bold' },
|
||||||
|
content: statsLine,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fieldItems.push(
|
||||||
|
...fields.map((f) => ({
|
||||||
tag: 'li',
|
tag: 'li',
|
||||||
content: `${f.key}: ${f.value}`,
|
content: `${f.key}: ${f.value}`,
|
||||||
}));
|
})),
|
||||||
|
);
|
||||||
|
if (fieldItems.length > 0) {
|
||||||
content.push(
|
content.push(
|
||||||
buildCollapsibleSection(
|
buildCollapsibleSection(
|
||||||
'Character Information',
|
'Character Information',
|
||||||
@@ -1248,12 +1634,21 @@ async function fetchCharactersForMedia(
|
|||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
description(asHtml: false)
|
description(asHtml: false)
|
||||||
|
gender
|
||||||
|
age
|
||||||
|
dateOfBirth {
|
||||||
|
month
|
||||||
|
day
|
||||||
|
}
|
||||||
|
bloodType
|
||||||
image {
|
image {
|
||||||
large
|
large
|
||||||
medium
|
medium
|
||||||
}
|
}
|
||||||
name {
|
name {
|
||||||
|
first
|
||||||
full
|
full
|
||||||
|
last
|
||||||
native
|
native
|
||||||
alternative
|
alternative
|
||||||
}
|
}
|
||||||
@@ -1287,7 +1682,9 @@ async function fetchCharactersForMedia(
|
|||||||
for (const edge of edges) {
|
for (const edge of edges) {
|
||||||
const node = edge?.node;
|
const node = edge?.node;
|
||||||
if (!node || typeof node.id !== 'number') continue;
|
if (!node || typeof node.id !== 'number') continue;
|
||||||
|
const firstNameHint = node.name?.first?.trim() || '';
|
||||||
const fullName = node.name?.full?.trim() || '';
|
const fullName = node.name?.full?.trim() || '';
|
||||||
|
const lastNameHint = node.name?.last?.trim() || '';
|
||||||
const nativeName = node.name?.native?.trim() || '';
|
const nativeName = node.name?.native?.trim() || '';
|
||||||
const alternativeNames = [
|
const alternativeNames = [
|
||||||
...new Set(
|
...new Set(
|
||||||
@@ -1297,7 +1694,7 @@ async function fetchCharactersForMedia(
|
|||||||
.filter((value) => value.length > 0),
|
.filter((value) => value.length > 0),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
if (!fullName && !nativeName && alternativeNames.length === 0) continue;
|
if (!nativeName) continue;
|
||||||
const voiceActors: VoiceActorRecord[] = [];
|
const voiceActors: VoiceActorRecord[] = [];
|
||||||
for (const va of edge?.voiceActors ?? []) {
|
for (const va of edge?.voiceActors ?? []) {
|
||||||
if (!va || typeof va.id !== 'number') continue;
|
if (!va || typeof va.id !== 'number') continue;
|
||||||
@@ -1314,11 +1711,25 @@ async function fetchCharactersForMedia(
|
|||||||
characters.push({
|
characters.push({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
role: mapRole(edge?.role),
|
role: mapRole(edge?.role),
|
||||||
|
firstNameHint,
|
||||||
fullName,
|
fullName,
|
||||||
|
lastNameHint,
|
||||||
nativeName,
|
nativeName,
|
||||||
alternativeNames,
|
alternativeNames,
|
||||||
|
bloodType: node.bloodType?.trim() || '',
|
||||||
|
birthday:
|
||||||
|
typeof node.dateOfBirth?.month === 'number' && typeof node.dateOfBirth?.day === 'number'
|
||||||
|
? [node.dateOfBirth.month, node.dateOfBirth.day]
|
||||||
|
: null,
|
||||||
description: node.description || '',
|
description: node.description || '',
|
||||||
imageUrl: node.image?.large || node.image?.medium || null,
|
imageUrl: node.image?.large || node.image?.medium || null,
|
||||||
|
age:
|
||||||
|
typeof node.age === 'string'
|
||||||
|
? node.age.trim()
|
||||||
|
: typeof node.age === 'number'
|
||||||
|
? String(node.age)
|
||||||
|
: '',
|
||||||
|
sex: node.gender?.trim() || '',
|
||||||
voiceActors,
|
voiceActors,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1400,9 +1811,9 @@ function buildSnapshotFromCharacters(
|
|||||||
) => boolean,
|
) => boolean,
|
||||||
): CharacterDictionarySnapshot {
|
): CharacterDictionarySnapshot {
|
||||||
const termEntries: CharacterDictionaryTermEntry[] = [];
|
const termEntries: CharacterDictionaryTermEntry[] = [];
|
||||||
const seen = new Set<string>();
|
|
||||||
|
|
||||||
for (const character of characters) {
|
for (const character of characters) {
|
||||||
|
const seenTerms = new Set<string>();
|
||||||
const imagePath = imagesByCharacterId.get(character.id)?.path ?? null;
|
const imagePath = imagesByCharacterId.get(character.id)?.path ?? null;
|
||||||
const vaImagePaths = new Map<number, string>();
|
const vaImagePaths = new Map<number, string>();
|
||||||
for (const va of character.voiceActors) {
|
for (const va of character.voiceActors) {
|
||||||
@@ -1417,11 +1828,21 @@ function buildSnapshotFromCharacters(
|
|||||||
getCollapsibleSectionOpenState,
|
getCollapsibleSectionOpenState,
|
||||||
);
|
);
|
||||||
const candidateTerms = buildNameTerms(character);
|
const candidateTerms = buildNameTerms(character);
|
||||||
|
const nameParts = splitJapaneseName(
|
||||||
|
character.nativeName,
|
||||||
|
character.firstNameHint,
|
||||||
|
character.lastNameHint,
|
||||||
|
);
|
||||||
|
const readings = generateNameReadings(
|
||||||
|
character.nativeName,
|
||||||
|
character.fullName,
|
||||||
|
character.firstNameHint,
|
||||||
|
character.lastNameHint,
|
||||||
|
);
|
||||||
for (const term of candidateTerms) {
|
for (const term of candidateTerms) {
|
||||||
const reading = buildReading(term);
|
if (seenTerms.has(term)) continue;
|
||||||
const dedupeKey = `${term}|${reading}|${character.role}`;
|
seenTerms.add(term);
|
||||||
if (seen.has(dedupeKey)) continue;
|
const reading = buildReadingForTerm(term, character, readings, nameParts);
|
||||||
seen.add(dedupeKey);
|
|
||||||
termEntries.push(buildTermEntry(term, reading, character.role, glossary));
|
termEntries.push(buildTermEntry(term, reading, character.role, glossary));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1560,7 +1981,10 @@ function buildMergedRevision(mediaIds: number[], snapshots: CharacterDictionaryS
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createCharacterDictionaryRuntimeService(deps: CharacterDictionaryRuntimeDeps): {
|
export function createCharacterDictionaryRuntimeService(deps: CharacterDictionaryRuntimeDeps): {
|
||||||
getOrCreateCurrentSnapshot: (targetPath?: string) => Promise<CharacterDictionarySnapshotResult>;
|
getOrCreateCurrentSnapshot: (
|
||||||
|
targetPath?: string,
|
||||||
|
progress?: CharacterDictionarySnapshotProgressCallbacks,
|
||||||
|
) => Promise<CharacterDictionarySnapshotResult>;
|
||||||
buildMergedDictionary: (mediaIds: number[]) => Promise<MergedCharacterDictionaryBuildResult>;
|
buildMergedDictionary: (mediaIds: number[]) => Promise<MergedCharacterDictionaryBuildResult>;
|
||||||
generateForCurrentMedia: (
|
generateForCurrentMedia: (
|
||||||
targetPath?: string,
|
targetPath?: string,
|
||||||
@@ -1606,6 +2030,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
mediaId: number,
|
mediaId: number,
|
||||||
mediaTitleHint?: string,
|
mediaTitleHint?: string,
|
||||||
beforeRequest?: () => Promise<void>,
|
beforeRequest?: () => Promise<void>,
|
||||||
|
progress?: CharacterDictionarySnapshotProgressCallbacks,
|
||||||
): Promise<CharacterDictionarySnapshotResult> => {
|
): Promise<CharacterDictionarySnapshotResult> => {
|
||||||
const snapshotPath = getSnapshotPath(outputDir, mediaId);
|
const snapshotPath = getSnapshotPath(outputDir, mediaId);
|
||||||
const cachedSnapshot = readSnapshot(snapshotPath);
|
const cachedSnapshot = readSnapshot(snapshotPath);
|
||||||
@@ -1620,6 +2045,10 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
progress?.onGenerating?.({
|
||||||
|
mediaId,
|
||||||
|
mediaTitle: mediaTitleHint || `AniList ${mediaId}`,
|
||||||
|
});
|
||||||
deps.logInfo?.(`[dictionary] snapshot miss for AniList ${mediaId}, fetching characters`);
|
deps.logInfo?.(`[dictionary] snapshot miss for AniList ${mediaId}, fetching characters`);
|
||||||
|
|
||||||
const { mediaTitle: fetchedMediaTitle, characters } = await fetchCharactersForMedia(
|
const { mediaTitle: fetchedMediaTitle, characters } = await fetchCharactersForMedia(
|
||||||
@@ -1700,7 +2129,10 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getOrCreateCurrentSnapshot: async (targetPath?: string) => {
|
getOrCreateCurrentSnapshot: async (
|
||||||
|
targetPath?: string,
|
||||||
|
progress?: CharacterDictionarySnapshotProgressCallbacks,
|
||||||
|
) => {
|
||||||
let hasAniListRequest = false;
|
let hasAniListRequest = false;
|
||||||
const waitForAniListRequestSlot = async (): Promise<void> => {
|
const waitForAniListRequestSlot = async (): Promise<void> => {
|
||||||
if (!hasAniListRequest) {
|
if (!hasAniListRequest) {
|
||||||
@@ -1710,7 +2142,16 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
|||||||
await sleepMs(ANILIST_REQUEST_DELAY_MS);
|
await sleepMs(ANILIST_REQUEST_DELAY_MS);
|
||||||
};
|
};
|
||||||
const resolvedMedia = await resolveCurrentMedia(targetPath, waitForAniListRequestSlot);
|
const resolvedMedia = await resolveCurrentMedia(targetPath, waitForAniListRequestSlot);
|
||||||
return getOrCreateSnapshot(resolvedMedia.id, resolvedMedia.title, waitForAniListRequestSlot);
|
progress?.onChecking?.({
|
||||||
|
mediaId: resolvedMedia.id,
|
||||||
|
mediaTitle: resolvedMedia.title,
|
||||||
|
});
|
||||||
|
return getOrCreateSnapshot(
|
||||||
|
resolvedMedia.id,
|
||||||
|
resolvedMedia.title,
|
||||||
|
waitForAniListRequestSlot,
|
||||||
|
progress,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
buildMergedDictionary: async (mediaIds: number[]) => {
|
buildMergedDictionary: async (mediaIds: number[]) => {
|
||||||
const normalizedMediaIds = mediaIds
|
const normalizedMediaIds = mediaIds
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
notifyCharacterDictionaryAutoSyncStatus,
|
||||||
|
type CharacterDictionaryAutoSyncNotificationEvent,
|
||||||
|
} from './character-dictionary-auto-sync-notifications';
|
||||||
|
|
||||||
|
function makeEvent(
|
||||||
|
phase: CharacterDictionaryAutoSyncNotificationEvent['phase'],
|
||||||
|
message: string,
|
||||||
|
): CharacterDictionaryAutoSyncNotificationEvent {
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
mediaId: 101291,
|
||||||
|
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('auto sync notifications send osd updates for progress phases', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
notifyCharacterDictionaryAutoSyncStatus(makeEvent('checking', 'checking'), {
|
||||||
|
getNotificationType: () => 'osd',
|
||||||
|
showOsd: (message) => calls.push(`osd:${message}`),
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
});
|
||||||
|
notifyCharacterDictionaryAutoSyncStatus(makeEvent('generating', 'generating'), {
|
||||||
|
getNotificationType: () => 'osd',
|
||||||
|
showOsd: (message) => calls.push(`osd:${message}`),
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
});
|
||||||
|
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
|
||||||
|
getNotificationType: () => 'osd',
|
||||||
|
showOsd: (message) => calls.push(`osd:${message}`),
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
});
|
||||||
|
notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), {
|
||||||
|
getNotificationType: () => 'osd',
|
||||||
|
showOsd: (message) => calls.push(`osd:${message}`),
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
});
|
||||||
|
notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), {
|
||||||
|
getNotificationType: () => 'osd',
|
||||||
|
showOsd: (message) => calls.push(`osd:${message}`),
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'osd:checking',
|
||||||
|
'osd:generating',
|
||||||
|
'osd:syncing',
|
||||||
|
'osd:importing',
|
||||||
|
'osd:ready',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto sync notifications never send desktop notifications', () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
notifyCharacterDictionaryAutoSyncStatus(makeEvent('syncing', 'syncing'), {
|
||||||
|
getNotificationType: () => 'both',
|
||||||
|
showOsd: (message) => calls.push(`osd:${message}`),
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
});
|
||||||
|
notifyCharacterDictionaryAutoSyncStatus(makeEvent('importing', 'importing'), {
|
||||||
|
getNotificationType: () => 'both',
|
||||||
|
showOsd: (message) => calls.push(`osd:${message}`),
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
});
|
||||||
|
notifyCharacterDictionaryAutoSyncStatus(makeEvent('ready', 'ready'), {
|
||||||
|
getNotificationType: () => 'both',
|
||||||
|
showOsd: (message) => calls.push(`osd:${message}`),
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
});
|
||||||
|
notifyCharacterDictionaryAutoSyncStatus(makeEvent('failed', 'failed'), {
|
||||||
|
getNotificationType: () => 'both',
|
||||||
|
showOsd: (message) => calls.push(`osd:${message}`),
|
||||||
|
showDesktopNotification: (title, options) =>
|
||||||
|
calls.push(`desktop:${title}:${options.body ?? ''}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['osd:syncing', 'osd:importing', 'osd:ready', 'osd:failed']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { CharacterDictionaryAutoSyncStatusEvent } from './character-dictionary-auto-sync';
|
||||||
|
import type { StartupOsdSequencerCharacterDictionaryEvent } from './startup-osd-sequencer';
|
||||||
|
|
||||||
|
export type CharacterDictionaryAutoSyncNotificationEvent = CharacterDictionaryAutoSyncStatusEvent;
|
||||||
|
|
||||||
|
export interface CharacterDictionaryAutoSyncNotificationDeps {
|
||||||
|
getNotificationType: () => 'osd' | 'system' | 'both' | 'none' | undefined;
|
||||||
|
showOsd: (message: string) => void;
|
||||||
|
showDesktopNotification: (title: string, options: { body?: string }) => void;
|
||||||
|
startupOsdSequencer?: {
|
||||||
|
notifyCharacterDictionaryStatus: (event: StartupOsdSequencerCharacterDictionaryEvent) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowOsd(type: 'osd' | 'system' | 'both' | 'none' | undefined): boolean {
|
||||||
|
return type !== 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyCharacterDictionaryAutoSyncStatus(
|
||||||
|
event: CharacterDictionaryAutoSyncNotificationEvent,
|
||||||
|
deps: CharacterDictionaryAutoSyncNotificationDeps,
|
||||||
|
): void {
|
||||||
|
const type = deps.getNotificationType();
|
||||||
|
if (shouldShowOsd(type)) {
|
||||||
|
if (deps.startupOsdSequencer) {
|
||||||
|
deps.startupOsdSequencer.notifyCharacterDictionaryStatus({
|
||||||
|
phase: event.phase,
|
||||||
|
message: event.message,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.showOsd(event.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,14 @@ function makeTempDir(): string {
|
|||||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-char-dict-auto-sync-'));
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-char-dict-auto-sync-'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((nextResolve) => {
|
||||||
|
resolve = nextResolve;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
test('auto sync imports merged dictionary and persists MRU state', async () => {
|
test('auto sync imports merged dictionary and persists MRU state', async () => {
|
||||||
const userDataPath = makeTempDir();
|
const userDataPath = makeTempDir();
|
||||||
const imported: string[] = [];
|
const imported: string[] = [];
|
||||||
@@ -267,3 +275,296 @@ test('auto sync evicts least recently used media from merged set', async () => {
|
|||||||
};
|
};
|
||||||
assert.deepEqual(state.activeMediaIds, [4, 3, 2]);
|
assert.deepEqual(state.activeMediaIds, [4, 3, 2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('auto sync invokes completion callback after successful sync', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const completions: Array<{ mediaId: number; mediaTitle: string; changed: boolean }> = [];
|
||||||
|
let importedRevision: string | null = null;
|
||||||
|
|
||||||
|
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getConfig: () => ({
|
||||||
|
enabled: true,
|
||||||
|
maxLoaded: 3,
|
||||||
|
profileScope: 'all',
|
||||||
|
}),
|
||||||
|
getOrCreateCurrentSnapshot: async () => ({
|
||||||
|
mediaId: 101291,
|
||||||
|
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
entryCount: 2560,
|
||||||
|
fromCache: false,
|
||||||
|
updatedAt: 1000,
|
||||||
|
}),
|
||||||
|
buildMergedDictionary: async () => ({
|
||||||
|
zipPath: '/tmp/merged.zip',
|
||||||
|
revision: 'rev-101291',
|
||||||
|
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||||
|
entryCount: 2560,
|
||||||
|
}),
|
||||||
|
getYomitanDictionaryInfo: async () =>
|
||||||
|
importedRevision
|
||||||
|
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
|
||||||
|
: [],
|
||||||
|
importYomitanDictionary: async () => {
|
||||||
|
importedRevision = 'rev-101291';
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
deleteYomitanDictionary: async () => true,
|
||||||
|
upsertYomitanDictionarySettings: async () => true,
|
||||||
|
now: () => 1000,
|
||||||
|
onSyncComplete: (completion) => {
|
||||||
|
completions.push(completion);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.runSyncNow();
|
||||||
|
|
||||||
|
assert.deepEqual(completions, [
|
||||||
|
{
|
||||||
|
mediaId: 101291,
|
||||||
|
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
changed: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto sync emits progress events for start import and completion', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const events: Array<{
|
||||||
|
phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed';
|
||||||
|
mediaId?: number;
|
||||||
|
mediaTitle?: string;
|
||||||
|
message: string;
|
||||||
|
changed?: boolean;
|
||||||
|
}> = [];
|
||||||
|
let importedRevision: string | null = null;
|
||||||
|
|
||||||
|
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getConfig: () => ({
|
||||||
|
enabled: true,
|
||||||
|
maxLoaded: 3,
|
||||||
|
profileScope: 'all',
|
||||||
|
}),
|
||||||
|
getOrCreateCurrentSnapshot: async (_targetPath, progress) => {
|
||||||
|
progress?.onChecking?.({
|
||||||
|
mediaId: 101291,
|
||||||
|
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
});
|
||||||
|
progress?.onGenerating?.({
|
||||||
|
mediaId: 101291,
|
||||||
|
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
mediaId: 101291,
|
||||||
|
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
entryCount: 2560,
|
||||||
|
fromCache: false,
|
||||||
|
updatedAt: 1000,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
buildMergedDictionary: async () => ({
|
||||||
|
zipPath: '/tmp/merged.zip',
|
||||||
|
revision: 'rev-101291',
|
||||||
|
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||||
|
entryCount: 2560,
|
||||||
|
}),
|
||||||
|
getYomitanDictionaryInfo: async () =>
|
||||||
|
importedRevision
|
||||||
|
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
|
||||||
|
: [],
|
||||||
|
importYomitanDictionary: async () => {
|
||||||
|
importedRevision = 'rev-101291';
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
deleteYomitanDictionary: async () => true,
|
||||||
|
upsertYomitanDictionarySettings: async () => true,
|
||||||
|
now: () => 1000,
|
||||||
|
onSyncStatus: (event) => {
|
||||||
|
events.push(event);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.runSyncNow();
|
||||||
|
|
||||||
|
assert.deepEqual(events, [
|
||||||
|
{
|
||||||
|
phase: 'checking',
|
||||||
|
mediaId: 101291,
|
||||||
|
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
message: 'Checking character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: 'generating',
|
||||||
|
mediaId: 101291,
|
||||||
|
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
message: 'Generating character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: 'syncing',
|
||||||
|
mediaId: 101291,
|
||||||
|
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
message: 'Updating character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: 'importing',
|
||||||
|
mediaId: 101291,
|
||||||
|
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
message: 'Importing character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
phase: 'ready',
|
||||||
|
mediaId: 101291,
|
||||||
|
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
message: 'Character dictionary ready for Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
changed: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto sync emits checking before snapshot resolves and skips generating on cache hit', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const events: Array<{
|
||||||
|
phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed';
|
||||||
|
mediaId?: number;
|
||||||
|
mediaTitle?: string;
|
||||||
|
message: string;
|
||||||
|
changed?: boolean;
|
||||||
|
}> = [];
|
||||||
|
const snapshotDeferred = createDeferred<{
|
||||||
|
mediaId: number;
|
||||||
|
mediaTitle: string;
|
||||||
|
entryCount: number;
|
||||||
|
fromCache: boolean;
|
||||||
|
updatedAt: number;
|
||||||
|
}>();
|
||||||
|
let importedRevision: string | null = null;
|
||||||
|
|
||||||
|
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getConfig: () => ({
|
||||||
|
enabled: true,
|
||||||
|
maxLoaded: 3,
|
||||||
|
profileScope: 'all',
|
||||||
|
}),
|
||||||
|
getOrCreateCurrentSnapshot: async (_targetPath, progress) => {
|
||||||
|
progress?.onChecking?.({
|
||||||
|
mediaId: 101291,
|
||||||
|
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
});
|
||||||
|
return await snapshotDeferred.promise;
|
||||||
|
},
|
||||||
|
buildMergedDictionary: async () => ({
|
||||||
|
zipPath: '/tmp/merged.zip',
|
||||||
|
revision: 'rev-101291',
|
||||||
|
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||||
|
entryCount: 2560,
|
||||||
|
}),
|
||||||
|
getYomitanDictionaryInfo: async () =>
|
||||||
|
importedRevision
|
||||||
|
? [{ title: 'SubMiner Character Dictionary', revision: importedRevision }]
|
||||||
|
: [],
|
||||||
|
importYomitanDictionary: async () => {
|
||||||
|
importedRevision = 'rev-101291';
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
deleteYomitanDictionary: async () => true,
|
||||||
|
upsertYomitanDictionarySettings: async () => true,
|
||||||
|
now: () => 1000,
|
||||||
|
onSyncStatus: (event) => {
|
||||||
|
events.push(event);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncPromise = runtime.runSyncNow();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.deepEqual(events, [
|
||||||
|
{
|
||||||
|
phase: 'checking',
|
||||||
|
mediaId: 101291,
|
||||||
|
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
message: 'Checking character dictionary for Rascal Does Not Dream of Bunny Girl Senpai...',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
snapshotDeferred.resolve({
|
||||||
|
mediaId: 101291,
|
||||||
|
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
entryCount: 2560,
|
||||||
|
fromCache: true,
|
||||||
|
updatedAt: 1000,
|
||||||
|
});
|
||||||
|
await syncPromise;
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
events.some((event) => event.phase === 'generating'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto sync waits for tokenization-ready gate before Yomitan mutations', async () => {
|
||||||
|
const userDataPath = makeTempDir();
|
||||||
|
const gate = (() => {
|
||||||
|
let resolve!: () => void;
|
||||||
|
const promise = new Promise<void>((nextResolve) => {
|
||||||
|
resolve = nextResolve;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
})();
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
const runtime = createCharacterDictionaryAutoSyncRuntimeService({
|
||||||
|
userDataPath,
|
||||||
|
getConfig: () => ({
|
||||||
|
enabled: true,
|
||||||
|
maxLoaded: 3,
|
||||||
|
profileScope: 'all',
|
||||||
|
}),
|
||||||
|
getOrCreateCurrentSnapshot: async () => ({
|
||||||
|
mediaId: 101291,
|
||||||
|
mediaTitle: 'Rascal Does Not Dream of Bunny Girl Senpai',
|
||||||
|
entryCount: 2560,
|
||||||
|
fromCache: false,
|
||||||
|
updatedAt: 1000,
|
||||||
|
}),
|
||||||
|
buildMergedDictionary: async () => {
|
||||||
|
calls.push('build');
|
||||||
|
return {
|
||||||
|
zipPath: '/tmp/merged.zip',
|
||||||
|
revision: 'rev-101291',
|
||||||
|
dictionaryTitle: 'SubMiner Character Dictionary',
|
||||||
|
entryCount: 2560,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
waitForYomitanMutationReady: async () => {
|
||||||
|
calls.push('wait');
|
||||||
|
await gate.promise;
|
||||||
|
},
|
||||||
|
getYomitanDictionaryInfo: async () => {
|
||||||
|
calls.push('info');
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
importYomitanDictionary: async () => {
|
||||||
|
calls.push('import');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
deleteYomitanDictionary: async () => true,
|
||||||
|
upsertYomitanDictionarySettings: async () => {
|
||||||
|
calls.push('settings');
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
now: () => 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncPromise = runtime.runSyncNow();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['build', 'wait']);
|
||||||
|
|
||||||
|
gate.resolve();
|
||||||
|
await syncPromise;
|
||||||
|
|
||||||
|
assert.deepEqual(calls, ['build', 'wait', 'info', 'import', 'settings']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type { AnilistCharacterDictionaryProfileScope } from '../../types';
|
import type { AnilistCharacterDictionaryProfileScope } from '../../types';
|
||||||
import type {
|
import type {
|
||||||
|
CharacterDictionarySnapshotProgressCallbacks,
|
||||||
CharacterDictionarySnapshotResult,
|
CharacterDictionarySnapshotResult,
|
||||||
MergedCharacterDictionaryBuildResult,
|
MergedCharacterDictionaryBuildResult,
|
||||||
} from '../character-dictionary-runtime';
|
} from '../character-dictionary-runtime';
|
||||||
@@ -23,11 +24,23 @@ export interface CharacterDictionaryAutoSyncConfig {
|
|||||||
profileScope: AnilistCharacterDictionaryProfileScope;
|
profileScope: AnilistCharacterDictionaryProfileScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CharacterDictionaryAutoSyncStatusEvent {
|
||||||
|
phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed';
|
||||||
|
mediaId?: number;
|
||||||
|
mediaTitle?: string;
|
||||||
|
message: string;
|
||||||
|
changed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CharacterDictionaryAutoSyncRuntimeDeps {
|
export interface CharacterDictionaryAutoSyncRuntimeDeps {
|
||||||
userDataPath: string;
|
userDataPath: string;
|
||||||
getConfig: () => CharacterDictionaryAutoSyncConfig;
|
getConfig: () => CharacterDictionaryAutoSyncConfig;
|
||||||
getOrCreateCurrentSnapshot: (targetPath?: string) => Promise<CharacterDictionarySnapshotResult>;
|
getOrCreateCurrentSnapshot: (
|
||||||
|
targetPath?: string,
|
||||||
|
progress?: CharacterDictionarySnapshotProgressCallbacks,
|
||||||
|
) => Promise<CharacterDictionarySnapshotResult>;
|
||||||
buildMergedDictionary: (mediaIds: number[]) => Promise<MergedCharacterDictionaryBuildResult>;
|
buildMergedDictionary: (mediaIds: number[]) => Promise<MergedCharacterDictionaryBuildResult>;
|
||||||
|
waitForYomitanMutationReady?: () => Promise<void>;
|
||||||
getYomitanDictionaryInfo: () => Promise<AutoSyncDictionaryInfo[]>;
|
getYomitanDictionaryInfo: () => Promise<AutoSyncDictionaryInfo[]>;
|
||||||
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
|
importYomitanDictionary: (zipPath: string) => Promise<boolean>;
|
||||||
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
|
deleteYomitanDictionary: (dictionaryTitle: string) => Promise<boolean>;
|
||||||
@@ -41,6 +54,8 @@ export interface CharacterDictionaryAutoSyncRuntimeDeps {
|
|||||||
operationTimeoutMs?: number;
|
operationTimeoutMs?: number;
|
||||||
logInfo?: (message: string) => void;
|
logInfo?: (message: string) => void;
|
||||||
logWarn?: (message: string) => void;
|
logWarn?: (message: string) => void;
|
||||||
|
onSyncStatus?: (event: CharacterDictionaryAutoSyncStatusEvent) => void;
|
||||||
|
onSyncComplete?: (result: { mediaId: number; mediaTitle: string; changed: boolean }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureDir(dirPath: string): void {
|
function ensureDir(dirPath: string): void {
|
||||||
@@ -92,6 +107,33 @@ function arraysEqual(left: number[], right: number[]): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSyncingMessage(mediaTitle: string): string {
|
||||||
|
return `Updating character dictionary for ${mediaTitle}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCheckingMessage(mediaTitle: string): string {
|
||||||
|
return `Checking character dictionary for ${mediaTitle}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGeneratingMessage(mediaTitle: string): string {
|
||||||
|
return `Generating character dictionary for ${mediaTitle}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildImportingMessage(mediaTitle: string): string {
|
||||||
|
return `Importing character dictionary for ${mediaTitle}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReadyMessage(mediaTitle: string): string {
|
||||||
|
return `Character dictionary ready for ${mediaTitle}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFailedMessage(mediaTitle: string | null, errorMessage: string): string {
|
||||||
|
if (mediaTitle) {
|
||||||
|
return `Character dictionary sync failed for ${mediaTitle}: ${errorMessage}`;
|
||||||
|
}
|
||||||
|
return `Character dictionary sync failed: ${errorMessage}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function createCharacterDictionaryAutoSyncRuntimeService(
|
export function createCharacterDictionaryAutoSyncRuntimeService(
|
||||||
deps: CharacterDictionaryAutoSyncRuntimeDeps,
|
deps: CharacterDictionaryAutoSyncRuntimeDeps,
|
||||||
): {
|
): {
|
||||||
@@ -133,8 +175,41 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let currentMediaId: number | undefined;
|
||||||
|
let currentMediaTitle: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
deps.logInfo?.('[dictionary:auto-sync] syncing current anime snapshot');
|
deps.logInfo?.('[dictionary:auto-sync] syncing current anime snapshot');
|
||||||
const snapshot = await deps.getOrCreateCurrentSnapshot();
|
const snapshot = await deps.getOrCreateCurrentSnapshot(undefined, {
|
||||||
|
onChecking: ({ mediaId, mediaTitle }) => {
|
||||||
|
currentMediaId = mediaId;
|
||||||
|
currentMediaTitle = mediaTitle;
|
||||||
|
deps.onSyncStatus?.({
|
||||||
|
phase: 'checking',
|
||||||
|
mediaId,
|
||||||
|
mediaTitle,
|
||||||
|
message: buildCheckingMessage(mediaTitle),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onGenerating: ({ mediaId, mediaTitle }) => {
|
||||||
|
currentMediaId = mediaId;
|
||||||
|
currentMediaTitle = mediaTitle;
|
||||||
|
deps.onSyncStatus?.({
|
||||||
|
phase: 'generating',
|
||||||
|
mediaId,
|
||||||
|
mediaTitle,
|
||||||
|
message: buildGeneratingMessage(mediaTitle),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
currentMediaId = snapshot.mediaId;
|
||||||
|
currentMediaTitle = snapshot.mediaTitle;
|
||||||
|
deps.onSyncStatus?.({
|
||||||
|
phase: 'syncing',
|
||||||
|
mediaId: snapshot.mediaId,
|
||||||
|
mediaTitle: snapshot.mediaTitle,
|
||||||
|
message: buildSyncingMessage(snapshot.mediaTitle),
|
||||||
|
});
|
||||||
const state = readAutoSyncState(statePath);
|
const state = readAutoSyncState(statePath);
|
||||||
const nextActiveMediaIds = [
|
const nextActiveMediaIds = [
|
||||||
snapshot.mediaId,
|
snapshot.mediaId,
|
||||||
@@ -162,6 +237,8 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
|||||||
throw new Error('Merged character dictionary state is incomplete.');
|
throw new Error('Merged character dictionary state is incomplete.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await deps.waitForYomitanMutationReady?.();
|
||||||
|
|
||||||
const dictionaryInfo = await withOperationTimeout(
|
const dictionaryInfo = await withOperationTimeout(
|
||||||
'getYomitanDictionaryInfo',
|
'getYomitanDictionaryInfo',
|
||||||
deps.getYomitanDictionaryInfo(),
|
deps.getYomitanDictionaryInfo(),
|
||||||
@@ -176,8 +253,15 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
|||||||
existing === null ||
|
existing === null ||
|
||||||
existingRevision === null ||
|
existingRevision === null ||
|
||||||
existingRevision !== revision;
|
existingRevision !== revision;
|
||||||
|
let changed = merged !== null;
|
||||||
|
|
||||||
if (shouldImport) {
|
if (shouldImport) {
|
||||||
|
deps.onSyncStatus?.({
|
||||||
|
phase: 'importing',
|
||||||
|
mediaId: snapshot.mediaId,
|
||||||
|
mediaTitle: snapshot.mediaTitle,
|
||||||
|
message: buildImportingMessage(snapshot.mediaTitle),
|
||||||
|
});
|
||||||
if (existing !== null) {
|
if (existing !== null) {
|
||||||
await withOperationTimeout(
|
await withOperationTimeout(
|
||||||
`deleteYomitanDictionary(${dictionaryTitle})`,
|
`deleteYomitanDictionary(${dictionaryTitle})`,
|
||||||
@@ -195,13 +279,15 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
|||||||
if (!imported) {
|
if (!imported) {
|
||||||
throw new Error(`Failed to import dictionary ZIP: ${merged.zipPath}`);
|
throw new Error(`Failed to import dictionary ZIP: ${merged.zipPath}`);
|
||||||
}
|
}
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
deps.logInfo?.(`[dictionary:auto-sync] applying Yomitan settings for ${dictionaryTitle}`);
|
deps.logInfo?.(`[dictionary:auto-sync] applying Yomitan settings for ${dictionaryTitle}`);
|
||||||
await withOperationTimeout(
|
const settingsUpdated = await withOperationTimeout(
|
||||||
`upsertYomitanDictionarySettings(${dictionaryTitle})`,
|
`upsertYomitanDictionarySettings(${dictionaryTitle})`,
|
||||||
deps.upsertYomitanDictionarySettings(dictionaryTitle, config.profileScope),
|
deps.upsertYomitanDictionarySettings(dictionaryTitle, config.profileScope),
|
||||||
);
|
);
|
||||||
|
changed = changed || settingsUpdated === true;
|
||||||
|
|
||||||
writeAutoSyncState(statePath, {
|
writeAutoSyncState(statePath, {
|
||||||
activeMediaIds: nextActiveMediaIds,
|
activeMediaIds: nextActiveMediaIds,
|
||||||
@@ -211,6 +297,28 @@ export function createCharacterDictionaryAutoSyncRuntimeService(
|
|||||||
deps.logInfo?.(
|
deps.logInfo?.(
|
||||||
`[dictionary:auto-sync] synced AniList ${snapshot.mediaId}: ${dictionaryTitle} (${snapshot.entryCount} entries)`,
|
`[dictionary:auto-sync] synced AniList ${snapshot.mediaId}: ${dictionaryTitle} (${snapshot.entryCount} entries)`,
|
||||||
);
|
);
|
||||||
|
deps.onSyncStatus?.({
|
||||||
|
phase: 'ready',
|
||||||
|
mediaId: snapshot.mediaId,
|
||||||
|
mediaTitle: snapshot.mediaTitle,
|
||||||
|
message: buildReadyMessage(snapshot.mediaTitle),
|
||||||
|
changed,
|
||||||
|
});
|
||||||
|
deps.onSyncComplete?.({
|
||||||
|
mediaId: snapshot.mediaId,
|
||||||
|
mediaTitle: snapshot.mediaTitle,
|
||||||
|
changed,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = (error as Error)?.message ?? String(error);
|
||||||
|
deps.onSyncStatus?.({
|
||||||
|
phase: 'failed',
|
||||||
|
mediaId: currentMediaId,
|
||||||
|
mediaTitle: currentMediaTitle ?? undefined,
|
||||||
|
message: buildFailedMessage(currentMediaTitle, errorMessage),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const enqueueSync = (): void => {
|
const enqueueSync = (): void => {
|
||||||
|
|||||||
42
src/main/runtime/current-media-tokenization-gate.test.ts
Normal file
42
src/main/runtime/current-media-tokenization-gate.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createCurrentMediaTokenizationGate } from './current-media-tokenization-gate';
|
||||||
|
|
||||||
|
test('current media tokenization gate waits until current path is marked ready', async () => {
|
||||||
|
const gate = createCurrentMediaTokenizationGate();
|
||||||
|
gate.updateCurrentMediaPath('/tmp/video-1.mkv');
|
||||||
|
|
||||||
|
let resolved = false;
|
||||||
|
const waitPromise = gate.waitUntilReady('/tmp/video-1.mkv').then(() => {
|
||||||
|
resolved = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(resolved, false);
|
||||||
|
|
||||||
|
gate.markReady('/tmp/video-1.mkv');
|
||||||
|
await waitPromise;
|
||||||
|
assert.equal(resolved, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('current media tokenization gate resolves old waiters when media changes', async () => {
|
||||||
|
const gate = createCurrentMediaTokenizationGate();
|
||||||
|
gate.updateCurrentMediaPath('/tmp/video-1.mkv');
|
||||||
|
|
||||||
|
let resolved = false;
|
||||||
|
const waitPromise = gate.waitUntilReady('/tmp/video-1.mkv').then(() => {
|
||||||
|
resolved = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
gate.updateCurrentMediaPath('/tmp/video-2.mkv');
|
||||||
|
await waitPromise;
|
||||||
|
assert.equal(resolved, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('current media tokenization gate returns immediately for ready media', async () => {
|
||||||
|
const gate = createCurrentMediaTokenizationGate();
|
||||||
|
gate.updateCurrentMediaPath('/tmp/video-1.mkv');
|
||||||
|
gate.markReady('/tmp/video-1.mkv');
|
||||||
|
|
||||||
|
await gate.waitUntilReady('/tmp/video-1.mkv');
|
||||||
|
});
|
||||||
70
src/main/runtime/current-media-tokenization-gate.ts
Normal file
70
src/main/runtime/current-media-tokenization-gate.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
function normalizeMediaPath(mediaPath: string | null | undefined): string | null {
|
||||||
|
if (typeof mediaPath !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const trimmed = mediaPath.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCurrentMediaTokenizationGate(): {
|
||||||
|
updateCurrentMediaPath: (mediaPath: string | null | undefined) => void;
|
||||||
|
markReady: (mediaPath: string | null | undefined) => void;
|
||||||
|
waitUntilReady: (mediaPath: string | null | undefined) => Promise<void>;
|
||||||
|
} {
|
||||||
|
let currentMediaPath: string | null = null;
|
||||||
|
let readyMediaPath: string | null = null;
|
||||||
|
let pendingMediaPath: string | null = null;
|
||||||
|
let pendingPromise: Promise<void> | null = null;
|
||||||
|
let resolvePending: (() => void) | null = null;
|
||||||
|
|
||||||
|
const resolvePendingWaiter = (): void => {
|
||||||
|
resolvePending?.();
|
||||||
|
resolvePending = null;
|
||||||
|
pendingPromise = null;
|
||||||
|
pendingMediaPath = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensurePendingPromise = (mediaPath: string): Promise<void> => {
|
||||||
|
if (pendingMediaPath === mediaPath && pendingPromise) {
|
||||||
|
return pendingPromise;
|
||||||
|
}
|
||||||
|
resolvePendingWaiter();
|
||||||
|
pendingMediaPath = mediaPath;
|
||||||
|
pendingPromise = new Promise<void>((resolve) => {
|
||||||
|
resolvePending = resolve;
|
||||||
|
});
|
||||||
|
return pendingPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateCurrentMediaPath: (mediaPath) => {
|
||||||
|
const normalizedPath = normalizeMediaPath(mediaPath);
|
||||||
|
if (normalizedPath === currentMediaPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentMediaPath = normalizedPath;
|
||||||
|
readyMediaPath = null;
|
||||||
|
resolvePendingWaiter();
|
||||||
|
if (normalizedPath) {
|
||||||
|
ensurePendingPromise(normalizedPath);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
markReady: (mediaPath) => {
|
||||||
|
const normalizedPath = normalizeMediaPath(mediaPath);
|
||||||
|
if (!normalizedPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
readyMediaPath = normalizedPath;
|
||||||
|
if (pendingMediaPath === normalizedPath) {
|
||||||
|
resolvePendingWaiter();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
waitUntilReady: async (mediaPath) => {
|
||||||
|
const normalizedPath = normalizeMediaPath(mediaPath) ?? currentMediaPath;
|
||||||
|
if (!normalizedPath || readyMediaPath === normalizedPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await ensurePendingPromise(normalizedPath);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
134
src/main/runtime/startup-osd-sequencer.test.ts
Normal file
134
src/main/runtime/startup-osd-sequencer.test.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
createStartupOsdSequencer,
|
||||||
|
type StartupOsdSequencerCharacterDictionaryEvent,
|
||||||
|
} from './startup-osd-sequencer';
|
||||||
|
|
||||||
|
function makeDictionaryEvent(
|
||||||
|
phase: StartupOsdSequencerCharacterDictionaryEvent['phase'],
|
||||||
|
message: string,
|
||||||
|
): StartupOsdSequencerCharacterDictionaryEvent {
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('startup OSD keeps dictionary progress hidden until tokenization and annotation loading finish', () => {
|
||||||
|
const osdMessages: string[] = [];
|
||||||
|
const sequencer = createStartupOsdSequencer({
|
||||||
|
showOsd: (message) => {
|
||||||
|
osdMessages.push(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
sequencer.notifyCharacterDictionaryStatus(
|
||||||
|
makeDictionaryEvent('syncing', 'Updating character dictionary for Frieren...'),
|
||||||
|
);
|
||||||
|
sequencer.showAnnotationLoading('Loading subtitle annotations |');
|
||||||
|
sequencer.markTokenizationReady();
|
||||||
|
|
||||||
|
assert.deepEqual(osdMessages, ['Loading subtitle annotations |']);
|
||||||
|
|
||||||
|
sequencer.showAnnotationLoading('Loading subtitle annotations /');
|
||||||
|
assert.deepEqual(osdMessages, [
|
||||||
|
'Loading subtitle annotations |',
|
||||||
|
'Loading subtitle annotations /',
|
||||||
|
]);
|
||||||
|
|
||||||
|
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
|
||||||
|
assert.deepEqual(osdMessages, [
|
||||||
|
'Loading subtitle annotations |',
|
||||||
|
'Loading subtitle annotations /',
|
||||||
|
'Updating character dictionary for Frieren...',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startup OSD buffers checking behind annotations and replaces it with later generating progress', () => {
|
||||||
|
const osdMessages: string[] = [];
|
||||||
|
const sequencer = createStartupOsdSequencer({
|
||||||
|
showOsd: (message) => {
|
||||||
|
osdMessages.push(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
sequencer.notifyCharacterDictionaryStatus(
|
||||||
|
makeDictionaryEvent('checking', 'Checking character dictionary for Frieren...'),
|
||||||
|
);
|
||||||
|
sequencer.showAnnotationLoading('Loading subtitle annotations |');
|
||||||
|
sequencer.markTokenizationReady();
|
||||||
|
sequencer.notifyCharacterDictionaryStatus(
|
||||||
|
makeDictionaryEvent('generating', 'Generating character dictionary for Frieren...'),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(osdMessages, ['Loading subtitle annotations |']);
|
||||||
|
|
||||||
|
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
|
||||||
|
|
||||||
|
assert.deepEqual(osdMessages, [
|
||||||
|
'Loading subtitle annotations |',
|
||||||
|
'Generating character dictionary for Frieren...',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startup OSD skips buffered dictionary ready messages when progress completed before it became visible', () => {
|
||||||
|
const osdMessages: string[] = [];
|
||||||
|
const sequencer = createStartupOsdSequencer({
|
||||||
|
showOsd: (message) => {
|
||||||
|
osdMessages.push(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
sequencer.notifyCharacterDictionaryStatus(
|
||||||
|
makeDictionaryEvent('syncing', 'Updating character dictionary for Frieren...'),
|
||||||
|
);
|
||||||
|
sequencer.notifyCharacterDictionaryStatus(
|
||||||
|
makeDictionaryEvent('ready', 'Character dictionary ready for Frieren'),
|
||||||
|
);
|
||||||
|
sequencer.markTokenizationReady();
|
||||||
|
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
|
||||||
|
|
||||||
|
assert.deepEqual(osdMessages, ['Subtitle annotations loaded']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startup OSD shows dictionary failure after annotation loading completes', () => {
|
||||||
|
const osdMessages: string[] = [];
|
||||||
|
const sequencer = createStartupOsdSequencer({
|
||||||
|
showOsd: (message) => {
|
||||||
|
osdMessages.push(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
sequencer.showAnnotationLoading('Loading subtitle annotations |');
|
||||||
|
sequencer.notifyCharacterDictionaryStatus(
|
||||||
|
makeDictionaryEvent('failed', 'Character dictionary sync failed for Frieren: boom'),
|
||||||
|
);
|
||||||
|
sequencer.markTokenizationReady();
|
||||||
|
sequencer.markAnnotationLoadingComplete('Subtitle annotations loaded');
|
||||||
|
|
||||||
|
assert.deepEqual(osdMessages, [
|
||||||
|
'Loading subtitle annotations |',
|
||||||
|
'Character dictionary sync failed for Frieren: boom',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startup OSD reset requires the next media to wait for tokenization again', () => {
|
||||||
|
const osdMessages: string[] = [];
|
||||||
|
const sequencer = createStartupOsdSequencer({
|
||||||
|
showOsd: (message) => {
|
||||||
|
osdMessages.push(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
sequencer.markTokenizationReady();
|
||||||
|
sequencer.reset();
|
||||||
|
sequencer.notifyCharacterDictionaryStatus(
|
||||||
|
makeDictionaryEvent('syncing', 'Updating character dictionary for Frieren...'),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(osdMessages, []);
|
||||||
|
|
||||||
|
sequencer.markTokenizationReady();
|
||||||
|
assert.deepEqual(osdMessages, ['Updating character dictionary for Frieren...']);
|
||||||
|
});
|
||||||
106
src/main/runtime/startup-osd-sequencer.ts
Normal file
106
src/main/runtime/startup-osd-sequencer.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
export interface StartupOsdSequencerCharacterDictionaryEvent {
|
||||||
|
phase: 'checking' | 'generating' | 'syncing' | 'importing' | 'ready' | 'failed';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStartupOsdSequencer(deps: { showOsd: (message: string) => void }): {
|
||||||
|
reset: () => void;
|
||||||
|
markTokenizationReady: () => void;
|
||||||
|
showAnnotationLoading: (message: string) => void;
|
||||||
|
markAnnotationLoadingComplete: (message: string) => void;
|
||||||
|
notifyCharacterDictionaryStatus: (event: StartupOsdSequencerCharacterDictionaryEvent) => void;
|
||||||
|
} {
|
||||||
|
let tokenizationReady = false;
|
||||||
|
let annotationLoadingMessage: string | null = null;
|
||||||
|
let pendingDictionaryProgress: StartupOsdSequencerCharacterDictionaryEvent | null = null;
|
||||||
|
let pendingDictionaryFailure: StartupOsdSequencerCharacterDictionaryEvent | null = null;
|
||||||
|
let dictionaryProgressShown = false;
|
||||||
|
|
||||||
|
const canShowDictionaryStatus = (): boolean =>
|
||||||
|
tokenizationReady && annotationLoadingMessage === null;
|
||||||
|
|
||||||
|
const flushBufferedDictionaryStatus = (): boolean => {
|
||||||
|
if (!canShowDictionaryStatus()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (pendingDictionaryProgress) {
|
||||||
|
deps.showOsd(pendingDictionaryProgress.message);
|
||||||
|
dictionaryProgressShown = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (pendingDictionaryFailure) {
|
||||||
|
deps.showOsd(pendingDictionaryFailure.message);
|
||||||
|
pendingDictionaryFailure = null;
|
||||||
|
dictionaryProgressShown = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
reset: () => {
|
||||||
|
tokenizationReady = false;
|
||||||
|
annotationLoadingMessage = null;
|
||||||
|
pendingDictionaryProgress = null;
|
||||||
|
pendingDictionaryFailure = null;
|
||||||
|
dictionaryProgressShown = false;
|
||||||
|
},
|
||||||
|
markTokenizationReady: () => {
|
||||||
|
tokenizationReady = true;
|
||||||
|
if (annotationLoadingMessage !== null) {
|
||||||
|
deps.showOsd(annotationLoadingMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
flushBufferedDictionaryStatus();
|
||||||
|
},
|
||||||
|
showAnnotationLoading: (message) => {
|
||||||
|
annotationLoadingMessage = message;
|
||||||
|
if (tokenizationReady) {
|
||||||
|
deps.showOsd(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
markAnnotationLoadingComplete: (message) => {
|
||||||
|
annotationLoadingMessage = null;
|
||||||
|
if (!tokenizationReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (flushBufferedDictionaryStatus()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.showOsd(message);
|
||||||
|
},
|
||||||
|
notifyCharacterDictionaryStatus: (event) => {
|
||||||
|
if (
|
||||||
|
event.phase === 'checking' ||
|
||||||
|
event.phase === 'generating' ||
|
||||||
|
event.phase === 'syncing' ||
|
||||||
|
event.phase === 'importing'
|
||||||
|
) {
|
||||||
|
pendingDictionaryProgress = event;
|
||||||
|
pendingDictionaryFailure = null;
|
||||||
|
if (canShowDictionaryStatus()) {
|
||||||
|
deps.showOsd(event.message);
|
||||||
|
dictionaryProgressShown = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingDictionaryProgress = null;
|
||||||
|
if (event.phase === 'failed') {
|
||||||
|
if (canShowDictionaryStatus()) {
|
||||||
|
deps.showOsd(event.message);
|
||||||
|
} else {
|
||||||
|
pendingDictionaryFailure = event;
|
||||||
|
}
|
||||||
|
dictionaryProgressShown = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingDictionaryFailure = null;
|
||||||
|
if (canShowDictionaryStatus() && dictionaryProgressShown) {
|
||||||
|
deps.showOsd(event.message);
|
||||||
|
}
|
||||||
|
dictionaryProgressShown = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -80,6 +80,8 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
|
|||||||
ensureJlptDictionaryLookup: () => Promise<void>;
|
ensureJlptDictionaryLookup: () => Promise<void>;
|
||||||
ensureFrequencyDictionaryLookup: () => Promise<void>;
|
ensureFrequencyDictionaryLookup: () => Promise<void>;
|
||||||
showMpvOsd?: (message: string) => void;
|
showMpvOsd?: (message: string) => void;
|
||||||
|
showLoadingOsd?: (message: string) => void;
|
||||||
|
showLoadedOsd?: (message: string) => void;
|
||||||
shouldShowOsdNotification?: () => boolean;
|
shouldShowOsdNotification?: () => boolean;
|
||||||
setInterval?: (callback: () => void, delayMs: number) => unknown;
|
setInterval?: (callback: () => void, delayMs: number) => unknown;
|
||||||
clearInterval?: (timer: unknown) => void;
|
clearInterval?: (timer: unknown) => void;
|
||||||
@@ -90,6 +92,8 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
|
|||||||
let loadingOsdFrame = 0;
|
let loadingOsdFrame = 0;
|
||||||
let loadingOsdTimer: unknown = null;
|
let loadingOsdTimer: unknown = null;
|
||||||
const showMpvOsd = deps.showMpvOsd;
|
const showMpvOsd = deps.showMpvOsd;
|
||||||
|
const showLoadingOsd = deps.showLoadingOsd ?? showMpvOsd;
|
||||||
|
const showLoadedOsd = deps.showLoadedOsd ?? showMpvOsd;
|
||||||
const setIntervalHandler =
|
const setIntervalHandler =
|
||||||
deps.setInterval ??
|
deps.setInterval ??
|
||||||
((callback: () => void, delayMs: number): unknown => setInterval(callback, delayMs));
|
((callback: () => void, delayMs: number): unknown => setInterval(callback, delayMs));
|
||||||
@@ -99,7 +103,7 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
|
|||||||
const spinnerFrames = ['|', '/', '-', '\\'];
|
const spinnerFrames = ['|', '/', '-', '\\'];
|
||||||
|
|
||||||
const beginLoadingOsd = (): boolean => {
|
const beginLoadingOsd = (): boolean => {
|
||||||
if (!showMpvOsd) {
|
if (!showLoadingOsd) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
loadingOsdDepth += 1;
|
loadingOsdDepth += 1;
|
||||||
@@ -108,13 +112,13 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadingOsdFrame = 0;
|
loadingOsdFrame = 0;
|
||||||
showMpvOsd(`Loading subtitle annotations ${spinnerFrames[loadingOsdFrame]}`);
|
showLoadingOsd(`Loading subtitle annotations ${spinnerFrames[loadingOsdFrame]}`);
|
||||||
loadingOsdFrame += 1;
|
loadingOsdFrame += 1;
|
||||||
loadingOsdTimer = setIntervalHandler(() => {
|
loadingOsdTimer = setIntervalHandler(() => {
|
||||||
if (!showMpvOsd) {
|
if (!showLoadingOsd) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showMpvOsd(
|
showLoadingOsd(
|
||||||
`Loading subtitle annotations ${spinnerFrames[loadingOsdFrame % spinnerFrames.length]}`,
|
`Loading subtitle annotations ${spinnerFrames[loadingOsdFrame % spinnerFrames.length]}`,
|
||||||
);
|
);
|
||||||
loadingOsdFrame += 1;
|
loadingOsdFrame += 1;
|
||||||
@@ -123,7 +127,7 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const endLoadingOsd = (): void => {
|
const endLoadingOsd = (): void => {
|
||||||
if (!showMpvOsd) {
|
if (!showLoadedOsd) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +140,7 @@ export function createPrewarmSubtitleDictionariesMainHandler(deps: {
|
|||||||
clearIntervalHandler(loadingOsdTimer);
|
clearIntervalHandler(loadingOsdTimer);
|
||||||
loadingOsdTimer = null;
|
loadingOsdTimer = null;
|
||||||
}
|
}
|
||||||
showMpvOsd('Subtitle annotations loaded');
|
showLoadedOsd('Subtitle annotations loaded');
|
||||||
};
|
};
|
||||||
|
|
||||||
return async (options?: { showLoadingOsd?: boolean }): Promise<void> => {
|
return async (options?: { showLoadingOsd?: boolean }): Promise<void> => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
|
parseHyprctlClients,
|
||||||
selectHyprlandMpvWindow,
|
selectHyprlandMpvWindow,
|
||||||
type HyprlandClient,
|
type HyprlandClient,
|
||||||
} from './hyprland-tracker';
|
} from './hyprland-tracker';
|
||||||
@@ -9,6 +10,7 @@ function makeClient(overrides: Partial<HyprlandClient> = {}): HyprlandClient {
|
|||||||
return {
|
return {
|
||||||
address: '0x1',
|
address: '0x1',
|
||||||
class: 'mpv',
|
class: 'mpv',
|
||||||
|
initialClass: 'mpv',
|
||||||
at: [0, 0],
|
at: [0, 0],
|
||||||
size: [1280, 720],
|
size: [1280, 720],
|
||||||
mapped: true,
|
mapped: true,
|
||||||
@@ -70,3 +72,37 @@ test('selectHyprlandMpvWindow prefers active visible window among socket matches
|
|||||||
|
|
||||||
assert.equal(selected?.address, '0xsecond');
|
assert.equal(selected?.address, '0xsecond');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('selectHyprlandMpvWindow matches mpv by initialClass when class is blank', () => {
|
||||||
|
const selected = selectHyprlandMpvWindow(
|
||||||
|
[
|
||||||
|
makeClient({
|
||||||
|
address: '0xinitial',
|
||||||
|
class: '',
|
||||||
|
initialClass: 'mpv',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
targetMpvSocketPath: null,
|
||||||
|
activeWindowAddress: null,
|
||||||
|
getWindowCommandLine: () => null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(selected?.address, '0xinitial');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseHyprctlClients tolerates non-json prefix output', () => {
|
||||||
|
const clients = parseHyprctlClients(`ok
|
||||||
|
[{"address":"0x1","class":"mpv","initialClass":"mpv","at":[1,2],"size":[3,4]}]`);
|
||||||
|
|
||||||
|
assert.deepEqual(clients, [
|
||||||
|
{
|
||||||
|
address: '0x1',
|
||||||
|
class: 'mpv',
|
||||||
|
initialClass: 'mpv',
|
||||||
|
at: [1, 2],
|
||||||
|
size: [3, 4],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const log = createLogger('tracker').child('hyprland');
|
|||||||
export interface HyprlandClient {
|
export interface HyprlandClient {
|
||||||
address?: string;
|
address?: string;
|
||||||
class: string;
|
class: string;
|
||||||
|
initialClass?: string;
|
||||||
at: [number, number];
|
at: [number, number];
|
||||||
size: [number, number];
|
size: [number, number];
|
||||||
pid?: number;
|
pid?: number;
|
||||||
@@ -39,6 +40,23 @@ interface SelectHyprlandMpvWindowOptions {
|
|||||||
getWindowCommandLine: (pid: number) => string | null;
|
getWindowCommandLine: (pid: number) => string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractHyprctlJsonPayload(output: string): string | null {
|
||||||
|
const trimmed = output.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayStart = trimmed.indexOf('[');
|
||||||
|
const objectStart = trimmed.indexOf('{');
|
||||||
|
const startCandidates = [arrayStart, objectStart].filter((index) => index >= 0);
|
||||||
|
if (startCandidates.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIndex = Math.min(...startCandidates);
|
||||||
|
return trimmed.slice(startIndex);
|
||||||
|
}
|
||||||
|
|
||||||
function matchesTargetSocket(commandLine: string, targetMpvSocketPath: string): boolean {
|
function matchesTargetSocket(commandLine: string, targetMpvSocketPath: string): boolean {
|
||||||
return (
|
return (
|
||||||
commandLine.includes(`--input-ipc-server=${targetMpvSocketPath}`) ||
|
commandLine.includes(`--input-ipc-server=${targetMpvSocketPath}`) ||
|
||||||
@@ -60,12 +78,23 @@ function preferActiveHyprlandWindow(
|
|||||||
return clients[0] ?? null;
|
return clients[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMpvClassName(value: string | undefined): boolean {
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.trim().toLowerCase().includes('mpv');
|
||||||
|
}
|
||||||
|
|
||||||
export function selectHyprlandMpvWindow(
|
export function selectHyprlandMpvWindow(
|
||||||
clients: HyprlandClient[],
|
clients: HyprlandClient[],
|
||||||
options: SelectHyprlandMpvWindowOptions,
|
options: SelectHyprlandMpvWindowOptions,
|
||||||
): HyprlandClient | null {
|
): HyprlandClient | null {
|
||||||
const visibleMpvWindows = clients.filter(
|
const visibleMpvWindows = clients.filter(
|
||||||
(client) => client.class === 'mpv' && client.mapped !== false && client.hidden !== true,
|
(client) =>
|
||||||
|
(isMpvClassName(client.class) || isMpvClassName(client.initialClass)) &&
|
||||||
|
client.mapped !== false &&
|
||||||
|
client.hidden !== true,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!options.targetMpvSocketPath) {
|
if (!options.targetMpvSocketPath) {
|
||||||
@@ -89,6 +118,20 @@ export function selectHyprlandMpvWindow(
|
|||||||
return preferActiveHyprlandWindow(matchingWindows, options.activeWindowAddress);
|
return preferActiveHyprlandWindow(matchingWindows, options.activeWindowAddress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseHyprctlClients(output: string): HyprlandClient[] | null {
|
||||||
|
const jsonPayload = extractHyprctlJsonPayload(output);
|
||||||
|
if (!jsonPayload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(jsonPayload) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed as HyprlandClient[];
|
||||||
|
}
|
||||||
|
|
||||||
export class HyprlandWindowTracker extends BaseWindowTracker {
|
export class HyprlandWindowTracker extends BaseWindowTracker {
|
||||||
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
private pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private eventSocket: net.Socket | null = null;
|
private eventSocket: net.Socket | null = null;
|
||||||
@@ -185,8 +228,12 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
|
|||||||
|
|
||||||
private pollGeometry(): void {
|
private pollGeometry(): void {
|
||||||
try {
|
try {
|
||||||
const output = execSync('hyprctl clients -j', { encoding: 'utf-8' });
|
const output = execSync('hyprctl -j clients', { encoding: 'utf-8' });
|
||||||
const clients: HyprlandClient[] = JSON.parse(output);
|
const clients = parseHyprctlClients(output);
|
||||||
|
if (!clients) {
|
||||||
|
this.updateGeometry(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const mpvWindow = this.findTargetWindow(clients);
|
const mpvWindow = this.findTargetWindow(clients);
|
||||||
|
|
||||||
if (mpvWindow) {
|
if (mpvWindow) {
|
||||||
|
|||||||
Reference in New Issue
Block a user