mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-14 01:55:58 -07:00
feat(animetosho): add English/Japanese subtitle download integration (#159)
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { describeDownloadError } from './utils.js';
|
||||
|
||||
test('describeDownloadError prefers the error message', () => {
|
||||
assert.equal(describeDownloadError(new Error('socket hang up')), 'socket hang up');
|
||||
});
|
||||
|
||||
test('describeDownloadError falls back to the error code when the message is empty', () => {
|
||||
const err = new Error('') as NodeJS.ErrnoException;
|
||||
err.code = 'ECONNRESET';
|
||||
assert.equal(describeDownloadError(err), 'ECONNRESET');
|
||||
});
|
||||
|
||||
test('describeDownloadError unwraps empty-message AggregateErrors', () => {
|
||||
const v4 = new Error('connect ECONNREFUSED 1.2.3.4:443') as NodeJS.ErrnoException;
|
||||
v4.code = 'ECONNREFUSED';
|
||||
const v6 = new Error('') as NodeJS.ErrnoException;
|
||||
v6.code = 'ENETUNREACH';
|
||||
const aggregate = new AggregateError([v4, v6], '');
|
||||
assert.equal(describeDownloadError(aggregate), 'connect ECONNREFUSED 1.2.3.4:443; ENETUNREACH');
|
||||
});
|
||||
|
||||
test('describeDownloadError never returns an empty string', () => {
|
||||
assert.equal(describeDownloadError(new Error('')), 'Error');
|
||||
assert.equal(describeDownloadError('boom'), 'boom');
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as http from 'node:http';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
|
||||
import { downloadToFile } from './utils.js';
|
||||
|
||||
interface TestServer {
|
||||
port: number;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
function startServer(handler: http.RequestListener): Promise<TestServer> {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer(handler);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
resolve({
|
||||
port,
|
||||
close: () =>
|
||||
new Promise((done) => {
|
||||
server.close(() => done());
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('downloadToFile follows redirects that pass the allow-list', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-redirect-test-'));
|
||||
const server = await startServer((req, res) => {
|
||||
if (req.url === '/start') {
|
||||
res.writeHead(302, { Location: '/final' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.writeHead(200);
|
||||
res.end('subtitle body');
|
||||
});
|
||||
|
||||
try {
|
||||
const destPath = path.join(dir, 'sub.ass');
|
||||
const result = await downloadToFile(
|
||||
`http://127.0.0.1:${server.port}/start`,
|
||||
destPath,
|
||||
{},
|
||||
{ isAllowedRedirect: (url) => url.hostname === '127.0.0.1' },
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(fs.readFileSync(destPath, 'utf8'), 'subtitle body');
|
||||
} finally {
|
||||
await server.close();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('downloadToFile refuses redirects to a host outside the allow-list', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-redirect-test-'));
|
||||
let finalHits = 0;
|
||||
const server = await startServer((req, res) => {
|
||||
if (req.url === '/start') {
|
||||
res.writeHead(302, { Location: 'http://localhost.localdomain/evil' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
finalHits += 1;
|
||||
res.writeHead(200);
|
||||
res.end('should never be fetched');
|
||||
});
|
||||
|
||||
try {
|
||||
const destPath = path.join(dir, 'sub.ass');
|
||||
const result = await downloadToFile(
|
||||
`http://127.0.0.1:${server.port}/start`,
|
||||
destPath,
|
||||
{},
|
||||
{ isAllowedRedirect: (url) => url.hostname === '127.0.0.1' },
|
||||
);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) {
|
||||
assert.match(result.error.error, /redirect/i);
|
||||
}
|
||||
assert.equal(finalHits, 0);
|
||||
assert.equal(fs.existsSync(destPath), false);
|
||||
} finally {
|
||||
await server.close();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+43
-4
@@ -306,12 +306,35 @@ export function isRemoteMediaPath(mediaPath: string): boolean {
|
||||
return /^[a-z][a-z0-9+.-]*:\/\//i.test(mediaPath);
|
||||
}
|
||||
|
||||
export function describeDownloadError(err: unknown): string {
|
||||
if (err instanceof AggregateError) {
|
||||
const parts = err.errors
|
||||
.map((inner) => describeDownloadError(inner))
|
||||
.filter((part) => part && part !== 'Error');
|
||||
if (parts.length > 0) return parts.join('; ');
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
if (err.message) return err.message;
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code) return code;
|
||||
return err.name || 'Error';
|
||||
}
|
||||
return String(err) || 'Unknown error';
|
||||
}
|
||||
|
||||
export interface DownloadToFileOptions {
|
||||
// Guards where a redirect may land. Without it any Location header is followed.
|
||||
isAllowedRedirect?: (url: URL) => boolean;
|
||||
redirectCount?: number;
|
||||
}
|
||||
|
||||
export async function downloadToFile(
|
||||
url: string,
|
||||
destPath: string,
|
||||
headers: Record<string, string>,
|
||||
redirectCount = 0,
|
||||
options: DownloadToFileOptions = {},
|
||||
): Promise<JimakuDownloadResult> {
|
||||
const redirectCount = options.redirectCount ?? 0;
|
||||
if (redirectCount > 3) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -326,9 +349,23 @@ export async function downloadToFile(
|
||||
const req = transport.get(parsedUrl, { headers }, (res) => {
|
||||
const status = res.statusCode || 0;
|
||||
if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) {
|
||||
const redirectUrl = new URL(res.headers.location, parsedUrl).toString();
|
||||
const redirectUrl = new URL(res.headers.location, parsedUrl);
|
||||
res.resume();
|
||||
downloadToFile(redirectUrl, destPath, headers, redirectCount + 1).then(resolve);
|
||||
if (options.isAllowedRedirect && !options.isAllowedRedirect(redirectUrl)) {
|
||||
logger.error(`Refusing redirect to disallowed host: ${redirectUrl.href}`);
|
||||
resolve({
|
||||
ok: false,
|
||||
error: {
|
||||
error: `Refusing to follow subtitle redirect to ${redirectUrl.host}.`,
|
||||
code: status,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
downloadToFile(redirectUrl.toString(), destPath, headers, {
|
||||
...options,
|
||||
redirectCount: redirectCount + 1,
|
||||
}).then(resolve);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -362,9 +399,11 @@ export async function downloadToFile(
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
const reason = describeDownloadError(err);
|
||||
logger.error(`Download request failed for ${url}: ${reason}`);
|
||||
resolve({
|
||||
ok: false,
|
||||
error: { error: `Download request failed: ${(err as Error).message}` },
|
||||
error: { error: `Download request failed: ${reason}` },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user