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

This commit is contained in:
2026-07-13 22:03:35 -07:00
committed by GitHub
parent 49b926e08c
commit 2e2ee3f028
90 changed files with 2253 additions and 1695 deletions
+33 -33
View File
@@ -55,10 +55,10 @@ test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => {
isRemoteMediaPath: () => false,
downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }),
onDownloadedSubtitle: () => {},
searchAnimetoshoEntries: async () => ({ ok: true, data: [] }),
listAnimetoshoFiles: async () => ({ ok: true, data: [] }),
downloadAnimetoshoSubtitle: async () => ({ ok: true, path: '/tmp/sub.en.ass' }),
getAnimetoshoSecondaryLanguages: () => ['en'],
searchTsukihimeEntries: async () => ({ ok: true, data: [] }),
listTsukihimeFiles: async () => ({ ok: true, data: [] }),
downloadTsukihimeSubtitle: async () => ({ ok: true, path: '/tmp/sub.en.ass' }),
getTsukihimeSecondaryLanguages: () => ['en'],
onDownloadedSecondarySubtitle: () => {},
},
registrar,
@@ -98,41 +98,41 @@ test('anki/jimaku IPC handlers reject malformed invoke payloads', async () => {
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, {
const tsukihimeSearchHandler = handleHandlers.get(IPC_CHANNELS.request.tsukihimeSearchEntries);
assert.ok(tsukihimeSearchHandler);
const invalidTsukihimeSearch = await tsukihimeSearchHandler!({}, { query: 12 });
assert.deepEqual(invalidTsukihimeSearch, {
ok: false,
error: { error: 'Invalid Animetosho search query payload', code: 400 },
error: { error: 'Invalid TsukiHime search query payload', code: 400 },
});
const animetoshoFilesHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoListFiles);
assert.ok(animetoshoFilesHandler);
const invalidAnimetoshoFiles = await animetoshoFilesHandler!({}, { entryId: 'x' });
assert.deepEqual(invalidAnimetoshoFiles, {
const tsukihimeFilesHandler = handleHandlers.get(IPC_CHANNELS.request.tsukihimeListFiles);
assert.ok(tsukihimeFilesHandler);
const invalidTsukihimeFiles = await tsukihimeFilesHandler!({}, { entryId: 'x' });
assert.deepEqual(invalidTsukihimeFiles, {
ok: false,
error: { error: 'Invalid Animetosho files query payload', code: 400 },
error: { error: 'Invalid TsukiHime files query payload', code: 400 },
});
const animetoshoDownloadHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoDownloadFile);
assert.ok(animetoshoDownloadHandler);
const invalidAnimetoshoDownload = await animetoshoDownloadHandler!({}, { entryId: 1, url: '/x' });
assert.deepEqual(invalidAnimetoshoDownload, {
const tsukihimeDownloadHandler = handleHandlers.get(IPC_CHANNELS.request.tsukihimeDownloadFile);
assert.ok(tsukihimeDownloadHandler);
const invalidTsukihimeDownload = await tsukihimeDownloadHandler!({}, { entryId: 1, url: '/x' });
assert.deepEqual(invalidTsukihimeDownload, {
ok: false,
error: { error: 'Invalid Animetosho download query payload', code: 400 },
error: { error: 'Invalid TsukiHime download query payload', code: 400 },
});
const foreignUrlDownload = await animetoshoDownloadHandler!(
const foreignUrlDownload = await tsukihimeDownloadHandler!(
{},
{ entryId: 1, url: 'https://evil.example/attach/00000001/1.xz', name: 'sub.ass' },
);
assert.deepEqual(foreignUrlDownload, {
ok: false,
error: { error: 'Refusing to download subtitle from a non-Animetosho URL.', code: 400 },
error: { error: 'Refusing to download subtitle from a non-TsukiHime URL.', code: 400 },
});
});
test('animetosho downloads route by language: secondary for eng, primary for jpn', async () => {
test('tsukihime downloads always route Japanese as primary', async () => {
const { registrar, handleHandlers } = createFakeRegistrar();
const primaryLoads: string[] = [];
const secondaryLoads: string[] = [];
@@ -160,10 +160,10 @@ test('animetosho downloads route by language: secondary for eng, primary for jpn
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'],
searchTsukihimeEntries: async () => ({ ok: true, data: [] }),
listTsukihimeFiles: async () => ({ ok: true, data: [] }),
downloadTsukihimeSubtitle: async (_url, destPath) => ({ ok: true, path: destPath }),
getTsukihimeSecondaryLanguages: () => ['ja'],
onDownloadedSecondarySubtitle: (path) => {
secondaryLoads.push(path);
},
@@ -171,13 +171,13 @@ test('animetosho downloads route by language: secondary for eng, primary for jpn
registrar,
);
const downloadHandler = handleHandlers.get(IPC_CHANNELS.request.animetoshoDownloadFile)!;
const downloadHandler = handleHandlers.get(IPC_CHANNELS.request.tsukihimeDownloadFile)!;
const engResult = (await downloadHandler!(
{},
{
entryId: 1,
url: 'https://animetosho.org/storage/attach/00000001/1.xz',
url: 'https://storage.tsukihime.org/attach/00000001/1.xz',
name: 'episode.eng.ass',
lang: 'eng',
},
@@ -191,7 +191,7 @@ test('animetosho downloads route by language: secondary for eng, primary for jpn
{},
{
entryId: 1,
url: 'https://animetosho.org/storage/attach/00000002/2.xz',
url: 'https://storage.tsukihime.org/attach/00000002/2.xz',
name: 'episode.jpn.ass',
lang: 'jpn',
},
@@ -232,10 +232,10 @@ test('anki/jimaku IPC command handlers ignore malformed payloads', () => {
isRemoteMediaPath: () => false,
downloadToFile: async () => ({ ok: true, path: '/tmp/sub.ass' }),
onDownloadedSubtitle: () => {},
searchAnimetoshoEntries: async () => ({ ok: true, data: [] }),
listAnimetoshoFiles: async () => ({ ok: true, data: [] }),
downloadAnimetoshoSubtitle: async () => ({ ok: true, path: '/tmp/sub.en.ass' }),
getAnimetoshoSecondaryLanguages: () => ['en'],
searchTsukihimeEntries: async () => ({ ok: true, data: [] }),
listTsukihimeFiles: async () => ({ ok: true, data: [] }),
downloadTsukihimeSubtitle: async () => ({ ok: true, path: '/tmp/sub.en.ass' }),
getTsukihimeSecondaryLanguages: () => ['en'],
onDownloadedSecondarySubtitle: () => {},
},
registrar,
+44 -44
View File
@@ -4,12 +4,12 @@ import * as path from 'path';
import * as os from 'os';
import { createLogger } from '../../logger';
import {
AnimetoshoApiResponse,
AnimetoshoDownloadResult,
AnimetoshoEntry,
AnimetoshoFilesQuery,
AnimetoshoSearchQuery,
AnimetoshoSubtitleFile,
TsukihimeApiResponse,
TsukihimeDownloadResult,
TsukihimeEntry,
TsukihimeFilesQuery,
TsukihimeSearchQuery,
TsukihimeSubtitleFile,
JimakuApiResponse,
JimakuDownloadResult,
JimakuEntry,
@@ -23,9 +23,9 @@ import {
} from '../../types';
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
import {
parseAnimetoshoDownloadQuery,
parseAnimetoshoFilesQuery,
parseAnimetoshoSearchQuery,
parseTsukihimeDownloadQuery,
parseTsukihimeFilesQuery,
parseTsukihimeSearchQuery,
parseJimakuDownloadQuery,
parseJimakuFilesQuery,
parseJimakuSearchQuery,
@@ -33,7 +33,7 @@ import {
parseKikuMergePreviewRequest,
} from '../../shared/ipc/validators';
import { buildJimakuSubtitleFilenameFromMediaPath } from './jimaku-download-path';
import { animetoshoLangToFilenameSuffix, isAnimetoshoDownloadUrl } from '../../animetosho/utils';
import { tsukihimeLangToFilenameSuffix, isTsukihimeDownloadUrl } from '../../tsukihime/utils';
const { ipcMain } = electron;
@@ -57,14 +57,14 @@ export interface AnkiJimakuIpcDeps {
headers: Record<string, string>,
) => Promise<JimakuDownloadResult>;
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[];
searchTsukihimeEntries: (
query: TsukihimeSearchQuery,
) => Promise<TsukihimeApiResponse<TsukihimeEntry[]>>;
listTsukihimeFiles: (
query: TsukihimeFilesQuery,
) => Promise<TsukihimeApiResponse<TsukihimeSubtitleFile[]>>;
downloadTsukihimeSubtitle: (url: string, destPath: string) => Promise<TsukihimeDownloadResult>;
getTsukihimeSecondaryLanguages: () => string[];
onDownloadedSecondarySubtitle: (pathToSubtitle: string) => void | Promise<void>;
}
@@ -206,50 +206,50 @@ export function registerAnkiJimakuIpcHandlers(
},
);
ipc.handle(IPC_CHANNELS.request.animetoshoGetSecondaryLanguages, (): string[] => {
return deps.getAnimetoshoSecondaryLanguages();
ipc.handle(IPC_CHANNELS.request.tsukihimeGetSecondaryLanguages, (): string[] => {
return deps.getTsukihimeSecondaryLanguages();
});
ipc.handle(
IPC_CHANNELS.request.animetoshoSearchEntries,
async (_event, query: unknown): Promise<AnimetoshoApiResponse<AnimetoshoEntry[]>> => {
const parsedQuery = parseAnimetoshoSearchQuery(query);
IPC_CHANNELS.request.tsukihimeSearchEntries,
async (_event, query: unknown): Promise<TsukihimeApiResponse<TsukihimeEntry[]>> => {
const parsedQuery = parseTsukihimeSearchQuery(query);
if (!parsedQuery) {
return {
ok: false,
error: { error: 'Invalid Animetosho search query payload', code: 400 },
error: { error: 'Invalid TsukiHime search query payload', code: 400 },
};
}
return deps.searchAnimetoshoEntries(parsedQuery);
return deps.searchTsukihimeEntries(parsedQuery);
},
);
ipc.handle(
IPC_CHANNELS.request.animetoshoListFiles,
async (_event, query: unknown): Promise<AnimetoshoApiResponse<AnimetoshoSubtitleFile[]>> => {
const parsedQuery = parseAnimetoshoFilesQuery(query);
IPC_CHANNELS.request.tsukihimeListFiles,
async (_event, query: unknown): Promise<TsukihimeApiResponse<TsukihimeSubtitleFile[]>> => {
const parsedQuery = parseTsukihimeFilesQuery(query);
if (!parsedQuery) {
return { ok: false, error: { error: 'Invalid Animetosho files query payload', code: 400 } };
return { ok: false, error: { error: 'Invalid TsukiHime files query payload', code: 400 } };
}
return deps.listAnimetoshoFiles(parsedQuery);
return deps.listTsukihimeFiles(parsedQuery);
},
);
ipc.handle(
IPC_CHANNELS.request.animetoshoDownloadFile,
async (_event, query: unknown): Promise<AnimetoshoDownloadResult> => {
const parsedQuery = parseAnimetoshoDownloadQuery(query);
IPC_CHANNELS.request.tsukihimeDownloadFile,
async (_event, query: unknown): Promise<TsukihimeDownloadResult> => {
const parsedQuery = parseTsukihimeDownloadQuery(query);
if (!parsedQuery) {
return {
ok: false,
error: { error: 'Invalid Animetosho download query payload', code: 400 },
error: { error: 'Invalid TsukiHime download query payload', code: 400 },
};
}
if (!isAnimetoshoDownloadUrl(parsedQuery.url)) {
if (!isTsukihimeDownloadUrl(parsedQuery.url)) {
return {
ok: false,
error: { error: 'Refusing to download subtitle from a non-Animetosho URL.', code: 400 },
error: { error: 'Refusing to download subtitle from a non-TsukiHime URL.', code: 400 },
};
}
@@ -259,13 +259,13 @@ export function registerAnkiJimakuIpcHandlers(
}
const mediaDir = deps.isRemoteMediaPath(currentMediaPath)
? fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-animetosho-'))
? fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-tsukihime-'))
: 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 languageSuffix = tsukihimeLangToFilenameSuffix(parsedQuery.lang);
const subtitleFilename = buildJimakuSubtitleFilenameFromMediaPath(
currentMediaPath,
safeName,
@@ -276,24 +276,24 @@ export function registerAnkiJimakuIpcHandlers(
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}`);
targetPath = path.join(mediaDir, `${baseName} (tsukihime-${parsedQuery.entryId})${ext}`);
let counter = 2;
while (fs.existsSync(targetPath)) {
targetPath = path.join(
mediaDir,
`${baseName} (animetosho-${parsedQuery.entryId}-${counter})${ext}`,
`${baseName} (tsukihime-${parsedQuery.entryId}-${counter})${ext}`,
);
counter += 1;
}
}
logger.info(
`[animetosho] download-file name="${parsedQuery.name}" entryId=${parsedQuery.entryId}`,
`[tsukihime] download-file name="${parsedQuery.name}" entryId=${parsedQuery.entryId}`,
);
const result = await deps.downloadAnimetoshoSubtitle(parsedQuery.url, targetPath);
const result = await deps.downloadTsukihimeSubtitle(parsedQuery.url, targetPath);
if (result.ok) {
logger.info(`[animetosho] download-file saved to ${result.path}`);
logger.info(`[tsukihime] download-file saved to ${result.path}`);
// Japanese tracks take the primary slot; anything else loads as the
// secondary subtitle so the Japanese primary stays in place.
if (languageSuffix === 'ja') {
@@ -303,7 +303,7 @@ export function registerAnkiJimakuIpcHandlers(
}
} else {
logger.error(
`[animetosho] download-file failed: ${result.error?.error ?? 'unknown error'}`,
`[tsukihime] download-file failed: ${result.error?.error ?? 'unknown error'}`,
);
}
+31 -29
View File
@@ -11,7 +11,7 @@ interface RuntimeHarness {
patches: boolean[];
broadcasts: number;
fetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
animetoshoFetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
tsukihimeFetchCalls: Array<{ endpoint: string; query?: Record<string, unknown> }>;
sentCommands: Array<{ command: (string | number)[] }>;
};
}
@@ -26,7 +26,7 @@ function createHarness(): RuntimeHarness {
endpoint: string;
query?: Record<string, unknown>;
}>,
animetoshoFetchCalls: [] as Array<{
tsukihimeFetchCalls: [] as Array<{
endpoint: string;
query?: Record<string, unknown>;
}>,
@@ -37,17 +37,18 @@ function createHarness(): RuntimeHarness {
patchAnkiConnectEnabled: (enabled) => {
state.patches.push(enabled);
},
getResolvedConfig: () => ({ animetosho: { maxSearchResults: 2 } }),
getResolvedConfig: () => ({ tsukihime: { maxSearchResults: 2 } }),
getRuntimeOptionsManager: () => null,
animetoshoFetchJson: async (endpoint, query) => {
state.animetoshoFetchCalls.push({
tsukihimeFetchJson: async (endpoint, query) => {
state.tsukihimeFetchCalls.push({
endpoint,
query: query as Record<string, unknown>,
});
if ((query as Record<string, unknown>)?.show === 'torrent') {
if (endpoint.startsWith('/torrents/')) {
return {
ok: true,
data: {
id: 606713,
files: [
{
id: 9,
@@ -55,9 +56,8 @@ function createHarness(): RuntimeHarness {
attachments: [
{
id: 1955356,
type: 'subtitle',
info: { codec: 'ASS', lang: 'eng', name: 'English subs' },
size: 33075,
type: 1,
info: { codec: 'ASS', lang: 'en', name: 'English subs' },
},
],
},
@@ -67,11 +67,13 @@ function createHarness(): RuntimeHarness {
}
return {
ok: true,
data: [
{ id: 1, title: 'release a' },
{ id: 2, title: 'release b' },
{ id: 3, title: 'release c' },
] as never,
data: {
results: [
{ id: 1, name: 'release a' },
{ id: 2, name: 'release b' },
{ id: 3, name: 'release c' },
],
} as never,
};
},
getSubtitleTimingTracker: () => null,
@@ -172,9 +174,9 @@ test('registerAnkiJimakuIpcRuntime provides full handler surface', () => {
'isRemoteMediaPath',
'downloadToFile',
'onDownloadedSubtitle',
'searchAnimetoshoEntries',
'listAnimetoshoFiles',
'downloadAnimetoshoSubtitle',
'searchTsukihimeEntries',
'listTsukihimeFiles',
'downloadTsukihimeSubtitle',
'onDownloadedSecondarySubtitle',
];
@@ -365,14 +367,14 @@ test('onDownloadedSecondarySubtitle retries until mpv reports the new track', as
});
});
test('searchAnimetoshoEntries caps results using animetosho.maxSearchResults', async () => {
test('searchTsukihimeEntries caps results using tsukihime.maxSearchResults', async () => {
const { registered, state } = createHarness();
const searchResult = await registered.searchAnimetoshoEntries!({ query: 'frieren 28' });
assert.deepEqual(state.animetoshoFetchCalls, [
const searchResult = await registered.searchTsukihimeEntries!({ query: 'frieren 28' });
assert.deepEqual(state.tsukihimeFetchCalls, [
{
endpoint: '/json',
query: { q: 'frieren 28', qx: 1 },
endpoint: '/search/torrents',
query: { q: 'frieren 28', limit: 2 },
},
]);
assert.equal((searchResult as { ok: boolean }).ok, true);
@@ -384,20 +386,20 @@ test('searchAnimetoshoEntries caps results using animetosho.maxSearchResults', a
);
});
test('listAnimetoshoFiles extracts subtitle attachments from torrent detail', async () => {
test('listTsukihimeFiles extracts subtitle attachments from torrent detail', async () => {
const { registered, state } = createHarness();
const filesResult = await registered.listAnimetoshoFiles!({ entryId: 606713 });
assert.deepEqual(state.animetoshoFetchCalls, [
const filesResult = await registered.listTsukihimeFiles!({ entryId: 606713 });
assert.deepEqual(state.tsukihimeFetchCalls, [
{
endpoint: '/json',
query: { show: 'torrent', id: 606713 },
endpoint: '/torrents/606713',
query: {},
},
]);
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');
assert.equal(files[0]!.filename, 'episode.en.ass');
assert.equal(files[0]!.url, 'https://storage.tsukihime.org/attach/001dd61c/1955356.xz');
});
+44 -42
View File
@@ -3,8 +3,8 @@ import { AnkiIntegration } from '../../anki-integration';
import { mergeAiConfig } from '../../ai/config';
import {
AiConfig,
AnimetoshoApiResponse,
AnimetoshoConfig,
TsukihimeApiResponse,
TsukihimeConfig,
AnkiConnectConfig,
JimakuApiResponse,
JimakuEntry,
@@ -17,13 +17,13 @@ import {
} from '../../types';
import { sortJimakuFiles } from '../../jimaku/utils';
import {
ANIMETOSHO_FEED_BASE_URL,
animetoshoFetchJson as animetoshoFetchJsonRequest,
TSUKIHIME_API_BASE_URL,
tsukihimeFetchJson as tsukihimeFetchJsonRequest,
decompressXzFile,
extractAnimetoshoSubtitleFiles,
isAnimetoshoDownloadUrl,
mapAnimetoshoSearchResults,
} from '../../animetosho/utils';
extractTsukihimeSubtitleFiles,
isTsukihimeDownloadUrl,
mapTsukihimeSearchResults,
} from '../../tsukihime/utils';
import type { AnkiJimakuIpcDeps } from './anki-jimaku-ipc';
import { createLogger } from '../../logger';
@@ -48,7 +48,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
getResolvedConfig: () => {
ankiConnect?: AnkiConnectConfig;
ai?: AiConfig;
animetosho?: AnimetoshoConfig;
tsukihime?: TsukihimeConfig;
secondarySub?: { secondarySubLanguages?: string[] };
};
getRuntimeOptionsManager: () => RuntimeOptionsManagerLike | null;
@@ -77,10 +77,10 @@ export interface AnkiJimakuIpcRuntimeOptions {
endpoint: string,
query?: Record<string, string | number | boolean | null | undefined>,
) => Promise<JimakuApiResponse<T>>;
animetoshoFetchJson?: <T>(
tsukihimeFetchJson?: <T>(
endpoint: string,
query?: Record<string, string | number | boolean | null | undefined>,
) => Promise<AnimetoshoApiResponse<T>>;
) => Promise<TsukihimeApiResponse<T>>;
getJimakuMaxEntryResults: () => number;
getJimakuLanguagePreference: () => JimakuLanguagePreference;
resolveJimakuApiKey: () => Promise<string | null>;
@@ -101,7 +101,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
const logger = createLogger('main:anki-jimaku');
const DEFAULT_ANIMETOSHO_MAX_SEARCH_RESULTS = 10;
const DEFAULT_TSUKIHIME_MAX_SEARCH_RESULTS = 10;
const SECONDARY_TRACK_LOOKUP_ATTEMPTS = 5;
const SECONDARY_TRACK_LOOKUP_RETRY_MS = 100;
@@ -109,24 +109,24 @@ function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getAnimetoshoMaxSearchResults(options: AnkiJimakuIpcRuntimeOptions): number {
const value = options.getResolvedConfig().animetosho?.maxSearchResults;
function getTsukihimeMaxSearchResults(options: AnkiJimakuIpcRuntimeOptions): number {
const value = options.getResolvedConfig().tsukihime?.maxSearchResults;
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
return DEFAULT_ANIMETOSHO_MAX_SEARCH_RESULTS;
return DEFAULT_TSUKIHIME_MAX_SEARCH_RESULTS;
}
function animetoshoFetch<T>(
function tsukihimeFetch<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);
): Promise<TsukihimeApiResponse<T>> {
if (options.tsukihimeFetchJson) {
return options.tsukihimeFetchJson<T>(endpoint, query);
}
const baseUrl = options.getResolvedConfig().animetosho?.apiBaseUrl || ANIMETOSHO_FEED_BASE_URL;
return animetoshoFetchJsonRequest<T>(endpoint, query, { baseUrl });
const baseUrl = options.getResolvedConfig().tsukihime?.apiBaseUrl || TSUKIHIME_API_BASE_URL;
return tsukihimeFetchJsonRequest<T>(endpoint, query, { baseUrl });
}
export function registerAnkiJimakuIpcRuntime(
@@ -242,39 +242,41 @@ export function registerAnkiJimakuIpcRuntime(
isRemoteMediaPath: (mediaPath) => options.isRemoteMediaPath(mediaPath),
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', {
searchTsukihimeEntries: async (query) => {
logger.info(`[tsukihime] search-entries query: "${query.query}"`);
const maxResults = getTsukihimeMaxSearchResults(options);
const response = await tsukihimeFetch<unknown>(options, '/search/torrents', {
q: query.query,
qx: 1,
// The API caps limit at 100.
limit: Math.min(maxResults, 100),
});
if (!response.ok) return response;
const maxResults = getAnimetoshoMaxSearchResults(options);
const entries = mapAnimetoshoSearchResults(response.data, maxResults);
logger.info(`[animetosho] search-entries returned ${entries.length} results`);
const entries = mapTsukihimeSearchResults(response.data, maxResults);
logger.info(`[tsukihime] 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,
});
listTsukihimeFiles: async (query) => {
logger.info(`[tsukihime] list-files entryId=${query.entryId}`);
const response = await tsukihimeFetch<unknown>(
options,
`/torrents/${encodeURIComponent(query.entryId)}`,
{},
);
if (!response.ok) return response;
const files = extractAnimetoshoSubtitleFiles(response.data);
logger.info(`[animetosho] list-files returned ${files.length} subtitle attachments`);
const files = extractTsukihimeSubtitleFiles(response.data);
logger.info(`[tsukihime] list-files returned ${files.length} subtitle attachments`);
return { ok: true, data: files };
},
getAnimetoshoSecondaryLanguages: () =>
getTsukihimeSecondaryLanguages: () =>
options.getResolvedConfig().secondarySub?.secondarySubLanguages ?? [],
downloadAnimetoshoSubtitle: async (url, destPath) => {
downloadTsukihimeSubtitle: 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) },
// The /tosho/ mirror 302s to storage.animetosho.org; keep the hop in-allowlist.
{ isAllowedRedirect: (redirectUrl) => isTsukihimeDownloadUrl(redirectUrl) },
);
if (!downloaded.ok) return downloaded;
const result = await decompressXzFile(tempXzPath, destPath);
@@ -310,14 +312,14 @@ export function registerAnkiJimakuIpcRuntime(
return;
}
} catch (error) {
logger.warn('[animetosho] failed to select downloaded subtitle as secondary:', error);
logger.warn('[tsukihime] failed to select downloaded subtitle as secondary:', error);
return;
}
await delay(SECONDARY_TRACK_LOOKUP_RETRY_MS);
}
logger.warn(
`[animetosho] could not find downloaded subtitle in track-list: ${pathToSubtitle}`,
`[tsukihime] could not find downloaded subtitle in track-list: ${pathToSubtitle}`,
);
},
});
+1 -1
View File
@@ -40,7 +40,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
openAnimetosho: false,
openTsukihime: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
replayCurrentSubtitle: false,
+1 -1
View File
@@ -45,7 +45,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
openAnimetosho: false,
openTsukihime: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
togglePrimarySubtitleBar: false,
+4 -4
View File
@@ -544,11 +544,11 @@ export function handleCliCommand(
);
} else if (args.openJimaku) {
dispatchCliSessionAction({ actionId: 'openJimaku' }, 'openJimaku', 'Open jimaku failed');
} else if (args.openAnimetosho) {
} else if (args.openTsukihime) {
dispatchCliSessionAction(
{ actionId: 'openAnimetosho' },
'openAnimetosho',
'Open animetosho failed',
{ actionId: 'openTsukihime' },
'openTsukihime',
'Open tsukihime failed',
);
} else if (args.openYoutubePicker) {
dispatchCliSessionAction(
+12 -2
View File
@@ -13,6 +13,7 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
RUNTIME_OPTIONS_OPEN: '__runtime-options-open',
JIMAKU_OPEN: '__jimaku-open',
ANIMETOSHO_OPEN: '__animetosho-open',
TSUKIHIME_OPEN: '__tsukihime-open',
RUNTIME_OPTION_CYCLE_PREFIX: '__runtime-option-cycle:',
REPLAY_SUBTITLE: '__replay-subtitle',
PLAY_NEXT_SUBTITLE: '__play-next-subtitle',
@@ -28,8 +29,8 @@ function createOptions(overrides: Partial<Parameters<typeof handleMpvCommandFrom
openJimaku: () => {
calls.push('jimaku');
},
openAnimetosho: () => {
calls.push('animetosho');
openTsukihime: () => {
calls.push('tsukihime');
},
openYoutubeTrackPicker: () => {
calls.push('youtube-picker');
@@ -152,6 +153,15 @@ test('handleMpvCommandFromIpc dispatches special jimaku open command', () => {
assert.deepEqual(osd, []);
});
test('handleMpvCommandFromIpc keeps the legacy Animetosho command as a TsukiHime alias', () => {
const { options, calls, sentCommands } = createOptions();
handleMpvCommandFromIpc(['__animetosho-open'], options);
assert.deepEqual(calls, ['tsukihime']);
assert.deepEqual(sentCommands, []);
});
test('handleMpvCommandFromIpc dispatches special playlist browser open command', async () => {
const { options, calls, sentCommands, osd } = createOptions();
handleMpvCommandFromIpc(['__playlist-browser-open'], options);
+7 -3
View File
@@ -11,6 +11,7 @@ export interface HandleMpvCommandFromIpcOptions {
RUNTIME_OPTIONS_OPEN: string;
JIMAKU_OPEN: string;
ANIMETOSHO_OPEN: string;
TSUKIHIME_OPEN: string;
RUNTIME_OPTION_CYCLE_PREFIX: string;
REPLAY_SUBTITLE: string;
PLAY_NEXT_SUBTITLE: string;
@@ -20,7 +21,7 @@ export interface HandleMpvCommandFromIpcOptions {
triggerSubsyncFromConfig: () => void;
openRuntimeOptionsPalette: () => void;
openJimaku: () => void;
openAnimetosho: () => void;
openTsukihime: () => void;
openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => void | Promise<void>;
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => RuntimeOptionApplyResult;
@@ -114,8 +115,11 @@ export function handleMpvCommandFromIpc(
return;
}
if (first === options.specialCommands.ANIMETOSHO_OPEN) {
options.openAnimetosho();
if (
first === options.specialCommands.TSUKIHIME_OPEN ||
first === options.specialCommands.ANIMETOSHO_OPEN
) {
options.openTsukihime();
return;
}
@@ -28,7 +28,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
openCharacterDictionaryManager: null,
openRuntimeOptions: null,
openJimaku: null,
openAnimetosho: null,
openTsukihime: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
@@ -55,8 +55,8 @@ function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
openJimaku: () => {
calls.push('openJimaku');
},
openAnimetosho: () => {
calls.push('openAnimetosho');
openTsukihime: () => {
calls.push('openTsukihime');
},
markAudioCard: async () => {
calls.push('markAudioCard');
@@ -168,7 +168,7 @@ test('runOverlayShortcutLocalFallback dispatches matching single-step actions',
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
openJimaku: () => handled.push('openJimaku'),
openAnimetosho: () => handled.push('openAnimetosho'),
openTsukihime: () => handled.push('openTsukihime'),
markAudioCard: () => handled.push('markAudioCard'),
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
copySubtitle: () => handled.push('copySubtitle'),
@@ -202,7 +202,7 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
openJimaku: () => handled.push('openJimaku'),
openAnimetosho: () => handled.push('openAnimetosho'),
openTsukihime: () => handled.push('openTsukihime'),
markAudioCard: () => handled.push('markAudioCard'),
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
copySubtitle: () => handled.push('copySubtitle'),
@@ -223,7 +223,7 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
openJimaku: () => handled.push('openJimaku'),
openAnimetosho: () => handled.push('openAnimetosho'),
openTsukihime: () => handled.push('openTsukihime'),
markAudioCard: () => handled.push('markAudioCard'),
copySubtitleMultiple: (timeoutMs) => handled.push(`copySubtitleMultiple:${timeoutMs}`),
copySubtitle: () => handled.push('copySubtitle'),
@@ -261,7 +261,7 @@ test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s
openRuntimeOptions: () => {},
openCharacterDictionaryManager: () => {},
openJimaku: () => {},
openAnimetosho: () => {},
openTsukihime: () => {},
markAudioCard: () => {},
copySubtitleMultiple: () => {},
copySubtitle: () => {},
@@ -298,7 +298,7 @@ test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut',
openRuntimeOptions: () => {},
openCharacterDictionaryManager: () => {},
openJimaku: () => {},
openAnimetosho: () => {},
openTsukihime: () => {},
markAudioCard: () => {},
copySubtitleMultiple: () => {},
copySubtitle: () => {},
@@ -331,7 +331,7 @@ test('runOverlayShortcutLocalFallback returns false when no action matches', ()
openJimaku: () => {
called = true;
},
openAnimetosho: () => {
openTsukihime: () => {
called = true;
},
markAudioCard: () => {
@@ -416,7 +416,7 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured',
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
openAnimetosho: () => {},
openTsukihime: () => {},
}),
cancelPendingMultiCopy: () => {},
cancelPendingMineSentenceMultiple: () => {},
@@ -444,7 +444,7 @@ test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
openAnimetosho: () => {},
openTsukihime: () => {},
}),
cancelPendingMultiCopy: () => {
calls.push('cancel-multi-copy');
@@ -8,7 +8,7 @@ export interface OverlayShortcutFallbackHandlers {
openRuntimeOptions: () => void;
openCharacterDictionaryManager: () => void;
openJimaku: () => void;
openAnimetosho: () => void;
openTsukihime: () => void;
markAudioCard: () => void;
copySubtitleMultiple: (timeoutMs: number) => void;
copySubtitle: () => void;
@@ -25,7 +25,7 @@ export interface OverlayShortcutRuntimeDeps {
openRuntimeOptions: () => void;
openCharacterDictionaryManager: () => void;
openJimaku: () => void;
openAnimetosho: () => void;
openTsukihime: () => void;
markAudioCard: () => Promise<void>;
copySubtitleMultiple: (timeoutMs: number) => void;
copySubtitle: () => void;
@@ -105,8 +105,8 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
openJimaku: () => {
deps.openJimaku();
},
openAnimetosho: () => {
deps.openAnimetosho();
openTsukihime: () => {
deps.openTsukihime();
},
};
@@ -114,7 +114,7 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
openRuntimeOptions: overlayHandlers.openRuntimeOptions,
openCharacterDictionaryManager: overlayHandlers.openCharacterDictionaryManager,
openJimaku: overlayHandlers.openJimaku,
openAnimetosho: overlayHandlers.openAnimetosho,
openTsukihime: overlayHandlers.openTsukihime,
markAudioCard: overlayHandlers.markAudioCard,
copySubtitleMultiple: overlayHandlers.copySubtitleMultiple,
copySubtitle: overlayHandlers.copySubtitle,
@@ -160,9 +160,9 @@ export function runOverlayShortcutLocalFallback(
allowWhenRegistered: true,
},
{
accelerator: shortcuts.openAnimetosho,
accelerator: shortcuts.openTsukihime,
run: () => {
handlers.openAnimetosho();
handlers.openTsukihime();
},
allowWhenRegistered: true,
},
+4 -4
View File
@@ -23,7 +23,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openCharacterDictionaryManager: null,
openRuntimeOptions: null,
openJimaku: null,
openAnimetosho: null,
openTsukihime: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
@@ -49,7 +49,7 @@ test('registerOverlayShortcuts reports active overlay shortcuts when configured'
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
openAnimetosho: () => {},
openTsukihime: () => {},
}),
true,
);
@@ -70,7 +70,7 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent'
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
openAnimetosho: () => {},
openTsukihime: () => {},
}),
false,
);
@@ -93,7 +93,7 @@ test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
openAnimetosho: () => {},
openTsukihime: () => {},
}),
cancelPendingMultiCopy: () => {
calls.push('cancel-multi-copy');
+2 -2
View File
@@ -13,7 +13,7 @@ export interface OverlayShortcutHandlers {
openCharacterDictionaryManager: () => void;
openRuntimeOptions: () => void;
openJimaku: () => void;
openAnimetosho: () => void;
openTsukihime: () => void;
}
export interface OverlayShortcutLifecycleDeps {
@@ -36,7 +36,7 @@ const OVERLAY_SHORTCUT_KEYS: Array<keyof Omit<ConfiguredShortcuts, 'multiCopyTim
'openCharacterDictionaryManager',
'openRuntimeOptions',
'openJimaku',
'openAnimetosho',
'openTsukihime',
];
function hasConfiguredOverlayShortcuts(shortcuts: ConfiguredShortcuts): boolean {
+1 -1
View File
@@ -40,7 +40,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
openControllerSelect: () => calls.push('controller-select'),
openControllerDebug: () => calls.push('controller-debug'),
openJimaku: () => calls.push('jimaku'),
openAnimetosho: () => calls.push('animetosho'),
openTsukihime: () => calls.push('tsukihime'),
openYoutubeTrackPicker: () => {
calls.push('youtube');
},
+3 -3
View File
@@ -24,7 +24,7 @@ export interface SessionActionExecutorDeps {
openControllerSelect: () => void;
openControllerDebug: () => void;
openJimaku: () => void;
openAnimetosho: () => void;
openTsukihime: () => void;
openYoutubeTrackPicker: () => void | Promise<void>;
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
replayCurrentSubtitle: () => void;
@@ -116,8 +116,8 @@ export async function dispatchSessionAction(
case 'openJimaku':
deps.openJimaku();
return;
case 'openAnimetosho':
deps.openAnimetosho();
case 'openTsukihime':
deps.openTsukihime();
return;
case 'openYoutubePicker':
await deps.openYoutubeTrackPicker();
+16 -1
View File
@@ -22,7 +22,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
openCharacterDictionaryManager: null,
openRuntimeOptions: null,
openJimaku: null,
openAnimetosho: null,
openTsukihime: null,
openSessionHelp: null,
openControllerSelect: null,
openControllerDebug: null,
@@ -320,6 +320,21 @@ test('compileSessionBindings wires every default keybinding to an overlay or mpv
}
});
test('compileSessionBindings maps the legacy Animetosho command to the TsukiHime action', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
keybindings: [createKeybinding('Ctrl+Alt+T', ['__animetosho-open'])],
platform: 'linux',
});
assert.deepEqual(result.warnings, []);
assert.equal(result.bindings[0]?.actionType, 'session-action');
assert.equal(
result.bindings[0]?.actionType === 'session-action' ? result.bindings[0].actionId : null,
'openTsukihime',
);
});
test('compileSessionBindings leaves retired subtitle-delay shift tokens as mpv commands', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts(),
+3 -3
View File
@@ -55,7 +55,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
{ key: 'openCharacterDictionaryManager', actionId: 'openCharacterDictionaryManager' },
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
{ key: 'openJimaku', actionId: 'openJimaku' },
{ key: 'openAnimetosho', actionId: 'openAnimetosho' },
{ key: 'openTsukihime', actionId: 'openTsukihime' },
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
{ key: 'openControllerDebug', actionId: 'openControllerDebug' },
@@ -305,9 +305,9 @@ function resolveCommandBinding(
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'openJimaku' };
}
if (first === SPECIAL_COMMANDS.ANIMETOSHO_OPEN) {
if (first === SPECIAL_COMMANDS.TSUKIHIME_OPEN || first === SPECIAL_COMMANDS.ANIMETOSHO_OPEN) {
if (command.length !== 1) return null;
return { actionType: 'session-action', actionId: 'openAnimetosho' };
return { actionType: 'session-action', actionId: 'openTsukihime' };
}
if (first === SPECIAL_COMMANDS.YOUTUBE_PICKER_OPEN) {
if (command.length !== 1) return null;
+1 -1
View File
@@ -40,7 +40,7 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
openAnimetosho: false,
openTsukihime: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
replayCurrentSubtitle: false,