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,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 });
|
||||
}
|
||||
Reference in new issue
Block a user