mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-15 13:55:51 -07:00
363 lines
13 KiB
TypeScript
363 lines
13 KiB
TypeScript
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}` },
|
|
});
|
|
}
|
|
},
|
|
);
|
|
});
|
|
}
|