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 });
}