refactor(tsukihime): swap Animetosho backend for TsukiHime API (#165)

This commit is contained in:
2026-07-13 22:03:35 -07:00
committed by GitHub
parent 49b926e08c
commit 2e2ee3f028
90 changed files with 2253 additions and 1695 deletions
+3
View File
@@ -61,3 +61,6 @@ tests/*
favicon.png favicon.png
.claude/* .claude/*
!stats/public/favicon.png !stats/public/favicon.png
# Browser-automation session artifacts (page snapshots, console logs, downloads)
.playwright-mcp/
+1
View File
@@ -238,6 +238,7 @@ SubMiner builds on the work of these open-source projects:
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration | | [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads | | [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration | | [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
| [TsukiHime](https://tsukihime.org) | Release-track subtitle search and downloads (Animetosho successor) |
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser | | [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary | | [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
-4
View File
@@ -1,4 +0,0 @@
type: added
area: overlay
- Added an Animetosho integration for downloading English (and Japanese) subtitles, mirroring the Jimaku flow: `Ctrl+Shift+T` (configurable via `shortcuts.openAnimetosho`) opens an in-overlay modal with two language tabs (the first follows `secondarySub.secondarySubLanguages`, defaulting to English; the second is Japanese) that parses the current video filename, searches Animetosho releases, lists extracted text subtitle tracks filtered by the active tab, then downloads the chosen track, decompresses it (requires the `xz` binary), saves it next to the video with a language suffix (`<video>.en.<ext>`, `.ja` for Japanese tracks, etc.), and loads it into mpv immediately - Japanese tracks as the primary subtitle, other languages as the secondary subtitle. No API key is required; also reachable via `subminer --open-animetosho`, the `__animetosho-open` keybinding command, and configurable under a new `animetosho` config section.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: overlay
- Added a TsukiHime integration for downloading primary and secondary subtitles, mirroring the Jimaku flow: `Ctrl+Shift+T` (configurable via `shortcuts.openTsukihime`) opens an in-overlay modal with a Japanese primary tab and a secondary tab that follows `secondarySub.secondarySubLanguages`. It parses the current video filename, searches TsukiHime releases, lists extracted text subtitle tracks filtered by the active tab, then downloads the chosen track, decompresses it (requires the `xz` binary), saves it next to the video with a language suffix (`<video>.en.<ext>`, `.ja` for Japanese tracks, etc.), and loads Japanese into mpv's primary slot or configured secondary tracks into its secondary slot. No API key is required; also reachable via `subminer --open-tsukihime`, the `__tsukihime-open` keybinding command, and configurable under a new `tsukihime` config section. TsukiHime is the successor to Animetosho, which stops processing new releases in May 2026; it imported the Animetosho index and mirrors its attachment storage, so older releases stay reachable. Existing `animetosho`, `shortcuts.openAnimetosho`, `--open-animetosho`, and `__animetosho-open` inputs remain accepted as deprecated aliases.
+8 -8
View File
@@ -205,7 +205,7 @@
"openCharacterDictionaryManager": "CommandOrControl+D", // Accelerator that opens the character dictionary manager modal. "openCharacterDictionaryManager": "CommandOrControl+D", // Accelerator that opens the character dictionary manager modal.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal. "openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal. "openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
"openAnimetosho": "Ctrl+Shift+T", // Accelerator that opens the Animetosho subtitle search modal (English/Japanese tabs). "openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. "openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts. "openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
@@ -614,14 +614,14 @@
}, // Jimaku API configuration and defaults. }, // Jimaku API configuration and defaults.
// ========================================== // ==========================================
// Animetosho // TsukiHime
// Animetosho subtitle search configuration (English and Japanese). No API key required. // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
// Hot-reload: Animetosho changes apply to the next Animetosho request. // Hot-reload: TsukiHime changes apply to the next TsukiHime request.
// ========================================== // ==========================================
"animetosho": { "tsukihime": {
"apiBaseUrl": "https://feed.animetosho.org", // Base URL of the Animetosho JSON feed API. No API key required. "apiBaseUrl": "https://api.tsukihime.org/v1", // Base URL of the TsukiHime API (Animetosho successor). No API key required.
"maxSearchResults": 10 // Maximum Animetosho search results returned. "maxSearchResults": 10 // Maximum TsukiHime search results returned.
}, // Animetosho subtitle search configuration (English and Japanese). No API key required. }, // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
// ========================================== // ==========================================
// YouTube Playback Settings // YouTube Playback Settings
+1 -1
View File
@@ -327,7 +327,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
{ text: 'Jellyfin', link: '/jellyfin-integration' }, { text: 'Jellyfin', link: '/jellyfin-integration' },
{ text: 'YouTube', link: '/youtube-integration' }, { text: 'YouTube', link: '/youtube-integration' },
{ text: 'Jimaku', link: '/jimaku-integration' }, { text: 'Jimaku', link: '/jimaku-integration' },
{ text: 'Animetosho', link: '/animetosho-integration' }, { text: 'TsukiHime', link: '/tsukihime-integration' },
{ text: 'AniList', link: '/anilist-integration' }, { text: 'AniList', link: '/anilist-integration' },
{ text: 'AniSkip', link: '/aniskip-integration' }, { text: 'AniSkip', link: '/aniskip-integration' },
{ text: 'Character Dictionary', link: '/character-dictionary' }, { text: 'Character Dictionary', link: '/character-dictionary' },
-73
View File
@@ -1,73 +0,0 @@
# Animetosho Integration
[Animetosho](https://animetosho.org) mirrors anime torrent releases and extracts every attachment - including embedded subtitle tracks - from the release files, hosting them for direct download. SubMiner integrates with the Animetosho JSON feed API so you can pull English subtitles for the currently playing episode straight from the overlay, no torrent client involved. Downloaded subtitles are decompressed, saved next to the video, and loaded into mpv immediately.
This is the English-side companion to the [Jimaku integration](/jimaku-integration): Jimaku covers Japanese subtitles, Animetosho covers your secondary language (English by default). Releases that ship multiple languages (e.g. Netflix `[MultiSub]` rips) expose them all; the modal's tabs pick which ones you see, and each download is saved with its own language suffix.
::: tip No API key required
Unlike Jimaku, Animetosho needs no account or API key. The only requirement is the `xz` binary on your `PATH` - Animetosho serves extracted subtitles xz-compressed, and SubMiner shells out to `xz` to decompress them. Most Linux distributions ship it by default (package `xz` or `xz-utils`).
:::
## How It Works
The integration runs through an in-overlay modal opened with `Ctrl+Shift+T` by default. The modal has two tabs that filter the subtitle tracks of the selected release by language: the first tab follows your `secondarySub.secondarySubLanguages` config (defaults to English when unset, and the tab is labeled accordingly - e.g. "German" if you configure `["de"]`), and the second tab is always **Japanese**. Tracks with no language tag stay visible on the first tab.
When you open the modal, SubMiner parses the current video filename to extract a title and episode number (same parser as Jimaku - `S01E03`, `1x03`, `E03`, and dash-separated numbers all work). If the filename yields a high-confidence match, SubMiner auto-searches immediately.
From there:
1. **Search** - SubMiner queries the Animetosho feed with `<title> <episode>`. Results appear as a list of releases (e.g. `[SubsPlease] ... - 28 (1080p)`), with size and file count.
2. **Browse releases** - Select a release to list the text subtitle tracks extracted from its files. English tracks sort first; image-based tracks (PGS/VobSub) are filtered out.
3. **Download** - Selecting a track downloads the xz-compressed subtitle from Animetosho's storage, decompresses it, saves it next to the video (or a temp directory for remote/streamed media), and loads it into mpv. Japanese tracks are selected as the **primary** subtitle; any other language loads as the **secondary** subtitle, so your Japanese primary track stays in place. The filename carries the track's language - `<video basename>.en.<ext>` for English, `.ja` for Japanese, and so on - so mpv and media servers detect the language correctly.
Because releases on Animetosho are the same files circulating as torrents, picking the release that matches your local file (same group, same version) gives you subtitles with exact timing - no resync needed. If your file is a raw or from a different group, pick any release of the same episode and adjust timing with the [subtitle sync tools](/troubleshooting#subtitle-sync-subsync) (`Ctrl+Alt+S`) if necessary.
### Modal Keyboard Shortcuts
| Key | Action |
| ------------------------- | ------------------------------- |
| `Enter` (in text field) | Search |
| `Enter` (in list) | Select release / download track |
| `Arrow Up` / `Arrow Down` | Navigate releases or tracks |
| `Arrow Left` / `Arrow Right` | Switch English / Japanese tab |
| `Escape` | Close modal |
## Configuration
The integration works out of the box. An optional `animetosho` section in `config.jsonc` tunes it:
```jsonc
{
"animetosho": {
"apiBaseUrl": "https://feed.animetosho.org",
"maxSearchResults": 10,
},
}
```
| Option | Type | Default | Description |
| ----------------------------- | -------- | ------------------------------- | ----------------------------------------------------------------------------- |
| `animetosho.apiBaseUrl` | `string` | `"https://feed.animetosho.org"` | Base URL of the Animetosho JSON feed API. Only change this if using a mirror. |
| `animetosho.maxSearchResults` | `number` | `10` | Maximum number of releases returned per search. |
The keyboard shortcut is configured separately under `shortcuts`:
```jsonc
{
"shortcuts": {
"openAnimetosho": "Ctrl+Shift+T", // default; set to null to disable
},
}
```
## Other Ways to Open It
- CLI: `subminer --open-animetosho`
- Keybinding command: bind any key to `["__animetosho-open"]` in the `keybindings` array
## Troubleshooting
- **"xz binary not found"** - install `xz`/`xz-utils` with your package manager.
- **"Batch releases are not supported"** - the Animetosho feed API only exposes extracted attachments for single-file torrents. Pick the single-episode release for your episode instead of a season batch.
- **"No text subtitle tracks in this release"** - the release only carries image-based subtitles (PGS/VobSub) or none at all; try a different release (fansub and SubsPlease-style releases almost always carry ASS tracks).
- **Timing is off** - the subtitle came from a different release than your video file. Use the subtitle sync modal (`Ctrl+Alt+S`) or pick the release matching your file exactly.
+8 -8
View File
@@ -205,7 +205,7 @@
"openCharacterDictionaryManager": "CommandOrControl+D", // Accelerator that opens the character dictionary manager modal. "openCharacterDictionaryManager": "CommandOrControl+D", // Accelerator that opens the character dictionary manager modal.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal. "openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal. "openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
"openAnimetosho": "Ctrl+Shift+T", // Accelerator that opens the Animetosho subtitle search modal (English/Japanese tabs). "openTsukihime": "Ctrl+Shift+T", // Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet. "openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal. "openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts. "openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
@@ -614,14 +614,14 @@
}, // Jimaku API configuration and defaults. }, // Jimaku API configuration and defaults.
// ========================================== // ==========================================
// Animetosho // TsukiHime
// Animetosho subtitle search configuration (English and Japanese). No API key required. // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
// Hot-reload: Animetosho changes apply to the next Animetosho request. // Hot-reload: TsukiHime changes apply to the next TsukiHime request.
// ========================================== // ==========================================
"animetosho": { "tsukihime": {
"apiBaseUrl": "https://feed.animetosho.org", // Base URL of the Animetosho JSON feed API. No API key required. "apiBaseUrl": "https://api.tsukihime.org/v1", // Base URL of the TsukiHime API (Animetosho successor). No API key required.
"maxSearchResults": 10 // Maximum Animetosho search results returned. "maxSearchResults": 10 // Maximum TsukiHime search results returned.
}, // Animetosho subtitle search configuration (English and Japanese). No API key required. }, // TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.
// ========================================== // ==========================================
// YouTube Playback Settings // YouTube Playback Settings
+3 -1
View File
@@ -82,7 +82,7 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | | `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` | | `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | | `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
| `Ctrl+Shift+T` | Open Animetosho subtitle search modal (EN/JA tabs) | `shortcuts.openAnimetosho` | | `Ctrl+Shift+T` | Open TsukiHime subtitle search modal (EN/JA tabs) | `shortcuts.openTsukihime` |
| `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` | | `Ctrl/Cmd+N` | Toggle overlay notification history panel | `shortcuts.toggleNotificationHistory` |
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | | `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | | `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
@@ -91,6 +91,8 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
| `` ` `` | Toggle stats overlay | `stats.toggleKey` | | `` ` `` | Toggle stats overlay | `stats.toggleKey` |
| `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` | | `W` | Mark current video watched and advance to next in queue | `stats.markWatchedKey` |
`shortcuts.openAnimetosho` remains accepted as a deprecated alias for `shortcuts.openTsukihime`. The current name takes precedence when both are configured.
The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`. The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`.
The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source. The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a parsed cue list for the active subtitle source.
+81
View File
@@ -0,0 +1,81 @@
# TsukiHime Integration
[TsukiHime](https://tsukihime.org) tracks anime torrent releases and extracts every attachment - including embedded subtitle tracks - from the release files, hosting them for direct download. SubMiner integrates with the TsukiHime API so you can pull English subtitles for the currently playing episode straight from the overlay, no torrent client involved. Downloaded subtitles are decompressed, saved next to the video, and loaded into mpv immediately.
This is the multi-language companion to the [Jimaku integration](/jimaku-integration). Releases that ship multiple languages (e.g. Netflix `[MultiSub]` rips) expose them all; the modal's tabs pick which ones you see, and each download is saved with its own language suffix.
::: tip Successor to Animetosho
TsukiHime replaces [Animetosho](https://animetosho.org), which stops processing new releases in May 2026. TsukiHime imported the Animetosho index and mirrors its attachment storage, so older releases stay reachable alongside new ones.
:::
::: tip No API key required
Unlike Jimaku, TsukiHime needs no account or API key. The only requirement is the `xz` binary on your `PATH` - TsukiHime serves extracted subtitles xz-compressed, and SubMiner shells out to `xz` to decompress them. Most Linux distributions ship it by default (package `xz` or `xz-utils`).
:::
## How It Works
The integration runs through an in-overlay modal opened with `Ctrl+Shift+T` by default. The modal has two tabs that filter the subtitle tracks of the selected release by role: the first follows `secondarySub.secondarySubLanguages` (English when unset), and the second is always **Japanese**, the currently supported primary subtitle language. Tracks with no language tag stay visible on the secondary tab.
When you open the modal, SubMiner parses the current video filename to extract a title and episode number (same parser as Jimaku - `S01E03`, `1x03`, `E03`, and dash-separated numbers all work). If the filename yields a high-confidence match, SubMiner auto-searches immediately.
From there:
1. **Search** - SubMiner queries TsukiHime with `<title> <episode>`. Results appear as a list of releases (e.g. `[SubsPlease] ... - 28 (1080p)`), each showing size, file count, and the subtitle languages the release carries.
2. **Browse releases** - Select a release to list the text subtitle tracks extracted from its files. English tracks sort first; image-based tracks (PGS/VobSub) are filtered out.
3. **Download** - Selecting a track downloads the xz-compressed subtitle from TsukiHime's storage, decompresses it, saves it next to the video (or a temp directory for remote/streamed media), and loads it into mpv. Japanese tracks are selected as mpv's **primary** subtitle. Tracks from the configured secondary tab are assigned to mpv's **secondary** subtitle slot without replacing the primary. The filename carries the track's language - `<video basename>.en.<ext>` for English, `.ja` for Japanese, and so on - so mpv and media servers detect the language correctly.
Because releases on TsukiHime are the same files circulating as torrents, picking the release that matches your local file (same group, same version) gives you subtitles with exact timing - no resync needed. If your file is a raw or from a different group, pick any release of the same episode and adjust timing with the [subtitle sync tools](/troubleshooting#subtitle-sync-subsync) (`Ctrl+Alt+S`) if necessary.
### Modal Keyboard Shortcuts
| Key | Action |
| ---------------------------- | ------------------------------- |
| `Enter` (in text field) | Search |
| `Enter` (in list) | Select release / download track |
| `Arrow Up` / `Arrow Down` | Navigate releases or tracks |
| `Arrow Left` / `Arrow Right` | Switch English / Japanese tab |
| `Escape` | Close modal |
## Configuration
The integration works out of the box. An optional `tsukihime` section in `config.jsonc` tunes it:
```jsonc
{
"tsukihime": {
"apiBaseUrl": "https://api.tsukihime.org/v1",
"maxSearchResults": 10,
},
}
```
| Option | Type | Default | Description |
| ---------------------------- | -------- | -------------------------------- | -------------------------------------------------------------------------- |
| `tsukihime.apiBaseUrl` | `string` | `"https://api.tsukihime.org/v1"` | Base URL of the TsukiHime API. Only change this if using a mirror. |
| `tsukihime.maxSearchResults` | `number` | `10` | Maximum number of releases returned per search (the API caps this at 100). |
The keyboard shortcut is configured separately under `shortcuts`:
```jsonc
{
"shortcuts": {
"openTsukihime": "Ctrl+Shift+T", // default; set to null to disable
},
}
```
Existing Animetosho configuration remains compatible. SubMiner treats the old `animetosho` section and `shortcuts.openAnimetosho` setting as deprecated aliases. When old and current names are both present, `tsukihime` and `shortcuts.openTsukihime` take precedence.
## Other Ways to Open It
- CLI: `subminer --open-tsukihime`
- Keybinding command: bind any key to `["__tsukihime-open"]` in the `keybindings` array
The previous `--open-animetosho` flag and `__animetosho-open` keybinding command remain accepted as deprecated aliases.
## Troubleshooting
- **"xz binary not found"** - install `xz`/`xz-utils` with your package manager.
- **"Batch releases are not supported"** - TsukiHime only exposes extracted attachments for single-file torrents. Pick the single-episode release for your episode instead of a season batch.
- **"No text subtitle tracks in this release"** - the release only carries image-based subtitles (PGS/VobSub) or none at all; try a different release (fansub and SubsPlease-style releases almost always carry ASS tracks).
- **Timing is off** - the subtitle came from a different release than your video file. Use the subtitle sync modal (`Ctrl+Alt+S`) or pick the release matching your file exactly.
+3
View File
@@ -151,6 +151,7 @@ SubMiner.AppImage --show-visible-overlay # Force show visible overl
SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay
SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle bar visibility SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle bar visibility
SubMiner.AppImage --toggle-subtitle-sidebar # Toggle the subtitle sidebar SubMiner.AppImage --toggle-subtitle-sidebar # Toggle the subtitle sidebar
SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
SubMiner.AppImage --start --dev # Enable app/dev mode only SubMiner.AppImage --start --dev # Enable app/dev mode only
SubMiner.AppImage --start --debug # Alias for --dev SubMiner.AppImage --start --debug # Alias for --dev
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
@@ -174,6 +175,8 @@ SubMiner.AppImage --help # Show all options
`--check` performs connection and version checks without changing data. `--json` emits the NDJSON event protocol used by the sync window. `--ui` opens that window in a detached app process and returns the shell immediately; closing a standalone-launched Sync window exits that app instance. `--make-temp` and `--remove-temp` are internal remote-transfer helpers and should normally be left to SubMiner. The packaged app's `--sync-cli` flag selects its headless sync-compatible entrypoint; the `subminer sync` launcher command proxies to it automatically. `--check` performs connection and version checks without changing data. `--json` emits the NDJSON event protocol used by the sync window. `--ui` opens that window in a detached app process and returns the shell immediately; closing a standalone-launched Sync window exits that app instance. `--make-temp` and `--remove-temp` are internal remote-transfer helpers and should normally be left to SubMiner. The packaged app's `--sync-cli` flag selects its headless sync-compatible entrypoint; the `subminer sync` launcher command proxies to it automatically.
The previous `--open-animetosho` flag remains accepted as a deprecated alias for `--open-tsukihime`.
The tray menu includes `Export Logs`, which creates the same sanitized local-date log ZIP as `subminer logs -e` and shows the archive path when complete. Export sanitization masks common PII and secrets, including home-directory usernames, IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL query strings. The exported copy is sanitized; source log files remain unredacted on disk. The tray menu includes `Export Logs`, which creates the same sanitized local-date log ZIP as `subminer logs -e` and shows the archive path when complete. Export sanitization masks common PII and secrets, including home-directory usernames, IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL query strings. The exported copy is sanitized; source log files remain unredacted on disk.
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config. Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
+2 -2
View File
@@ -254,8 +254,8 @@ function M.create(ctx)
return { "--open-runtime-options" } return { "--open-runtime-options" }
elseif action_id == "openJimaku" then elseif action_id == "openJimaku" then
return { "--open-jimaku" } return { "--open-jimaku" }
elseif action_id == "openAnimetosho" then elseif action_id == "openTsukihime" or action_id == "openAnimetosho" then
return { "--open-animetosho" } return { "--open-tsukihime" }
elseif action_id == "openYoutubePicker" then elseif action_id == "openYoutubePicker" then
return { "--open-youtube-picker" } return { "--open-youtube-picker" }
elseif action_id == "openSessionHelp" then elseif action_id == "openSessionHelp" then
+9
View File
@@ -237,6 +237,14 @@ local ctx = {
actionType = "session-action", actionType = "session-action",
actionId = "openPlaylistBrowser", actionId = "openPlaylistBrowser",
}, },
{
key = {
code = "KeyT",
modifiers = { "ctrl", "alt" },
},
actionType = "session-action",
actionId = "openAnimetosho",
},
{ {
key = { key = {
code = "KeyH", code = "KeyH",
@@ -387,6 +395,7 @@ end
local expected_cli_bindings = { local expected_cli_bindings = {
{ keys = "Ctrl+Alt+c", flag = "--open-youtube-picker" }, { keys = "Ctrl+Alt+c", flag = "--open-youtube-picker" },
{ keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" }, { keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" },
{ keys = "Ctrl+Alt+t", flag = "--open-tsukihime" },
{ keys = "Ctrl+H", flag = "--replay-current-subtitle" }, { keys = "Ctrl+H", flag = "--replay-current-subtitle" },
{ keys = "Ctrl+L", flag = "--play-next-subtitle" }, { keys = "Ctrl+L", flag = "--play-next-subtitle" },
{ keys = "w", flag = "--mark-watched" }, { keys = "w", flag = "--mark-watched" },
-45
View File
@@ -1,45 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
animetoshoLangToFilenameSuffix,
animetoshoTrackMatchesLanguages,
describeAnimetoshoTabLanguages,
normalizeAnimetoshoLangCode,
} from './lang.js';
test('normalizeAnimetoshoLangCode collapses 2/3-letter and region variants', () => {
assert.equal(normalizeAnimetoshoLangCode('eng'), 'en');
assert.equal(normalizeAnimetoshoLangCode('en'), 'en');
assert.equal(normalizeAnimetoshoLangCode('en-US'), 'en');
assert.equal(normalizeAnimetoshoLangCode('GER'), 'de');
assert.equal(normalizeAnimetoshoLangCode('jpn'), 'ja');
assert.equal(normalizeAnimetoshoLangCode('vie'), 'vie');
assert.equal(normalizeAnimetoshoLangCode(''), '');
});
test('animetoshoTrackMatchesLanguages matches across code forms', () => {
assert.equal(animetoshoTrackMatchesLanguages('eng', ['en']), true);
assert.equal(animetoshoTrackMatchesLanguages('eng', ['en', 'eng']), true);
assert.equal(animetoshoTrackMatchesLanguages('ger', ['de']), true);
assert.equal(animetoshoTrackMatchesLanguages('por', ['en']), false);
assert.equal(animetoshoTrackMatchesLanguages('spa', ['en', 'de']), false);
});
test('animetoshoTrackMatchesLanguages keeps unknown-language tracks visible', () => {
assert.equal(animetoshoTrackMatchesLanguages('', ['en']), true);
assert.equal(animetoshoTrackMatchesLanguages('und', ['en']), true);
});
test('describeAnimetoshoTabLanguages names common languages and dedupes', () => {
assert.equal(describeAnimetoshoTabLanguages(['en', 'eng']), 'English');
assert.equal(describeAnimetoshoTabLanguages(['de']), 'German');
assert.equal(describeAnimetoshoTabLanguages(['en', 'de']), 'English / German');
assert.equal(describeAnimetoshoTabLanguages(['vie']), 'VIE');
assert.equal(describeAnimetoshoTabLanguages([]), 'English');
});
test('animetoshoLangToFilenameSuffix is re-exported from the pure module', () => {
assert.equal(animetoshoLangToFilenameSuffix('jpn'), 'ja');
assert.equal(animetoshoLangToFilenameSuffix('eng'), 'en');
});
-233
View File
@@ -1,233 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { execFileSync } from 'node:child_process';
import {
animetoshoLangToFilenameSuffix,
buildAnimetoshoAttachmentUrl,
decompressXzFile,
extractAnimetoshoSubtitleFiles,
mapAnimetoshoSearchResults,
} from './utils.js';
test('buildAnimetoshoAttachmentUrl pads the attachment id to 8 hex digits', () => {
assert.equal(
buildAnimetoshoAttachmentUrl(1955356),
'https://animetosho.org/storage/attach/001dd61c/1955356.xz',
);
});
test('animetoshoLangToFilenameSuffix maps common ISO 639-2 codes to two-letter suffixes', () => {
assert.equal(animetoshoLangToFilenameSuffix('eng'), 'en');
assert.equal(animetoshoLangToFilenameSuffix('jpn'), 'ja');
assert.equal(animetoshoLangToFilenameSuffix('ger'), 'de');
assert.equal(animetoshoLangToFilenameSuffix('spa'), 'es');
assert.equal(animetoshoLangToFilenameSuffix('POR'), 'pt');
});
test('animetoshoLangToFilenameSuffix falls back to the raw code, and to en when unknown', () => {
assert.equal(animetoshoLangToFilenameSuffix('vie'), 'vie');
assert.equal(animetoshoLangToFilenameSuffix(''), 'en');
assert.equal(animetoshoLangToFilenameSuffix(undefined), 'en');
assert.equal(animetoshoLangToFilenameSuffix('und'), 'en');
});
test('buildAnimetoshoAttachmentUrl rejects non-positive and non-integer ids', () => {
assert.equal(buildAnimetoshoAttachmentUrl(0), null);
assert.equal(buildAnimetoshoAttachmentUrl(-5), null);
assert.equal(buildAnimetoshoAttachmentUrl(1.5), null);
assert.equal(buildAnimetoshoAttachmentUrl(Number.NaN), null);
});
test('mapAnimetoshoSearchResults maps valid entries and caps to maxResults', () => {
const payload = [
{
id: 606713,
title: '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv',
timestamp: 1710000000,
total_size: 1490354395,
num_files: 1,
},
{ id: 'bogus', title: 'missing numeric id' },
{ id: 606714, title: '[Erai-raws] Sousou no Frieren - 28 [1080p].mkv' },
{ id: 606715, title: 'capped away' },
];
const entries = mapAnimetoshoSearchResults(payload, 2);
assert.equal(entries.length, 2);
assert.deepEqual(entries[0], {
id: 606713,
title: '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv',
timestamp: 1710000000,
totalSize: 1490354395,
numFiles: 1,
});
assert.equal(entries[1]!.id, 606714);
assert.equal(entries[1]!.totalSize, null);
assert.equal(entries[1]!.numFiles, null);
});
test('mapAnimetoshoSearchResults returns empty list for non-array payloads', () => {
assert.deepEqual(mapAnimetoshoSearchResults({ error: 'nope' }, 10), []);
assert.deepEqual(mapAnimetoshoSearchResults(null, 10), []);
});
const DETAIL_PAYLOAD = {
id: 606713,
title: '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv',
files: [
{
id: 1151711,
filename: '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv',
attachments: [
{
id: 1955355,
type: 'font',
info: { name: 'arial.ttf' },
size: 300000,
},
{
id: 1955356,
type: 'subtitle',
info: { codec: 'ASS', lang: 'eng', name: 'English subs', trackid: 2 },
size: 33075,
},
],
},
],
};
test('extractAnimetoshoSubtitleFiles keeps only text subtitle attachments with download urls', () => {
const files = extractAnimetoshoSubtitleFiles(DETAIL_PAYLOAD);
assert.equal(files.length, 1);
const file = files[0]!;
assert.equal(file.attachmentId, 1955356);
assert.equal(file.lang, 'eng');
assert.equal(file.trackName, 'English subs');
assert.equal(file.size, 33075);
assert.equal(file.url, 'https://animetosho.org/storage/attach/001dd61c/1955356.xz');
assert.equal(file.sourceFilename, '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].mkv');
assert.equal(file.filename, '[SubsPlease] Sousou no Frieren - 28 (1080p) [8BBBC28C].eng.ass');
});
test('extractAnimetoshoSubtitleFiles skips image-based subtitle codecs', () => {
const files = extractAnimetoshoSubtitleFiles({
files: [
{
id: 1,
filename: 'movie.mkv',
attachments: [
{ id: 10, type: 'subtitle', info: { codec: 'PGS', lang: 'eng' }, size: 100 },
{ id: 11, type: 'subtitle', info: { codec: 'VobSub', lang: 'eng' }, size: 100 },
{ id: 12, type: 'subtitle', info: { codec: 'SRT', lang: 'eng' }, size: 100 },
],
},
],
});
assert.deepEqual(
files.map((f) => f.attachmentId),
[12],
);
assert.equal(files[0]!.filename, 'movie.eng.srt');
});
test('extractAnimetoshoSubtitleFiles sorts English tracks first and disambiguates duplicates', () => {
const files = extractAnimetoshoSubtitleFiles({
files: [
{
id: 1,
filename: 'episode.mkv',
attachments: [
{
id: 21,
type: 'subtitle',
info: { codec: 'ASS', lang: 'ger', name: 'Deutsch' },
size: 1,
},
{
id: 22,
type: 'subtitle',
info: { codec: 'ASS', lang: 'eng', name: 'Signs & Songs' },
size: 2,
},
{
id: 23,
type: 'subtitle',
info: { codec: 'ASS', lang: 'eng', name: 'Full Subtitles' },
size: 3,
},
],
},
],
});
assert.deepEqual(
files.map((f) => f.attachmentId),
[22, 23, 21],
);
assert.equal(files[0]!.filename, 'episode.eng.signs-songs.ass');
assert.equal(files[1]!.filename, 'episode.eng.full-subtitles.ass');
assert.equal(files[2]!.filename, 'episode.ger.ass');
});
test('extractAnimetoshoSubtitleFiles tolerates missing info fields', () => {
const files = extractAnimetoshoSubtitleFiles({
files: [
{
id: 1,
attachments: [{ id: 31, type: 'subtitle', info: { codec: 'ASS' }, size: 5 }],
},
],
});
assert.equal(files.length, 1);
assert.equal(files[0]!.lang, '');
assert.equal(files[0]!.trackName, null);
assert.equal(files[0]!.filename, 'subtitle.ass');
});
const hasXz = (() => {
try {
execFileSync('xz', ['--version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
})();
test('decompressXzFile round-trips an xz-compressed subtitle', { skip: !hasXz }, async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-animetosho-test-'));
try {
const plainPath = path.join(dir, 'sub.ass');
const content = '[Script Info]\nTitle: test\n';
fs.writeFileSync(plainPath, content, 'utf8');
execFileSync('xz', ['-z', plainPath]);
const destPath = path.join(dir, 'out.ass');
const result = await decompressXzFile(`${plainPath}.xz`, destPath);
assert.equal(result.ok, true);
if (result.ok) {
assert.equal(result.path, destPath);
}
assert.equal(fs.readFileSync(destPath, 'utf8'), content);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('decompressXzFile reports an error for corrupt input', { skip: !hasXz }, async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-animetosho-test-'));
try {
const srcPath = path.join(dir, 'broken.xz');
fs.writeFileSync(srcPath, 'not xz data');
const result = await decompressXzFile(srcPath, path.join(dir, 'out.ass'));
assert.equal(result.ok, false);
if (!result.ok) {
assert.match(result.error.error, /xz|decompress/i);
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
+10 -2
View File
@@ -115,7 +115,7 @@ test('parseArgs captures session action forwarding flags', () => {
'--toggle-stats-overlay', '--toggle-stats-overlay',
'--mark-watched', '--mark-watched',
'--open-jimaku', '--open-jimaku',
'--open-animetosho', '--open-tsukihime',
'--open-youtube-picker', '--open-youtube-picker',
'--open-playlist-browser', '--open-playlist-browser',
'--toggle-primary-subtitle-bar', '--toggle-primary-subtitle-bar',
@@ -133,7 +133,7 @@ test('parseArgs captures session action forwarding flags', () => {
assert.equal(args.toggleStatsOverlay, true); assert.equal(args.toggleStatsOverlay, true);
assert.equal(args.markWatched, true); assert.equal(args.markWatched, true);
assert.equal(args.openJimaku, true); assert.equal(args.openJimaku, true);
assert.equal(args.openAnimetosho, true); assert.equal(args.openTsukihime, true);
assert.equal(args.openYoutubePicker, true); assert.equal(args.openYoutubePicker, true);
assert.equal(args.openPlaylistBrowser, true); assert.equal(args.openPlaylistBrowser, true);
assert.equal(args.togglePrimarySubtitleBar, true); assert.equal(args.togglePrimarySubtitleBar, true);
@@ -148,6 +148,14 @@ test('parseArgs captures session action forwarding flags', () => {
assert.equal(shouldStartApp(args), true); assert.equal(shouldStartApp(args), true);
}); });
test('parseArgs keeps the legacy Animetosho open flag as a TsukiHime alias', () => {
const args = parseArgs(['--open-animetosho']);
assert.equal(args.openTsukihime, true);
assert.equal(hasExplicitCommand(args), true);
assert.equal(shouldStartApp(args), true);
});
test('parseArgs ignores retired subtitle delay shift flags', () => { test('parseArgs ignores retired subtitle delay shift flags', () => {
const args = parseArgs(['--shift-sub-delay-prev-line', '--shift-sub-delay-next-line']); const args = parseArgs(['--shift-sub-delay-prev-line', '--shift-sub-delay-next-line']);
+10 -9
View File
@@ -38,7 +38,7 @@ export interface CliArgs {
openControllerSelect: boolean; openControllerSelect: boolean;
openControllerDebug: boolean; openControllerDebug: boolean;
openJimaku: boolean; openJimaku: boolean;
openAnimetosho: boolean; openTsukihime: boolean;
openYoutubePicker: boolean; openYoutubePicker: boolean;
openPlaylistBrowser: boolean; openPlaylistBrowser: boolean;
replayCurrentSubtitle: boolean; replayCurrentSubtitle: boolean;
@@ -148,7 +148,7 @@ export function parseArgs(argv: string[]): CliArgs {
openControllerSelect: false, openControllerSelect: false,
openControllerDebug: false, openControllerDebug: false,
openJimaku: false, openJimaku: false,
openAnimetosho: false, openTsukihime: false,
openYoutubePicker: false, openYoutubePicker: false,
openPlaylistBrowser: false, openPlaylistBrowser: false,
replayCurrentSubtitle: false, replayCurrentSubtitle: false,
@@ -297,8 +297,9 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--open-controller-select') args.openControllerSelect = true; else if (arg === '--open-controller-select') args.openControllerSelect = true;
else if (arg === '--open-controller-debug') args.openControllerDebug = true; else if (arg === '--open-controller-debug') args.openControllerDebug = true;
else if (arg === '--open-jimaku') args.openJimaku = true; else if (arg === '--open-jimaku') args.openJimaku = true;
else if (arg === '--open-animetosho') args.openAnimetosho = true; else if (arg === '--open-tsukihime' || arg === '--open-animetosho') {
else if (arg === '--open-youtube-picker') args.openYoutubePicker = true; args.openTsukihime = true;
} else if (arg === '--open-youtube-picker') args.openYoutubePicker = true;
else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true; else if (arg === '--open-playlist-browser') args.openPlaylistBrowser = true;
else if (arg === '--replay-current-subtitle') args.replayCurrentSubtitle = true; else if (arg === '--replay-current-subtitle') args.replayCurrentSubtitle = true;
else if (arg === '--play-next-subtitle') args.playNextSubtitle = true; else if (arg === '--play-next-subtitle') args.playNextSubtitle = true;
@@ -571,7 +572,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.openControllerSelect || args.openControllerSelect ||
args.openControllerDebug || args.openControllerDebug ||
args.openJimaku || args.openJimaku ||
args.openAnimetosho || args.openTsukihime ||
args.openYoutubePicker || args.openYoutubePicker ||
args.openPlaylistBrowser || args.openPlaylistBrowser ||
args.replayCurrentSubtitle || args.replayCurrentSubtitle ||
@@ -650,7 +651,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.openControllerSelect && !args.openControllerSelect &&
!args.openControllerDebug && !args.openControllerDebug &&
!args.openJimaku && !args.openJimaku &&
!args.openAnimetosho && !args.openTsukihime &&
!args.openYoutubePicker && !args.openYoutubePicker &&
!args.openPlaylistBrowser && !args.openPlaylistBrowser &&
!args.replayCurrentSubtitle && !args.replayCurrentSubtitle &&
@@ -718,7 +719,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.openControllerSelect || args.openControllerSelect ||
args.openControllerDebug || args.openControllerDebug ||
args.openJimaku || args.openJimaku ||
args.openAnimetosho || args.openTsukihime ||
args.openYoutubePicker || args.openYoutubePicker ||
args.openPlaylistBrowser || args.openPlaylistBrowser ||
args.replayCurrentSubtitle || args.replayCurrentSubtitle ||
@@ -780,7 +781,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
!args.openControllerSelect && !args.openControllerSelect &&
!args.openControllerDebug && !args.openControllerDebug &&
!args.openJimaku && !args.openJimaku &&
!args.openAnimetosho && !args.openTsukihime &&
!args.openYoutubePicker && !args.openYoutubePicker &&
!args.openPlaylistBrowser && !args.openPlaylistBrowser &&
!args.replayCurrentSubtitle && !args.replayCurrentSubtitle &&
@@ -846,7 +847,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.openControllerSelect || args.openControllerSelect ||
args.openControllerDebug || args.openControllerDebug ||
args.openJimaku || args.openJimaku ||
args.openAnimetosho || args.openTsukihime ||
args.openYoutubePicker || args.openYoutubePicker ||
args.openPlaylistBrowser || args.openPlaylistBrowser ||
args.replayCurrentSubtitle || args.replayCurrentSubtitle ||
+2 -2
View File
@@ -40,7 +40,7 @@ const {
const { const {
ankiConnect, ankiConnect,
jimaku, jimaku,
animetosho, tsukihime,
anilist, anilist,
mpv, mpv,
yomitan, yomitan,
@@ -73,7 +73,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
subtitleSidebar, subtitleSidebar,
auto_start_overlay, auto_start_overlay,
jimaku, jimaku,
animetosho, tsukihime,
anilist, anilist,
mpv, mpv,
yomitan, yomitan,
+1 -1
View File
@@ -98,7 +98,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
openCharacterDictionaryManager: 'CommandOrControl+D', openCharacterDictionaryManager: 'CommandOrControl+D',
openRuntimeOptions: 'CommandOrControl+Shift+O', openRuntimeOptions: 'CommandOrControl+Shift+O',
openJimaku: 'Ctrl+Shift+J', openJimaku: 'Ctrl+Shift+J',
openAnimetosho: 'Ctrl+Shift+T', openTsukihime: 'Ctrl+Shift+T',
openSessionHelp: 'CommandOrControl+Slash', openSessionHelp: 'CommandOrControl+Slash',
openControllerSelect: 'Alt+C', openControllerSelect: 'Alt+C',
openControllerDebug: 'Alt+Shift+C', openControllerDebug: 'Alt+Shift+C',
@@ -5,7 +5,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
ResolvedConfig, ResolvedConfig,
| 'ankiConnect' | 'ankiConnect'
| 'jimaku' | 'jimaku'
| 'animetosho' | 'tsukihime'
| 'anilist' | 'anilist'
| 'mpv' | 'mpv'
| 'yomitan' | 'yomitan'
@@ -97,8 +97,8 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
languagePreference: 'ja', languagePreference: 'ja',
maxEntryResults: 10, maxEntryResults: 10,
}, },
animetosho: { tsukihime: {
apiBaseUrl: 'https://feed.animetosho.org', apiBaseUrl: 'https://api.tsukihime.org/v1',
maxSearchResults: 10, maxSearchResults: 10,
}, },
mpv: { mpv: {
+3 -3
View File
@@ -616,11 +616,11 @@ export function buildCoreConfigOptionRegistry(
description: 'Accelerator that opens the Jimaku subtitle search modal.', description: 'Accelerator that opens the Jimaku subtitle search modal.',
}, },
{ {
path: 'shortcuts.openAnimetosho', path: 'shortcuts.openTsukihime',
kind: 'string', kind: 'string',
defaultValue: defaultConfig.shortcuts.openAnimetosho, defaultValue: defaultConfig.shortcuts.openTsukihime,
description: description:
'Accelerator that opens the Animetosho subtitle search modal (English/Japanese tabs).', 'Accelerator that opens the TsukiHime subtitle search modal (configured secondary/Japanese primary tabs).',
}, },
{ {
path: 'shortcuts.openSessionHelp', path: 'shortcuts.openSessionHelp',
@@ -401,16 +401,17 @@ export function buildIntegrationConfigOptionRegistry(
description: 'Maximum Jimaku search results returned.', description: 'Maximum Jimaku search results returned.',
}, },
{ {
path: 'animetosho.apiBaseUrl', path: 'tsukihime.apiBaseUrl',
kind: 'string', kind: 'string',
defaultValue: defaultConfig.animetosho.apiBaseUrl, defaultValue: defaultConfig.tsukihime.apiBaseUrl,
description: 'Base URL of the Animetosho JSON feed API. No API key required.', description:
'Base URL of the TsukiHime API (Animetosho successor). No API key required.',
}, },
{ {
path: 'animetosho.maxSearchResults', path: 'tsukihime.maxSearchResults',
kind: 'number', kind: 'number',
defaultValue: defaultConfig.animetosho.maxSearchResults, defaultValue: defaultConfig.tsukihime.maxSearchResults,
description: 'Maximum Animetosho search results returned.', description: 'Maximum TsukiHime search results returned.',
}, },
{ {
path: 'anilist.enabled', path: 'anilist.enabled',
+2
View File
@@ -53,7 +53,9 @@ export const SPECIAL_COMMANDS = {
SUBSYNC_TRIGGER: '__subsync-trigger', SUBSYNC_TRIGGER: '__subsync-trigger',
RUNTIME_OPTIONS_OPEN: '__runtime-options-open', RUNTIME_OPTIONS_OPEN: '__runtime-options-open',
JIMAKU_OPEN: '__jimaku-open', JIMAKU_OPEN: '__jimaku-open',
/** @deprecated Use TSUKIHIME_OPEN. */
ANIMETOSHO_OPEN: '__animetosho-open', ANIMETOSHO_OPEN: '__animetosho-open',
TSUKIHIME_OPEN: '__tsukihime-open',
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:', RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
REPLAY_SUBTITLE: '__replay-subtitle', REPLAY_SUBTITLE: '__replay-subtitle',
PLAY_NEXT_SUBTITLE: '__play-next-subtitle', PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
+4 -4
View File
@@ -148,12 +148,12 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
key: 'jimaku', key: 'jimaku',
}, },
{ {
title: 'Animetosho', title: 'TsukiHime',
description: [ description: [
'Animetosho subtitle search configuration (English and Japanese). No API key required.', 'TsukiHime subtitle search configuration for Japanese primary and configured secondary subtitles. No API key required.',
], ],
notes: ['Hot-reload: Animetosho changes apply to the next Animetosho request.'], notes: ['Hot-reload: TsukiHime changes apply to the next TsukiHime request.'],
key: 'animetosho', key: 'tsukihime',
}, },
{ {
title: 'YouTube Playback Settings', title: 'YouTube Playback Settings',
+15 -1
View File
@@ -236,7 +236,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
'openCharacterDictionaryManager', 'openCharacterDictionaryManager',
'openRuntimeOptions', 'openRuntimeOptions',
'openJimaku', 'openJimaku',
'openAnimetosho', 'openTsukihime',
'openSessionHelp', 'openSessionHelp',
'openControllerSelect', 'openControllerSelect',
'openControllerDebug', 'openControllerDebug',
@@ -254,6 +254,20 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
} }
} }
if (src.shortcuts.openTsukihime === undefined) {
const legacyOpenTsukihime = src.shortcuts.openAnimetosho;
if (typeof legacyOpenTsukihime === 'string' || legacyOpenTsukihime === null) {
resolved.shortcuts.openTsukihime = legacyOpenTsukihime;
} else if (legacyOpenTsukihime !== undefined) {
warn(
'shortcuts.openAnimetosho',
legacyOpenTsukihime,
resolved.shortcuts.openTsukihime,
'Expected string or null.',
);
}
}
const timeout = asNumber(src.shortcuts.multiCopyTimeoutMs); const timeout = asNumber(src.shortcuts.multiCopyTimeoutMs);
if (timeout !== undefined && timeout > 0) { if (timeout !== undefined && timeout > 0) {
resolved.shortcuts.multiCopyTimeoutMs = Math.floor(timeout); resolved.shortcuts.multiCopyTimeoutMs = Math.floor(timeout);
+18 -9
View File
@@ -80,18 +80,27 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
} }
} }
if (isObject(src.animetosho)) { const currentTsukihimeSource = isObject(src.tsukihime) ? src.tsukihime : null;
const apiBaseUrl = asString(src.animetosho.apiBaseUrl); if (src.tsukihime !== undefined && !currentTsukihimeSource) {
if (apiBaseUrl !== undefined) resolved.animetosho.apiBaseUrl = apiBaseUrl; warn('tsukihime', src.tsukihime, resolved.tsukihime, 'Expected object.');
}
const maxSearchResults = asNumber(src.animetosho.maxSearchResults); const legacyTsukihimeSource =
src.tsukihime === undefined && isObject(src.animetosho) ? src.animetosho : null;
const tsukihimeSource = currentTsukihimeSource ?? legacyTsukihimeSource;
const tsukihimeSourcePath = currentTsukihimeSource ? 'tsukihime' : 'animetosho';
if (tsukihimeSource) {
const apiBaseUrl = asString(tsukihimeSource.apiBaseUrl);
if (apiBaseUrl !== undefined) resolved.tsukihime.apiBaseUrl = apiBaseUrl;
const maxSearchResults = asNumber(tsukihimeSource.maxSearchResults);
if (maxSearchResults !== undefined && Math.floor(maxSearchResults) > 0) { if (maxSearchResults !== undefined && Math.floor(maxSearchResults) > 0) {
resolved.animetosho.maxSearchResults = Math.floor(maxSearchResults); resolved.tsukihime.maxSearchResults = Math.floor(maxSearchResults);
} else if (src.animetosho.maxSearchResults !== undefined) { } else if (tsukihimeSource.maxSearchResults !== undefined) {
warn( warn(
'animetosho.maxSearchResults', `${tsukihimeSourcePath}.maxSearchResults`,
src.animetosho.maxSearchResults, tsukihimeSource.maxSearchResults,
resolved.animetosho.maxSearchResults, resolved.tsukihime.maxSearchResults,
'Expected positive number.', 'Expected positive number.',
); );
} }
+1 -1
View File
@@ -3,7 +3,7 @@ import { asBoolean } from './shared';
export function applyTopLevelConfig(context: ResolveContext): void { export function applyTopLevelConfig(context: ResolveContext): void {
const { src, resolved, warn } = context; const { src, resolved, warn } = context;
const knownTopLevelKeys = new Set(Object.keys(resolved)); const knownTopLevelKeys = new Set([...Object.keys(resolved), 'animetosho']);
for (const key of Object.keys(src)) { for (const key of Object.keys(src)) {
if (!knownTopLevelKeys.has(key)) { if (!knownTopLevelKeys.has(key)) {
warn(key, src[key], undefined, 'Unknown top-level config key; ignored.'); warn(key, src[key], undefined, 'Unknown top-level config key; ignored.');
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveConfig } from '../resolve';
test('resolveConfig maps legacy Animetosho settings to TsukiHime', () => {
const { resolved, warnings } = resolveConfig({
animetosho: {
apiBaseUrl: 'https://legacy.example/v1',
maxSearchResults: 23,
},
shortcuts: {
openAnimetosho: 'Ctrl+Alt+T',
},
});
assert.equal(resolved.tsukihime.apiBaseUrl, 'https://legacy.example/v1');
assert.equal(resolved.tsukihime.maxSearchResults, 23);
assert.equal(resolved.shortcuts.openTsukihime, 'Ctrl+Alt+T');
assert.deepEqual(warnings, []);
});
test('resolveConfig gives current TsukiHime settings precedence over legacy aliases', () => {
const { resolved, warnings } = resolveConfig({
animetosho: {
apiBaseUrl: 'https://legacy.example/v1',
maxSearchResults: 23,
},
tsukihime: {
apiBaseUrl: 'https://current.example/v1',
maxSearchResults: 7,
},
shortcuts: {
openAnimetosho: 'Ctrl+Alt+T',
openTsukihime: null,
},
});
assert.equal(resolved.tsukihime.apiBaseUrl, 'https://current.example/v1');
assert.equal(resolved.tsukihime.maxSearchResults, 7);
assert.equal(resolved.shortcuts.openTsukihime, null);
assert.deepEqual(warnings, []);
});
test('resolveConfig does not fall back when the current TsukiHime setting is invalid', () => {
const { resolved, warnings } = resolveConfig({
animetosho: {
apiBaseUrl: 'https://legacy.example/v1',
maxSearchResults: 23,
},
tsukihime: 'invalid' as never,
});
assert.equal(resolved.tsukihime.apiBaseUrl, 'https://api.tsukihime.org/v1');
assert.equal(resolved.tsukihime.maxSearchResults, 10);
assert.deepEqual(warnings, [
{
path: 'tsukihime',
value: 'invalid',
fallback: resolved.tsukihime,
message: 'Expected object.',
},
]);
});
+2
View File
@@ -247,6 +247,8 @@ test('settings registry routes playback-related integrations into integrations',
assert.equal(field('jimaku.apiBaseUrl').section, 'Jimaku'); assert.equal(field('jimaku.apiBaseUrl').section, 'Jimaku');
assert.equal(field('subsync.replace').category, 'integrations'); assert.equal(field('subsync.replace').category, 'integrations');
assert.equal(field('subsync.replace').section, 'Subtitle Sync'); assert.equal(field('subsync.replace').section, 'Subtitle Sync');
assert.equal(field('tsukihime.apiBaseUrl').category, 'integrations');
assert.equal(field('tsukihime.apiBaseUrl').section, 'TsukiHime');
}); });
test('settings registry puts feature toggles first, then other toggles alphabetically', () => { test('settings registry puts feature toggles first, then other toggles alphabetically', () => {
+3 -2
View File
@@ -422,7 +422,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
if (path.startsWith('mpv.') || path.startsWith('youtube.')) { if (path.startsWith('mpv.') || path.startsWith('youtube.')) {
return { category: 'behavior', section: topSection(path) }; return { category: 'behavior', section: topSection(path) };
} }
if (path.startsWith('jimaku.')) { if (path.startsWith('jimaku.') || path.startsWith('tsukihime.')) {
return { category: 'integrations', section: topSection(path) }; return { category: 'integrations', section: topSection(path) };
} }
if (path.startsWith('subsync.')) { if (path.startsWith('subsync.')) {
@@ -486,6 +486,7 @@ function topSection(path: string): string {
notifications: 'Notifications', notifications: 'Notifications',
subsync: 'Subtitle Sync', subsync: 'Subtitle Sync',
texthooker: 'Texthooker', texthooker: 'Texthooker',
tsukihime: 'TsukiHime',
updates: 'Updates', updates: 'Updates',
websocket: 'WebSocket server', websocket: 'WebSocket server',
yomitan: 'Yomitan', yomitan: 'Yomitan',
@@ -594,7 +595,7 @@ function subsectionForPath(path: string): string | undefined {
leaf === 'openCharacterDictionaryManager' || leaf === 'openCharacterDictionaryManager' ||
leaf === 'openRuntimeOptions' || leaf === 'openRuntimeOptions' ||
leaf === 'openJimaku' || leaf === 'openJimaku' ||
leaf === 'openAnimetosho' || leaf === 'openTsukihime' ||
leaf === 'openSessionHelp' || leaf === 'openSessionHelp' ||
leaf === 'openControllerSelect' || leaf === 'openControllerSelect' ||
leaf === 'openControllerDebug' leaf === 'openControllerDebug'
+33 -33
View File
@@ -55,10 +55,10 @@ test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => {
isRemoteMediaPath: () => false, isRemoteMediaPath: () => false,
downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }), downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }),
onDownloadedSubtitle: () => {}, onDownloadedSubtitle: () => {},
searchAnimetoshoEntries: async () => ({ ok: true, data: [] }), searchTsukihimeEntries: async () => ({ ok: true, data: [] }),
listAnimetoshoFiles: async () => ({ ok: true, data: [] }), listTsukihimeFiles: async () => ({ ok: true, data: [] }),
downloadAnimetoshoSubtitle: async () => ({ ok: true, path: '/tmp/sub.en.ass' }), downloadTsukihimeSubtitle: async () => ({ ok: true, path: '/tmp/sub.en.ass' }),
getAnimetoshoSecondaryLanguages: () => ['en'], getTsukihimeSecondaryLanguages: () => ['en'],
onDownloadedSecondarySubtitle: () => {}, onDownloadedSecondarySubtitle: () => {},
}, },
registrar, registrar,
@@ -98,41 +98,41 @@ test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => {
error: { error: 'Invalid Jimaku download query payload', code: 400 }, error: { error: 'Invalid Jimaku download query payload', code: 400 },
}); });
const animetoshoSearchHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoSearchEntries); const tsukihimeSearchHandler = handleHandlers.get(IPC_CHANNELS.request.tsukihimeSearchEntries);
assert.ok(animetoshoSearchHandler); assert.ok(tsukihimeSearchHandler);
const invalidAnimetoshoSearch = await animetoshoSearchHandler!({}, { query: 12 }); const invalidTsukihimeSearch = await tsukihimeSearchHandler!({}, { query: 12 });
assert.deepEqual(invalidAnimetoshoSearch, { assert.deepEqual(invalidTsukihimeSearch, {
ok: false, ok: false,
error: { error: 'Invalid Animetosho search query payload', code: 400 }, error: { error: 'Invalid TsukiHime search query payload', code: 400 },
}); });
const animetoshoFilesHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoListFiles); const tsukihimeFilesHandler = handleHandlers.get(IPC_CHANNELS.request.tsukihimeListFiles);
assert.ok(animetoshoFilesHandler); assert.ok(tsukihimeFilesHandler);
const invalidAnimetoshoFiles = await animetoshoFilesHandler!({}, { entryId: 'x' }); const invalidTsukihimeFiles = await tsukihimeFilesHandler!({}, { entryId: 'x' });
assert.deepEqual(invalidAnimetoshoFiles, { assert.deepEqual(invalidTsukihimeFiles, {
ok: false, ok: false,
error: { error: 'Invalid Animetosho files query payload', code: 400 }, error: { error: 'Invalid TsukiHime files query payload', code: 400 },
}); });
const animetoshoDownloadHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoDownloadFile); const tsukihimeDownloadHandler = handleHandlers.get(IPC_CHANNELS.request.tsukihimeDownloadFile);
assert.ok(animetoshoDownloadHandler); assert.ok(tsukihimeDownloadHandler);
const invalidAnimetoshoDownload = await animetoshoDownloadHandler!({}, { entryId: 1, url: '/x' }); const invalidTsukihimeDownload = await tsukihimeDownloadHandler!({}, { entryId: 1, url: '/x' });
assert.deepEqual(invalidAnimetoshoDownload, { assert.deepEqual(invalidTsukihimeDownload, {
ok: false, ok: false,
error: { error: 'Invalid Animetosho download query payload', code: 400 }, error: { error: 'Invalid TsukiHime download query payload', code: 400 },
}); });
const foreignUrlDownload = await animetoshoDownloadHandler!( const foreignUrlDownload = await tsukihimeDownloadHandler!(
{}, {},
{ entryId: 1, url: 'https://evil.example/attach/00000001/1.xz', name: 'sub.ass' }, { entryId: 1, url: 'https://evil.example/attach/00000001/1.xz', name: 'sub.ass' },
); );
assert.deepEqual(foreignUrlDownload, { assert.deepEqual(foreignUrlDownload, {
ok: false, ok: false,
error: { error: 'Refusing to download subtitle from a non-Animetosho URL.', code: 400 }, error: { error: 'Refusing to download subtitle from a non-TsukiHime URL.', code: 400 },
}); });
}); });
test('animetosho downloads route by language: secondary for eng, primary for jpn', async () => { test('tsukihime downloads always route Japanese as primary', async () => {
const { registrar, handleHandlers } = createFakeRegistrar(); const { registrar, handleHandlers } = createFakeRegistrar();
const primaryLoads: string[] = []; const primaryLoads: string[] = [];
const secondaryLoads: string[] = []; const secondaryLoads: string[] = [];
@@ -160,10 +160,10 @@ test('animetosho downloads route by language: secondary for eng, primary for jpn
onDownloadedSubtitle: (path) => { onDownloadedSubtitle: (path) => {
primaryLoads.push(path); primaryLoads.push(path);
}, },
searchAnimetoshoEntries: async () => ({ ok: true, data: [] }), searchTsukihimeEntries: async () => ({ ok: true, data: [] }),
listAnimetoshoFiles: async () => ({ ok: true, data: [] }), listTsukihimeFiles: async () => ({ ok: true, data: [] }),
downloadAnimetoshoSubtitle: async (_url, destPath) => ({ ok: true, path: destPath }), downloadTsukihimeSubtitle: async (_url, destPath) => ({ ok: true, path: destPath }),
getAnimetoshoSecondaryLanguages: () => ['en'], getTsukihimeSecondaryLanguages: () => ['ja'],
onDownloadedSecondarySubtitle: (path) => { onDownloadedSecondarySubtitle: (path) => {
secondaryLoads.push(path); secondaryLoads.push(path);
}, },
@@ -171,13 +171,13 @@ test('animetosho downloads route by language: secondary for eng, primary for jpn
registrar, registrar,
); );
const downloadHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoDownloadFile)!; const downloadHandler = handleHandlers.get(IPC_CHANNELS.request.tsukihimeDownloadFile)!;
const engResult = (await downloadHandler!( const engResult = (await downloadHandler!(
{}, {},
{ {
entryId: 1, entryId: 1,
url: 'https://animetosho.org/storage/attach/00000001/1.xz', url: 'https://storage.tsukihime.org/attach/00000001/1.xz',
name: 'episode.eng.ass', name: 'episode.eng.ass',
lang: 'eng', lang: 'eng',
}, },
@@ -191,7 +191,7 @@ test('animetosho downloads route by language: secondary for eng, primary for jpn
{}, {},
{ {
entryId: 1, entryId: 1,
url: 'https://animetosho.org/storage/attach/00000002/2.xz', url: 'https://storage.tsukihime.org/attach/00000002/2.xz',
name: 'episode.jpn.ass', name: 'episode.jpn.ass',
lang: 'jpn', lang: 'jpn',
}, },
@@ -232,10 +232,10 @@ test('anki/jimaku IPC command handlers ignore malformed payloads', () => {
isRemoteMediaPath: () => false, isRemoteMediaPath: () => false,
downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }), downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }),
onDownloadedSubtitle: () => {}, onDownloadedSubtitle: () => {},
searchAnimetoshoEntries: async () => ({ ok: true, data: [] }), searchTsukihimeEntries: async () => ({ ok: true, data: [] }),
listAnimetoshoFiles: async () => ({ ok: true, data: [] }), listTsukihimeFiles: async () => ({ ok: true, data: [] }),
downloadAnimetoshoSubtitle: async () => ({ ok: true, path: '/tmp/sub.en.ass' }), downloadTsukihimeSubtitle: async () => ({ ok: true, path: '/tmp/sub.en.ass' }),
getAnimetoshoSecondaryLanguages: () => ['en'], getTsukihimeSecondaryLanguages: () => ['en'],
onDownloadedSecondarySubtitle: () => {}, onDownloadedSecondarySubtitle: () => {},
}, },
registrar, registrar,
+44 -44
View File
@@ -4,12 +4,12 @@ import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
import { createLogger } from '../../logger'; import { createLogger } from '../../logger';
import { import {
AnimetoshoApiResponse, TsukihimeApiResponse,
AnimetoshoDownloadResult, TsukihimeDownloadResult,
AnimetoshoEntry, TsukihimeEntry,
AnimetoshoFilesQuery, TsukihimeFilesQuery,
AnimetoshoSearchQuery, TsukihimeSearchQuery,
AnimetoshoSubtitleFile, TsukihimeSubtitleFile,
JimakuApiResponse, JimakuApiResponse,
JimakuDownloadResult, JimakuDownloadResult,
JimakuEntry, JimakuEntry,
@@ -23,9 +23,9 @@ import {
} from '../../types'; } from '../../types';
import { IPC_CHANNELS } from '../../shared/ipc/contracts'; import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { import {
parseAnimetoshoDownloadQuery, parseTsukihimeDownloadQuery,
parseAnimetoshoFilesQuery, parseTsukihimeFilesQuery,
parseAnimetoshoSearchQuery, parseTsukihimeSearchQuery,
parseJimakuDownloadQuery, parseJimakuDownloadQuery,
parseJimakuFilesQuery, parseJimakuFilesQuery,
parseJimakuSearchQuery, parseJimakuSearchQuery,
@@ -33,7 +33,7 @@ import {
parseKikuMergePreviewRequest, parseKikuMergePreviewRequest,
} from '../../shared/ipc/validators'; } from '../../shared/ipc/validators';
import { buildJimakuSubtitleFilenameFromMediaPath } from './jimaku-download-path'; import { buildJimakuSubtitleFilenameFromMediaPath } from './jimaku-download-path';
import { animetoshoLangToFilenameSuffix, isAnimetoshoDownloadUrl } from '../../animetosho/utils'; import { tsukihimeLangToFilenameSuffix, isTsukihimeDownloadUrl } from '../../tsukihime/utils';
const { ipcMain } = electron; const { ipcMain } = electron;
@@ -57,14 +57,14 @@ export interface AnkiJimakuIpcDeps {
headers: Record<string, string>, headers: Record<string, string>,
) => Promise<JimakuDownloadResult>; ) => Promise<JimakuDownloadResult>;
onDownloadedSubtitle: (pathToSubtitle: string) => void; onDownloadedSubtitle: (pathToSubtitle: string) => void;
searchAnimetoshoEntries: ( searchTsukihimeEntries: (
query: AnimetoshoSearchQuery, query: TsukihimeSearchQuery,
) => Promise<AnimetoshoApiResponse<AnimetoshoEntry[]>>; ) => Promise<TsukihimeApiResponse<TsukihimeEntry[]>>;
listAnimetoshoFiles: ( listTsukihimeFiles: (
query: AnimetoshoFilesQuery, query: TsukihimeFilesQuery,
) => Promise<AnimetoshoApiResponse<AnimetoshoSubtitleFile[]>>; ) => Promise<TsukihimeApiResponse<TsukihimeSubtitleFile[]>>;
downloadAnimetoshoSubtitle: (url: string, destPath: string) => Promise<AnimetoshoDownloadResult>; downloadTsukihimeSubtitle: (url: string, destPath: string) => Promise<TsukihimeDownloadResult>;
getAnimetoshoSecondaryLanguages: () => string[]; getTsukihimeSecondaryLanguages: () => string[];
onDownloadedSecondarySubtitle: (pathToSubtitle: string) => void | Promise<void>; onDownloadedSecondarySubtitle: (pathToSubtitle: string) => void | Promise<void>;
} }
@@ -206,50 +206,50 @@ export function registerAnkiJimakuIpcHandlers(
}, },
); );
ipc.handle(IPC_CHANNELS.request.animetoshoGetSecondaryLanguages, (): string[] => { ipc.handle(IPC_CHANNELS.request.tsukihimeGetSecondaryLanguages, (): string[] => {
return deps.getAnimetoshoSecondaryLanguages(); return deps.getTsukihimeSecondaryLanguages();
}); });
ipc.handle( ipc.handle(
IPC_CHANNELS.request.animetoshoSearchEntries, IPC_CHANNELS.request.tsukihimeSearchEntries,
async (_event, query: unknown): Promise<AnimetoshoApiResponse<AnimetoshoEntry[]>> => { async (_event, query: unknown): Promise<TsukihimeApiResponse<TsukihimeEntry[]>> => {
const parsedQuery = parseAnimetoshoSearchQuery(query); const parsedQuery = parseTsukihimeSearchQuery(query);
if (!parsedQuery) { if (!parsedQuery) {
return { return {
ok: false, ok: false,
error: { error: 'Invalid Animetosho search query payload', code: 400 }, error: { error: 'Invalid TsukiHime search query payload', code: 400 },
}; };
} }
return deps.searchAnimetoshoEntries(parsedQuery); return deps.searchTsukihimeEntries(parsedQuery);
}, },
); );
ipc.handle( ipc.handle(
IPC_CHANNELS.request.animetoshoListFiles, IPC_CHANNELS.request.tsukihimeListFiles,
async (_event, query: unknown): Promise<AnimetoshoApiResponse<AnimetoshoSubtitleFile[]>> => { async (_event, query: unknown): Promise<TsukihimeApiResponse<TsukihimeSubtitleFile[]>> => {
const parsedQuery = parseAnimetoshoFilesQuery(query); const parsedQuery = parseTsukihimeFilesQuery(query);
if (!parsedQuery) { if (!parsedQuery) {
return { ok: false, error: { error: 'Invalid Animetosho files query payload', code: 400 } }; return { ok: false, error: { error: 'Invalid TsukiHime files query payload', code: 400 } };
} }
return deps.listAnimetoshoFiles(parsedQuery); return deps.listTsukihimeFiles(parsedQuery);
}, },
); );
ipc.handle( ipc.handle(
IPC_CHANNELS.request.animetoshoDownloadFile, IPC_CHANNELS.request.tsukihimeDownloadFile,
async (_event, query: unknown): Promise<AnimetoshoDownloadResult> => { async (_event, query: unknown): Promise<TsukihimeDownloadResult> => {
const parsedQuery = parseAnimetoshoDownloadQuery(query); const parsedQuery = parseTsukihimeDownloadQuery(query);
if (!parsedQuery) { if (!parsedQuery) {
return { return {
ok: false, ok: false,
error: { error: 'Invalid Animetosho download query payload', code: 400 }, error: { error: 'Invalid TsukiHime download query payload', code: 400 },
}; };
} }
if (!isAnimetoshoDownloadUrl(parsedQuery.url)) { if (!isTsukihimeDownloadUrl(parsedQuery.url)) {
return { return {
ok: false, ok: false,
error: { error: 'Refusing to download subtitle from a non-Animetosho URL.', code: 400 }, error: { error: 'Refusing to download subtitle from a non-TsukiHime URL.', code: 400 },
}; };
} }
@@ -259,13 +259,13 @@ export function registerAnkiJimakuIpcHandlers(
} }
const mediaDir = deps.isRemoteMediaPath(currentMediaPath) const mediaDir = deps.isRemoteMediaPath(currentMediaPath)
? fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-animetosho-')) ? fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-tsukihime-'))
: path.dirname(path.resolve(currentMediaPath)); : path.dirname(path.resolve(currentMediaPath));
const safeName = path.basename(parsedQuery.name); const safeName = path.basename(parsedQuery.name);
if (!safeName) { if (!safeName) {
return { ok: false, error: { error: 'Invalid subtitle filename.' } }; return { ok: false, error: { error: 'Invalid subtitle filename.' } };
} }
const languageSuffix = animetoshoLangToFilenameSuffix(parsedQuery.lang); const languageSuffix = tsukihimeLangToFilenameSuffix(parsedQuery.lang);
const subtitleFilename = buildJimakuSubtitleFilenameFromMediaPath( const subtitleFilename = buildJimakuSubtitleFilenameFromMediaPath(
currentMediaPath, currentMediaPath,
safeName, safeName,
@@ -276,24 +276,24 @@ export function registerAnkiJimakuIpcHandlers(
const baseName = ext ? subtitleFilename.slice(0, -ext.length) : subtitleFilename; const baseName = ext ? subtitleFilename.slice(0, -ext.length) : subtitleFilename;
let targetPath = path.join(mediaDir, subtitleFilename); let targetPath = path.join(mediaDir, subtitleFilename);
if (fs.existsSync(targetPath)) { if (fs.existsSync(targetPath)) {
targetPath = path.join(mediaDir, `${baseName} (animetosho-${parsedQuery.entryId})${ext}`); targetPath = path.join(mediaDir, `${baseName} (tsukihime-${parsedQuery.entryId})${ext}`);
let counter = 2; let counter = 2;
while (fs.existsSync(targetPath)) { while (fs.existsSync(targetPath)) {
targetPath = path.join( targetPath = path.join(
mediaDir, mediaDir,
`${baseName} (animetosho-${parsedQuery.entryId}-${counter})${ext}`, `${baseName} (tsukihime-${parsedQuery.entryId}-${counter})${ext}`,
); );
counter += 1; counter += 1;
} }
} }
logger.info( logger.info(
`[animetosho] download-file name="${parsedQuery.name}" entryId=${parsedQuery.entryId}`, `[tsukihime] download-file name="${parsedQuery.name}" entryId=${parsedQuery.entryId}`,
); );
const result = await deps.downloadAnimetoshoSubtitle(parsedQuery.url, targetPath); const result = await deps.downloadTsukihimeSubtitle(parsedQuery.url, targetPath);
if (result.ok) { if (result.ok) {
logger.info(`[animetosho] download-file saved to ${result.path}`); logger.info(`[tsukihime] download-file saved to ${result.path}`);
// Japanese tracks take the primary slot; anything else loads as the // Japanese tracks take the primary slot; anything else loads as the
// secondary subtitle so the Japanese primary stays in place. // secondary subtitle so the Japanese primary stays in place.
if (languageSuffix === 'ja') { if (languageSuffix === 'ja') {
@@ -303,7 +303,7 @@ export function registerAnkiJimakuIpcHandlers(
} }
} else { } else {
logger.error( logger.error(
`[animetosho] download-file failed: ${result.error?.error ?? 'unknown error'}`, `[tsukihime] download-file failed: ${result.error?.error ?? 'unknown error'}`,
); );
} }
+31 -29
View File
@@ -11,7 +11,7 @@ interface RuntimeHarness {
patches: boolean[]; patches: boolean[];
broadcasts: number; broadcasts: number;
fetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>; fetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
animetoshoFetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>; tsukihimeFetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
sentCommands: Array<{ command: (string | number)[] }>; sentCommands: Array<{ command: (string | number)[] }>;
}; };
} }
@@ -26,7 +26,7 @@ function createHarness(): RuntimeHarness {
endpoint: string; endpoint: string;
query?: Record<string, unknown>; query?: Record<string, unknown>;
}>, }>,
animetoshoFetchCalls: [] as Array<{ tsukihimeFetchCalls: [] as Array<{
endpoint: string; endpoint: string;
query?: Record<string, unknown>; query?: Record<string, unknown>;
}>, }>,
@@ -37,17 +37,18 @@ function createHarness(): RuntimeHarness {
patchAnkiConnectEnabled: (enabled) => { patchAnkiConnectEnabled: (enabled) => {
state.patches.push(enabled); state.patches.push(enabled);
}, },
getResolvedConfig: () => ({ animetosho: { maxSearchResults: 2 } }), getResolvedConfig: () => ({ tsukihime: { maxSearchResults: 2 } }),
getRuntimeOptionsManager: () => null, getRuntimeOptionsManager: () => null,
animetoshoFetchJson: async (endpoint, query) => { tsukihimeFetchJson: async (endpoint, query) => {
state.animetoshoFetchCalls.push({ state.tsukihimeFetchCalls.push({
endpoint, endpoint,
query: query as Record<string, unknown>, query: query as Record<string, unknown>,
}); });
if ((query as Record<string, unknown>)?.show === 'torrent') { if (endpoint.startsWith('/torrents/')) {
return { return {
ok: true, ok: true,
data: { data: {
id: 606713,
files: [ files: [
{ {
id: 9, id: 9,
@@ -55,9 +56,8 @@ function createHarness(): RuntimeHarness {
attachments: [ attachments: [
{ {
id: 1955356, id: 1955356,
type: 'subtitle', type: 1,
info: { codec: 'ASS', lang: 'eng', name: 'English subs' }, info: { codec: 'ASS', lang: 'en', name: 'English subs' },
size: 33075,
}, },
], ],
}, },
@@ -67,11 +67,13 @@ function createHarness(): RuntimeHarness {
} }
return { return {
ok: true, ok: true,
data: [ data: {
{ id: 1, title: 'release a' }, results: [
{ id: 2, title: 'release b' }, { id: 1, name: 'release a' },
{ id: 3, title: 'release c' }, { id: 2, name: 'release b' },
] as never, { id: 3, name: 'release c' },
],
} as never,
}; };
}, },
getSubtitleTimingTracker: () => null, getSubtitleTimingTracker: () => null,
@@ -172,9 +174,9 @@ test('registerAnkiJimakuIpcRuntime provides full handler surface', () => {
'isRemoteMediaPath', 'isRemoteMediaPath',
'downloadToFile', 'downloadToFile',
'onDownloadedSubtitle', 'onDownloadedSubtitle',
'searchAnimetoshoEntries', 'searchTsukihimeEntries',
'listAnimetoshoFiles', 'listTsukihimeFiles',
'downloadAnimetoshoSubtitle', 'downloadTsukihimeSubtitle',
'onDownloadedSecondarySubtitle', 'onDownloadedSecondarySubtitle',
]; ];
@@ -365,14 +367,14 @@ test('onDownloadedSecondarySubtitle retries until mpv reports the new track', as
}); });
}); });
test('searchAnimetoshoEntries caps results using animetosho.maxSearchResults', async () => { test('searchTsukihimeEntries caps results using tsukihime.maxSearchResults', async () => {
const { registered, state } = createHarness(); const { registered, state } = createHarness();
const searchResult = await registered.searchAnimetoshoEntries!({ query: 'frieren 28' }); const searchResult = await registered.searchTsukihimeEntries!({ query: 'frieren 28' });
assert.deepEqual(state.animetoshoFetchCalls, [ assert.deepEqual(state.tsukihimeFetchCalls, [
{ {
endpoint: '/json', endpoint: '/search/torrents',
query: { q: 'frieren 28', qx: 1 }, query: { q: 'frieren 28', limit: 2 },
}, },
]); ]);
assert.equal((searchResult as { ok: boolean }).ok, true); assert.equal((searchResult as { ok: boolean }).ok, true);
@@ -384,20 +386,20 @@ test('searchAnimetoshoEntries caps results using animetosho.maxSearchResults', a
); );
}); });
test('listAnimetoshoFiles extracts subtitle attachments from torrent detail', async () => { test('listTsukihimeFiles extracts subtitle attachments from torrent detail', async () => {
const { registered, state } = createHarness(); const { registered, state } = createHarness();
const filesResult = await registered.listAnimetoshoFiles!({ entryId: 606713 }); const filesResult = await registered.listTsukihimeFiles!({ entryId: 606713 });
assert.deepEqual(state.animetoshoFetchCalls, [ assert.deepEqual(state.tsukihimeFetchCalls, [
{ {
endpoint: '/json', endpoint: '/torrents/606713',
query: { show: 'torrent', id: 606713 }, query: {},
}, },
]); ]);
assert.equal((filesResult as { ok: boolean }).ok, true); assert.equal((filesResult as { ok: boolean }).ok, true);
const files = (filesResult as { data: Array<Record<string, unknown>> }).data; const files = (filesResult as { data: Array<Record<string, unknown>> }).data;
assert.equal(files.length, 1); assert.equal(files.length, 1);
assert.equal(files[0]!.attachmentId, 1955356); assert.equal(files[0]!.attachmentId, 1955356);
assert.equal(files[0]!.filename, 'episode.eng.ass'); assert.equal(files[0]!.filename, 'episode.en.ass');
assert.equal(files[0]!.url, 'https://animetosho.org/storage/attach/001dd61c/1955356.xz'); assert.equal(files[0]!.url, 'https://storage.tsukihime.org/attach/001dd61c/1955356.xz');
}); });
+44 -42
View File
@@ -3,8 +3,8 @@ import { AnkiIntegration } from '../../anki-integration';
import { mergeAiConfig } from '../../ai/config'; import { mergeAiConfig } from '../../ai/config';
import { import {
AiConfig, AiConfig,
AnimetoshoApiResponse, TsukihimeApiResponse,
AnimetoshoConfig, TsukihimeConfig,
AnkiConnectConfig, AnkiConnectConfig,
JimakuApiResponse, JimakuApiResponse,
JimakuEntry, JimakuEntry,
@@ -17,13 +17,13 @@ import {
} from '../../types'; } from '../../types';
import { sortJimakuFiles } from '../../jimaku/utils'; import { sortJimakuFiles } from '../../jimaku/utils';
import { import {
ANIMETOSHO_FEED_BASE_URL, TSUKIHIME_API_BASE_URL,
animetoshoFetchJson as animetoshoFetchJsonRequest, tsukihimeFetchJson as tsukihimeFetchJsonRequest,
decompressXzFile, decompressXzFile,
extractAnimetoshoSubtitleFiles, extractTsukihimeSubtitleFiles,
isAnimetoshoDownloadUrl, isTsukihimeDownloadUrl,
mapAnimetoshoSearchResults, mapTsukihimeSearchResults,
} from '../../animetosho/utils'; } from '../../tsukihime/utils';
import type { AnkiJimakuIpcDeps } from './anki-jimaku-ipc'; import type { AnkiJimakuIpcDeps } from './anki-jimaku-ipc';
import { createLogger } from '../../logger'; import { createLogger } from '../../logger';
@@ -48,7 +48,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
getResolvedConfig: () => { getResolvedConfig: () => {
ankiConnect?: AnkiConnectConfig; ankiConnect?: AnkiConnectConfig;
ai?: AiConfig; ai?: AiConfig;
animetosho?: AnimetoshoConfig; tsukihime?: TsukihimeConfig;
secondarySub?: { secondarySubLanguages?: string[] }; secondarySub?: { secondarySubLanguages?: string[] };
}; };
getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null; getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null;
@@ -77,10 +77,10 @@ export interface AnkiJimakuIpcRuntimeOptions {
endpoint: string, endpoint: string,
query?: Record<string, string | number | boolean | null | undefined>, query?: Record<string, string | number | boolean | null | undefined>,
) => Promise<JimakuApiResponse<T>>; ) => Promise<JimakuApiResponse<T>>;
animetoshoFetchJson?: <T>( tsukihimeFetchJson?: <T>(
endpoint: string, endpoint: string,
query?: Record<string, string | number | boolean | null | undefined>, query?: Record<string, string | number | boolean | null | undefined>,
) => Promise<AnimetoshoApiResponse<T>>; ) => Promise<TsukihimeApiResponse<T>>;
getJimakuMaxEntryResults: () => number; getJimakuMaxEntryResults: () => number;
getJimakuLanguagePreference: () => JimakuLanguagePreference; getJimakuLanguagePreference: () => JimakuLanguagePreference;
resolveJimakuApiKey: () => Promise<string | null>; resolveJimakuApiKey: () => Promise<string | null>;
@@ -101,7 +101,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
const logger = createLogger('main:anki-jimaku'); const logger = createLogger('main:anki-jimaku');
const DEFAULT_ANIMETOSHO_MAX_SEARCH_RESULTS = 10; const DEFAULT_TSUKIHIME_MAX_SEARCH_RESULTS = 10;
const SECONDARY_TRACK_LOOKUP_ATTEMPTS = 5; const SECONDARY_TRACK_LOOKUP_ATTEMPTS = 5;
const SECONDARY_TRACK_LOOKUP_RETRY_MS = 100; const SECONDARY_TRACK_LOOKUP_RETRY_MS = 100;
@@ -109,24 +109,24 @@ function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
function getAnimetoshoMaxSearchResults(options: AnkiJimakuIpcRuntimeOptions): number { function getTsukihimeMaxSearchResults(options: AnkiJimakuIpcRuntimeOptions): number {
const value = options.getResolvedConfig().animetosho?.maxSearchResults; const value = options.getResolvedConfig().tsukihime?.maxSearchResults;
if (typeof value === 'number' && Number.isFinite(value) && value > 0) { if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Math.floor(value); return Math.floor(value);
} }
return DEFAULT_ANIMETOSHO_MAX_SEARCH_RESULTS; return DEFAULT_TSUKIHIME_MAX_SEARCH_RESULTS;
} }
function animetoshoFetch<T>( function tsukihimeFetch<T>(
options: AnkiJimakuIpcRuntimeOptions, options: AnkiJimakuIpcRuntimeOptions,
endpoint: string, endpoint: string,
query: Record<string, string | number | boolean | null | undefined>, query: Record<string, string | number | boolean | null | undefined>,
): Promise<AnimetoshoApiResponse<T>> { ): Promise<TsukihimeApiResponse<T>> {
if (options.animetoshoFetchJson) { if (options.tsukihimeFetchJson) {
return options.animetoshoFetchJson<T>(endpoint, query); return options.tsukihimeFetchJson<T>(endpoint, query);
} }
const baseUrl = options.getResolvedConfig().animetosho?.apiBaseUrl || ANIMETOSHO_FEED_BASE_URL; const baseUrl = options.getResolvedConfig().tsukihime?.apiBaseUrl || TSUKIHIME_API_BASE_URL;
return animetoshoFetchJsonRequest<T>(endpoint, query, { baseUrl }); return tsukihimeFetchJsonRequest<T>(endpoint, query, { baseUrl });
} }
export function registerAnkiJimakuIpcRuntime( export function registerAnkiJimakuIpcRuntime(
@@ -242,39 +242,41 @@ export function registerAnkiJimakuIpcRuntime(
isRemoteMediaPath: (mediaPath) => options.isRemoteMediaPath(mediaPath), isRemoteMediaPath: (mediaPath) => options.isRemoteMediaPath(mediaPath),
downloadToFile: (url, destPath, headers) => options.downloadToFile(url, destPath, headers), downloadToFile: (url, destPath, headers) => options.downloadToFile(url, destPath, headers),
searchAnimetoshoEntries: async (query) => { searchTsukihimeEntries: async (query) => {
logger.info(`[animetosho] search-entries query: "${query.query}"`); logger.info(`[tsukihime] search-entries query: "${query.query}"`);
const response = await animetoshoFetch<unknown>(options, '/json', { const maxResults = getTsukihimeMaxSearchResults(options);
const response = await tsukihimeFetch<unknown>(options, '/search/torrents', {
q: query.query, q: query.query,
qx: 1, // The API caps limit at 100.
limit: Math.min(maxResults, 100),
}); });
if (!response.ok) return response; if (!response.ok) return response;
const maxResults = getAnimetoshoMaxSearchResults(options); const entries = mapTsukihimeSearchResults(response.data, maxResults);
const entries = mapAnimetoshoSearchResults(response.data, maxResults); logger.info(`[tsukihime] search-entries returned ${entries.length} results`);
logger.info(`[animetosho] search-entries returned ${entries.length} results`);
return { ok: true, data: entries }; return { ok: true, data: entries };
}, },
listAnimetoshoFiles: async (query) => { listTsukihimeFiles: async (query) => {
logger.info(`[animetosho] list-files entryId=${query.entryId}`); logger.info(`[tsukihime] list-files entryId=${query.entryId}`);
const response = await animetoshoFetch<unknown>(options, '/json', { const response = await tsukihimeFetch<unknown>(
show: 'torrent', options,
id: query.entryId, `/torrents/${encodeURIComponent(query.entryId)}`,
}); {},
);
if (!response.ok) return response; if (!response.ok) return response;
const files = extractAnimetoshoSubtitleFiles(response.data); const files = extractTsukihimeSubtitleFiles(response.data);
logger.info(`[animetosho] list-files returned ${files.length} subtitle attachments`); logger.info(`[tsukihime] list-files returned ${files.length} subtitle attachments`);
return { ok: true, data: files }; return { ok: true, data: files };
}, },
getAnimetoshoSecondaryLanguages: () => getTsukihimeSecondaryLanguages: () =>
options.getResolvedConfig().secondarySub?.secondarySubLanguages ?? [], options.getResolvedConfig().secondarySub?.secondarySubLanguages ?? [],
downloadAnimetoshoSubtitle: async (url, destPath) => { downloadTsukihimeSubtitle: async (url, destPath) => {
const tempXzPath = `${destPath}.xz`; const tempXzPath = `${destPath}.xz`;
const downloaded = await options.downloadToFile( const downloaded = await options.downloadToFile(
url, url,
tempXzPath, tempXzPath,
{ 'User-Agent': 'SubMiner' }, { 'User-Agent': 'SubMiner' },
// animetosho.org redirects to storage.animetosho.org; keep the hop in-domain. // The /tosho/ mirror 302s to storage.animetosho.org; keep the hop in-allowlist.
{ isAllowedRedirect: (redirectUrl) => isAnimetoshoDownloadUrl(redirectUrl) }, { isAllowedRedirect: (redirectUrl) => isTsukihimeDownloadUrl(redirectUrl) },
); );
if (!downloaded.ok) return downloaded; if (!downloaded.ok) return downloaded;
const result = await decompressXzFile(tempXzPath, destPath); const result = await decompressXzFile(tempXzPath, destPath);
@@ -310,14 +312,14 @@ export function registerAnkiJimakuIpcRuntime(
return; return;
} }
} catch (error) { } catch (error) {
logger.warn('[animetosho] failed to select downloaded subtitle as secondary:', error); logger.warn('[tsukihime] failed to select downloaded subtitle as secondary:', error);
return; return;
} }
await delay(SECONDARY_TRACK_LOOKUP_RETRY_MS); await delay(SECONDARY_TRACK_LOOKUP_RETRY_MS);
} }
logger.warn( logger.warn(
`[animetosho] could not find downloaded subtitle in track-list: ${pathToSubtitle}`, `[tsukihime] could not find downloaded subtitle in track-list: ${pathToSubtitle}`,
); );
}, },
}); });
+1 -1
View File
@@ -40,7 +40,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openControllerSelect: false, openControllerSelect: false,
openControllerDebug: false, openControllerDebug: false,
openJimaku: false, openJimaku: false,
openAnimetosho: false, openTsukihime: false,
openYoutubePicker: false, openYoutubePicker: false,
openPlaylistBrowser: false, openPlaylistBrowser: false,
replayCurrentSubtitle: false, replayCurrentSubtitle: false,
+1 -1
View File
@@ -45,7 +45,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openControllerSelect: false, openControllerSelect: false,
openControllerDebug: false, openControllerDebug: false,
openJimaku: false, openJimaku: false,
openAnimetosho: false, openTsukihime: false,
openYoutubePicker: false, openYoutubePicker: false,
openPlaylistBrowser: false, openPlaylistBrowser: false,
togglePrimarySubtitleBar: false, togglePrimarySubtitleBar: false,
+4 -4
View File
@@ -544,11 +544,11 @@ export function handleCliCommand(
); );
} else if (args.openJimaku) { } else if (args.openJimaku) {
dispatchCliSessionAction({ actionId: 'openJimaku' }, 'openJimaku', 'Open jimaku failed'); dispatchCliSessionAction({ actionId: 'openJimaku' }, 'openJimaku', 'Open jimaku failed');
} else if (args.openAnimetosho) { } else if (args.openTsukihime) {
dispatchCliSessionAction( dispatchCliSessionAction(
{ actionId: 'openAnimetosho' }, { actionId: 'openTsukihime' },
'openAnimetosho', 'openTsukihime',
'Open animetosho failed', 'Open tsukihime failed',
); );
} else if (args.openYoutubePicker) { } else if (args.openYoutubePicker) {
dispatchCliSessionAction( dispatchCliSessionAction(
+12 -2
View File
@@ -13,6 +13,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
RUNTIME_OPTIONS_OPEN: '__runtime-options-open', RUNTIME_OPTIONS_OPEN: '__runtime-options-open',
JIMAKU_OPEN: '__jimaku-open', JIMAKU_OPEN: '__jimaku-open',
ANIMETOSHO_OPEN: '__animetosho-open', ANIMETOSHO_OPEN: '__animetosho-open',
TSUKIHIME_OPEN: '__tsukihime-open',
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:', RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
REPLAY_SUBTITLE: '__replay-subtitle', REPLAY_SUBTITLE: '__replay-subtitle',
PLAY_NEXT_SUBTITLE: '__play-next-subtitle', PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
@@ -28,8 +29,8 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
openJimaku: () => { openJimaku: () => {
calls.push('jimaku'); calls.push('jimaku');
}, },
openAnimetosho: () => { openTsukihime: () => {
calls.push('animetosho'); calls.push('tsukihime');
}, },
openYoutubeTrackPicker: () => { openYoutubeTrackPicker: () => {
calls.push('youtube-picker'); calls.push('youtube-picker');
@@ -152,6 +153,15 @@ test('handleMpvCommandFromIpc dispatches special jimaku open command', () => {
assert.deepEqual(osd, []); assert.deepEqual(osd, []);
}); });
test('handleMpvCommandFromIpc keeps the legacy Animetosho command as a TsukiHime alias', () => {
const { options, calls, sentCommands } = createOptions();
handleMpvCommandFromIpc(['__animetosho-open'], options);
assert.deepEqual(calls, ['tsukihime']);
assert.deepEqual(sentCommands, []);
});
test('handleMpvCommandFromIpc dispatches special playlist browser open command', async () => { test('handleMpvCommandFromIpc dispatches special playlist browser open command', async () => {
const { options, calls, sentCommands, osd } = createOptions(); const { options, calls, sentCommands, osd } = createOptions();
handleMpvCommandFromIpc(['__playlist-browser-open'], options); handleMpvCommandFromIpc(['__playlist-browser-open'], options);
+7 -3
View File
@@ -11,6 +11,7 @@ export interface HandleMpvCommandFromIpcOptions {
RUNTIME_OPTIONS_OPEN: string; RUNTIME_OPTIONS_OPEN: string;
JIMAKU_OPEN: string; JIMAKU_OPEN: string;
ANIMETOSHO_OPEN: string; ANIMETOSHO_OPEN: string;
TSUKIHIME_OPEN: string;
RUNTIME_OPTION_CYCLE_PREFIX: string; RUNTIME_OPTION_CYCLE_PREFIX: string;
REPLAY_SUBTITLE: string; REPLAY_SUBTITLE: string;
PLAY_NEXT_SUBTITLE: string; PLAY_NEXT_SUBTITLE: string;
@@ -20,7 +21,7 @@ export interface HandleMpvCommandFromIpcOptions {
triggerSubsyncFromConfig: () => void; triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
openJimaku: () => void; openJimaku: () => void;
openAnimetosho: () => void; openTsukihime: () => void;
openYoutubeTrackPicker: () => void | Promise<void>; openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => void | Promise<void>; openPlaylistBrowser: () => void | Promise<void>;
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
@@ -114,8 +115,11 @@ export function handleMpvCommandFromIpc(
return; return;
} }
if (first === options.specialCommands.ANIMETOSHO_OPEN) { if (
options.openAnimetosho(); first === options.specialCommands.TSUKIHIME_OPEN ||
first === options.specialCommands.ANIMETOSHO_OPEN
) {
options.openTsukihime();
return; return;
} }
@@ -28,7 +28,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
openCharacterDictionaryManager: null, openCharacterDictionaryManager: null,
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openAnimetosho: null, openTsukihime: null,
openSessionHelp: null, openSessionHelp: null,
openControllerSelect: null, openControllerSelect: null,
openControllerDebug: null, openControllerDebug: null,
@@ -55,8 +55,8 @@ function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
openJimaku: () => { openJimaku: () => {
calls.push('openJimaku'); calls.push('openJimaku');
}, },
openAnimetosho: () => { openTsukihime: () => {
calls.push('openAnimetosho'); calls.push('openTsukihime');
}, },
markAudioCard: async () => { markAudioCard: async () => {
calls.push('markAudioCard'); calls.push('markAudioCard');
@@ -168,7 +168,7 @@ test('runOverlayShortcutLocalFallback dispatches matching single-step actions',
openRuntimeOptions: () => handled.push('openRuntimeOptions'), openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'), openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
openJimaku: () => handled.push('openJimaku'), openJimaku: () => handled.push('openJimaku'),
openAnimetosho: () => handled.push('openAnimetosho'), openTsukihime: () => handled.push('openTsukihime'),
markAudioCard: () => handled.push('markAudioCard'), markAudioCard: () => handled.push('markAudioCard'),
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`), copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
copySubtitle: () => handled.push('copySubtitle'), copySubtitle: () => handled.push('copySubtitle'),
@@ -202,7 +202,7 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
openRuntimeOptions: () => handled.push('openRuntimeOptions'), openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'), openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
openJimaku: () => handled.push('openJimaku'), openJimaku: () => handled.push('openJimaku'),
openAnimetosho: () => handled.push('openAnimetosho'), openTsukihime: () => handled.push('openTsukihime'),
markAudioCard: () => handled.push('markAudioCard'), markAudioCard: () => handled.push('markAudioCard'),
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`), copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
copySubtitle: () => handled.push('copySubtitle'), copySubtitle: () => handled.push('copySubtitle'),
@@ -223,7 +223,7 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
openRuntimeOptions: () => handled.push('openRuntimeOptions'), openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'), openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
openJimaku: () => handled.push('openJimaku'), openJimaku: () => handled.push('openJimaku'),
openAnimetosho: () => handled.push('openAnimetosho'), openTsukihime: () => handled.push('openTsukihime'),
markAudioCard: () => handled.push('markAudioCard'), markAudioCard: () => handled.push('markAudioCard'),
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`), copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
copySubtitle: () => handled.push('copySubtitle'), copySubtitle: () => handled.push('copySubtitle'),
@@ -261,7 +261,7 @@ test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s
openRuntimeOptions: () => {}, openRuntimeOptions: () => {},
openCharacterDictionaryManager: () => {}, openCharacterDictionaryManager: () => {},
openJimaku: () => {}, openJimaku: () => {},
openAnimetosho: () => {}, openTsukihime: () => {},
markAudioCard: () => {}, markAudioCard: () => {},
copySubtitleMultiple: () => {}, copySubtitleMultiple: () => {},
copySubtitle: () => {}, copySubtitle: () => {},
@@ -298,7 +298,7 @@ test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut',
openRuntimeOptions: () => {}, openRuntimeOptions: () => {},
openCharacterDictionaryManager: () => {}, openCharacterDictionaryManager: () => {},
openJimaku: () => {}, openJimaku: () => {},
openAnimetosho: () => {}, openTsukihime: () => {},
markAudioCard: () => {}, markAudioCard: () => {},
copySubtitleMultiple: () => {}, copySubtitleMultiple: () => {},
copySubtitle: () => {}, copySubtitle: () => {},
@@ -331,7 +331,7 @@ test('runOverlayShortcutLocalFallback returns false when no action matches', ()
openJimaku: () => { openJimaku: () => {
called = true; called = true;
}, },
openAnimetosho: () => { openTsukihime: () => {
called = true; called = true;
}, },
markAudioCard: () => { markAudioCard: () => {
@@ -416,7 +416,7 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured',
openCharacterDictionaryManager: () => {}, openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {}, openRuntimeOptions: () => {},
openJimaku: () => {}, openJimaku: () => {},
openAnimetosho: () => {}, openTsukihime: () => {},
}), }),
cancelPendingMultiCopy: () => {}, cancelPendingMultiCopy: () => {},
cancelPendingMineSentenceMultiple: () => {}, cancelPendingMineSentenceMultiple: () => {},
@@ -444,7 +444,7 @@ test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active
openCharacterDictionaryManager: () => {}, openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {}, openRuntimeOptions: () => {},
openJimaku: () => {}, openJimaku: () => {},
openAnimetosho: () => {}, openTsukihime: () => {},
}), }),
cancelPendingMultiCopy: () => { cancelPendingMultiCopy: () => {
calls.push('cancel-multi-copy'); calls.push('cancel-multi-copy');
@@ -8,7 +8,7 @@ export interface OverlayShortcutFallbackHandlers {
openRuntimeOptions: () => void; openRuntimeOptions: () => void;
openCharacterDictionaryManager: () => void; openCharacterDictionaryManager: () => void;
openJimaku: () => void; openJimaku: () => void;
openAnimetosho: () => void; openTsukihime: () => void;
markAudioCard: () => void; markAudioCard: () => void;
copySubtitleMultiple: (timeoutMs: number) => void; copySubtitleMultiple: (timeoutMs: number) => void;
copySubtitle: () => void; copySubtitle: () => void;
@@ -25,7 +25,7 @@ export interface OverlayShortcutRuntimeDeps {
openRuntimeOptions: () => void; openRuntimeOptions: () => void;
openCharacterDictionaryManager: () => void; openCharacterDictionaryManager: () => void;
openJimaku: () => void; openJimaku: () => void;
openAnimetosho: () => void; openTsukihime: () => void;
markAudioCard: () => Promise<void>; markAudioCard: () => Promise<void>;
copySubtitleMultiple: (timeoutMs: number) => void; copySubtitleMultiple: (timeoutMs: number) => void;
copySubtitle: () => void; copySubtitle: () => void;
@@ -105,8 +105,8 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
openJimaku: () => { openJimaku: () => {
deps.openJimaku(); deps.openJimaku();
}, },
openAnimetosho: () => { openTsukihime: () => {
deps.openAnimetosho(); deps.openTsukihime();
}, },
}; };
@@ -114,7 +114,7 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
openRuntimeOptions: overlayHandlers.openRuntimeOptions, openRuntimeOptions: overlayHandlers.openRuntimeOptions,
openCharacterDictionaryManager: overlayHandlers.openCharacterDictionaryManager, openCharacterDictionaryManager: overlayHandlers.openCharacterDictionaryManager,
openJimaku: overlayHandlers.openJimaku, openJimaku: overlayHandlers.openJimaku,
openAnimetosho: overlayHandlers.openAnimetosho, openTsukihime: overlayHandlers.openTsukihime,
markAudioCard: overlayHandlers.markAudioCard, markAudioCard: overlayHandlers.markAudioCard,
copySubtitleMultiple: overlayHandlers.copySubtitleMultiple, copySubtitleMultiple: overlayHandlers.copySubtitleMultiple,
copySubtitle: overlayHandlers.copySubtitle, copySubtitle: overlayHandlers.copySubtitle,
@@ -160,9 +160,9 @@ export function runOverlayShortcutLocalFallback(
allowWhenRegistered: true, allowWhenRegistered: true,
}, },
{ {
accelerator: shortcuts.openAnimetosho, accelerator: shortcuts.openTsukihime,
run: () => { run: () => {
handlers.openAnimetosho(); handlers.openTsukihime();
}, },
allowWhenRegistered: true, allowWhenRegistered: true,
}, },
+4 -4
View File
@@ -23,7 +23,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openCharacterDictionaryManager: null, openCharacterDictionaryManager: null,
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openAnimetosho: null, openTsukihime: null,
openSessionHelp: null, openSessionHelp: null,
openControllerSelect: null, openControllerSelect: null,
openControllerDebug: null, openControllerDebug: null,
@@ -49,7 +49,7 @@ test('registerOverlayShortcuts reports active overlay shortcuts when configured'
openCharacterDictionaryManager: () => {}, openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {}, openRuntimeOptions: () => {},
openJimaku: () => {}, openJimaku: () => {},
openAnimetosho: () => {}, openTsukihime: () => {},
}), }),
true, true,
); );
@@ -70,7 +70,7 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent'
openCharacterDictionaryManager: () => {}, openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {}, openRuntimeOptions: () => {},
openJimaku: () => {}, openJimaku: () => {},
openAnimetosho: () => {}, openTsukihime: () => {},
}), }),
false, false,
); );
@@ -93,7 +93,7 @@ test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active
openCharacterDictionaryManager: () => {}, openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {}, openRuntimeOptions: () => {},
openJimaku: () => {}, openJimaku: () => {},
openAnimetosho: () => {}, openTsukihime: () => {},
}), }),
cancelPendingMultiCopy: () => { cancelPendingMultiCopy: () => {
calls.push('cancel-multi-copy'); calls.push('cancel-multi-copy');
+2 -2
View File
@@ -13,7 +13,7 @@ export interface OverlayShortcutHandlers {
openCharacterDictionaryManager: () => void; openCharacterDictionaryManager: () => void;
openRuntimeOptions: () => void; openRuntimeOptions: () => void;
openJimaku: () => void; openJimaku: () => void;
openAnimetosho: () => void; openTsukihime: () => void;
} }
export interface OverlayShortcutLifecycleDeps { export interface OverlayShortcutLifecycleDeps {
@@ -36,7 +36,7 @@ const OVERLAY_SHORTCUT_KEYS: Array<keyof Omit<ConfiguredShortcuts, 'multiCopyTim
'openCharacterDictionaryManager', 'openCharacterDictionaryManager',
'openRuntimeOptions', 'openRuntimeOptions',
'openJimaku', 'openJimaku',
'openAnimetosho', 'openTsukihime',
]; ];
function hasConfiguredOverlayShortcuts(shortcuts: ConfiguredShortcuts): boolean { function hasConfiguredOverlayShortcuts(shortcuts: ConfiguredShortcuts): boolean {
+1 -1
View File
@@ -40,7 +40,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
openControllerSelect: () => calls.push('controller-select'), openControllerSelect: () => calls.push('controller-select'),
openControllerDebug: () => calls.push('controller-debug'), openControllerDebug: () => calls.push('controller-debug'),
openJimaku: () => calls.push('jimaku'), openJimaku: () => calls.push('jimaku'),
openAnimetosho: () => calls.push('animetosho'), openTsukihime: () => calls.push('tsukihime'),
openYoutubeTrackPicker: () => { openYoutubeTrackPicker: () => {
calls.push('youtube'); calls.push('youtube');
}, },
+3 -3
View File
@@ -24,7 +24,7 @@ export interface SessionActionExecutorDeps {
openControllerSelect: () => void; openControllerSelect: () => void;
openControllerDebug: () => void; openControllerDebug: () => void;
openJimaku: () => void; openJimaku: () => void;
openAnimetosho: () => void; openTsukihime: () => void;
openYoutubeTrackPicker: () => void | Promise<void>; openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>; openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
replayCurrentSubtitle: () => void; replayCurrentSubtitle: () => void;
@@ -116,8 +116,8 @@ export async function dispatchSessionAction(
case 'openJimaku': case 'openJimaku':
deps.openJimaku(); deps.openJimaku();
return; return;
case 'openAnimetosho': case 'openTsukihime':
deps.openAnimetosho(); deps.openTsukihime();
return; return;
case 'openYoutubePicker': case 'openYoutubePicker':
await deps.openYoutubeTrackPicker(); await deps.openYoutubeTrackPicker();
+16 -1
View File
@@ -22,7 +22,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openCharacterDictionaryManager: null, openCharacterDictionaryManager: null,
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openAnimetosho: null, openTsukihime: null,
openSessionHelp: null, openSessionHelp: null,
openControllerSelect: null, openControllerSelect: null,
openControllerDebug: null, openControllerDebug: null,
@@ -320,6 +320,21 @@ test('compileSessionBindings wires every default keybinding to an overlay or mpv
} }
}); });
test('compileSessionBindings maps the legacy Animetosho command to the TsukiHime action', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: [createKeybinding('Ctrl+Alt+T', ['__animetosho-open'])],
platform: 'linux',
});
assert.deepEqual(result.warnings, []);
assert.equal(result.bindings[0]?.actionType, 'session-action');
assert.equal(
result.bindings[0]?.actionType === 'session-action' ? result.bindings[0].actionId : null,
'openTsukihime',
);
});
test('compileSessionBindings leaves retired subtitle-delay shift tokens as mpv commands', () => { test('compileSessionBindings leaves retired subtitle-delay shift tokens as mpv commands', () => {
const result = compileSessionBindings({ const result = compileSessionBindings({
shortcuts: createShortcuts(), shortcuts: createShortcuts(),
+3 -3
View File
@@ -55,7 +55,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
{ key: 'openCharacterDictionaryManager', actionId: 'openCharacterDictionaryManager' }, { key: 'openCharacterDictionaryManager', actionId: 'openCharacterDictionaryManager' },
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' }, { key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
{ key: 'openJimaku', actionId: 'openJimaku' }, { key: 'openJimaku', actionId: 'openJimaku' },
{ key: 'openAnimetosho', actionId: 'openAnimetosho' }, { key: 'openTsukihime', actionId: 'openTsukihime' },
{ key: 'openSessionHelp', actionId: 'openSessionHelp' }, { key: 'openSessionHelp', actionId: 'openSessionHelp' },
{ key: 'openControllerSelect', actionId: 'openControllerSelect' }, { key: 'openControllerSelect', actionId: 'openControllerSelect' },
{ key: 'openControllerDebug', actionId: 'openControllerDebug' }, { key: 'openControllerDebug', actionId: 'openControllerDebug' },
@@ -305,9 +305,9 @@ function resolveCommandBinding(
if (command.length !== 1) return null; if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'openJimaku' }; return { actionType: 'session-action', actionId: 'openJimaku' };
} }
if (first === SPECIAL_COMMANDS.ANIMETOSHO_OPEN) { if (first === SPECIAL_COMMANDS.TSUKIHIME_OPEN || first === SPECIAL_COMMANDS.ANIMETOSHO_OPEN) {
if (command.length !== 1) return null; if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'openAnimetosho' }; return { actionType: 'session-action', actionId: 'openTsukihime' };
} }
if (first === SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN) { if (first === SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN) {
if (command.length !== 1) return null; if (command.length !== 1) return null;
+1 -1
View File
@@ -40,7 +40,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openControllerSelect: false, openControllerSelect: false,
openControllerDebug: false, openControllerDebug: false,
openJimaku: false, openJimaku: false,
openAnimetosho: false, openTsukihime: false,
openYoutubePicker: false, openYoutubePicker: false,
openPlaylistBrowser: false, openPlaylistBrowser: false,
replayCurrentSubtitle: false, replayCurrentSubtitle: false,
+2 -2
View File
@@ -15,7 +15,7 @@ export interface ConfiguredShortcuts {
openCharacterDictionaryManager: string | null | undefined; openCharacterDictionaryManager: string | null | undefined;
openRuntimeOptions: string | null | undefined; openRuntimeOptions: string | null | undefined;
openJimaku: string | null | undefined; openJimaku: string | null | undefined;
openAnimetosho: string | null | undefined; openTsukihime: string | null | undefined;
openSessionHelp: string | null | undefined; openSessionHelp: string | null | undefined;
openControllerSelect: string | null | undefined; openControllerSelect: string | null | undefined;
openControllerDebug: string | null | undefined; openControllerDebug: string | null | undefined;
@@ -66,7 +66,7 @@ export function resolveConfiguredShortcuts(
), ),
openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')), openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')),
openJimaku: normalizeShortcut(shortcutValue('openJimaku')), openJimaku: normalizeShortcut(shortcutValue('openJimaku')),
openAnimetosho: normalizeShortcut(shortcutValue('openAnimetosho')), openTsukihime: normalizeShortcut(shortcutValue('openTsukihime')),
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')), openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')), openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
openControllerDebug: normalizeShortcut(shortcutValue('openControllerDebug')), openControllerDebug: normalizeShortcut(shortcutValue('openControllerDebug')),
+9 -9
View File
@@ -467,7 +467,7 @@ import { createOverlayModalInputState } from './main/runtime/overlay-modal-input
import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open'; import { openYoutubeTrackPicker } from './main/runtime/youtube-picker-open';
import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open'; import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './main/runtime/runtime-options-open';
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open'; import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
import { openAnimetoshoModal as openAnimetoshoModalRuntime } from './main/runtime/animetosho-open'; import { openTsukihimeModal as openTsukihimeModalRuntime } from './main/runtime/tsukihime-open';
import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/runtime/subsync-open'; import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/runtime/subsync-open';
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open'; import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
import { openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime } from './main/runtime/character-dictionary-open'; import { openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime } from './main/runtime/character-dictionary-open';
@@ -2068,8 +2068,8 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
openJimaku: () => { openJimaku: () => {
openJimakuOverlay(); openJimakuOverlay();
}, },
openAnimetosho: () => { openTsukihime: () => {
openAnimetoshoOverlay(); openTsukihimeOverlay();
}, },
markAudioCard: () => markLastCardAsAudioCard(), markAudioCard: () => markLastCardAsAudioCard(),
copySubtitleMultiple: (timeoutMs: number) => { copySubtitleMultiple: (timeoutMs: number) => {
@@ -3001,11 +3001,11 @@ function openJimakuOverlay(): void {
); );
} }
function openAnimetoshoOverlay(): void { function openTsukihimeOverlay(): void {
openOverlayHostedModalWithOsd( openOverlayHostedModalWithOsd(
openAnimetoshoModalRuntime, openTsukihimeModalRuntime,
'Animetosho overlay unavailable.', 'TsukiHime overlay unavailable.',
'Failed to open Animetosho overlay.', 'Failed to open TsukiHime overlay.',
); );
} }
@@ -5528,7 +5528,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
}, },
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJimaku: () => openJimakuOverlay(), openJimaku: () => openJimakuOverlay(),
openAnimetosho: () => openAnimetoshoOverlay(), openTsukihime: () => openTsukihimeOverlay(),
openSessionHelp: () => openSessionHelpOverlay(), openSessionHelp: () => openSessionHelpOverlay(),
openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(), openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(),
openControllerSelect: () => openControllerSelectOverlay(), openControllerSelect: () => openControllerSelectOverlay(),
@@ -5562,7 +5562,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJimaku: () => openJimakuOverlay(), openJimaku: () => openJimakuOverlay(),
openAnimetosho: () => openAnimetoshoOverlay(), openTsukihime: () => openTsukihimeOverlay(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
openPlaylistBrowser: () => openPlaylistBrowser(), openPlaylistBrowser: () => openPlaylistBrowser(),
cycleRuntimeOption: (id, direction) => { cycleRuntimeOption: (id, direction) => {
+2 -2
View File
@@ -229,7 +229,7 @@ export interface MpvCommandRuntimeServiceDepsParams {
triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig']; triggerSubsyncFromConfig: HandleMpvCommandFromIpcOptions['triggerSubsyncFromConfig'];
openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette']; openRuntimeOptionsPalette: HandleMpvCommandFromIpcOptions['openRuntimeOptionsPalette'];
openJimaku: HandleMpvCommandFromIpcOptions['openJimaku']; openJimaku: HandleMpvCommandFromIpcOptions['openJimaku'];
openAnimetosho: HandleMpvCommandFromIpcOptions['openAnimetosho']; openTsukihime: HandleMpvCommandFromIpcOptions['openTsukihime'];
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker']; openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser']; openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd']; showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
@@ -437,7 +437,7 @@ export function createMpvCommandRuntimeServiceDeps(
triggerSubsyncFromConfig: params.triggerSubsyncFromConfig, triggerSubsyncFromConfig: params.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: params.openRuntimeOptionsPalette, openRuntimeOptionsPalette: params.openRuntimeOptionsPalette,
openJimaku: params.openJimaku, openJimaku: params.openJimaku,
openAnimetosho: params.openAnimetosho, openTsukihime: params.openTsukihime,
openYoutubeTrackPicker: params.openYoutubeTrackPicker, openYoutubeTrackPicker: params.openYoutubeTrackPicker,
openPlaylistBrowser: params.openPlaylistBrowser, openPlaylistBrowser: params.openPlaylistBrowser,
runtimeOptionsCycle: params.runtimeOptionsCycle, runtimeOptionsCycle: params.runtimeOptionsCycle,
+2 -2
View File
@@ -13,7 +13,7 @@ export interface MpvCommandFromIpcRuntimeDeps {
triggerSubsyncFromConfig: () => void; triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
openJimaku: () => void; openJimaku: () => void;
openAnimetosho: () => void; openTsukihime: () => void;
openYoutubeTrackPicker: () => void | Promise<void>; openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => void | Promise<void>; openPlaylistBrowser: () => void | Promise<void>;
cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult; cycleRuntimeOption: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
@@ -39,7 +39,7 @@ export function handleMpvCommandFromIpcRuntime(
triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig, triggerSubsyncFromConfig: deps.triggerSubsyncFromConfig,
openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette, openRuntimeOptionsPalette: deps.openRuntimeOptionsPalette,
openJimaku: deps.openJimaku, openJimaku: deps.openJimaku,
openAnimetosho: deps.openAnimetosho, openTsukihime: deps.openTsukihime,
openYoutubeTrackPicker: deps.openYoutubeTrackPicker, openYoutubeTrackPicker: deps.openYoutubeTrackPicker,
openPlaylistBrowser: deps.openPlaylistBrowser, openPlaylistBrowser: deps.openPlaylistBrowser,
runtimeOptionsCycle: deps.cycleRuntimeOption, runtimeOptionsCycle: deps.cycleRuntimeOption,
+5 -5
View File
@@ -49,7 +49,7 @@ export interface OverlayModalRuntime {
) => boolean; ) => boolean;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
openJimaku: () => void; openJimaku: () => void;
openAnimetosho: () => void; openTsukihime: () => void;
handleOverlayModalClosed: (modal: OverlayHostedModal) => void; handleOverlayModalClosed: (modal: OverlayHostedModal) => void;
notifyOverlayModalOpened: (modal: OverlayHostedModal) => void; notifyOverlayModalOpened: (modal: OverlayHostedModal) => void;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>; waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
@@ -433,9 +433,9 @@ export function createOverlayModalRuntimeService(
}); });
}; };
const openAnimetosho = (): void => { const openTsukihime = (): void => {
sendToActiveOverlayWindow('animetosho:open', undefined, { sendToActiveOverlayWindow('tsukihime:open', undefined, {
restoreOnModalClose: 'animetosho', restoreOnModalClose: 'tsukihime',
}); });
}; };
@@ -518,7 +518,7 @@ export function createOverlayModalRuntimeService(
sendToActiveOverlayWindow, sendToActiveOverlayWindow,
openRuntimeOptionsPalette, openRuntimeOptionsPalette,
openJimaku, openJimaku,
openAnimetosho, openTsukihime,
handleOverlayModalClosed, handleOverlayModalClosed,
notifyOverlayModalOpened, notifyOverlayModalOpened,
waitForModalOpen, waitForModalOpen,
+3 -3
View File
@@ -21,7 +21,7 @@ export interface OverlayShortcutRuntimeServiceInput {
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
openCharacterDictionaryManager: () => void; openCharacterDictionaryManager: () => void;
openJimaku: () => void; openJimaku: () => void;
openAnimetosho: () => void; openTsukihime: () => void;
markAudioCard: () => Promise<void>; markAudioCard: () => Promise<void>;
copySubtitleMultiple: (timeoutMs: number) => void; copySubtitleMultiple: (timeoutMs: number) => void;
copySubtitle: () => void; copySubtitle: () => void;
@@ -57,8 +57,8 @@ export function createOverlayShortcutsRuntimeService(
openJimaku: () => { openJimaku: () => {
input.openJimaku(); input.openJimaku();
}, },
openAnimetosho: () => { openTsukihime: () => {
input.openAnimetosho(); input.openTsukihime();
}, },
markAudioCard: () => { markAudioCard: () => {
return input.markAudioCard(); return input.markAudioCard();
@@ -11,7 +11,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
triggerSubsyncFromConfig: async () => {}, triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {}, openRuntimeOptionsPalette: () => {},
openJimaku: () => {}, openJimaku: () => {},
openAnimetosho: () => {}, openTsukihime: () => {},
openYoutubeTrackPicker: () => {}, openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {}, openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: true }), cycleRuntimeOption: () => ({ ok: true }),
@@ -55,7 +55,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openControllerSelect: false, openControllerSelect: false,
openControllerDebug: false, openControllerDebug: false,
openJimaku: false, openJimaku: false,
openAnimetosho: false, openTsukihime: false,
openYoutubePicker: false, openYoutubePicker: false,
openPlaylistBrowser: false, openPlaylistBrowser: false,
replayCurrentSubtitle: false, replayCurrentSubtitle: false,
+1 -1
View File
@@ -97,7 +97,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
args.openControllerSelect || args.openControllerSelect ||
args.openControllerDebug || args.openControllerDebug ||
args.openJimaku || args.openJimaku ||
args.openAnimetosho || args.openTsukihime ||
args.openYoutubePicker || args.openYoutubePicker ||
args.openPlaylistBrowser || args.openPlaylistBrowser ||
args.replayCurrentSubtitle || args.replayCurrentSubtitle ||
@@ -19,7 +19,7 @@ function createShortcuts(): ConfiguredShortcuts {
openCharacterDictionaryManager: null, openCharacterDictionaryManager: null,
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openAnimetosho: null, openTsukihime: null,
openSessionHelp: null, openSessionHelp: null,
openControllerSelect: null, openControllerSelect: null,
openControllerDebug: null, openControllerDebug: null,
+1 -1
View File
@@ -23,7 +23,7 @@ function createShortcuts(): ConfiguredShortcuts {
openCharacterDictionaryManager: null, openCharacterDictionaryManager: null,
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openAnimetosho: null, openTsukihime: null,
openSessionHelp: null, openSessionHelp: null,
openControllerSelect: null, openControllerSelect: null,
openControllerDebug: null, openControllerDebug: null,
@@ -14,7 +14,7 @@ test('ipc bridge action main deps builders map callbacks', async () => {
triggerSubsyncFromConfig: async () => {}, triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {}, openRuntimeOptionsPalette: () => {},
openJimaku: () => {}, openJimaku: () => {},
openAnimetosho: () => {}, openTsukihime: () => {},
openYoutubeTrackPicker: () => {}, openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {}, openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
+1 -1
View File
@@ -11,7 +11,7 @@ test('handle mpv command handler forwards command and built deps', () => {
triggerSubsyncFromConfig: () => {}, triggerSubsyncFromConfig: () => {},
openRuntimeOptionsPalette: () => {}, openRuntimeOptionsPalette: () => {},
openJimaku: () => {}, openJimaku: () => {},
openAnimetosho: () => {}, openTsukihime: () => {},
openYoutubeTrackPicker: () => {}, openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {}, openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
@@ -8,7 +8,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
triggerSubsyncFromConfig: () => calls.push('subsync'), triggerSubsyncFromConfig: () => calls.push('subsync'),
openRuntimeOptionsPalette: () => calls.push('palette'), openRuntimeOptionsPalette: () => calls.push('palette'),
openJimaku: () => calls.push('jimaku'), openJimaku: () => calls.push('jimaku'),
openAnimetosho: () => calls.push('animetosho'), openTsukihime: () => calls.push('tsukihime'),
openYoutubeTrackPicker: () => { openYoutubeTrackPicker: () => {
calls.push('youtube-picker'); calls.push('youtube-picker');
}, },
@@ -30,7 +30,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
deps.triggerSubsyncFromConfig(); deps.triggerSubsyncFromConfig();
deps.openRuntimeOptionsPalette(); deps.openRuntimeOptionsPalette();
deps.openJimaku(); deps.openJimaku();
deps.openAnimetosho(); deps.openTsukihime();
void deps.openYoutubeTrackPicker(); void deps.openYoutubeTrackPicker();
void deps.openPlaylistBrowser(); void deps.openPlaylistBrowser();
assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' }); assert.deepEqual(deps.cycleRuntimeOption('anki.nPlusOneMatchMode', 1), { ok: false, error: 'x' });
@@ -47,7 +47,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
'subsync', 'subsync',
'palette', 'palette',
'jimaku', 'jimaku',
'animetosho', 'tsukihime',
'youtube-picker', 'youtube-picker',
'playlist-browser', 'playlist-browser',
'osd:hello', 'osd:hello',
@@ -10,7 +10,7 @@ export function createBuildMpvCommandFromIpcRuntimeMainDepsHandler(
triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(), triggerSubsyncFromConfig: () => deps.triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
openJimaku: () => deps.openJimaku(), openJimaku: () => deps.openJimaku(),
openAnimetosho: () => deps.openAnimetosho(), openTsukihime: () => deps.openTsukihime(),
openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(), openYoutubeTrackPicker: () => deps.openYoutubeTrackPicker(),
openPlaylistBrowser: () => deps.openPlaylistBrowser(), openPlaylistBrowser: () => deps.openPlaylistBrowser(),
cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction), cycleRuntimeOption: (id, direction) => deps.cycleRuntimeOption(id, direction),
@@ -18,7 +18,7 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
openRuntimeOptionsPalette: () => calls.push('runtime-options'), openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openCharacterDictionaryManager: () => calls.push('character-dictionary-manager'), openCharacterDictionaryManager: () => calls.push('character-dictionary-manager'),
openJimaku: () => calls.push('jimaku'), openJimaku: () => calls.push('jimaku'),
openAnimetosho: () => calls.push('animetosho'), openTsukihime: () => calls.push('tsukihime'),
markAudioCard: async () => { markAudioCard: async () => {
calls.push('mark-audio'); calls.push('mark-audio');
}, },
@@ -13,7 +13,7 @@ export function createBuildOverlayShortcutsRuntimeMainDepsHandler(
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
openCharacterDictionaryManager: () => deps.openCharacterDictionaryManager(), openCharacterDictionaryManager: () => deps.openCharacterDictionaryManager(),
openJimaku: () => deps.openJimaku(), openJimaku: () => deps.openJimaku(),
openAnimetosho: () => deps.openAnimetosho(), openTsukihime: () => deps.openTsukihime(),
markAudioCard: () => deps.markAudioCard(), markAudioCard: () => deps.markAudioCard(),
copySubtitleMultiple: (timeoutMs: number) => deps.copySubtitleMultiple(timeoutMs), copySubtitleMultiple: (timeoutMs: number) => deps.copySubtitleMultiple(timeoutMs),
copySubtitle: () => deps.copySubtitle(), copySubtitle: () => deps.copySubtitle(),
@@ -2,10 +2,10 @@ import type { OverlayHostedModal } from '../../shared/ipc/contracts';
import { IPC_CHANNELS } from '../../shared/ipc/contracts'; import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open'; import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
const ANIMETOSHO_MODAL: OverlayHostedModal = 'animetosho'; const TSUKIHIME_MODAL: OverlayHostedModal = 'tsukihime';
const ANIMETOSHO_OPEN_TIMEOUT_MS = 1500; const TSUKIHIME_OPEN_TIMEOUT_MS = 1500;
export async function openAnimetoshoModal(deps: { export async function openTsukihimeModal(deps: {
ensureOverlayStartupPrereqs: () => void; ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void; ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: ( sendToActiveOverlayWindow: (
@@ -25,10 +25,10 @@ export async function openAnimetoshoModal(deps: {
logWarn: deps.logWarn, logWarn: deps.logWarn,
}, },
{ {
modal: ANIMETOSHO_MODAL, modal: TSUKIHIME_MODAL,
timeoutMs: ANIMETOSHO_OPEN_TIMEOUT_MS, timeoutMs: TSUKIHIME_OPEN_TIMEOUT_MS,
retryWarning: retryWarning:
'Animetosho modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', 'Tsukihime modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
sendOpen: () => sendOpen: () =>
openOverlayHostedModal( openOverlayHostedModal(
{ {
@@ -38,8 +38,8 @@ export async function openAnimetoshoModal(deps: {
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow, sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
}, },
{ {
channel: IPC_CHANNELS.event.animetoshoOpen, channel: IPC_CHANNELS.event.tsukihimeOpen,
modal: ANIMETOSHO_MODAL, modal: TSUKIHIME_MODAL,
preferModalWindow: true, preferModalWindow: true,
}, },
), ),
+21 -21
View File
@@ -34,13 +34,13 @@ import type {
JimakuFileEntry, JimakuFileEntry,
JimakuApiResponse, JimakuApiResponse,
JimakuDownloadResult, JimakuDownloadResult,
AnimetoshoSearchQuery, TsukihimeSearchQuery,
AnimetoshoFilesQuery, TsukihimeFilesQuery,
AnimetoshoDownloadQuery, TsukihimeDownloadQuery,
AnimetoshoEntry, TsukihimeEntry,
AnimetoshoSubtitleFile, TsukihimeSubtitleFile,
AnimetoshoApiResponse, TsukihimeApiResponse,
AnimetoshoDownloadResult, TsukihimeDownloadResult,
SubsyncManualPayload, SubsyncManualPayload,
SubsyncManualRunRequest, SubsyncManualRunRequest,
SubsyncResult, SubsyncResult,
@@ -174,7 +174,7 @@ const onOpenControllerSelectEvent = createQueuedIpcListener(
); );
const onOpenControllerDebugEvent = createQueuedIpcListener(IPC_CHANNELS.event.controllerDebugOpen); const onOpenControllerDebugEvent = createQueuedIpcListener(IPC_CHANNELS.event.controllerDebugOpen);
const onOpenJimakuEvent = createQueuedIpcListener(IPC_CHANNELS.event.jimakuOpen); const onOpenJimakuEvent = createQueuedIpcListener(IPC_CHANNELS.event.jimakuOpen);
const onOpenAnimetoshoEvent = createQueuedIpcListener(IPC_CHANNELS.event.animetoshoOpen); const onOpenTsukihimeEvent = createQueuedIpcListener(IPC_CHANNELS.event.tsukihimeOpen);
const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload<YoutubePickerOpenPayload>( const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload<YoutubePickerOpenPayload>(
IPC_CHANNELS.event.youtubePickerOpen, IPC_CHANNELS.event.youtubePickerOpen,
(payload) => payload as YoutubePickerOpenPayload, (payload) => payload as YoutubePickerOpenPayload,
@@ -358,18 +358,18 @@ const electronAPI: ElectronAPI = {
jimakuDownloadFile: (query: JimakuDownloadQuery): Promise<JimakuDownloadResult> => jimakuDownloadFile: (query: JimakuDownloadQuery): Promise<JimakuDownloadResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.jimakuDownloadFile, query), ipcRenderer.invoke(IPC_CHANNELS.request.jimakuDownloadFile, query),
animetoshoSearchEntries: ( tsukihimeSearchEntries: (
query: AnimetoshoSearchQuery, query: TsukihimeSearchQuery,
): Promise<AnimetoshoApiResponse<AnimetoshoEntry[]>> => ): Promise<TsukihimeApiResponse<TsukihimeEntry[]>> =>
ipcRenderer.invoke(IPC_CHANNELS.request.animetoshoSearchEntries, query), ipcRenderer.invoke(IPC_CHANNELS.request.tsukihimeSearchEntries, query),
animetoshoListFiles: ( tsukihimeListFiles: (
query: AnimetoshoFilesQuery, query: TsukihimeFilesQuery,
): Promise<AnimetoshoApiResponse<AnimetoshoSubtitleFile[]>> => ): Promise<TsukihimeApiResponse<TsukihimeSubtitleFile[]>> =>
ipcRenderer.invoke(IPC_CHANNELS.request.animetoshoListFiles, query), ipcRenderer.invoke(IPC_CHANNELS.request.tsukihimeListFiles, query),
animetoshoDownloadFile: (query: AnimetoshoDownloadQuery): Promise<AnimetoshoDownloadResult> => tsukihimeDownloadFile: (query: TsukihimeDownloadQuery): Promise<TsukihimeDownloadResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.animetoshoDownloadFile, query), ipcRenderer.invoke(IPC_CHANNELS.request.tsukihimeDownloadFile, query),
animetoshoGetSecondaryLanguages: (): Promise<string[]> => tsukihimeGetSecondaryLanguages: (): Promise<string[]> =>
ipcRenderer.invoke(IPC_CHANNELS.request.animetoshoGetSecondaryLanguages), ipcRenderer.invoke(IPC_CHANNELS.request.tsukihimeGetSecondaryLanguages),
quitApp: () => { quitApp: () => {
ipcRenderer.send(IPC_CHANNELS.command.quitApp); ipcRenderer.send(IPC_CHANNELS.command.quitApp);
@@ -450,7 +450,7 @@ const electronAPI: ElectronAPI = {
onOpenControllerSelect: onOpenControllerSelectEvent, onOpenControllerSelect: onOpenControllerSelectEvent,
onOpenControllerDebug: onOpenControllerDebugEvent, onOpenControllerDebug: onOpenControllerDebugEvent,
onOpenJimaku: onOpenJimakuEvent, onOpenJimaku: onOpenJimakuEvent,
onOpenAnimetosho: onOpenAnimetoshoEvent, onOpenTsukihime: onOpenTsukihimeEvent,
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent, onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent, onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
onOpenCharacterDictionaryManager: onOpenCharacterDictionaryManagerEvent, onOpenCharacterDictionaryManager: onOpenCharacterDictionaryManagerEvent,
+2 -2
View File
@@ -90,7 +90,7 @@ function createEmptyShortcuts(): ConfiguredShortcuts {
openCharacterDictionaryManager: null, openCharacterDictionaryManager: null,
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openAnimetosho: null, openTsukihime: null,
openSessionHelp: null, openSessionHelp: null,
openControllerSelect: null, openControllerSelect: null,
openControllerDebug: null, openControllerDebug: null,
@@ -492,7 +492,7 @@ function createKeyboardHandlerHarness() {
handleSubsyncKeydown: () => false, handleSubsyncKeydown: () => false,
handleKikuKeydown: () => false, handleKikuKeydown: () => false,
handleJimakuKeydown: () => false, handleJimakuKeydown: () => false,
handleAnimetoshoKeydown: () => false, handleTsukihimeKeydown: () => false,
handleControllerSelectKeydown: () => { handleControllerSelectKeydown: () => {
controllerSelectKeydownCount += 1; controllerSelectKeydownCount += 1;
return true; return true;
+3 -3
View File
@@ -16,7 +16,7 @@ export function createKeyboardHandlers(
handleSubsyncKeydown: (e: KeyboardEvent) => boolean; handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
handleKikuKeydown: (e: KeyboardEvent) => boolean; handleKikuKeydown: (e: KeyboardEvent) => boolean;
handleJimakuKeydown: (e: KeyboardEvent) => boolean; handleJimakuKeydown: (e: KeyboardEvent) => boolean;
handleAnimetoshoKeydown: (e: KeyboardEvent) => boolean; handleTsukihimeKeydown: (e: KeyboardEvent) => boolean;
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean; handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean; handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean; handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
@@ -1120,8 +1120,8 @@ export function createKeyboardHandlers(
options.handleJimakuKeydown(e); options.handleJimakuKeydown(e);
return; return;
} }
if (ctx.state.animetoshoModalOpen) { if (ctx.state.tsukihimeModalOpen) {
options.handleAnimetoshoKeydown(e); options.handleTsukihimeKeydown(e);
return; return;
} }
if (ctx.state.youtubePickerModalOpen) { if (ctx.state.youtubePickerModalOpen) {
+26 -14
View File
@@ -116,40 +116,52 @@
</div> </div>
</div> </div>
</div> </div>
<div id="animetoshoModal" class="modal hidden" aria-hidden="true"> <div id="tsukihimeModal" class="modal hidden" aria-hidden="true">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<div class="modal-title">Animetosho Subtitles</div> <div class="modal-title">TsukiHime Subtitles</div>
<button id="animetoshoClose" class="modal-close" type="button">Close</button> <button id="tsukihimeClose" class="modal-close" type="button">Close</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="animetosho-tabs"> <div class="tsukihime-tabs" role="tablist">
<button id="animetoshoTabEnglish" class="animetosho-tab active" type="button"> <button
id="tsukihimeTabSecondary"
class="tsukihime-tab active"
type="button"
role="tab"
aria-selected="true"
>
English English
</button> </button>
<button id="animetoshoTabJapanese" class="animetosho-tab" type="button"> <button
id="tsukihimeTabPrimary"
class="tsukihime-tab"
type="button"
role="tab"
aria-selected="false"
>
Japanese Japanese
</button> </button>
</div> </div>
<div class="jimaku-form"> <div class="jimaku-form">
<label class="jimaku-field"> <label class="jimaku-field">
<span>Title</span> <span>Title</span>
<input id="animetoshoTitle" type="text" placeholder="Anime title" /> <input id="tsukihimeTitle" type="text" placeholder="Anime title" />
</label> </label>
<label class="jimaku-field"> <label class="jimaku-field">
<span>Episode</span> <span>Episode</span>
<input id="animetoshoEpisode" type="number" min="1" placeholder="1" /> <input id="tsukihimeEpisode" type="number" min="1" placeholder="1" />
</label> </label>
<button id="animetoshoSearch" class="jimaku-button" type="button">Search</button> <button id="tsukihimeSearch" class="jimaku-button" type="button">Search</button>
</div> </div>
<div id="animetoshoStatus" class="jimaku-status"></div> <div id="tsukihimeStatus" class="jimaku-status"></div>
<div id="animetoshoEntriesSection" class="jimaku-section hidden"> <div id="tsukihimeEntriesSection" class="jimaku-section hidden">
<div class="jimaku-section-title">Releases</div> <div class="jimaku-section-title">Releases</div>
<ul id="animetoshoEntries" class="jimaku-list"></ul> <ul id="tsukihimeEntries" class="jimaku-list"></ul>
</div> </div>
<div id="animetoshoFilesSection" class="jimaku-section hidden"> <div id="tsukihimeFilesSection" class="jimaku-section hidden">
<div class="jimaku-section-title">Subtitle tracks</div> <div class="jimaku-section-title">Subtitle tracks</div>
<ul id="animetoshoFiles" class="jimaku-list"></ul> <ul id="tsukihimeFiles" class="jimaku-list"></ul>
</div> </div>
</div> </div>
</div> </div>
-388
View File
@@ -1,388 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AnimetoshoSubtitleFile, ElectronAPI } from '../../types';
import { createRendererState } from '../state.js';
import { createAnimetoshoModal } from './animetosho.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) {
tokens.add(entry);
}
},
remove: (...entries: string[]) => {
for (const entry of entries) {
tokens.delete(entry);
}
},
contains: (entry: string) => tokens.has(entry),
};
}
function createElementStub() {
const classList = createClassList();
return {
textContent: '',
className: '',
style: {},
classList,
children: [] as unknown[],
appendChild(child: unknown) {
this.children.push(child);
},
addEventListener: () => {},
};
}
function createListStub() {
return {
innerHTML: '',
children: [] as unknown[],
appendChild(child: unknown) {
this.children.push(child);
},
};
}
function flushAsyncWork(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
const ENGLISH_TRACK: AnimetoshoSubtitleFile = {
attachmentId: 1955356,
filename: 'episode01.eng.ass',
lang: 'eng',
trackName: 'English subs',
size: 33075,
url: 'https://animetosho.org/storage/attach/001dd61c/1955356.xz',
sourceFilename: 'episode01.mkv',
};
const JAPANESE_TRACK: AnimetoshoSubtitleFile = {
attachmentId: 1955400,
filename: 'episode01.jpn.ass',
lang: 'jpn',
trackName: 'Japanese subs',
size: 41000,
url: 'https://animetosho.org/storage/attach/001dd648/1955400.xz',
sourceFilename: 'episode01.mkv',
};
const GERMAN_TRACK: AnimetoshoSubtitleFile = {
attachmentId: 1955500,
filename: 'episode01.ger.ass',
lang: 'ger',
trackName: 'Deutsch',
size: 28000,
url: 'https://animetosho.org/storage/attach/001dd6ac/1955500.xz',
sourceFilename: 'episode01.mkv',
};
interface ModalHarness {
modal: ReturnType<typeof createAnimetoshoModal>;
state: ReturnType<typeof createRendererState>;
downloadQueries: unknown[];
modalCloseNotifications: string[];
overlayClassList: ReturnType<typeof createClassList>;
animetoshoModalClassList: ReturnType<typeof createClassList>;
restoreGlobals: () => void;
}
function createModalHarness(
files: AnimetoshoSubtitleFile[],
options: {
secondaryLanguages?: string[];
listFiles?: (entryId: number) => Promise<unknown>;
} = {},
): ModalHarness {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, 'window');
const hadDocument = Object.prototype.hasOwnProperty.call(globalThis, 'document');
const previousWindow = globals.window;
const previousDocument = globals.document;
const modalCloseNotifications: string[] = [];
const downloadQueries: unknown[] = [];
const electronAPI = {
animetoshoDownloadFile: async (query: unknown) => {
downloadQueries.push(query);
return { ok: true, path: '/tmp/subtitles/episode01.en.ass' };
},
animetoshoGetSecondaryLanguages: async () => options.secondaryLanguages ?? ['en', 'eng'],
animetoshoListFiles: async ({ entryId }: { entryId: number }) =>
options.listFiles ? options.listFiles(entryId) : { ok: true, data: [] },
getJimakuMediaInfo: async () => ({
title: '',
season: null,
episode: null,
confidence: 'low',
filename: '',
rawTitle: '',
}),
notifyOverlayModalClosed: (modal: string) => {
modalCloseNotifications.push(modal);
},
} as unknown as ElectronAPI;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { electronAPI },
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
activeElement: null,
createElement: () => createElementStub(),
},
});
const overlayClassList = createClassList(['interactive']);
const animetoshoModalClassList = createClassList();
const state = createRendererState();
state.animetoshoModalOpen = true;
state.currentAnimetoshoEntryId = 606713;
state.selectedAnimetoshoFileIndex = 0;
state.animetoshoFiles = files;
const ctx = {
dom: {
overlay: { classList: overlayClassList },
animetoshoModal: {
classList: animetoshoModalClassList,
setAttribute: () => {},
},
animetoshoTitleInput: { value: '' },
animetoshoEpisodeInput: { value: '' },
animetoshoSearchButton: { addEventListener: () => {} },
animetoshoCloseButton: { addEventListener: () => {} },
animetoshoTabEnglishButton: {
textContent: 'English',
classList: createClassList(['active']),
addEventListener: () => {},
},
animetoshoTabJapaneseButton: {
textContent: 'Japanese',
classList: createClassList(),
addEventListener: () => {},
},
animetoshoStatus: { textContent: '', style: { color: '' } },
animetoshoEntriesSection: { classList: createClassList(['hidden']) },
animetoshoEntriesList: createListStub(),
animetoshoFilesSection: { classList: createClassList() },
animetoshoFilesList: createListStub(),
},
state,
};
const modal = createAnimetoshoModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
return {
modal,
state,
downloadQueries,
modalCloseNotifications,
overlayClassList,
animetoshoModalClassList,
restoreGlobals: () => {
const target = globalThis as unknown as Record<string, unknown>;
if (hadWindow) {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
} else {
delete target.window;
}
if (hadDocument) {
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: previousDocument,
});
} else {
delete target.document;
}
},
};
}
function pressKey(harness: ModalHarness, key: string): boolean {
let prevented = false;
harness.modal.handleAnimetoshoKeydown({
key,
preventDefault: () => {
prevented = true;
},
} as KeyboardEvent);
return prevented;
}
test('successful Animetosho subtitle selection closes modal', async () => {
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
try {
const prevented = pressKey(harness, 'Enter');
await flushAsyncWork();
assert.equal(prevented, true);
assert.equal(harness.state.animetoshoModalOpen, false);
assert.equal(harness.animetoshoModalClassList.contains('hidden'), true);
assert.equal(harness.overlayClassList.contains('interactive'), false);
assert.deepEqual(harness.modalCloseNotifications, ['animetosho']);
assert.deepEqual(harness.downloadQueries, [
{
entryId: 606713,
url: ENGLISH_TRACK.url,
name: ENGLISH_TRACK.filename,
lang: 'eng',
},
]);
} finally {
harness.restoreGlobals();
}
});
test('English tab hides non-English languages, not just Japanese', async () => {
const harness = createModalHarness([GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK]);
try {
// With German visible this would move selection onto it; English-only
// filtering must clamp to the single English track instead.
pressKey(harness, 'ArrowDown');
pressKey(harness, 'Enter');
await flushAsyncWork();
assert.deepEqual(harness.downloadQueries, [
{
entryId: 606713,
url: ENGLISH_TRACK.url,
name: ENGLISH_TRACK.filename,
lang: 'eng',
},
]);
} finally {
harness.restoreGlobals();
}
});
test('Japanese tab filters tracks so Enter downloads the Japanese one', async () => {
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
try {
assert.equal(harness.state.animetoshoActiveTab, 'en');
pressKey(harness, 'ArrowRight');
assert.equal(harness.state.animetoshoActiveTab, 'ja');
pressKey(harness, 'Enter');
await flushAsyncWork();
assert.deepEqual(harness.downloadQueries, [
{
entryId: 606713,
url: JAPANESE_TRACK.url,
name: JAPANESE_TRACK.filename,
lang: 'jpn',
},
]);
} finally {
harness.restoreGlobals();
}
});
test('secondary tab follows configured secondarySub languages', async () => {
const harness = createModalHarness([GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK], {
secondaryLanguages: ['de'],
});
try {
// Re-open through the API so the modal fetches the configured languages.
harness.state.animetoshoModalOpen = false;
harness.modal.openAnimetoshoModal();
await flushAsyncWork();
harness.state.animetoshoFiles = [GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK];
harness.state.currentAnimetoshoEntryId = 606713;
pressKey(harness, 'Enter');
await flushAsyncWork();
assert.deepEqual(harness.downloadQueries, [
{
entryId: 606713,
url: GERMAN_TRACK.url,
name: GERMAN_TRACK.filename,
lang: 'ger',
},
]);
} finally {
harness.restoreGlobals();
}
});
test('a slow release response does not overwrite the newly selected release', async () => {
const STALE_TRACK: AnimetoshoSubtitleFile = {
...ENGLISH_TRACK,
attachmentId: 999,
filename: 'stale.eng.ass',
};
const SECOND_ENGLISH_TRACK: AnimetoshoSubtitleFile = {
...ENGLISH_TRACK,
attachmentId: 1955357,
filename: 'episode01.eng.sdh.ass',
};
const resolvers: Array<(value: unknown) => void> = [];
const harness = createModalHarness([], {
listFiles: (entryId) =>
new Promise((resolve) => {
if (entryId === 1) {
// Entry 1 answers late, after the user has moved on to entry 2.
resolvers.push(() => resolve({ ok: true, data: [STALE_TRACK] }));
} else {
// Two tracks, so the modal does not auto-download a lone match.
resolve({ ok: true, data: [ENGLISH_TRACK, SECOND_ENGLISH_TRACK] });
}
}),
});
try {
harness.state.animetoshoEntries = [
{ id: 1, title: 'slow release', timestamp: null, totalSize: null, numFiles: 1 },
{ id: 2, title: 'fast release', timestamp: null, totalSize: null, numFiles: 1 },
];
harness.modal.selectAnimetoshoEntry(0);
harness.modal.selectAnimetoshoEntry(1);
await flushAsyncWork();
// Entry 2's tracks are on screen; now entry 1 finally answers.
assert.deepEqual(
harness.state.animetoshoFiles.map((file) => file.attachmentId),
[ENGLISH_TRACK.attachmentId, SECOND_ENGLISH_TRACK.attachmentId],
);
resolvers.forEach((resolve) => resolve(undefined));
await flushAsyncWork();
assert.equal(harness.state.currentAnimetoshoEntryId, 2);
assert.deepEqual(
harness.state.animetoshoFiles.map((file) => file.attachmentId),
[ENGLISH_TRACK.attachmentId, SECOND_ENGLISH_TRACK.attachmentId],
);
} finally {
harness.restoreGlobals();
}
});
test('ArrowLeft switches back to the English tab', () => {
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
try {
pressKey(harness, 'ArrowRight');
assert.equal(harness.state.animetoshoActiveTab, 'ja');
pressKey(harness, 'ArrowLeft');
assert.equal(harness.state.animetoshoActiveTab, 'en');
} finally {
harness.restoreGlobals();
}
});
-487
View File
@@ -1,487 +0,0 @@
import type {
AnimetoshoApiResponse,
AnimetoshoDownloadResult,
AnimetoshoEntry,
AnimetoshoSubtitleFile,
JimakuMediaInfo,
} from '../../types';
import {
animetoshoTrackMatchesLanguages,
describeAnimetoshoTabLanguages,
normalizeAnimetoshoLangCode,
} from '../../animetosho/lang.js';
import type { ModalStateReader, RendererContext } from '../context';
export function createAnimetoshoModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
function setAnimetoshoStatus(message: string, isError = false): void {
ctx.dom.animetoshoStatus.textContent = message;
ctx.dom.animetoshoStatus.style.color = isError
? 'rgba(255, 120, 120, 0.95)'
: 'rgba(255, 255, 255, 0.8)';
}
function resetAnimetoshoLists(): void {
ctx.state.animetoshoEntries = [];
ctx.state.animetoshoFiles = [];
ctx.state.selectedAnimetoshoEntryIndex = 0;
ctx.state.selectedAnimetoshoFileIndex = 0;
ctx.state.currentAnimetoshoEntryId = null;
ctx.dom.animetoshoEntriesList.innerHTML = '';
ctx.dom.animetoshoFilesList.innerHTML = '';
ctx.dom.animetoshoEntriesSection.classList.add('hidden');
ctx.dom.animetoshoFilesSection.classList.add('hidden');
}
// Defaults to English until the configured secondarySub languages arrive.
let secondaryLanguages: string[] = ['en'];
function secondaryTabLabel(): string {
return describeAnimetoshoTabLanguages(secondaryLanguages);
}
function isJapaneseTrack(file: AnimetoshoSubtitleFile): boolean {
return normalizeAnimetoshoLangCode(file.lang) === 'ja';
}
function getVisibleFiles(): AnimetoshoSubtitleFile[] {
if (ctx.state.animetoshoActiveTab === 'ja') {
return ctx.state.animetoshoFiles.filter(isJapaneseTrack);
}
return ctx.state.animetoshoFiles.filter(
(file) =>
!isJapaneseTrack(file) && animetoshoTrackMatchesLanguages(file.lang, secondaryLanguages),
);
}
function renderTabs(): void {
if (ctx.state.animetoshoActiveTab === 'ja') {
ctx.dom.animetoshoTabEnglishButton.classList.remove('active');
ctx.dom.animetoshoTabJapaneseButton.classList.add('active');
} else {
ctx.dom.animetoshoTabEnglishButton.classList.add('active');
ctx.dom.animetoshoTabJapaneseButton.classList.remove('active');
}
}
function describeEmptyTab(): string {
const hiddenCount = ctx.state.animetoshoFiles.length;
if (ctx.state.animetoshoActiveTab === 'ja') {
return hiddenCount > 0
? `No Japanese tracks in this release. Switch to the ${secondaryTabLabel()} tab.`
: 'No Japanese tracks in this release.';
}
return hiddenCount > 0
? `No ${secondaryTabLabel()} tracks in this release. Switch to the Japanese tab.`
: `No ${secondaryTabLabel()} tracks in this release.`;
}
function setActiveTab(tab: 'en' | 'ja'): void {
if (ctx.state.animetoshoActiveTab === tab) return;
ctx.state.animetoshoActiveTab = tab;
ctx.state.selectedAnimetoshoFileIndex = 0;
renderTabs();
if (ctx.state.animetoshoFiles.length === 0) return;
renderFiles();
if (getVisibleFiles().length === 0) {
setAnimetoshoStatus(describeEmptyTab());
} else {
setAnimetoshoStatus('Select a subtitle track.');
}
}
function formatBytes(size: number): string {
if (!Number.isFinite(size)) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let value = size;
let idx = 0;
while (value >= 1024 && idx < units.length - 1) {
value /= 1024;
idx += 1;
}
return `${value.toFixed(value >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`;
}
function renderEntries(): void {
ctx.dom.animetoshoEntriesList.innerHTML = '';
if (ctx.state.animetoshoEntries.length === 0) {
ctx.dom.animetoshoEntriesSection.classList.add('hidden');
return;
}
ctx.dom.animetoshoEntriesSection.classList.remove('hidden');
ctx.state.animetoshoEntries.forEach((entry, index) => {
const li = document.createElement('li');
li.textContent = entry.title;
const details: string[] = [];
if (entry.totalSize !== null) details.push(formatBytes(entry.totalSize));
if (entry.numFiles !== null) {
details.push(`${entry.numFiles} file${entry.numFiles === 1 ? '' : 's'}`);
}
if (details.length > 0) {
const sub = document.createElement('div');
sub.className = 'jimaku-subtext';
sub.textContent = details.join(' • ');
li.appendChild(sub);
}
if (index === ctx.state.selectedAnimetoshoEntryIndex) {
li.classList.add('active');
}
li.addEventListener('click', () => {
selectEntry(index);
});
ctx.dom.animetoshoEntriesList.appendChild(li);
});
}
function renderFiles(): void {
ctx.dom.animetoshoFilesList.innerHTML = '';
const visibleFiles = getVisibleFiles();
if (visibleFiles.length === 0) {
ctx.dom.animetoshoFilesSection.classList.add('hidden');
return;
}
ctx.dom.animetoshoFilesSection.classList.remove('hidden');
visibleFiles.forEach((file, index) => {
const li = document.createElement('li');
li.textContent = file.filename;
const details: string[] = [];
if (file.lang) details.push(file.lang);
if (file.trackName) details.push(file.trackName);
details.push(formatBytes(file.size));
const sub = document.createElement('div');
sub.className = 'jimaku-subtext';
sub.textContent = details.filter(Boolean).join(' • ');
li.appendChild(sub);
if (index === ctx.state.selectedAnimetoshoFileIndex) {
li.classList.add('active');
}
li.addEventListener('click', () => {
void selectFile(index);
});
ctx.dom.animetoshoFilesList.appendChild(li);
});
}
function getSearchQuery(): string {
const title = ctx.dom.animetoshoTitleInput.value.trim();
if (!title) return '';
const episodeValue = ctx.dom.animetoshoEpisodeInput.value
? Number.parseInt(ctx.dom.animetoshoEpisodeInput.value, 10)
: null;
if (episodeValue !== null && Number.isFinite(episodeValue)) {
return `${title} ${String(episodeValue).padStart(2, '0')}`;
}
return title;
}
async function performAnimetoshoSearch(): Promise<void> {
const query = getSearchQuery();
if (!query) {
setAnimetoshoStatus('Enter a title before searching.', true);
return;
}
resetAnimetoshoLists();
setAnimetoshoStatus('Searching Animetosho...');
const response: AnimetoshoApiResponse<AnimetoshoEntry[]> =
await window.electronAPI.animetoshoSearchEntries({ query });
if (!response.ok) {
setAnimetoshoStatus(response.error.error, true);
return;
}
ctx.state.animetoshoEntries = response.data;
ctx.state.selectedAnimetoshoEntryIndex = 0;
if (ctx.state.animetoshoEntries.length === 0) {
setAnimetoshoStatus('No releases found.');
return;
}
setAnimetoshoStatus('Select a release.');
renderEntries();
if (ctx.state.animetoshoEntries.length === 1) {
selectEntry(0);
}
}
async function loadFiles(entryId: number): Promise<void> {
setAnimetoshoStatus('Loading subtitle tracks...');
ctx.state.animetoshoFiles = [];
ctx.state.selectedAnimetoshoFileIndex = 0;
ctx.dom.animetoshoFilesList.innerHTML = '';
ctx.dom.animetoshoFilesSection.classList.add('hidden');
const response: AnimetoshoApiResponse<AnimetoshoSubtitleFile[]> =
await window.electronAPI.animetoshoListFiles({ entryId });
// The user may have picked another release while this was in flight.
if (ctx.state.currentAnimetoshoEntryId !== entryId) return;
if (!response.ok) {
setAnimetoshoStatus(response.error.error, true);
return;
}
ctx.state.animetoshoFiles = response.data;
if (ctx.state.animetoshoFiles.length === 0) {
const entry = ctx.state.animetoshoEntries.find((candidate) => candidate.id === entryId);
// The feed API omits per-file attachment data for multi-file torrents.
if (entry && entry.numFiles !== null && entry.numFiles > 1) {
setAnimetoshoStatus(
'Batch releases are not supported. Pick a single-episode release instead.',
);
} else {
setAnimetoshoStatus('No text subtitle tracks in this release. Try another one.');
}
return;
}
const visibleFiles = getVisibleFiles();
if (visibleFiles.length === 0) {
setAnimetoshoStatus(describeEmptyTab());
return;
}
setAnimetoshoStatus('Select a subtitle track.');
renderFiles();
if (visibleFiles.length === 1) {
await selectFile(0);
}
}
function selectEntry(index: number): void {
if (index < 0 || index >= ctx.state.animetoshoEntries.length) return;
ctx.state.selectedAnimetoshoEntryIndex = index;
ctx.state.currentAnimetoshoEntryId = ctx.state.animetoshoEntries[index]!.id;
renderEntries();
if (ctx.state.currentAnimetoshoEntryId !== null) {
void loadFiles(ctx.state.currentAnimetoshoEntryId);
}
}
async function selectFile(index: number): Promise<void> {
const visibleFiles = getVisibleFiles();
if (index < 0 || index >= visibleFiles.length) return;
ctx.state.selectedAnimetoshoFileIndex = index;
renderFiles();
if (ctx.state.currentAnimetoshoEntryId === null) {
setAnimetoshoStatus('Select a release first.', true);
return;
}
const file = visibleFiles[index]!;
setAnimetoshoStatus('Downloading subtitle...');
const result: AnimetoshoDownloadResult = await window.electronAPI.animetoshoDownloadFile({
entryId: ctx.state.currentAnimetoshoEntryId,
url: file.url,
name: file.filename,
lang: file.lang,
});
if (result.ok) {
setAnimetoshoStatus(`Downloaded and loaded: ${result.path}`);
closeAnimetoshoModal();
return;
}
setAnimetoshoStatus(result.error.error, true);
}
function isTextInputFocused(): boolean {
const active = document.activeElement;
if (!active) return false;
const tag = active.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea';
}
async function loadSecondaryLanguages(): Promise<void> {
try {
const languages = await window.electronAPI.animetoshoGetSecondaryLanguages();
secondaryLanguages = languages.length > 0 ? languages : ['en'];
} catch {
secondaryLanguages = ['en'];
}
ctx.dom.animetoshoTabEnglishButton.textContent = secondaryTabLabel();
// Tracks may already be on screen if the languages arrived late.
if (ctx.state.animetoshoFiles.length > 0) {
renderFiles();
}
}
function openAnimetoshoModal(): void {
if (ctx.state.animetoshoModalOpen) return;
ctx.state.animetoshoModalOpen = true;
ctx.state.animetoshoActiveTab = 'en';
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
ctx.dom.animetoshoModal.classList.remove('hidden');
ctx.dom.animetoshoModal.setAttribute('aria-hidden', 'false');
setAnimetoshoStatus('Loading media info...');
resetAnimetoshoLists();
renderTabs();
const secondaryLanguagesReady = loadSecondaryLanguages();
window.electronAPI
.getJimakuMediaInfo()
.then(async (info: JimakuMediaInfo) => {
ctx.dom.animetoshoTitleInput.value = info.title || '';
ctx.dom.animetoshoEpisodeInput.value = info.episode ? String(info.episode) : '';
if (info.confidence === 'high' && info.title && info.episode) {
await secondaryLanguagesReady;
void performAnimetoshoSearch();
} else if (info.title) {
setAnimetoshoStatus('Check title/episode and press Search.');
} else {
setAnimetoshoStatus('Enter title/episode and press Search.');
}
})
.catch(() => {
setAnimetoshoStatus('Failed to load media info.', true);
});
}
function closeAnimetoshoModal(): void {
if (!ctx.state.animetoshoModalOpen) return;
ctx.state.animetoshoModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.animetoshoModal.classList.add('hidden');
ctx.dom.animetoshoModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('animetosho');
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
resetAnimetoshoLists();
}
function handleAnimetoshoKeydown(e: KeyboardEvent): boolean {
if (e.key === 'Escape') {
e.preventDefault();
closeAnimetoshoModal();
return true;
}
if (isTextInputFocused()) {
if (e.key === 'Enter') {
e.preventDefault();
void performAnimetoshoSearch();
}
return true;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
setActiveTab('en');
return true;
}
if (e.key === 'ArrowRight') {
e.preventDefault();
setActiveTab('ja');
return true;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
const visibleFiles = getVisibleFiles();
if (visibleFiles.length > 0) {
ctx.state.selectedAnimetoshoFileIndex = Math.min(
visibleFiles.length - 1,
ctx.state.selectedAnimetoshoFileIndex + 1,
);
renderFiles();
} else if (ctx.state.animetoshoEntries.length > 0) {
ctx.state.selectedAnimetoshoEntryIndex = Math.min(
ctx.state.animetoshoEntries.length - 1,
ctx.state.selectedAnimetoshoEntryIndex + 1,
);
renderEntries();
}
return true;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (getVisibleFiles().length > 0) {
ctx.state.selectedAnimetoshoFileIndex = Math.max(
0,
ctx.state.selectedAnimetoshoFileIndex - 1,
);
renderFiles();
} else if (ctx.state.animetoshoEntries.length > 0) {
ctx.state.selectedAnimetoshoEntryIndex = Math.max(
0,
ctx.state.selectedAnimetoshoEntryIndex - 1,
);
renderEntries();
}
return true;
}
if (e.key === 'Enter') {
e.preventDefault();
if (getVisibleFiles().length > 0) {
void selectFile(ctx.state.selectedAnimetoshoFileIndex);
} else if (ctx.state.animetoshoEntries.length > 0) {
selectEntry(ctx.state.selectedAnimetoshoEntryIndex);
} else {
void performAnimetoshoSearch();
}
return true;
}
return true;
}
function wireDomEvents(): void {
ctx.dom.animetoshoSearchButton.addEventListener('click', () => {
void performAnimetoshoSearch();
});
ctx.dom.animetoshoCloseButton.addEventListener('click', () => {
closeAnimetoshoModal();
});
ctx.dom.animetoshoTabEnglishButton.addEventListener('click', () => {
setActiveTab('en');
});
ctx.dom.animetoshoTabJapaneseButton.addEventListener('click', () => {
setActiveTab('ja');
});
}
return {
closeAnimetoshoModal,
handleAnimetoshoKeydown,
openAnimetoshoModal,
selectAnimetoshoEntry: selectEntry,
wireDomEvents,
};
}
+5 -5
View File
@@ -104,7 +104,7 @@ function describeCommand(command: (string | number)[]): string {
if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls'; if (first === SPECIAL_COMMANDS.SUBSYNC_TRIGGER) return 'Open subtitle sync controls';
if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options'; if (first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN) return 'Open runtime options';
if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku'; if (first === SPECIAL_COMMANDS.JIMAKU_OPEN) return 'Open jimaku';
if (first === SPECIAL_COMMANDS.ANIMETOSHO_OPEN) return 'Open animetosho'; if (first === SPECIAL_COMMANDS.TSUKIHIME_OPEN) return 'Open TsukiHime';
if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser'; if (first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN) return 'Open playlist browser';
if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle'; if (first === SPECIAL_COMMANDS.REPLAY_SUBTITLE) return 'Replay current subtitle';
if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle'; if (first === SPECIAL_COMMANDS.PLAY_NEXT_SUBTITLE) return 'Play next subtitle';
@@ -149,7 +149,7 @@ function sectionForCommand(command: (string | number)[]): string {
if ( if (
first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN || first === SPECIAL_COMMANDS.RUNTIME_OPTIONS_OPEN ||
first === SPECIAL_COMMANDS.JIMAKU_OPEN || first === SPECIAL_COMMANDS.JIMAKU_OPEN ||
first === SPECIAL_COMMANDS.ANIMETOSHO_OPEN || first === SPECIAL_COMMANDS.TSUKIHIME_OPEN ||
first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN || first === SPECIAL_COMMANDS.PLAYLIST_BROWSER_OPEN ||
first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX) first.startsWith(SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX)
) { ) {
@@ -223,8 +223,8 @@ function describeSessionAction(
return 'Open controller debug'; return 'Open controller debug';
case 'openJimaku': case 'openJimaku':
return 'Open jimaku'; return 'Open jimaku';
case 'openAnimetosho': case 'openTsukihime':
return 'Open animetosho'; return 'Open TsukiHime';
case 'openYoutubePicker': case 'openYoutubePicker':
return 'Open YouTube subtitle picker'; return 'Open YouTube subtitle picker';
case 'openPlaylistBrowser': case 'openPlaylistBrowser':
@@ -264,7 +264,7 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
return 'Subtitle sync'; return 'Subtitle sync';
case 'openRuntimeOptions': case 'openRuntimeOptions':
case 'openJimaku': case 'openJimaku':
case 'openAnimetosho': case 'openTsukihime':
case 'openCharacterDictionaryManager': case 'openCharacterDictionaryManager':
case 'openControllerSelect': case 'openControllerSelect':
case 'openControllerDebug': case 'openControllerDebug':
+615
View File
@@ -0,0 +1,615 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import type { TsukihimeSubtitleFile, ElectronAPI } from '../../types';
import { createRendererState } from '../state.js';
import { createTsukihimeModal } from './tsukihime.js';
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
add: (...entries: string[]) => {
for (const entry of entries) {
tokens.add(entry);
}
},
remove: (...entries: string[]) => {
for (const entry of entries) {
tokens.delete(entry);
}
},
contains: (entry: string) => tokens.has(entry),
};
}
function createElementStub() {
const classList = createClassList();
return {
textContent: '',
className: '',
style: {},
classList,
children: [] as unknown[],
appendChild(child: unknown) {
this.children.push(child);
},
addEventListener: () => {},
};
}
function createListStub() {
return {
innerHTML: '',
children: [] as unknown[],
appendChild(child: unknown) {
this.children.push(child);
},
};
}
function createTabStub(active: boolean) {
const attributes = new Map<string, string>();
return {
textContent: '',
classList: createClassList(active ? ['active'] : []),
attributes,
setAttribute(name: string, value: string) {
attributes.set(name, value);
},
addEventListener: () => {},
};
}
function flushAsyncWork(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
const ENGLISH_TRACK: TsukihimeSubtitleFile = {
attachmentId: 1955356,
filename: 'episode01.eng.ass',
lang: 'eng',
trackName: 'English subs',
size: 33075,
url: 'https://storage.tsukihime.org/attach/001dd61c/1955356.xz',
sourceFilename: 'episode01.mkv',
};
const JAPANESE_TRACK: TsukihimeSubtitleFile = {
attachmentId: 1955400,
filename: 'episode01.jpn.ass',
lang: 'jpn',
trackName: 'Japanese subs',
size: 41000,
url: 'https://storage.tsukihime.org/attach/001dd648/1955400.xz',
sourceFilename: 'episode01.mkv',
};
const GERMAN_TRACK: TsukihimeSubtitleFile = {
attachmentId: 1955500,
filename: 'episode01.ger.ass',
lang: 'ger',
trackName: 'Deutsch',
size: 28000,
url: 'https://storage.tsukihime.org/attach/001dd6ac/1955500.xz',
sourceFilename: 'episode01.mkv',
};
interface ModalHarness {
modal: ReturnType<typeof createTsukihimeModal>;
state: ReturnType<typeof createRendererState>;
downloadQueries: unknown[];
modalCloseNotifications: string[];
overlayClassList: ReturnType<typeof createClassList>;
tsukihimeModalClassList: ReturnType<typeof createClassList>;
titleInput: { value: string };
status: { textContent: string; style: { color: string } };
entriesList: ReturnType<typeof createListStub>;
filesList: ReturnType<typeof createListStub>;
secondaryTab: ReturnType<typeof createTabStub>;
primaryTab: ReturnType<typeof createTabStub>;
restoreGlobals: () => void;
}
function createModalHarness(
files: TsukihimeSubtitleFile[],
options: {
secondaryLanguages?: string[];
downloadFile?: (query: unknown) => Promise<unknown>;
listFiles?: (entryId: number) => Promise<unknown>;
searchEntries?: (query: unknown) => Promise<unknown>;
} = {},
): ModalHarness {
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, 'window');
const hadDocument = Object.prototype.hasOwnProperty.call(globalThis, 'document');
const previousWindow = globals.window;
const previousDocument = globals.document;
const modalCloseNotifications: string[] = [];
const downloadQueries: unknown[] = [];
const electronAPI = {
tsukihimeDownloadFile: async (query: unknown) => {
downloadQueries.push(query);
if (options.downloadFile) return options.downloadFile(query);
return { ok: true, path: '/tmp/subtitles/episode01.en.ass' };
},
tsukihimeGetSecondaryLanguages: async () => options.secondaryLanguages ?? ['en', 'eng'],
tsukihimeListFiles: async ({ entryId }: { entryId: number }) =>
options.listFiles ? options.listFiles(entryId) : { ok: true, data: [] },
tsukihimeSearchEntries: async (query: unknown) =>
options.searchEntries ? options.searchEntries(query) : { ok: true, data: [] },
getJimakuMediaInfo: async () => ({
title: '',
season: null,
episode: null,
confidence: 'low',
filename: '',
rawTitle: '',
}),
notifyOverlayModalClosed: (modal: string) => {
modalCloseNotifications.push(modal);
},
} as unknown as ElectronAPI;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { electronAPI },
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
activeElement: null,
createElement: () => createElementStub(),
},
});
const overlayClassList = createClassList(['interactive']);
const tsukihimeModalClassList = createClassList();
const state = createRendererState();
state.tsukihimeModalOpen = true;
state.currentTsukihimeEntryId = 606713;
state.selectedTsukihimeFileIndex = 0;
state.tsukihimeFiles = files;
const secondaryTab = createTabStub(true);
secondaryTab.textContent = 'English';
const primaryTab = createTabStub(false);
primaryTab.textContent = 'Japanese';
const ctx = {
dom: {
overlay: { classList: overlayClassList },
tsukihimeModal: {
classList: tsukihimeModalClassList,
setAttribute: () => {},
},
tsukihimeTitleInput: { value: '' },
tsukihimeEpisodeInput: { value: '' },
tsukihimeSearchButton: { addEventListener: () => {} },
tsukihimeCloseButton: { addEventListener: () => {} },
tsukihimeTabSecondaryButton: secondaryTab,
tsukihimeTabPrimaryButton: primaryTab,
tsukihimeStatus: { textContent: '', style: { color: '' } },
tsukihimeEntriesSection: { classList: createClassList(['hidden']) },
tsukihimeEntriesList: createListStub(),
tsukihimeFilesSection: { classList: createClassList() },
tsukihimeFilesList: createListStub(),
},
state,
};
const modal = createTsukihimeModal(ctx as never, {
modalStateReader: { isAnyModalOpen: () => false },
syncSettingsModalSubtitleSuppression: () => {},
});
return {
modal,
state,
downloadQueries,
modalCloseNotifications,
overlayClassList,
tsukihimeModalClassList,
titleInput: ctx.dom.tsukihimeTitleInput,
status: ctx.dom.tsukihimeStatus,
entriesList: ctx.dom.tsukihimeEntriesList,
filesList: ctx.dom.tsukihimeFilesList,
secondaryTab,
primaryTab,
restoreGlobals: () => {
const target = globalThis as unknown as Record<string, unknown>;
if (hadWindow) {
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
} else {
delete target.window;
}
if (hadDocument) {
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: previousDocument,
});
} else {
delete target.document;
}
},
};
}
test('TsukiHime language tabs expose tab semantics in the renderer markup', () => {
const html = fs.readFileSync(path.join(process.cwd(), 'src', 'renderer', 'index.html'), 'utf8');
const tabs = html.match(/<div class="tsukihime-tabs"[\s\S]*?<\/div>/)?.[0];
assert.ok(tabs);
assert.match(tabs, /<div class="tsukihime-tabs" role="tablist">/);
assert.match(tabs, /id="tsukihimeTabSecondary"[\s\S]*?role="tab"[\s\S]*?aria-selected="true"/);
assert.match(tabs, /id="tsukihimeTabPrimary"[\s\S]*?role="tab"[\s\S]*?aria-selected="false"/);
});
test('switching TsukiHime language tabs synchronizes aria-selected', () => {
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
try {
pressKey(harness, 'ArrowRight');
assert.equal(harness.secondaryTab.attributes.get('aria-selected'), 'false');
assert.equal(harness.primaryTab.attributes.get('aria-selected'), 'true');
pressKey(harness, 'ArrowLeft');
assert.equal(harness.secondaryTab.attributes.get('aria-selected'), 'true');
assert.equal(harness.primaryTab.attributes.get('aria-selected'), 'false');
} finally {
harness.restoreGlobals();
}
});
function pressKey(harness: ModalHarness, key: string): boolean {
let prevented = false;
harness.modal.handleTsukihimeKeydown({
key,
preventDefault: () => {
prevented = true;
},
} as KeyboardEvent);
return prevented;
}
test('successful Tsukihime subtitle selection closes modal', async () => {
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
try {
const prevented = pressKey(harness, 'Enter');
await flushAsyncWork();
assert.equal(prevented, true);
assert.equal(harness.state.tsukihimeModalOpen, false);
assert.equal(harness.tsukihimeModalClassList.contains('hidden'), true);
assert.equal(harness.overlayClassList.contains('interactive'), false);
assert.deepEqual(harness.modalCloseNotifications, ['tsukihime']);
assert.deepEqual(harness.downloadQueries, [
{
entryId: 606713,
url: ENGLISH_TRACK.url,
name: ENGLISH_TRACK.filename,
lang: 'eng',
},
]);
} finally {
harness.restoreGlobals();
}
});
test('a download from a prior modal session cannot close a reopened modal', async () => {
let resolveDownload!: (value: unknown) => void;
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK], {
downloadFile: () =>
new Promise((resolve) => {
resolveDownload = resolve;
}),
});
try {
pressKey(harness, 'Enter');
harness.modal.closeTsukihimeModal();
harness.modal.openTsukihimeModal();
await flushAsyncWork();
harness.state.currentTsukihimeEntryId = 606713;
harness.status.textContent = 'Fresh modal session';
resolveDownload({ ok: true, path: '/tmp/subtitles/stale.ass' });
await flushAsyncWork();
assert.equal(harness.state.tsukihimeModalOpen, true);
assert.equal(harness.status.textContent, 'Fresh modal session');
assert.deepEqual(harness.modalCloseNotifications, ['tsukihime']);
} finally {
harness.restoreGlobals();
}
});
test('a download cannot affect a newly selected release', async () => {
let resolveDownload!: (value: unknown) => void;
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK], {
downloadFile: () =>
new Promise((resolve) => {
resolveDownload = resolve;
}),
});
try {
pressKey(harness, 'Enter');
harness.state.tsukihimeEntries = [
{
id: 999,
title: 'new release',
timestamp: null,
totalSize: null,
numFiles: 1,
sublangs: [],
},
];
harness.modal.selectTsukihimeEntry(0);
await flushAsyncWork();
const currentStatus = harness.status.textContent;
resolveDownload({ ok: false, error: { error: 'stale failure' } });
await flushAsyncWork();
assert.equal(harness.state.tsukihimeModalOpen, true);
assert.equal(harness.state.currentTsukihimeEntryId, 999);
assert.equal(harness.status.textContent, currentStatus);
assert.deepEqual(harness.modalCloseNotifications, []);
} finally {
harness.restoreGlobals();
}
});
test('secondary tab hides languages outside its configured defaults', async () => {
const harness = createModalHarness([GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK]);
try {
// With German visible this would move selection onto it; English-only
// filtering must clamp to the single English track instead.
pressKey(harness, 'ArrowDown');
pressKey(harness, 'Enter');
await flushAsyncWork();
assert.deepEqual(harness.downloadQueries, [
{
entryId: 606713,
url: ENGLISH_TRACK.url,
name: ENGLISH_TRACK.filename,
lang: 'eng',
},
]);
} finally {
harness.restoreGlobals();
}
});
test('primary tab defaults to Japanese tracks', async () => {
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
try {
assert.equal(harness.state.tsukihimeActiveTab, 'secondary');
pressKey(harness, 'ArrowRight');
assert.equal(harness.state.tsukihimeActiveTab, 'primary');
pressKey(harness, 'Enter');
await flushAsyncWork();
assert.deepEqual(harness.downloadQueries, [
{
entryId: 606713,
url: JAPANESE_TRACK.url,
name: JAPANESE_TRACK.filename,
lang: 'jpn',
},
]);
} finally {
harness.restoreGlobals();
}
});
test('secondary tab follows configured secondarySub languages', async () => {
const harness = createModalHarness([GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK], {
secondaryLanguages: ['de'],
});
try {
// Re-open through the API so the modal fetches the configured languages.
harness.state.tsukihimeModalOpen = false;
harness.modal.openTsukihimeModal();
await flushAsyncWork();
harness.state.tsukihimeFiles = [GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK];
harness.state.currentTsukihimeEntryId = 606713;
pressKey(harness, 'Enter');
await flushAsyncWork();
assert.deepEqual(harness.downloadQueries, [
{
entryId: 606713,
url: GERMAN_TRACK.url,
name: GERMAN_TRACK.filename,
lang: 'ger',
},
]);
} finally {
harness.restoreGlobals();
}
});
test('primary tab remains Japanese when the release includes other languages', async () => {
const harness = createModalHarness([GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK], {
secondaryLanguages: ['en'],
});
try {
harness.state.tsukihimeModalOpen = false;
harness.modal.openTsukihimeModal();
await flushAsyncWork();
harness.state.tsukihimeFiles = [GERMAN_TRACK, ENGLISH_TRACK, JAPANESE_TRACK];
harness.state.currentTsukihimeEntryId = 606713;
pressKey(harness, 'ArrowRight');
pressKey(harness, 'Enter');
await flushAsyncWork();
assert.deepEqual(harness.downloadQueries, [
{
entryId: 606713,
url: JAPANESE_TRACK.url,
name: JAPANESE_TRACK.filename,
lang: 'jpn',
},
]);
} finally {
harness.restoreGlobals();
}
});
test('a slow release response does not overwrite the newly selected release', async () => {
const STALE_TRACK: TsukihimeSubtitleFile = {
...ENGLISH_TRACK,
attachmentId: 999,
filename: 'stale.eng.ass',
};
const SECOND_ENGLISH_TRACK: TsukihimeSubtitleFile = {
...ENGLISH_TRACK,
attachmentId: 1955357,
filename: 'episode01.eng.sdh.ass',
};
const resolvers: Array<(value: unknown) => void> = [];
const harness = createModalHarness([], {
listFiles: (entryId) =>
new Promise((resolve) => {
if (entryId === 1) {
// Entry 1 answers late, after the user has moved on to entry 2.
resolvers.push(() => resolve({ ok: true, data: [STALE_TRACK] }));
} else {
// Two tracks, so the modal does not auto-download a lone match.
resolve({ ok: true, data: [ENGLISH_TRACK, SECOND_ENGLISH_TRACK] });
}
}),
});
try {
harness.state.tsukihimeEntries = [
{ id: 1, title: 'slow release', timestamp: null, totalSize: null, numFiles: 1, sublangs: [] },
{ id: 2, title: 'fast release', timestamp: null, totalSize: null, numFiles: 1, sublangs: [] },
];
harness.modal.selectTsukihimeEntry(0);
harness.modal.selectTsukihimeEntry(1);
await flushAsyncWork();
// Entry 2's tracks are on screen; now entry 1 finally answers.
assert.deepEqual(
harness.state.tsukihimeFiles.map((file) => file.attachmentId),
[ENGLISH_TRACK.attachmentId, SECOND_ENGLISH_TRACK.attachmentId],
);
resolvers.forEach((resolve) => resolve(undefined));
await flushAsyncWork();
assert.equal(harness.state.currentTsukihimeEntryId, 2);
assert.deepEqual(
harness.state.tsukihimeFiles.map((file) => file.attachmentId),
[ENGLISH_TRACK.attachmentId, SECOND_ENGLISH_TRACK.attachmentId],
);
} finally {
harness.restoreGlobals();
}
});
test('ArrowLeft switches back to the secondary-language tab', () => {
const harness = createModalHarness([ENGLISH_TRACK, JAPANESE_TRACK]);
try {
pressKey(harness, 'ArrowRight');
assert.equal(harness.state.tsukihimeActiveTab, 'primary');
pressKey(harness, 'ArrowLeft');
assert.equal(harness.state.tsukihimeActiveTab, 'secondary');
} finally {
harness.restoreGlobals();
}
});
test('searching reports TsukiHime as the backend and lists sublangs per release', async () => {
const harness = createModalHarness([], {
searchEntries: async () => ({
ok: true,
data: [
{
id: 12255,
title: '[DKB] Futsutsuka na Akujo - S01E01 [Multi-Subs]',
timestamp: null,
totalSize: 423868898,
numFiles: 1,
sublangs: ['en-US', 'ja'],
},
{
id: 12256,
title: 'release without langs',
timestamp: null,
totalSize: null,
numFiles: null,
sublangs: [],
},
],
}),
});
try {
harness.state.tsukihimeFiles = [];
harness.state.currentTsukihimeEntryId = null;
harness.titleInput.value = 'Futsutsuka na Akujo';
const seenStatuses: string[] = [];
const status = harness.status;
Object.defineProperty(status, 'textContent', {
get: () => seenStatuses.at(-1) ?? '',
set: (value: string) => {
seenStatuses.push(value);
},
});
pressKey(harness, 'Enter');
await flushAsyncWork();
assert.equal(
seenStatuses.some((message) => message.includes('TsukiHime')),
true,
);
assert.equal(
seenStatuses.some((message) => message.includes('Tsukihime')),
false,
);
const firstEntry = harness.entriesList.children[0] as {
children: Array<{ textContent: string }>;
};
assert.equal(firstEntry.children.length, 1);
assert.match(firstEntry.children[0]!.textContent, /en-US, ja/);
} finally {
harness.restoreGlobals();
}
});
test('renderFiles omits the size detail when the API does not report one', () => {
const zeroSizeTrack: TsukihimeSubtitleFile = {
...ENGLISH_TRACK,
size: 0,
};
const harness = createModalHarness([zeroSizeTrack, JAPANESE_TRACK]);
try {
pressKey(harness, 'ArrowDown');
const firstFile = harness.filesList.children[0] as {
children: Array<{ textContent: string }>;
};
assert.equal(firstFile.children.length, 1);
assert.equal(firstFile.children[0]!.textContent.includes('0 B'), false);
assert.match(firstFile.children[0]!.textContent, /English subs/);
} finally {
harness.restoreGlobals();
}
});
+506
View File
@@ -0,0 +1,506 @@
import type {
TsukihimeApiResponse,
TsukihimeDownloadResult,
TsukihimeEntry,
TsukihimeSubtitleFile,
JimakuMediaInfo,
} from '../../types';
import {
tsukihimeTrackMatchesLanguages,
describeTsukihimeTabLanguages,
normalizeTsukihimeLangCode,
} from '../../tsukihime/lang.js';
import type { ModalStateReader, RendererContext } from '../context';
export function createTsukihimeModal(
ctx: RendererContext,
options: {
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
syncSettingsModalSubtitleSuppression: () => void;
},
) {
function setTsukihimeStatus(message: string, isError = false): void {
ctx.dom.tsukihimeStatus.textContent = message;
ctx.dom.tsukihimeStatus.style.color = isError
? 'rgba(255, 120, 120, 0.95)'
: 'rgba(255, 255, 255, 0.8)';
}
function resetTsukihimeLists(): void {
ctx.state.tsukihimeEntries = [];
ctx.state.tsukihimeFiles = [];
ctx.state.selectedTsukihimeEntryIndex = 0;
ctx.state.selectedTsukihimeFileIndex = 0;
ctx.state.currentTsukihimeEntryId = null;
ctx.dom.tsukihimeEntriesList.innerHTML = '';
ctx.dom.tsukihimeFilesList.innerHTML = '';
ctx.dom.tsukihimeEntriesSection.classList.add('hidden');
ctx.dom.tsukihimeFilesSection.classList.add('hidden');
}
// Defaults to English until the configured secondary languages arrive.
let secondaryLanguages: string[] = ['en'];
let activeDownloadToken = 0;
function secondaryTabLabel(): string {
return describeTsukihimeTabLanguages(secondaryLanguages);
}
function isPrimaryTrack(file: TsukihimeSubtitleFile): boolean {
return normalizeTsukihimeLangCode(file.lang) === 'ja';
}
function getVisibleFiles(): TsukihimeSubtitleFile[] {
if (ctx.state.tsukihimeActiveTab === 'primary') {
return ctx.state.tsukihimeFiles.filter(isPrimaryTrack);
}
return ctx.state.tsukihimeFiles.filter(
(file) =>
!isPrimaryTrack(file) && tsukihimeTrackMatchesLanguages(file.lang, secondaryLanguages),
);
}
function renderTabs(): void {
const primaryActive = ctx.state.tsukihimeActiveTab === 'primary';
ctx.dom.tsukihimeTabSecondaryButton.setAttribute(
'aria-selected',
primaryActive ? 'false' : 'true',
);
ctx.dom.tsukihimeTabPrimaryButton.setAttribute(
'aria-selected',
primaryActive ? 'true' : 'false',
);
if (primaryActive) {
ctx.dom.tsukihimeTabSecondaryButton.classList.remove('active');
ctx.dom.tsukihimeTabPrimaryButton.classList.add('active');
} else {
ctx.dom.tsukihimeTabSecondaryButton.classList.add('active');
ctx.dom.tsukihimeTabPrimaryButton.classList.remove('active');
}
}
function describeEmptyTab(): string {
const hiddenCount = ctx.state.tsukihimeFiles.length;
if (ctx.state.tsukihimeActiveTab === 'primary') {
return hiddenCount > 0
? `No Japanese tracks in this release. Switch to the ${secondaryTabLabel()} tab.`
: 'No Japanese tracks in this release.';
}
return hiddenCount > 0
? `No ${secondaryTabLabel()} tracks in this release. Switch to the Japanese tab.`
: `No ${secondaryTabLabel()} tracks in this release.`;
}
function setActiveTab(tab: 'secondary' | 'primary'): void {
if (ctx.state.tsukihimeActiveTab === tab) return;
ctx.state.tsukihimeActiveTab = tab;
ctx.state.selectedTsukihimeFileIndex = 0;
renderTabs();
if (ctx.state.tsukihimeFiles.length === 0) return;
renderFiles();
if (getVisibleFiles().length === 0) {
setTsukihimeStatus(describeEmptyTab());
} else {
setTsukihimeStatus('Select a subtitle track.');
}
}
function formatBytes(size: number): string {
if (!Number.isFinite(size)) return '';
const units = ['B', 'KB', 'MB', 'GB'];
let value = size;
let idx = 0;
while (value >= 1024 && idx < units.length - 1) {
value /= 1024;
idx += 1;
}
return `${value.toFixed(value >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`;
}
function renderEntries(): void {
ctx.dom.tsukihimeEntriesList.innerHTML = '';
if (ctx.state.tsukihimeEntries.length === 0) {
ctx.dom.tsukihimeEntriesSection.classList.add('hidden');
return;
}
ctx.dom.tsukihimeEntriesSection.classList.remove('hidden');
ctx.state.tsukihimeEntries.forEach((entry, index) => {
const li = document.createElement('li');
li.textContent = entry.title;
const details: string[] = [];
if (entry.totalSize !== null) details.push(formatBytes(entry.totalSize));
if (entry.numFiles !== null) {
details.push(`${entry.numFiles} file${entry.numFiles === 1 ? '' : 's'}`);
}
if (entry.sublangs?.length) details.push(`subs: ${entry.sublangs.join(', ')}`);
if (details.length > 0) {
const sub = document.createElement('div');
sub.className = 'jimaku-subtext';
sub.textContent = details.join(' • ');
li.appendChild(sub);
}
if (index === ctx.state.selectedTsukihimeEntryIndex) {
li.classList.add('active');
}
li.addEventListener('click', () => {
selectEntry(index);
});
ctx.dom.tsukihimeEntriesList.appendChild(li);
});
}
function renderFiles(): void {
ctx.dom.tsukihimeFilesList.innerHTML = '';
const visibleFiles = getVisibleFiles();
if (visibleFiles.length === 0) {
ctx.dom.tsukihimeFilesSection.classList.add('hidden');
return;
}
ctx.dom.tsukihimeFilesSection.classList.remove('hidden');
visibleFiles.forEach((file, index) => {
const li = document.createElement('li');
li.textContent = file.filename;
const details: string[] = [];
if (file.lang) details.push(file.lang);
if (file.trackName) details.push(file.trackName);
// TsukiHime does not report attachment sizes; skip a meaningless "0 B".
if (file.size > 0) details.push(formatBytes(file.size));
const sub = document.createElement('div');
sub.className = 'jimaku-subtext';
sub.textContent = details.filter(Boolean).join(' • ');
li.appendChild(sub);
if (index === ctx.state.selectedTsukihimeFileIndex) {
li.classList.add('active');
}
li.addEventListener('click', () => {
void selectFile(index);
});
ctx.dom.tsukihimeFilesList.appendChild(li);
});
}
function getSearchQuery(): string {
const title = ctx.dom.tsukihimeTitleInput.value.trim();
if (!title) return '';
const episodeValue = ctx.dom.tsukihimeEpisodeInput.value
? Number.parseInt(ctx.dom.tsukihimeEpisodeInput.value, 10)
: null;
if (episodeValue !== null && Number.isFinite(episodeValue)) {
return `${title} ${String(episodeValue).padStart(2, '0')}`;
}
return title;
}
async function performTsukihimeSearch(): Promise<void> {
const query = getSearchQuery();
if (!query) {
setTsukihimeStatus('Enter a title before searching.', true);
return;
}
resetTsukihimeLists();
setTsukihimeStatus('Searching TsukiHime...');
const response: TsukihimeApiResponse<TsukihimeEntry[]> =
await window.electronAPI.tsukihimeSearchEntries({ query });
if (!response.ok) {
setTsukihimeStatus(response.error.error, true);
return;
}
ctx.state.tsukihimeEntries = response.data;
ctx.state.selectedTsukihimeEntryIndex = 0;
if (ctx.state.tsukihimeEntries.length === 0) {
setTsukihimeStatus('No releases found.');
return;
}
setTsukihimeStatus('Select a release.');
renderEntries();
if (ctx.state.tsukihimeEntries.length === 1) {
selectEntry(0);
}
}
async function loadFiles(entryId: number): Promise<void> {
setTsukihimeStatus('Loading subtitle tracks...');
ctx.state.tsukihimeFiles = [];
ctx.state.selectedTsukihimeFileIndex = 0;
ctx.dom.tsukihimeFilesList.innerHTML = '';
ctx.dom.tsukihimeFilesSection.classList.add('hidden');
const response: TsukihimeApiResponse<TsukihimeSubtitleFile[]> =
await window.electronAPI.tsukihimeListFiles({ entryId });
// The user may have picked another release while this was in flight.
if (ctx.state.currentTsukihimeEntryId !== entryId) return;
if (!response.ok) {
setTsukihimeStatus(response.error.error, true);
return;
}
ctx.state.tsukihimeFiles = response.data;
if (ctx.state.tsukihimeFiles.length === 0) {
const entry = ctx.state.tsukihimeEntries.find((candidate) => candidate.id === entryId);
// The feed API omits per-file attachment data for multi-file torrents.
if (entry && entry.numFiles !== null && entry.numFiles > 1) {
setTsukihimeStatus(
'Batch releases are not supported. Pick a single-episode release instead.',
);
} else {
setTsukihimeStatus('No text subtitle tracks in this release. Try another one.');
}
return;
}
const visibleFiles = getVisibleFiles();
if (visibleFiles.length === 0) {
setTsukihimeStatus(describeEmptyTab());
return;
}
setTsukihimeStatus('Select a subtitle track.');
renderFiles();
if (visibleFiles.length === 1) {
await selectFile(0);
}
}
function selectEntry(index: number): void {
if (index < 0 || index >= ctx.state.tsukihimeEntries.length) return;
ctx.state.selectedTsukihimeEntryIndex = index;
ctx.state.currentTsukihimeEntryId = ctx.state.tsukihimeEntries[index]!.id;
renderEntries();
if (ctx.state.currentTsukihimeEntryId !== null) {
void loadFiles(ctx.state.currentTsukihimeEntryId);
}
}
async function selectFile(index: number): Promise<void> {
const visibleFiles = getVisibleFiles();
if (index < 0 || index >= visibleFiles.length) return;
ctx.state.selectedTsukihimeFileIndex = index;
renderFiles();
if (ctx.state.currentTsukihimeEntryId === null) {
setTsukihimeStatus('Select a release first.', true);
return;
}
const entryId = ctx.state.currentTsukihimeEntryId;
const file = visibleFiles[index]!;
const downloadToken = ++activeDownloadToken;
setTsukihimeStatus('Downloading subtitle...');
const result: TsukihimeDownloadResult = await window.electronAPI.tsukihimeDownloadFile({
entryId,
url: file.url,
name: file.filename,
lang: file.lang,
});
if (downloadToken !== activeDownloadToken || ctx.state.currentTsukihimeEntryId !== entryId) {
return;
}
if (result.ok) {
setTsukihimeStatus(`Downloaded and loaded: ${result.path}`);
closeTsukihimeModal();
return;
}
setTsukihimeStatus(result.error.error, true);
}
function isTextInputFocused(): boolean {
const active = document.activeElement;
if (!active) return false;
const tag = active.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea';
}
async function loadSecondaryLanguages(): Promise<void> {
try {
const configuredSecondary = await window.electronAPI.tsukihimeGetSecondaryLanguages();
secondaryLanguages = configuredSecondary.length > 0 ? configuredSecondary : ['en'];
} catch {
secondaryLanguages = ['en'];
}
ctx.dom.tsukihimeTabSecondaryButton.textContent = secondaryTabLabel();
ctx.dom.tsukihimeTabPrimaryButton.textContent = 'Japanese';
// Tracks may already be on screen if the languages arrived late.
if (ctx.state.tsukihimeFiles.length > 0) {
renderFiles();
}
}
function openTsukihimeModal(): void {
if (ctx.state.tsukihimeModalOpen) return;
ctx.state.tsukihimeModalOpen = true;
ctx.state.tsukihimeActiveTab = 'secondary';
options.syncSettingsModalSubtitleSuppression();
ctx.dom.overlay.classList.add('interactive');
ctx.dom.tsukihimeModal.classList.remove('hidden');
ctx.dom.tsukihimeModal.setAttribute('aria-hidden', 'false');
setTsukihimeStatus('Loading media info...');
resetTsukihimeLists();
renderTabs();
const secondaryLanguagesReady = loadSecondaryLanguages();
window.electronAPI
.getJimakuMediaInfo()
.then(async (info: JimakuMediaInfo) => {
ctx.dom.tsukihimeTitleInput.value = info.title || '';
ctx.dom.tsukihimeEpisodeInput.value = info.episode ? String(info.episode) : '';
if (info.confidence === 'high' && info.title && info.episode) {
await secondaryLanguagesReady;
void performTsukihimeSearch();
} else if (info.title) {
setTsukihimeStatus('Check title/episode and press Search.');
} else {
setTsukihimeStatus('Enter title/episode and press Search.');
}
})
.catch(() => {
setTsukihimeStatus('Failed to load media info.', true);
});
}
function closeTsukihimeModal(): void {
if (!ctx.state.tsukihimeModalOpen) return;
activeDownloadToken += 1;
ctx.state.tsukihimeModalOpen = false;
options.syncSettingsModalSubtitleSuppression();
ctx.dom.tsukihimeModal.classList.add('hidden');
ctx.dom.tsukihimeModal.setAttribute('aria-hidden', 'true');
window.electronAPI.notifyOverlayModalClosed('tsukihime');
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
ctx.dom.overlay.classList.remove('interactive');
}
resetTsukihimeLists();
}
function handleTsukihimeKeydown(e: KeyboardEvent): boolean {
if (e.key === 'Escape') {
e.preventDefault();
closeTsukihimeModal();
return true;
}
if (isTextInputFocused()) {
if (e.key === 'Enter') {
e.preventDefault();
void performTsukihimeSearch();
}
return true;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
setActiveTab('secondary');
return true;
}
if (e.key === 'ArrowRight') {
e.preventDefault();
setActiveTab('primary');
return true;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
const visibleFiles = getVisibleFiles();
if (visibleFiles.length > 0) {
ctx.state.selectedTsukihimeFileIndex = Math.min(
visibleFiles.length - 1,
ctx.state.selectedTsukihimeFileIndex + 1,
);
renderFiles();
} else if (ctx.state.tsukihimeEntries.length > 0) {
ctx.state.selectedTsukihimeEntryIndex = Math.min(
ctx.state.tsukihimeEntries.length - 1,
ctx.state.selectedTsukihimeEntryIndex + 1,
);
renderEntries();
}
return true;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (getVisibleFiles().length > 0) {
ctx.state.selectedTsukihimeFileIndex = Math.max(
0,
ctx.state.selectedTsukihimeFileIndex - 1,
);
renderFiles();
} else if (ctx.state.tsukihimeEntries.length > 0) {
ctx.state.selectedTsukihimeEntryIndex = Math.max(
0,
ctx.state.selectedTsukihimeEntryIndex - 1,
);
renderEntries();
}
return true;
}
if (e.key === 'Enter') {
e.preventDefault();
if (getVisibleFiles().length > 0) {
void selectFile(ctx.state.selectedTsukihimeFileIndex);
} else if (ctx.state.tsukihimeEntries.length > 0) {
selectEntry(ctx.state.selectedTsukihimeEntryIndex);
} else {
void performTsukihimeSearch();
}
return true;
}
return true;
}
function wireDomEvents(): void {
ctx.dom.tsukihimeSearchButton.addEventListener('click', () => {
void performTsukihimeSearch();
});
ctx.dom.tsukihimeCloseButton.addEventListener('click', () => {
closeTsukihimeModal();
});
ctx.dom.tsukihimeTabSecondaryButton.addEventListener('click', () => {
setActiveTab('secondary');
});
ctx.dom.tsukihimeTabPrimaryButton.addEventListener('click', () => {
setActiveTab('primary');
});
}
return {
closeTsukihimeModal,
handleTsukihimeKeydown,
openTsukihimeModal,
selectTsukihimeEntry: selectEntry,
wireDomEvents,
};
}
+11 -11
View File
@@ -33,7 +33,7 @@ import { createControllerStatusIndicator } from './controller-status-indicator.j
import { createControllerDebugModal } from './modals/controller-debug.js'; import { createControllerDebugModal } from './modals/controller-debug.js';
import { createControllerSelectModal } from './modals/controller-select.js'; import { createControllerSelectModal } from './modals/controller-select.js';
import { createJimakuModal } from './modals/jimaku.js'; import { createJimakuModal } from './modals/jimaku.js';
import { createAnimetoshoModal } from './modals/animetosho.js'; import { createTsukihimeModal } from './modals/tsukihime.js';
import { createKikuModal } from './modals/kiku.js'; import { createKikuModal } from './modals/kiku.js';
import { prepareForKikuFieldGroupingOpen } from './kiku-open.js'; import { prepareForKikuFieldGroupingOpen } from './kiku-open.js';
import { createPlaylistBrowserModal } from './modals/playlist-browser.js'; import { createPlaylistBrowserModal } from './modals/playlist-browser.js';
@@ -102,9 +102,9 @@ const modalDescriptors = [
suppressesSubtitles: true, suppressesSubtitles: true,
}, },
{ {
id: 'animetosho', id: 'tsukihime',
isOpen: () => ctx.state.animetoshoModalOpen, isOpen: () => ctx.state.tsukihimeModalOpen,
close: () => animetoshoModal.closeAnimetoshoModal(), close: () => tsukihimeModal.closeTsukihimeModal(),
suppressesSubtitles: false, suppressesSubtitles: false,
}, },
{ {
@@ -229,7 +229,7 @@ const jimakuModal = createJimakuModal(ctx, {
modalStateReader: { isAnyModalOpen }, modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression, syncSettingsModalSubtitleSuppression,
}); });
const animetoshoModal = createAnimetoshoModal(ctx, { const tsukihimeModal = createTsukihimeModal(ctx, {
modalStateReader: { isAnyModalOpen }, modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression, syncSettingsModalSubtitleSuppression,
}); });
@@ -260,7 +260,7 @@ const keyboardHandlers = createKeyboardHandlers(ctx, {
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown, handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
handleKikuKeydown: kikuModal.handleKikuKeydown, handleKikuKeydown: kikuModal.handleKikuKeydown,
handleJimakuKeydown: jimakuModal.handleJimakuKeydown, handleJimakuKeydown: jimakuModal.handleJimakuKeydown,
handleAnimetoshoKeydown: animetoshoModal.handleAnimetoshoKeydown, handleTsukihimeKeydown: tsukihimeModal.handleTsukihimeKeydown,
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown, handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown, handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown,
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown, handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
@@ -545,10 +545,10 @@ function registerModalOpenHandlers(): void {
window.electronAPI.notifyOverlayModalOpened('jimaku'); window.electronAPI.notifyOverlayModalOpened('jimaku');
}); });
}); });
window.electronAPI.onOpenAnimetosho(() => { window.electronAPI.onOpenTsukihime(() => {
runGuarded('animetosho:open', () => { runGuarded('tsukihime:open', () => {
animetoshoModal.openAnimetoshoModal(); tsukihimeModal.openTsukihimeModal();
window.electronAPI.notifyOverlayModalOpened('animetosho'); window.electronAPI.notifyOverlayModalOpened('tsukihime');
}); });
}); });
window.electronAPI.onOpenYoutubeTrackPicker((payload) => { window.electronAPI.onOpenYoutubeTrackPicker((payload) => {
@@ -785,7 +785,7 @@ async function init(): Promise<void> {
}); });
jimakuModal.wireDomEvents(); jimakuModal.wireDomEvents();
animetoshoModal.wireDomEvents(); tsukihimeModal.wireDomEvents();
youtubePickerModal.wireDomEvents(); youtubePickerModal.wireDomEvents();
playlistBrowserModal.wireDomEvents(); playlistBrowserModal.wireDomEvents();
kikuModal.wireDomEvents(); kikuModal.wireDomEvents();
+16 -16
View File
@@ -4,8 +4,8 @@ import type {
ControllerButtonSnapshot, ControllerButtonSnapshot,
ControllerDeviceInfo, ControllerDeviceInfo,
ResolvedControllerConfig, ResolvedControllerConfig,
AnimetoshoEntry, TsukihimeEntry,
AnimetoshoSubtitleFile, TsukihimeSubtitleFile,
JimakuEntry, JimakuEntry,
JimakuFileEntry, JimakuFileEntry,
KikuDuplicateCardInfo, KikuDuplicateCardInfo,
@@ -50,13 +50,13 @@ export type RendererState = {
currentEpisodeFilter: number | null; currentEpisodeFilter: number | null;
currentEntryId: number | null; currentEntryId: number | null;
animetoshoModalOpen: boolean; tsukihimeModalOpen: boolean;
animetoshoActiveTab: 'en' | 'ja'; tsukihimeActiveTab: 'secondary' | 'primary';
animetoshoEntries: AnimetoshoEntry[]; tsukihimeEntries: TsukihimeEntry[];
animetoshoFiles: AnimetoshoSubtitleFile[]; tsukihimeFiles: TsukihimeSubtitleFile[];
selectedAnimetoshoEntryIndex: number; selectedTsukihimeEntryIndex: number;
selectedAnimetoshoFileIndex: number; selectedTsukihimeFileIndex: number;
currentAnimetoshoEntryId: number | null; currentTsukihimeEntryId: number | null;
youtubePickerModalOpen: boolean; youtubePickerModalOpen: boolean;
youtubePickerPayload: YoutubePickerOpenPayload | null; youtubePickerPayload: YoutubePickerOpenPayload | null;
@@ -174,13 +174,13 @@ export function createRendererState(): RendererState {
currentEpisodeFilter: null, currentEpisodeFilter: null,
currentEntryId: null, currentEntryId: null,
animetoshoModalOpen: false, tsukihimeModalOpen: false,
animetoshoActiveTab: 'en', tsukihimeActiveTab: 'secondary',
animetoshoEntries: [], tsukihimeEntries: [],
animetoshoFiles: [], tsukihimeFiles: [],
selectedAnimetoshoEntryIndex: 0, selectedTsukihimeEntryIndex: 0,
selectedAnimetoshoFileIndex: 0, selectedTsukihimeFileIndex: 0,
currentAnimetoshoEntryId: null, currentTsukihimeEntryId: null,
youtubePickerModalOpen: false, youtubePickerModalOpen: false,
youtubePickerPayload: null, youtubePickerPayload: null,
+7 -7
View File
@@ -811,21 +811,21 @@ body:focus-visible,
z-index: 1100; z-index: 1100;
} }
#animetoshoModal { #tsukihimeModal {
z-index: 1100; z-index: 1100;
} }
#animetoshoModal .jimaku-form { #tsukihimeModal .jimaku-form {
grid-template-columns: 1fr 120px auto; grid-template-columns: 1fr 120px auto;
} }
.animetosho-tabs { .tsukihime-tabs {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px; gap: 6px;
} }
.animetosho-tab { .tsukihime-tab {
min-width: 0; min-width: 0;
min-height: 34px; min-height: 34px;
padding: 7px 8px; padding: 7px 8px;
@@ -842,14 +842,14 @@ body:focus-visible,
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.animetosho-tab:hover, .tsukihime-tab:hover,
.animetosho-tab:focus-visible { .tsukihime-tab:focus-visible {
border-color: rgba(138, 173, 244, 0.48); border-color: rgba(138, 173, 244, 0.48);
color: var(--ctp-text); color: var(--ctp-text);
outline: none; outline: none;
} }
.animetosho-tab.active { .tsukihime-tab.active {
border-color: rgba(238, 212, 159, 0.62); border-color: rgba(238, 212, 159, 0.62);
background: rgba(238, 212, 159, 0.16); background: rgba(238, 212, 159, 0.16);
color: var(--ctp-yellow); color: var(--ctp-yellow);
+24 -24
View File
@@ -22,18 +22,18 @@ export type RendererDom = {
jimakuFilesList: HTMLUListElement; jimakuFilesList: HTMLUListElement;
jimakuBroadenButton: HTMLButtonElement; jimakuBroadenButton: HTMLButtonElement;
animetoshoModal: HTMLDivElement; tsukihimeModal: HTMLDivElement;
animetoshoTitleInput: HTMLInputElement; tsukihimeTitleInput: HTMLInputElement;
animetoshoEpisodeInput: HTMLInputElement; tsukihimeEpisodeInput: HTMLInputElement;
animetoshoSearchButton: HTMLButtonElement; tsukihimeSearchButton: HTMLButtonElement;
animetoshoCloseButton: HTMLButtonElement; tsukihimeCloseButton: HTMLButtonElement;
animetoshoTabEnglishButton: HTMLButtonElement; tsukihimeTabSecondaryButton: HTMLButtonElement;
animetoshoTabJapaneseButton: HTMLButtonElement; tsukihimeTabPrimaryButton: HTMLButtonElement;
animetoshoStatus: HTMLDivElement; tsukihimeStatus: HTMLDivElement;
animetoshoEntriesSection: HTMLDivElement; tsukihimeEntriesSection: HTMLDivElement;
animetoshoEntriesList: HTMLUListElement; tsukihimeEntriesList: HTMLUListElement;
animetoshoFilesSection: HTMLDivElement; tsukihimeFilesSection: HTMLDivElement;
animetoshoFilesList: HTMLUListElement; tsukihimeFilesList: HTMLUListElement;
youtubePickerModal: HTMLDivElement; youtubePickerModal: HTMLDivElement;
youtubePickerTitle: HTMLDivElement; youtubePickerTitle: HTMLDivElement;
@@ -167,18 +167,18 @@ export function resolveRendererDom(): RendererDom {
jimakuFilesList: getRequiredElement<HTMLUListElement>('jimakuFiles'), jimakuFilesList: getRequiredElement<HTMLUListElement>('jimakuFiles'),
jimakuBroadenButton: getRequiredElement<HTMLButtonElement>('jimakuBroaden'), jimakuBroadenButton: getRequiredElement<HTMLButtonElement>('jimakuBroaden'),
animetoshoModal: getRequiredElement<HTMLDivElement>('animetoshoModal'), tsukihimeModal: getRequiredElement<HTMLDivElement>('tsukihimeModal'),
animetoshoTitleInput: getRequiredElement<HTMLInputElement>('animetoshoTitle'), tsukihimeTitleInput: getRequiredElement<HTMLInputElement>('tsukihimeTitle'),
animetoshoEpisodeInput: getRequiredElement<HTMLInputElement>('animetoshoEpisode'), tsukihimeEpisodeInput: getRequiredElement<HTMLInputElement>('tsukihimeEpisode'),
animetoshoSearchButton: getRequiredElement<HTMLButtonElement>('animetoshoSearch'), tsukihimeSearchButton: getRequiredElement<HTMLButtonElement>('tsukihimeSearch'),
animetoshoCloseButton: getRequiredElement<HTMLButtonElement>('animetoshoClose'), tsukihimeCloseButton: getRequiredElement<HTMLButtonElement>('tsukihimeClose'),
animetoshoTabEnglishButton: getRequiredElement<HTMLButtonElement>('animetoshoTabEnglish'), tsukihimeTabSecondaryButton: getRequiredElement<HTMLButtonElement>('tsukihimeTabSecondary'),
animetoshoTabJapaneseButton: getRequiredElement<HTMLButtonElement>('animetoshoTabJapanese'), tsukihimeTabPrimaryButton: getRequiredElement<HTMLButtonElement>('tsukihimeTabPrimary'),
animetoshoStatus: getRequiredElement<HTMLDivElement>('animetoshoStatus'), tsukihimeStatus: getRequiredElement<HTMLDivElement>('tsukihimeStatus'),
animetoshoEntriesSection: getRequiredElement<HTMLDivElement>('animetoshoEntriesSection'), tsukihimeEntriesSection: getRequiredElement<HTMLDivElement>('tsukihimeEntriesSection'),
animetoshoEntriesList: getRequiredElement<HTMLUListElement>('animetoshoEntries'), tsukihimeEntriesList: getRequiredElement<HTMLUListElement>('tsukihimeEntries'),
animetoshoFilesSection: getRequiredElement<HTMLDivElement>('animetoshoFilesSection'), tsukihimeFilesSection: getRequiredElement<HTMLDivElement>('tsukihimeFilesSection'),
animetoshoFilesList: getRequiredElement<HTMLUListElement>('animetoshoFiles'), tsukihimeFilesList: getRequiredElement<HTMLUListElement>('tsukihimeFiles'),
youtubePickerModal: getRequiredElement<HTMLDivElement>('youtubePickerModal'), youtubePickerModal: getRequiredElement<HTMLDivElement>('youtubePickerModal'),
youtubePickerTitle: getRequiredElement<HTMLDivElement>('youtubePickerTitle'), youtubePickerTitle: getRequiredElement<HTMLDivElement>('youtubePickerTitle'),
+6 -6
View File
@@ -5,7 +5,7 @@ export const OVERLAY_HOSTED_MODALS = [
'runtime-options', 'runtime-options',
'subsync', 'subsync',
'jimaku', 'jimaku',
'animetosho', 'tsukihime',
'youtube-track-picker', 'youtube-track-picker',
'playlist-browser', 'playlist-browser',
'kiku', 'kiku',
@@ -96,10 +96,10 @@ export const IPC_CHANNELS = {
jimakuSearchEntries: 'jimaku:search-entries', jimakuSearchEntries: 'jimaku:search-entries',
jimakuListFiles: 'jimaku:list-files', jimakuListFiles: 'jimaku:list-files',
jimakuDownloadFile: 'jimaku:download-file', jimakuDownloadFile: 'jimaku:download-file',
animetoshoSearchEntries: 'animetosho:search-entries', tsukihimeSearchEntries: 'tsukihime:search-entries',
animetoshoListFiles: 'animetosho:list-files', tsukihimeListFiles: 'tsukihime:list-files',
animetoshoDownloadFile: 'animetosho:download-file', tsukihimeDownloadFile: 'tsukihime:download-file',
animetoshoGetSecondaryLanguages: 'animetosho:get-secondary-languages', tsukihimeGetSecondaryLanguages: 'tsukihime:get-secondary-languages',
kikuBuildMergePreview: 'kiku:build-merge-preview', kikuBuildMergePreview: 'kiku:build-merge-preview',
getConfigSettingsSnapshot: 'config:get-settings-snapshot', getConfigSettingsSnapshot: 'config:get-settings-snapshot',
saveConfigSettingsPatch: 'config:save-settings-patch', saveConfigSettingsPatch: 'config:save-settings-patch',
@@ -138,7 +138,7 @@ export const IPC_CHANNELS = {
runtimeOptionsChanged: 'runtime-options:changed', runtimeOptionsChanged: 'runtime-options:changed',
runtimeOptionsOpen: 'runtime-options:open', runtimeOptionsOpen: 'runtime-options:open',
jimakuOpen: 'jimaku:open', jimakuOpen: 'jimaku:open',
animetoshoOpen: 'animetosho:open', tsukihimeOpen: 'tsukihime:open',
youtubePickerOpen: 'youtube:picker-open', youtubePickerOpen: 'youtube:picker-open',
youtubePickerCancel: 'youtube:picker-cancel', youtubePickerCancel: 'youtube:picker-cancel',
playlistBrowserOpen: 'playlist-browser:open', playlistBrowserOpen: 'playlist-browser:open',
+7 -7
View File
@@ -1,8 +1,8 @@
import type { KikuFieldGroupingChoice, KikuMergePreviewRequest } from '../../types/anki'; import type { KikuFieldGroupingChoice, KikuMergePreviewRequest } from '../../types/anki';
import type { import type {
AnimetoshoDownloadQuery, TsukihimeDownloadQuery,
AnimetoshoFilesQuery, TsukihimeFilesQuery,
AnimetoshoSearchQuery, TsukihimeSearchQuery,
JimakuDownloadQuery, JimakuDownloadQuery,
JimakuFilesQuery, JimakuFilesQuery,
JimakuSearchQuery, JimakuSearchQuery,
@@ -43,7 +43,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
'openControllerSelect', 'openControllerSelect',
'openControllerDebug', 'openControllerDebug',
'openJimaku', 'openJimaku',
'openAnimetosho', 'openTsukihime',
'openYoutubePicker', 'openYoutubePicker',
'openPlaylistBrowser', 'openPlaylistBrowser',
'replayCurrentSubtitle', 'replayCurrentSubtitle',
@@ -406,17 +406,17 @@ export function parseJimakuDownloadQuery(value: unknown): JimakuDownloadQuery |
}; };
} }
export function parseAnimetoshoSearchQuery(value: unknown): AnimetoshoSearchQuery | null { export function parseTsukihimeSearchQuery(value: unknown): TsukihimeSearchQuery | null {
if (!isObject(value) || typeof value.query !== 'string') return null; if (!isObject(value) || typeof value.query !== 'string') return null;
return { query: value.query }; return { query: value.query };
} }
export function parseAnimetoshoFilesQuery(value: unknown): AnimetoshoFilesQuery | null { export function parseTsukihimeFilesQuery(value: unknown): TsukihimeFilesQuery | null {
if (!isObject(value) || !isInteger(value.entryId)) return null; if (!isObject(value) || !isInteger(value.entryId)) return null;
return { entryId: value.entryId }; return { entryId: value.entryId };
} }
export function parseAnimetoshoDownloadQuery(value: unknown): AnimetoshoDownloadQuery | null { export function parseTsukihimeDownloadQuery(value: unknown): TsukihimeDownloadQuery | null {
if (!isObject(value)) return null; if (!isObject(value)) return null;
if ( if (
!isInteger(value.entryId) || !isInteger(value.entryId) ||
@@ -3,7 +3,7 @@ import test from 'node:test';
import * as http from 'node:http'; import * as http from 'node:http';
import type { AddressInfo } from 'node:net'; import type { AddressInfo } from 'node:net';
import { animetoshoFetchJson } from './utils.js'; import { tsukihimeFetchJson } from './utils.js';
interface TestServer { interface TestServer {
baseUrl: string; baseUrl: string;
@@ -27,7 +27,7 @@ function startServer(handler: http.RequestListener): Promise<TestServer> {
}); });
} }
test('animetoshoFetchJson gives up on a hanging response', async () => { test('tsukihimeFetchJson gives up on a hanging response', async () => {
const server = await startServer((_req, res) => { const server = await startServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
// Never end the response. // Never end the response.
@@ -35,7 +35,7 @@ test('animetoshoFetchJson gives up on a hanging response', async () => {
}); });
try { try {
const result = await animetoshoFetchJson( const result = await tsukihimeFetchJson(
'/json', '/json',
{}, {},
{ baseUrl: server.baseUrl, timeoutMs: 150 }, { baseUrl: server.baseUrl, timeoutMs: 150 },
@@ -49,14 +49,14 @@ test('animetoshoFetchJson gives up on a hanging response', async () => {
} }
}); });
test('animetoshoFetchJson rejects an oversized response', async () => { test('tsukihimeFetchJson rejects an oversized response', async () => {
const server = await startServer((_req, res) => { const server = await startServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(Array.from({ length: 2000 }, (_, index) => ({ id: index })))); res.end(JSON.stringify(Array.from({ length: 2000 }, (_, index) => ({ id: index }))));
}); });
try { try {
const result = await animetoshoFetchJson( const result = await tsukihimeFetchJson(
'/json', '/json',
{}, {},
{ baseUrl: server.baseUrl, maxResponseBytes: 512 }, { baseUrl: server.baseUrl, maxResponseBytes: 512 },
@@ -70,7 +70,7 @@ test('animetoshoFetchJson rejects an oversized response', async () => {
} }
}); });
test('animetoshoFetchJson decodes multi-byte characters split across chunks', async () => { test('tsukihimeFetchJson decodes multi-byte characters split across chunks', async () => {
const title = '葬送のフリーレン'; const title = '葬送のフリーレン';
const body = Buffer.from(JSON.stringify([{ id: 1, title }]), 'utf8'); const body = Buffer.from(JSON.stringify([{ id: 1, title }]), 'utf8');
// Split inside the first Japanese character's 3-byte UTF-8 sequence. // Split inside the first Japanese character's 3-byte UTF-8 sequence.
@@ -83,7 +83,7 @@ test('animetoshoFetchJson decodes multi-byte characters split across chunks', as
}); });
try { try {
const result = await animetoshoFetchJson<Array<{ id: number; title: string }>>( const result = await tsukihimeFetchJson<Array<{ id: number; title: string }>>(
'/json', '/json',
{}, {},
{ baseUrl: server.baseUrl }, { baseUrl: server.baseUrl },
@@ -97,14 +97,14 @@ test('animetoshoFetchJson decodes multi-byte characters split across chunks', as
} }
}); });
test('animetoshoFetchJson still parses a normal response', async () => { test('tsukihimeFetchJson still parses a normal response', async () => {
const server = await startServer((_req, res) => { const server = await startServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify([{ id: 1, title: 'ok' }])); res.end(JSON.stringify([{ id: 1, title: 'ok' }]));
}); });
try { try {
const result = await animetoshoFetchJson<Array<{ id: number }>>( const result = await tsukihimeFetchJson<Array<{ id: number }>>(
'/json', '/json',
{ q: 'x' }, { q: 'x' },
{ baseUrl: server.baseUrl }, { baseUrl: server.baseUrl },
@@ -117,3 +117,19 @@ test('animetoshoFetchJson still parses a normal response', async () => {
await server.close(); await server.close();
} }
}); });
test('tsukihimeFetchJson reports a structured error for a malformed base URL', async () => {
const result = await tsukihimeFetchJson('/search/torrents', {}, { baseUrl: 'not a url' });
assert.equal(result.ok, false);
if (!result.ok) {
assert.match(result.error.error, /base url/i);
}
});
test('tsukihimeFetchJson rejects non-HTTP(S) base URLs', async () => {
const result = await tsukihimeFetchJson('/search/torrents', {}, { baseUrl: 'file:///etc' });
assert.equal(result.ok, false);
if (!result.ok) {
assert.match(result.error.error, /http/i);
}
});
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
tsukihimeLangToFilenameSuffix,
tsukihimeTrackMatchesLanguages,
describeTsukihimeTabLanguages,
normalizeTsukihimeLangCode,
} from './lang.js';
test('normalizeTsukihimeLangCode collapses 2/3-letter and region variants', () => {
assert.equal(normalizeTsukihimeLangCode('eng'), 'en');
assert.equal(normalizeTsukihimeLangCode('en'), 'en');
assert.equal(normalizeTsukihimeLangCode('en-US'), 'en');
assert.equal(normalizeTsukihimeLangCode('GER'), 'de');
assert.equal(normalizeTsukihimeLangCode('jpn'), 'ja');
assert.equal(normalizeTsukihimeLangCode('vie'), 'vie');
assert.equal(normalizeTsukihimeLangCode(''), '');
});
test('tsukihimeTrackMatchesLanguages matches across code forms', () => {
assert.equal(tsukihimeTrackMatchesLanguages('eng', ['en']), true);
assert.equal(tsukihimeTrackMatchesLanguages('eng', ['en', 'eng']), true);
assert.equal(tsukihimeTrackMatchesLanguages('ger', ['de']), true);
assert.equal(tsukihimeTrackMatchesLanguages('por', ['en']), false);
assert.equal(tsukihimeTrackMatchesLanguages('spa', ['en', 'de']), false);
});
test('tsukihimeTrackMatchesLanguages keeps unknown-language tracks visible', () => {
assert.equal(tsukihimeTrackMatchesLanguages('', ['en']), true);
assert.equal(tsukihimeTrackMatchesLanguages('und', ['en']), true);
});
test('describeTsukihimeTabLanguages names common languages and dedupes', () => {
assert.equal(describeTsukihimeTabLanguages(['en', 'eng']), 'English');
assert.equal(describeTsukihimeTabLanguages(['de']), 'German');
assert.equal(describeTsukihimeTabLanguages(['en', 'de']), 'English / German');
assert.equal(describeTsukihimeTabLanguages(['vie']), 'VIE');
assert.equal(describeTsukihimeTabLanguages([]), 'English');
});
test('tsukihimeLangToFilenameSuffix is re-exported from the pure module', () => {
assert.equal(tsukihimeLangToFilenameSuffix('jpn'), 'ja');
assert.equal(tsukihimeLangToFilenameSuffix('eng'), 'en');
});
@@ -1,4 +1,4 @@
// Pure language-code helpers shared between the main-process Animetosho client // Pure language-code helpers shared between the main-process Tsukihime client
// and the overlay renderer. Must stay free of node imports so the renderer // and the overlay renderer. Must stay free of node imports so the renderer
// bundle can use it. // bundle can use it.
@@ -33,35 +33,35 @@ const LANG_DISPLAY_NAMES: Record<string, string> = {
ko: 'Korean', ko: 'Korean',
}; };
export function normalizeAnimetoshoLangCode(code: string): string { export function normalizeTsukihimeLangCode(code: string): string {
const base = code.trim().toLowerCase().split(/[-_]/)[0] ?? ''; const base = code.trim().toLowerCase().split(/[-_]/)[0] ?? '';
return LANG_FILENAME_SUFFIXES[base] ?? base; return LANG_FILENAME_SUFFIXES[base] ?? base;
} }
export function animetoshoLangToFilenameSuffix(lang: string | undefined): string { export function tsukihimeLangToFilenameSuffix(lang: string | undefined): string {
const normalized = normalizeAnimetoshoLangCode(lang ?? ''); const normalized = normalizeTsukihimeLangCode(lang ?? '');
if (!normalized || normalized === 'und') return 'en'; if (!normalized || normalized === 'und') return 'en';
return normalized; return normalized;
} }
export function animetoshoTrackMatchesLanguages( export function tsukihimeTrackMatchesLanguages(
trackLang: string, trackLang: string,
configuredLanguages: string[], configuredLanguages: string[],
): boolean { ): boolean {
const normalizedTrack = normalizeAnimetoshoLangCode(trackLang); const normalizedTrack = normalizeTsukihimeLangCode(trackLang);
// Unlabeled tracks cannot be classified; keep them visible rather than // Unlabeled tracks cannot be classified; keep them visible rather than
// hiding them behind a tab that never shows them. // hiding them behind a tab that never shows them.
if (!normalizedTrack || normalizedTrack === 'und') return true; if (!normalizedTrack || normalizedTrack === 'und') return true;
return configuredLanguages.some( return configuredLanguages.some(
(candidate) => normalizeAnimetoshoLangCode(candidate) === normalizedTrack, (candidate) => normalizeTsukihimeLangCode(candidate) === normalizedTrack,
); );
} }
export function describeAnimetoshoTabLanguages(configuredLanguages: string[]): string { export function describeTsukihimeTabLanguages(configuredLanguages: string[]): string {
const normalized = [ const normalized = [
...new Set( ...new Set(
configuredLanguages configuredLanguages
.map((candidate) => normalizeAnimetoshoLangCode(candidate)) .map((candidate) => normalizeTsukihimeLangCode(candidate))
.filter(Boolean), .filter(Boolean),
), ),
]; ];
+349
View File
@@ -0,0 +1,349 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { execFileSync } from 'node:child_process';
import {
tsukihimeLangToFilenameSuffix,
buildTsukihimeAttachmentUrl,
decompressXzFile,
extractTsukihimeSubtitleFiles,
isTsukihimeDownloadUrl,
mapTsukihimeSearchResults,
} from './utils.js';
test('buildTsukihimeAttachmentUrl pads the attachment id to 8 hex digits', () => {
assert.equal(
buildTsukihimeAttachmentUrl(96412),
'https://storage.tsukihime.org/attach/0001789c/96412.xz',
);
});
test('buildTsukihimeAttachmentUrl uses the tosho mirror path for imported entries', () => {
assert.equal(
buildTsukihimeAttachmentUrl(2826332, { imported: true }),
'https://storage.tsukihime.org/tosho/attach/002b205c/2826332.xz',
);
});
test('tsukihimeLangToFilenameSuffix maps common ISO 639-2 codes to two-letter suffixes', () => {
assert.equal(tsukihimeLangToFilenameSuffix('eng'), 'en');
assert.equal(tsukihimeLangToFilenameSuffix('jpn'), 'ja');
assert.equal(tsukihimeLangToFilenameSuffix('ger'), 'de');
assert.equal(tsukihimeLangToFilenameSuffix('spa'), 'es');
assert.equal(tsukihimeLangToFilenameSuffix('POR'), 'pt');
});
test('tsukihimeLangToFilenameSuffix handles TsukiHime BCP-47 style codes', () => {
assert.equal(tsukihimeLangToFilenameSuffix('en-US'), 'en');
assert.equal(tsukihimeLangToFilenameSuffix('es-419'), 'es');
assert.equal(tsukihimeLangToFilenameSuffix('zh-Hant'), 'zh');
assert.equal(tsukihimeLangToFilenameSuffix('ja'), 'ja');
});
test('tsukihimeLangToFilenameSuffix falls back to the raw code, and to en when unknown', () => {
assert.equal(tsukihimeLangToFilenameSuffix('vie'), 'vie');
assert.equal(tsukihimeLangToFilenameSuffix(''), 'en');
assert.equal(tsukihimeLangToFilenameSuffix(undefined), 'en');
assert.equal(tsukihimeLangToFilenameSuffix('und'), 'en');
});
test('buildTsukihimeAttachmentUrl rejects non-positive and non-integer ids', () => {
assert.equal(buildTsukihimeAttachmentUrl(0), null);
assert.equal(buildTsukihimeAttachmentUrl(-5), null);
assert.equal(buildTsukihimeAttachmentUrl(1.5), null);
assert.equal(buildTsukihimeAttachmentUrl(Number.NaN), null);
});
test('isTsukihimeDownloadUrl accepts tsukihime.org and animetosho.org hosts only', () => {
assert.equal(isTsukihimeDownloadUrl('https://storage.tsukihime.org/attach/0001789c/1.xz'), true);
assert.equal(isTsukihimeDownloadUrl('https://tsukihime.org/view/1'), true);
// The tosho mirror currently 302s to storage.animetosho.org; the hop must stay allowed.
assert.equal(
isTsukihimeDownloadUrl('https://storage.animetosho.org/attach/002b205c/2826332.xz'),
true,
);
assert.equal(isTsukihimeDownloadUrl('http://storage.tsukihime.org/attach/1/1.xz'), false);
assert.equal(isTsukihimeDownloadUrl('https://eviltsukihime.org/attach/1/1.xz'), false);
assert.equal(isTsukihimeDownloadUrl('https://example.com/1.xz'), false);
assert.equal(isTsukihimeDownloadUrl('not a url'), false);
});
test('mapTsukihimeSearchResults maps valid results and caps to maxResults', () => {
const payload = {
total: 4,
start: 0,
limit: 50,
error: false,
results: [
{
id: 1000752022,
state: 'completed',
name: '[SubsPlease] Sousou no Frieren S2 - 10 (1080p) [7D35515E].mkv',
totalsize: 1457009569,
filecount: 1,
sublangs: ['en'],
audiolangs: ['ja'],
source_date: 1774623761,
added_date: 1774624861,
animetosho: true,
},
{ id: 'bogus', name: 'missing numeric id' },
{ id: 12255, name: '[DKB] Futsutsuka na Akujo - S01E01 [Multi-Subs]' },
{ id: 12256, name: 'capped away' },
],
};
const entries = mapTsukihimeSearchResults(payload, 2);
assert.equal(entries.length, 2);
assert.deepEqual(entries[0], {
id: 1000752022,
title: '[SubsPlease] Sousou no Frieren S2 - 10 (1080p) [7D35515E].mkv',
timestamp: 1774624861,
totalSize: 1457009569,
numFiles: 1,
sublangs: ['en'],
});
assert.equal(entries[1]!.id, 12255);
assert.equal(entries[1]!.totalSize, null);
assert.equal(entries[1]!.numFiles, null);
assert.deepEqual(entries[1]!.sublangs, []);
});
test('mapTsukihimeSearchResults returns empty list for malformed payloads', () => {
assert.deepEqual(mapTsukihimeSearchResults({ error: 'nope' }, 10), []);
assert.deepEqual(mapTsukihimeSearchResults({ results: 'nope' }, 10), []);
assert.deepEqual(mapTsukihimeSearchResults(null, 10), []);
assert.deepEqual(mapTsukihimeSearchResults([], 10), []);
});
// Trimmed from a real /v1/torrents/{id} response (native entry).
const DETAIL_PAYLOAD = {
id: 12255,
name: '[DKB] Futsutsuka na Akujo dewa Gozaimasu ga - S01E01 [Multi-Subs]',
files: [
{
id: 16179,
filename: '[DKB] Futsutsuka na Akujo dewa Gozaimasu ga - S01E01 [Multi-Subs].mkv',
attachments: [
{
id: 96400,
type: 0,
info: { name: 'arial.ttf', mime: 'application/x-truetype-font' },
},
{
id: 96412,
type: 1,
info: { codec: 'ASS', lang: 'en-US', name: 'English subs', trackid: 2, tracknum: 3 },
},
{ id: 96499, type: 3 },
],
},
],
};
test('extractTsukihimeSubtitleFiles keeps only text subtitle attachments with download urls', () => {
const files = extractTsukihimeSubtitleFiles(DETAIL_PAYLOAD);
assert.equal(files.length, 1);
const file = files[0]!;
assert.equal(file.attachmentId, 96412);
assert.equal(file.lang, 'en-us');
assert.equal(file.trackName, 'English subs');
assert.equal(file.size, 0);
assert.equal(file.url, 'https://storage.tsukihime.org/attach/0001789c/96412.xz');
assert.equal(
file.sourceFilename,
'[DKB] Futsutsuka na Akujo dewa Gozaimasu ga - S01E01 [Multi-Subs].mkv',
);
assert.equal(
file.filename,
'[DKB] Futsutsuka na Akujo dewa Gozaimasu ga - S01E01 [Multi-Subs].en-us.ass',
);
});
test('extractTsukihimeSubtitleFiles uses the tosho mirror for animetosho-imported entries', () => {
const files = extractTsukihimeSubtitleFiles({
id: 1000752022,
animetosho: true,
files: [
{
id: 1001361385,
filename: '[SubsPlease] Sousou no Frieren S2 - 10 (1080p) [7D35515E].mkv',
attachments: [
{ id: 2826332, type: 1, info: { codec: 'ASS', lang: 'en', name: 'English subs' } },
],
},
],
});
assert.equal(files.length, 1);
assert.equal(files[0]!.url, 'https://storage.tsukihime.org/tosho/attach/002b205c/2826332.xz');
});
test('extractTsukihimeSubtitleFiles detects imported entries by id when the flag is absent', () => {
const files = extractTsukihimeSubtitleFiles({
id: 1000752022,
files: [
{
id: 1001361385,
filename: 'episode.mkv',
attachments: [{ id: 2826332, type: 1, info: { codec: 'ASS', lang: 'en' } }],
},
],
});
assert.equal(files[0]!.url, 'https://storage.tsukihime.org/tosho/attach/002b205c/2826332.xz');
});
test('extractTsukihimeSubtitleFiles skips image-based subtitle codecs', () => {
const files = extractTsukihimeSubtitleFiles({
id: 1,
files: [
{
id: 1,
filename: 'movie.mkv',
attachments: [
{ id: 10, type: 1, info: { codec: 'PGS', lang: 'en' } },
{ id: 11, type: 1, info: { codec: 'VobSub', lang: 'en' } },
{ id: 12, type: 1, info: { codec: 'SRT', lang: 'en' } },
],
},
],
});
assert.deepEqual(
files.map((f) => f.attachmentId),
[12],
);
assert.equal(files[0]!.filename, 'movie.en.srt');
});
test('extractTsukihimeSubtitleFiles sorts English tracks first and disambiguates duplicates', () => {
const files = extractTsukihimeSubtitleFiles({
id: 1,
files: [
{
id: 1,
filename: 'episode.mkv',
attachments: [
{ id: 21, type: 1, info: { codec: 'ASS', lang: 'de', name: 'Deutsch' } },
{ id: 22, type: 1, info: { codec: 'ASS', lang: 'en', name: 'Signs & Songs' } },
{ id: 23, type: 1, info: { codec: 'ASS', lang: 'en', name: 'Full Subtitles' } },
],
},
],
});
assert.deepEqual(
files.map((f) => f.attachmentId),
[22, 23, 21],
);
assert.equal(files[0]!.filename, 'episode.en.signs-songs.ass');
assert.equal(files[1]!.filename, 'episode.en.full-subtitles.ass');
assert.equal(files[2]!.filename, 'episode.de.ass');
});
test('extractTsukihimeSubtitleFiles tolerates missing info fields', () => {
const files = extractTsukihimeSubtitleFiles({
id: 1,
files: [
{
id: 1,
attachments: [{ id: 31, type: 1, info: { codec: 'ASS' } }],
},
],
});
assert.equal(files.length, 1);
assert.equal(files[0]!.lang, '');
assert.equal(files[0]!.trackName, null);
assert.equal(files[0]!.filename, 'subtitle.ass');
});
const hasXz = (() => {
try {
execFileSync('xz', ['--version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
})();
test('decompressXzFile round-trips an xz-compressed subtitle', { skip: !hasXz }, async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-tsukihime-test-'));
try {
const plainPath = path.join(dir, 'sub.ass');
const content = '[Script Info]\nTitle: test\n';
fs.writeFileSync(plainPath, content, 'utf8');
execFileSync('xz', ['-z', plainPath]);
const destPath = path.join(dir, 'out.ass');
const result = await decompressXzFile(`${plainPath}.xz`, destPath);
assert.equal(result.ok, true);
if (result.ok) {
assert.equal(result.path, destPath);
}
assert.equal(fs.readFileSync(destPath, 'utf8'), content);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('decompressXzFile reports an error for corrupt input', { skip: !hasXz }, async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-tsukihime-test-'));
try {
const srcPath = path.join(dir, 'broken.xz');
fs.writeFileSync(srcPath, 'not xz data');
const result = await decompressXzFile(srcPath, path.join(dir, 'out.ass'));
assert.equal(result.ok, false);
if (!result.ok) {
assert.match(result.error.error, /xz|decompress/i);
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('extractTsukihimeSubtitleFiles disambiguates duplicate tracks without slug characters', () => {
const files = extractTsukihimeSubtitleFiles({
id: 1,
files: [
{
id: 1,
filename: 'episode.mkv',
// Both names slugify to an empty string.
attachments: [
{ id: 41, type: 1, info: { codec: 'ASS', lang: 'ja', name: '日本語字幕' } },
{ id: 42, type: 1, info: { codec: 'ASS', lang: 'ja' } },
],
},
],
});
assert.equal(files.length, 2);
for (const file of files) {
assert.doesNotMatch(file.filename, /\.\./, `double dot in ${file.filename}`);
}
assert.notEqual(files[0]!.filename, files[1]!.filename);
assert.equal(files.find((f) => f.attachmentId === 41)!.filename, 'episode.ja.41.ass');
assert.equal(files.find((f) => f.attachmentId === 42)!.filename, 'episode.ja.42.ass');
});
test('extractTsukihimeSubtitleFiles disambiguates duplicate identical track names', () => {
const files = extractTsukihimeSubtitleFiles({
id: 1,
files: [
{
id: 1,
filename: 'episode.mkv',
attachments: [
{ id: 51, type: 1, info: { codec: 'ASS', lang: 'ja', name: 'Signs' } },
{ id: 52, type: 1, info: { codec: 'ASS', lang: 'ja', name: 'Signs' } },
],
},
],
});
assert.deepEqual(
files.map((file) => file.filename),
['episode.ja.51.ass', 'episode.ja.52.ass'],
);
});
Binary file not shown.
+12 -5
View File
@@ -10,7 +10,7 @@ import type {
ImmersionTrackingConfig, ImmersionTrackingConfig,
ImmersionTrackingRetentionMode, ImmersionTrackingRetentionMode,
ImmersionTrackingRetentionPreset, ImmersionTrackingRetentionPreset,
AnimetoshoConfig, TsukihimeConfig,
JellyfinConfig, JellyfinConfig,
JimakuConfig, JimakuConfig,
JimakuLanguagePreference, JimakuLanguagePreference,
@@ -123,7 +123,7 @@ export interface ShortcutsConfig {
openCharacterDictionaryManager?: string | null; openCharacterDictionaryManager?: string | null;
openRuntimeOptions?: string | null; openRuntimeOptions?: string | null;
openJimaku?: string | null; openJimaku?: string | null;
openAnimetosho?: string | null; openTsukihime?: string | null;
openSessionHelp?: string | null; openSessionHelp?: string | null;
openControllerSelect?: string | null; openControllerSelect?: string | null;
openControllerDebug?: string | null; openControllerDebug?: string | null;
@@ -132,6 +132,11 @@ export interface ShortcutsConfig {
appendClipboardVideoToQueue?: string | null; appendClipboardVideoToQueue?: string | null;
} }
export interface RawShortcutsConfig extends ShortcutsConfig {
/** @deprecated Use openTsukihime. */
openAnimetosho?: string | null;
}
export interface Config { export interface Config {
subtitlePosition?: SubtitlePosition; subtitlePosition?: SubtitlePosition;
keybindings?: Keybinding[]; keybindings?: Keybinding[];
@@ -141,7 +146,7 @@ export interface Config {
mpv?: MpvConfig; mpv?: MpvConfig;
controller?: ControllerConfig; controller?: ControllerConfig;
ankiConnect?: AnkiConnectConfig; ankiConnect?: AnkiConnectConfig;
shortcuts?: ShortcutsConfig; shortcuts?: RawShortcutsConfig;
secondarySub?: SecondarySubConfig; secondarySub?: SecondarySubConfig;
subsync?: SubsyncConfig; subsync?: SubsyncConfig;
startupWarmups?: StartupWarmupsConfig; startupWarmups?: StartupWarmupsConfig;
@@ -149,7 +154,9 @@ export interface Config {
subtitleSidebar?: SubtitleSidebarConfig; subtitleSidebar?: SubtitleSidebarConfig;
auto_start_overlay?: boolean; auto_start_overlay?: boolean;
jimaku?: JimakuConfig; jimaku?: JimakuConfig;
animetosho?: AnimetoshoConfig; /** @deprecated Use tsukihime. */
animetosho?: TsukihimeConfig;
tsukihime?: TsukihimeConfig;
anilist?: AnilistConfig; anilist?: AnilistConfig;
yomitan?: YomitanConfig; yomitan?: YomitanConfig;
jellyfin?: JellyfinConfig; jellyfin?: JellyfinConfig;
@@ -306,7 +313,7 @@ export interface ResolvedConfig {
languagePreference: JimakuLanguagePreference; languagePreference: JimakuLanguagePreference;
maxEntryResults: number; maxEntryResults: number;
}; };
animetosho: AnimetoshoConfig & { tsukihime: TsukihimeConfig & {
apiBaseUrl: string; apiBaseUrl: string;
maxSearchResults: number; maxSearchResults: number;
}; };
+9 -8
View File
@@ -241,23 +241,24 @@ export type JimakuDownloadResult =
| { ok: true; path: string } | { ok: true; path: string }
| { ok: false; error: JimakuApiError }; | { ok: false; error: JimakuApiError };
export interface AnimetoshoSearchQuery { export interface TsukihimeSearchQuery {
query: string; query: string;
} }
export interface AnimetoshoEntry { export interface TsukihimeEntry {
id: number; id: number;
title: string; title: string;
timestamp: number | null; timestamp: number | null;
totalSize: number | null; totalSize: number | null;
numFiles: number | null; numFiles: number | null;
sublangs: string[];
} }
export interface AnimetoshoFilesQuery { export interface TsukihimeFilesQuery {
entryId: number; entryId: number;
} }
export interface AnimetoshoSubtitleFile { export interface TsukihimeSubtitleFile {
attachmentId: number; attachmentId: number;
filename: string; filename: string;
lang: string; lang: string;
@@ -267,18 +268,18 @@ export interface AnimetoshoSubtitleFile {
sourceFilename: string; sourceFilename: string;
} }
export interface AnimetoshoDownloadQuery { export interface TsukihimeDownloadQuery {
entryId: number; entryId: number;
url: string; url: string;
name: string; name: string;
lang?: string; lang?: string;
} }
export type AnimetoshoApiResponse<T> = JimakuApiResponse<T>; export type TsukihimeApiResponse<T> = JimakuApiResponse<T>;
export type AnimetoshoDownloadResult = JimakuDownloadResult; export type TsukihimeDownloadResult = JimakuDownloadResult;
export interface AnimetoshoConfig { export interface TsukihimeConfig {
apiBaseUrl?: string; apiBaseUrl?: string;
maxSearchResults?: number; maxSearchResults?: number;
} }
+18 -18
View File
@@ -12,13 +12,13 @@ import type {
SessionBindingWarning, SessionBindingWarning,
} from './session-bindings'; } from './session-bindings';
import type { import type {
AnimetoshoApiResponse, TsukihimeApiResponse,
AnimetoshoDownloadQuery, TsukihimeDownloadQuery,
AnimetoshoDownloadResult, TsukihimeDownloadResult,
AnimetoshoEntry, TsukihimeEntry,
AnimetoshoFilesQuery, TsukihimeFilesQuery,
AnimetoshoSearchQuery, TsukihimeSearchQuery,
AnimetoshoSubtitleFile, TsukihimeSubtitleFile,
JimakuApiResponse, JimakuApiResponse,
JimakuDownloadQuery, JimakuDownloadQuery,
JimakuDownloadResult, JimakuDownloadResult,
@@ -461,14 +461,14 @@ export interface ElectronAPI {
jimakuSearchEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>; jimakuSearchEntries: (query: JimakuSearchQuery) => Promise<JimakuApiResponse<JimakuEntry[]>>;
jimakuListFiles: (query: JimakuFilesQuery) => Promise<JimakuApiResponse<JimakuFileEntry[]>>; jimakuListFiles: (query: JimakuFilesQuery) => Promise<JimakuApiResponse<JimakuFileEntry[]>>;
jimakuDownloadFile: (query: JimakuDownloadQuery) => Promise<JimakuDownloadResult>; jimakuDownloadFile: (query: JimakuDownloadQuery) => Promise<JimakuDownloadResult>;
animetoshoSearchEntries: ( tsukihimeSearchEntries: (
query: AnimetoshoSearchQuery, query: TsukihimeSearchQuery,
) => Promise<AnimetoshoApiResponse<AnimetoshoEntry[]>>; ) => Promise<TsukihimeApiResponse<TsukihimeEntry[]>>;
animetoshoListFiles: ( tsukihimeListFiles: (
query: AnimetoshoFilesQuery, query: TsukihimeFilesQuery,
) => Promise<AnimetoshoApiResponse<AnimetoshoSubtitleFile[]>>; ) => Promise<TsukihimeApiResponse<TsukihimeSubtitleFile[]>>;
animetoshoDownloadFile: (query: AnimetoshoDownloadQuery) => Promise<AnimetoshoDownloadResult>; tsukihimeDownloadFile: (query: TsukihimeDownloadQuery) => Promise<TsukihimeDownloadResult>;
animetoshoGetSecondaryLanguages: () => Promise<string[]>; tsukihimeGetSecondaryLanguages: () => Promise<string[]>;
quitApp: () => void; quitApp: () => void;
toggleDevTools: () => void; toggleDevTools: () => void;
toggleOverlay: () => void; toggleOverlay: () => void;
@@ -501,7 +501,7 @@ export interface ElectronAPI {
onOpenControllerSelect: (callback: () => void) => void; onOpenControllerSelect: (callback: () => void) => void;
onOpenControllerDebug: (callback: () => void) => void; onOpenControllerDebug: (callback: () => void) => void;
onOpenJimaku: (callback: () => void) => void; onOpenJimaku: (callback: () => void) => void;
onOpenAnimetosho: (callback: () => void) => void; onOpenTsukihime: (callback: () => void) => void;
onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void; onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void;
onOpenPlaylistBrowser: (callback: () => void) => void; onOpenPlaylistBrowser: (callback: () => void) => void;
onOpenCharacterDictionaryManager: (callback: () => void) => void; onOpenCharacterDictionaryManager: (callback: () => void) => void;
@@ -546,7 +546,7 @@ export interface ElectronAPI {
| 'runtime-options' | 'runtime-options'
| 'subsync' | 'subsync'
| 'jimaku' | 'jimaku'
| 'animetosho' | 'tsukihime'
| 'youtube-track-picker' | 'youtube-track-picker'
| 'playlist-browser' | 'playlist-browser'
| 'kiku' | 'kiku'
@@ -561,7 +561,7 @@ export interface ElectronAPI {
| 'runtime-options' | 'runtime-options'
| 'subsync' | 'subsync'
| 'jimaku' | 'jimaku'
| 'animetosho' | 'tsukihime'
| 'youtube-track-picker' | 'youtube-track-picker'
| 'playlist-browser' | 'playlist-browser'
| 'kiku' | 'kiku'
+1 -1
View File
@@ -22,7 +22,7 @@ export type SessionActionId =
| 'openControllerSelect' | 'openControllerSelect'
| 'openControllerDebug' | 'openControllerDebug'
| 'openJimaku' | 'openJimaku'
| 'openAnimetosho' | 'openTsukihime'
| 'openYoutubePicker' | 'openYoutubePicker'
| 'openPlaylistBrowser' | 'openPlaylistBrowser'
| 'replayCurrentSubtitle' | 'replayCurrentSubtitle'