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:
1 parent
9c881e737f
commit
ef2e504a94
45 files changed
+5766
-1
No files matched your search
@@ -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 }),
|
||||
];
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./fixtures";
|
||||
export * from "./mock-emby";
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./temp-tree";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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}`,
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./fixtures";
|
||||
export * from "./mock-http";
|
||||
export * from "./mock-tmdb";
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in new issue
Block a user