feat(adapters): qBittorrent, filesystem, TMDB and Emby adapters with testkit mocks (W1-3a)

- qbittorrent: WebUI API v2 client (cookie/API-key auth, add/info/files/filePrio,
  pause/resume with 5.x stop/start fallback, tags, delete with explicit deleteFiles,
  maindata), error classes, idempotent submit with local infohash (own bencode)
  and post-timeout hash/probe-tag lookup; state mapping per MoviePilot
- fs: copyWithVerify (tmp + fsync + size/sha256/sample + no-clobber commit),
  moveSafe (EXDEV surfaced), resumable crossDeviceMove, statSafe/checkRoot with
  root sentinel, budgeted incremental scan with cursor, ffprobe, statfs usage
- tmdb: multi-language search/details/seasons, LRU+TTL cache with coalescing,
  rate-limit backoff, MoviePilot match chain (name normalization, year +-1)
- emby: Emby/Jellyfin items/seasons/episodes/MediaSources, failures -> unknown
- testkit: mock qB / TMDB / Emby servers, torrent builder, temp-tree fixtures
- docs: W1-3a porting table appended to downloader-and-sites ledger

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-27 18:07:48 +08:00
1 parent 9c881e737f
commit ef2e504a94
45 files changed
+5766 -1

No files matched your search

