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