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
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as http from 'node:http';
import type { AddressInfo } from 'node:net';
import { tsukihimeFetchJson } 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('tsukihimeFetchJson 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 tsukihimeFetchJson(
'/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('tsukihimeFetchJson 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 tsukihimeFetchJson(
'/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('tsukihimeFetchJson 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 tsukihimeFetchJson<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('tsukihimeFetchJson 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 tsukihimeFetchJson<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();
}
});
test('tsukihimeFetchJson reports a structured error for a malformed base URL', async () => {
const result = await tsukihimeFetchJson('/search/torrents', {}, { baseUrl: 'not a url' });
assert.equal(result.ok, false);
if (!result.ok) {
assert.match(result.error.error, /base url/i);
}
});
test('tsukihimeFetchJson rejects non-HTTP(S) base URLs', async () => {
const result = await tsukihimeFetchJson('/search/torrents', {}, { baseUrl: 'file:///etc' });
assert.equal(result.ok, false);
if (!result.ok) {
assert.match(result.error.error, /http/i);
}
});
+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
tsukihimeLangToFilenameSuffix,
tsukihimeTrackMatchesLanguages,
describeTsukihimeTabLanguages,
normalizeTsukihimeLangCode,
} from './lang.js';
test('normalizeTsukihimeLangCode collapses 2/3-letter and region variants', () => {
assert.equal(normalizeTsukihimeLangCode('eng'), 'en');
assert.equal(normalizeTsukihimeLangCode('en'), 'en');
assert.equal(normalizeTsukihimeLangCode('en-US'), 'en');
assert.equal(normalizeTsukihimeLangCode('GER'), 'de');
assert.equal(normalizeTsukihimeLangCode('jpn'), 'ja');
assert.equal(normalizeTsukihimeLangCode('vie'), 'vie');
assert.equal(normalizeTsukihimeLangCode(''), '');
});
test('tsukihimeTrackMatchesLanguages matches across code forms', () => {
assert.equal(tsukihimeTrackMatchesLanguages('eng', ['en']), true);
assert.equal(tsukihimeTrackMatchesLanguages('eng', ['en', 'eng']), true);
assert.equal(tsukihimeTrackMatchesLanguages('ger', ['de']), true);
assert.equal(tsukihimeTrackMatchesLanguages('por', ['en']), false);
assert.equal(tsukihimeTrackMatchesLanguages('spa', ['en', 'de']), false);
});
test('tsukihimeTrackMatchesLanguages keeps unknown-language tracks visible', () => {
assert.equal(tsukihimeTrackMatchesLanguages('', ['en']), true);
assert.equal(tsukihimeTrackMatchesLanguages('und', ['en']), true);
});
test('describeTsukihimeTabLanguages names common languages and dedupes', () => {
assert.equal(describeTsukihimeTabLanguages(['en', 'eng']), 'English');
assert.equal(describeTsukihimeTabLanguages(['de']), 'German');
assert.equal(describeTsukihimeTabLanguages(['en', 'de']), 'English / German');
assert.equal(describeTsukihimeTabLanguages(['vie']), 'VIE');
assert.equal(describeTsukihimeTabLanguages([]), 'English');
});
test('tsukihimeLangToFilenameSuffix is re-exported from the pure module', () => {
assert.equal(tsukihimeLangToFilenameSuffix('jpn'), 'ja');
assert.equal(tsukihimeLangToFilenameSuffix('eng'), 'en');
});
+70
View File
@@ -0,0 +1,70 @@
// Pure language-code helpers shared between the main-process Tsukihime 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 normalizeTsukihimeLangCode(code: string): string {
const base = code.trim().toLowerCase().split(/[-_]/)[0] ?? '';
return LANG_FILENAME_SUFFIXES[base] ?? base;
}
export function tsukihimeLangToFilenameSuffix(lang: string | undefined): string {
const normalized = normalizeTsukihimeLangCode(lang ?? '');
if (!normalized || normalized === 'und') return 'en';
return normalized;
}
export function tsukihimeTrackMatchesLanguages(
trackLang: string,
configuredLanguages: string[],
): boolean {
const normalizedTrack = normalizeTsukihimeLangCode(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) => normalizeTsukihimeLangCode(candidate) === normalizedTrack,
);
}
export function describeTsukihimeTabLanguages(configuredLanguages: string[]): string {
const normalized = [
...new Set(
configuredLanguages
.map((candidate) => normalizeTsukihimeLangCode(candidate))
.filter(Boolean),
),
];
if (normalized.length === 0) return 'English';
return normalized.map((code) => LANG_DISPLAY_NAMES[code] ?? code.toUpperCase()).join(' / ');
}
+349
View File
@@ -0,0 +1,349 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { execFileSync } from 'node:child_process';
import {
tsukihimeLangToFilenameSuffix,
buildTsukihimeAttachmentUrl,
decompressXzFile,
extractTsukihimeSubtitleFiles,
isTsukihimeDownloadUrl,
mapTsukihimeSearchResults,
} from './utils.js';
test('buildTsukihimeAttachmentUrl pads the attachment id to 8 hex digits', () => {
assert.equal(
buildTsukihimeAttachmentUrl(96412),
'https://storage.tsukihime.org/attach/0001789c/96412.xz',
);
});
test('buildTsukihimeAttachmentUrl uses the tosho mirror path for imported entries', () => {
assert.equal(
buildTsukihimeAttachmentUrl(2826332, { imported: true }),
'https://storage.tsukihime.org/tosho/attach/002b205c/2826332.xz',
);
});
test('tsukihimeLangToFilenameSuffix maps common ISO 639-2 codes to two-letter suffixes', () => {
assert.equal(tsukihimeLangToFilenameSuffix('eng'), 'en');
assert.equal(tsukihimeLangToFilenameSuffix('jpn'), 'ja');
assert.equal(tsukihimeLangToFilenameSuffix('ger'), 'de');
assert.equal(tsukihimeLangToFilenameSuffix('spa'), 'es');
assert.equal(tsukihimeLangToFilenameSuffix('POR'), 'pt');
});
test('tsukihimeLangToFilenameSuffix handles TsukiHime BCP-47 style codes', () => {
assert.equal(tsukihimeLangToFilenameSuffix('en-US'), 'en');
assert.equal(tsukihimeLangToFilenameSuffix('es-419'), 'es');
assert.equal(tsukihimeLangToFilenameSuffix('zh-Hant'), 'zh');
assert.equal(tsukihimeLangToFilenameSuffix('ja'), 'ja');
});
test('tsukihimeLangToFilenameSuffix falls back to the raw code, and to en when unknown', () => {
assert.equal(tsukihimeLangToFilenameSuffix('vie'), 'vie');
assert.equal(tsukihimeLangToFilenameSuffix(''), 'en');
assert.equal(tsukihimeLangToFilenameSuffix(undefined), 'en');
assert.equal(tsukihimeLangToFilenameSuffix('und'), 'en');
});
test('buildTsukihimeAttachmentUrl rejects non-positive and non-integer ids', () => {
assert.equal(buildTsukihimeAttachmentUrl(0), null);
assert.equal(buildTsukihimeAttachmentUrl(-5), null);
assert.equal(buildTsukihimeAttachmentUrl(1.5), null);
assert.equal(buildTsukihimeAttachmentUrl(Number.NaN), null);
});
test('isTsukihimeDownloadUrl accepts tsukihime.org and animetosho.org hosts only', () => {
assert.equal(isTsukihimeDownloadUrl('https://storage.tsukihime.org/attach/0001789c/1.xz'), true);
assert.equal(isTsukihimeDownloadUrl('https://tsukihime.org/view/1'), true);
// The tosho mirror currently 302s to storage.animetosho.org; the hop must stay allowed.
assert.equal(
isTsukihimeDownloadUrl('https://storage.animetosho.org/attach/002b205c/2826332.xz'),
true,
);
assert.equal(isTsukihimeDownloadUrl('http://storage.tsukihime.org/attach/1/1.xz'), false);
assert.equal(isTsukihimeDownloadUrl('https://eviltsukihime.org/attach/1/1.xz'), false);
assert.equal(isTsukihimeDownloadUrl('https://example.com/1.xz'), false);
assert.equal(isTsukihimeDownloadUrl('not a url'), false);
});
test('mapTsukihimeSearchResults maps valid results and caps to maxResults', () => {
const payload = {
total: 4,
start: 0,
limit: 50,
error: false,
results: [
{
id: 1000752022,
state: 'completed',
name: '[SubsPlease] Sousou no Frieren S2 - 10 (1080p) [7D35515E].mkv',
totalsize: 1457009569,
filecount: 1,
sublangs: ['en'],
audiolangs: ['ja'],
source_date: 1774623761,
added_date: 1774624861,
animetosho: true,
},
{ id: 'bogus', name: 'missing numeric id' },
{ id: 12255, name: '[DKB] Futsutsuka na Akujo - S01E01 [Multi-Subs]' },
{ id: 12256, name: 'capped away' },
],
};
const entries = mapTsukihimeSearchResults(payload, 2);
assert.equal(entries.length, 2);
assert.deepEqual(entries[0], {
id: 1000752022,
title: '[SubsPlease] Sousou no Frieren S2 - 10 (1080p) [7D35515E].mkv',
timestamp: 1774624861,
totalSize: 1457009569,
numFiles: 1,
sublangs: ['en'],
});
assert.equal(entries[1]!.id, 12255);
assert.equal(entries[1]!.totalSize, null);
assert.equal(entries[1]!.numFiles, null);
assert.deepEqual(entries[1]!.sublangs, []);
});
test('mapTsukihimeSearchResults returns empty list for malformed payloads', () => {
assert.deepEqual(mapTsukihimeSearchResults({ error: 'nope' }, 10), []);
assert.deepEqual(mapTsukihimeSearchResults({ results: 'nope' }, 10), []);
assert.deepEqual(mapTsukihimeSearchResults(null, 10), []);
assert.deepEqual(mapTsukihimeSearchResults([], 10), []);
});
// Trimmed from a real /v1/torrents/{id} response (native entry).
const DETAIL_PAYLOAD = {
id: 12255,
name: '[DKB] Futsutsuka na Akujo dewa Gozaimasu ga - S01E01 [Multi-Subs]',
files: [
{
id: 16179,
filename: '[DKB] Futsutsuka na Akujo dewa Gozaimasu ga - S01E01 [Multi-Subs].mkv',
attachments: [
{
id: 96400,
type: 0,
info: { name: 'arial.ttf', mime: 'application/x-truetype-font' },
},
{
id: 96412,
type: 1,
info: { codec: 'ASS', lang: 'en-US', name: 'English subs', trackid: 2, tracknum: 3 },
},
{ id: 96499, type: 3 },
],
},
],
};
test('extractTsukihimeSubtitleFiles keeps only text subtitle attachments with download urls', () => {
const files = extractTsukihimeSubtitleFiles(DETAIL_PAYLOAD);
assert.equal(files.length, 1);
const file = files[0]!;
assert.equal(file.attachmentId, 96412);
assert.equal(file.lang, 'en-us');
assert.equal(file.trackName, 'English subs');
assert.equal(file.size, 0);
assert.equal(file.url, 'https://storage.tsukihime.org/attach/0001789c/96412.xz');
assert.equal(
file.sourceFilename,
'[DKB] Futsutsuka na Akujo dewa Gozaimasu ga - S01E01 [Multi-Subs].mkv',
);
assert.equal(
file.filename,
'[DKB] Futsutsuka na Akujo dewa Gozaimasu ga - S01E01 [Multi-Subs].en-us.ass',
);
});
test('extractTsukihimeSubtitleFiles uses the tosho mirror for animetosho-imported entries', () => {
const files = extractTsukihimeSubtitleFiles({
id: 1000752022,
animetosho: true,
files: [
{
id: 1001361385,
filename: '[SubsPlease] Sousou no Frieren S2 - 10 (1080p) [7D35515E].mkv',
attachments: [
{ id: 2826332, type: 1, info: { codec: 'ASS', lang: 'en', name: 'English subs' } },
],
},
],
});
assert.equal(files.length, 1);
assert.equal(files[0]!.url, 'https://storage.tsukihime.org/tosho/attach/002b205c/2826332.xz');
});
test('extractTsukihimeSubtitleFiles detects imported entries by id when the flag is absent', () => {
const files = extractTsukihimeSubtitleFiles({
id: 1000752022,
files: [
{
id: 1001361385,
filename: 'episode.mkv',
attachments: [{ id: 2826332, type: 1, info: { codec: 'ASS', lang: 'en' } }],
},
],
});
assert.equal(files[0]!.url, 'https://storage.tsukihime.org/tosho/attach/002b205c/2826332.xz');
});
test('extractTsukihimeSubtitleFiles skips image-based subtitle codecs', () => {
const files = extractTsukihimeSubtitleFiles({
id: 1,
files: [
{
id: 1,
filename: 'movie.mkv',
attachments: [
{ id: 10, type: 1, info: { codec: 'PGS', lang: 'en' } },
{ id: 11, type: 1, info: { codec: 'VobSub', lang: 'en' } },
{ id: 12, type: 1, info: { codec: 'SRT', lang: 'en' } },
],
},
],
});
assert.deepEqual(
files.map((f) => f.attachmentId),
[12],
);
assert.equal(files[0]!.filename, 'movie.en.srt');
});
test('extractTsukihimeSubtitleFiles sorts English tracks first and disambiguates duplicates', () => {
const files = extractTsukihimeSubtitleFiles({
id: 1,
files: [
{
id: 1,
filename: 'episode.mkv',
attachments: [
{ id: 21, type: 1, info: { codec: 'ASS', lang: 'de', name: 'Deutsch' } },
{ id: 22, type: 1, info: { codec: 'ASS', lang: 'en', name: 'Signs & Songs' } },
{ id: 23, type: 1, info: { codec: 'ASS', lang: 'en', name: 'Full Subtitles' } },
],
},
],
});
assert.deepEqual(
files.map((f) => f.attachmentId),
[22, 23, 21],
);
assert.equal(files[0]!.filename, 'episode.en.signs-songs.ass');
assert.equal(files[1]!.filename, 'episode.en.full-subtitles.ass');
assert.equal(files[2]!.filename, 'episode.de.ass');
});
test('extractTsukihimeSubtitleFiles tolerates missing info fields', () => {
const files = extractTsukihimeSubtitleFiles({
id: 1,
files: [
{
id: 1,
attachments: [{ id: 31, type: 1, info: { codec: 'ASS' } }],
},
],
});
assert.equal(files.length, 1);
assert.equal(files[0]!.lang, '');
assert.equal(files[0]!.trackName, null);
assert.equal(files[0]!.filename, 'subtitle.ass');
});
const hasXz = (() => {
try {
execFileSync('xz', ['--version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
})();
test('decompressXzFile round-trips an xz-compressed subtitle', { skip: !hasXz }, async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-tsukihime-test-'));
try {
const plainPath = path.join(dir, 'sub.ass');
const content = '[Script Info]\nTitle: test\n';
fs.writeFileSync(plainPath, content, 'utf8');
execFileSync('xz', ['-z', plainPath]);
const destPath = path.join(dir, 'out.ass');
const result = await decompressXzFile(`${plainPath}.xz`, destPath);
assert.equal(result.ok, true);
if (result.ok) {
assert.equal(result.path, destPath);
}
assert.equal(fs.readFileSync(destPath, 'utf8'), content);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('decompressXzFile reports an error for corrupt input', { skip: !hasXz }, async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-tsukihime-test-'));
try {
const srcPath = path.join(dir, 'broken.xz');
fs.writeFileSync(srcPath, 'not xz data');
const result = await decompressXzFile(srcPath, path.join(dir, 'out.ass'));
assert.equal(result.ok, false);
if (!result.ok) {
assert.match(result.error.error, /xz|decompress/i);
}
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('extractTsukihimeSubtitleFiles disambiguates duplicate tracks without slug characters', () => {
const files = extractTsukihimeSubtitleFiles({
id: 1,
files: [
{
id: 1,
filename: 'episode.mkv',
// Both names slugify to an empty string.
attachments: [
{ id: 41, type: 1, info: { codec: 'ASS', lang: 'ja', name: '日本語字幕' } },
{ id: 42, type: 1, info: { codec: 'ASS', lang: 'ja' } },
],
},
],
});
assert.equal(files.length, 2);
for (const file of files) {
assert.doesNotMatch(file.filename, /\.\./, `double dot in ${file.filename}`);
}
assert.notEqual(files[0]!.filename, files[1]!.filename);
assert.equal(files.find((f) => f.attachmentId === 41)!.filename, 'episode.ja.41.ass');
assert.equal(files.find((f) => f.attachmentId === 42)!.filename, 'episode.ja.42.ass');
});
test('extractTsukihimeSubtitleFiles disambiguates duplicate identical track names', () => {
const files = extractTsukihimeSubtitleFiles({
id: 1,
files: [
{
id: 1,
filename: 'episode.mkv',
attachments: [
{ id: 51, type: 1, info: { codec: 'ASS', lang: 'ja', name: 'Signs' } },
{ id: 52, type: 1, info: { codec: 'ASS', lang: 'ja', name: 'Signs' } },
],
},
],
});
assert.deepEqual(
files.map((file) => file.filename),
['episode.ja.51.ass', 'episode.ja.52.ass'],
);
});
+365
View File
@@ -0,0 +1,365 @@
import * as http from 'http';
import * as https from 'https';
import * as fs from 'fs';
import * as path from 'path';
import * as childProcess from 'child_process';
import { createLogger } from '../logger';
import {
TsukihimeApiResponse,
TsukihimeDownloadResult,
TsukihimeEntry,
TsukihimeSubtitleFile,
} from '../types';
// Backend: TsukiHime (https://tsukihime.org), the community successor to
// Animetosho (read-only from May 2026). TsukiHime imported the Animetosho
// index, so "animetosho" still appears here where it names the upstream
// service: the imported-entry flag, the /tosho/ storage mirror, and the
// redirect host that mirror currently points at.
const logger = createLogger('main:tsukihime');
export const TSUKIHIME_API_BASE_URL = 'https://api.tsukihime.org/v1';
export const TSUKIHIME_STORAGE_BASE_URL = 'https://storage.tsukihime.org';
// Entries imported from the Animetosho index sit in this id range and their
// attachments live under the /tosho/ mirror path on the storage host.
const IMPORTED_ANIMETOSHO_ID_FLOOR = 1_000_000_000;
const TEXT_SUBTITLE_EXTENSIONS: Record<string, string> = {
ass: '.ass',
ssa: '.ssa',
srt: '.srt',
subrip: '.srt',
};
const XZ_MAX_SUBTITLE_BYTES = 64 * 1024 * 1024;
const TSUKIHIME_REQUEST_TIMEOUT_MS = 15000;
const TSUKIHIME_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
export {
tsukihimeLangToFilenameSuffix,
tsukihimeTrackMatchesLanguages,
describeTsukihimeTabLanguages,
normalizeTsukihimeLangCode,
} from './lang';
export function isTsukihimeDownloadUrl(url: string | URL): boolean {
try {
const parsed = typeof url === 'string' ? new URL(url) : url;
if (parsed.protocol !== 'https:') return false;
// The /tosho/ mirror currently 302s to storage.animetosho.org, so both
// hosts must stay allowed for the redirect hop.
const allowedHosts = ['tsukihime.org', 'animetosho.org'];
return allowedHosts.some(
(host) => parsed.hostname === host || parsed.hostname.endsWith(`.${host}`),
);
} catch {
return false;
}
}
export function buildTsukihimeAttachmentUrl(
attachmentId: number,
options?: { imported?: boolean },
): string | null {
if (!Number.isInteger(attachmentId) || attachmentId <= 0) return null;
const hexId = attachmentId.toString(16).padStart(8, '0');
const attachPath = options?.imported ? '/tosho/attach/' : '/attach/';
return `${TSUKIHIME_STORAGE_BASE_URL}${attachPath}${hexId}/${attachmentId}.xz`;
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function asFiniteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
export function mapTsukihimeSearchResults(
payload: unknown,
maxResults: number,
): TsukihimeEntry[] {
if (!isObject(payload) || !Array.isArray(payload.results)) return [];
const entries: TsukihimeEntry[] = [];
for (const item of payload.results) {
if (entries.length >= maxResults) break;
if (!isObject(item)) continue;
const id = item.id;
const title = item.name;
if (!Number.isInteger(id) || typeof title !== 'string' || !title) continue;
const sublangs = Array.isArray(item.sublangs)
? item.sublangs.filter((lang): lang is string => typeof lang === 'string')
: [];
entries.push({
id: id as number,
title,
timestamp: asFiniteNumber(item.added_date),
totalSize: asFiniteNumber(item.totalsize),
numFiles: asFiniteNumber(item.filecount),
sublangs,
});
}
return entries;
}
function stripFileExtension(name: string): string {
const ext = path.extname(name);
return ext ? name.slice(0, -ext.length) : name;
}
function slugifyTrackName(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function subtitleLangRank(lang: string): number {
if (lang.startsWith('en')) return 0;
if (!lang || lang === 'und') return 1;
return 2;
}
interface RawSubtitleAttachment {
attachmentId: number;
lang: string;
trackName: string | null;
size: number;
url: string;
sourceFilename: string;
extension: string;
}
const SUBTITLE_ATTACHMENT_TYPE = 1;
function isImportedAnimetoshoEntry(payload: Record<string, unknown>): boolean {
if (payload.animetosho === true) return true;
const id = asFiniteNumber(payload.id);
return id !== null && id >= IMPORTED_ANIMETOSHO_ID_FLOOR;
}
function collectSubtitleAttachments(payload: unknown): RawSubtitleAttachment[] {
if (!isObject(payload) || !Array.isArray(payload.files)) return [];
const imported = isImportedAnimetoshoEntry(payload);
const collected: RawSubtitleAttachment[] = [];
for (const file of payload.files) {
if (!isObject(file) || !Array.isArray(file.attachments)) continue;
const sourceFilename = typeof file.filename === 'string' && file.filename ? file.filename : '';
for (const attachment of file.attachments) {
if (!isObject(attachment) || attachment.type !== SUBTITLE_ATTACHMENT_TYPE) continue;
const attachmentId = attachment.id;
if (!Number.isInteger(attachmentId) || (attachmentId as number) <= 0) continue;
const info = isObject(attachment.info) ? attachment.info : {};
const codec = typeof info.codec === 'string' ? info.codec.toLowerCase() : '';
const extension = TEXT_SUBTITLE_EXTENSIONS[codec];
if (!extension) continue;
const url = buildTsukihimeAttachmentUrl(attachmentId as number, { imported });
if (!url) continue;
collected.push({
attachmentId: attachmentId as number,
lang: typeof info.lang === 'string' ? info.lang.toLowerCase() : '',
trackName: typeof info.name === 'string' && info.name ? info.name : null,
size: asFiniteNumber(attachment.size) ?? 0,
url,
sourceFilename,
extension,
});
}
}
return collected;
}
export function extractTsukihimeSubtitleFiles(payload: unknown): TsukihimeSubtitleFile[] {
const collected = collectSubtitleAttachments(payload);
const duplicateKeyCounts = new Map<string, number>();
const duplicateTrackSlugCounts = new Map<string, number>();
for (const attachment of collected) {
const key = `${attachment.sourceFilename} ${attachment.lang}`;
duplicateKeyCounts.set(key, (duplicateKeyCounts.get(key) ?? 0) + 1);
const trackSlug = attachment.trackName ? slugifyTrackName(attachment.trackName) : '';
const trackKey = `${key} ${trackSlug}`;
duplicateTrackSlugCounts.set(trackKey, (duplicateTrackSlugCounts.get(trackKey) ?? 0) + 1);
}
const files = collected.map((attachment) => {
const base = attachment.sourceFilename
? stripFileExtension(attachment.sourceFilename)
: 'subtitle';
const langPart = attachment.lang ? `.${attachment.lang}` : '';
const key = `${attachment.sourceFilename} ${attachment.lang}`;
// A CJK-only track name (e.g. 日本語字幕) slugifies to an empty string, which
// would leave a bare dot in the filename.
const trackSlug = attachment.trackName ? slugifyTrackName(attachment.trackName) : '';
const needsTrackSuffix = (duplicateKeyCounts.get(key) ?? 0) > 1;
const repeatedTrackSlug = (duplicateTrackSlugCounts.get(`${key} ${trackSlug}`) ?? 0) > 1;
const trackPart = needsTrackSuffix
? `.${!trackSlug || repeatedTrackSlug ? attachment.attachmentId : trackSlug}`
: '';
return {
attachmentId: attachment.attachmentId,
filename: `${base}${langPart}${trackPart}${attachment.extension}`,
lang: attachment.lang,
trackName: attachment.trackName,
size: attachment.size,
url: attachment.url,
sourceFilename: attachment.sourceFilename,
};
});
return files
.map((file, index) => ({ file, index }))
.sort((a, b) => {
const rankDiff = subtitleLangRank(a.file.lang) - subtitleLangRank(b.file.lang);
if (rankDiff !== 0) return rankDiff;
return a.index - b.index;
})
.map(({ file }) => file);
}
export async function tsukihimeFetchJson<T>(
endpoint: string,
query: Record<string, string | number | boolean | null | undefined>,
options: { baseUrl: string; timeoutMs?: number; maxResponseBytes?: number },
): Promise<TsukihimeApiResponse<T>> {
// Concatenate instead of new URL(endpoint, base): the API base ends in a
// path segment (/v1) that URL resolution would drop for absolute endpoints.
// baseUrl comes from user config, so a bad value must not throw here.
let url: URL;
try {
url = new URL(`${options.baseUrl.replace(/\/+$/, '')}${endpoint}`);
} catch {
logger.error(`Invalid TsukiHime API base URL: ${options.baseUrl}`);
return { ok: false, error: { error: 'Invalid TsukiHime API base URL.' } };
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
logger.error(`Unsupported TsukiHime API protocol: ${url.protocol}`);
return { ok: false, error: { error: 'TsukiHime API URL must use HTTP or HTTPS.' } };
}
for (const [key, value] of Object.entries(query)) {
if (value === null || value === undefined) continue;
url.searchParams.set(key, String(value));
}
const timeoutMs = options.timeoutMs ?? TSUKIHIME_REQUEST_TIMEOUT_MS;
const maxResponseBytes = options.maxResponseBytes ?? TSUKIHIME_MAX_RESPONSE_BYTES;
logger.debug(`GET ${url.toString()}`);
const transport = url.protocol === 'https:' ? https : http;
return new Promise((resolve) => {
let settled = false;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const settle = (result: TsukihimeApiResponse<T>): void => {
if (settled) return;
settled = true;
if (timeoutHandle) clearTimeout(timeoutHandle);
resolve(result);
};
const req = transport.request(
url,
{
method: 'GET',
headers: { 'User-Agent': 'SubMiner' },
},
(res) => {
// Buffer raw chunks and decode once: a multi-byte UTF-8 character
// (e.g. a Japanese title) can straddle a chunk boundary.
const chunks: Buffer[] = [];
let receivedBytes = 0;
res.on('data', (chunk: Buffer) => {
receivedBytes += chunk.length;
if (receivedBytes > maxResponseBytes) {
logger.error(`TsukiHime response exceeded ${maxResponseBytes} bytes; aborting.`);
// Settle before destroying: destroy can emit 'end' synchronously.
settle({
ok: false,
error: { error: 'TsukiHime response was too large.' },
});
req.destroy();
return;
}
chunks.push(chunk);
});
res.on('end', () => {
if (settled) return;
const status = res.statusCode || 0;
logger.debug(`Response HTTP ${status} for ${endpoint}`);
if (status >= 200 && status < 300) {
const data = Buffer.concat(chunks).toString('utf8');
try {
const parsed = JSON.parse(data) as T;
settle({ ok: true, data: parsed });
} catch {
logger.error(`JSON parse error: ${data.slice(0, 200)}`);
settle({
ok: false,
error: { error: 'Failed to parse TsukiHime response JSON.' },
});
}
return;
}
logger.error(`TsukiHime API error (HTTP ${status})`);
settle({
ok: false,
error: { error: `TsukiHime API error (HTTP ${status})`, code: status || undefined },
});
});
},
);
timeoutHandle = setTimeout(() => {
logger.error(`TsukiHime request timed out after ${timeoutMs}ms: ${url.toString()}`);
settle({
ok: false,
error: { error: `TsukiHime request timed out after ${timeoutMs}ms.` },
});
req.destroy();
}, timeoutMs);
req.on('error', (err) => {
logger.error(`Network error: ${(err as Error).message}`);
settle({
ok: false,
error: { error: `TsukiHime request failed: ${(err as Error).message}` },
});
});
req.end();
});
}
export async function decompressXzFile(
srcPath: string,
destPath: string,
): Promise<TsukihimeDownloadResult> {
return new Promise((resolve) => {
childProcess.execFile(
'xz',
['-dc', srcPath],
{ encoding: 'buffer', maxBuffer: XZ_MAX_SUBTITLE_BYTES },
async (err, stdout) => {
if (err) {
const reason =
(err as NodeJS.ErrnoException).code === 'ENOENT'
? 'xz binary not found. Install xz-utils to download TsukiHime subtitles.'
: `Failed to decompress subtitle with xz: ${err.message}`;
logger.error(reason);
resolve({ ok: false, error: { error: reason } });
return;
}
try {
await fs.promises.writeFile(destPath, stdout);
resolve({ ok: true, path: destPath });
} catch (writeErr) {
resolve({
ok: false,
error: { error: `Failed to save subtitle: ${(writeErr as Error).message}` },
});
}
},
);
});
}