mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 19:21:33 -07:00
refactor(tsukihime): swap Animetosho backend for TsukiHime API (#165)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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(' / ');
|
||||
}
|
||||
@@ -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'],
|
||||
);
|
||||
});
|
||||
@@ -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}` },
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user