feat(animetosho): add English/Japanese subtitle download integration (#159)

This commit is contained in:
2026-07-12 00:48:12 -07:00
committed by GitHub
parent 6ab3d823a4
commit 4b7f750919
80 changed files with 2647 additions and 13 deletions
+4
View File
@@ -0,0 +1,4 @@
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.
+11
View File
@@ -205,6 +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).
"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.
@@ -612,6 +613,16 @@
"maxEntryResults": 10 // Maximum Jimaku search results returned. "maxEntryResults": 10 // Maximum Jimaku search results returned.
}, // Jimaku API configuration and defaults. }, // Jimaku API configuration and defaults.
// ==========================================
// Animetosho
// Animetosho subtitle search configuration (English and Japanese). No API key required.
// Hot-reload: Animetosho changes apply to the next Animetosho request.
// ==========================================
"animetosho": {
"apiBaseUrl": "https://feed.animetosho.org", // Base URL of the Animetosho JSON feed API. No API key required.
"maxSearchResults": 10 // Maximum Animetosho search results returned.
}, // Animetosho subtitle search configuration (English and Japanese). No API key required.
// ========================================== // ==========================================
// YouTube Playback Settings // YouTube Playback Settings
// Defaults for managed subtitle language preferences and YouTube subtitle loading. // Defaults for managed subtitle language preferences and YouTube subtitle loading.
+1
View File
@@ -327,6 +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: '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
@@ -0,0 +1,73 @@
# 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.
+11
View File
@@ -205,6 +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).
"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.
@@ -612,6 +613,16 @@
"maxEntryResults": 10 // Maximum Jimaku search results returned. "maxEntryResults": 10 // Maximum Jimaku search results returned.
}, // Jimaku API configuration and defaults. }, // Jimaku API configuration and defaults.
// ==========================================
// Animetosho
// Animetosho subtitle search configuration (English and Japanese). No API key required.
// Hot-reload: Animetosho changes apply to the next Animetosho request.
// ==========================================
"animetosho": {
"apiBaseUrl": "https://feed.animetosho.org", // Base URL of the Animetosho JSON feed API. No API key required.
"maxSearchResults": 10 // Maximum Animetosho search results returned.
}, // Animetosho subtitle search configuration (English and Japanese). No API key required.
// ========================================== // ==========================================
// YouTube Playback Settings // YouTube Playback Settings
// Defaults for managed subtitle language preferences and YouTube subtitle loading. // Defaults for managed subtitle language preferences and YouTube subtitle loading.
+1
View File
@@ -82,6 +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/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` |
+2
View File
@@ -254,6 +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
return { "--open-animetosho" }
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
+119
View File
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as http from 'node:http';
import type { AddressInfo } from 'node:net';
import { animetoshoFetchJson } from './utils.js';
interface TestServer {
baseUrl: string;
close: () => Promise<void>;
}
function startServer(handler: http.RequestListener): Promise<TestServer> {
return new Promise((resolve) => {
const server = http.createServer(handler);
server.listen(0, '127.0.0.1', () => {
const { port } = server.address() as AddressInfo;
resolve({
baseUrl: `http://127.0.0.1:${port}`,
close: () =>
new Promise((done) => {
server.closeAllConnections?.();
server.close(() => done());
}),
});
});
});
}
test('animetoshoFetchJson gives up on a hanging response', async () => {
const server = await startServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
// Never end the response.
res.write('[');
});
try {
const result = await animetoshoFetchJson(
'/json',
{},
{ baseUrl: server.baseUrl, timeoutMs: 150 },
);
assert.equal(result.ok, false);
if (!result.ok) {
assert.match(result.error.error, /timed out/i);
}
} finally {
await server.close();
}
});
test('animetoshoFetchJson rejects an oversized response', async () => {
const server = await startServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(Array.from({ length: 2000 }, (_, index) => ({ id: index }))));
});
try {
const result = await animetoshoFetchJson(
'/json',
{},
{ baseUrl: server.baseUrl, maxResponseBytes: 512 },
);
assert.equal(result.ok, false);
if (!result.ok) {
assert.match(result.error.error, /too large/i);
}
} finally {
await server.close();
}
});
test('animetoshoFetchJson decodes multi-byte characters split across chunks', async () => {
const title = '葬送のフリーレン';
const body = Buffer.from(JSON.stringify([{ id: 1, title }]), 'utf8');
// Split inside the first Japanese character's 3-byte UTF-8 sequence.
const splitAt = body.indexOf(Buffer.from(title, 'utf8')) + 1;
const server = await startServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.write(body.subarray(0, splitAt));
setTimeout(() => res.end(body.subarray(splitAt)), 10);
});
try {
const result = await animetoshoFetchJson<Array<{ id: number; title: string }>>(
'/json',
{},
{ baseUrl: server.baseUrl },
);
assert.equal(result.ok, true);
if (result.ok) {
assert.equal(result.data[0]!.title, title);
}
} finally {
await server.close();
}
});
test('animetoshoFetchJson still parses a normal response', async () => {
const server = await startServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify([{ id: 1, title: 'ok' }]));
});
try {
const result = await animetoshoFetchJson<Array<{ id: number }>>(
'/json',
{ q: 'x' },
{ baseUrl: server.baseUrl },
);
assert.equal(result.ok, true);
if (result.ok) {
assert.deepEqual(result.data, [{ id: 1, title: 'ok' }]);
}
} finally {
await server.close();
}
});
+45
View File
@@ -0,0 +1,45 @@
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');
});
+70
View File
@@ -0,0 +1,70 @@
// Pure language-code helpers shared between the main-process Animetosho client
// and the overlay renderer. Must stay free of node imports so the renderer
// bundle can use it.
const LANG_FILENAME_SUFFIXES: Record<string, string> = {
eng: 'en',
jpn: 'ja',
ger: 'de',
deu: 'de',
spa: 'es',
por: 'pt',
fre: 'fr',
fra: 'fr',
ita: 'it',
rus: 'ru',
ara: 'ar',
chi: 'zh',
zho: 'zh',
kor: 'ko',
};
const LANG_DISPLAY_NAMES: Record<string, string> = {
en: 'English',
ja: 'Japanese',
de: 'German',
es: 'Spanish',
pt: 'Portuguese',
fr: 'French',
it: 'Italian',
ru: 'Russian',
ar: 'Arabic',
zh: 'Chinese',
ko: 'Korean',
};
export function normalizeAnimetoshoLangCode(code: string): string {
const base = code.trim().toLowerCase().split(/[-_]/)[0] ?? '';
return LANG_FILENAME_SUFFIXES[base] ?? base;
}
export function animetoshoLangToFilenameSuffix(lang: string | undefined): string {
const normalized = normalizeAnimetoshoLangCode(lang ?? '');
if (!normalized || normalized === 'und') return 'en';
return normalized;
}
export function animetoshoTrackMatchesLanguages(
trackLang: string,
configuredLanguages: string[],
): boolean {
const normalizedTrack = normalizeAnimetoshoLangCode(trackLang);
// Unlabeled tracks cannot be classified; keep them visible rather than
// hiding them behind a tab that never shows them.
if (!normalizedTrack || normalizedTrack === 'und') return true;
return configuredLanguages.some(
(candidate) => normalizeAnimetoshoLangCode(candidate) === normalizedTrack,
);
}
export function describeAnimetoshoTabLanguages(configuredLanguages: string[]): string {
const normalized = [
...new Set(
configuredLanguages
.map((candidate) => normalizeAnimetoshoLangCode(candidate))
.filter(Boolean),
),
];
if (normalized.length === 0) return 'English';
return normalized.map((code) => LANG_DISPLAY_NAMES[code] ?? code.toUpperCase()).join(' / ');
}
+233
View File
@@ -0,0 +1,233 @@
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 });
}
});
Binary file not shown.
+2
View File
@@ -115,6 +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-youtube-picker', '--open-youtube-picker',
'--open-playlist-browser', '--open-playlist-browser',
'--toggle-primary-subtitle-bar', '--toggle-primary-subtitle-bar',
@@ -132,6 +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.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);
+8
View File
@@ -37,6 +37,7 @@ export interface CliArgs {
openControllerSelect: boolean; openControllerSelect: boolean;
openControllerDebug: boolean; openControllerDebug: boolean;
openJimaku: boolean; openJimaku: boolean;
openAnimetosho: boolean;
openYoutubePicker: boolean; openYoutubePicker: boolean;
openPlaylistBrowser: boolean; openPlaylistBrowser: boolean;
replayCurrentSubtitle: boolean; replayCurrentSubtitle: boolean;
@@ -145,6 +146,7 @@ export function parseArgs(argv: string[]): CliArgs {
openControllerSelect: false, openControllerSelect: false,
openControllerDebug: false, openControllerDebug: false,
openJimaku: false, openJimaku: false,
openAnimetosho: false,
openYoutubePicker: false, openYoutubePicker: false,
openPlaylistBrowser: false, openPlaylistBrowser: false,
replayCurrentSubtitle: false, replayCurrentSubtitle: false,
@@ -292,6 +294,7 @@ 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-youtube-picker') args.openYoutubePicker = 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;
@@ -564,6 +567,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.openControllerSelect || args.openControllerSelect ||
args.openControllerDebug || args.openControllerDebug ||
args.openJimaku || args.openJimaku ||
args.openAnimetosho ||
args.openYoutubePicker || args.openYoutubePicker ||
args.openPlaylistBrowser || args.openPlaylistBrowser ||
args.replayCurrentSubtitle || args.replayCurrentSubtitle ||
@@ -641,6 +645,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.openControllerSelect && !args.openControllerSelect &&
!args.openControllerDebug && !args.openControllerDebug &&
!args.openJimaku && !args.openJimaku &&
!args.openAnimetosho &&
!args.openYoutubePicker && !args.openYoutubePicker &&
!args.openPlaylistBrowser && !args.openPlaylistBrowser &&
!args.replayCurrentSubtitle && !args.replayCurrentSubtitle &&
@@ -707,6 +712,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.openControllerSelect || args.openControllerSelect ||
args.openControllerDebug || args.openControllerDebug ||
args.openJimaku || args.openJimaku ||
args.openAnimetosho ||
args.openYoutubePicker || args.openYoutubePicker ||
args.openPlaylistBrowser || args.openPlaylistBrowser ||
args.replayCurrentSubtitle || args.replayCurrentSubtitle ||
@@ -767,6 +773,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
!args.openControllerSelect && !args.openControllerSelect &&
!args.openControllerDebug && !args.openControllerDebug &&
!args.openJimaku && !args.openJimaku &&
!args.openAnimetosho &&
!args.openYoutubePicker && !args.openYoutubePicker &&
!args.openPlaylistBrowser && !args.openPlaylistBrowser &&
!args.replayCurrentSubtitle && !args.replayCurrentSubtitle &&
@@ -832,6 +839,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.openControllerSelect || args.openControllerSelect ||
args.openControllerDebug || args.openControllerDebug ||
args.openJimaku || args.openJimaku ||
args.openAnimetosho ||
args.openYoutubePicker || args.openYoutubePicker ||
args.openPlaylistBrowser || args.openPlaylistBrowser ||
args.replayCurrentSubtitle || args.replayCurrentSubtitle ||
+13 -2
View File
@@ -37,8 +37,18 @@ const {
notifications, notifications,
auto_start_overlay, auto_start_overlay,
} = CORE_DEFAULT_CONFIG; } = CORE_DEFAULT_CONFIG;
const { ankiConnect, jimaku, anilist, mpv, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } = const {
INTEGRATIONS_DEFAULT_CONFIG; ankiConnect,
jimaku,
animetosho,
anilist,
mpv,
yomitan,
jellyfin,
discordPresence,
ai,
youtubeSubgen,
} = INTEGRATIONS_DEFAULT_CONFIG;
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG; const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG; const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
const { stats } = STATS_DEFAULT_CONFIG; const { stats } = STATS_DEFAULT_CONFIG;
@@ -63,6 +73,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
subtitleSidebar, subtitleSidebar,
auto_start_overlay, auto_start_overlay,
jimaku, jimaku,
animetosho,
anilist, anilist,
mpv, mpv,
yomitan, yomitan,
+1
View File
@@ -98,6 +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',
openSessionHelp: 'CommandOrControl+Slash', openSessionHelp: 'CommandOrControl+Slash',
openControllerSelect: 'Alt+C', openControllerSelect: 'Alt+C',
openControllerDebug: 'Alt+Shift+C', openControllerDebug: 'Alt+Shift+C',
@@ -5,6 +5,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
ResolvedConfig, ResolvedConfig,
| 'ankiConnect' | 'ankiConnect'
| 'jimaku' | 'jimaku'
| 'animetosho'
| 'anilist' | 'anilist'
| 'mpv' | 'mpv'
| 'yomitan' | 'yomitan'
@@ -96,6 +97,10 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
languagePreference: 'ja', languagePreference: 'ja',
maxEntryResults: 10, maxEntryResults: 10,
}, },
animetosho: {
apiBaseUrl: 'https://feed.animetosho.org',
maxSearchResults: 10,
},
mpv: { mpv: {
executablePath: '', executablePath: '',
launchMode: 'normal', launchMode: 'normal',
+7
View File
@@ -615,6 +615,13 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.shortcuts.openJimaku, defaultValue: defaultConfig.shortcuts.openJimaku,
description: 'Accelerator that opens the Jimaku subtitle search modal.', description: 'Accelerator that opens the Jimaku subtitle search modal.',
}, },
{
path: 'shortcuts.openAnimetosho',
kind: 'string',
defaultValue: defaultConfig.shortcuts.openAnimetosho,
description:
'Accelerator that opens the Animetosho subtitle search modal (English/Japanese tabs).',
},
{ {
path: 'shortcuts.openSessionHelp', path: 'shortcuts.openSessionHelp',
kind: 'string', kind: 'string',
@@ -400,6 +400,18 @@ export function buildIntegrationConfigOptionRegistry(
defaultValue: defaultConfig.jimaku.maxEntryResults, defaultValue: defaultConfig.jimaku.maxEntryResults,
description: 'Maximum Jimaku search results returned.', description: 'Maximum Jimaku search results returned.',
}, },
{
path: 'animetosho.apiBaseUrl',
kind: 'string',
defaultValue: defaultConfig.animetosho.apiBaseUrl,
description: 'Base URL of the Animetosho JSON feed API. No API key required.',
},
{
path: 'animetosho.maxSearchResults',
kind: 'number',
defaultValue: defaultConfig.animetosho.maxSearchResults,
description: 'Maximum Animetosho search results returned.',
},
{ {
path: 'anilist.enabled', path: 'anilist.enabled',
kind: 'boolean', kind: 'boolean',
+1
View File
@@ -53,6 +53,7 @@ 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',
ANIMETOSHO_OPEN: '__animetosho-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',
@@ -147,6 +147,14 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
notes: ['Hot-reload: Jimaku changes apply to the next Jimaku request.'], notes: ['Hot-reload: Jimaku changes apply to the next Jimaku request.'],
key: 'jimaku', key: 'jimaku',
}, },
{
title: 'Animetosho',
description: [
'Animetosho subtitle search configuration (English and Japanese). No API key required.',
],
notes: ['Hot-reload: Animetosho changes apply to the next Animetosho request.'],
key: 'animetosho',
},
{ {
title: 'YouTube Playback Settings', title: 'YouTube Playback Settings',
description: [ description: [
+1
View File
@@ -236,6 +236,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
'openCharacterDictionaryManager', 'openCharacterDictionaryManager',
'openRuntimeOptions', 'openRuntimeOptions',
'openJimaku', 'openJimaku',
'openAnimetosho',
'openSessionHelp', 'openSessionHelp',
'openControllerSelect', 'openControllerSelect',
'openControllerDebug', 'openControllerDebug',
+17
View File
@@ -80,6 +80,23 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
} }
} }
if (isObject(src.animetosho)) {
const apiBaseUrl = asString(src.animetosho.apiBaseUrl);
if (apiBaseUrl !== undefined) resolved.animetosho.apiBaseUrl = apiBaseUrl;
const maxSearchResults = asNumber(src.animetosho.maxSearchResults);
if (maxSearchResults !== undefined && Math.floor(maxSearchResults) > 0) {
resolved.animetosho.maxSearchResults = Math.floor(maxSearchResults);
} else if (src.animetosho.maxSearchResults !== undefined) {
warn(
'animetosho.maxSearchResults',
src.animetosho.maxSearchResults,
resolved.animetosho.maxSearchResults,
'Expected positive number.',
);
}
}
if (isObject(src.youtubeSubgen)) { if (isObject(src.youtubeSubgen)) {
const whisperBin = asString(src.youtubeSubgen.whisperBin); const whisperBin = asString(src.youtubeSubgen.whisperBin);
if (whisperBin !== undefined) { if (whisperBin !== undefined) {
+1
View File
@@ -594,6 +594,7 @@ function subsectionForPath(path: string): string | undefined {
leaf === 'openCharacterDictionaryManager' || leaf === 'openCharacterDictionaryManager' ||
leaf === 'openRuntimeOptions' || leaf === 'openRuntimeOptions' ||
leaf === 'openJimaku' || leaf === 'openJimaku' ||
leaf === 'openAnimetosho' ||
leaf === 'openSessionHelp' || leaf === 'openSessionHelp' ||
leaf === 'openControllerSelect' || leaf === 'openControllerSelect' ||
leaf === 'openControllerDebug' leaf === 'openControllerDebug'
+113
View File
@@ -55,6 +55,11 @@ 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: [] }),
listAnimetoshoFiles: async () => ({ ok: true, data: [] }),
downloadAnimetoshoSubtitle: async () => ({ ok: true, path: '/tmp/sub.en.ass' }),
getAnimetoshoSecondaryLanguages: () => ['en'],
onDownloadedSecondarySubtitle: () => {},
}, },
registrar, registrar,
); );
@@ -92,6 +97,109 @@ test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => {
ok: false, ok: false,
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);
assert.ok(animetoshoSearchHandler);
const invalidAnimetoshoSearch = await animetoshoSearchHandler!({}, { query: 12 });
assert.deepEqual(invalidAnimetoshoSearch, {
ok: false,
error: { error: 'Invalid Animetosho search query payload', code: 400 },
});
const animetoshoFilesHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoListFiles);
assert.ok(animetoshoFilesHandler);
const invalidAnimetoshoFiles = await animetoshoFilesHandler!({}, { entryId: 'x' });
assert.deepEqual(invalidAnimetoshoFiles, {
ok: false,
error: { error: 'Invalid Animetosho files query payload', code: 400 },
});
const animetoshoDownloadHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoDownloadFile);
assert.ok(animetoshoDownloadHandler);
const invalidAnimetoshoDownload = await animetoshoDownloadHandler!({}, { entryId: 1, url: '/x' });
assert.deepEqual(invalidAnimetoshoDownload, {
ok: false,
error: { error: 'Invalid Animetosho download query payload', code: 400 },
});
const foreignUrlDownload = await animetoshoDownloadHandler!(
{},
{ entryId: 1, url: 'https://evil.example/attach/00000001/1.xz', name: 'sub.ass' },
);
assert.deepEqual(foreignUrlDownload, {
ok: false,
error: { error: 'Refusing to download subtitle from a non-Animetosho URL.', code: 400 },
});
});
test('animetosho downloads route by language: secondary for eng, primary for jpn', async () => {
const { registrar, handleHandlers } = createFakeRegistrar();
const primaryLoads: string[] = [];
const secondaryLoads: string[] = [];
registerAnkiJimakuIpcHandlers(
{
setAnkiConnectEnabled: () => {},
clearAnkiHistory: () => {},
refreshKnownWords: async () => {},
respondFieldGrouping: () => {},
buildKikuMergePreview: async () => ({ ok: true }),
getJimakuMediaInfo: () => ({
title: 'x',
season: null,
episode: null,
confidence: 'high',
filename: 'x.mkv',
rawTitle: 'x',
}),
searchJimakuEntries: async () => ({ ok: true, data: [] }),
listJimakuFiles: async () => ({ ok: true, data: [] }),
resolveJimakuApiKey: async () => 'token',
getCurrentMediaPath: () => '/tmp/a.mkv',
isRemoteMediaPath: () => false,
downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }),
onDownloadedSubtitle: (path) => {
primaryLoads.push(path);
},
searchAnimetoshoEntries: async () => ({ ok: true, data: [] }),
listAnimetoshoFiles: async () => ({ ok: true, data: [] }),
downloadAnimetoshoSubtitle: async (_url, destPath) => ({ ok: true, path: destPath }),
getAnimetoshoSecondaryLanguages: () => ['en'],
onDownloadedSecondarySubtitle: (path) => {
secondaryLoads.push(path);
},
},
registrar,
);
const downloadHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoDownloadFile)!;
const engResult = (await downloadHandler!(
{},
{
entryId: 1,
url: 'https://animetosho.org/storage/attach/00000001/1.xz',
name: 'episode.eng.ass',
lang: 'eng',
},
)) as { ok: boolean };
assert.equal(engResult.ok, true);
assert.equal(primaryLoads.length, 0);
assert.equal(secondaryLoads.length, 1);
assert.match(secondaryLoads[0]!, /\.en.*\.ass$/);
const jpnResult = (await downloadHandler!(
{},
{
entryId: 1,
url: 'https://animetosho.org/storage/attach/00000002/2.xz',
name: 'episode.jpn.ass',
lang: 'jpn',
},
)) as { ok: boolean };
assert.equal(jpnResult.ok, true);
assert.equal(secondaryLoads.length, 1);
assert.equal(primaryLoads.length, 1);
assert.match(primaryLoads[0]!, /\.ja.*\.ass$/);
}); });
test('anki/jimaku IPC command handlers ignore malformed payloads', () => { test('anki/jimaku IPC command handlers ignore malformed payloads', () => {
@@ -124,6 +232,11 @@ 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: [] }),
listAnimetoshoFiles: async () => ({ ok: true, data: [] }),
downloadAnimetoshoSubtitle: async () => ({ ok: true, path: '/tmp/sub.en.ass' }),
getAnimetoshoSecondaryLanguages: () => ['en'],
onDownloadedSecondarySubtitle: () => {},
}, },
registrar, registrar,
); );
+124
View File
@@ -4,6 +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,
AnimetoshoDownloadResult,
AnimetoshoEntry,
AnimetoshoFilesQuery,
AnimetoshoSearchQuery,
AnimetoshoSubtitleFile,
JimakuApiResponse, JimakuApiResponse,
JimakuDownloadResult, JimakuDownloadResult,
JimakuEntry, JimakuEntry,
@@ -17,6 +23,9 @@ import {
} from '../../types'; } from '../../types';
import { IPC_CHANNELS } from '../../shared/ipc/contracts'; import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { import {
parseAnimetoshoDownloadQuery,
parseAnimetoshoFilesQuery,
parseAnimetoshoSearchQuery,
parseJimakuDownloadQuery, parseJimakuDownloadQuery,
parseJimakuFilesQuery, parseJimakuFilesQuery,
parseJimakuSearchQuery, parseJimakuSearchQuery,
@@ -24,6 +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';
const { ipcMain } = electron; const { ipcMain } = electron;
@@ -47,6 +57,15 @@ export interface AnkiJimakuIpcDeps {
headers: Record<string, string>, headers: Record<string, string>,
) => Promise<JimakuDownloadResult>; ) => Promise<JimakuDownloadResult>;
onDownloadedSubtitle: (pathToSubtitle: string) => void; onDownloadedSubtitle: (pathToSubtitle: string) => void;
searchAnimetoshoEntries: (
query: AnimetoshoSearchQuery,
) => Promise<AnimetoshoApiResponse<AnimetoshoEntry[]>>;
listAnimetoshoFiles: (
query: AnimetoshoFilesQuery,
) => Promise<AnimetoshoApiResponse<AnimetoshoSubtitleFile[]>>;
downloadAnimetoshoSubtitle: (url: string, destPath: string) => Promise<AnimetoshoDownloadResult>;
getAnimetoshoSecondaryLanguages: () => string[];
onDownloadedSecondarySubtitle: (pathToSubtitle: string) => void | Promise<void>;
} }
interface IpcMainRegistrar { interface IpcMainRegistrar {
@@ -186,4 +205,109 @@ export function registerAnkiJimakuIpcHandlers(
return result; return result;
}, },
); );
ipc.handle(IPC_CHANNELS.request.animetoshoGetSecondaryLanguages, (): string[] => {
return deps.getAnimetoshoSecondaryLanguages();
});
ipc.handle(
IPC_CHANNELS.request.animetoshoSearchEntries,
async (_event, query: unknown): Promise<AnimetoshoApiResponse<AnimetoshoEntry[]>> => {
const parsedQuery = parseAnimetoshoSearchQuery(query);
if (!parsedQuery) {
return {
ok: false,
error: { error: 'Invalid Animetosho search query payload', code: 400 },
};
}
return deps.searchAnimetoshoEntries(parsedQuery);
},
);
ipc.handle(
IPC_CHANNELS.request.animetoshoListFiles,
async (_event, query: unknown): Promise<AnimetoshoApiResponse<AnimetoshoSubtitleFile[]>> => {
const parsedQuery = parseAnimetoshoFilesQuery(query);
if (!parsedQuery) {
return { ok: false, error: { error: 'Invalid Animetosho files query payload', code: 400 } };
}
return deps.listAnimetoshoFiles(parsedQuery);
},
);
ipc.handle(
IPC_CHANNELS.request.animetoshoDownloadFile,
async (_event, query: unknown): Promise<AnimetoshoDownloadResult> => {
const parsedQuery = parseAnimetoshoDownloadQuery(query);
if (!parsedQuery) {
return {
ok: false,
error: { error: 'Invalid Animetosho download query payload', code: 400 },
};
}
if (!isAnimetoshoDownloadUrl(parsedQuery.url)) {
return {
ok: false,
error: { error: 'Refusing to download subtitle from a non-Animetosho URL.', code: 400 },
};
}
const currentMediaPath = deps.getCurrentMediaPath();
if (!currentMediaPath) {
return { ok: false, error: { error: 'No media file loaded in MPV.' } };
}
const mediaDir = deps.isRemoteMediaPath(currentMediaPath)
? fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-animetosho-'))
: path.dirname(path.resolve(currentMediaPath));
const safeName = path.basename(parsedQuery.name);
if (!safeName) {
return { ok: false, error: { error: 'Invalid subtitle filename.' } };
}
const languageSuffix = animetoshoLangToFilenameSuffix(parsedQuery.lang);
const subtitleFilename = buildJimakuSubtitleFilenameFromMediaPath(
currentMediaPath,
safeName,
languageSuffix,
);
const ext = path.extname(subtitleFilename);
const baseName = ext ? subtitleFilename.slice(0, -ext.length) : subtitleFilename;
let targetPath = path.join(mediaDir, subtitleFilename);
if (fs.existsSync(targetPath)) {
targetPath = path.join(mediaDir, `${baseName} (animetosho-${parsedQuery.entryId})${ext}`);
let counter = 2;
while (fs.existsSync(targetPath)) {
targetPath = path.join(
mediaDir,
`${baseName} (animetosho-${parsedQuery.entryId}-${counter})${ext}`,
);
counter += 1;
}
}
logger.info(
`[animetosho] download-file name="${parsedQuery.name}" entryId=${parsedQuery.entryId}`,
);
const result = await deps.downloadAnimetoshoSubtitle(parsedQuery.url, targetPath);
if (result.ok) {
logger.info(`[animetosho] download-file saved to ${result.path}`);
// Japanese tracks take the primary slot; anything else loads as the
// secondary subtitle so the Japanese primary stays in place.
if (languageSuffix === 'ja') {
deps.onDownloadedSubtitle(result.path);
} else {
await deps.onDownloadedSecondarySubtitle(result.path);
}
} else {
logger.error(
`[animetosho] download-file failed: ${result.error?.error ?? 'unknown error'}`,
);
}
return result;
},
);
} }
+151 -3
View File
@@ -11,7 +11,8 @@ 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> }>;
sentCommands: Array<{ command: string[] }>; animetoshoFetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
sentCommands: Array<{ command: (string | number)[] }>;
}; };
} }
@@ -25,21 +26,75 @@ function createHarness(): RuntimeHarness {
endpoint: string; endpoint: string;
query?: Record<string, unknown>; query?: Record<string, unknown>;
}>, }>,
sentCommands: [] as Array<{ command: string[] }>, animetoshoFetchCalls: [] as Array<{
endpoint: string;
query?: Record<string, unknown>;
}>,
sentCommands: [] as Array<{ command: (string | number)[] }>,
}; };
const options: AnkiJimakuIpcRuntimeOptions = { const options: AnkiJimakuIpcRuntimeOptions = {
patchAnkiConnectEnabled: (enabled) => { patchAnkiConnectEnabled: (enabled) => {
state.patches.push(enabled); state.patches.push(enabled);
}, },
getResolvedConfig: () => ({}), getResolvedConfig: () => ({ animetosho: { maxSearchResults: 2 } }),
getRuntimeOptionsManager: () => null, getRuntimeOptionsManager: () => null,
animetoshoFetchJson: async (endpoint, query) => {
state.animetoshoFetchCalls.push({
endpoint,
query: query as Record<string, unknown>,
});
if ((query as Record<string, unknown>)?.show === 'torrent') {
return {
ok: true,
data: {
files: [
{
id: 9,
filename: 'episode.mkv',
attachments: [
{
id: 1955356,
type: 'subtitle',
info: { codec: 'ASS', lang: 'eng', name: 'English subs' },
size: 33075,
},
],
},
],
} as never,
};
}
return {
ok: true,
data: [
{ id: 1, title: 'release a' },
{ id: 2, title: 'release b' },
{ id: 3, title: 'release c' },
] as never,
};
},
getSubtitleTimingTracker: () => null, getSubtitleTimingTracker: () => null,
getMpvClient: () => ({ getMpvClient: () => ({
connected: true, connected: true,
send: (payload) => { send: (payload) => {
state.sentCommands.push(payload); state.sentCommands.push(payload);
}, },
request: async (command: unknown[]) => {
state.sentCommands.push({ command } as never);
return {
data: [
{ id: 1, type: 'sub', selected: true },
{
id: 3,
type: 'sub',
lang: 'en',
external: true,
'external-filename': '/tmp/video.en.ass',
},
],
};
},
}), }),
getAnkiIntegration: () => state.ankiIntegration as never, getAnkiIntegration: () => state.ankiIntegration as never,
setAnkiIntegration: (integration) => { setAnkiIntegration: (integration) => {
@@ -117,6 +172,10 @@ test('registerAnkiJimakuIpcRuntime provides full handler surface', () => {
'isRemoteMediaPath', 'isRemoteMediaPath',
'downloadToFile', 'downloadToFile',
'onDownloadedSubtitle', 'onDownloadedSubtitle',
'searchAnimetoshoEntries',
'listAnimetoshoFiles',
'downloadAnimetoshoSubtitle',
'onDownloadedSecondarySubtitle',
]; ];
for (const key of expected) { for (const key of expected) {
@@ -253,3 +312,92 @@ test('searchJimakuEntries caps results and onDownloadedSubtitle sends sub-add to
registered.onDownloadedSubtitle!('/tmp/subtitle.ass'); registered.onDownloadedSubtitle!('/tmp/subtitle.ass');
assert.deepEqual(state.sentCommands, [{ command: ['sub-add', '/tmp/subtitle.ass', 'select'] }]); assert.deepEqual(state.sentCommands, [{ command: ['sub-add', '/tmp/subtitle.ass', 'select'] }]);
}); });
test('onDownloadedSecondarySubtitle loads without stealing the primary track', async () => {
const { registered, state } = createHarness();
await registered.onDownloadedSecondarySubtitle!('/tmp/video.en.ass');
assert.deepEqual(state.sentCommands[0], { command: ['sub-add', '/tmp/video.en.ass', 'auto'] });
assert.deepEqual(state.sentCommands[1], { command: ['get_property', 'track-list'] });
assert.deepEqual(state.sentCommands[2], { command: ['set_property', 'secondary-sid', 3] });
});
test('onDownloadedSecondarySubtitle retries until mpv reports the new track', async () => {
const state = {
sentCommands: [] as Array<{ command: (string | number)[] }>,
trackListCalls: 0,
};
const options = {
...createHarness().options,
getMpvClient: () => ({
connected: true,
send: (payload: { command: (string | number)[] }) => {
state.sentCommands.push(payload);
},
request: async () => {
state.trackListCalls += 1;
// mpv has not registered the external file yet on the first poll.
if (state.trackListCalls < 2) {
return { data: [{ id: 1, type: 'sub', selected: true }] };
}
return {
data: [
{ id: 1, type: 'sub', selected: true },
{ id: 4, type: 'sub', external: true, 'external-filename': '/tmp/video.en.ass' },
],
};
},
}),
} as unknown as AnkiJimakuIpcRuntimeOptions;
let registered: Record<string, (...args: unknown[]) => unknown> = {};
registerAnkiJimakuIpcRuntime(options, (deps) => {
registered = deps as unknown as Record<string, (...args: unknown[]) => unknown>;
});
await registered.onDownloadedSecondarySubtitle!('/tmp/video.en.ass');
assert.equal(state.trackListCalls, 2);
assert.deepEqual(state.sentCommands.at(-1), {
command: ['set_property', 'secondary-sid', 4],
});
});
test('searchAnimetoshoEntries caps results using animetosho.maxSearchResults', async () => {
const { registered, state } = createHarness();
const searchResult = await registered.searchAnimetoshoEntries!({ query: 'frieren 28' });
assert.deepEqual(state.animetoshoFetchCalls, [
{
endpoint: '/json',
query: { q: 'frieren 28', qx: 1 },
},
]);
assert.equal((searchResult as { ok: boolean }).ok, true);
const entries = (searchResult as { data: Array<{ id: number }> }).data;
assert.equal(entries.length, 2);
assert.deepEqual(
entries.map((entry) => entry.id),
[1, 2],
);
});
test('listAnimetoshoFiles extracts subtitle attachments from torrent detail', async () => {
const { registered, state } = createHarness();
const filesResult = await registered.listAnimetoshoFiles!({ entryId: 606713 });
assert.deepEqual(state.animetoshoFetchCalls, [
{
endpoint: '/json',
query: { show: 'torrent', id: 606713 },
},
]);
assert.equal((filesResult as { ok: boolean }).ok, true);
const files = (filesResult as { data: Array<Record<string, unknown>> }).data;
assert.equal(files.length, 1);
assert.equal(files[0]!.attachmentId, 1955356);
assert.equal(files[0]!.filename, 'episode.eng.ass');
assert.equal(files[0]!.url, 'https://animetosho.org/storage/attach/001dd61c/1955356.xz');
});
+125 -2
View File
@@ -1,7 +1,10 @@
import * as fs from 'fs';
import { AnkiIntegration } from '../../anki-integration'; import { AnkiIntegration } from '../../anki-integration';
import { mergeAiConfig } from '../../ai/config'; import { mergeAiConfig } from '../../ai/config';
import { import {
AiConfig, AiConfig,
AnimetoshoApiResponse,
AnimetoshoConfig,
AnkiConnectConfig, AnkiConnectConfig,
JimakuApiResponse, JimakuApiResponse,
JimakuEntry, JimakuEntry,
@@ -13,6 +16,14 @@ import {
OverlayNotificationPayload, OverlayNotificationPayload,
} from '../../types'; } from '../../types';
import { sortJimakuFiles } from '../../jimaku/utils'; import { sortJimakuFiles } from '../../jimaku/utils';
import {
ANIMETOSHO_FEED_BASE_URL,
animetoshoFetchJson as animetoshoFetchJsonRequest,
decompressXzFile,
extractAnimetoshoSubtitleFiles,
isAnimetoshoDownloadUrl,
mapAnimetoshoSearchResults,
} from '../../animetosho/utils';
import type { AnkiJimakuIpcDeps } from './anki-jimaku-ipc'; import type { AnkiJimakuIpcDeps } from './anki-jimaku-ipc';
import { createLogger } from '../../logger'; import { createLogger } from '../../logger';
@@ -20,7 +31,8 @@ export type RegisterAnkiJimakuIpcRuntimeHandler = (deps: AnkiJimakuIpcDeps) => v
interface MpvClientLike { interface MpvClientLike {
connected: boolean; connected: boolean;
send: (payload: { command: string[] }) => void; send: (payload: { command: (string | number)[] }) => void;
request?: (command: unknown[]) => Promise<{ data?: unknown }>;
} }
interface RuntimeOptionsManagerLike { interface RuntimeOptionsManagerLike {
@@ -33,7 +45,12 @@ interface SubtitleTimingTrackerLike {
export interface AnkiJimakuIpcRuntimeOptions { export interface AnkiJimakuIpcRuntimeOptions {
patchAnkiConnectEnabled: (enabled: boolean) => void; patchAnkiConnectEnabled: (enabled: boolean) => void;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig; ai?: AiConfig }; getResolvedConfig: () => {
ankiConnect?: AnkiConnectConfig;
ai?: AiConfig;
animetosho?: AnimetoshoConfig;
secondarySub?: { secondarySubLanguages?: string[] };
};
getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null; getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null;
getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null; getSubtitleTimingTracker: () => SubtitleTimingTrackerLike | null;
getMpvClient: () => MpvClientLike | null; getMpvClient: () => MpvClientLike | null;
@@ -60,6 +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>(
endpoint: string,
query?: Record<string, string | number | boolean | null | undefined>,
) => Promise<AnimetoshoApiResponse<T>>;
getJimakuMaxEntryResults: () => number; getJimakuMaxEntryResults: () => number;
getJimakuLanguagePreference: () => JimakuLanguagePreference; getJimakuLanguagePreference: () => JimakuLanguagePreference;
resolveJimakuApiKey: () => Promise<string | null>; resolveJimakuApiKey: () => Promise<string | null>;
@@ -68,6 +89,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
url: string, url: string,
destPath: string, destPath: string,
headers: Record<string, string>, headers: Record<string, string>,
downloadOptions?: { isAllowedRedirect?: (url: URL) => boolean },
) => Promise< ) => Promise<
| { ok: true; path: string } | { ok: true; path: string }
| { | {
@@ -79,6 +101,34 @@ export interface AnkiJimakuIpcRuntimeOptions {
const logger = createLogger('main:anki-jimaku'); const logger = createLogger('main:anki-jimaku');
const DEFAULT_ANIMETOSHO_MAX_SEARCH_RESULTS = 10;
const SECONDARY_TRACK_LOOKUP_ATTEMPTS = 5;
const SECONDARY_TRACK_LOOKUP_RETRY_MS = 100;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getAnimetoshoMaxSearchResults(options: AnkiJimakuIpcRuntimeOptions): number {
const value = options.getResolvedConfig().animetosho?.maxSearchResults;
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
return DEFAULT_ANIMETOSHO_MAX_SEARCH_RESULTS;
}
function animetoshoFetch<T>(
options: AnkiJimakuIpcRuntimeOptions,
endpoint: string,
query: Record<string, string | number | boolean | null | undefined>,
): Promise<AnimetoshoApiResponse<T>> {
if (options.animetoshoFetchJson) {
return options.animetoshoFetchJson<T>(endpoint, query);
}
const baseUrl = options.getResolvedConfig().animetosho?.apiBaseUrl || ANIMETOSHO_FEED_BASE_URL;
return animetoshoFetchJsonRequest<T>(endpoint, query, { baseUrl });
}
export function registerAnkiJimakuIpcRuntime( export function registerAnkiJimakuIpcRuntime(
options: AnkiJimakuIpcRuntimeOptions, options: AnkiJimakuIpcRuntimeOptions,
registerHandlers: RegisterAnkiJimakuIpcRuntimeHandler, registerHandlers: RegisterAnkiJimakuIpcRuntimeHandler,
@@ -191,11 +241,84 @@ export function registerAnkiJimakuIpcRuntime(
getCurrentMediaPath: () => options.getCurrentMediaPath(), getCurrentMediaPath: () => options.getCurrentMediaPath(),
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) => {
logger.info(`[animetosho] search-entries query: "${query.query}"`);
const response = await animetoshoFetch<unknown>(options, '/json', {
q: query.query,
qx: 1,
});
if (!response.ok) return response;
const maxResults = getAnimetoshoMaxSearchResults(options);
const entries = mapAnimetoshoSearchResults(response.data, maxResults);
logger.info(`[animetosho] search-entries returned ${entries.length} results`);
return { ok: true, data: entries };
},
listAnimetoshoFiles: async (query) => {
logger.info(`[animetosho] list-files entryId=${query.entryId}`);
const response = await animetoshoFetch<unknown>(options, '/json', {
show: 'torrent',
id: query.entryId,
});
if (!response.ok) return response;
const files = extractAnimetoshoSubtitleFiles(response.data);
logger.info(`[animetosho] list-files returned ${files.length} subtitle attachments`);
return { ok: true, data: files };
},
getAnimetoshoSecondaryLanguages: () =>
options.getResolvedConfig().secondarySub?.secondarySubLanguages ?? [],
downloadAnimetoshoSubtitle: async (url, destPath) => {
const tempXzPath = `${destPath}.xz`;
const downloaded = await options.downloadToFile(
url,
tempXzPath,
{ 'User-Agent': 'SubMiner' },
// animetosho.org redirects to storage.animetosho.org; keep the hop in-domain.
{ isAllowedRedirect: (redirectUrl) => isAnimetoshoDownloadUrl(redirectUrl) },
);
if (!downloaded.ok) return downloaded;
const result = await decompressXzFile(tempXzPath, destPath);
fs.promises.unlink(tempXzPath).catch(() => {});
return result;
},
onDownloadedSubtitle: (pathToSubtitle) => { onDownloadedSubtitle: (pathToSubtitle) => {
const mpvClient = options.getMpvClient(); const mpvClient = options.getMpvClient();
if (mpvClient && mpvClient.connected) { if (mpvClient && mpvClient.connected) {
mpvClient.send({ command: ['sub-add', pathToSubtitle, 'select'] }); mpvClient.send({ command: ['sub-add', pathToSubtitle, 'select'] });
} }
}, },
onDownloadedSecondarySubtitle: async (pathToSubtitle) => {
const mpvClient = options.getMpvClient();
if (!mpvClient || !mpvClient.connected) return;
mpvClient.send({ command: ['sub-add', pathToSubtitle, 'auto'] });
const request = mpvClient.request;
if (!request) return;
// sub-add is queued, so the track may not appear in the first track-list
// reply; poll briefly before giving up.
for (let attempt = 0; attempt < SECONDARY_TRACK_LOOKUP_ATTEMPTS; attempt += 1) {
try {
const response = await request(['get_property', 'track-list']);
const tracks = Array.isArray(response?.data)
? (response.data as Array<Record<string, unknown>>)
: [];
const added = tracks.find(
(track) => track?.type === 'sub' && track['external-filename'] === pathToSubtitle,
);
if (added && typeof added.id === 'number') {
mpvClient.send({ command: ['set_property', 'secondary-sid', added.id] });
return;
}
} catch (error) {
logger.warn('[animetosho] failed to select downloaded subtitle as secondary:', error);
return;
}
await delay(SECONDARY_TRACK_LOOKUP_RETRY_MS);
}
logger.warn(
`[animetosho] could not find downloaded subtitle in track-list: ${pathToSubtitle}`,
);
},
}); });
} }
+1
View File
@@ -39,6 +39,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openControllerSelect: false, openControllerSelect: false,
openControllerDebug: false, openControllerDebug: false,
openJimaku: false, openJimaku: false,
openAnimetosho: false,
openYoutubePicker: false, openYoutubePicker: false,
openPlaylistBrowser: false, openPlaylistBrowser: false,
replayCurrentSubtitle: false, replayCurrentSubtitle: false,
+1
View File
@@ -44,6 +44,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openControllerSelect: false, openControllerSelect: false,
openControllerDebug: false, openControllerDebug: false,
openJimaku: false, openJimaku: false,
openAnimetosho: false,
openYoutubePicker: false, openYoutubePicker: false,
openPlaylistBrowser: false, openPlaylistBrowser: false,
togglePrimarySubtitleBar: false, togglePrimarySubtitleBar: false,
+6
View File
@@ -539,6 +539,12 @@ 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) {
dispatchCliSessionAction(
{ actionId: 'openAnimetosho' },
'openAnimetosho',
'Open animetosho failed',
);
} else if (args.openYoutubePicker) { } else if (args.openYoutubePicker) {
dispatchCliSessionAction( dispatchCliSessionAction(
{ actionId: 'openYoutubePicker' }, { actionId: 'openYoutubePicker' },
+4
View File
@@ -12,6 +12,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
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',
ANIMETOSHO_OPEN: '__animetosho-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',
@@ -27,6 +28,9 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
openJimaku: () => { openJimaku: () => {
calls.push('jimaku'); calls.push('jimaku');
}, },
openAnimetosho: () => {
calls.push('animetosho');
},
openYoutubeTrackPicker: () => { openYoutubeTrackPicker: () => {
calls.push('youtube-picker'); calls.push('youtube-picker');
}, },
+7
View File
@@ -10,6 +10,7 @@ export interface HandleMpvCommandFromIpcOptions {
SUBSYNC_TRIGGER: string; SUBSYNC_TRIGGER: string;
RUNTIME_OPTIONS_OPEN: string; RUNTIME_OPTIONS_OPEN: string;
JIMAKU_OPEN: string; JIMAKU_OPEN: string;
ANIMETOSHO_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;
@@ -19,6 +20,7 @@ export interface HandleMpvCommandFromIpcOptions {
triggerSubsyncFromConfig: () => void; triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
openJimaku: () => void; openJimaku: () => void;
openAnimetosho: () => 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;
@@ -112,6 +114,11 @@ export function handleMpvCommandFromIpc(
return; return;
} }
if (first === options.specialCommands.ANIMETOSHO_OPEN) {
options.openAnimetosho();
return;
}
if (first === options.specialCommands.YOUTUBE_PICKER_OPEN) { if (first === options.specialCommands.YOUTUBE_PICKER_OPEN) {
void options.openYoutubeTrackPicker(); void options.openYoutubeTrackPicker();
return; return;
@@ -28,6 +28,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
openCharacterDictionaryManager: null, openCharacterDictionaryManager: null,
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openAnimetosho: null,
openSessionHelp: null, openSessionHelp: null,
openControllerSelect: null, openControllerSelect: null,
openControllerDebug: null, openControllerDebug: null,
@@ -54,6 +55,9 @@ function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
openJimaku: () => { openJimaku: () => {
calls.push('openJimaku'); calls.push('openJimaku');
}, },
openAnimetosho: () => {
calls.push('openAnimetosho');
},
markAudioCard: async () => { markAudioCard: async () => {
calls.push('markAudioCard'); calls.push('markAudioCard');
}, },
@@ -164,6 +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'),
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'),
@@ -197,6 +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'),
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'),
@@ -217,6 +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'),
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'),
@@ -254,6 +261,7 @@ test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s
openRuntimeOptions: () => {}, openRuntimeOptions: () => {},
openCharacterDictionaryManager: () => {}, openCharacterDictionaryManager: () => {},
openJimaku: () => {}, openJimaku: () => {},
openAnimetosho: () => {},
markAudioCard: () => {}, markAudioCard: () => {},
copySubtitleMultiple: () => {}, copySubtitleMultiple: () => {},
copySubtitle: () => {}, copySubtitle: () => {},
@@ -290,6 +298,7 @@ test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut',
openRuntimeOptions: () => {}, openRuntimeOptions: () => {},
openCharacterDictionaryManager: () => {}, openCharacterDictionaryManager: () => {},
openJimaku: () => {}, openJimaku: () => {},
openAnimetosho: () => {},
markAudioCard: () => {}, markAudioCard: () => {},
copySubtitleMultiple: () => {}, copySubtitleMultiple: () => {},
copySubtitle: () => {}, copySubtitle: () => {},
@@ -322,6 +331,9 @@ test('runOverlayShortcutLocalFallback returns false when no action matches', ()
openJimaku: () => { openJimaku: () => {
called = true; called = true;
}, },
openAnimetosho: () => {
called = true;
},
markAudioCard: () => { markAudioCard: () => {
called = true; called = true;
}, },
@@ -404,6 +416,7 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured',
openCharacterDictionaryManager: () => {}, openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {}, openRuntimeOptions: () => {},
openJimaku: () => {}, openJimaku: () => {},
openAnimetosho: () => {},
}), }),
cancelPendingMultiCopy: () => {}, cancelPendingMultiCopy: () => {},
cancelPendingMineSentenceMultiple: () => {}, cancelPendingMineSentenceMultiple: () => {},
@@ -431,6 +444,7 @@ test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active
openCharacterDictionaryManager: () => {}, openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {}, openRuntimeOptions: () => {},
openJimaku: () => {}, openJimaku: () => {},
openAnimetosho: () => {},
}), }),
cancelPendingMultiCopy: () => { cancelPendingMultiCopy: () => {
calls.push('cancel-multi-copy'); calls.push('cancel-multi-copy');
@@ -8,6 +8,7 @@ export interface OverlayShortcutFallbackHandlers {
openRuntimeOptions: () => void; openRuntimeOptions: () => void;
openCharacterDictionaryManager: () => void; openCharacterDictionaryManager: () => void;
openJimaku: () => void; openJimaku: () => void;
openAnimetosho: () => void;
markAudioCard: () => void; markAudioCard: () => void;
copySubtitleMultiple: (timeoutMs: number) => void; copySubtitleMultiple: (timeoutMs: number) => void;
copySubtitle: () => void; copySubtitle: () => void;
@@ -24,6 +25,7 @@ export interface OverlayShortcutRuntimeDeps {
openRuntimeOptions: () => void; openRuntimeOptions: () => void;
openCharacterDictionaryManager: () => void; openCharacterDictionaryManager: () => void;
openJimaku: () => void; openJimaku: () => void;
openAnimetosho: () => void;
markAudioCard: () => Promise<void>; markAudioCard: () => Promise<void>;
copySubtitleMultiple: (timeoutMs: number) => void; copySubtitleMultiple: (timeoutMs: number) => void;
copySubtitle: () => void; copySubtitle: () => void;
@@ -103,12 +105,16 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
openJimaku: () => { openJimaku: () => {
deps.openJimaku(); deps.openJimaku();
}, },
openAnimetosho: () => {
deps.openAnimetosho();
},
}; };
const fallbackHandlers: OverlayShortcutFallbackHandlers = { const fallbackHandlers: OverlayShortcutFallbackHandlers = {
openRuntimeOptions: overlayHandlers.openRuntimeOptions, openRuntimeOptions: overlayHandlers.openRuntimeOptions,
openCharacterDictionaryManager: overlayHandlers.openCharacterDictionaryManager, openCharacterDictionaryManager: overlayHandlers.openCharacterDictionaryManager,
openJimaku: overlayHandlers.openJimaku, openJimaku: overlayHandlers.openJimaku,
openAnimetosho: overlayHandlers.openAnimetosho,
markAudioCard: overlayHandlers.markAudioCard, markAudioCard: overlayHandlers.markAudioCard,
copySubtitleMultiple: overlayHandlers.copySubtitleMultiple, copySubtitleMultiple: overlayHandlers.copySubtitleMultiple,
copySubtitle: overlayHandlers.copySubtitle, copySubtitle: overlayHandlers.copySubtitle,
@@ -153,6 +159,13 @@ export function runOverlayShortcutLocalFallback(
}, },
allowWhenRegistered: true, allowWhenRegistered: true,
}, },
{
accelerator: shortcuts.openAnimetosho,
run: () => {
handlers.openAnimetosho();
},
allowWhenRegistered: true,
},
{ {
accelerator: shortcuts.markAudioCard, accelerator: shortcuts.markAudioCard,
run: () => { run: () => {
@@ -23,6 +23,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openCharacterDictionaryManager: null, openCharacterDictionaryManager: null,
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openAnimetosho: null,
openSessionHelp: null, openSessionHelp: null,
openControllerSelect: null, openControllerSelect: null,
openControllerDebug: null, openControllerDebug: null,
@@ -48,6 +49,7 @@ test('registerOverlayShortcuts reports active overlay shortcuts when configured'
openCharacterDictionaryManager: () => {}, openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {}, openRuntimeOptions: () => {},
openJimaku: () => {}, openJimaku: () => {},
openAnimetosho: () => {},
}), }),
true, true,
); );
@@ -68,6 +70,7 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent'
openCharacterDictionaryManager: () => {}, openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {}, openRuntimeOptions: () => {},
openJimaku: () => {}, openJimaku: () => {},
openAnimetosho: () => {},
}), }),
false, false,
); );
@@ -90,6 +93,7 @@ test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active
openCharacterDictionaryManager: () => {}, openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {}, openRuntimeOptions: () => {},
openJimaku: () => {}, openJimaku: () => {},
openAnimetosho: () => {},
}), }),
cancelPendingMultiCopy: () => { cancelPendingMultiCopy: () => {
calls.push('cancel-multi-copy'); calls.push('cancel-multi-copy');
+2
View File
@@ -13,6 +13,7 @@ export interface OverlayShortcutHandlers {
openCharacterDictionaryManager: () => void; openCharacterDictionaryManager: () => void;
openRuntimeOptions: () => void; openRuntimeOptions: () => void;
openJimaku: () => void; openJimaku: () => void;
openAnimetosho: () => void;
} }
export interface OverlayShortcutLifecycleDeps { export interface OverlayShortcutLifecycleDeps {
@@ -35,6 +36,7 @@ const OVERLAY_SHORTCUT_KEYS: Array<keyof Omit<ConfiguredShortcuts, 'multiCopyTim
'openCharacterDictionaryManager', 'openCharacterDictionaryManager',
'openRuntimeOptions', 'openRuntimeOptions',
'openJimaku', 'openJimaku',
'openAnimetosho',
]; ];
function hasConfiguredOverlayShortcuts(shortcuts: ConfiguredShortcuts): boolean { function hasConfiguredOverlayShortcuts(shortcuts: ConfiguredShortcuts): boolean {
@@ -40,6 +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'),
openYoutubeTrackPicker: () => { openYoutubeTrackPicker: () => {
calls.push('youtube'); calls.push('youtube');
}, },
+4
View File
@@ -24,6 +24,7 @@ export interface SessionActionExecutorDeps {
openControllerSelect: () => void; openControllerSelect: () => void;
openControllerDebug: () => void; openControllerDebug: () => void;
openJimaku: () => void; openJimaku: () => void;
openAnimetosho: () => 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;
@@ -115,6 +116,9 @@ export async function dispatchSessionAction(
case 'openJimaku': case 'openJimaku':
deps.openJimaku(); deps.openJimaku();
return; return;
case 'openAnimetosho':
deps.openAnimetosho();
return;
case 'openYoutubePicker': case 'openYoutubePicker':
await deps.openYoutubeTrackPicker(); await deps.openYoutubeTrackPicker();
return; return;
@@ -22,6 +22,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openCharacterDictionaryManager: null, openCharacterDictionaryManager: null,
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openAnimetosho: null,
openSessionHelp: null, openSessionHelp: null,
openControllerSelect: null, openControllerSelect: null,
openControllerDebug: null, openControllerDebug: null,
+5
View File
@@ -55,6 +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: 'openSessionHelp', actionId: 'openSessionHelp' }, { key: 'openSessionHelp', actionId: 'openSessionHelp' },
{ key: 'openControllerSelect', actionId: 'openControllerSelect' }, { key: 'openControllerSelect', actionId: 'openControllerSelect' },
{ key: 'openControllerDebug', actionId: 'openControllerDebug' }, { key: 'openControllerDebug', actionId: 'openControllerDebug' },
@@ -304,6 +305,10 @@ 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 (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'openAnimetosho' };
}
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;
return { actionType: 'session-action', actionId: 'openYoutubePicker' }; return { actionType: 'session-action', actionId: 'openYoutubePicker' };
@@ -39,6 +39,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openControllerSelect: false, openControllerSelect: false,
openControllerDebug: false, openControllerDebug: false,
openJimaku: false, openJimaku: false,
openAnimetosho: false,
openYoutubePicker: false, openYoutubePicker: false,
openPlaylistBrowser: false, openPlaylistBrowser: false,
replayCurrentSubtitle: false, replayCurrentSubtitle: false,
+2
View File
@@ -15,6 +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;
openSessionHelp: string | null | undefined; openSessionHelp: string | null | undefined;
openControllerSelect: string | null | undefined; openControllerSelect: string | null | undefined;
openControllerDebug: string | null | undefined; openControllerDebug: string | null | undefined;
@@ -65,6 +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')),
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')), openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')), openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
openControllerDebug: normalizeShortcut(shortcutValue('openControllerDebug')), openControllerDebug: normalizeShortcut(shortcutValue('openControllerDebug')),
+28
View File
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { describeDownloadError } from './utils.js';
test('describeDownloadError prefers the error message', () => {
assert.equal(describeDownloadError(new Error('socket hang up')), 'socket hang up');
});
test('describeDownloadError falls back to the error code when the message is empty', () => {
const err = new Error('') as NodeJS.ErrnoException;
err.code = 'ECONNRESET';
assert.equal(describeDownloadError(err), 'ECONNRESET');
});
test('describeDownloadError unwraps empty-message AggregateErrors', () => {
const v4 = new Error('connect ECONNREFUSED 1.2.3.4:443') as NodeJS.ErrnoException;
v4.code = 'ECONNREFUSED';
const v6 = new Error('') as NodeJS.ErrnoException;
v6.code = 'ENETUNREACH';
const aggregate = new AggregateError([v4, v6], '');
assert.equal(describeDownloadError(aggregate), 'connect ECONNREFUSED 1.2.3.4:443; ENETUNREACH');
});
test('describeDownloadError never returns an empty string', () => {
assert.equal(describeDownloadError(new Error('')), 'Error');
assert.equal(describeDownloadError('boom'), 'boom');
});
+94
View File
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as fs from 'node:fs';
import * as http from 'node:http';
import * as os from 'node:os';
import * as path from 'node:path';
import type { AddressInfo } from 'node:net';
import { downloadToFile } from './utils.js';
interface TestServer {
port: number;
close: () => Promise<void>;
}
function startServer(handler: http.RequestListener): Promise<TestServer> {
return new Promise((resolve) => {
const server = http.createServer(handler);
server.listen(0, '127.0.0.1', () => {
const { port } = server.address() as AddressInfo;
resolve({
port,
close: () =>
new Promise((done) => {
server.close(() => done());
}),
});
});
});
}
test('downloadToFile follows redirects that pass the allow-list', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-redirect-test-'));
const server = await startServer((req, res) => {
if (req.url === '/start') {
res.writeHead(302, { Location: '/final' });
res.end();
return;
}
res.writeHead(200);
res.end('subtitle body');
});
try {
const destPath = path.join(dir, 'sub.ass');
const result = await downloadToFile(
`http://127.0.0.1:${server.port}/start`,
destPath,
{},
{ isAllowedRedirect: (url) => url.hostname === '127.0.0.1' },
);
assert.equal(result.ok, true);
assert.equal(fs.readFileSync(destPath, 'utf8'), 'subtitle body');
} finally {
await server.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('downloadToFile refuses redirects to a host outside the allow-list', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-redirect-test-'));
let finalHits = 0;
const server = await startServer((req, res) => {
if (req.url === '/start') {
res.writeHead(302, { Location: 'http://localhost.localdomain/evil' });
res.end();
return;
}
finalHits += 1;
res.writeHead(200);
res.end('should never be fetched');
});
try {
const destPath = path.join(dir, 'sub.ass');
const result = await downloadToFile(
`http://127.0.0.1:${server.port}/start`,
destPath,
{},
{ isAllowedRedirect: (url) => url.hostname === '127.0.0.1' },
);
assert.equal(result.ok, false);
if (!result.ok) {
assert.match(result.error.error, /redirect/i);
}
assert.equal(finalHits, 0);
assert.equal(fs.existsSync(destPath), false);
} finally {
await server.close();
fs.rmSync(dir, { recursive: true, force: true });
}
});
+43 -4
View File
@@ -306,12 +306,35 @@ export function isRemoteMediaPath(mediaPath: string): boolean {
return /^[a-z][a-z0-9+.-]*:\/\//i.test(mediaPath); return /^[a-z][a-z0-9+.-]*:\/\//i.test(mediaPath);
} }
export function describeDownloadError(err: unknown): string {
if (err instanceof AggregateError) {
const parts = err.errors
.map((inner) => describeDownloadError(inner))
.filter((part) => part && part !== 'Error');
if (parts.length > 0) return parts.join('; ');
}
if (err instanceof Error) {
if (err.message) return err.message;
const code = (err as NodeJS.ErrnoException).code;
if (code) return code;
return err.name || 'Error';
}
return String(err) || 'Unknown error';
}
export interface DownloadToFileOptions {
// Guards where a redirect may land. Without it any Location header is followed.
isAllowedRedirect?: (url: URL) => boolean;
redirectCount?: number;
}
export async function downloadToFile( export async function downloadToFile(
url: string, url: string,
destPath: string, destPath: string,
headers: Record<string, string>, headers: Record<string, string>,
redirectCount = 0, options: DownloadToFileOptions = {},
): Promise<JimakuDownloadResult> { ): Promise<JimakuDownloadResult> {
const redirectCount = options.redirectCount ?? 0;
if (redirectCount > 3) { if (redirectCount > 3) {
return { return {
ok: false, ok: false,
@@ -326,9 +349,23 @@ export async function downloadToFile(
const req = transport.get(parsedUrl, { headers }, (res) => { const req = transport.get(parsedUrl, { headers }, (res) => {
const status = res.statusCode || 0; const status = res.statusCode || 0;
if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) { if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) {
const redirectUrl = new URL(res.headers.location, parsedUrl).toString(); const redirectUrl = new URL(res.headers.location, parsedUrl);
res.resume(); res.resume();
downloadToFile(redirectUrl, destPath, headers, redirectCount + 1).then(resolve); if (options.isAllowedRedirect && !options.isAllowedRedirect(redirectUrl)) {
logger.error(`Refusing redirect to disallowed host: ${redirectUrl.href}`);
resolve({
ok: false,
error: {
error: `Refusing to follow subtitle redirect to ${redirectUrl.host}.`,
code: status,
},
});
return;
}
downloadToFile(redirectUrl.toString(), destPath, headers, {
...options,
redirectCount: redirectCount + 1,
}).then(resolve);
return; return;
} }
@@ -362,9 +399,11 @@ export async function downloadToFile(
}); });
req.on('error', (err) => { req.on('error', (err) => {
const reason = describeDownloadError(err);
logger.error(`Download request failed for ${url}: ${reason}`);
resolve({ resolve({
ok: false, ok: false,
error: { error: `Download request failed: ${(err as Error).message}` }, error: { error: `Download request failed: ${reason}` },
}); });
}); });
}); });
+20 -2
View File
@@ -467,6 +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 { 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';
@@ -2058,6 +2059,9 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
openJimaku: () => { openJimaku: () => {
openJimakuOverlay(); openJimakuOverlay();
}, },
openAnimetosho: () => {
openAnimetoshoOverlay();
},
markAudioCard: () => markLastCardAsAudioCard(), markAudioCard: () => markLastCardAsAudioCard(),
copySubtitleMultiple: (timeoutMs: number) => { copySubtitleMultiple: (timeoutMs: number) => {
startPendingMultiCopy(timeoutMs); startPendingMultiCopy(timeoutMs);
@@ -2919,6 +2923,14 @@ function openJimakuOverlay(): void {
); );
} }
function openAnimetoshoOverlay(): void {
openOverlayHostedModalWithOsd(
openAnimetoshoModalRuntime,
'Animetosho overlay unavailable.',
'Failed to open Animetosho overlay.',
);
}
function openSessionHelpOverlay(): void { function openSessionHelpOverlay(): void {
openOverlayHostedModalWithOsd( openOverlayHostedModalWithOsd(
openSessionHelpModalRuntime, openSessionHelpModalRuntime,
@@ -5434,6 +5446,7 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
}, },
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJimaku: () => openJimakuOverlay(), openJimaku: () => openJimakuOverlay(),
openAnimetosho: () => openAnimetoshoOverlay(),
openSessionHelp: () => openSessionHelpOverlay(), openSessionHelp: () => openSessionHelpOverlay(),
openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(), openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(),
openControllerSelect: () => openControllerSelectOverlay(), openControllerSelect: () => openControllerSelectOverlay(),
@@ -5467,6 +5480,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(), triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJimaku: () => openJimakuOverlay(), openJimaku: () => openJimakuOverlay(),
openAnimetosho: () => openAnimetoshoOverlay(),
openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(), openYoutubeTrackPicker: () => openYoutubeTrackPickerFromPlayback(),
openPlaylistBrowser: () => openPlaylistBrowser(), openPlaylistBrowser: () => openPlaylistBrowser(),
cycleRuntimeOption: (id, direction) => { cycleRuntimeOption: (id, direction) => {
@@ -5896,8 +5910,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
getJimakuLanguagePreference: () => configDerivedRuntime.getJimakuLanguagePreference(), getJimakuLanguagePreference: () => configDerivedRuntime.getJimakuLanguagePreference(),
resolveJimakuApiKey: () => configDerivedRuntime.resolveJimakuApiKey(), resolveJimakuApiKey: () => configDerivedRuntime.resolveJimakuApiKey(),
isRemoteMediaPath: (mediaPath: string) => isRemoteMediaPath(mediaPath), isRemoteMediaPath: (mediaPath: string) => isRemoteMediaPath(mediaPath),
downloadToFile: (url: string, destPath: string, headers: Record<string, string>) => downloadToFile: (
downloadToFile(url, destPath, headers), url: string,
destPath: string,
headers: Record<string, string>,
downloadOptions?: { isAllowedRedirect?: (url: URL) => boolean },
) => downloadToFile(url, destPath, headers, downloadOptions),
}), }),
registerIpcRuntimeServices, registerIpcRuntimeServices,
}, },
+2
View File
@@ -228,6 +228,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'];
openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker']; openYoutubeTrackPicker: HandleMpvCommandFromIpcOptions['openYoutubeTrackPicker'];
openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser']; openPlaylistBrowser: HandleMpvCommandFromIpcOptions['openPlaylistBrowser'];
showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd']; showMpvOsd: HandleMpvCommandFromIpcOptions['showMpvOsd'];
@@ -434,6 +435,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,
openYoutubeTrackPicker: params.openYoutubeTrackPicker, openYoutubeTrackPicker: params.openYoutubeTrackPicker,
openPlaylistBrowser: params.openPlaylistBrowser, openPlaylistBrowser: params.openPlaylistBrowser,
runtimeOptionsCycle: params.runtimeOptionsCycle, runtimeOptionsCycle: params.runtimeOptionsCycle,
+2
View File
@@ -13,6 +13,7 @@ export interface MpvCommandFromIpcRuntimeDeps {
triggerSubsyncFromConfig: () => void; triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
openJimaku: () => void; openJimaku: () => void;
openAnimetosho: () => 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;
@@ -38,6 +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,
openYoutubeTrackPicker: deps.openYoutubeTrackPicker, openYoutubeTrackPicker: deps.openYoutubeTrackPicker,
openPlaylistBrowser: deps.openPlaylistBrowser, openPlaylistBrowser: deps.openPlaylistBrowser,
runtimeOptionsCycle: deps.cycleRuntimeOption, runtimeOptionsCycle: deps.cycleRuntimeOption,
+8
View File
@@ -49,6 +49,7 @@ export interface OverlayModalRuntime {
) => boolean; ) => boolean;
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
openJimaku: () => void; openJimaku: () => void;
openAnimetosho: () => 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>;
@@ -432,6 +433,12 @@ export function createOverlayModalRuntimeService(
}); });
}; };
const openAnimetosho = (): void => {
sendToActiveOverlayWindow('animetosho:open', undefined, {
restoreOnModalClose: 'animetosho',
});
};
const handleOverlayModalClosed = (modal: OverlayHostedModal): void => { const handleOverlayModalClosed = (modal: OverlayHostedModal): void => {
openedModals.delete(modal); openedModals.delete(modal);
if (!restoreVisibleOverlayOnModalClose.has(modal)) return; if (!restoreVisibleOverlayOnModalClose.has(modal)) return;
@@ -511,6 +518,7 @@ export function createOverlayModalRuntimeService(
sendToActiveOverlayWindow, sendToActiveOverlayWindow,
openRuntimeOptionsPalette, openRuntimeOptionsPalette,
openJimaku, openJimaku,
openAnimetosho,
handleOverlayModalClosed, handleOverlayModalClosed,
notifyOverlayModalOpened, notifyOverlayModalOpened,
waitForModalOpen, waitForModalOpen,
+4
View File
@@ -21,6 +21,7 @@ export interface OverlayShortcutRuntimeServiceInput {
openRuntimeOptionsPalette: () => void; openRuntimeOptionsPalette: () => void;
openCharacterDictionaryManager: () => void; openCharacterDictionaryManager: () => void;
openJimaku: () => void; openJimaku: () => void;
openAnimetosho: () => void;
markAudioCard: () => Promise<void>; markAudioCard: () => Promise<void>;
copySubtitleMultiple: (timeoutMs: number) => void; copySubtitleMultiple: (timeoutMs: number) => void;
copySubtitle: () => void; copySubtitle: () => void;
@@ -56,6 +57,9 @@ export function createOverlayShortcutsRuntimeService(
openJimaku: () => { openJimaku: () => {
input.openJimaku(); input.openJimaku();
}, },
openAnimetosho: () => {
input.openAnimetosho();
},
markAudioCard: () => { markAudioCard: () => {
return input.markAudioCard(); return input.markAudioCard();
}, },
+48
View File
@@ -0,0 +1,48 @@
import type { OverlayHostedModal } from '../../shared/ipc/contracts';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
const ANIMETOSHO_MODAL: OverlayHostedModal = 'animetosho';
const ANIMETOSHO_OPEN_TIMEOUT_MS = 1500;
export async function openAnimetoshoModal(deps: {
ensureOverlayStartupPrereqs: () => void;
ensureOverlayWindowsReadyForVisibilityActions: () => void;
sendToActiveOverlayWindow: (
channel: string,
payload?: unknown,
runtimeOptions?: {
restoreOnModalClose?: OverlayHostedModal;
preferModalWindow?: boolean;
},
) => boolean;
waitForModalOpen: (modal: OverlayHostedModal, timeoutMs: number) => Promise<boolean>;
logWarn: (message: string) => void;
}): Promise<boolean> {
return await retryOverlayModalOpen(
{
waitForModalOpen: deps.waitForModalOpen,
logWarn: deps.logWarn,
},
{
modal: ANIMETOSHO_MODAL,
timeoutMs: ANIMETOSHO_OPEN_TIMEOUT_MS,
retryWarning:
'Animetosho modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
sendOpen: () =>
openOverlayHostedModal(
{
ensureOverlayStartupPrereqs: deps.ensureOverlayStartupPrereqs,
ensureOverlayWindowsReadyForVisibilityActions:
deps.ensureOverlayWindowsReadyForVisibilityActions,
sendToActiveOverlayWindow: deps.sendToActiveOverlayWindow,
},
{
channel: IPC_CHANNELS.event.animetoshoOpen,
modal: ANIMETOSHO_MODAL,
preferModalWindow: true,
},
),
},
);
}
@@ -11,6 +11,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
triggerSubsyncFromConfig: async () => {}, triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {}, openRuntimeOptionsPalette: () => {},
openJimaku: () => {}, openJimaku: () => {},
openAnimetosho: () => {},
openYoutubeTrackPicker: () => {}, openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {}, openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: true }), cycleRuntimeOption: () => ({ ok: true }),
@@ -54,6 +54,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openControllerSelect: false, openControllerSelect: false,
openControllerDebug: false, openControllerDebug: false,
openJimaku: false, openJimaku: false,
openAnimetosho: false,
openYoutubePicker: false, openYoutubePicker: false,
openPlaylistBrowser: false, openPlaylistBrowser: false,
replayCurrentSubtitle: false, replayCurrentSubtitle: false,
@@ -97,6 +97,7 @@ function hasAnyStartupCommandBeyondSetup(args: CliArgs): boolean {
args.openControllerSelect || args.openControllerSelect ||
args.openControllerDebug || args.openControllerDebug ||
args.openJimaku || args.openJimaku ||
args.openAnimetosho ||
args.openYoutubePicker || args.openYoutubePicker ||
args.openPlaylistBrowser || args.openPlaylistBrowser ||
args.replayCurrentSubtitle || args.replayCurrentSubtitle ||
@@ -19,6 +19,7 @@ function createShortcuts(): ConfiguredShortcuts {
openCharacterDictionaryManager: null, openCharacterDictionaryManager: null,
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openAnimetosho: null,
openSessionHelp: null, openSessionHelp: null,
openControllerSelect: null, openControllerSelect: null,
openControllerDebug: null, openControllerDebug: null,
@@ -23,6 +23,7 @@ function createShortcuts(): ConfiguredShortcuts {
openCharacterDictionaryManager: null, openCharacterDictionaryManager: null,
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openAnimetosho: null,
openSessionHelp: null, openSessionHelp: null,
openControllerSelect: null, openControllerSelect: null,
openControllerDebug: null, openControllerDebug: null,
@@ -14,6 +14,7 @@ test('ipc bridge action main deps builders map callbacks', async () => {
triggerSubsyncFromConfig: async () => {}, triggerSubsyncFromConfig: async () => {},
openRuntimeOptionsPalette: () => {}, openRuntimeOptionsPalette: () => {},
openJimaku: () => {}, openJimaku: () => {},
openAnimetosho: () => {},
openYoutubeTrackPicker: () => {}, openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {}, openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
@@ -11,6 +11,7 @@ test('handle mpv command handler forwards command and built deps', () => {
triggerSubsyncFromConfig: () => {}, triggerSubsyncFromConfig: () => {},
openRuntimeOptionsPalette: () => {}, openRuntimeOptionsPalette: () => {},
openJimaku: () => {}, openJimaku: () => {},
openAnimetosho: () => {},
openYoutubeTrackPicker: () => {}, openYoutubeTrackPicker: () => {},
openPlaylistBrowser: () => {}, openPlaylistBrowser: () => {},
cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }), cycleRuntimeOption: () => ({ ok: false as const, error: 'x' }),
@@ -8,6 +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'),
openYoutubeTrackPicker: () => { openYoutubeTrackPicker: () => {
calls.push('youtube-picker'); calls.push('youtube-picker');
}, },
@@ -29,6 +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();
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' });
@@ -45,6 +47,7 @@ test('ipc mpv command main deps builder maps callbacks', () => {
'subsync', 'subsync',
'palette', 'palette',
'jimaku', 'jimaku',
'animetosho',
'youtube-picker', 'youtube-picker',
'playlist-browser', 'playlist-browser',
'osd:hello', 'osd:hello',
@@ -10,6 +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(),
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,6 +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'),
markAudioCard: async () => { markAudioCard: async () => {
calls.push('mark-audio'); calls.push('mark-audio');
}, },
@@ -13,6 +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(),
markAudioCard: () => deps.markAudioCard(), markAudioCard: () => deps.markAudioCard(),
copySubtitleMultiple: (timeoutMs: number) => deps.copySubtitleMultiple(timeoutMs), copySubtitleMultiple: (timeoutMs: number) => deps.copySubtitleMultiple(timeoutMs),
copySubtitle: () => deps.copySubtitle(), copySubtitle: () => deps.copySubtitle(),
+22
View File
@@ -34,6 +34,13 @@ import type {
JimakuFileEntry, JimakuFileEntry,
JimakuApiResponse, JimakuApiResponse,
JimakuDownloadResult, JimakuDownloadResult,
AnimetoshoSearchQuery,
AnimetoshoFilesQuery,
AnimetoshoDownloadQuery,
AnimetoshoEntry,
AnimetoshoSubtitleFile,
AnimetoshoApiResponse,
AnimetoshoDownloadResult,
SubsyncManualPayload, SubsyncManualPayload,
SubsyncManualRunRequest, SubsyncManualRunRequest,
SubsyncResult, SubsyncResult,
@@ -167,6 +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 onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload<YoutubePickerOpenPayload>( const onOpenYoutubeTrackPickerEvent = createQueuedIpcListenerWithPayload<YoutubePickerOpenPayload>(
IPC_CHANNELS.event.youtubePickerOpen, IPC_CHANNELS.event.youtubePickerOpen,
(payload) => payload as YoutubePickerOpenPayload, (payload) => payload as YoutubePickerOpenPayload,
@@ -350,6 +358,19 @@ 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: (
query: AnimetoshoSearchQuery,
): Promise<AnimetoshoApiResponse<AnimetoshoEntry[]>> =>
ipcRenderer.invoke(IPC_CHANNELS.request.animetoshoSearchEntries, query),
animetoshoListFiles: (
query: AnimetoshoFilesQuery,
): Promise<AnimetoshoApiResponse<AnimetoshoSubtitleFile[]>> =>
ipcRenderer.invoke(IPC_CHANNELS.request.animetoshoListFiles, query),
animetoshoDownloadFile: (query: AnimetoshoDownloadQuery): Promise<AnimetoshoDownloadResult> =>
ipcRenderer.invoke(IPC_CHANNELS.request.animetoshoDownloadFile, query),
animetoshoGetSecondaryLanguages: (): Promise<string[]> =>
ipcRenderer.invoke(IPC_CHANNELS.request.animetoshoGetSecondaryLanguages),
quitApp: () => { quitApp: () => {
ipcRenderer.send(IPC_CHANNELS.command.quitApp); ipcRenderer.send(IPC_CHANNELS.command.quitApp);
}, },
@@ -429,6 +450,7 @@ const electronAPI: ElectronAPI = {
onOpenControllerSelect: onOpenControllerSelectEvent, onOpenControllerSelect: onOpenControllerSelectEvent,
onOpenControllerDebug: onOpenControllerDebugEvent, onOpenControllerDebug: onOpenControllerDebugEvent,
onOpenJimaku: onOpenJimakuEvent, onOpenJimaku: onOpenJimakuEvent,
onOpenAnimetosho: onOpenAnimetoshoEvent,
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent, onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent, onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
onOpenCharacterDictionaryManager: onOpenCharacterDictionaryManagerEvent, onOpenCharacterDictionaryManager: onOpenCharacterDictionaryManagerEvent,
+2
View File
@@ -90,6 +90,7 @@ function createEmptyShortcuts(): ConfiguredShortcuts {
openCharacterDictionaryManager: null, openCharacterDictionaryManager: null,
openRuntimeOptions: null, openRuntimeOptions: null,
openJimaku: null, openJimaku: null,
openAnimetosho: null,
openSessionHelp: null, openSessionHelp: null,
openControllerSelect: null, openControllerSelect: null,
openControllerDebug: null, openControllerDebug: null,
@@ -491,6 +492,7 @@ function createKeyboardHandlerHarness() {
handleSubsyncKeydown: () => false, handleSubsyncKeydown: () => false,
handleKikuKeydown: () => false, handleKikuKeydown: () => false,
handleJimakuKeydown: () => false, handleJimakuKeydown: () => false,
handleAnimetoshoKeydown: () => false,
handleControllerSelectKeydown: () => { handleControllerSelectKeydown: () => {
controllerSelectKeydownCount += 1; controllerSelectKeydownCount += 1;
return true; return true;
+5
View File
@@ -16,6 +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;
handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean; handleYoutubePickerKeydown: (e: KeyboardEvent) => boolean;
handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean; handlePlaylistBrowserKeydown: (e: KeyboardEvent) => boolean;
handleControllerSelectKeydown: (e: KeyboardEvent) => boolean; handleControllerSelectKeydown: (e: KeyboardEvent) => boolean;
@@ -1119,6 +1120,10 @@ export function createKeyboardHandlers(
options.handleJimakuKeydown(e); options.handleJimakuKeydown(e);
return; return;
} }
if (ctx.state.animetoshoModalOpen) {
options.handleAnimetoshoKeydown(e);
return;
}
if (ctx.state.youtubePickerModalOpen) { if (ctx.state.youtubePickerModalOpen) {
if (options.handleYoutubePickerKeydown(e)) { if (options.handleYoutubePickerKeydown(e)) {
return; return;
+38
View File
@@ -116,6 +116,44 @@
</div> </div>
</div> </div>
</div> </div>
<div id="animetoshoModal" class="modal hidden" aria-hidden="true">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title">Animetosho Subtitles</div>
<button id="animetoshoClose" class="modal-close" type="button">Close</button>
</div>
<div class="modal-body">
<div class="animetosho-tabs">
<button id="animetoshoTabEnglish" class="animetosho-tab active" type="button">
English
</button>
<button id="animetoshoTabJapanese" class="animetosho-tab" type="button">
Japanese
</button>
</div>
<div class="jimaku-form">
<label class="jimaku-field">
<span>Title</span>
<input id="animetoshoTitle" type="text" placeholder="Anime title" />
</label>
<label class="jimaku-field">
<span>Episode</span>
<input id="animetoshoEpisode" type="number" min="1" placeholder="1" />
</label>
<button id="animetoshoSearch" class="jimaku-button" type="button">Search</button>
</div>
<div id="animetoshoStatus" class="jimaku-status"></div>
<div id="animetoshoEntriesSection" class="jimaku-section hidden">
<div class="jimaku-section-title">Releases</div>
<ul id="animetoshoEntries" class="jimaku-list"></ul>
</div>
<div id="animetoshoFilesSection" class="jimaku-section hidden">
<div class="jimaku-section-title">Subtitle tracks</div>
<ul id="animetoshoFiles" class="jimaku-list"></ul>
</div>
</div>
</div>
</div>
<div id="youtubePickerModal" class="modal hidden" aria-hidden="true"> <div id="youtubePickerModal" class="modal hidden" aria-hidden="true">
<div class="modal-content youtube-picker-content"> <div class="modal-content youtube-picker-content">
<div class="modal-header"> <div class="modal-header">
+388
View File
@@ -0,0 +1,388 @@
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
@@ -0,0 +1,487 @@
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,
};
}
@@ -104,6 +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.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';
@@ -148,6 +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.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)
) { ) {
@@ -221,6 +223,8 @@ function describeSessionAction(
return 'Open controller debug'; return 'Open controller debug';
case 'openJimaku': case 'openJimaku':
return 'Open jimaku'; return 'Open jimaku';
case 'openAnimetosho':
return 'Open animetosho';
case 'openYoutubePicker': case 'openYoutubePicker':
return 'Open YouTube subtitle picker'; return 'Open YouTube subtitle picker';
case 'openPlaylistBrowser': case 'openPlaylistBrowser':
@@ -260,6 +264,7 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
return 'Subtitle sync'; return 'Subtitle sync';
case 'openRuntimeOptions': case 'openRuntimeOptions':
case 'openJimaku': case 'openJimaku':
case 'openAnimetosho':
case 'openCharacterDictionaryManager': case 'openCharacterDictionaryManager':
case 'openControllerSelect': case 'openControllerSelect':
case 'openControllerDebug': case 'openControllerDebug':
+17
View File
@@ -32,6 +32,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 { 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';
@@ -93,6 +94,7 @@ function isAnyModalOpen(): boolean {
ctx.state.controllerSelectModalOpen || ctx.state.controllerSelectModalOpen ||
ctx.state.controllerDebugModalOpen || ctx.state.controllerDebugModalOpen ||
ctx.state.jimakuModalOpen || ctx.state.jimakuModalOpen ||
ctx.state.animetoshoModalOpen ||
ctx.state.kikuModalOpen || ctx.state.kikuModalOpen ||
ctx.state.runtimeOptionsModalOpen || ctx.state.runtimeOptionsModalOpen ||
ctx.state.characterDictionaryModalOpen || ctx.state.characterDictionaryModalOpen ||
@@ -172,6 +174,10 @@ const jimakuModal = createJimakuModal(ctx, {
modalStateReader: { isAnyModalOpen }, modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression, syncSettingsModalSubtitleSuppression,
}); });
const animetoshoModal = createAnimetoshoModal(ctx, {
modalStateReader: { isAnyModalOpen },
syncSettingsModalSubtitleSuppression,
});
const mouseHandlers = createMouseHandlers(ctx, { const mouseHandlers = createMouseHandlers(ctx, {
modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen }, modalStateReader: { isAnySettingsModalOpen, isAnyModalOpen },
applyYPercent: positioning.applyYPercent, applyYPercent: positioning.applyYPercent,
@@ -199,6 +205,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,
handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown, handleYoutubePickerKeydown: youtubePickerModal.handleYoutubePickerKeydown,
handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown, handlePlaylistBrowserKeydown: playlistBrowserModal.handlePlaylistBrowserKeydown,
handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown, handleControllerSelectKeydown: controllerSelectModal.handleControllerSelectKeydown,
@@ -272,6 +279,9 @@ function dismissActiveUiAfterError(): void {
if (ctx.state.jimakuModalOpen) { if (ctx.state.jimakuModalOpen) {
jimakuModal.closeJimakuModal(); jimakuModal.closeJimakuModal();
} }
if (ctx.state.animetoshoModalOpen) {
animetoshoModal.closeAnimetoshoModal();
}
if (ctx.state.youtubePickerModalOpen) { if (ctx.state.youtubePickerModalOpen) {
youtubePickerModal.closeYoutubePickerModal(); youtubePickerModal.closeYoutubePickerModal();
} }
@@ -527,6 +537,12 @@ function registerModalOpenHandlers(): void {
window.electronAPI.notifyOverlayModalOpened('jimaku'); window.electronAPI.notifyOverlayModalOpened('jimaku');
}); });
}); });
window.electronAPI.onOpenAnimetosho(() => {
runGuarded('animetosho:open', () => {
animetoshoModal.openAnimetoshoModal();
window.electronAPI.notifyOverlayModalOpened('animetosho');
});
});
window.electronAPI.onOpenYoutubeTrackPicker((payload) => { window.electronAPI.onOpenYoutubeTrackPicker((payload) => {
runGuarded('youtube:picker-open', () => { runGuarded('youtube:picker-open', () => {
youtubePickerModal.openYoutubePickerModal(payload); youtubePickerModal.openYoutubePickerModal(payload);
@@ -761,6 +777,7 @@ async function init(): Promise<void> {
}); });
jimakuModal.wireDomEvents(); jimakuModal.wireDomEvents();
animetoshoModal.wireDomEvents();
youtubePickerModal.wireDomEvents(); youtubePickerModal.wireDomEvents();
playlistBrowserModal.wireDomEvents(); playlistBrowserModal.wireDomEvents();
kikuModal.wireDomEvents(); kikuModal.wireDomEvents();
+18
View File
@@ -4,6 +4,8 @@ import type {
ControllerButtonSnapshot, ControllerButtonSnapshot,
ControllerDeviceInfo, ControllerDeviceInfo,
ResolvedControllerConfig, ResolvedControllerConfig,
AnimetoshoEntry,
AnimetoshoSubtitleFile,
JimakuEntry, JimakuEntry,
JimakuFileEntry, JimakuFileEntry,
KikuDuplicateCardInfo, KikuDuplicateCardInfo,
@@ -48,6 +50,14 @@ export type RendererState = {
currentEpisodeFilter: number | null; currentEpisodeFilter: number | null;
currentEntryId: number | null; currentEntryId: number | null;
animetoshoModalOpen: boolean;
animetoshoActiveTab: 'en' | 'ja';
animetoshoEntries: AnimetoshoEntry[];
animetoshoFiles: AnimetoshoSubtitleFile[];
selectedAnimetoshoEntryIndex: number;
selectedAnimetoshoFileIndex: number;
currentAnimetoshoEntryId: number | null;
youtubePickerModalOpen: boolean; youtubePickerModalOpen: boolean;
youtubePickerPayload: YoutubePickerOpenPayload | null; youtubePickerPayload: YoutubePickerOpenPayload | null;
youtubePickerPrimaryTrackId: string | null; youtubePickerPrimaryTrackId: string | null;
@@ -164,6 +174,14 @@ export function createRendererState(): RendererState {
currentEpisodeFilter: null, currentEpisodeFilter: null,
currentEntryId: null, currentEntryId: null,
animetoshoModalOpen: false,
animetoshoActiveTab: 'en',
animetoshoEntries: [],
animetoshoFiles: [],
selectedAnimetoshoEntryIndex: 0,
selectedAnimetoshoFileIndex: 0,
currentAnimetoshoEntryId: null,
youtubePickerModalOpen: false, youtubePickerModalOpen: false,
youtubePickerPayload: null, youtubePickerPayload: null,
youtubePickerPrimaryTrackId: null, youtubePickerPrimaryTrackId: null,
+44
View File
@@ -811,6 +811,50 @@ body:focus-visible,
z-index: 1100; z-index: 1100;
} }
#animetoshoModal {
z-index: 1100;
}
#animetoshoModal .jimaku-form {
grid-template-columns: 1fr 120px auto;
}
.animetosho-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.animetosho-tab {
min-width: 0;
min-height: 34px;
padding: 7px 8px;
border-radius: 7px;
border: 1px solid rgba(110, 115, 141, 0.22);
background: rgba(49, 50, 68, 0.76);
color: var(--ctp-subtext1);
font-size: 12px;
font-weight: 700;
line-height: 1.15;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.animetosho-tab:hover,
.animetosho-tab:focus-visible {
border-color: rgba(138, 173, 244, 0.48);
color: var(--ctp-text);
outline: none;
}
.animetosho-tab.active {
border-color: rgba(238, 212, 159, 0.62);
background: rgba(238, 212, 159, 0.16);
color: var(--ctp-yellow);
}
#youtubePickerModal { #youtubePickerModal {
z-index: 1110; z-index: 1110;
} }
+26
View File
@@ -22,6 +22,19 @@ export type RendererDom = {
jimakuFilesList: HTMLUListElement; jimakuFilesList: HTMLUListElement;
jimakuBroadenButton: HTMLButtonElement; jimakuBroadenButton: HTMLButtonElement;
animetoshoModal: HTMLDivElement;
animetoshoTitleInput: HTMLInputElement;
animetoshoEpisodeInput: HTMLInputElement;
animetoshoSearchButton: HTMLButtonElement;
animetoshoCloseButton: HTMLButtonElement;
animetoshoTabEnglishButton: HTMLButtonElement;
animetoshoTabJapaneseButton: HTMLButtonElement;
animetoshoStatus: HTMLDivElement;
animetoshoEntriesSection: HTMLDivElement;
animetoshoEntriesList: HTMLUListElement;
animetoshoFilesSection: HTMLDivElement;
animetoshoFilesList: HTMLUListElement;
youtubePickerModal: HTMLDivElement; youtubePickerModal: HTMLDivElement;
youtubePickerTitle: HTMLDivElement; youtubePickerTitle: HTMLDivElement;
youtubePickerPrimarySelect: HTMLSelectElement; youtubePickerPrimarySelect: HTMLSelectElement;
@@ -154,6 +167,19 @@ export function resolveRendererDom(): RendererDom {
jimakuFilesList: getRequiredElement<HTMLUListElement>('jimakuFiles'), jimakuFilesList: getRequiredElement<HTMLUListElement>('jimakuFiles'),
jimakuBroadenButton: getRequiredElement<HTMLButtonElement>('jimakuBroaden'), jimakuBroadenButton: getRequiredElement<HTMLButtonElement>('jimakuBroaden'),
animetoshoModal: getRequiredElement<HTMLDivElement>('animetoshoModal'),
animetoshoTitleInput: getRequiredElement<HTMLInputElement>('animetoshoTitle'),
animetoshoEpisodeInput: getRequiredElement<HTMLInputElement>('animetoshoEpisode'),
animetoshoSearchButton: getRequiredElement<HTMLButtonElement>('animetoshoSearch'),
animetoshoCloseButton: getRequiredElement<HTMLButtonElement>('animetoshoClose'),
animetoshoTabEnglishButton: getRequiredElement<HTMLButtonElement>('animetoshoTabEnglish'),
animetoshoTabJapaneseButton: getRequiredElement<HTMLButtonElement>('animetoshoTabJapanese'),
animetoshoStatus: getRequiredElement<HTMLDivElement>('animetoshoStatus'),
animetoshoEntriesSection: getRequiredElement<HTMLDivElement>('animetoshoEntriesSection'),
animetoshoEntriesList: getRequiredElement<HTMLUListElement>('animetoshoEntries'),
animetoshoFilesSection: getRequiredElement<HTMLDivElement>('animetoshoFilesSection'),
animetoshoFilesList: getRequiredElement<HTMLUListElement>('animetoshoFiles'),
youtubePickerModal: getRequiredElement<HTMLDivElement>('youtubePickerModal'), youtubePickerModal: getRequiredElement<HTMLDivElement>('youtubePickerModal'),
youtubePickerTitle: getRequiredElement<HTMLDivElement>('youtubePickerTitle'), youtubePickerTitle: getRequiredElement<HTMLDivElement>('youtubePickerTitle'),
youtubePickerPrimarySelect: getRequiredElement<HTMLSelectElement>('youtubePickerPrimarySelect'), youtubePickerPrimarySelect: getRequiredElement<HTMLSelectElement>('youtubePickerPrimarySelect'),
+6
View File
@@ -5,6 +5,7 @@ export const OVERLAY_HOSTED_MODALS = [
'runtime-options', 'runtime-options',
'subsync', 'subsync',
'jimaku', 'jimaku',
'animetosho',
'youtube-track-picker', 'youtube-track-picker',
'playlist-browser', 'playlist-browser',
'kiku', 'kiku',
@@ -95,6 +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',
animetoshoListFiles: 'animetosho:list-files',
animetoshoDownloadFile: 'animetosho:download-file',
animetoshoGetSecondaryLanguages: 'animetosho:get-secondary-languages',
kikuBuildMergePreview: 'kiku:build-merge-preview', kikuBuildMergePreview: 'kiku:build-merge-preview',
statsGetOverview: 'stats:get-overview', statsGetOverview: 'stats:get-overview',
statsGetDailyRollups: 'stats:get-daily-rollups', statsGetDailyRollups: 'stats:get-daily-rollups',
@@ -134,6 +139,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',
youtubePickerOpen: 'youtube:picker-open', youtubePickerOpen: 'youtube:picker-open',
youtubePickerCancel: 'youtube:picker-cancel', youtubePickerCancel: 'youtube:picker-cancel',
playlistBrowserOpen: 'playlist-browser:open', playlistBrowserOpen: 'playlist-browser:open',
+34
View File
@@ -1,5 +1,8 @@
import type { KikuFieldGroupingChoice, KikuMergePreviewRequest } from '../../types/anki'; import type { KikuFieldGroupingChoice, KikuMergePreviewRequest } from '../../types/anki';
import type { import type {
AnimetoshoDownloadQuery,
AnimetoshoFilesQuery,
AnimetoshoSearchQuery,
JimakuDownloadQuery, JimakuDownloadQuery,
JimakuFilesQuery, JimakuFilesQuery,
JimakuSearchQuery, JimakuSearchQuery,
@@ -40,6 +43,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
'openControllerSelect', 'openControllerSelect',
'openControllerDebug', 'openControllerDebug',
'openJimaku', 'openJimaku',
'openAnimetosho',
'openYoutubePicker', 'openYoutubePicker',
'openPlaylistBrowser', 'openPlaylistBrowser',
'replayCurrentSubtitle', 'replayCurrentSubtitle',
@@ -402,6 +406,36 @@ export function parseJimakuDownloadQuery(value: unknown): JimakuDownloadQuery |
}; };
} }
export function parseAnimetoshoSearchQuery(value: unknown): AnimetoshoSearchQuery | null {
if (!isObject(value) || typeof value.query !== 'string') return null;
return { query: value.query };
}
export function parseAnimetoshoFilesQuery(value: unknown): AnimetoshoFilesQuery | null {
if (!isObject(value) || !isInteger(value.entryId)) return null;
return { entryId: value.entryId };
}
export function parseAnimetoshoDownloadQuery(value: unknown): AnimetoshoDownloadQuery | null {
if (!isObject(value)) return null;
if (
!isInteger(value.entryId) ||
typeof value.url !== 'string' ||
typeof value.name !== 'string'
) {
return null;
}
if (value.lang !== undefined && typeof value.lang !== 'string') {
return null;
}
return {
entryId: value.entryId,
url: value.url,
name: value.name,
lang: value.lang,
};
}
export function parseYoutubePickerResolveRequest( export function parseYoutubePickerResolveRequest(
value: unknown, value: unknown,
): YoutubePickerResolveRequest | null { ): YoutubePickerResolveRequest | null {
+7
View File
@@ -10,6 +10,7 @@ import type {
ImmersionTrackingConfig, ImmersionTrackingConfig,
ImmersionTrackingRetentionMode, ImmersionTrackingRetentionMode,
ImmersionTrackingRetentionPreset, ImmersionTrackingRetentionPreset,
AnimetoshoConfig,
JellyfinConfig, JellyfinConfig,
JimakuConfig, JimakuConfig,
JimakuLanguagePreference, JimakuLanguagePreference,
@@ -122,6 +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;
openSessionHelp?: string | null; openSessionHelp?: string | null;
openControllerSelect?: string | null; openControllerSelect?: string | null;
openControllerDebug?: string | null; openControllerDebug?: string | null;
@@ -147,6 +149,7 @@ export interface Config {
subtitleSidebar?: SubtitleSidebarConfig; subtitleSidebar?: SubtitleSidebarConfig;
auto_start_overlay?: boolean; auto_start_overlay?: boolean;
jimaku?: JimakuConfig; jimaku?: JimakuConfig;
animetosho?: AnimetoshoConfig;
anilist?: AnilistConfig; anilist?: AnilistConfig;
yomitan?: YomitanConfig; yomitan?: YomitanConfig;
jellyfin?: JellyfinConfig; jellyfin?: JellyfinConfig;
@@ -303,6 +306,10 @@ export interface ResolvedConfig {
languagePreference: JimakuLanguagePreference; languagePreference: JimakuLanguagePreference;
maxEntryResults: number; maxEntryResults: number;
}; };
animetosho: AnimetoshoConfig & {
apiBaseUrl: string;
maxSearchResults: number;
};
anilist: { anilist: {
enabled: boolean; enabled: boolean;
accessToken: string; accessToken: string;
+42
View File
@@ -240,3 +240,45 @@ export type JimakuApiResponse<T> = { ok: true; data: T } | { ok: false; error: J
export type JimakuDownloadResult = export type JimakuDownloadResult =
| { ok: true; path: string } | { ok: true; path: string }
| { ok: false; error: JimakuApiError }; | { ok: false; error: JimakuApiError };
export interface AnimetoshoSearchQuery {
query: string;
}
export interface AnimetoshoEntry {
id: number;
title: string;
timestamp: number | null;
totalSize: number | null;
numFiles: number | null;
}
export interface AnimetoshoFilesQuery {
entryId: number;
}
export interface AnimetoshoSubtitleFile {
attachmentId: number;
filename: string;
lang: string;
trackName: string | null;
size: number;
url: string;
sourceFilename: string;
}
export interface AnimetoshoDownloadQuery {
entryId: number;
url: string;
name: string;
lang?: string;
}
export type AnimetoshoApiResponse<T> = JimakuApiResponse<T>;
export type AnimetoshoDownloadResult = JimakuDownloadResult;
export interface AnimetoshoConfig {
apiBaseUrl?: string;
maxSearchResults?: number;
}
+18
View File
@@ -12,6 +12,13 @@ import type {
SessionBindingWarning, SessionBindingWarning,
} from './session-bindings'; } from './session-bindings';
import type { import type {
AnimetoshoApiResponse,
AnimetoshoDownloadQuery,
AnimetoshoDownloadResult,
AnimetoshoEntry,
AnimetoshoFilesQuery,
AnimetoshoSearchQuery,
AnimetoshoSubtitleFile,
JimakuApiResponse, JimakuApiResponse,
JimakuDownloadQuery, JimakuDownloadQuery,
JimakuDownloadResult, JimakuDownloadResult,
@@ -454,6 +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: (
query: AnimetoshoSearchQuery,
) => Promise<AnimetoshoApiResponse<AnimetoshoEntry[]>>;
animetoshoListFiles: (
query: AnimetoshoFilesQuery,
) => Promise<AnimetoshoApiResponse<AnimetoshoSubtitleFile[]>>;
animetoshoDownloadFile: (query: AnimetoshoDownloadQuery) => Promise<AnimetoshoDownloadResult>;
animetoshoGetSecondaryLanguages: () => Promise<string[]>;
quitApp: () => void; quitApp: () => void;
toggleDevTools: () => void; toggleDevTools: () => void;
toggleOverlay: () => void; toggleOverlay: () => void;
@@ -486,6 +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;
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;
@@ -530,6 +546,7 @@ export interface ElectronAPI {
| 'runtime-options' | 'runtime-options'
| 'subsync' | 'subsync'
| 'jimaku' | 'jimaku'
| 'animetosho'
| 'youtube-track-picker' | 'youtube-track-picker'
| 'playlist-browser' | 'playlist-browser'
| 'kiku' | 'kiku'
@@ -544,6 +561,7 @@ export interface ElectronAPI {
| 'runtime-options' | 'runtime-options'
| 'subsync' | 'subsync'
| 'jimaku' | 'jimaku'
| 'animetosho'
| 'youtube-track-picker' | 'youtube-track-picker'
| 'playlist-browser' | 'playlist-browser'
| 'kiku' | 'kiku'
+1
View File
@@ -22,6 +22,7 @@ export type SessionActionId =
| 'openControllerSelect' | 'openControllerSelect'
| 'openControllerDebug' | 'openControllerDebug'
| 'openJimaku' | 'openJimaku'
| 'openAnimetosho'
| 'openYoutubePicker' | 'openYoutubePicker'
| 'openPlaylistBrowser' | 'openPlaylistBrowser'
| 'replayCurrentSubtitle' | 'replayCurrentSubtitle'