+166
View File
@@ -0,0 +1,166 @@
/** mock Emby/Jellyfin 库存数据(字段形状按 Emby BaseItemDto,取值为虚构)。 */
export interface EmbyFixtureItem {
Id: string;
Name: string;
Type: "Movie" | "Series" | "Season" | "Episode";
OriginalTitle?: string;
ProductionYear?: number;
ProviderIds?: Record<string, string>;
Path?: string;
SeriesId?: string;
SeriesName?: string;
SeasonId?: string;
ParentIndexNumber?: number;
IndexNumber?: number;
IndexNumberEnd?: number;
LocationType?: "FileSystem" | "Virtual";
MediaSources?: Record<string, unknown>[];
}
const src4k = {
Id: "ms-m1-4k",
Name: "2160p REMUX",
Path: "/media/movies/奥本海默 (2023)/奥本海默 (2023) - 2160p.mkv",
Container: "mkv",
Size: 80_000_000_000,
Bitrate: 72_000_000,
MediaStreams: [
{ Type: "Video", Codec: "hevc", Width: 3840, Height: 2160, VideoRange: "HDR", BitDepth: 10, Profile: "Main 10" },
{ Type: "Audio", Codec: "truehd", Channels: 8, Language: "eng", DisplayTitle: "TrueHD 7.1", IsDefault: true },
{ Type: "Audio", Codec: "ac3", Channels: 6, Language: "chi", Title: "国语" },
{ Type: "Subtitle", Codec: "subrip", Language: "chi", IsExternal: true },
],
};
const src1080 = {
Id: "ms-m1-1080",
Name: "1080p",
Path: "/media/movies/奥本海默 (2023)/奥本海默 (2023) - 1080p.mkv",
Container: "mkv",
Size: 20_000_000_000,
MediaStreams: [
{ Type: "Video", Codec: "h264", Width: 1920, Height: 800, VideoRange: "SDR" },
{ Type: "Audio", Codec: "dts", Channels: 6, Language: "eng" },
],
};
function ep(
series: string,
seriesName: string,
season: string,
s: number,
e: number,
extra: Partial<EmbyFixtureItem> = {},
) {
const virtual = extra.LocationType === "Virtual";
return {
Id: `${series}-s${s}e${e}`,
Name: `第 ${e} 集`,
Type: "Episode" as const,
SeriesId: series,
SeriesName: seriesName,
SeasonId: season,
ParentIndexNumber: s,
IndexNumber: e,
LocationType: "FileSystem" as const,
...(virtual
? {}
: {
Path: `/media/tv/${seriesName}/Season ${s}/${seriesName} - S${String(s).padStart(2, "0")}E${String(e).padStart(2, "0")}.mkv`,
MediaSources: [
{
Id: `ms-${series}-${s}-${e}`,
Container: "mkv",
MediaStreams: [
{ Type: "Video", Codec: "hevc", Width: 1920, Height: 1080 },
{ Type: "Audio", Codec: "eac3", Channels: 6 },
],
},
],
}),
...extra,
};
}
export const EMBY_FIXTURE_USERS = [
{ Id: "u-admin", Name: "admin", Policy: { IsAdministrator: true } },
{ Id: "u-kid", Name: "kid", Policy: { IsAdministrator: false } },
];
export const EMBY_FIXTURE_ITEMS: EmbyFixtureItem[] = [
{
Id: "m1",
Name: "奥本海默",
OriginalTitle: "Oppenheimer",
Type: "Movie",
ProductionYear: 2023,
ProviderIds: { Tmdb: "872585", Imdb: "tt15398776" },
Path: src4k.Path,
LocationType: "FileSystem",
MediaSources: [src4k, src1080],
},
{
Id: "m2",
Name: "流浪地球2",
Type: "Movie",
ProviderIds: { Tmdb: "842675" },
Path: "/media/movies/流浪地球2 (2023)/流浪地球2.mkv",
LocationType: "FileSystem",
// 故意缺字段:无 Size/VideoRange/音轨语言,用于验证"未知不补值"
MediaSources: [
{
Id: "ms-m2",
Path: "/media/movies/流浪地球2 (2023)/流浪地球2.mkv",
MediaStreams: [{ Type: "Video", Codec: "hevc" }, { Type: "Audio" }],
},
],
},
{
Id: "s1",
Name: "熊家餐馆",
OriginalTitle: "The Bear",
Type: "Series",
ProductionYear: 2022,
ProviderIds: { Tmdb: "136315", Tvdb: "401654" },
Path: "/media/tv/熊家餐馆",
LocationType: "FileSystem",
},
{
Id: "s1-se1",
Name: "第 1 季",
Type: "Season",
SeriesId: "s1",
SeriesName: "熊家餐馆",
IndexNumber: 1,
LocationType: "FileSystem",
},
{
Id: "s1-se2",
Name: "第 2 季",
Type: "Season",
SeriesId: "s1",
SeriesName: "熊家餐馆",
IndexNumber: 2,
LocationType: "FileSystem",
},
...Array.from({ length: 8 }, (_, i) => ep("s1", "熊家餐馆", "s1-se1", 1, i + 1)),
...[1, 2, 3].map((e) => ep("s1", "熊家餐馆", "s1-se2", 2, e)),
ep("s1", "熊家餐馆", "s1-se2", 2, 4, { LocationType: "Virtual" }),
{
Id: "s2",
Name: "向往的生活",
Type: "Series",
Path: "/media/variety/向往的生活",
LocationType: "FileSystem",
},
{
Id: "s2-se1",
Name: "第 1 季",
Type: "Season",
SeriesId: "s2",
SeriesName: "向往的生活",
IndexNumber: 1,
LocationType: "FileSystem",
},
ep("s2", "向往的生活", "s2-se1", 1, 1),
ep("s2", "向往的生活", "s2-se1", 1, 3, { IndexNumberEnd: 4 }),
];
+2
View File
@@ -0,0 +1,2 @@
export * from "./fixtures";
export * from "./mock-emby";
+122
View File
@@ -0,0 +1,122 @@
import { type MockHttp, type MockReply, startMockHttp } from "../tmdb/mock-http";
import { EMBY_FIXTURE_ITEMS, EMBY_FIXTURE_USERS, type EmbyFixtureItem } from "./fixtures";
export interface MockEmbyOptions {
flavor?: "emby" | "jellyfin";
apiKey?: string;
items?: EmbyFixtureItem[];
users?: { Id: string; Name: string; Policy?: { IsAdministrator?: boolean } }[];
port?: number;
}
export type MockEmby = MockHttp & { items: EmbyFixtureItem[]; flavor: "emby" | "jellyfin" };
/**
* mock Emby / Jellyfin:/Users、/Users/{uid}/Items、/Items、/Users/{uid}/Items/{id}、/Shows/{id}/Seasons、
* /Shows/{id}/Episodes、/System/Info/Public。Emby 支持 AnyProviderIdEquals;Jellyfin flavor 忽略该参数。
* 路径可带 /emby 前缀。Fields 未请求的字段(ProviderIds/Path/MediaSources/OriginalTitle)不返回。
*/
export async function createMockEmby(opts: MockEmbyOptions = {}): Promise<MockEmby> {
const flavor = opts.flavor ?? "emby";
const apiKey = opts.apiKey ?? "test-emby-key";
const items = opts.items ?? EMBY_FIXTURE_ITEMS;
const users = opts.users ?? EMBY_FIXTURE_USERS;
const project = (it: EmbyFixtureItem, fields: Set<string>) => {
const { ProviderIds, Path, MediaSources, OriginalTitle, ...rest } = it;
return {
...rest,
...(fields.has("providerids") && ProviderIds ? { ProviderIds } : {}),
...(fields.has("path") && Path ? { Path } : {}),
...(fields.has("mediasources") && MediaSources ? { MediaSources } : {}),
...(fields.has("originaltitle") && OriginalTitle ? { OriginalTitle } : {}),
};
};
const fieldsOf = (q: Record<string, string>) =>
new Set(
(q.Fields ?? q.fields ?? "")
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
);
const page = (list: EmbyFixtureItem[], q: Record<string, string>): MockReply => {
const start = Number(q.StartIndex ?? 0);
const limit = q.Limit ? Number(q.Limit) : list.length;
const f = fieldsOf(q);
return {
json: { Items: list.slice(start, start + limit).map((i) => project(i, f)), TotalRecordCount: list.length },
};
};
const knownUser = (uid: string | undefined) => !uid || users.some((u) => u.Id === uid);
const unauthorizedUser: MockReply = { status: 400, json: "Invalid user" };
const queryItems = (q: Record<string, string>) => {
let list = items;
const types = (q.IncludeItemTypes ?? "").split(",").filter(Boolean);
if (types.length) list = list.filter((i) => types.includes(i.Type));
if (q.ParentId) list = list.filter((i) => i.SeriesId === q.ParentId || i.SeasonId === q.ParentId);
const term = (q.SearchTerm ?? q.searchTerm ?? "").toLowerCase();
if (term) list = list.filter((i) => [i.Name, i.OriginalTitle].some((t) => t?.toLowerCase().includes(term)));
if (flavor === "emby" && q.AnyProviderIdEquals) {
const wanted = q.AnyProviderIdEquals.split(",").map((s) => s.toLowerCase());
list = list.filter((i) =>
Object.entries(i.ProviderIds ?? {}).some(([k, v]) => wanted.includes(`${k.toLowerCase()}.${v.toLowerCase()}`)),
);
}
return list;
};
const r = (re: string) => new RegExp(`^(?:/emby)?${re}$`, "i");
const http = await startMockHttp(
[
[
r("/System/Info/Public"),
() => ({ json: { ServerName: "mock", Version: flavor === "emby" ? "4.8.10.0" : "10.10.3", Id: "srv" } }),
],
[r("/Users"), () => ({ json: users })],
[
r("/Users/([^/]+)/Items/([^/]+)"),
(m, q) => {
if (!knownUser(m[1])) return unauthorizedUser;
const it = items.find((i) => i.Id === m[2]);
return it
? { json: project(it, fieldsOf({ Fields: q.Fields ?? "ProviderIds,Path,MediaSources,OriginalTitle" })) }
: { status: 404, json: "Not Found" };
},
],
[r("/Users/([^/]+)/Items"), (m, q) => (knownUser(m[1]) ? page(queryItems(q), q) : unauthorizedUser)],
[r("/Items"), (_m, q) => (knownUser(q.UserId ?? q.userId) ? page(queryItems(q), q) : unauthorizedUser)],
[
r("/Shows/([^/]+)/Seasons"),
(m, q) => {
if (!items.some((i) => i.Id === m[1] && i.Type === "Series")) return { status: 404, json: "Not Found" };
return page(
items.filter((i) => i.Type === "Season" && i.SeriesId === m[1]),
q,
);
},
],
[
r("/Shows/([^/]+)/Episodes"),
(m, q) => {
if (!items.some((i) => i.Id === m[1] && i.Type === "Series")) return { status: 404, json: "Not Found" };
let list = items.filter((i) => i.Type === "Episode" && i.SeriesId === m[1]);
if (q.SeasonId) list = list.filter((i) => i.SeasonId === q.SeasonId);
if (q.Season) list = list.filter((i) => String(i.ParentIndexNumber) === q.Season);
return page(list, q);
},
],
],
{
...(opts.port !== undefined ? { port: opts.port } : {}),
guard: (req, q) => {
if (/\/System\/Info\/Public$/i.test(new URL(req.url ?? "/", "http://x").pathname)) return null;
const h = req.headers["x-emby-token"];
const auth = /Token="([^"]+)"/.exec(req.headers.authorization ?? "")?.[1];
const ok = h === apiKey || auth === apiKey || q.api_key === apiKey || q.ApiKey === apiKey;
return ok ? null : { status: 401, json: "Access token is invalid or expired." };
},
},
);
return Object.assign(http, { items, flavor });
}
+1
View File
@@ -0,0 +1 @@
export * from "./temp-tree";
+60
View File
@@ -0,0 +1,60 @@
import { randomBytes } from "node:crypto";
import { existsSync, statSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
export interface TempTree {
root: string;
/** 根目录下的绝对路径 */
path(rel: string): string;
/** 写文件(自动建父目录);content 为数字时写入该长度的随机字节 */
write(rel: string, content: string | Buffer | number): Promise<string>;
cleanup(): Promise<void>;
}
/**
* 临时目录夹具。spec 的 key 为相对路径(以 / 结尾表示目录),value 同 write。
* sentinel 为真时写入库根哨兵 .uhd-root。
*/
export async function createTempTree(
spec: Record<string, string | Buffer | number | null> = {},
opts: { base?: string; sentinel?: boolean | string; prefix?: string } = {},
): Promise<TempTree> {
const root = await mkdtemp(join(opts.base ?? tmpdir(), opts.prefix ?? "umt-fs-"));
const tree: TempTree = {
root,
path: (rel) => join(root, rel),
write: async (rel, content) => {
const p = join(root, rel);
await mkdir(dirname(p), { recursive: true });
await writeFile(p, typeof content === "number" ? randomBytes(content) : content);
return p;
},
cleanup: () => rm(root, { recursive: true, force: true }),
};
if (opts.sentinel) {
await writeFile(join(root, typeof opts.sentinel === "string" ? opts.sentinel : ".uhd-root"), "sentinel\n");
}
for (const [rel, v] of Object.entries(spec)) {
if (rel.endsWith("/") || v === null) await mkdir(join(root, rel), { recursive: true });
else await tree.write(rel, v);
}
return tree;
}
/**
* 找一个与 os.tmpdir() 不在同一设备上的可写目录(优先 /dev/shm),用于 EXDEV 测试。
* 找不到返回 null,测试应 skip 并注明。
*/
export function findCrossDeviceBase(candidates = ["/dev/shm", "/run/user", "/var/tmp"]): string | null {
const baseDev = statSync(tmpdir()).dev;
for (const c of candidates) {
try {
if (existsSync(c) && statSync(c).dev !== baseDev) return c;
} catch {
// ignore
}
}
return null;
}
+4
View File
@@ -1,2 +1,6 @@
// @umt/testkit 公开入口;W1 起在此导出实现
export const PACKAGE_NAME = "@umt/testkit";
export * from "./emby";
export * from "./fs";
export * from "./qbittorrent";
export * from "./tmdb";
+130
View File
@@ -0,0 +1,130 @@
import { createHash } from "node:crypto";
/** 测试用 bencode 编解码与种子构造器(独立于 adapters 实现,用于交叉校验 infohash)。 */
export type BVal = number | string | Uint8Array | BVal[] | { [k: string]: BVal };
export function bencode(v: BVal): Buffer {
if (typeof v === "number") return Buffer.from(`i${Math.trunc(v)}e`);
if (typeof v === "string") {
const b = Buffer.from(v, "utf8");
return Buffer.concat([Buffer.from(`${b.length}:`), b]);
}
if (v instanceof Uint8Array) return Buffer.concat([Buffer.from(`${v.length}:`), v]);
if (Array.isArray(v)) return Buffer.concat([Buffer.from("l"), ...v.map(bencode), Buffer.from("e")]);
const keys = Object.keys(v).sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)));
return Buffer.concat([Buffer.from("d"), ...keys.flatMap((k) => [bencode(k), bencode(v[k]!)]), Buffer.from("e")]);
}
type Dec = number | Buffer | Dec[] | { [k: string]: Dec };
export function bdecodeLoose(buf: Buffer): Dec {
let pos = 0;
const readUntil = (ch: string) => {
const end = buf.indexOf(ch, pos);
if (end < 0) throw new Error("bad bencode");
const s = buf.subarray(pos, end).toString();
pos = end + 1;
return s;
};
const val = (): Dec => {
const c = String.fromCharCode(buf[pos]!);
if (c === "i") {
pos++;
return Number(readUntil("e"));
}
if (c === "l") {
pos++;
const out: Dec[] = [];
while (buf[pos] !== 0x65) out.push(val());
pos++;
return out;
}
if (c === "d") {
pos++;
const out: { [k: string]: Dec } = {};
while (buf[pos] !== 0x65) {
const k = (val() as Buffer).toString();
out[k] = val();
}
pos++;
return out;
}
const n = Number(readUntil(":"));
const b = buf.subarray(pos, pos + n);
pos += n;
return b;
};
return val();
}
export interface MakeTorrentInput {
name: string;
/** 省略则为单文件种子,长度用 length */
files?: { path: string; length: number }[];
length?: number;
pieceLength?: number;
private?: boolean;
announce?: string;
/** 改变伪 pieces 内容,用于构造同名同大小但 infohash 不同的种子 */
pieceSeed?: number;
}
export interface MadeTorrent {
bytes: Buffer;
hash: string;
name: string;
totalSize: number;
files: { path: string; length: number }[];
}
/** 构造一个合法的 v1 .torrent(pieces 为伪值,不可真正下载)。 */
export function makeTorrent(input: MakeTorrentInput): MadeTorrent {
const pieceLength = input.pieceLength ?? 4 * 1024 * 1024;
const info: { [k: string]: BVal } = { name: input.name, "piece length": pieceLength };
let files: { path: string; length: number }[];
if (input.files) {
info.files = input.files.map((f) => ({ length: f.length, path: f.path.split("/") }));
files = input.files.map((f) => ({ path: `${input.name}/${f.path}`, length: f.length }));
} else {
info.length = input.length ?? 1024 * 1024 * 1024;
files = [{ path: input.name, length: info.length as number }];
}
const total = files.reduce((a, f) => a + f.length, 0);
const pieces = Math.max(1, Math.ceil(total / pieceLength));
info.pieces = Buffer.alloc(20 * Math.min(pieces, 4), input.pieceSeed ?? 7);
if (input.private) info.private = 1;
const root: { [k: string]: BVal } = { announce: input.announce ?? "https://tracker.example.invalid/announce", info };
const bytes = bencode(root);
const hash = createHash("sha1").update(bencode(info)).digest("hex");
return { bytes, hash, name: input.name, totalSize: total, files };
}
export function makeMagnet(hash: string, name?: string): string {
return `magnet:?xt=urn:btih:${hash}${name ? `&dn=${encodeURIComponent(name)}` : ""}`;
}
/** 从 .torrent 字节解析(mock 服务器使用):hash = sha1(bencode(info)) */
export function inspectTorrent(bytes: Buffer): MadeTorrent {
const root = bdecodeLoose(bytes) as { [k: string]: Dec };
const info = root.info as { [k: string]: Dec };
if (!info) throw new Error("no info");
const reenc = (d: Dec): BVal =>
Buffer.isBuffer(d)
? d
: Array.isArray(d)
? d.map(reenc)
: typeof d === "number"
? d
: Object.fromEntries(Object.entries(d).map(([k, v]) => [k, reenc(v)]));
const hash = createHash("sha1")
.update(bencode(reenc(info)))
.digest("hex");
const name = (info.name as Buffer).toString();
const files = Array.isArray(info.files)
? (info.files as { [k: string]: Dec }[]).map((f) => ({
path: [name, ...(f.path as Buffer[]).map((p) => p.toString())].join("/"),
length: f.length as number,
}))
: [{ path: name, length: info.length as number }];
return { bytes, hash, name, totalSize: files.reduce((a, f) => a + f.length, 0), files };
}
@@ -0,0 +1,46 @@
import type { IncomingMessage } from "node:http";
export async function readBody(req: IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
return Buffer.concat(chunks);
}
export interface MultipartPart {
name: string;
filename?: string;
data: Buffer;
}
/** 极简 multipart/form-data 解析,仅供 mock 使用。 */
export function parseMultipart(body: Buffer, contentType: string): MultipartPart[] {
const m = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType);
if (!m) return [];
const boundary = Buffer.from(`--${m[1] ?? m[2]}`);
const parts: MultipartPart[] = [];
let pos = body.indexOf(boundary);
while (pos >= 0) {
const start = pos + boundary.length;
if (body.subarray(start, start + 2).toString() === "--") break;
const next = body.indexOf(boundary, start);
if (next < 0) break;
const chunk = body.subarray(start + 2, next - 2); // 去掉起始 CRLF 与结尾 CRLF
const sep = chunk.indexOf("\r\n\r\n");
const head = chunk.subarray(0, sep).toString();
const data = chunk.subarray(sep + 4);
const name = /name="([^"]*)"/.exec(head)?.[1] ?? "";
const filename = /filename="([^"]*)"/.exec(head)?.[1];
parts.push({ name, ...(filename !== undefined ? { filename } : {}), data });
pos = next;
}
return parts;
}
export function parseCookies(header: string | undefined): Record<string, string> {
const out: Record<string, string> = {};
for (const p of (header ?? "").split(";")) {
const i = p.indexOf("=");
if (i > 0) out[p.slice(0, i).trim()] = p.slice(i + 1).trim();
}
return out;
}
@@ -0,0 +1,8 @@
export {
inspectTorrent as inspectTorrentFixture,
type MadeTorrent,
type MakeTorrentInput,
makeMagnet,
makeTorrent,
} from "./bencode";
export * from "./mock-qb";
+530
View File
@@ -0,0 +1,530 @@
import { randomBytes } from "node:crypto";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { inspectTorrent } from "./bencode";
import { parseCookies, parseMultipart, readBody } from "./http-util";
export interface MockQbFile {
name: string;
size: number;
progress: number;
priority: number;
}
export interface MockQbTorrent {
hash: string;
name: string;
state: string;
progress: number;
size: number;
total_size: number;
amount_left: number;
save_path: string;
content_path: string;
category: string;
tags: string[];
added_on: number;
completion_on: number;
dlspeed: number;
upspeed: number;
eta: number;
ratio: number;
seeding_time: number;
infohash_v1: string;
infohash_v2: string;
force_start: boolean;
up_limit: number;
dl_limit: number;
ratio_limit: number;
seeding_time_limit: number;
files: MockQbFile[];
/** 被 torrents/info 查询到之前还需隐藏的次数(模拟磁力异步出现) */
hiddenPolls: number;
}
export interface MockQbCall {
method: string;
endpoint: string;
query: Record<string, string>;
form: Record<string, string>;
status: number;
}
export interface MockQbFault {
/** 返回该 HTTP 状态码(不执行动作) */
status?: number;
body?: string;
/** 在执行动作之后销毁连接(模拟"已收单但响应丢失") */
dropAfterApply?: boolean;
/** 不执行动作直接销毁连接 */
drop?: boolean;
/** 延迟响应(先执行动作) */
delayMs?: number;
/** 生效次数,默认 1 */
times?: number;
}
export interface MockQbOptions {
version?: "4.6.7" | "5.1.0";
username?: string;
password?: string;
apiKey?: string;
/** add 响应格式;默认 4.x 为 text("Ok."),5.x 为 json(含 added_torrent_ids) */
addResponse?: "text" | "json";
/** 新加入的任务在 torrents/info 中隐藏的查询次数 */
addVisibleAfterPolls?: number;
/** app/preferences 返回值(只读;客户端不应写入) */
preferences?: Record<string, unknown>;
/** 磁力无元数据时的默认大小 */
magnetDefaultSize?: number;
port?: number;
}
export interface MockQb {
url: string;
calls: MockQbCall[];
close(): Promise<void>;
torrents(): MockQbTorrent[];
get(hash: string): MockQbTorrent | undefined;
tagDefinitions(): string[];
seed(t: Partial<MockQbTorrent> & { hash: string; name: string }): MockQbTorrent;
setProgress(hash: string, progress: number): void;
complete(hash: string): void;
setState(hash: string, state: string): void;
setError(hash: string): void;
setMissingFiles(hash: string): void;
/** 为某个端点注入故障(endpoint 如 "torrents/add") */
fault(endpoint: string, f: MockQbFault): void;
clearFaults(): void;
/** 让已有会话失效(模拟 qB 重启/会话过期) */
expireSessions(): void;
callsTo(endpoint: string): MockQbCall[];
}
const now = () => Math.floor(Date.now() / 1000);
export async function createMockQb(opts: MockQbOptions = {}): Promise<MockQb> {
const version = opts.version ?? "4.6.7";
const v5 = version.startsWith("5");
const user = opts.username ?? "admin";
const pass = opts.password ?? "adminadmin";
const addResponse = opts.addResponse ?? (v5 ? "json" : "text");
const torrents = new Map<string, MockQbTorrent>();
const tagDefs = new Set<string>();
const sessions = new Set<string>();
const faults = new Map<string, MockQbFault[]>();
const calls: MockQbCall[] = [];
let rid = 0;
const pausedState = (done: boolean) => (v5 ? (done ? "stoppedUP" : "stoppedDL") : done ? "pausedUP" : "pausedDL");
function mk(t: Partial<MockQbTorrent> & { hash: string; name: string }): MockQbTorrent {
const size = t.size ?? t.files?.reduce((a, f) => a + f.size, 0) ?? 1024;
const progress = t.progress ?? 0;
const savePath = t.save_path ?? "/downloads";
return {
state: progress >= 1 ? "uploading" : "downloading",
progress,
size,
total_size: size,
amount_left: Math.round(size * (1 - progress)),
save_path: savePath,
content_path: `${savePath.replace(/\/$/, "")}/${t.name}`,
category: "",
tags: [],
added_on: now(),
completion_on: progress >= 1 ? now() : -1,
dlspeed: 0,
upspeed: 0,
eta: 8640000,
ratio: 0,
seeding_time: 0,
infohash_v1: t.hash,
infohash_v2: "",
force_start: false,
up_limit: -1,
dl_limit: -1,
ratio_limit: -2,
seeding_time_limit: -2,
files: [{ name: t.name, size, progress, priority: 1 }],
hiddenPolls: 0,
...t,
};
}
function need(hash: string): MockQbTorrent {
const t = torrents.get(hash.toLowerCase());
if (!t) throw new Error(`mock qb: no torrent ${hash}`);
return t;
}
function setProgress(t: MockQbTorrent, p: number) {
t.progress = Math.max(0, Math.min(1, p));
t.amount_left = Math.round(t.size * (1 - t.progress));
for (const f of t.files) f.progress = f.priority === 0 ? 0 : t.progress;
if (t.progress >= 1) {
t.amount_left = 0;
t.state =
t.state.toLowerCase().startsWith("paused") || t.state.startsWith("stopped") ? pausedState(true) : "uploading";
t.completion_on = now();
} else if (!/^(paused|stopped)/i.test(t.state)) {
t.state = "downloading";
}
}
function toInfo(t: MockQbTorrent) {
const { files: _f, hiddenPolls: _h, tags, ...rest } = t;
return { ...rest, tags: tags.join(", ") };
}
function hashesOf(v: string | undefined): string[] | "all" {
if (!v) return [];
if (v === "all") return "all";
return v.split("|").map((h) => h.toLowerCase());
}
function select(v: string | undefined): MockQbTorrent[] {
const hs = hashesOf(v);
if (hs === "all") return [...torrents.values()];
return hs.map((h) => torrents.get(h)).filter((t): t is MockQbTorrent => !!t);
}
function splitTags(v: string | undefined): string[] {
return (v ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
function matchFilter(t: MockQbTorrent, filter: string): boolean {
const s = t.state.toLowerCase();
switch (filter) {
case "all":
return true;
case "downloading":
return ["downloading", "stalleddl", "metadl", "forceddl", "queueddl", "pauseddl", "stoppeddl"].includes(s);
case "completed":
return t.progress >= 1;
case "seeding":
return ["uploading", "stalledup", "forcedup", "queuedup"].includes(s);
case "paused":
case "stopped":
return s.startsWith("paused") || s.startsWith("stopped");
case "errored":
return s === "error" || s === "missingfiles";
case "stalled":
return s.startsWith("stalled");
default:
return true;
}
}
type Handler = (
q: Record<string, string>,
form: Record<string, string>,
raw: IncomingMessage,
body: Buffer,
) => {
status?: number;
body: string;
json?: boolean;
headers?: Record<string, string>;
};
const handlers: Record<string, Handler> = {
"app/version": () => ({ body: `v${version}` }),
"app/webapiVersion": () => ({ body: v5 ? "2.11.4" : "2.9.3" }),
"torrents/info": (q) => {
let list = [...torrents.values()];
if (q.hashes) {
const hs = hashesOf(q.hashes);
if (hs !== "all") list = list.filter((t) => hs.includes(t.hash));
}
if (q.tag !== undefined)
list = list.filter((t) => (q.tag === "" ? t.tags.length === 0 : t.tags.includes(q.tag!)));
if (q.category !== undefined) list = list.filter((t) => t.category === q.category);
if (q.filter) list = list.filter((t) => matchFilter(t, q.filter!));
const visible = list.filter((t) => {
if (t.hiddenPolls > 0) {
t.hiddenPolls--;
return false;
}
return true;
});
return { body: JSON.stringify(visible.map(toInfo)), json: true };
},
"torrents/files": (q) => {
const t = torrents.get((q.hash ?? "").toLowerCase());
if (!t) return { status: 404, body: "Torrent hash was not found" };
return {
body: JSON.stringify(
t.files.map((f, index) => ({ index, ...f, is_seed: t.progress >= 1, availability: 1, piece_range: [0, 0] })),
),
json: true,
};
},
"torrents/add": (_q, form, raw, body) => {
const parts = parseMultipart(body, raw.headers["content-type"] ?? "");
const field = (n: string) => parts.find((p) => p.name === n)?.data.toString();
const added: MockQbTorrent[] = [];
let failed = 0;
const base = {
save_path: field("savepath") || "/downloads",
category: field("category") ?? "",
tags: splitTags(field("tags")),
};
const paused = (field(v5 ? "stopped" : "paused") ?? form.paused) === "true";
const rename = field("rename");
const create = (hash: string, name: string, files: { path: string; length: number }[]) => {
if (torrents.has(hash)) {
failed++;
return;
}
const nm = rename || name;
const size = files.reduce((a, f) => a + f.length, 0);
const t = mk({
hash,
name: nm,
...base,
size,
files: files.map((f) => ({ name: f.path, size: f.length, progress: 0, priority: 1 })),
hiddenPolls: opts.addVisibleAfterPolls ?? 0,
});
if (paused) t.state = pausedState(false);
for (const tg of t.tags) tagDefs.add(tg);
torrents.set(hash, t);
added.push(t);
};
for (const p of parts.filter((x) => x.name === "torrents")) {
try {
const m = inspectTorrent(p.data);
create(m.hash, m.name, m.files);
} catch {
return { status: 415, body: "Torrent file is not valid" };
}
}
for (const u of (field("urls") ?? "").split("\n").map((s) => s.trim())) {
if (!u) continue;
const m = /xt=urn:btih:([0-9a-fA-F]{40})/.exec(u);
if (!m) {
failed++;
continue;
}
const hash = m[1]!.toLowerCase();
const dn = new URLSearchParams(u.slice(u.indexOf("?") + 1)).get("dn") ?? hash;
create(hash, dn, [{ path: dn, length: opts.magnetDefaultSize ?? 1024 * 1024 * 1024 }]);
}
if (addResponse === "json") {
return {
body: JSON.stringify({
success_count: added.length,
failure_count: failed,
pending_count: 0,
added_torrent_ids: added.map((t) => t.hash),
}),
json: true,
};
}
return { body: added.length > 0 ? "Ok." : "Fails." };
},
"torrents/addTags": (_q, form) => {
const tags = splitTags(form.tags);
for (const tg of tags) tagDefs.add(tg);
for (const t of select(form.hashes)) for (const tg of tags) if (!t.tags.includes(tg)) t.tags.push(tg);
return { body: "" };
},
"torrents/removeTags": (_q, form) => {
const tags = splitTags(form.tags);
for (const t of select(form.hashes)) t.tags = t.tags.filter((x) => !tags.includes(x));
return { body: "" };
},
"torrents/deleteTags": (_q, form) => {
const tags = splitTags(form.tags);
for (const tg of tags) tagDefs.delete(tg);
for (const t of torrents.values()) t.tags = t.tags.filter((x) => !tags.includes(x));
return { body: "" };
},
"torrents/filePrio": (_q, form) => {
const t = torrents.get((form.hash ?? "").toLowerCase());
if (!t) return { status: 404, body: "Torrent hash was not found" };
const ids = (form.id ?? "").split("|").map(Number);
if (ids.some((i) => !Number.isInteger(i) || i < 0 || i >= t.files.length))
return { status: 409, body: "invalid file id" };
for (const i of ids) t.files[i]!.priority = Number(form.priority);
return { body: "" };
},
"torrents/setForceStart": (_q, form) => {
for (const t of select(form.hashes)) {
t.force_start = form.value === "true";
if (t.force_start) t.state = t.progress >= 1 ? "forcedUP" : "forcedDL";
}
return { body: "" };
},
"torrents/recheck": (_q, form) => {
for (const t of select(form.hashes)) t.state = t.progress >= 1 ? "checkingUP" : "checkingDL";
return { body: "" };
},
"torrents/setLocation": (_q, form) => {
if (!form.location) return { status: 400, body: "location" };
for (const t of select(form.hashes)) {
t.save_path = form.location;
t.content_path = `${form.location.replace(/\/$/, "")}/${t.name}`;
}
return { body: "" };
},
"torrents/setCategory": (_q, form) => {
for (const t of select(form.hashes)) t.category = form.category ?? "";
return { body: "" };
},
"torrents/setUploadLimit": (_q, form) => {
for (const t of select(form.hashes)) t.up_limit = Number(form.limit);
return { body: "" };
},
"torrents/setDownloadLimit": (_q, form) => {
for (const t of select(form.hashes)) t.dl_limit = Number(form.limit);
return { body: "" };
},
"torrents/setShareLimits": (_q, form) => {
for (const t of select(form.hashes)) {
t.ratio_limit = Number(form.ratioLimit);
t.seeding_time_limit = Number(form.seedingTimeLimit);
}
return { body: "" };
},
"app/preferences": () => ({
body: JSON.stringify({ torrent_content_layout: "Original", incomplete_files_ext: false, ...opts.preferences }),
json: true,
}),
"app/setPreferences": () => ({ status: 500, body: "mock: writing global preferences is not allowed" }),
"transfer/info": () => ({
body: JSON.stringify({ dl_info_speed: 0, up_info_speed: 0, connection_status: "connected" }),
json: true,
}),
"torrents/tags": () => ({ body: JSON.stringify([...tagDefs]), json: true }),
"torrents/delete": (_q, form) => {
if (form.deleteFiles !== "true" && form.deleteFiles !== "false") return { status: 400, body: "deleteFiles" };
for (const t of select(form.hashes)) torrents.delete(t.hash);
return { body: "" };
},
"sync/maindata": () => {
rid++;
return {
body: JSON.stringify({
rid,
full_update: true,
torrents: Object.fromEntries([...torrents.values()].map((t) => [t.hash, toInfo(t)])),
server_state: { free_space_on_disk: 1e12 },
}),
json: true,
};
},
};
const pauseH: Handler = (_q, form) => {
for (const t of select(form.hashes)) t.state = pausedState(t.progress >= 1);
return { body: "" };
};
const resumeH: Handler = (_q, form) => {
for (const t of select(form.hashes)) t.state = t.progress >= 1 ? "stalledUP" : "downloading";
return { body: "" };
};
if (v5) {
handlers["torrents/stop"] = pauseH;
handlers["torrents/start"] = resumeH;
} else {
handlers["torrents/pause"] = pauseH;
handlers["torrents/resume"] = resumeH;
}
const server: Server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
const u = new URL(req.url ?? "/", "http://mock");
const endpoint = u.pathname.replace(/^\/api\/v2\//, "");
const query = Object.fromEntries(u.searchParams);
const body = await readBody(req);
const ct = req.headers["content-type"] ?? "";
const form = ct.includes("urlencoded") ? Object.fromEntries(new URLSearchParams(body.toString())) : {};
const call: MockQbCall = { method: req.method ?? "GET", endpoint, query, form, status: 0 };
calls.push(call);
const send = (status: number, text: string, headers: Record<string, string> = {}) => {
call.status = status;
res.writeHead(status, { "content-type": "text/plain; charset=utf-8", ...headers });
res.end(text);
};
const fq = faults.get(endpoint);
const fault = fq?.[0];
if (fault) {
fault.times = (fault.times ?? 1) - 1;
if (fault.times <= 0) fq!.shift();
if (fault.drop) {
call.status = -1;
req.socket.destroy();
return;
}
if (fault.status) return send(fault.status, fault.body ?? "");
}
if (endpoint === "auth/login") {
if (form.username === user && form.password === pass) {
const sid = randomBytes(16).toString("hex");
sessions.add(sid);
return send(200, "Ok.", { "set-cookie": `SID=${sid}; HttpOnly; path=/` });
}
return send(200, "Fails.");
}
const bearer = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1];
const sid = parseCookies(req.headers.cookie).SID;
const authed = (opts.apiKey && bearer === opts.apiKey) || (sid !== undefined && sessions.has(sid));
if (!authed) return send(403, "Forbidden");
const h = handlers[endpoint];
if (!h) return send(404, "Not Found");
const r = h(query, form, req, body);
if (fault?.dropAfterApply) {
call.status = -1;
req.socket.destroy();
return;
}
if (fault?.delayMs) await new Promise((ok) => setTimeout(ok, fault.delayMs));
if (res.destroyed) return;
send(r.status ?? 200, r.body, r.json ? { "content-type": "application/json" } : {});
});
await new Promise<void>((ok) => server.listen(opts.port ?? 0, "127.0.0.1", ok));
const { port } = server.address() as AddressInfo;
return {
url: `http://127.0.0.1:${port}`,
calls,
close: () =>
new Promise<void>((ok) => {
server.closeAllConnections();
server.close(() => ok());
}),
torrents: () => [...torrents.values()],
get: (h) => torrents.get(h.toLowerCase()),
tagDefinitions: () => [...tagDefs],
seed: (t) => {
const m = mk({ ...t, hash: t.hash.toLowerCase() });
for (const tg of m.tags) tagDefs.add(tg);
torrents.set(m.hash, m);
return m;
},
setProgress: (h, p) => setProgress(need(h), p),
complete: (h) => setProgress(need(h), 1),
setState: (h, s) => {
need(h).state = s;
},
setError: (h) => {
need(h).state = "error";
},
setMissingFiles: (h) => {
need(h).state = "missingFiles";
},
fault: (endpoint, f) => {
const q = faults.get(endpoint) ?? [];
q.push({ ...f });
faults.set(endpoint, q);
},
clearFaults: () => faults.clear(),
expireSessions: () => sessions.clear(),
callsTo: (e) => calls.filter((c) => c.endpoint === e),
};
}
+219
View File
@@ -0,0 +1,219 @@
/**
* mock TMDB 固定数据。电影/剧集 ID 取自公开 TMDB 条目以便对照,但标题/集数/日期只保证"合理",
* 不作为真实数据的权威来源;国产综艺条目 ID(900001)为虚构。
*/
export interface TmdbFixtureEpisode {
episode_number: number;
air_date: string | null;
names: Record<string, string>;
}
export interface TmdbFixtureSeason {
season_number: number;
air_date: string | null;
names: Record<string, string>;
episodes: TmdbFixtureEpisode[];
}
export interface TmdbFixture {
type: "movie" | "tv";
id: number;
original_title: string;
original_language: string;
origin_country: string[];
date: string;
titles: Record<string, string>;
overviews?: Record<string, string>;
popularity: number;
alternative_titles: { iso_3166_1: string; title: string; type: string }[];
external_ids: { imdb_id?: string | null; tvdb_id?: number | null };
/** 额外译名(titles 中的语言会自动生成 translations) */
extra_translations?: { iso_639_1: string; iso_3166_1: string; title: string }[];
seasons?: TmdbFixtureSeason[];
}
function eps(n: number, start: string, zh: (i: number) => string, en: (i: number) => string): TmdbFixtureEpisode[] {
const base = Date.parse(start);
return Array.from({ length: n }, (_, k) => ({
episode_number: k + 1,
air_date: new Date(base + k * 7 * 86400_000).toISOString().slice(0, 10),
names: { "zh-CN": zh(k + 1), "en-US": en(k + 1) },
}));
}
export const TMDB_FIXTURES: TmdbFixture[] = [
{
type: "movie",
id: 872585,
original_title: "Oppenheimer",
original_language: "en",
origin_country: ["US"],
date: "2023-07-19",
titles: { "zh-CN": "奥本海默", "en-US": "Oppenheimer" },
overviews: { "zh-CN": "原子弹之父的故事。", "en-US": "The story of J. Robert Oppenheimer." },
popularity: 120.5,
alternative_titles: [
{ iso_3166_1: "TW", title: "奧本海默", type: "" },
{ iso_3166_1: "CN", title: "奥本海默传", type: "" },
],
external_ids: { imdb_id: "tt15398776" },
extra_translations: [{ iso_639_1: "ja", iso_3166_1: "JP", title: "オッペンハイマー" }],
},
{
type: "movie",
id: 693134,
original_title: "Dune: Part Two",
original_language: "en",
origin_country: ["US"],
date: "2024-02-27",
titles: { "zh-CN": "沙丘2", "en-US": "Dune: Part Two" },
popularity: 210.1,
alternative_titles: [
{ iso_3166_1: "US", title: "Dune 2", type: "" },
{ iso_3166_1: "CN", title: "沙丘:第二部", type: "" },
{ iso_3166_1: "TW", title: "沙丘:第二部", type: "" },
],
external_ids: { imdb_id: "tt15239678" },
},
{
type: "movie",
id: 842675,
original_title: "流浪地球2",
original_language: "zh",
origin_country: ["CN"],
date: "2023-01-22",
titles: { "zh-CN": "流浪地球2", "en-US": "The Wandering Earth II" },
popularity: 45.3,
alternative_titles: [
{ iso_3166_1: "US", title: "The Wandering Earth 2", type: "" },
{ iso_3166_1: "CN", title: "流浪地球 2", type: "" },
],
external_ids: { imdb_id: "tt13539646" },
},
{
type: "tv",
id: 136315,
original_title: "The Bear",
original_language: "en",
origin_country: ["US"],
date: "2022-06-23",
titles: { "zh-CN": "熊家餐馆", "en-US": "The Bear" },
popularity: 88.2,
alternative_titles: [
{ iso_3166_1: "TW", title: "大熊餐廳", type: "" },
{ iso_3166_1: "CN", title: "熊家小馆", type: "" },
],
external_ids: { imdb_id: "tt14452776", tvdb_id: 401654 },
seasons: [
{
season_number: 1,
air_date: "2022-06-23",
names: { "zh-CN": "第 1 季", "en-US": "Season 1" },
episodes: eps(
8,
"2022-06-23",
(i) => `第 ${i} 集`,
(i) => `Episode ${i}`,
),
},
{
season_number: 2,
air_date: "2023-06-22",
names: { "zh-CN": "第 2 季", "en-US": "Season 2" },
episodes: eps(
10,
"2023-06-22",
(i) => `第 ${i} 集`,
(i) => `Episode ${i}`,
),
},
{
season_number: 3,
air_date: "2024-06-26",
names: { "zh-CN": "第 3 季", "en-US": "Season 3" },
episodes: eps(
10,
"2024-06-26",
(i) => `第 ${i} 集`,
(i) => `Episode ${i}`,
),
},
],
},
{
type: "tv",
id: 100088,
original_title: "The Last of Us",
original_language: "en",
origin_country: ["US"],
date: "2023-01-15",
titles: { "zh-CN": "最后生还者", "en-US": "The Last of Us" },
popularity: 150.7,
alternative_titles: [
{ iso_3166_1: "CN", title: "最后的生还者", type: "" },
{ iso_3166_1: "TW", title: "最後生還者", type: "" },
],
external_ids: { imdb_id: "tt3581920", tvdb_id: 392256 },
seasons: [
{
season_number: 1,
air_date: "2023-01-15",
names: { "zh-CN": "第 1 季", "en-US": "Season 1" },
episodes: eps(
9,
"2023-01-15",
(i) => `第 ${i} 集`,
(i) => `Episode ${i}`,
),
},
],
},
{
type: "tv",
id: 900001,
original_title: "向往的生活",
original_language: "zh",
origin_country: ["CN"],
date: "2017-01-15",
titles: { "zh-CN": "向往的生活", "en-US": "Back to Field" },
popularity: 12.4,
alternative_titles: [
{ iso_3166_1: "CN", title: "向往的生活 第一季", type: "" },
{ iso_3166_1: "CN", title: "向往", type: "简称" },
],
external_ids: { imdb_id: null, tvdb_id: null },
seasons: [
{
season_number: 0,
air_date: "2017-01-10",
names: { "zh-CN": "特别篇", "en-US": "Specials" },
episodes: eps(
2,
"2017-01-10",
(i) => `先导片 ${i}`,
(i) => `Special ${i}`,
),
},
{
season_number: 1,
air_date: "2017-01-15",
names: { "zh-CN": "第 1 季", "en-US": "Season 1" },
episodes: eps(
12,
"2017-01-15",
(i) => `第 ${i} 期`,
(i) => `Episode ${i}`,
),
},
{
season_number: 2,
air_date: "2018-04-20",
names: { "zh-CN": "第 2 季", "en-US": "Season 2" },
episodes: eps(
3,
"2018-04-20",
(i) => `第 ${i} 期`,
(i) => `Episode ${i}`,
),
},
],
},
];
+3
View File
@@ -0,0 +1,3 @@
export * from "./fixtures";
export * from "./mock-http";
export * from "./mock-tmdb";
+104
View File
@@ -0,0 +1,104 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
/** 通用 JSON mock 服务底座:路由、调用日志、可编程故障。供 mock TMDB / mock Emby 使用。 */
export interface MockCall {
method: string;
path: string;
query: Record<string, string>;
headers: Record<string, string | string[] | undefined>;
status: number;
}
export interface MockFault {
status?: number;
body?: unknown;
headers?: Record<string, string>;
delayMs?: number;
/** 直接断开连接 */
drop?: boolean;
times?: number;
}
export interface MockReply {
status?: number;
json?: unknown;
headers?: Record<string, string>;
}
export type MockRoute = (m: RegExpMatchArray, q: Record<string, string>, req: IncomingMessage) => MockReply;
export interface MockHttp {
url: string;
calls: MockCall[];
close(): Promise<void>;
/** 对路径匹配 pattern(字符串前缀或正则)的请求注入故障 */
fault(pattern: string | RegExp, f: MockFault): void;
clearFaults(): void;
callsTo(pattern: string | RegExp): MockCall[];
}
const matches = (p: string | RegExp, path: string) => (typeof p === "string" ? path.startsWith(p) : p.test(path));
export async function startMockHttp(
routes: [RegExp, MockRoute][],
opts: { port?: number; guard?: (req: IncomingMessage, q: Record<string, string>) => MockReply | null } = {},
): Promise<MockHttp> {
const calls: MockCall[] = [];
const faults: { pattern: string | RegExp; f: MockFault }[] = [];
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
const u = new URL(req.url ?? "/", "http://mock");
const q = Object.fromEntries(u.searchParams);
const call: MockCall = { method: req.method ?? "GET", path: u.pathname, query: q, headers: req.headers, status: 0 };
calls.push(call);
const reply = (r: MockReply) => {
call.status = r.status ?? 200;
if (res.destroyed) return;
res.writeHead(call.status, { "content-type": "application/json", ...r.headers });
res.end(r.json === undefined ? "" : JSON.stringify(r.json));
};
const fi = faults.findIndex((x) => matches(x.pattern, u.pathname));
const fault = fi >= 0 ? faults[fi]!.f : undefined;
if (fault) {
fault.times = (fault.times ?? 1) - 1;
if (fault.times <= 0) faults.splice(fi, 1);
if (fault.delayMs) await new Promise((ok) => setTimeout(ok, fault.delayMs));
if (fault.drop) {
call.status = -1;
req.socket.destroy();
return;
}
if (fault.status)
return reply({ status: fault.status, json: fault.body, ...(fault.headers ? { headers: fault.headers } : {}) });
}
const denied = opts.guard?.(req, q);
if (denied) return reply(denied);
for (const [re, h] of routes) {
const m = re.exec(u.pathname);
if (m) {
try {
return reply(h(m, q, req));
} catch (e) {
return reply({ status: 500, json: { error: String(e) } });
}
}
}
reply({ status: 404, json: { error: "no route" } });
});
await new Promise<void>((ok) => server.listen(opts.port ?? 0, "127.0.0.1", ok));
const { port } = server.address() as AddressInfo;
return {
url: `http://127.0.0.1:${port}`,
calls,
close: () =>
new Promise<void>((ok) => {
server.closeAllConnections();
server.close(() => ok());
}),
fault: (pattern, f) => faults.push({ pattern, f: { ...f } }),
clearFaults: () => {
faults.length = 0;
},
callsTo: (p) => calls.filter((c) => matches(p, c.path)),
};
}
+158
View File
@@ -0,0 +1,158 @@
import { TMDB_FIXTURES, type TmdbFixture } from "./fixtures";
import { type MockHttp, type MockReply, startMockHttp } from "./mock-http";
export interface MockTmdbOptions {
apiKey?: string;
accessToken?: string;
fixtures?: TmdbFixture[];
port?: number;
}
export type MockTmdb = MockHttp & { fixtures: TmdbFixture[] };
const norm = (s: string) => s.toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, "");
const pick = (m: Record<string, string>, lang: string | undefined, fb: string) =>
m[lang ?? "en-US"] ?? m[(lang ?? "").split("-")[0] ?? ""] ?? m["en-US"] ?? fb;
const notFound: MockReply = {
status: 404,
json: { success: false, status_code: 34, status_message: "The resource you requested could not be found." },
};
/** mock TMDB v3:/3/search/{movie,tv}、/3/{movie,tv}/:id(append_to_response)、/3/tv/:id/season/:n */
export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise<MockTmdb> {
const fx = opts.fixtures ?? TMDB_FIXTURES;
const apiKey = opts.apiKey ?? "test-tmdb-key";
const find = (type: string, id: string) => fx.find((f) => f.type === type && String(f.id) === id);
const matchText = (f: TmdbFixture, needle: string) =>
[f.original_title, ...Object.values(f.titles), ...f.alternative_titles.map((a) => a.title)].some((t) =>
norm(t).includes(needle),
);
const brief = (f: TmdbFixture, lang?: string) => {
const title = pick(f.titles, lang, f.original_title);
const overview = f.overviews ? pick(f.overviews, lang, "") : "";
return f.type === "movie"
? {
id: f.id,
title,
original_title: f.original_title,
release_date: f.date,
original_language: f.original_language,
overview,
popularity: f.popularity,
}
: {
id: f.id,
name: title,
original_name: f.original_title,
first_air_date: f.date,
original_language: f.original_language,
origin_country: f.origin_country,
overview,
popularity: f.popularity,
};
};
const http = await startMockHttp(
[
[
/^\/3\/search\/multi$/,
(_m, q) => {
const needle = norm(q.query ?? "");
const results = fx
.filter((f) => needle && matchText(f, needle))
.sort((a, b) => b.popularity - a.popularity)
.map((f) => ({ ...brief(f, q.language), media_type: f.type }));
return { json: { page: 1, results, total_pages: 1, total_results: results.length } };
},
],
[
/^\/3\/search\/(movie|tv)$/,
(m, q) => {
const type = m[1]!;
const needle = norm(q.query ?? "");
const year = q.year ?? q.primary_release_year ?? q.first_air_date_year;
const results = fx
.filter((f) => f.type === type)
.filter((f) => !year || f.date.startsWith(year))
.filter((f) => !!needle && matchText(f, needle))
.sort((a, b) => b.popularity - a.popularity)
.map((f) => brief(f, q.language));
return { json: { page: 1, results, total_pages: 1, total_results: results.length } };
},
],
[
/^\/3\/tv\/(\d+)\/season\/(\d+)$/,
(m, q) => {
const f = find("tv", m[1]!);
const s = f?.seasons?.find((x) => String(x.season_number) === m[2]);
if (!f || !s) return notFound;
return {
json: {
id: f.id * 100 + s.season_number,
season_number: s.season_number,
name: pick(s.names, q.language, `Season ${s.season_number}`),
air_date: s.air_date,
episodes: s.episodes.map((e) => ({
episode_number: e.episode_number,
season_number: s.season_number,
name: pick(e.names, q.language, ""),
air_date: e.air_date,
})),
},
};
},
],
[
/^\/3\/(movie|tv)\/(\d+)$/,
(m, q) => {
const f = find(m[1]!, m[2]!);
if (!f) return notFound;
const append = new Set((q.append_to_response ?? "").split(",").filter(Boolean));
const out: Record<string, unknown> = { ...brief(f, q.language), origin_country: f.origin_country };
if (f.type === "tv") {
out.number_of_seasons = f.seasons?.filter((s) => s.season_number > 0).length ?? 0;
out.seasons = (f.seasons ?? []).map((s) => ({
season_number: s.season_number,
episode_count: s.episodes.length,
name: pick(s.names, q.language, ""),
air_date: s.air_date,
}));
}
if (append.has("alternative_titles")) {
out.alternative_titles =
f.type === "movie" ? { titles: f.alternative_titles } : { results: f.alternative_titles };
}
if (append.has("external_ids")) out.external_ids = f.external_ids;
if (append.has("translations")) {
const key = f.type === "movie" ? "title" : "name";
out.translations = {
translations: [
...Object.entries(f.titles).map(([lang, t]) => {
const [l, r] = lang.split("-");
return { iso_639_1: l, iso_3166_1: r ?? "", data: { [key]: t } };
}),
...(f.extra_translations ?? []).map((x) => ({
iso_639_1: x.iso_639_1,
iso_3166_1: x.iso_3166_1,
data: { [key]: x.title },
})),
],
};
}
return { json: out };
},
],
],
{
...(opts.port !== undefined ? { port: opts.port } : {}),
guard: (req, q) => {
const bearer = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1];
const ok = q.api_key === apiKey || (opts.accessToken !== undefined && bearer === opts.accessToken);
return ok ? null : { status: 401, json: { success: false, status_code: 7, status_message: "Invalid API key" } };
},
},
);
return Object.assign(http, { fixtures: fx });
}