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,5 @@
|
||||
# emby
|
||||
|
||||
Emby / Jellyfin 兼容库存查询(`EmbyClient`,`MediaServerRegistry` 管多实例):`/Users` 与查询用户解析(用户名 → 管理员回退)、`/Users/{id}/Items`(IncludeItemTypes、SearchTerm、AnyProviderIdEquals + 客户端复核 ProviderIds)、单条目、`/Shows/{id}/Seasons|Episodes`(排除 Virtual 缺集占位,多集文件展开)、MediaSources 归一化(分辨率/编码/HDR/音轨/字幕/路径/大小,接口没给的字段不补值)。所有查询返回 `ok` 或 `unknown`;`findByProviderId` 只有在查询成功且结果未截断时才给 `not_found`。Emby 走 `/emby` 前缀 + `X-Emby-Token`,Jellyfin 无前缀 + `Authorization: MediaBrowser Token=`。
|
||||
|
||||
仅对 mock 与官方 API 文档验证,未连接真实实例。
|
||||
@@ -0,0 +1,575 @@
|
||||
/**
|
||||
* Emby / Jellyfin 兼容库存查询。
|
||||
* 迁移来源:MoviePilot app/modules/emby/emby.py(e6820a8)
|
||||
* get_user → resolveUserId;__get_emby_series_id_by_name → findSeriesIdByName;
|
||||
* get_movies → getMovies;get_tv_episodes → getTvEpisodes;get_iteminfo → getItem;
|
||||
* app/application/mediaserver.py MediaServerIdentityHelper → providerIdentity / identitiesCompatible。
|
||||
* 调整:MP 用 None/""/[] 表达"连不通/未找到",这里统一为判别联合;失败一律 unknown,不当作"不存在"
|
||||
* (REQ §7.4);api_key 走请求头而非 URL,避免凭证进日志。
|
||||
*/
|
||||
|
||||
export type EmbyErrorKind = "auth" | "network" | "timeout" | "notfound" | "rate" | "protocol";
|
||||
|
||||
export interface EmbyConfig {
|
||||
/** media_server_instance.id */
|
||||
instanceId: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
flavor?: "emby" | "jellyfin";
|
||||
/** 查询用户;缺省按 username 匹配,再回退管理员(对照 MP get_user) */
|
||||
userId?: string;
|
||||
username?: string;
|
||||
/** API 路径前缀;默认 emby → "/emby",jellyfin → "" */
|
||||
pathPrefix?: string;
|
||||
timeoutMs?: number;
|
||||
retries?: number;
|
||||
retryBaseMs?: number;
|
||||
/** 搜索/分页单页大小,默认 50 */
|
||||
pageSize?: number;
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
export type Observed<T> =
|
||||
| { status: "ok"; value: T; observedAt: string }
|
||||
| { status: "unknown"; reason: EmbyErrorKind | "truncated"; message: string; observedAt: string };
|
||||
|
||||
export interface EmbyVideoStream {
|
||||
codec?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
videoRange?: string;
|
||||
bitDepth?: number;
|
||||
profile?: string;
|
||||
}
|
||||
export interface EmbyAudioStream {
|
||||
codec?: string;
|
||||
channels?: number;
|
||||
language?: string;
|
||||
title?: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
export interface EmbySubtitleStream {
|
||||
codec?: string;
|
||||
language?: string;
|
||||
title?: string;
|
||||
isExternal?: boolean;
|
||||
}
|
||||
/** 一个 MediaSource = 一个版本。接口未给出的字段保持缺省,不推断补值。 */
|
||||
export interface EmbyMediaVersion {
|
||||
id: string;
|
||||
name?: string;
|
||||
path?: string;
|
||||
container?: string;
|
||||
sizeBytes?: number;
|
||||
bitrate?: number;
|
||||
video?: EmbyVideoStream;
|
||||
audio: EmbyAudioStream[];
|
||||
subtitles: EmbySubtitleStream[];
|
||||
}
|
||||
|
||||
export interface EmbyItem {
|
||||
instanceId: string;
|
||||
itemId: string;
|
||||
type: string;
|
||||
name: string;
|
||||
originalTitle?: string;
|
||||
year?: number;
|
||||
/** 原样保留服务器返回的键(Tmdb/Imdb/Tvdb…) */
|
||||
providerIds: Record<string, string>;
|
||||
path?: string;
|
||||
seriesId?: string;
|
||||
seriesName?: string;
|
||||
seasonId?: string;
|
||||
/** Season: IndexNumber;Episode: ParentIndexNumber */
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
episodeNumberEnd?: number;
|
||||
/** Virtual = 服务器生成的缺集占位,不代表有文件 */
|
||||
locationType?: string;
|
||||
versions: EmbyMediaVersion[];
|
||||
}
|
||||
|
||||
export interface EmbyUser {
|
||||
id: string;
|
||||
name: string;
|
||||
isAdministrator: boolean | null;
|
||||
}
|
||||
|
||||
export interface ItemQuery {
|
||||
types?: ("Movie" | "Series" | "Season" | "Episode")[];
|
||||
searchTerm?: string;
|
||||
/** 例 { Tmdb: "872585" };Emby 走 AnyProviderIdEquals,并总在客户端复核(Jellyfin 忽略该参数) */
|
||||
providerIds?: Record<string, string>;
|
||||
parentId?: string;
|
||||
startIndex?: number;
|
||||
limit?: number;
|
||||
withMediaSources?: boolean;
|
||||
}
|
||||
|
||||
export type MediaIdentitySource = "tmdb" | "douban" | "bangumi" | "anilist" | "imdb" | "tvdb" | "musicbrainz";
|
||||
|
||||
const PROVIDER_KEYS: [MediaIdentitySource, string[]][] = [
|
||||
["tmdb", ["Tmdb", "TMDB", "tmdb", "tmdb_id"]],
|
||||
["douban", ["Douban", "douban", "douban_id"]],
|
||||
["bangumi", ["Bangumi", "bangumi", "bangumi_id"]],
|
||||
["anilist", ["AniList", "Anilist", "anilist", "anilist_id"]],
|
||||
["imdb", ["Imdb", "IMDb", "imdb", "imdb_id"]],
|
||||
["tvdb", ["Tvdb", "TVDB", "tvdb", "tvdb_id"]],
|
||||
["musicbrainz", ["MusicBrainz", "musicbrainz", "musicbrainz_id"]],
|
||||
];
|
||||
|
||||
/** 按固定优先级从 ProviderIds 选一个规范身份(对照 MediaServerIdentityHelper.from_provider_ids)。 */
|
||||
export function providerIdentity(
|
||||
providerIds: Record<string, string> | undefined,
|
||||
): { source: MediaIdentitySource; id: string } | null {
|
||||
if (!providerIds) return null;
|
||||
for (const [source, keys] of PROVIDER_KEYS) {
|
||||
for (const k of keys) {
|
||||
const v = providerIds[k];
|
||||
if (v != null && String(v).trim()) return { source, id: String(v).trim() };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 两组身份是否"没有可证实的同来源 ID 冲突"(对照 are_compatible):任一为空或来源不同 → 兼容。 */
|
||||
export function identitiesCompatible(
|
||||
a: { source: string; id: string } | null | undefined,
|
||||
b: { source: string; id: string } | null | undefined,
|
||||
): boolean {
|
||||
if (!a || !b) return true;
|
||||
if (a.source.toLowerCase() !== b.source.toLowerCase()) return true;
|
||||
return a.id === b.id;
|
||||
}
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const str = (v: unknown) => (typeof v === "string" && v !== "" ? v : undefined);
|
||||
const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
|
||||
const bool = (v: unknown) => (typeof v === "boolean" ? v : undefined);
|
||||
function clean<T extends object>(o: T): T {
|
||||
return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T;
|
||||
}
|
||||
|
||||
class EmbyHttpError extends Error {
|
||||
constructor(
|
||||
readonly kind: EmbyErrorKind,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeMediaSource(ms: Json): EmbyMediaVersion {
|
||||
const streams = (Array.isArray(ms.MediaStreams) ? ms.MediaStreams : []) as Json[];
|
||||
const v = streams.find((s) => s.Type === "Video");
|
||||
return {
|
||||
...clean({
|
||||
id: String(ms.Id ?? ""),
|
||||
name: str(ms.Name),
|
||||
path: str(ms.Path),
|
||||
container: str(ms.Container),
|
||||
sizeBytes: num(ms.Size),
|
||||
bitrate: num(ms.Bitrate),
|
||||
video: v
|
||||
? clean({
|
||||
codec: str(v.Codec),
|
||||
width: num(v.Width),
|
||||
height: num(v.Height),
|
||||
videoRange: str(v.VideoRange),
|
||||
bitDepth: num(v.BitDepth),
|
||||
profile: str(v.Profile),
|
||||
})
|
||||
: undefined,
|
||||
}),
|
||||
audio: streams
|
||||
.filter((s) => s.Type === "Audio")
|
||||
.map((s) =>
|
||||
clean({
|
||||
codec: str(s.Codec),
|
||||
channels: num(s.Channels),
|
||||
language: str(s.Language),
|
||||
title: str(s.Title) ?? str(s.DisplayTitle),
|
||||
isDefault: bool(s.IsDefault),
|
||||
}),
|
||||
),
|
||||
subtitles: streams
|
||||
.filter((s) => s.Type === "Subtitle")
|
||||
.map((s) =>
|
||||
clean({
|
||||
codec: str(s.Codec),
|
||||
language: str(s.Language),
|
||||
title: str(s.Title),
|
||||
isExternal: bool(s.IsExternal),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeItem(instanceId: string, it: Json): EmbyItem {
|
||||
const type = String(it.Type ?? "");
|
||||
const pids: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries((it.ProviderIds as Json | undefined) ?? {})) {
|
||||
if (v != null && String(v).trim()) pids[k] = String(v);
|
||||
}
|
||||
return {
|
||||
...clean({
|
||||
instanceId,
|
||||
itemId: String(it.Id ?? ""),
|
||||
type,
|
||||
name: String(it.Name ?? ""),
|
||||
originalTitle: str(it.OriginalTitle),
|
||||
year: num(it.ProductionYear),
|
||||
path: str(it.Path),
|
||||
seriesId: str(it.SeriesId),
|
||||
seriesName: str(it.SeriesName),
|
||||
seasonId: str(it.SeasonId),
|
||||
seasonNumber: type === "Season" ? num(it.IndexNumber) : num(it.ParentIndexNumber),
|
||||
episodeNumber: type === "Episode" ? num(it.IndexNumber) : undefined,
|
||||
episodeNumberEnd: type === "Episode" ? num(it.IndexNumberEnd) : undefined,
|
||||
locationType: str(it.LocationType),
|
||||
}),
|
||||
providerIds: pids,
|
||||
versions: Array.isArray(it.MediaSources) ? (it.MediaSources as Json[]).map(normalizeMediaSource) : [],
|
||||
};
|
||||
}
|
||||
|
||||
const ITEM_FIELDS = "ProviderIds,OriginalTitle,ProductionYear,Path,ParentId";
|
||||
|
||||
export class EmbyClient {
|
||||
readonly instanceId: string;
|
||||
private readonly base: string;
|
||||
private readonly flavor: "emby" | "jellyfin";
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private userId: string | undefined;
|
||||
|
||||
constructor(private readonly cfg: EmbyConfig) {
|
||||
this.instanceId = cfg.instanceId;
|
||||
this.flavor = cfg.flavor ?? "emby";
|
||||
const prefix = cfg.pathPrefix ?? (this.flavor === "emby" ? "/emby" : "");
|
||||
this.base = `${cfg.baseUrl.replace(/\/+$/, "")}${prefix}`;
|
||||
this.userId = cfg.userId;
|
||||
this.fetchImpl = cfg.fetch ?? fetch;
|
||||
}
|
||||
|
||||
private now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
private unknown<T>(e: unknown): Observed<T> {
|
||||
const kind = e instanceof EmbyHttpError ? e.kind : "protocol";
|
||||
return { status: "unknown", reason: kind, message: (e as Error)?.message ?? String(e), observedAt: this.now() };
|
||||
}
|
||||
|
||||
private async getJson(path: string, query: Record<string, string | undefined> = {}): Promise<unknown> {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(query)) if (v !== undefined) qs.set(k, v);
|
||||
const url = `${this.base}${path}${qs.size ? `?${qs}` : ""}`;
|
||||
const headers: Record<string, string> = { accept: "application/json" };
|
||||
if (this.flavor === "jellyfin") headers.authorization = `MediaBrowser Token="${this.cfg.apiKey}"`;
|
||||
else headers["x-emby-token"] = this.cfg.apiKey;
|
||||
const retries = this.cfg.retries ?? 2;
|
||||
let last: EmbyHttpError | null = null;
|
||||
for (let i = 0; i <= retries; i++) {
|
||||
if (i > 0) await sleep((this.cfg.retryBaseMs ?? 300) * 2 ** (i - 1));
|
||||
let res: Response;
|
||||
try {
|
||||
res = await this.fetchImpl(url, { headers, signal: AbortSignal.timeout(this.cfg.timeoutMs ?? 10_000) });
|
||||
} catch (e) {
|
||||
const name = (e as { name?: string })?.name;
|
||||
last =
|
||||
name === "TimeoutError" || name === "AbortError"
|
||||
? new EmbyHttpError("timeout", `[emby:${this.instanceId}] ${path} timed out`)
|
||||
: new EmbyHttpError("network", `[emby:${this.instanceId}] ${path} failed: ${(e as Error)?.message}`);
|
||||
continue;
|
||||
}
|
||||
const s = res.status;
|
||||
if (s === 401 || s === 403) {
|
||||
await res.body?.cancel();
|
||||
throw new EmbyHttpError("auth", `[emby:${this.instanceId}] ${path} unauthorized (HTTP ${s})`);
|
||||
}
|
||||
if (s === 404) {
|
||||
await res.body?.cancel();
|
||||
throw new EmbyHttpError("notfound", `[emby:${this.instanceId}] ${path} not found`);
|
||||
}
|
||||
if (s === 429 || s >= 500) {
|
||||
await res.body?.cancel();
|
||||
last = new EmbyHttpError(s === 429 ? "rate" : "protocol", `[emby:${this.instanceId}] ${path} HTTP ${s}`);
|
||||
continue;
|
||||
}
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel();
|
||||
throw new EmbyHttpError("protocol", `[emby:${this.instanceId}] ${path} HTTP ${s}`);
|
||||
}
|
||||
try {
|
||||
return await res.json();
|
||||
} catch {
|
||||
throw new EmbyHttpError("protocol", `[emby:${this.instanceId}] ${path} invalid JSON`);
|
||||
}
|
||||
}
|
||||
throw last!;
|
||||
}
|
||||
|
||||
async ping(): Promise<Observed<{ serverName?: string; version?: string }>> {
|
||||
try {
|
||||
const j = (await this.getJson("/System/Info/Public")) as Json;
|
||||
return {
|
||||
status: "ok",
|
||||
value: clean({ serverName: str(j.ServerName), version: str(j.Version) }),
|
||||
observedAt: this.now(),
|
||||
};
|
||||
} catch (e) {
|
||||
return this.unknown(e);
|
||||
}
|
||||
}
|
||||
|
||||
async users(): Promise<Observed<EmbyUser[]>> {
|
||||
try {
|
||||
const j = await this.getJson("/Users");
|
||||
if (!Array.isArray(j)) throw new EmbyHttpError("protocol", "/Users did not return an array");
|
||||
return {
|
||||
status: "ok",
|
||||
value: (j as Json[]).map((u) => ({
|
||||
id: String(u.Id ?? ""),
|
||||
name: String(u.Name ?? ""),
|
||||
isAdministrator: bool((u.Policy as Json | undefined)?.IsAdministrator) ?? null,
|
||||
})),
|
||||
observedAt: this.now(),
|
||||
};
|
||||
} catch (e) {
|
||||
return this.unknown(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 查询用户:配置 userId > 用户名匹配 > 管理员(对照 MP get_user)。 */
|
||||
async resolveUserId(): Promise<Observed<string>> {
|
||||
if (this.userId) return { status: "ok", value: this.userId, observedAt: this.now() };
|
||||
const u = await this.users();
|
||||
if (u.status !== "ok") return u;
|
||||
const pick =
|
||||
(this.cfg.username && u.value.find((x) => x.name === this.cfg.username)) ||
|
||||
u.value.find((x) => x.isAdministrator === true);
|
||||
if (!pick) {
|
||||
return { status: "unknown", reason: "auth", message: "no usable user on server", observedAt: this.now() };
|
||||
}
|
||||
this.userId = pick.id;
|
||||
return { status: "ok", value: pick.id, observedAt: this.now() };
|
||||
}
|
||||
|
||||
/** 通用条目查询;总数大于已取回条目且需要客户端过滤时,调用方应把"未命中"当作 unknown。 */
|
||||
async searchItems(q: ItemQuery): Promise<Observed<{ items: EmbyItem[]; total: number; fetched: number }>> {
|
||||
const uid = await this.resolveUserId();
|
||||
if (uid.status !== "ok") return uid;
|
||||
try {
|
||||
const anyPid = q.providerIds
|
||||
? Object.entries(q.providerIds)
|
||||
.map(([k, v]) => `${k.toLowerCase()}.${v}`)
|
||||
.join(",")
|
||||
: undefined;
|
||||
const j = (await this.getJson(`/Users/${encodeURIComponent(uid.value)}/Items`, {
|
||||
Recursive: "true",
|
||||
IncludeItemTypes: q.types?.join(","),
|
||||
SearchTerm: q.searchTerm,
|
||||
AnyProviderIdEquals: anyPid,
|
||||
ParentId: q.parentId,
|
||||
StartIndex: String(q.startIndex ?? 0),
|
||||
Limit: String(q.limit ?? this.cfg.pageSize ?? 50),
|
||||
Fields: q.withMediaSources ? `${ITEM_FIELDS},MediaSources` : ITEM_FIELDS,
|
||||
})) as Json;
|
||||
const raw = (Array.isArray(j.Items) ? j.Items : []) as Json[];
|
||||
let items = raw.map((it) => normalizeItem(this.instanceId, it));
|
||||
if (q.providerIds) {
|
||||
const want = Object.entries(q.providerIds).map(([k, v]) => [k.toLowerCase(), v] as const);
|
||||
items = items.filter((it) =>
|
||||
Object.entries(it.providerIds).some(([k, v]) => want.some(([wk, wv]) => wk === k.toLowerCase() && wv === v)),
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: "ok",
|
||||
value: { items, total: num(j.TotalRecordCount) ?? raw.length, fetched: raw.length },
|
||||
observedAt: this.now(),
|
||||
};
|
||||
} catch (e) {
|
||||
return this.unknown(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按外部 ID 查有无。只有查询成功且结果完整(未截断)时才返回 not_found;
|
||||
* 失败/截断 → unknown(REQ §7.4 "接口失败不能视为没有资源")。
|
||||
*/
|
||||
async findByProviderId(
|
||||
provider: string,
|
||||
id: string,
|
||||
type: "Movie" | "Series",
|
||||
): Promise<
|
||||
{ status: "present"; items: EmbyItem[] } | { status: "not_found" } | { status: "unknown"; reason: string }
|
||||
> {
|
||||
const r = await this.searchItems({
|
||||
types: [type],
|
||||
providerIds: { [provider]: id },
|
||||
withMediaSources: type === "Movie",
|
||||
});
|
||||
if (r.status !== "ok") return { status: "unknown", reason: r.reason };
|
||||
if (r.value.items.length) return { status: "present", items: r.value.items };
|
||||
if (r.value.total > r.value.fetched) return { status: "unknown", reason: "truncated" };
|
||||
return { status: "not_found" };
|
||||
}
|
||||
|
||||
async getItem(itemId: string): Promise<Observed<EmbyItem | null>> {
|
||||
const uid = await this.resolveUserId();
|
||||
if (uid.status !== "ok") return uid;
|
||||
try {
|
||||
const j = (await this.getJson(`/Users/${encodeURIComponent(uid.value)}/Items/${encodeURIComponent(itemId)}`, {
|
||||
Fields: `${ITEM_FIELDS},MediaSources`,
|
||||
})) as Json;
|
||||
return { status: "ok", value: normalizeItem(this.instanceId, j), observedAt: this.now() };
|
||||
} catch (e) {
|
||||
// 单条目 404:ID 已失效(媒体被删后重新入库会换 ID),不是"没有资源"
|
||||
if (e instanceof EmbyHttpError && e.kind === "notfound")
|
||||
return { status: "ok", value: null, observedAt: this.now() };
|
||||
return this.unknown(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 名称精确 + 年份匹配剧集 ID(对照 __get_emby_series_id_by_name):value=null 表示查询成功但未找到。 */
|
||||
async findSeriesIdByName(name: string, year?: number | string): Promise<Observed<string | null>> {
|
||||
const r = await this.searchItems({ types: ["Series"], searchTerm: name, limit: 10 });
|
||||
if (r.status !== "ok") return r;
|
||||
const hit = r.value.items.find((it) => it.name === name && (!year || String(it.year) === String(year)));
|
||||
return { status: "ok", value: hit?.itemId ?? null, observedAt: r.observedAt };
|
||||
}
|
||||
|
||||
/** 按标题(+年份)查电影,名称精确匹配且外部身份无冲突(对照 get_movies)。 */
|
||||
async getMovies(
|
||||
title: string,
|
||||
opts: { year?: number | string; identity?: { source: string; id: string } } = {},
|
||||
): Promise<Observed<EmbyItem[]>> {
|
||||
const r = await this.searchItems({ types: ["Movie"], searchTerm: title, limit: 10, withMediaSources: true });
|
||||
if (r.status !== "ok") return r;
|
||||
const value = r.value.items.filter(
|
||||
(it) =>
|
||||
identitiesCompatible(providerIdentity(it.providerIds), opts.identity) &&
|
||||
it.name === title &&
|
||||
(!opts.year || String(it.year) === String(opts.year)),
|
||||
);
|
||||
return { status: "ok", value, observedAt: r.observedAt };
|
||||
}
|
||||
|
||||
async seasons(seriesId: string): Promise<Observed<EmbyItem[]>> {
|
||||
const uid = await this.resolveUserId();
|
||||
if (uid.status !== "ok") return uid;
|
||||
try {
|
||||
const j = (await this.getJson(`/Shows/${encodeURIComponent(seriesId)}/Seasons`, {
|
||||
UserId: uid.value,
|
||||
Fields: ITEM_FIELDS,
|
||||
})) as Json;
|
||||
return {
|
||||
status: "ok",
|
||||
value: ((j.Items ?? []) as Json[]).map((x) => normalizeItem(this.instanceId, x)),
|
||||
observedAt: this.now(),
|
||||
};
|
||||
} catch (e) {
|
||||
return this.unknown(e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 剧集下的集(排除 Virtual 缺集占位,对照 MP IsMissing=false)。 */
|
||||
async episodes(
|
||||
seriesId: string,
|
||||
opts: { season?: number; seasonId?: string; withMediaSources?: boolean } = {},
|
||||
): Promise<Observed<EmbyItem[]>> {
|
||||
const uid = await this.resolveUserId();
|
||||
if (uid.status !== "ok") return uid;
|
||||
try {
|
||||
const j = (await this.getJson(`/Shows/${encodeURIComponent(seriesId)}/Episodes`, {
|
||||
UserId: uid.value,
|
||||
Season: opts.season !== undefined ? String(opts.season) : undefined,
|
||||
SeasonId: opts.seasonId,
|
||||
IsMissing: "false",
|
||||
Fields: opts.withMediaSources ? `${ITEM_FIELDS},MediaSources` : ITEM_FIELDS,
|
||||
})) as Json;
|
||||
const items = ((j.Items ?? []) as Json[])
|
||||
.map((x) => normalizeItem(this.instanceId, x))
|
||||
.filter((x) => x.locationType !== "Virtual");
|
||||
return { status: "ok", value: items, observedAt: this.now() };
|
||||
} catch (e) {
|
||||
return this.unknown(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 每季已有集号(对照 get_tv_episodes):itemId 优先;itemId 失效时按标题重查;
|
||||
* 身份冲突或未找到返回 value=null(找不到剧),查询失败返回 unknown。
|
||||
* IndexNumberEnd(多集合一文件)展开为区间。
|
||||
*/
|
||||
async getTvEpisodes(opts: {
|
||||
itemId?: string;
|
||||
title?: string;
|
||||
year?: number | string;
|
||||
identity?: { source: string; id: string };
|
||||
season?: number;
|
||||
}): Promise<Observed<{ seriesId: string; seasons: Record<number, number[]> } | null>> {
|
||||
let id = opts.itemId;
|
||||
const byName = async () => {
|
||||
if (!opts.title) return { status: "ok", value: null, observedAt: this.now() } as Observed<string | null>;
|
||||
return this.findSeriesIdByName(opts.title, opts.year);
|
||||
};
|
||||
if (!id) {
|
||||
const r = await byName();
|
||||
if (r.status !== "ok") return r;
|
||||
if (!r.value) return { status: "ok", value: null, observedAt: r.observedAt };
|
||||
id = r.value;
|
||||
}
|
||||
let info = await this.getItem(id);
|
||||
if (info.status !== "ok") return info;
|
||||
if (!info.value && opts.itemId && opts.title) {
|
||||
const r = await byName();
|
||||
if (r.status !== "ok") return r;
|
||||
if (!r.value) return { status: "ok", value: null, observedAt: r.observedAt };
|
||||
id = r.value;
|
||||
info = await this.getItem(id);
|
||||
if (info.status !== "ok") return info;
|
||||
}
|
||||
if (!info.value) return { status: "ok", value: null, observedAt: info.observedAt };
|
||||
if (!identitiesCompatible(providerIdentity(info.value.providerIds), opts.identity)) {
|
||||
return { status: "ok", value: null, observedAt: info.observedAt };
|
||||
}
|
||||
const eps = await this.episodes(id, opts.season !== undefined ? { season: opts.season } : {});
|
||||
if (eps.status !== "ok") return eps;
|
||||
const seasons: Record<number, number[]> = {};
|
||||
for (const e of eps.value) {
|
||||
if (e.seasonNumber === undefined || e.episodeNumber === undefined) continue;
|
||||
if (opts.season !== undefined && opts.season !== e.seasonNumber) continue;
|
||||
if (!seasons[e.seasonNumber]) seasons[e.seasonNumber] = [];
|
||||
const list = seasons[e.seasonNumber]!;
|
||||
const end = e.episodeNumberEnd && e.episodeNumberEnd > e.episodeNumber ? e.episodeNumberEnd : e.episodeNumber;
|
||||
for (let n = e.episodeNumber; n <= end; n++) if (!list.includes(n)) list.push(n);
|
||||
}
|
||||
for (const k of Object.keys(seasons)) seasons[Number(k)]!.sort((a, b) => a - b);
|
||||
return { status: "ok", value: { seriesId: id, seasons }, observedAt: eps.observedAt };
|
||||
}
|
||||
}
|
||||
|
||||
export function createEmbyClient(cfg: EmbyConfig): EmbyClient {
|
||||
return new EmbyClient(cfg);
|
||||
}
|
||||
|
||||
/** 多实例注册表。 */
|
||||
export class MediaServerRegistry {
|
||||
private readonly clients = new Map<string, EmbyClient>();
|
||||
constructor(configs: EmbyConfig[] = []) {
|
||||
for (const c of configs) this.register(c);
|
||||
}
|
||||
register(cfg: EmbyConfig): EmbyClient {
|
||||
const c = new EmbyClient(cfg);
|
||||
this.clients.set(cfg.instanceId, c);
|
||||
return c;
|
||||
}
|
||||
get(id: string): EmbyClient | undefined {
|
||||
return this.clients.get(id);
|
||||
}
|
||||
list(): EmbyClient[] {
|
||||
return [...this.clients.values()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { createMockEmby, type MockEmby } from "@umt/testkit";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createEmbyClient,
|
||||
type EmbyConfig,
|
||||
identitiesCompatible,
|
||||
MediaServerRegistry,
|
||||
providerIdentity,
|
||||
} from "./index";
|
||||
|
||||
let mocks: MockEmby[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(mocks.map((m) => m.close()));
|
||||
mocks = [];
|
||||
});
|
||||
|
||||
async function setup(flavor: "emby" | "jellyfin" = "emby", extra: Partial<EmbyConfig> = {}) {
|
||||
const mock = await createMockEmby({ flavor });
|
||||
mocks.push(mock);
|
||||
const emby = createEmbyClient({
|
||||
instanceId: `${flavor}-1`,
|
||||
baseUrl: mock.url,
|
||||
apiKey: "test-emby-key",
|
||||
flavor,
|
||||
retries: 1,
|
||||
retryBaseMs: 5,
|
||||
timeoutMs: 300,
|
||||
...extra,
|
||||
});
|
||||
return { mock, emby };
|
||||
}
|
||||
|
||||
describe("EmbyClient 查询", () => {
|
||||
it("用户解析:用户名匹配优先,否则回退管理员", async () => {
|
||||
const { emby } = await setup();
|
||||
const u = await emby.resolveUserId();
|
||||
expect(u).toMatchObject({ status: "ok", value: "u-admin" });
|
||||
const { emby: e2 } = await setup("emby", { username: "kid" });
|
||||
expect(await e2.resolveUserId()).toMatchObject({ status: "ok", value: "u-kid" });
|
||||
});
|
||||
|
||||
it("按 TMDB ID 查电影:返回多版本 MediaSources(分辨率/编码/音轨/路径)", async () => {
|
||||
const { emby, mock } = await setup();
|
||||
const r = await emby.findByProviderId("Tmdb", "872585", "Movie");
|
||||
expect(r.status).toBe("present");
|
||||
if (r.status !== "present") return;
|
||||
const [m] = r.items;
|
||||
expect(m).toMatchObject({ itemId: "m1", name: "奥本海默", year: 2023, instanceId: "emby-1" });
|
||||
expect(m!.versions.map((v) => [v.video?.height, v.video?.codec, v.video?.videoRange])).toEqual([
|
||||
[2160, "hevc", "HDR"],
|
||||
[800, "h264", "SDR"],
|
||||
]);
|
||||
expect(m!.versions[0]!.audio[0]).toEqual({
|
||||
codec: "truehd",
|
||||
channels: 8,
|
||||
language: "eng",
|
||||
title: "TrueHD 7.1",
|
||||
isDefault: true,
|
||||
});
|
||||
expect(m!.versions[0]!.path).toContain("2160p.mkv");
|
||||
expect(mock.calls.at(-1)!.path).toBe("/emby/Users/u-admin/Items");
|
||||
});
|
||||
|
||||
it("未知字段不补值", async () => {
|
||||
const { emby } = await setup();
|
||||
const r = await emby.findByProviderId("Tmdb", "842675", "Movie");
|
||||
if (r.status !== "present") throw new Error("expected present");
|
||||
const v = r.items[0]!.versions[0]!;
|
||||
expect(v).toEqual({
|
||||
id: "ms-m2",
|
||||
path: "/media/movies/流浪地球2 (2023)/流浪地球2.mkv",
|
||||
video: { codec: "hevc" },
|
||||
audio: [{}],
|
||||
subtitles: [],
|
||||
});
|
||||
expect(r.items[0]!.year).toBeUndefined();
|
||||
});
|
||||
|
||||
it("按名称+年份查电影,身份冲突排除", async () => {
|
||||
const { emby } = await setup();
|
||||
const ok = await emby.getMovies("奥本海默", { year: 2023, identity: { source: "tmdb", id: "872585" } });
|
||||
expect(ok.status === "ok" && ok.value.map((i) => i.itemId)).toEqual(["m1"]);
|
||||
const conflict = await emby.getMovies("奥本海默", { identity: { source: "tmdb", id: "1" } });
|
||||
expect(conflict.status === "ok" && conflict.value).toEqual([]);
|
||||
const wrongYear = await emby.getMovies("奥本海默", { year: 2020 });
|
||||
expect(wrongYear.status === "ok" && wrongYear.value).toEqual([]);
|
||||
});
|
||||
|
||||
it("季集覆盖:Virtual 缺集不计入,多集文件展开", async () => {
|
||||
const { emby } = await setup();
|
||||
const r = await emby.getTvEpisodes({ title: "熊家餐馆", year: 2022, identity: { source: "tmdb", id: "136315" } });
|
||||
expect(r).toMatchObject({
|
||||
status: "ok",
|
||||
value: { seriesId: "s1", seasons: { 1: [1, 2, 3, 4, 5, 6, 7, 8], 2: [1, 2, 3] } },
|
||||
});
|
||||
const v = await emby.getTvEpisodes({ itemId: "s2", season: 1 });
|
||||
expect(v.status === "ok" && v.value?.seasons).toEqual({ 1: [1, 3, 4] });
|
||||
const seasons = await emby.seasons("s1");
|
||||
expect(seasons.status === "ok" && seasons.value.map((s) => s.seasonNumber)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("缓存的 itemId 失效时按标题重查;确实没有返回 null", async () => {
|
||||
const { emby } = await setup();
|
||||
const r = await emby.getTvEpisodes({ itemId: "stale-id", title: "熊家餐馆" });
|
||||
expect(r.status === "ok" && r.value?.seriesId).toBe("s1");
|
||||
const none = await emby.getTvEpisodes({ title: "不存在的剧" });
|
||||
expect(none).toMatchObject({ status: "ok", value: null });
|
||||
expect(await emby.findByProviderId("Tmdb", "999", "Movie")).toEqual({ status: "not_found" });
|
||||
});
|
||||
|
||||
it("Jellyfin:无 /emby 前缀、Authorization 头;忽略 AnyProviderIdEquals 时结果截断 → unknown", async () => {
|
||||
const { emby, mock } = await setup("jellyfin", { pageSize: 1 });
|
||||
const r = await emby.findByProviderId("Tmdb", "842675", "Movie");
|
||||
expect(r).toEqual({ status: "unknown", reason: "truncated" });
|
||||
expect(mock.calls.at(-1)!.path).toBe("/Users/u-admin/Items");
|
||||
expect(String(mock.calls.at(-1)!.headers.authorization)).toContain('Token="test-emby-key"');
|
||||
const { emby: full } = await setup("jellyfin");
|
||||
expect((await full.findByProviderId("Tmdb", "842675", "Movie")).status).toBe("present");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EmbyClient 失败即 unknown", () => {
|
||||
it("凭据错误 → unknown(auth),而不是不存在", async () => {
|
||||
const { mock } = await setup();
|
||||
const bad = createEmbyClient({ instanceId: "x", baseUrl: mock.url, apiKey: "wrong", retries: 0 });
|
||||
expect(await bad.findByProviderId("Tmdb", "872585", "Movie")).toEqual({ status: "unknown", reason: "auth" });
|
||||
expect((await bad.getTvEpisodes({ title: "熊家餐馆" })).status).toBe("unknown");
|
||||
});
|
||||
|
||||
it("5xx 重试后成功;持续 5xx → unknown(protocol);超时 → unknown(timeout);不可达 → network", async () => {
|
||||
const { emby, mock } = await setup();
|
||||
await emby.resolveUserId();
|
||||
mock.fault("/emby/Users/u-admin/Items", { status: 502 });
|
||||
expect((await emby.getMovies("奥本海默")).status).toBe("ok");
|
||||
mock.fault("/emby/Users/u-admin/Items", { status: 500, times: 5 });
|
||||
expect(await emby.getMovies("奥本海默")).toMatchObject({ status: "unknown", reason: "protocol" });
|
||||
mock.clearFaults();
|
||||
mock.fault("/emby/Shows/s1/Episodes", { delayMs: 500, times: 5 });
|
||||
expect(await emby.episodes("s1")).toMatchObject({ status: "unknown", reason: "timeout" });
|
||||
const dead = createEmbyClient({ instanceId: "d", baseUrl: "http://127.0.0.1:1", apiKey: "k", retries: 0 });
|
||||
expect(await dead.ping()).toMatchObject({ status: "unknown", reason: "network" });
|
||||
});
|
||||
|
||||
it("多实例注册表;身份兼容规则", async () => {
|
||||
const a = await setup("emby");
|
||||
const b = await setup("jellyfin");
|
||||
const reg = new MediaServerRegistry([
|
||||
{ instanceId: "a", baseUrl: a.mock.url, apiKey: "test-emby-key" },
|
||||
{ instanceId: "b", baseUrl: b.mock.url, apiKey: "test-emby-key", flavor: "jellyfin" },
|
||||
]);
|
||||
const res = await Promise.all(reg.list().map((c) => c.ping()));
|
||||
expect(res.map((r) => r.status === "ok" && r.value.version)).toEqual(["4.8.10.0", "10.10.3"]);
|
||||
expect(providerIdentity({ Imdb: "tt1", Tmdb: "5" })).toEqual({ source: "tmdb", id: "5" });
|
||||
expect(identitiesCompatible({ source: "tmdb", id: "1" }, { source: "imdb", id: "tt1" })).toBe(true);
|
||||
expect(identitiesCompatible({ source: "tmdb", id: "1" }, { source: "TMDB", id: "2" })).toBe(false);
|
||||
expect(identitiesCompatible(null, { source: "tmdb", id: "2" })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./client";
|
||||
@@ -0,0 +1,5 @@
|
||||
# fs
|
||||
|
||||
本地文件操作:`copyWithVerify`(同目录隐藏临时文件 → fsync → size/sha256/抽样校验 → no-clobber 原子提交 → fsync 目录,保留 mtime,默认不建父目录)、`moveSafe`(同设备 no-clobber 移动,EXDEV 返回 `exdev` 不降级)、`crossDeviceMove`(copy → verify → delete_source,返回步骤日志,可重入,删源失败为 `attention`)、`statSafe`/`checkRoot`(先查库根哨兵 `.uhd-root`,区分 missing / permission_denied / root_offline)、`scanIncremental`(名称排序深度优先、每批 lstat 上限、游标续扫、new/changed/unchanged 标注)、`ffprobe`(无 ffprobe 或失败返回 `unknown`,不抛)、`diskUsage`(statfs,失败返回 `unknown`)、`sweepStalePartials`。
|
||||
|
||||
在本机临时目录与 /dev/shm(跨设备)上做了集成测试;权限拒绝类用例在 root 下跳过。未在真实 NAS/FUSE/网络挂载上验证。
|
||||
@@ -0,0 +1,213 @@
|
||||
// 迁移来源:MoviePilot app/modules/filemanager/storages/local.py _write_atomically/_partial_path/_cleanup_stale_partials/copy(e6820a8);
|
||||
// 调整:fsync + size/sha256/sample 校验、no-clobber 提交(link 原子失败),见 docs/porting/moviepilot-transfer.md §2.6。
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { createReadStream, createWriteStream } from "node:fs";
|
||||
import { link, lstat, mkdir, open, readdir, rename, stat, unlink, utimes } from "node:fs/promises";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { errnoOf, FsError, toFsError } from "./errors";
|
||||
|
||||
export type VerifyMethod = "size" | "sha256" | "sample";
|
||||
|
||||
export interface CopyOptions {
|
||||
/** 临时文件后缀,默认 .uhd-partial;临时文件与目标同目录、以 . 开头 */
|
||||
tmpSuffix?: string;
|
||||
/** 校验方式,默认 size(总是比对大小;sha256/sample 额外比对内容) */
|
||||
hash?: VerifyMethod;
|
||||
/** sample 模式的抽样块数与块大小 */
|
||||
sampleChunks?: number;
|
||||
sampleChunkBytes?: number;
|
||||
/** 是否创建目标父目录;默认 false(防止库根离线时在挂载点下写入) */
|
||||
createParents?: boolean;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (bytesCopied: number, totalBytes: number) => void;
|
||||
}
|
||||
|
||||
export interface CopyReceipt {
|
||||
src: string;
|
||||
dst: string;
|
||||
bytes: number;
|
||||
verifiedBy: VerifyMethod;
|
||||
sha256?: string;
|
||||
sampleDigest?: string;
|
||||
partialPath: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
async function fsyncPath(p: string, flags = "r"): Promise<void> {
|
||||
let fh: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
fh = await open(p, flags);
|
||||
await fh.sync();
|
||||
} catch (e) {
|
||||
// 部分文件系统不支持对目录 fsync
|
||||
if (!["EISDIR", "EINVAL", "EBADF", "EPERM", "ENOTSUP"].includes(errnoOf(e) ?? "")) throw e;
|
||||
} finally {
|
||||
await fh?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function sha256File(p: string, signal?: AbortSignal): Promise<string> {
|
||||
const h = createHash("sha256");
|
||||
await pipeline(createReadStream(p), async (src: AsyncIterable<Buffer>) => {
|
||||
for await (const c of src) {
|
||||
if (signal?.aborted) throw new FsError("ABORTED", "aborted", { path: p });
|
||||
h.update(c);
|
||||
}
|
||||
});
|
||||
return h.digest("hex");
|
||||
}
|
||||
|
||||
/** 抽样摘要:首/尾/等距 N 块 + 文件大小。抽样一致不等于完整一致性证明(REQ §8.2)。 */
|
||||
export async function sampleDigest(p: string, chunks = 8, chunkBytes = 1024 * 1024): Promise<string> {
|
||||
const fh = await open(p, "r");
|
||||
try {
|
||||
const { size } = await fh.stat();
|
||||
const h = createHash("sha256");
|
||||
h.update(String(size));
|
||||
const n = Math.max(2, chunks);
|
||||
const buf = Buffer.alloc(chunkBytes);
|
||||
const offsets = new Set<number>();
|
||||
for (let i = 0; i < n; i++) offsets.add(Math.max(0, Math.floor(((size - chunkBytes) * i) / (n - 1))));
|
||||
for (const off of [...offsets].sort((a, b) => a - b)) {
|
||||
const { bytesRead } = await fh.read(buf, 0, chunkBytes, off);
|
||||
h.update(buf.subarray(0, bytesRead));
|
||||
}
|
||||
return h.digest("hex");
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function partialPathFor(dst: string, suffix = ".uhd-partial"): string {
|
||||
return join(dirname(dst), `.${basename(dst)}.${process.pid}.${randomBytes(3).toString("hex")}${suffix}`);
|
||||
}
|
||||
|
||||
/** no-clobber 提交:link(tmp,dst) 在目标存在时原子失败;文件系统不支持硬链接时退回 lstat+rename。 */
|
||||
export async function commitNoClobber(tmp: string, dst: string): Promise<void> {
|
||||
try {
|
||||
await link(tmp, dst);
|
||||
await unlink(tmp);
|
||||
return;
|
||||
} catch (e) {
|
||||
const code = errnoOf(e);
|
||||
if (code === "EEXIST") throw new FsError("EEXIST", `target exists: ${dst}`, { path: dst, cause: e });
|
||||
if (!["EPERM", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EMLINK"].includes(code ?? "")) throw toFsError(e, dst);
|
||||
}
|
||||
try {
|
||||
await lstat(dst);
|
||||
throw new FsError("EEXIST", `target exists: ${dst}`, { path: dst });
|
||||
} catch (e) {
|
||||
if (e instanceof FsError) throw e;
|
||||
if (errnoOf(e) !== "ENOENT") throw toFsError(e, dst);
|
||||
}
|
||||
await rename(tmp, dst);
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制并校验:写目标同目录临时文件 → fsync → 校验 → no-clobber 原子提交 → fsync 目录。
|
||||
* 任何失败都会清理临时文件并抛出 FsError;目标已存在抛 EEXIST,绝不覆盖。
|
||||
*/
|
||||
export async function copyWithVerify(src: string, dst: string, opts: CopyOptions = {}): Promise<CopyReceipt> {
|
||||
const started = Date.now();
|
||||
const method = opts.hash ?? "size";
|
||||
let srcStat: Awaited<ReturnType<typeof stat>>;
|
||||
try {
|
||||
srcStat = await stat(src);
|
||||
} catch (e) {
|
||||
throw toFsError(e, src);
|
||||
}
|
||||
if (!srcStat.isFile()) throw new FsError("IO", `source is not a regular file: ${src}`, { path: src });
|
||||
try {
|
||||
await lstat(dst);
|
||||
throw new FsError("EEXIST", `target exists: ${dst}`, { path: dst });
|
||||
} catch (e) {
|
||||
if (e instanceof FsError) throw e;
|
||||
if (errnoOf(e) !== "ENOENT") throw toFsError(e, dst);
|
||||
}
|
||||
if (opts.createParents) await mkdir(dirname(dst), { recursive: true });
|
||||
|
||||
const tmp = partialPathFor(dst, opts.tmpSuffix);
|
||||
const hasher = method === "sha256" ? createHash("sha256") : null;
|
||||
let copied = 0;
|
||||
try {
|
||||
const meter = new Transform({
|
||||
transform(chunk: Buffer, _enc, cb) {
|
||||
copied += chunk.length;
|
||||
hasher?.update(chunk);
|
||||
opts.onProgress?.(copied, srcStat.size);
|
||||
cb(null, chunk);
|
||||
},
|
||||
});
|
||||
await pipeline(
|
||||
createReadStream(src),
|
||||
meter,
|
||||
createWriteStream(tmp, { flags: "wx", flush: true }),
|
||||
opts.signal ? { signal: opts.signal } : {},
|
||||
);
|
||||
await fsyncPath(tmp, "r+");
|
||||
|
||||
const after = await stat(src);
|
||||
if (after.size !== srcStat.size || after.mtimeMs !== srcStat.mtimeMs) {
|
||||
throw new FsError("SOURCE_CHANGED", `source changed during copy: ${src}`, { path: src });
|
||||
}
|
||||
const tmpStat = await stat(tmp);
|
||||
if (tmpStat.size !== srcStat.size || copied !== srcStat.size) {
|
||||
throw new FsError("VERIFY_FAILED", `size mismatch ${tmpStat.size} != ${srcStat.size}`, { path: dst });
|
||||
}
|
||||
const receipt: CopyReceipt = {
|
||||
src,
|
||||
dst,
|
||||
bytes: srcStat.size,
|
||||
verifiedBy: method,
|
||||
partialPath: tmp,
|
||||
durationMs: 0,
|
||||
};
|
||||
if (hasher) {
|
||||
const expected = hasher.digest("hex");
|
||||
const actual = await sha256File(tmp, opts.signal);
|
||||
if (actual !== expected) throw new FsError("VERIFY_FAILED", "sha256 mismatch", { path: dst });
|
||||
receipt.sha256 = actual;
|
||||
} else if (method === "sample") {
|
||||
const a = await sampleDigest(src, opts.sampleChunks, opts.sampleChunkBytes);
|
||||
const b = await sampleDigest(tmp, opts.sampleChunks, opts.sampleChunkBytes);
|
||||
if (a !== b) throw new FsError("VERIFY_FAILED", "sample digest mismatch", { path: dst });
|
||||
receipt.sampleDigest = b;
|
||||
}
|
||||
await utimes(tmp, srcStat.atime, srcStat.mtime);
|
||||
await commitNoClobber(tmp, dst);
|
||||
await fsyncPath(dirname(dst));
|
||||
receipt.durationMs = Date.now() - started;
|
||||
return receipt;
|
||||
} catch (e) {
|
||||
await unlink(tmp).catch(() => undefined);
|
||||
if (opts.signal?.aborted) throw new FsError("ABORTED", "copy aborted", { path: dst, cause: e });
|
||||
throw toFsError(e, dst);
|
||||
}
|
||||
}
|
||||
|
||||
/** 按指定方式比对两个文件是否一致(size 恒比对)。 */
|
||||
export async function filesMatch(a: string, b: string, method: VerifyMethod = "size"): Promise<boolean> {
|
||||
const [sa, sb] = await Promise.all([stat(a), stat(b)]);
|
||||
if (sa.size !== sb.size) return false;
|
||||
if (method === "sha256") return (await sha256File(a)) === (await sha256File(b));
|
||||
if (method === "sample") return (await sampleDigest(a)) === (await sampleDigest(b));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 清理目录下超过 olderThanMs 的残留临时文件,返回被删除的路径。 */
|
||||
export async function sweepStalePartials(dir: string, olderThanMs: number, suffix = ".uhd-partial"): Promise<string[]> {
|
||||
const removed: string[] = [];
|
||||
const now = Date.now();
|
||||
for (const name of await readdir(dir)) {
|
||||
if (!name.startsWith(".") || !name.endsWith(suffix)) continue;
|
||||
const p = join(dir, name);
|
||||
const st = await lstat(p).catch(() => null);
|
||||
if (st?.isFile() && now - st.mtimeMs > olderThanMs) {
|
||||
await unlink(p).catch(() => undefined);
|
||||
removed.push(p);
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export type FsErrorCode =
|
||||
| "EXDEV" // 跨设备,不会自动降级
|
||||
| "EEXIST" // 目标已存在(no-clobber)
|
||||
| "ENOENT"
|
||||
| "EACCES"
|
||||
| "ENOSPC"
|
||||
| "VERIFY_FAILED"
|
||||
| "SOURCE_CHANGED"
|
||||
| "ROOT_OFFLINE"
|
||||
| "ABORTED"
|
||||
| "IO";
|
||||
|
||||
export class FsError extends Error {
|
||||
override readonly name = "FsError";
|
||||
constructor(
|
||||
readonly code: FsErrorCode,
|
||||
message: string,
|
||||
readonly detail: { path?: string; syscall?: string; cause?: unknown } = {},
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function errnoOf(e: unknown): string | undefined {
|
||||
return (e as NodeJS.ErrnoException | undefined)?.code;
|
||||
}
|
||||
|
||||
export function toFsError(e: unknown, path?: string): FsError {
|
||||
if (e instanceof FsError) return e;
|
||||
const code = errnoOf(e);
|
||||
const mapped: FsErrorCode =
|
||||
code === "EXDEV" || code === "EEXIST" || code === "ENOENT" || code === "ENOSPC"
|
||||
? code
|
||||
: code === "EACCES" || code === "EPERM"
|
||||
? "EACCES"
|
||||
: (e as Error)?.name === "AbortError"
|
||||
? "ABORTED"
|
||||
: "IO";
|
||||
return new FsError(mapped, (e as Error)?.message ?? String(e), {
|
||||
...(path !== undefined ? { path } : {}),
|
||||
...((e as NodeJS.ErrnoException)?.syscall ? { syscall: (e as NodeJS.ErrnoException).syscall! } : {}),
|
||||
cause: e,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// 本项目新增:ffprobe 封装与 statfs 磁盘用量(REQ §8.2、§8.4.1);失败一律返回 unknown。
|
||||
import { execFile } from "node:child_process";
|
||||
import { statfs } from "node:fs/promises";
|
||||
import { errnoOf } from "./errors";
|
||||
|
||||
export interface ProbeVideo {
|
||||
codec?: string;
|
||||
profile?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
pixFmt?: string;
|
||||
/** 仅在流信息明确时给出;未知不补值 */
|
||||
hdr?: "HDR10" | "HLG" | "DV";
|
||||
frameRate?: string;
|
||||
}
|
||||
export interface ProbeAudio {
|
||||
codec?: string;
|
||||
profile?: string;
|
||||
channels?: number;
|
||||
channelLayout?: string;
|
||||
language?: string;
|
||||
title?: string;
|
||||
}
|
||||
export interface ProbeSubtitle {
|
||||
codec?: string;
|
||||
language?: string;
|
||||
title?: string;
|
||||
forced?: boolean;
|
||||
}
|
||||
|
||||
export type ProbeResult =
|
||||
| {
|
||||
status: "ok";
|
||||
container?: string;
|
||||
durationSec?: number;
|
||||
bitRate?: number;
|
||||
sizeBytes?: number;
|
||||
video: ProbeVideo[];
|
||||
audio: ProbeAudio[];
|
||||
subtitles: ProbeSubtitle[];
|
||||
}
|
||||
| {
|
||||
status: "unknown";
|
||||
reason: "ffprobe_unavailable" | "timeout" | "probe_failed" | "parse_failed";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export interface ProbeOptions {
|
||||
/** ffprobe 可执行文件,默认 "ffprobe"(PATH 查找) */
|
||||
bin?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
const optNum = (v: unknown): number | undefined => {
|
||||
const n = typeof v === "string" ? Number(v) : v;
|
||||
return typeof n === "number" && Number.isFinite(n) ? n : undefined;
|
||||
};
|
||||
const optStr = (v: unknown): string | undefined => (typeof v === "string" && v !== "" ? v : undefined);
|
||||
|
||||
type Stream = Record<string, unknown> & {
|
||||
tags?: Record<string, string>;
|
||||
side_data_list?: { side_data_type?: string }[];
|
||||
};
|
||||
|
||||
function hdrOf(s: Stream): ProbeVideo["hdr"] {
|
||||
if (s.side_data_list?.some((d) => /dovi|dolby vision/i.test(d.side_data_type ?? ""))) return "DV";
|
||||
if (s.color_transfer === "smpte2084") return "HDR10";
|
||||
if (s.color_transfer === "arib-std-b67") return "HLG";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function clean<T extends object>(o: T): T {
|
||||
return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T;
|
||||
}
|
||||
|
||||
/** ffprobe 封装:任何失败(含本机无 ffprobe)返回 { status: "unknown" },不抛。 */
|
||||
export function ffprobe(path: string, opts: ProbeOptions = {}): Promise<ProbeResult> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
opts.bin ?? "ffprobe",
|
||||
["-v", "error", "-print_format", "json", "-show_format", "-show_streams", "--", path],
|
||||
{ timeout: opts.timeoutMs ?? 30_000, maxBuffer: 16 * 1024 * 1024 },
|
||||
(err, stdout, stderr) => {
|
||||
if (err) {
|
||||
const e = err as NodeJS.ErrnoException & { killed?: boolean; signal?: string };
|
||||
if (errnoOf(e) === "ENOENT" || errnoOf(e) === "EACCES")
|
||||
return resolve({ status: "unknown", reason: "ffprobe_unavailable" });
|
||||
if (e.killed || e.signal === "SIGTERM") return resolve({ status: "unknown", reason: "timeout" });
|
||||
return resolve({ status: "unknown", reason: "probe_failed", message: String(stderr).slice(0, 500) });
|
||||
}
|
||||
try {
|
||||
const j = JSON.parse(stdout) as { format?: Record<string, unknown>; streams?: Stream[] };
|
||||
const streams = j.streams ?? [];
|
||||
const f = j.format ?? {};
|
||||
resolve(
|
||||
clean({
|
||||
status: "ok" as const,
|
||||
container: optStr(f.format_name),
|
||||
durationSec: optNum(f.duration),
|
||||
bitRate: optNum(f.bit_rate),
|
||||
sizeBytes: optNum(f.size),
|
||||
video: streams
|
||||
.filter((s) => s.codec_type === "video" && !(s.disposition as { attached_pic?: number })?.attached_pic)
|
||||
.map((s) =>
|
||||
clean({
|
||||
codec: optStr(s.codec_name),
|
||||
profile: optStr(s.profile),
|
||||
width: optNum(s.width),
|
||||
height: optNum(s.height),
|
||||
pixFmt: optStr(s.pix_fmt),
|
||||
hdr: hdrOf(s),
|
||||
frameRate: optStr(s.avg_frame_rate) === "0/0" ? undefined : optStr(s.avg_frame_rate),
|
||||
}),
|
||||
),
|
||||
audio: streams
|
||||
.filter((s) => s.codec_type === "audio")
|
||||
.map((s) =>
|
||||
clean({
|
||||
codec: optStr(s.codec_name),
|
||||
profile: optStr(s.profile),
|
||||
channels: optNum(s.channels),
|
||||
channelLayout: optStr(s.channel_layout),
|
||||
language: optStr(s.tags?.language),
|
||||
title: optStr(s.tags?.title),
|
||||
}),
|
||||
),
|
||||
subtitles: streams
|
||||
.filter((s) => s.codec_type === "subtitle")
|
||||
.map((s) =>
|
||||
clean({
|
||||
codec: optStr(s.codec_name),
|
||||
language: optStr(s.tags?.language),
|
||||
title: optStr(s.tags?.title),
|
||||
forced: (s.disposition as { forced?: number } | undefined)?.forced === 1 ? true : undefined,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
resolve({ status: "unknown", reason: "parse_failed", message: (e as Error).message });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export type DiskUsage =
|
||||
| {
|
||||
status: "ok";
|
||||
path: string;
|
||||
totalBytes: number;
|
||||
freeBytes: number;
|
||||
/** 非 root 可用空间 */
|
||||
availableBytes: number;
|
||||
usedBytes: number;
|
||||
/** used / (used + available),与 df 的 Use% 一致 */
|
||||
usedRatio: number;
|
||||
}
|
||||
/** 无法读取:调用方应保守阻止新增分配并告警(REQ §8.4.1) */
|
||||
| { status: "unknown"; path: string; code: string; message: string };
|
||||
|
||||
export async function diskUsage(path: string): Promise<DiskUsage> {
|
||||
try {
|
||||
const s = await statfs(path);
|
||||
const total = s.blocks * s.bsize;
|
||||
const free = s.bfree * s.bsize;
|
||||
const avail = s.bavail * s.bsize;
|
||||
const used = total - free;
|
||||
return {
|
||||
status: "ok",
|
||||
path,
|
||||
totalBytes: total,
|
||||
freeBytes: free,
|
||||
availableBytes: avail,
|
||||
usedBytes: used,
|
||||
usedRatio: used + avail > 0 ? used / (used + avail) : 0,
|
||||
};
|
||||
} catch (e) {
|
||||
return { status: "unknown", path, code: errnoOf(e) ?? "UNKNOWN", message: (e as Error)?.message ?? String(e) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { chmod, readFile, rm, stat, symlink, unlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createTempTree, findCrossDeviceBase, type TempTree } from "@umt/testkit";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
checkRoot,
|
||||
copyWithVerify,
|
||||
crossDeviceMove,
|
||||
diskUsage,
|
||||
FsError,
|
||||
ffprobe,
|
||||
moveSafe,
|
||||
scanIncremental,
|
||||
statSafe,
|
||||
sweepStalePartials,
|
||||
} from "./index";
|
||||
|
||||
const trees: TempTree[] = [];
|
||||
async function tree(...a: Parameters<typeof createTempTree>) {
|
||||
const t = await createTempTree(...a);
|
||||
trees.push(t);
|
||||
return t;
|
||||
}
|
||||
afterEach(async () => {
|
||||
await Promise.all(trees.splice(0).map((t) => t.cleanup()));
|
||||
});
|
||||
|
||||
const xdevBase = findCrossDeviceBase();
|
||||
const isRoot = process.getuid?.() === 0;
|
||||
const hasFfmpeg = (() => {
|
||||
try {
|
||||
execFileSync("ffmpeg", ["-version"], { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
describe("copyWithVerify", () => {
|
||||
it.each(["size", "sha256", "sample"] as const)("%s 校验后原子提交,保留 mtime,无残留临时文件", async (hash) => {
|
||||
const t = await tree({ "src/a.mkv": 3 * 1024 * 1024 + 17, "dst/": null });
|
||||
const r = await copyWithVerify(t.path("src/a.mkv"), t.path("dst/a.mkv"), { hash, sampleChunkBytes: 4096 });
|
||||
expect(r.bytes).toBe(3 * 1024 * 1024 + 17);
|
||||
expect(r.verifiedBy).toBe(hash);
|
||||
if (hash === "sha256") expect(r.sha256).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect((await readFile(t.path("dst/a.mkv"))).equals(await readFile(t.path("src/a.mkv")))).toBe(true);
|
||||
expect(Math.round((await stat(t.path("dst/a.mkv"))).mtimeMs)).toBe(
|
||||
Math.round((await stat(t.path("src/a.mkv"))).mtimeMs),
|
||||
);
|
||||
expect(await sweepStalePartials(t.path("dst"), -1)).toEqual([]);
|
||||
});
|
||||
|
||||
it("目标已存在 → EEXIST,不覆盖", async () => {
|
||||
const t = await tree({ "a.mkv": "new", "b.mkv": "old" });
|
||||
const e = await copyWithVerify(t.path("a.mkv"), t.path("b.mkv")).catch((x) => x);
|
||||
expect(e).toBeInstanceOf(FsError);
|
||||
expect(e.code).toBe("EEXIST");
|
||||
expect(await readFile(t.path("b.mkv"), "utf8")).toBe("old");
|
||||
});
|
||||
|
||||
it("父目录不存在默认不创建;中止信号清理临时文件", async () => {
|
||||
const t = await tree({ "a.mkv": 1024 * 1024 });
|
||||
expect((await copyWithVerify(t.path("a.mkv"), t.path("lib/x/a.mkv")).catch((e) => e)).code).toBe("ENOENT");
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
await t.write("lib/.keep", "");
|
||||
const e = await copyWithVerify(t.path("a.mkv"), t.path("lib/a.mkv"), { signal: ac.signal }).catch((x) => x);
|
||||
expect(e.code).toBe("ABORTED");
|
||||
expect(await sweepStalePartials(t.path("lib"), -1)).toEqual([]);
|
||||
await expect(stat(t.path("lib/a.mkv"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("残留临时文件清理只删过期的 .uhd-partial", async () => {
|
||||
const t = await tree({ "lib/.x.mkv.1.abc.uhd-partial": "x", "lib/keep.mkv": "k" });
|
||||
const removed = await sweepStalePartials(t.path("lib"), -1);
|
||||
expect(removed).toEqual([t.path("lib/.x.mkv.1.abc.uhd-partial")]);
|
||||
expect(await sweepStalePartials(t.path("lib"), 60_000)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveSafe / crossDeviceMove", () => {
|
||||
it("同设备移动;目标存在返回 exists 且不覆盖", async () => {
|
||||
const t = await tree({ "a.mkv": "A", "b.mkv": "B", "c.mkv": "C" });
|
||||
expect(await moveSafe(t.path("a.mkv"), t.path("moved.mkv"))).toEqual({ status: "moved" });
|
||||
await expect(stat(t.path("a.mkv"))).rejects.toThrow();
|
||||
expect(await moveSafe(t.path("b.mkv"), t.path("c.mkv"))).toEqual({ status: "exists" });
|
||||
expect(await readFile(t.path("c.mkv"), "utf8")).toBe("C");
|
||||
const e = await moveSafe(t.path("nope.mkv"), t.path("x.mkv")).catch((x) => x);
|
||||
expect(e.code).toBe("ENOENT");
|
||||
});
|
||||
|
||||
it("link 成功但 unlink 前中断(同 inode)→ 续跑补完删除", async () => {
|
||||
const t = await tree({ "a.mkv": "A" });
|
||||
const { link } = await import("node:fs/promises");
|
||||
await link(t.path("a.mkv"), t.path("b.mkv"));
|
||||
expect(await moveSafe(t.path("a.mkv"), t.path("b.mkv"))).toEqual({ status: "moved" });
|
||||
await expect(stat(t.path("a.mkv"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.skipIf(!xdevBase)("跨设备:moveSafe 返回 exdev 不降级;crossDeviceMove 三步完成", async () => {
|
||||
// xdevBase 为 null 时跳过:本机找不到与 tmpdir 不同设备的可写目录
|
||||
const a = await tree({ "a.mkv": 256 * 1024 });
|
||||
const b = await tree({ "lib/": null }, { base: xdevBase! });
|
||||
expect(await moveSafe(a.path("a.mkv"), b.path("lib/a.mkv"))).toEqual({ status: "exdev" });
|
||||
await expect(stat(a.path("a.mkv"))).resolves.toBeTruthy();
|
||||
const r = await crossDeviceMove(a.path("a.mkv"), b.path("lib/a.mkv"), { hash: "sha256" });
|
||||
expect(r.status).toBe("completed");
|
||||
expect(r.steps.map((s) => `${s.step}:${s.status}`)).toEqual(["copy:done", "verify:done", "delete_source:done"]);
|
||||
await expect(stat(a.path("a.mkv"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("crossDeviceMove 可重入:目标已一致则跳过复制;源已删且上次已验证 → completed;目标不同 → failed 不删源", async () => {
|
||||
const t = await tree({ "src/a.mkv": "same", "dst/a.mkv": "same", "src/b.mkv": "bbbb", "dst/b.mkv": "cccc" });
|
||||
const r1 = await crossDeviceMove(t.path("src/a.mkv"), t.path("dst/a.mkv"), { hash: "sha256" });
|
||||
expect(r1.steps.map((s) => `${s.step}:${s.status}`)).toEqual(["copy:skipped", "verify:done", "delete_source:done"]);
|
||||
const r2 = await crossDeviceMove(t.path("src/a.mkv"), t.path("dst/a.mkv"), { previous: r1.steps });
|
||||
expect(r2.status).toBe("completed");
|
||||
const r3 = await crossDeviceMove(t.path("src/b.mkv"), t.path("dst/b.mkv"), { hash: "sha256" });
|
||||
expect(r3.status).toBe("failed");
|
||||
expect(await readFile(t.path("src/b.mkv"), "utf8")).toBe("bbbb");
|
||||
const r4 = await crossDeviceMove(t.path("src/none.mkv"), t.path("dst/b.mkv"));
|
||||
expect(r4.status).toBe("attention");
|
||||
});
|
||||
|
||||
it.skipIf(isRoot)("删源失败 → attention(以 root 运行时跳过:root 无视目录权限)", async () => {
|
||||
const t = await tree({ "src/a.mkv": "A", "dst/": null });
|
||||
await chmod(t.path("src"), 0o555);
|
||||
try {
|
||||
const r = await crossDeviceMove(t.path("src/a.mkv"), t.path("dst/a.mkv"));
|
||||
expect(r.status).toBe("attention");
|
||||
expect(r.steps.at(-1)).toMatchObject({ step: "delete_source", status: "failed" });
|
||||
} finally {
|
||||
await chmod(t.path("src"), 0o755);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("statSafe / checkRoot / diskUsage", () => {
|
||||
it("区分正常、缺失、根离线", async () => {
|
||||
const t = await tree({ "movies/a.mkv": "A" }, { sentinel: true });
|
||||
const ok = await statSafe(t.path("movies/a.mkv"), { root: t.root });
|
||||
expect(ok.kind).toBe("ok");
|
||||
if (ok.kind === "ok") expect(ok.facts.size).toBe(1);
|
||||
expect((await statSafe(t.path("movies/b.mkv"), { root: t.root })).kind).toBe("missing");
|
||||
expect(await checkRoot(t.root)).toMatchObject({ kind: "online" });
|
||||
await unlink(t.path(".uhd-root"));
|
||||
expect(await statSafe(t.path("movies/b.mkv"), { root: t.root })).toMatchObject({
|
||||
kind: "root_offline",
|
||||
reason: "sentinel_missing",
|
||||
});
|
||||
expect(await checkRoot(join(t.root, "gone"))).toMatchObject({ kind: "offline", reason: "root_missing" });
|
||||
});
|
||||
|
||||
it.skipIf(isRoot)("权限拒绝 → permission_denied(root 运行时跳过)", async () => {
|
||||
const t = await tree({ "locked/a.mkv": "A" });
|
||||
await chmod(t.path("locked"), 0o000);
|
||||
try {
|
||||
expect((await statSafe(t.path("locked/a.mkv"))).kind).toBe("permission_denied");
|
||||
} finally {
|
||||
await chmod(t.path("locked"), 0o755);
|
||||
}
|
||||
});
|
||||
|
||||
it("磁盘用量:statfs 成功给出比例,路径不存在返回 unknown", async () => {
|
||||
const t = await tree();
|
||||
const u = await diskUsage(t.root);
|
||||
expect(u.status).toBe("ok");
|
||||
if (u.status === "ok") {
|
||||
expect(u.totalBytes).toBeGreaterThan(0);
|
||||
expect(u.usedRatio).toBeGreaterThanOrEqual(0);
|
||||
expect(u.usedRatio).toBeLessThanOrEqual(1);
|
||||
}
|
||||
expect(await diskUsage(join(t.root, "nope"))).toMatchObject({ status: "unknown", code: "ENOENT" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanIncremental", () => {
|
||||
it("按 I/O 上限分批,游标续扫覆盖全部且不重复", async () => {
|
||||
const spec: Record<string, string> = {};
|
||||
for (const d of ["A", "B", "C"]) for (let i = 0; i < 4; i++) spec[`${d}/s${i}/f${i}.mkv`] = `${d}${i}`;
|
||||
spec["B/.x.uhd-partial"] = "p";
|
||||
const t = await tree(spec, { sentinel: true });
|
||||
const seen: string[] = [];
|
||||
let cursor: string | null = null;
|
||||
let batches = 0;
|
||||
do {
|
||||
const r = await scanIncremental(t.root, { cursor, maxStats: 5 });
|
||||
expect(r.statCount).toBeLessThanOrEqual(5);
|
||||
seen.push(...r.entries.map((e) => e.relPath));
|
||||
cursor = r.cursor;
|
||||
batches++;
|
||||
if (r.status === "complete") break;
|
||||
expect(r.status).toBe("partial");
|
||||
} while (batches < 10);
|
||||
expect(batches).toBe(3);
|
||||
expect(seen).toEqual([...seen].sort());
|
||||
expect(new Set(seen).size).toBe(12);
|
||||
expect(seen).not.toContain("B/.x.uhd-partial");
|
||||
});
|
||||
|
||||
it("标注 new/changed/unchanged;根离线不扫描;符号链接不跟随", async () => {
|
||||
const t = await tree({ "a.mkv": "A", "b.mkv": "B", "c.mkv": "C" }, { sentinel: true });
|
||||
await symlink(t.path("a.mkv"), t.path("d.mkv"));
|
||||
const first = await scanIncremental(t.root);
|
||||
const prev = new Map(first.entries.map((e) => [e.relPath, e]));
|
||||
await writeFile(t.path("b.mkv"), "BBBB");
|
||||
const second = await scanIncremental(t.root, { previous: (p) => prev.get(p) });
|
||||
const byPath = Object.fromEntries(second.entries.map((e) => [e.relPath, e]));
|
||||
expect(byPath["a.mkv"]!.change).toBe("unchanged");
|
||||
expect(byPath["b.mkv"]!.change).toBe("changed");
|
||||
expect(byPath["d.mkv"]!.isSymlink).toBe(true);
|
||||
await rm(t.path(".uhd-root"));
|
||||
const off = await scanIncremental(t.root);
|
||||
expect(off.status).toBe("root_offline");
|
||||
expect(off.entries).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ffprobe", () => {
|
||||
it("可执行文件不存在 / 非媒体文件 → unknown,不抛", async () => {
|
||||
const t = await tree({ "a.txt": "hello" });
|
||||
expect(await ffprobe(t.path("a.txt"), { bin: "/nonexistent/ffprobe" })).toEqual({
|
||||
status: "unknown",
|
||||
reason: "ffprobe_unavailable",
|
||||
});
|
||||
const r = await ffprobe(t.path("a.txt"));
|
||||
expect(r.status).toBe("unknown");
|
||||
});
|
||||
|
||||
it.skipIf(!hasFfmpeg)("真实小样片:读出分辨率与音轨(本机无 ffmpeg 时跳过)", async () => {
|
||||
const t = await tree();
|
||||
const out = t.path("s.mkv");
|
||||
execFileSync("ffmpeg", [
|
||||
"-v",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc=size=320x240:rate=5",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440",
|
||||
"-t",
|
||||
"1",
|
||||
"-c:v",
|
||||
"mpeg4",
|
||||
"-c:a",
|
||||
"ac3",
|
||||
"-metadata:s:a:0",
|
||||
"language=jpn",
|
||||
out,
|
||||
]);
|
||||
const r = await ffprobe(out);
|
||||
expect(r.status).toBe("ok");
|
||||
if (r.status === "ok") {
|
||||
expect(r.video[0]).toMatchObject({ codec: "mpeg4", width: 320, height: 240 });
|
||||
expect(r.video[0]!.hdr).toBeUndefined();
|
||||
expect(r.audio[0]).toMatchObject({ codec: "ac3", language: "jpn" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./copy";
|
||||
export * from "./errors";
|
||||
export * from "./ffprobe";
|
||||
export * from "./move";
|
||||
export * from "./scan";
|
||||
export * from "./stat";
|
||||
@@ -0,0 +1,180 @@
|
||||
// 迁移来源:MoviePilot storages/local.py move 与 transhandler.py materialize_target/delete_move_source(e6820a8);
|
||||
// 调整:EXDEV 显式返回不降级;跨设备拆为 copy→verify→delete_source 可重入三步,删源失败 = attention。
|
||||
import { link, lstat, rename, stat, unlink } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
import { type CopyOptions, copyWithVerify, filesMatch, type VerifyMethod } from "./copy";
|
||||
import { errnoOf, FsError, toFsError } from "./errors";
|
||||
|
||||
export type MoveResult = { status: "moved" } | { status: "exdev" } | { status: "exists" };
|
||||
|
||||
/**
|
||||
* 同设备移动(no-clobber)。跨设备返回 { status: "exdev" },**不会**自动降级为复制——
|
||||
* 调用方需显式切换到 crossDeviceMove 并在操作账本中记录(REQ §8.3)。
|
||||
* 其他错误抛 FsError。
|
||||
*/
|
||||
export async function moveSafe(src: string, dst: string): Promise<MoveResult> {
|
||||
let s: Awaited<ReturnType<typeof lstat>>;
|
||||
try {
|
||||
s = await lstat(src);
|
||||
} catch (e) {
|
||||
throw toFsError(e, src);
|
||||
}
|
||||
if (s.isFile()) {
|
||||
try {
|
||||
await link(src, dst);
|
||||
} catch (e) {
|
||||
const code = errnoOf(e);
|
||||
if (code === "EXDEV") return { status: "exdev" };
|
||||
if (code === "EEXIST") {
|
||||
// 上次 link 成功但 unlink 前中断:目标与源是同一 inode → 补完删除源
|
||||
const d = await lstat(dst).catch(() => null);
|
||||
if (d && d.ino === s.ino && d.dev === s.dev) {
|
||||
await unlink(src);
|
||||
return { status: "moved" };
|
||||
}
|
||||
return { status: "exists" };
|
||||
}
|
||||
if (!["EPERM", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EMLINK"].includes(code ?? "")) throw toFsError(e, dst);
|
||||
return renameChecked(src, dst);
|
||||
}
|
||||
try {
|
||||
await unlink(src);
|
||||
} catch (e) {
|
||||
throw toFsError(e, src);
|
||||
}
|
||||
return { status: "moved" };
|
||||
}
|
||||
return renameChecked(src, dst);
|
||||
}
|
||||
|
||||
async function renameChecked(src: string, dst: string): Promise<MoveResult> {
|
||||
try {
|
||||
await lstat(dst);
|
||||
return { status: "exists" };
|
||||
} catch (e) {
|
||||
if (errnoOf(e) !== "ENOENT") throw toFsError(e, dst);
|
||||
}
|
||||
try {
|
||||
await rename(src, dst);
|
||||
return { status: "moved" };
|
||||
} catch (e) {
|
||||
if (errnoOf(e) === "EXDEV") return { status: "exdev" };
|
||||
throw toFsError(e, dst);
|
||||
}
|
||||
}
|
||||
|
||||
export type CrossMoveStep = "copy" | "verify" | "delete_source";
|
||||
|
||||
export interface CrossMoveStepLog {
|
||||
step: CrossMoveStep;
|
||||
status: "done" | "skipped" | "failed";
|
||||
at: string;
|
||||
detail?: string;
|
||||
error?: { code: string; message: string };
|
||||
}
|
||||
|
||||
export interface CrossMoveResult {
|
||||
/** completed:三步完成;attention:目标已就绪但删源失败/无法确认;failed:目标未提交,源保持不动 */
|
||||
status: "completed" | "attention" | "failed";
|
||||
steps: CrossMoveStepLog[];
|
||||
}
|
||||
|
||||
export interface CrossMoveOptions extends Omit<CopyOptions, "hash"> {
|
||||
hash?: VerifyMethod;
|
||||
/** 上次运行的步骤日志,用于中断续跑(只作参考,实际仍以文件系统观察为准) */
|
||||
previous?: CrossMoveStepLog[];
|
||||
}
|
||||
|
||||
async function exists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(p);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (errnoOf(e) === "ENOENT") return false;
|
||||
throw toFsError(e, p);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨设备搬运:copy → verify → delete_source,每步返回日志,可重入:
|
||||
* - 目标已存在且与源一致 → copy 记为 skipped(上次已完成);不一致 → failed,不覆盖
|
||||
* - 源已不存在但目标在,且上次日志 verify=done → 视为删源已完成
|
||||
* - 删源失败 → attention(不能报成功)
|
||||
*/
|
||||
export async function crossDeviceMove(src: string, dst: string, opts: CrossMoveOptions = {}): Promise<CrossMoveResult> {
|
||||
const method = opts.hash ?? "size";
|
||||
const steps: CrossMoveStepLog[] = [];
|
||||
const log = (step: CrossMoveStep, status: CrossMoveStepLog["status"], detail?: string, err?: unknown) => {
|
||||
const e = err === undefined ? undefined : toFsError(err);
|
||||
steps.push({
|
||||
step,
|
||||
status,
|
||||
at: new Date().toISOString(),
|
||||
...(detail ? { detail } : {}),
|
||||
...(e ? { error: { code: e.code, message: e.message } } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
let srcExists: boolean;
|
||||
let dstExists: boolean;
|
||||
try {
|
||||
[srcExists, dstExists] = await Promise.all([exists(src), exists(dst)]);
|
||||
} catch (e) {
|
||||
log("copy", "failed", "observe failed", e);
|
||||
return { status: "failed", steps };
|
||||
}
|
||||
|
||||
if (!srcExists) {
|
||||
const verified = opts.previous?.some((s) => s.step === "verify" && s.status === "done");
|
||||
if (dstExists && verified) {
|
||||
log("copy", "skipped", "already applied");
|
||||
log("verify", "skipped", "verified in previous run");
|
||||
log("delete_source", "skipped", "source already removed");
|
||||
return { status: "completed", steps };
|
||||
}
|
||||
log("copy", "failed", dstExists ? "source missing, target cannot be verified" : "source missing", undefined);
|
||||
return { status: dstExists ? "attention" : "failed", steps };
|
||||
}
|
||||
|
||||
if (dstExists) {
|
||||
try {
|
||||
if (await filesMatch(src, dst, method)) log("copy", "skipped", "target already matches source");
|
||||
else {
|
||||
log("copy", "failed", "target exists and differs", new FsError("EEXIST", `target exists: ${dst}`));
|
||||
return { status: "failed", steps };
|
||||
}
|
||||
} catch (e) {
|
||||
log("copy", "failed", "compare failed", e);
|
||||
return { status: "failed", steps };
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const { previous: _p, ...copyOpts } = opts;
|
||||
const r = await copyWithVerify(src, dst, { ...copyOpts, hash: method });
|
||||
log("copy", "done", `${r.bytes} bytes`);
|
||||
} catch (e) {
|
||||
log("copy", "failed", undefined, e);
|
||||
return { status: "failed", steps };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!(await filesMatch(src, dst, method))) {
|
||||
log("verify", "failed", undefined, new FsError("VERIFY_FAILED", "target differs from source"));
|
||||
return { status: "failed", steps };
|
||||
}
|
||||
log("verify", "done", method);
|
||||
} catch (e) {
|
||||
log("verify", "failed", undefined, e);
|
||||
return { status: "failed", steps };
|
||||
}
|
||||
|
||||
try {
|
||||
await unlink(src);
|
||||
log("delete_source", "done", dirname(src));
|
||||
} catch (e) {
|
||||
log("delete_source", "failed", undefined, e);
|
||||
return { status: "attention", steps };
|
||||
}
|
||||
return { status: "completed", steps };
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// 本项目新增(REQ §8.1/§8.2 分批增量扫描与中断续扫);MP 整理监控为全量目录遍历,无游标。
|
||||
import type { Dirent } from "node:fs";
|
||||
import { lstat, readdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { errnoOf } from "./errors";
|
||||
import { checkRoot, DEFAULT_ROOT_SENTINEL } from "./stat";
|
||||
|
||||
export interface ScanEntry {
|
||||
/** 相对根目录的路径,/ 分隔 */
|
||||
relPath: string;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
ino: number;
|
||||
dev: number;
|
||||
nlink: number;
|
||||
isSymlink: boolean;
|
||||
change: "new" | "changed" | "unchanged";
|
||||
}
|
||||
|
||||
export interface ScanOptions {
|
||||
/** 上次返回的游标;空则从头扫 */
|
||||
cursor?: string | null;
|
||||
/** 本批最多 lstat 的文件数(I/O 上限),默认 2000 */
|
||||
maxStats?: number;
|
||||
/** 本批最多 readdir 的目录数,默认不限 */
|
||||
maxDirs?: number;
|
||||
/** 哨兵文件名;false 表示不检查(不推荐) */
|
||||
sentinel?: string | false;
|
||||
/** 已知记录,用于标注 new/changed/unchanged */
|
||||
previous?: (relPath: string) => { size: number; mtimeMs: number } | undefined;
|
||||
/** 跳过的文件名判定(默认跳过 .uhd-partial 与 .!qB 未完成文件) */
|
||||
skip?: (name: string) => boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
status: "complete" | "partial" | "root_offline" | "aborted";
|
||||
entries: ScanEntry[];
|
||||
/** 继续扫描用的游标;complete 时为 null */
|
||||
cursor: string | null;
|
||||
statCount: number;
|
||||
dirCount: number;
|
||||
errors: { relPath: string; code: string }[];
|
||||
}
|
||||
|
||||
const defaultSkip = (n: string) => n.endsWith(".uhd-partial") || n.endsWith(".!qB");
|
||||
|
||||
function encodeCursor(after: string[]): string {
|
||||
return Buffer.from(JSON.stringify({ v: 1, after })).toString("base64url");
|
||||
}
|
||||
function decodeCursor(c: string | null | undefined): string[] | null {
|
||||
if (!c) return null;
|
||||
try {
|
||||
const j = JSON.parse(Buffer.from(c, "base64url").toString()) as { v: number; after: string[] };
|
||||
return j.v === 1 && Array.isArray(j.after) ? j.after : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function cmp(a: string, b: string): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
}
|
||||
/** 段序比较(深度优先前序、名称排序的遍历顺序) */
|
||||
function cmpSegs(a: string[], b: string[]): number {
|
||||
for (let i = 0; i < Math.min(a.length, b.length); i++) {
|
||||
const c = cmp(a[i]!, b[i]!);
|
||||
if (c) return c;
|
||||
}
|
||||
return a.length - b.length;
|
||||
}
|
||||
function isPrefix(p: string[], of: string[]): boolean {
|
||||
return p.length < of.length && p.every((s, i) => s === of[i]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分批增量扫描:确定性顺序(名称排序、深度优先),每批受 maxStats/maxDirs 限制,
|
||||
* 返回游标供下一批续扫;进程中断后用最后持久化的游标续扫,不必从头遍历。
|
||||
* 目录只 readdir 不 stat;文件 lstat(不跟随符号链接)。
|
||||
*/
|
||||
export async function scanIncremental(root: string, opts: ScanOptions = {}): Promise<ScanResult> {
|
||||
const result: ScanResult = { status: "complete", entries: [], cursor: null, statCount: 0, dirCount: 0, errors: [] };
|
||||
if (opts.sentinel !== false) {
|
||||
const h = await checkRoot(root, opts.sentinel ?? DEFAULT_ROOT_SENTINEL);
|
||||
if (h.kind === "offline") return { ...result, status: "root_offline" };
|
||||
}
|
||||
const maxStats = opts.maxStats ?? 2000;
|
||||
const maxDirs = opts.maxDirs ?? Number.POSITIVE_INFINITY;
|
||||
const skip = opts.skip ?? defaultSkip;
|
||||
const sentinelName = opts.sentinel === false ? null : (opts.sentinel ?? DEFAULT_ROOT_SENTINEL);
|
||||
const after = decodeCursor(opts.cursor);
|
||||
let last: string[] | null = after;
|
||||
let stopped: ScanResult["status"] | null = null;
|
||||
|
||||
const walk = async (dirSegs: string[]): Promise<void> => {
|
||||
if (result.dirCount >= maxDirs) {
|
||||
stopped = "partial";
|
||||
return;
|
||||
}
|
||||
let ents: Dirent[];
|
||||
try {
|
||||
ents = await readdir(join(root, ...dirSegs), { withFileTypes: true });
|
||||
result.dirCount++;
|
||||
} catch (e) {
|
||||
result.errors.push({ relPath: dirSegs.join("/"), code: errnoOf(e) ?? "UNKNOWN" });
|
||||
return;
|
||||
}
|
||||
ents.sort((a, b) => cmp(a.name, b.name));
|
||||
for (const d of ents) {
|
||||
if (opts.signal?.aborted) {
|
||||
stopped = "aborted";
|
||||
return;
|
||||
}
|
||||
const segs = [...dirSegs, d.name];
|
||||
if (dirSegs.length === 0 && d.name === sentinelName) continue;
|
||||
if (after && cmpSegs(segs, after) <= 0 && !isPrefix(segs, after)) continue;
|
||||
if (d.isDirectory()) {
|
||||
await walk(segs);
|
||||
if (stopped) return;
|
||||
continue;
|
||||
}
|
||||
if (skip(d.name)) continue;
|
||||
if (result.statCount >= maxStats) {
|
||||
stopped = "partial";
|
||||
return;
|
||||
}
|
||||
const rel = segs.join("/");
|
||||
try {
|
||||
const st = await lstat(join(root, ...segs));
|
||||
result.statCount++;
|
||||
if (st.isDirectory()) continue;
|
||||
const prev = opts.previous?.(rel);
|
||||
result.entries.push({
|
||||
relPath: rel,
|
||||
size: st.size,
|
||||
mtimeMs: st.mtimeMs,
|
||||
ino: st.ino,
|
||||
dev: st.dev,
|
||||
nlink: st.nlink,
|
||||
isSymlink: st.isSymbolicLink(),
|
||||
change: !prev ? "new" : prev.size === st.size && prev.mtimeMs === st.mtimeMs ? "unchanged" : "changed",
|
||||
});
|
||||
} catch (e) {
|
||||
result.statCount++;
|
||||
result.errors.push({ relPath: rel, code: errnoOf(e) ?? "UNKNOWN" });
|
||||
}
|
||||
last = segs;
|
||||
}
|
||||
};
|
||||
|
||||
await walk([]);
|
||||
if (stopped) {
|
||||
result.status = stopped;
|
||||
result.cursor = last ? encodeCursor(last) : (opts.cursor ?? null);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// 本项目新增(REQ §8.2 挂载根哨兵);MP 无对应实现。
|
||||
import { lstat, stat, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { errnoOf } from "./errors";
|
||||
|
||||
export const DEFAULT_ROOT_SENTINEL = ".uhd-root";
|
||||
|
||||
export interface FileFacts {
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
ino: number;
|
||||
dev: number;
|
||||
nlink: number;
|
||||
isFile: boolean;
|
||||
isDirectory: boolean;
|
||||
isSymbolicLink: boolean;
|
||||
}
|
||||
|
||||
export type StatResult =
|
||||
| { kind: "ok"; facts: FileFacts }
|
||||
| { kind: "missing" }
|
||||
| { kind: "permission_denied" }
|
||||
/** 挂载根哨兵不存在/不可读:不能据此判定文件缺失 */
|
||||
| { kind: "root_offline"; root: string; reason: string }
|
||||
| { kind: "error"; code: string; message: string };
|
||||
|
||||
export type RootHealth =
|
||||
| { kind: "online"; dev: number }
|
||||
| { kind: "offline"; reason: "sentinel_missing" | "root_missing" | "permission_denied" | "error"; code?: string };
|
||||
|
||||
/** 检查库根/缓存根是否在线:根目录存在且哨兵文件存在。 */
|
||||
export async function checkRoot(root: string, sentinel: string = DEFAULT_ROOT_SENTINEL): Promise<RootHealth> {
|
||||
let dev: number;
|
||||
try {
|
||||
const st = await stat(root);
|
||||
dev = st.dev;
|
||||
} catch (e) {
|
||||
const code = errnoOf(e);
|
||||
if (code === "ENOENT") return { kind: "offline", reason: "root_missing" };
|
||||
if (code === "EACCES" || code === "EPERM") return { kind: "offline", reason: "permission_denied", code };
|
||||
return { kind: "offline", reason: "error", code: code ?? "UNKNOWN" };
|
||||
}
|
||||
try {
|
||||
await stat(join(root, sentinel));
|
||||
return { kind: "online", dev };
|
||||
} catch (e) {
|
||||
const code = errnoOf(e);
|
||||
if (code === "ENOENT") return { kind: "offline", reason: "sentinel_missing" };
|
||||
if (code === "EACCES" || code === "EPERM") return { kind: "offline", reason: "permission_denied", code };
|
||||
return { kind: "offline", reason: "error", code: code ?? "UNKNOWN" };
|
||||
}
|
||||
}
|
||||
|
||||
/** 在根目录写入哨兵文件(首次登记库根时调用)。 */
|
||||
export async function writeRootSentinel(root: string, sentinel: string = DEFAULT_ROOT_SENTINEL): Promise<void> {
|
||||
await writeFile(join(root, sentinel), `uhd-media-tool root sentinel\ncreated=${new Date().toISOString()}\n`, {
|
||||
flag: "wx",
|
||||
}).catch((e) => {
|
||||
if (errnoOf(e) !== "EEXIST") throw e;
|
||||
});
|
||||
}
|
||||
|
||||
function facts(st: Awaited<ReturnType<typeof lstat>>): FileFacts {
|
||||
return {
|
||||
size: Number(st.size),
|
||||
mtimeMs: Number(st.mtimeMs),
|
||||
ino: Number(st.ino),
|
||||
dev: Number(st.dev),
|
||||
nlink: Number(st.nlink),
|
||||
isFile: st.isFile(),
|
||||
isDirectory: st.isDirectory(),
|
||||
isSymbolicLink: st.isSymbolicLink(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 区分 ENOENT / EACCES / 挂载根离线的 stat。给出 root 时先检查哨兵,
|
||||
* 根离线返回 root_offline 而不是 missing(REQ §8.2)。不抛异常。
|
||||
*/
|
||||
export async function statSafe(path: string, opts: { root?: string; sentinel?: string } = {}): Promise<StatResult> {
|
||||
if (opts.root) {
|
||||
const h = await checkRoot(opts.root, opts.sentinel);
|
||||
if (h.kind === "offline") return { kind: "root_offline", root: opts.root, reason: h.reason };
|
||||
}
|
||||
try {
|
||||
return { kind: "ok", facts: facts(await lstat(path)) };
|
||||
} catch (e) {
|
||||
const code = errnoOf(e);
|
||||
if (code === "ENOENT" || code === "ENOTDIR") return { kind: "missing" };
|
||||
if (code === "EACCES" || code === "EPERM") return { kind: "permission_denied" };
|
||||
return { kind: "error", code: code ?? "UNKNOWN", message: (e as Error)?.message ?? String(e) };
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,6 @@
|
||||
// @umt/adapters 公开入口;W1 起在此导出实现
|
||||
export const PACKAGE_NAME = "@umt/adapters";
|
||||
export * from "./emby";
|
||||
export * from "./fs";
|
||||
export * from "./qbittorrent";
|
||||
export * from "./tmdb";
|
||||
@@ -0,0 +1,5 @@
|
||||
# qbittorrent
|
||||
|
||||
qBittorrent WebUI API v2 适配(`QbClient` 实现 `DownloaderClient`,按实例创建,`DownloaderRegistry` 管多实例):Cookie 登录与会话过期自动重登、API Key(Bearer)、`torrents/add`(磁力/URL/.torrent,savepath/category/tags/paused+stopped/rename/contentLayout,autoTMM 固定关闭)、`torrents/info`(hashes/tag/category/filter,多标签子集过滤)、`files`、`filePrio`、pause/resume(5.x 自动切到 stop/start)、标签增删与定义删除、`delete`(`deleteFiles` 必须显式)、`setForceStart`/`recheck`/`setLocation`/`setCategory`/限速与分享限制、只读偏好、`sync/maindata`、`app/version`。错误统一为 `DownloaderError`(auth/network/timeout/notfound/rate/rejected/protocol),读请求按配置重试,写请求不自动重试。`submit()` 本地算 infohash(bencode 自写)→ 预查已存在 → 提交 → 按 hash 或探针标签确认;收单超时/断连时先回查再给结论(`added`/`already_exists`/`accepted_unconfirmed`/`rejected`/`unreachable`)。状态映射与完成判定对照 MoviePilot,未知状态不兜底为完成。
|
||||
|
||||
仅对 mock 与官方 API 文档验证,未连接真实实例。
|
||||
@@ -0,0 +1,754 @@
|
||||
/**
|
||||
* qBittorrent WebUI API v2 客户端。
|
||||
* 迁移来源:MoviePilot app/modules/qbittorrent/qbittorrent.py(e6820a8),逐函数对照见
|
||||
* docs/porting/moviepilot-downloader-and-sites.md「W1-3a 实际迁移对照」。
|
||||
* 叠加:多实例、错误分类(DownloaderError)、提交幂等(本地 infohash 预查 + 收单超时按 hash 回查)。
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { DownloaderError, type DownloaderErrorKind } from "./errors";
|
||||
import { isDownloadComplete, mapQbState, type QbObservedState } from "./state";
|
||||
import { BencodeError, parseMagnet, parseTorrent } from "./torrent";
|
||||
|
||||
export interface QbInstanceConfig {
|
||||
/** 本系统内的下载器实例 ID(downloader_instance.id) */
|
||||
instanceId: string;
|
||||
/** 例:http://127.0.0.1:8080(不带 /api/v2) */
|
||||
baseUrl: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
/** qB 5.2+ WebUI API Key;有则走 Bearer,不登录 */
|
||||
apiKey?: string;
|
||||
/** 单次请求超时,默认 15000 */
|
||||
timeoutMs?: number;
|
||||
/** 幂等读请求的重试次数(不含首次),默认 2;写请求不自动重试 */
|
||||
retries?: number;
|
||||
/** 重试退避基数,指数增长,默认 300ms */
|
||||
retryBaseMs?: number;
|
||||
/** 提交后按 infohash/探针标签回查的次数与间隔,默认 9 次 × 3000ms(对照 MP get_torrent_id_by_tag;首查不等待) */
|
||||
lookup?: { attempts: number; intervalMs: number };
|
||||
/** 探针标签前缀,默认 uhd-probe- */
|
||||
probeTagPrefix?: string;
|
||||
/** 实例级默认:顺序下载 / 首尾块优先(对照 MP sequentail / first_last_piece 配置) */
|
||||
sequentialDownload?: boolean;
|
||||
firstLastPiecePrio?: boolean;
|
||||
/** 注入 fetch(测试/代理) */
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface QbTorrent {
|
||||
instanceId: string;
|
||||
hash: string;
|
||||
infohashV1: string | null;
|
||||
infohashV2: string | null;
|
||||
name: string;
|
||||
rawState: string;
|
||||
state: QbObservedState;
|
||||
/** 严格完成判定结果(progress>=1 && amountLeft<=0 && 终态) */
|
||||
complete: boolean;
|
||||
progress: number;
|
||||
amountLeft: number;
|
||||
size: number;
|
||||
totalSize: number | null;
|
||||
savePath: string;
|
||||
contentPath: string | null;
|
||||
category: string;
|
||||
tags: string[];
|
||||
addedOn: number | null;
|
||||
completionOn: number | null;
|
||||
dlspeed: number;
|
||||
upspeed: number;
|
||||
eta: number | null;
|
||||
ratio: number | null;
|
||||
seedingTimeSec: number | null;
|
||||
}
|
||||
|
||||
export interface QbFile {
|
||||
index: number;
|
||||
name: string;
|
||||
size: number;
|
||||
progress: number;
|
||||
/** 0 不下载,1 普通,6 高,7 最高 */
|
||||
priority: number;
|
||||
isSeed: boolean | null;
|
||||
}
|
||||
|
||||
export type TorrentSource =
|
||||
| { kind: "magnet"; uri: string }
|
||||
| { kind: "url"; url: string }
|
||||
| { kind: "file"; bytes: Uint8Array; filename?: string };
|
||||
|
||||
export interface AddTorrentRequest {
|
||||
source: TorrentSource;
|
||||
savePath: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
paused?: boolean;
|
||||
rename?: string;
|
||||
contentLayout?: "Original" | "Subfolder" | "NoSubfolder";
|
||||
sequential?: boolean;
|
||||
firstLastPiecePrio?: boolean;
|
||||
}
|
||||
|
||||
export interface AddTorrentResult {
|
||||
accepted: boolean;
|
||||
addedIds: string[];
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export type SubmitOutcome =
|
||||
| { kind: "added"; hash: string; via: "confirmed" | "added_ids" | "probe_tag" | "recovered_after_timeout" }
|
||||
| { kind: "already_exists"; hash: string; existing: QbTorrent }
|
||||
/** 下载器已接收(或可能已接收),但回查窗口内未找到任务;调用方稍后按 hash/probeTag 对账,不得盲目重提 */
|
||||
| { kind: "accepted_unconfirmed"; hash: string | null; probeTag: string | null }
|
||||
| { kind: "rejected"; reason: string; hash: string | null }
|
||||
/** 确认未提交成功(回查未命中且无歧义)或下载器不可达;有 hash 时用同一 hash 重试是幂等的 */
|
||||
| { kind: "unreachable"; error: DownloaderError; hash: string | null };
|
||||
|
||||
export interface TorrentQuery {
|
||||
hashes?: string[];
|
||||
tag?: string;
|
||||
/** 须同时具备的标签(对照 MP get_torrents 的 tags 子集判断);第一个走 qB 原生 tag 参数 */
|
||||
tags?: string[];
|
||||
category?: string;
|
||||
filter?:
|
||||
| "all"
|
||||
| "downloading"
|
||||
| "seeding"
|
||||
| "completed"
|
||||
| "paused"
|
||||
| "stopped"
|
||||
| "active"
|
||||
| "inactive"
|
||||
| "stalled"
|
||||
| "errored";
|
||||
}
|
||||
|
||||
export interface QbMainData {
|
||||
rid: number;
|
||||
fullUpdate: boolean;
|
||||
torrents: Record<string, Record<string, unknown>>;
|
||||
torrentsRemoved: string[];
|
||||
serverState: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** 下载器端口(实例维度)。上层 tasks/agent 只依赖此接口。 */
|
||||
export interface DownloaderClient {
|
||||
readonly instanceId: string;
|
||||
version(): Promise<{ app: string; webApi: string }>;
|
||||
add(req: AddTorrentRequest): Promise<AddTorrentResult>;
|
||||
submit(req: AddTorrentRequest): Promise<SubmitOutcome>;
|
||||
list(q?: TorrentQuery): Promise<QbTorrent[]>;
|
||||
/** 严格完成判定后的已完成任务(对照 get_completed_torrents) */
|
||||
getCompleted(q?: Pick<TorrentQuery, "hashes" | "tags">): Promise<QbTorrent[]>;
|
||||
getDownloading(q?: Pick<TorrentQuery, "hashes" | "tags">): Promise<QbTorrent[]>;
|
||||
findByHash(hash: string): Promise<QbTorrent | null>;
|
||||
files(hash: string, opts?: { retries?: number; intervalMs?: number }): Promise<QbFile[]>;
|
||||
/** priority 0 不下载;fileIds 为空返回 false 且不发请求(对照 set_files) */
|
||||
setFilePriority(hash: string, fileIds: number[], priority: 0 | 1 | 6 | 7): Promise<boolean>;
|
||||
setForceStart(hashes: string[], enable?: boolean): Promise<void>;
|
||||
recheck(hashes: string[]): Promise<void>;
|
||||
setLocation(hashes: string[], location: string): Promise<void>;
|
||||
setCategory(hashes: string[], category: string): Promise<void>;
|
||||
changeTorrent(hash: string, opts: TorrentLimits): Promise<void>;
|
||||
/** 只读下载器全局偏好(不改写,见 QA6) */
|
||||
preferences(): Promise<Record<string, unknown>>;
|
||||
contentLayout(): Promise<"Original" | "Subfolder" | "NoSubfolder">;
|
||||
transferInfo(): Promise<Record<string, unknown>>;
|
||||
pause(hashes: string[]): Promise<void>;
|
||||
resume(hashes: string[]): Promise<void>;
|
||||
addTags(hashes: string[], tags: string[]): Promise<void>;
|
||||
removeTags(hashes: string[], tags: string[]): Promise<void>;
|
||||
deleteTagDefinitions(tags: string[]): Promise<void>;
|
||||
/** 从任务移除标签并删除全局定义;hashes 为 null 时只删定义(对照 delete_torrents_tag) */
|
||||
deleteTorrentsTag(hashes: string[] | null, tags: string[]): Promise<void>;
|
||||
/** deleteFiles 必须显式给出;删数据前的引用检查由上层 assertSafeToDeleteData 负责 */
|
||||
delete(hashes: string[], opts: { deleteFiles: boolean }): Promise<void>;
|
||||
mainData(rid?: number): Promise<QbMainData>;
|
||||
}
|
||||
|
||||
export interface TorrentLimits {
|
||||
/** KB/s,0 不限速 */
|
||||
uploadLimitKBps?: number;
|
||||
downloadLimitKBps?: number;
|
||||
ratioLimit?: number;
|
||||
/** 分钟 */
|
||||
seedingTimeLimitMin?: number;
|
||||
}
|
||||
|
||||
type Body = { kind: "form"; data: Record<string, string> } | { kind: "multipart"; data: FormData };
|
||||
|
||||
interface ReqOpts {
|
||||
query?: Record<string, string | undefined>;
|
||||
body?: Body;
|
||||
idempotent: boolean;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function num(v: unknown): number | null {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
function str(v: unknown): string | null {
|
||||
return typeof v === "string" && v !== "" ? v : null;
|
||||
}
|
||||
|
||||
export function normalizeTorrent(instanceId: string, r: Record<string, unknown>): QbTorrent {
|
||||
const rawState = typeof r.state === "string" ? r.state : "";
|
||||
const progress = num(r.progress) ?? 0;
|
||||
const amountLeft = num(r.amount_left) ?? Number.POSITIVE_INFINITY;
|
||||
const tags =
|
||||
typeof r.tags === "string"
|
||||
? r.tags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
return {
|
||||
instanceId,
|
||||
hash: String(r.hash ?? "").toLowerCase(),
|
||||
infohashV1: str(r.infohash_v1),
|
||||
infohashV2: str(r.infohash_v2),
|
||||
name: String(r.name ?? ""),
|
||||
rawState,
|
||||
state: mapQbState(rawState),
|
||||
complete: isDownloadComplete({ progress, amountLeft, state: rawState }),
|
||||
progress,
|
||||
amountLeft,
|
||||
size: num(r.size) ?? 0,
|
||||
totalSize: num(r.total_size),
|
||||
savePath: String(r.save_path ?? ""),
|
||||
contentPath: str(r.content_path),
|
||||
category: String(r.category ?? ""),
|
||||
tags,
|
||||
addedOn: num(r.added_on),
|
||||
completionOn: (num(r.completion_on) ?? -1) > 0 ? num(r.completion_on) : null,
|
||||
dlspeed: num(r.dlspeed) ?? 0,
|
||||
upspeed: num(r.upspeed) ?? 0,
|
||||
eta: num(r.eta),
|
||||
ratio: num(r.ratio),
|
||||
seedingTimeSec: num(r.seeding_time),
|
||||
};
|
||||
}
|
||||
|
||||
export class QbClient implements DownloaderClient {
|
||||
readonly instanceId: string;
|
||||
private readonly base: string;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly retries: number;
|
||||
private readonly retryBaseMs: number;
|
||||
private readonly lookup: { attempts: number; intervalMs: number };
|
||||
private readonly probePrefix: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private sid: string | null = null;
|
||||
private loginInFlight: Promise<void> | null = null;
|
||||
/** qB 5.x 把 pause/resume 改名为 stop/start;探测到后缓存 */
|
||||
private legacyPauseApi: boolean | null = null;
|
||||
|
||||
constructor(private readonly cfg: QbInstanceConfig) {
|
||||
this.instanceId = cfg.instanceId;
|
||||
this.base = cfg.baseUrl.replace(/\/+$/, "");
|
||||
this.timeoutMs = cfg.timeoutMs ?? 15_000;
|
||||
this.retries = cfg.retries ?? 2;
|
||||
this.retryBaseMs = cfg.retryBaseMs ?? 300;
|
||||
this.lookup = cfg.lookup ?? { attempts: 9, intervalMs: 3000 };
|
||||
this.probePrefix = cfg.probeTagPrefix ?? "uhd-probe-";
|
||||
this.fetchImpl = cfg.fetch ?? fetch;
|
||||
}
|
||||
|
||||
private err(kind: DownloaderErrorKind, message: string, endpoint: string, status?: number, cause?: unknown) {
|
||||
return new DownloaderError(kind, `[qb:${this.instanceId}] ${message}`, {
|
||||
instanceId: this.instanceId,
|
||||
endpoint,
|
||||
...(status !== undefined ? { status } : {}),
|
||||
...(cause !== undefined ? { cause } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
private async rawFetch(endpoint: string, init: RequestInit): Promise<Response> {
|
||||
try {
|
||||
return await this.fetchImpl(`${this.base}/api/v2/${endpoint}`, {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
} catch (e) {
|
||||
const name = (e as { name?: string })?.name;
|
||||
if (name === "TimeoutError" || name === "AbortError") {
|
||||
throw this.err("timeout", `request timed out after ${this.timeoutMs}ms`, endpoint, undefined, e);
|
||||
}
|
||||
throw this.err("network", `request failed: ${(e as Error)?.message ?? String(e)}`, endpoint, undefined, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录;并发调用合并为一次。 */
|
||||
async login(): Promise<void> {
|
||||
if (this.cfg.apiKey) return;
|
||||
if (!this.loginInFlight) {
|
||||
this.loginInFlight = this.doLogin().finally(() => {
|
||||
this.loginInFlight = null;
|
||||
});
|
||||
}
|
||||
return this.loginInFlight;
|
||||
}
|
||||
|
||||
private async doLogin(): Promise<void> {
|
||||
const endpoint = "auth/login";
|
||||
const res = await this.rawFetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded", referer: this.base },
|
||||
body: new URLSearchParams({ username: this.cfg.username ?? "", password: this.cfg.password ?? "" }),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (res.status === 403) throw this.err("auth", "login forbidden (IP banned after failed attempts?)", endpoint, 403);
|
||||
if (res.status === 429) throw this.err("rate", "login rate limited", endpoint, 429);
|
||||
if (!res.ok)
|
||||
throw this.err(res.status >= 500 ? "protocol" : "auth", `login HTTP ${res.status}`, endpoint, res.status);
|
||||
if (!text.trim().startsWith("Ok")) throw this.err("auth", "login rejected (bad credentials)", endpoint, res.status);
|
||||
const cookie = res.headers
|
||||
.getSetCookie()
|
||||
.map((c) => /^SID=([^;]*)/.exec(c)?.[1])
|
||||
.find((v) => v !== undefined);
|
||||
// 关闭鉴权或白名单 IP 时 qB 可能不下发 cookie,仍视为成功
|
||||
this.sid = cookie ?? "";
|
||||
}
|
||||
|
||||
private async send(endpoint: string, opts: ReqOpts): Promise<Response> {
|
||||
const attempts = opts.idempotent ? this.retries + 1 : 1;
|
||||
let lastErr: DownloaderError | null = null;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
if (i > 0) await sleep(this.retryBaseMs * 2 ** (i - 1));
|
||||
try {
|
||||
return await this.sendOnce(endpoint, opts, false);
|
||||
} catch (e) {
|
||||
if (!(e instanceof DownloaderError)) throw e;
|
||||
lastErr = e;
|
||||
const retryable = e.kind === "network" || e.kind === "timeout" || e.kind === "rate" || e.kind === "protocol";
|
||||
if (!retryable) throw e;
|
||||
}
|
||||
}
|
||||
throw lastErr!;
|
||||
}
|
||||
|
||||
private async sendOnce(endpoint: string, opts: ReqOpts, relogged: boolean): Promise<Response> {
|
||||
if (!this.cfg.apiKey && this.sid === null) await this.login();
|
||||
const headers: Record<string, string> = { referer: this.base };
|
||||
if (this.cfg.apiKey) headers.authorization = `Bearer ${this.cfg.apiKey}`;
|
||||
else if (this.sid) headers.cookie = `SID=${this.sid}`;
|
||||
let body: RequestInit["body"] | undefined;
|
||||
if (opts.body?.kind === "form") {
|
||||
headers["content-type"] = "application/x-www-form-urlencoded";
|
||||
body = new URLSearchParams(opts.body.data);
|
||||
} else if (opts.body?.kind === "multipart") {
|
||||
body = opts.body.data;
|
||||
}
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(opts.query ?? {})) if (v !== undefined) qs.set(k, v);
|
||||
const path = qs.size ? `${endpoint}?${qs}` : endpoint;
|
||||
const res = await this.rawFetch(path, { method: body ? "POST" : "GET", headers, ...(body ? { body } : {}) });
|
||||
const s = res.status;
|
||||
if (s === 401 || s === 403) {
|
||||
await res.body?.cancel();
|
||||
if (!this.cfg.apiKey && !relogged) {
|
||||
this.sid = null;
|
||||
await this.login();
|
||||
return this.sendOnce(endpoint, opts, true);
|
||||
}
|
||||
throw this.err("auth", `unauthorized (HTTP ${s})`, endpoint, s);
|
||||
}
|
||||
if (s === 404) {
|
||||
const t = await res.text();
|
||||
throw this.err("notfound", t || "not found", endpoint, s);
|
||||
}
|
||||
if (s === 429) {
|
||||
await res.body?.cancel();
|
||||
throw this.err("rate", "rate limited", endpoint, s);
|
||||
}
|
||||
if (s === 400 || s === 409 || s === 415) {
|
||||
const t = await res.text();
|
||||
throw this.err("rejected", t || `HTTP ${s}`, endpoint, s);
|
||||
}
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel();
|
||||
throw this.err("protocol", `unexpected HTTP ${s}`, endpoint, s);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
private async json<T>(endpoint: string, query?: Record<string, string | undefined>): Promise<T> {
|
||||
const res = await this.send(endpoint, { idempotent: true, ...(query ? { query } : {}) });
|
||||
const text = await res.text();
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch (e) {
|
||||
throw this.err("protocol", "invalid JSON response", endpoint, res.status, e);
|
||||
}
|
||||
}
|
||||
|
||||
private async post(endpoint: string, data: Record<string, string>, idempotent = true): Promise<string> {
|
||||
const res = await this.send(endpoint, { idempotent, body: { kind: "form", data } });
|
||||
return res.text();
|
||||
}
|
||||
|
||||
async version(): Promise<{ app: string; webApi: string }> {
|
||||
const app = await (await this.send("app/version", { idempotent: true })).text();
|
||||
const webApi = await (await this.send("app/webapiVersion", { idempotent: true })).text();
|
||||
return { app: app.trim(), webApi: webApi.trim() };
|
||||
}
|
||||
|
||||
async add(req: AddTorrentRequest): Promise<AddTorrentResult> {
|
||||
const form = new FormData();
|
||||
const src = req.source;
|
||||
if (src.kind === "file") {
|
||||
form.append(
|
||||
"torrents",
|
||||
new Blob([new Uint8Array(src.bytes)], { type: "application/x-bittorrent" }),
|
||||
src.filename ?? "upload.torrent",
|
||||
);
|
||||
} else {
|
||||
form.append("urls", src.kind === "magnet" ? src.uri : src.url);
|
||||
}
|
||||
form.append("savepath", req.savePath);
|
||||
if (req.category !== undefined) form.append("category", req.category);
|
||||
if (req.tags?.length) form.append("tags", req.tags.join(","));
|
||||
const paused = req.paused ? "true" : "false";
|
||||
form.append("paused", paused); // <= 4.x
|
||||
form.append("stopped", paused); // >= 5.x
|
||||
if (req.rename) form.append("rename", req.rename);
|
||||
form.append("contentLayout", req.contentLayout ?? "Original");
|
||||
form.append("autoTMM", "false");
|
||||
const seq = req.sequential ?? this.cfg.sequentialDownload;
|
||||
const flp = req.firstLastPiecePrio ?? this.cfg.firstLastPiecePrio;
|
||||
if (seq !== undefined) form.append("sequentialDownload", String(seq));
|
||||
if (flp !== undefined) form.append("firstLastPiecePrio", String(flp));
|
||||
const res = await this.send("torrents/add", { idempotent: false, body: { kind: "multipart", data: form } });
|
||||
const raw = (await res.text()).trim();
|
||||
if (raw.startsWith("{")) {
|
||||
try {
|
||||
const j = JSON.parse(raw) as {
|
||||
success_count?: number;
|
||||
pending_count?: number;
|
||||
added_torrent_ids?: string[];
|
||||
};
|
||||
const ids = (j.added_torrent_ids ?? []).map((x) => x.toLowerCase());
|
||||
return { accepted: ids.length > 0 || (j.success_count ?? 0) + (j.pending_count ?? 0) > 0, addedIds: ids, raw };
|
||||
} catch {
|
||||
// 落到文本判定
|
||||
}
|
||||
}
|
||||
return { accepted: raw.startsWith("Ok"), addedIds: [], raw };
|
||||
}
|
||||
|
||||
async list(q: TorrentQuery = {}): Promise<QbTorrent[]> {
|
||||
const need = [...(q.tag !== undefined ? [q.tag] : []), ...(q.tags ?? [])];
|
||||
const rows = await this.json<Record<string, unknown>[]>("torrents/info", {
|
||||
filter: q.filter,
|
||||
category: q.category,
|
||||
tag: need[0],
|
||||
hashes: q.hashes?.length ? q.hashes.map((h) => h.toLowerCase()).join("|") : undefined,
|
||||
});
|
||||
if (!Array.isArray(rows)) throw this.err("protocol", "torrents/info did not return an array", "torrents/info");
|
||||
const list = rows.map((r) => normalizeTorrent(this.instanceId, r));
|
||||
return need.length > 1 ? list.filter((t) => need.every((tag) => t.tags.includes(tag))) : list;
|
||||
}
|
||||
|
||||
async getCompleted(q: Pick<TorrentQuery, "hashes" | "tags"> = {}): Promise<QbTorrent[]> {
|
||||
return (await this.list({ ...q, filter: "completed" })).filter((t) => t.complete);
|
||||
}
|
||||
|
||||
async getDownloading(q: Pick<TorrentQuery, "hashes" | "tags"> = {}): Promise<QbTorrent[]> {
|
||||
return this.list({ ...q, filter: "downloading" });
|
||||
}
|
||||
|
||||
/** 按 hash 精确查询,不受管理标签限制。查不到返回 null;下载器不可达抛错(不能当作不存在)。 */
|
||||
async findByHash(hash: string): Promise<QbTorrent | null> {
|
||||
const h = hash.toLowerCase();
|
||||
const rows = await this.list({ hashes: [h] });
|
||||
return rows.find((t) => t.hash === h || t.infohashV1 === h || t.infohashV2?.startsWith(h)) ?? null;
|
||||
}
|
||||
|
||||
/** 文件列表;空列表(磁力元数据未就绪)按 retries/intervalMs 重试(对照 get_files)。 */
|
||||
async files(hash: string, opts: { retries?: number; intervalMs?: number } = {}): Promise<QbFile[]> {
|
||||
const tries = Math.max(1, opts.retries ?? 1);
|
||||
let rows: Record<string, unknown>[] = [];
|
||||
for (let i = 0; i < tries; i++) {
|
||||
if (i > 0 && opts.intervalMs) await sleep(opts.intervalMs);
|
||||
rows = await this.json<Record<string, unknown>[]>("torrents/files", { hash: hash.toLowerCase() });
|
||||
if (rows.length) break;
|
||||
}
|
||||
return rows.map((r, i) => ({
|
||||
index: num(r.index) ?? i,
|
||||
name: String(r.name ?? ""),
|
||||
size: num(r.size) ?? 0,
|
||||
progress: num(r.progress) ?? 0,
|
||||
priority: num(r.priority) ?? 1,
|
||||
isSeed: typeof r.is_seed === "boolean" ? r.is_seed : null,
|
||||
}));
|
||||
}
|
||||
|
||||
private async pauseLike(hashes: string[], legacy: string, modern: string): Promise<void> {
|
||||
if (!hashes.length) return;
|
||||
const data = { hashes: hashes.join("|") };
|
||||
if (this.legacyPauseApi !== false) {
|
||||
try {
|
||||
await this.post(legacy, data);
|
||||
this.legacyPauseApi = true;
|
||||
return;
|
||||
} catch (e) {
|
||||
if (!(e instanceof DownloaderError && e.kind === "notfound" && this.legacyPauseApi === null)) throw e;
|
||||
this.legacyPauseApi = false;
|
||||
}
|
||||
}
|
||||
await this.post(modern, data);
|
||||
}
|
||||
|
||||
pause(hashes: string[]): Promise<void> {
|
||||
return this.pauseLike(hashes, "torrents/pause", "torrents/stop");
|
||||
}
|
||||
resume(hashes: string[]): Promise<void> {
|
||||
return this.pauseLike(hashes, "torrents/resume", "torrents/start");
|
||||
}
|
||||
|
||||
async addTags(hashes: string[], tags: string[]): Promise<void> {
|
||||
await this.post("torrents/addTags", { hashes: hashes.join("|"), tags: tags.join(",") });
|
||||
}
|
||||
async removeTags(hashes: string[], tags: string[]): Promise<void> {
|
||||
await this.post("torrents/removeTags", { hashes: hashes.join("|"), tags: tags.join(",") });
|
||||
}
|
||||
async deleteTagDefinitions(tags: string[]): Promise<void> {
|
||||
await this.post("torrents/deleteTags", { tags: tags.join(",") });
|
||||
}
|
||||
async deleteTorrentsTag(hashes: string[] | null, tags: string[]): Promise<void> {
|
||||
if (hashes?.length) await this.removeTags(hashes, tags);
|
||||
await this.deleteTagDefinitions(tags);
|
||||
}
|
||||
|
||||
async setFilePriority(hash: string, fileIds: number[], priority: 0 | 1 | 6 | 7): Promise<boolean> {
|
||||
if (!hash || !fileIds.length) return false;
|
||||
await this.post("torrents/filePrio", {
|
||||
hash: hash.toLowerCase(),
|
||||
id: fileIds.join("|"),
|
||||
priority: String(priority),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async setForceStart(hashes: string[], enable = true): Promise<void> {
|
||||
if (!hashes.length) return;
|
||||
await this.post("torrents/setForceStart", { hashes: hashes.join("|"), value: String(enable) });
|
||||
}
|
||||
|
||||
async recheck(hashes: string[]): Promise<void> {
|
||||
if (!hashes.length) return;
|
||||
await this.post("torrents/recheck", { hashes: hashes.join("|") });
|
||||
}
|
||||
|
||||
async setLocation(hashes: string[], location: string): Promise<void> {
|
||||
if (!hashes.length || !location) return;
|
||||
await this.post("torrents/setLocation", { hashes: hashes.join("|"), location });
|
||||
}
|
||||
|
||||
async setCategory(hashes: string[], category: string): Promise<void> {
|
||||
if (!hashes.length) return;
|
||||
await this.post("torrents/setCategory", { hashes: hashes.join("|"), category });
|
||||
}
|
||||
|
||||
/** 对照 change_torrent:限速单位 KB/s → B/s;只给 seedingTime 时 ratioLimit=-2(沿用全局)。 */
|
||||
async changeTorrent(hash: string, o: TorrentLimits): Promise<void> {
|
||||
const h = hash.toLowerCase();
|
||||
if (o.uploadLimitKBps !== undefined) {
|
||||
await this.post("torrents/setUploadLimit", { hashes: h, limit: String(Math.trunc(o.uploadLimitKBps * 1024)) });
|
||||
}
|
||||
if (o.downloadLimitKBps !== undefined) {
|
||||
await this.post("torrents/setDownloadLimit", {
|
||||
hashes: h,
|
||||
limit: String(Math.trunc(o.downloadLimitKBps * 1024)),
|
||||
});
|
||||
}
|
||||
if (o.ratioLimit !== undefined || o.seedingTimeLimitMin !== undefined) {
|
||||
await this.post("torrents/setShareLimits", {
|
||||
hashes: h,
|
||||
ratioLimit: o.ratioLimit !== undefined ? String(Math.round(o.ratioLimit * 100) / 100) : "-2",
|
||||
seedingTimeLimit: String(Math.trunc(o.seedingTimeLimitMin ?? -1)),
|
||||
inactiveSeedingTimeLimit: "-1",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async preferences(): Promise<Record<string, unknown>> {
|
||||
return this.json<Record<string, unknown>>("app/preferences");
|
||||
}
|
||||
|
||||
async contentLayout(): Promise<"Original" | "Subfolder" | "NoSubfolder"> {
|
||||
const v = (await this.preferences()).torrent_content_layout;
|
||||
return v === "Subfolder" || v === "NoSubfolder" ? v : "Original";
|
||||
}
|
||||
|
||||
async transferInfo(): Promise<Record<string, unknown>> {
|
||||
return this.json<Record<string, unknown>>("transfer/info");
|
||||
}
|
||||
|
||||
async delete(hashes: string[], opts: { deleteFiles: boolean }): Promise<void> {
|
||||
if (typeof opts?.deleteFiles !== "boolean") throw new TypeError("deleteFiles must be explicit");
|
||||
if (!hashes.length) return;
|
||||
await this.post("torrents/delete", { hashes: hashes.join("|"), deleteFiles: String(opts.deleteFiles) });
|
||||
}
|
||||
|
||||
async mainData(rid = 0): Promise<QbMainData> {
|
||||
const j = await this.json<Record<string, unknown>>("sync/maindata", { rid: String(rid) });
|
||||
return {
|
||||
rid: num(j.rid) ?? 0,
|
||||
fullUpdate: j.full_update === true,
|
||||
torrents: (j.torrents as Record<string, Record<string, unknown>>) ?? {},
|
||||
torrentsRemoved: (j.torrents_removed as string[]) ?? [],
|
||||
serverState: (j.server_state as Record<string, unknown>) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async pollHash(hash: string): Promise<QbTorrent | null> {
|
||||
for (let i = 0; i < this.lookup.attempts; i++) {
|
||||
if (i > 0) await sleep(this.lookup.intervalMs);
|
||||
try {
|
||||
const t = await this.findByHash(hash);
|
||||
if (t) return t;
|
||||
} catch (e) {
|
||||
if (!(e instanceof DownloaderError) || e.kind === "auth") throw e;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async pollTag(tag: string): Promise<QbTorrent | null> {
|
||||
for (let i = 0; i < this.lookup.attempts; i++) {
|
||||
if (i > 0) await sleep(this.lookup.intervalMs);
|
||||
try {
|
||||
const rows = await this.list({ tag });
|
||||
if (rows[0]) return rows[0];
|
||||
} catch (e) {
|
||||
if (!(e instanceof DownloaderError) || e.kind === "auth") throw e;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async dropProbe(tag: string, hash: string | null): Promise<void> {
|
||||
try {
|
||||
if (hash) await this.removeTags([hash], [tag]);
|
||||
await this.deleteTagDefinitions([tag]);
|
||||
} catch {
|
||||
// 残留探针标签由对账任务按前缀清理
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 幂等提交:本地算 infohash → 预查是否已存在 → add → 按 hash(或探针标签)确认。
|
||||
* add 超时/断连时不重提,先回查(REQ §8.4)。
|
||||
*/
|
||||
async submit(req: AddTorrentRequest): Promise<SubmitOutcome> {
|
||||
let hash: string | null = null;
|
||||
const src = req.source;
|
||||
if (src.kind === "file") {
|
||||
try {
|
||||
hash = parseTorrent(src.bytes).hash;
|
||||
} catch (e) {
|
||||
if (e instanceof BencodeError) return { kind: "rejected", reason: `invalid torrent: ${e.message}`, hash: null };
|
||||
throw e;
|
||||
}
|
||||
} else if (src.kind === "magnet") {
|
||||
const m = parseMagnet(src.uri);
|
||||
if (!m) return { kind: "rejected", reason: "invalid magnet uri", hash: null };
|
||||
hash = m.hash;
|
||||
} else {
|
||||
hash = parseMagnet(src.url)?.hash ?? null;
|
||||
}
|
||||
|
||||
if (hash) {
|
||||
try {
|
||||
const existing = await this.findByHash(hash);
|
||||
if (existing) return { kind: "already_exists", hash: existing.hash, existing };
|
||||
} catch (e) {
|
||||
if (e instanceof DownloaderError) return { kind: "unreachable", error: e, hash };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const probeTag = hash ? null : `${this.probePrefix}${Date.now().toString(36)}${randomBytes(5).toString("hex")}`;
|
||||
const tags = probeTag ? [...(req.tags ?? []), probeTag] : req.tags;
|
||||
let result: AddTorrentResult;
|
||||
try {
|
||||
result = await this.add({ ...req, ...(tags ? { tags } : {}) });
|
||||
} catch (e) {
|
||||
if (!(e instanceof DownloaderError)) throw e;
|
||||
if (e.ambiguous) {
|
||||
if (hash) {
|
||||
const t = await this.pollHash(hash).catch(() => null);
|
||||
if (t) return { kind: "added", hash: t.hash, via: "recovered_after_timeout" };
|
||||
return { kind: "unreachable", error: e, hash };
|
||||
}
|
||||
const t = await this.pollTag(probeTag!).catch(() => null);
|
||||
if (t) {
|
||||
await this.dropProbe(probeTag!, t.hash);
|
||||
return { kind: "added", hash: t.hash, via: "recovered_after_timeout" };
|
||||
}
|
||||
return { kind: "accepted_unconfirmed", hash: null, probeTag };
|
||||
}
|
||||
if (e.kind === "rejected") {
|
||||
if (hash) {
|
||||
const t = await this.findByHash(hash).catch(() => null);
|
||||
if (t) return { kind: "already_exists", hash: t.hash, existing: t };
|
||||
}
|
||||
if (probeTag) await this.dropProbe(probeTag, null);
|
||||
return { kind: "rejected", reason: e.message, hash };
|
||||
}
|
||||
return { kind: "unreachable", error: e, hash };
|
||||
}
|
||||
|
||||
if (!result.accepted) {
|
||||
// 4.x:已存在/非法链接都返回 Fails.;先查是否已存在
|
||||
if (hash) {
|
||||
const t = await this.findByHash(hash).catch(() => null);
|
||||
if (t) return { kind: "already_exists", hash: t.hash, existing: t };
|
||||
}
|
||||
if (probeTag) await this.dropProbe(probeTag, null);
|
||||
return { kind: "rejected", reason: `downloader refused: ${result.raw.slice(0, 200)}`, hash };
|
||||
}
|
||||
|
||||
const knownHash = hash ?? result.addedIds[0] ?? null;
|
||||
if (knownHash) {
|
||||
const t = await this.pollHash(knownHash).catch(() => null);
|
||||
if (probeTag) await this.dropProbe(probeTag, t?.hash ?? knownHash);
|
||||
if (t) return { kind: "added", hash: t.hash, via: hash ? "confirmed" : "added_ids" };
|
||||
return { kind: "accepted_unconfirmed", hash: knownHash, probeTag: null };
|
||||
}
|
||||
const t = await this.pollTag(probeTag!).catch(() => null);
|
||||
if (t) {
|
||||
await this.dropProbe(probeTag!, t.hash);
|
||||
return { kind: "added", hash: t.hash, via: "probe_tag" };
|
||||
}
|
||||
return { kind: "accepted_unconfirmed", hash: null, probeTag };
|
||||
}
|
||||
}
|
||||
|
||||
export function createQbClient(cfg: QbInstanceConfig): DownloaderClient & { login(): Promise<void> } {
|
||||
return new QbClient(cfg);
|
||||
}
|
||||
|
||||
/** 多实例注册表:按 instanceId 取客户端。 */
|
||||
export class DownloaderRegistry {
|
||||
private readonly clients = new Map<string, DownloaderClient>();
|
||||
constructor(configs: QbInstanceConfig[] = []) {
|
||||
for (const c of configs) this.register(c);
|
||||
}
|
||||
register(cfg: QbInstanceConfig): DownloaderClient {
|
||||
const c = new QbClient(cfg);
|
||||
this.clients.set(cfg.instanceId, c);
|
||||
return c;
|
||||
}
|
||||
get(instanceId: string): DownloaderClient | undefined {
|
||||
return this.clients.get(instanceId);
|
||||
}
|
||||
remove(instanceId: string): boolean {
|
||||
return this.clients.delete(instanceId);
|
||||
}
|
||||
list(): DownloaderClient[] {
|
||||
return [...this.clients.values()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/** 下载器错误分类。调用方据此决定重试/回查/告警,不解析 message。 */
|
||||
export type DownloaderErrorKind =
|
||||
| "auth" // 凭据错误、会话失效且重登失败、IP 被封禁
|
||||
| "network" // 连接失败/连接被重置:请求是否到达下载器未知
|
||||
| "timeout" // 超时:请求是否被执行未知(需按 infohash 回查)
|
||||
| "notfound" // 目标 hash 不存在
|
||||
| "rate" // 429 / 限流
|
||||
| "rejected" // 下载器明确拒绝(如 torrents/add 返回 Fails.、415 非法种子)
|
||||
| "protocol"; // 响应不符合预期(版本不兼容、5xx 等)
|
||||
|
||||
export class DownloaderError extends Error {
|
||||
override readonly name = "DownloaderError";
|
||||
constructor(
|
||||
readonly kind: DownloaderErrorKind,
|
||||
message: string,
|
||||
readonly detail: { instanceId: string; endpoint?: string; status?: number; cause?: unknown } = { instanceId: "" },
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
/** 该错误发生时,写操作是否可能已在下载器侧生效。 */
|
||||
get ambiguous(): boolean {
|
||||
return this.kind === "timeout" || this.kind === "network";
|
||||
}
|
||||
}
|
||||
|
||||
export function isDownloaderError(e: unknown): e is DownloaderError {
|
||||
return e instanceof DownloaderError;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./client";
|
||||
export * from "./errors";
|
||||
export * from "./paths";
|
||||
export * from "./state";
|
||||
export * from "./torrent";
|
||||
@@ -0,0 +1,31 @@
|
||||
// 迁移来源:MoviePilot app/modules/__init__.py normalize_path / normalize_return_path(Q27,e6820a8)。
|
||||
/** 下载器视角路径 ↔ 本机存储路径的前缀映射,首条命中即停(对照 MP normalize_path)。 */
|
||||
export interface PathMapping {
|
||||
/** 本机(本服务进程)看到的路径前缀 */
|
||||
local: string;
|
||||
/** 下载器看到的路径前缀 */
|
||||
downloader: string;
|
||||
}
|
||||
|
||||
function swap(p: string, from: string, to: string): string | null {
|
||||
const f = from.replace(/\/+$/, "");
|
||||
if (p === f) return to.replace(/\/+$/, "") || "/";
|
||||
if (p.startsWith(`${f}/`)) return `${to.replace(/\/+$/, "")}${p.slice(f.length)}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function toDownloaderPath(p: string, map: PathMapping[]): string {
|
||||
for (const m of map) {
|
||||
const r = swap(p, m.local, m.downloader);
|
||||
if (r !== null) return r;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
export function fromDownloaderPath(p: string, map: PathMapping[]): string {
|
||||
for (const m of map) {
|
||||
const r = swap(p, m.downloader, m.local);
|
||||
if (r !== null) return r;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import { createMockQb, type MockQb, makeMagnet, makeTorrent } from "@umt/testkit";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createQbClient, DownloaderError, isDownloadComplete, mapQbState, parseMagnet, parseTorrent } from "./index";
|
||||
import { fromDownloaderPath, toDownloaderPath } from "./paths";
|
||||
|
||||
let mock: MockQb | null = null;
|
||||
afterEach(async () => {
|
||||
await mock?.close();
|
||||
mock = null;
|
||||
});
|
||||
|
||||
const fast = { retries: 1, retryBaseMs: 5, lookup: { attempts: 3, intervalMs: 10 }, timeoutMs: 500 };
|
||||
|
||||
async function setup(opts: Parameters<typeof createMockQb>[0] = {}) {
|
||||
mock = await createMockQb(opts);
|
||||
const qb = createQbClient({
|
||||
instanceId: "qb-1",
|
||||
baseUrl: mock.url,
|
||||
username: "admin",
|
||||
password: "adminadmin",
|
||||
...fast,
|
||||
});
|
||||
return { mock, qb };
|
||||
}
|
||||
|
||||
const movie = () =>
|
||||
makeTorrent({ name: "Oppenheimer.2023.2160p.UHD.BluRay.REMUX.HDR.HEVC.DTS-HD.MA.5.1-FGT.mkv", length: 80e9 });
|
||||
|
||||
describe("torrent / magnet 解析", () => {
|
||||
it("本地 infohash 与独立实现一致,多文件列表完整", () => {
|
||||
const t = makeTorrent({
|
||||
name: "The.Bear.S03.1080p",
|
||||
files: [
|
||||
{ path: "E01.mkv", length: 100 },
|
||||
{ path: "Subs/E01.ass", length: 5 },
|
||||
],
|
||||
});
|
||||
const meta = parseTorrent(t.bytes);
|
||||
expect(meta.hash).toBe(t.hash);
|
||||
expect(meta.infohashV1).toBe(t.hash);
|
||||
expect(meta.totalSize).toBe(105);
|
||||
expect(meta.files.map((f) => f.path)).toEqual(["The.Bear.S03.1080p/E01.mkv", "The.Bear.S03.1080p/Subs/E01.ass"]);
|
||||
expect(() => parseTorrent(new TextEncoder().encode("not bencode"))).toThrow();
|
||||
});
|
||||
|
||||
it("磁力 btih hex/base32 都能取出 hash", () => {
|
||||
const hex = "3f5a0000000000000000000000000000000000c9";
|
||||
expect(parseMagnet(makeMagnet(hex, "x"))?.hash).toBe(hex);
|
||||
// base32 of 20 zero bytes = 32 个 A
|
||||
expect(parseMagnet(`magnet:?xt=urn:btih:${"A".repeat(32)}`)?.hash).toBe("0".repeat(40));
|
||||
expect(parseMagnet("http://example.com/a.torrent")).toBeNull();
|
||||
});
|
||||
|
||||
it("状态映射不兜底为完成", () => {
|
||||
expect(mapQbState("stalledDL")).toBe("stalled");
|
||||
expect(mapQbState("pausedUP")).toBe("completed_paused");
|
||||
expect(mapQbState("missingFiles")).toBe("missing_files");
|
||||
expect(mapQbState("whatever")).toBe("unknown");
|
||||
expect(isDownloadComplete({ progress: 1, amountLeft: 0, state: "error" })).toBe(false);
|
||||
expect(isDownloadComplete({ progress: 1, amountLeft: 0, state: "checkingUP" })).toBe(false);
|
||||
expect(isDownloadComplete({ progress: 1, amountLeft: 0, state: "forcedUP" })).toBe(true);
|
||||
expect(isDownloadComplete({ progress: 1, amountLeft: 0, state: "brandNewState" })).toBe(false);
|
||||
});
|
||||
|
||||
it("路径映射首条命中", () => {
|
||||
const map = [{ local: "/mnt/cache/downloads", downloader: "/downloads" }];
|
||||
expect(toDownloaderPath("/mnt/cache/downloads/pt/a.mkv", map)).toBe("/downloads/pt/a.mkv");
|
||||
expect(fromDownloaderPath("/downloads/pt/a.mkv", map)).toBe("/mnt/cache/downloads/pt/a.mkv");
|
||||
expect(fromDownloaderPath("/downloadsX/a.mkv", map)).toBe("/downloadsX/a.mkv");
|
||||
});
|
||||
});
|
||||
|
||||
describe("QbClient 鉴权与错误分类", () => {
|
||||
it("登录后读版本,会话复用只登录一次", async () => {
|
||||
const { mock, qb } = await setup();
|
||||
expect(await qb.version()).toEqual({ app: "v4.6.7", webApi: "2.9.3" });
|
||||
await qb.list();
|
||||
expect(mock.callsTo("auth/login")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("错误凭据 → auth", async () => {
|
||||
mock = await createMockQb();
|
||||
const qb = createQbClient({ instanceId: "qb-x", baseUrl: mock.url, username: "admin", password: "bad", ...fast });
|
||||
const e = await qb.version().catch((x) => x);
|
||||
expect(e).toBeInstanceOf(DownloaderError);
|
||||
expect(e.kind).toBe("auth");
|
||||
});
|
||||
|
||||
it("会话过期 403 → 自动重登一次后成功", async () => {
|
||||
const { mock, qb } = await setup();
|
||||
await qb.list();
|
||||
mock.expireSessions();
|
||||
await expect(qb.list()).resolves.toEqual([]);
|
||||
expect(mock.callsTo("auth/login")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("5.x API Key(Bearer)无需登录", async () => {
|
||||
mock = await createMockQb({ version: "5.1.0", apiKey: "k-123" });
|
||||
const qb = createQbClient({ instanceId: "qb-5", baseUrl: mock.url, apiKey: "k-123", ...fast });
|
||||
expect((await qb.version()).webApi).toBe("2.11.4");
|
||||
expect(mock.callsTo("auth/login")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("连接失败 → network;超时 → timeout;429 → rate;5xx 重试后成功", async () => {
|
||||
const { mock: m, qb } = await setup();
|
||||
await qb.list();
|
||||
m.fault("torrents/info", { status: 429, times: 5 });
|
||||
expect((await qb.list().catch((e) => e)).kind).toBe("rate");
|
||||
m.clearFaults();
|
||||
m.fault("torrents/info", { status: 502 });
|
||||
await expect(qb.list()).resolves.toEqual([]);
|
||||
m.fault("torrents/info", { delayMs: 800, times: 5 });
|
||||
expect((await qb.list().catch((e) => e)).kind).toBe("timeout");
|
||||
m.clearFaults();
|
||||
const url = m.url;
|
||||
await m.close();
|
||||
mock = null;
|
||||
const dead = createQbClient({ instanceId: "dead", baseUrl: url, username: "a", password: "b", ...fast });
|
||||
const e = await dead.list().catch((x) => x);
|
||||
expect(e.kind).toBe("network");
|
||||
expect(e.ambiguous).toBe(true);
|
||||
});
|
||||
|
||||
it("files 对不存在的 hash → notfound;findByHash 返回 null", async () => {
|
||||
const { qb } = await setup();
|
||||
expect(await qb.findByHash("0".repeat(40))).toBeNull();
|
||||
expect((await qb.files("0".repeat(40)).catch((e) => e)).kind).toBe("notfound");
|
||||
});
|
||||
});
|
||||
|
||||
describe("QbClient 提交与回查", () => {
|
||||
it("4.x .torrent 提交:参数透传,本地 hash 确认", async () => {
|
||||
const { mock, qb } = await setup();
|
||||
const t = movie();
|
||||
const out = await qb.submit({
|
||||
source: { kind: "file", bytes: t.bytes, filename: "a.torrent" },
|
||||
savePath: "/downloads/uhd",
|
||||
category: "movies",
|
||||
tags: ["uhd-managed"],
|
||||
paused: true,
|
||||
});
|
||||
expect(out).toEqual({ kind: "added", hash: t.hash, via: "confirmed" });
|
||||
const m = mock.get(t.hash)!;
|
||||
expect(m.save_path).toBe("/downloads/uhd");
|
||||
expect(m.category).toBe("movies");
|
||||
expect(m.tags).toEqual(["uhd-managed"]);
|
||||
expect(m.state).toBe("pausedDL");
|
||||
});
|
||||
|
||||
it("5.x 磁力提交:JSON 响应,added_torrent_ids 生效", async () => {
|
||||
const { mock, qb } = await setup({ version: "5.1.0" });
|
||||
const hash = "ab".repeat(20);
|
||||
const out = await qb.submit({ source: { kind: "magnet", uri: makeMagnet(hash, "Dune") }, savePath: "/d" });
|
||||
expect(out).toMatchObject({ kind: "added", hash });
|
||||
expect(mock.callsTo("torrents/add")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("hash 未知(URL):探针标签轮询找回,用后删除", async () => {
|
||||
const { mock, qb } = await setup({ addVisibleAfterPolls: 1 });
|
||||
const hash = "cd".repeat(20);
|
||||
// mock 能从 URL 中取出 btih;adapter 视其为普通 URL(不是 magnet: 开头)
|
||||
const out = await qb.submit({
|
||||
source: { kind: "url", url: `https://site.invalid/dl?x=1&xt=urn:btih:${hash}` },
|
||||
savePath: "/d",
|
||||
tags: ["uhd-managed"],
|
||||
});
|
||||
expect(out).toEqual({ kind: "added", hash, via: "probe_tag" });
|
||||
expect(mock.get(hash)!.tags).toEqual(["uhd-managed"]);
|
||||
expect(mock.tagDefinitions().some((t) => t.startsWith("uhd-probe-"))).toBe(false);
|
||||
});
|
||||
|
||||
it("已存在同 infohash:不重复提交、不改其路径/标签", async () => {
|
||||
const { mock, qb } = await setup();
|
||||
const t = movie();
|
||||
mock.seed({ hash: t.hash, name: t.name, save_path: "/downloads/pt", tags: ["BT", "keep"] });
|
||||
const out = await qb.submit({ source: { kind: "file", bytes: t.bytes }, savePath: "/x", tags: ["uhd"] });
|
||||
expect(out.kind).toBe("already_exists");
|
||||
if (out.kind === "already_exists") expect(out.existing.tags).toEqual(["BT", "keep"]);
|
||||
expect(mock.callsTo("torrents/add")).toHaveLength(0);
|
||||
expect(mock.get(t.hash)!.save_path).toBe("/downloads/pt");
|
||||
});
|
||||
|
||||
it("同名同大小但 infohash 不同:不视为已存在", async () => {
|
||||
const { mock, qb } = await setup();
|
||||
const a = makeTorrent({ name: "The.Bear.S03.1080p.DSNP.WEB-DL", length: 5000, pieceSeed: 1 });
|
||||
const b = makeTorrent({ name: "The.Bear.S03.1080p.DSNP.WEB-DL", length: 5000, pieceSeed: 2 });
|
||||
expect(a.hash).not.toBe(b.hash);
|
||||
mock.seed({ hash: a.hash, name: a.name, size: 5000 });
|
||||
const out = await qb.submit({ source: { kind: "file", bytes: b.bytes }, savePath: "/d" });
|
||||
expect(out).toMatchObject({ kind: "added", hash: b.hash });
|
||||
});
|
||||
|
||||
it("add 响应丢失(已收单):按 infohash 回查找回,不重提", async () => {
|
||||
const { mock, qb } = await setup();
|
||||
await qb.list();
|
||||
const t = movie();
|
||||
mock.fault("torrents/add", { dropAfterApply: true });
|
||||
const out = await qb.submit({ source: { kind: "file", bytes: t.bytes }, savePath: "/d" });
|
||||
expect(out).toEqual({ kind: "added", hash: t.hash, via: "recovered_after_timeout" });
|
||||
expect(mock.callsTo("torrents/add")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("add 超时但已执行:回查命中", async () => {
|
||||
const { mock, qb } = await setup();
|
||||
await qb.list();
|
||||
const hash = "ef".repeat(20);
|
||||
mock.fault("torrents/add", { delayMs: 900 });
|
||||
const out = await qb.submit({ source: { kind: "magnet", uri: makeMagnet(hash) }, savePath: "/d" });
|
||||
expect(out).toEqual({ kind: "added", hash, via: "recovered_after_timeout" });
|
||||
});
|
||||
|
||||
it("add 连接断开且未执行:unreachable 并带 hash 供幂等重试", async () => {
|
||||
const { mock, qb } = await setup();
|
||||
await qb.list();
|
||||
const hash = "12".repeat(20);
|
||||
mock.fault("torrents/add", { drop: true });
|
||||
const out = await qb.submit({ source: { kind: "magnet", uri: makeMagnet(hash) }, savePath: "/d" });
|
||||
expect(out.kind).toBe("unreachable");
|
||||
if (out.kind === "unreachable") {
|
||||
expect(out.hash).toBe(hash);
|
||||
expect(out.error.kind).toBe("network");
|
||||
}
|
||||
expect(mock.get(hash)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("非法种子 → rejected,不发请求", async () => {
|
||||
const { mock, qb } = await setup();
|
||||
const out = await qb.submit({ source: { kind: "file", bytes: new Uint8Array([1, 2, 3]) }, savePath: "/d" });
|
||||
expect(out.kind).toBe("rejected");
|
||||
expect(mock.callsTo("torrents/add")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("QbClient 查询与操作", () => {
|
||||
it("按 tag 查询、进度推进、完成/出错/缺文件状态", async () => {
|
||||
const { mock, qb } = await setup();
|
||||
mock.seed({ hash: "a".repeat(40), name: "A", tags: ["uhd"] });
|
||||
mock.seed({ hash: "b".repeat(40), name: "B" });
|
||||
expect((await qb.list({ tag: "uhd" })).map((t) => t.name)).toEqual(["A"]);
|
||||
mock.setProgress("a".repeat(40), 0.5);
|
||||
let a = (await qb.findByHash("a".repeat(40)))!;
|
||||
expect(a.state).toBe("downloading");
|
||||
expect(a.complete).toBe(false);
|
||||
mock.complete("a".repeat(40));
|
||||
a = (await qb.findByHash("a".repeat(40)))!;
|
||||
expect(a.state).toBe("seeding");
|
||||
expect(a.complete).toBe(true);
|
||||
mock.setError("a".repeat(40));
|
||||
expect((await qb.findByHash("a".repeat(40)))!.complete).toBe(false);
|
||||
mock.setMissingFiles("b".repeat(40));
|
||||
expect((await qb.findByHash("b".repeat(40)))!.state).toBe("missing_files");
|
||||
});
|
||||
|
||||
it("文件列表、标签增删、删除必须显式 deleteFiles", async () => {
|
||||
const { mock, qb } = await setup();
|
||||
const t = makeTorrent({
|
||||
name: "Show.S01",
|
||||
files: [
|
||||
{ path: "E01.mkv", length: 10 },
|
||||
{ path: "E02.mkv", length: 10 },
|
||||
],
|
||||
});
|
||||
await qb.submit({ source: { kind: "file", bytes: t.bytes }, savePath: "/d" });
|
||||
expect((await qb.files(t.hash)).map((f) => f.name)).toEqual(["Show.S01/E01.mkv", "Show.S01/E02.mkv"]);
|
||||
await qb.addTags([t.hash], ["uhd", "x"]);
|
||||
await qb.removeTags([t.hash], ["x"]);
|
||||
expect(mock.get(t.hash)!.tags).toEqual(["uhd"]);
|
||||
// @ts-expect-error deleteFiles 缺失应被拒绝
|
||||
await expect(qb.delete([t.hash], {})).rejects.toThrow(TypeError);
|
||||
await qb.delete([t.hash], { deleteFiles: false });
|
||||
expect(mock.callsTo("torrents/delete")[0]!.form.deleteFiles).toBe("false");
|
||||
expect(mock.get(t.hash)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("5.x 暂停/恢复自动切换到 stop/start,maindata 可读", async () => {
|
||||
const { mock, qb } = await setup({ version: "5.1.0" });
|
||||
mock.seed({ hash: "c".repeat(40), name: "C" });
|
||||
await qb.pause(["c".repeat(40)]);
|
||||
expect(mock.get("c".repeat(40))!.state).toBe("stoppedDL");
|
||||
await qb.resume(["c".repeat(40)]);
|
||||
expect(mock.get("c".repeat(40))!.state).toBe("downloading");
|
||||
expect(mock.callsTo("torrents/pause")).toHaveLength(1);
|
||||
expect(mock.callsTo("torrents/resume")).toHaveLength(0);
|
||||
const md = await qb.mainData();
|
||||
expect(Object.keys(md.torrents)).toEqual(["c".repeat(40)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("QbClient 对照 MP 的补充操作", () => {
|
||||
it("选文件(set_files)、强制开始、重新校验、改路径/分类、限速与分享限制", async () => {
|
||||
const { mock, qb } = await setup();
|
||||
const t = makeTorrent({
|
||||
name: "The.Bear.S03",
|
||||
files: [
|
||||
{ path: "E01.mkv", length: 10 },
|
||||
{ path: "E02.mkv", length: 10 },
|
||||
{ path: "E03.mkv", length: 10 },
|
||||
],
|
||||
});
|
||||
await qb.submit({ source: { kind: "file", bytes: t.bytes }, savePath: "/d", paused: true });
|
||||
expect(await qb.setFilePriority(t.hash, [], 0)).toBe(false);
|
||||
expect(await qb.setFilePriority(t.hash, [0, 2], 0)).toBe(true);
|
||||
expect((await qb.files(t.hash)).map((f) => f.priority)).toEqual([0, 1, 0]);
|
||||
await qb.setForceStart([t.hash]);
|
||||
expect(mock.get(t.hash)!.force_start).toBe(true);
|
||||
await qb.recheck([t.hash]);
|
||||
expect(mock.get(t.hash)!.state).toBe("checkingDL");
|
||||
await qb.setLocation([t.hash], "/downloads/tv");
|
||||
await qb.setCategory([t.hash], "tv");
|
||||
expect(mock.get(t.hash)).toMatchObject({ save_path: "/downloads/tv", category: "tv" });
|
||||
await qb.changeTorrent(t.hash, { uploadLimitKBps: 100, seedingTimeLimitMin: 4320 });
|
||||
expect(mock.get(t.hash)).toMatchObject({ up_limit: 102400, ratio_limit: -2, seeding_time_limit: 4320 });
|
||||
});
|
||||
|
||||
it("getCompleted 严格判定;多标签子集过滤;只读偏好与内容布局", async () => {
|
||||
const { mock, qb } = await setup({ preferences: { torrent_content_layout: "Subfolder" } });
|
||||
mock.seed({ hash: "1".repeat(40), name: "done", tags: ["uhd", "movie"], progress: 1 });
|
||||
mock.seed({ hash: "2".repeat(40), name: "err", tags: ["uhd"], progress: 1 });
|
||||
mock.setError("2".repeat(40));
|
||||
mock.seed({ hash: "3".repeat(40), name: "dl", tags: ["uhd"], progress: 0.2 });
|
||||
expect((await qb.getCompleted({ tags: ["uhd"] })).map((t) => t.name)).toEqual(["done"]);
|
||||
expect((await qb.getDownloading()).map((t) => t.name)).toEqual(["dl"]);
|
||||
expect((await qb.list({ tags: ["uhd", "movie"] })).map((t) => t.name)).toEqual(["done"]);
|
||||
expect(await qb.contentLayout()).toBe("Subfolder");
|
||||
expect(mock.callsTo("app/setPreferences")).toHaveLength(0);
|
||||
await qb.deleteTorrentsTag(["1".repeat(40)], ["movie"]);
|
||||
expect(mock.tagDefinitions()).not.toContain("movie");
|
||||
expect((await qb.transferInfo()).connection_status).toBe("connected");
|
||||
});
|
||||
|
||||
it("磁力元数据未就绪:files 空列表按次数重试", async () => {
|
||||
const { mock, qb } = await setup();
|
||||
const h = "9".repeat(40);
|
||||
mock.seed({ hash: h, name: "meta", files: [] });
|
||||
expect(await qb.files(h, { retries: 3, intervalMs: 5 })).toEqual([]);
|
||||
expect(mock.callsTo("torrents/files")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
// 迁移来源:MoviePilot app/modules/qbittorrent/qbittorrent.py get_completed_torrents(Q6)与 __init__.py __normalize_torrent_state(Q23),e6820a8。
|
||||
/**
|
||||
* qB 原始 state → 观测态。与 MoviePilot 不同:未识别/error/missingFiles 绝不兜底为完成
|
||||
* (见 docs/porting/moviepilot-downloader-and-sites.md §1.4、QA5)。
|
||||
*/
|
||||
export type QbObservedState =
|
||||
| "downloading"
|
||||
| "stalled"
|
||||
| "paused"
|
||||
| "completed_paused"
|
||||
| "seeding"
|
||||
| "checking"
|
||||
| "moving"
|
||||
| "error"
|
||||
| "missing_files"
|
||||
| "queued"
|
||||
| "metadata"
|
||||
| "unknown";
|
||||
|
||||
const MAP: Record<string, QbObservedState> = {
|
||||
downloading: "downloading",
|
||||
forceddl: "downloading",
|
||||
allocating: "downloading",
|
||||
checkingresumedata: "checking",
|
||||
checkingdl: "checking",
|
||||
checkingup: "checking",
|
||||
metadl: "metadata",
|
||||
forcedmetadl: "metadata",
|
||||
queueddl: "queued",
|
||||
stalleddl: "stalled",
|
||||
pauseddl: "paused",
|
||||
stoppeddl: "paused",
|
||||
pausedup: "completed_paused",
|
||||
stoppedup: "completed_paused",
|
||||
uploading: "seeding",
|
||||
stalledup: "seeding",
|
||||
forcedup: "seeding",
|
||||
queuedup: "seeding",
|
||||
moving: "moving",
|
||||
error: "error",
|
||||
missingfiles: "missing_files",
|
||||
};
|
||||
|
||||
export function mapQbState(raw: string | null | undefined): QbObservedState {
|
||||
if (!raw) return "unknown";
|
||||
return MAP[raw.toLowerCase()] ?? "unknown";
|
||||
}
|
||||
|
||||
const NOT_DONE = new Set([
|
||||
"allocating",
|
||||
"checkingdl",
|
||||
"checkingup",
|
||||
"checkingresumedata",
|
||||
"downloading",
|
||||
"error",
|
||||
"forceddl",
|
||||
"missingfiles",
|
||||
"metadl",
|
||||
"forcedmetadl",
|
||||
"moving",
|
||||
"queueddl",
|
||||
"stalleddl",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
/** 严格完成判定(QK4):progress>=1 && amountLeft<=0 && state 属于已知终态。 */
|
||||
export function isDownloadComplete(t: { progress: number; amountLeft: number; state: string }): boolean {
|
||||
const s = t.state.toLowerCase();
|
||||
if (!(s in MAP) || NOT_DONE.has(s)) return false;
|
||||
return t.progress >= 1 && t.amountLeft <= 0;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// 迁移来源:MoviePilot app/modules/_base/downloader.py _get_torrent_info(Q26,e6820a8);本地 infohash 计算为本项目新增(QK2)。
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/**
|
||||
* 最小 bencode 解码 + infohash 计算。
|
||||
* v1 infohash = sha1(info 原始字节);v2 = sha256(info 原始字节)(仅 meta version 2)。
|
||||
* 直接对原始字节切片求哈希,不做 decode→encode 往返,避免非规范编码导致 hash 漂移。
|
||||
*/
|
||||
export type BencodeValue = number | Uint8Array | BencodeValue[] | { [k: string]: BencodeValue };
|
||||
|
||||
export class BencodeError extends Error {
|
||||
override readonly name = "BencodeError";
|
||||
}
|
||||
|
||||
interface DecodeState {
|
||||
buf: Uint8Array;
|
||||
pos: number;
|
||||
infoStart: number;
|
||||
infoEnd: number;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
const C_I = 0x69;
|
||||
const C_L = 0x6c;
|
||||
const C_D = 0x64;
|
||||
const C_E = 0x65;
|
||||
const C_COLON = 0x3a;
|
||||
|
||||
function readInt(s: DecodeState, end: number): number {
|
||||
let str = "";
|
||||
while (s.pos < s.buf.length && s.buf[s.pos] !== end) {
|
||||
str += String.fromCharCode(s.buf[s.pos]!);
|
||||
s.pos++;
|
||||
}
|
||||
if (s.pos >= s.buf.length) throw new BencodeError("unexpected end of input");
|
||||
s.pos++;
|
||||
if (!/^-?\d+$/.test(str)) throw new BencodeError(`invalid integer: ${str}`);
|
||||
const n = Number(str);
|
||||
if (!Number.isSafeInteger(n)) throw new BencodeError(`integer out of range: ${str}`);
|
||||
return n;
|
||||
}
|
||||
|
||||
function decodeValue(s: DecodeState): BencodeValue {
|
||||
if (s.depth > 64) throw new BencodeError("nesting too deep");
|
||||
const c = s.buf[s.pos];
|
||||
if (c === undefined) throw new BencodeError("unexpected end of input");
|
||||
if (c === C_I) {
|
||||
s.pos++;
|
||||
return readInt(s, C_E);
|
||||
}
|
||||
if (c === C_L) {
|
||||
s.pos++;
|
||||
s.depth++;
|
||||
const out: BencodeValue[] = [];
|
||||
while (s.buf[s.pos] !== C_E) {
|
||||
if (s.pos >= s.buf.length) throw new BencodeError("unterminated list");
|
||||
out.push(decodeValue(s));
|
||||
}
|
||||
s.pos++;
|
||||
s.depth--;
|
||||
return out;
|
||||
}
|
||||
if (c === C_D) {
|
||||
s.pos++;
|
||||
s.depth++;
|
||||
const out: { [k: string]: BencodeValue } = {};
|
||||
while (s.buf[s.pos] !== C_E) {
|
||||
if (s.pos >= s.buf.length) throw new BencodeError("unterminated dict");
|
||||
const key = Buffer.from(decodeBytes(s)).toString("utf8");
|
||||
const start = s.pos;
|
||||
out[key] = decodeValue(s);
|
||||
if (key === "info" && s.depth === 1) {
|
||||
s.infoStart = start;
|
||||
s.infoEnd = s.pos;
|
||||
}
|
||||
}
|
||||
s.pos++;
|
||||
s.depth--;
|
||||
return out;
|
||||
}
|
||||
return decodeBytes(s);
|
||||
}
|
||||
|
||||
function decodeBytes(s: DecodeState): Uint8Array {
|
||||
const len = readInt(s, C_COLON);
|
||||
if (len < 0 || s.pos + len > s.buf.length) throw new BencodeError("byte string out of bounds");
|
||||
const out = s.buf.subarray(s.pos, s.pos + len);
|
||||
s.pos += len;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function bdecode(buf: Uint8Array): BencodeValue {
|
||||
const s: DecodeState = { buf, pos: 0, infoStart: -1, infoEnd: -1, depth: 0 };
|
||||
const v = decodeValue(s);
|
||||
if (s.pos !== buf.length) throw new BencodeError("trailing bytes after root value");
|
||||
return v;
|
||||
}
|
||||
|
||||
export interface TorrentFileEntry {
|
||||
path: string;
|
||||
length: number;
|
||||
}
|
||||
|
||||
export interface TorrentMeta {
|
||||
/** qB 用作任务 ID 的 hash:v1 优先,纯 v2 时为 v2 前 40 位 */
|
||||
hash: string;
|
||||
infohashV1: string | null;
|
||||
infohashV2: string | null;
|
||||
name: string;
|
||||
totalSize: number;
|
||||
files: TorrentFileEntry[];
|
||||
private: boolean;
|
||||
}
|
||||
|
||||
function asBytes(v: BencodeValue | undefined): Uint8Array | undefined {
|
||||
return v instanceof Uint8Array ? v : undefined;
|
||||
}
|
||||
function asStr(v: BencodeValue | undefined): string | undefined {
|
||||
const b = asBytes(v);
|
||||
return b ? Buffer.from(b).toString("utf8") : undefined;
|
||||
}
|
||||
function asDict(v: BencodeValue | undefined): { [k: string]: BencodeValue } | undefined {
|
||||
return v && typeof v === "object" && !(v instanceof Uint8Array) && !Array.isArray(v) ? v : undefined;
|
||||
}
|
||||
|
||||
function walkV2Tree(node: { [k: string]: BencodeValue }, prefix: string[], out: TorrentFileEntry[]): void {
|
||||
for (const [k, v] of Object.entries(node)) {
|
||||
const d = asDict(v);
|
||||
if (!d) continue;
|
||||
if (k === "") {
|
||||
const len = d.length;
|
||||
if (typeof len === "number") out.push({ path: prefix.join("/"), length: len });
|
||||
} else {
|
||||
walkV2Tree(d, [...prefix, k], out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 .torrent 字节;非法内容抛 BencodeError。 */
|
||||
export function parseTorrent(bytes: Uint8Array): TorrentMeta {
|
||||
const s: DecodeState = { buf: bytes, pos: 0, infoStart: -1, infoEnd: -1, depth: 0 };
|
||||
const root = asDict(decodeValue(s));
|
||||
if (!root || s.infoStart < 0) throw new BencodeError("missing info dictionary");
|
||||
const info = asDict(root.info);
|
||||
if (!info) throw new BencodeError("info is not a dictionary");
|
||||
const raw = bytes.subarray(s.infoStart, s.infoEnd);
|
||||
const isV2 = info["meta version"] === 2;
|
||||
const hasV1 = asBytes(info.pieces) !== undefined;
|
||||
const infohashV1 = hasV1 ? createHash("sha1").update(raw).digest("hex") : null;
|
||||
const infohashV2 = isV2 ? createHash("sha256").update(raw).digest("hex") : null;
|
||||
if (!infohashV1 && !infohashV2) throw new BencodeError("neither v1 pieces nor v2 meta version present");
|
||||
const name = asStr(info["name.utf-8"]) ?? asStr(info.name) ?? "";
|
||||
const files: TorrentFileEntry[] = [];
|
||||
const list = info.files;
|
||||
if (Array.isArray(list)) {
|
||||
for (const f of list) {
|
||||
const fd = asDict(f);
|
||||
if (!fd) continue;
|
||||
const parts = (Array.isArray(fd["path.utf-8"]) ? fd["path.utf-8"] : fd.path) as BencodeValue[] | undefined;
|
||||
const segs = (parts ?? []).map((p) => asStr(p) ?? "");
|
||||
if (fd.attr && asStr(fd.attr)?.includes("p")) continue; // BEP47 padding
|
||||
files.push({ path: [name, ...segs].join("/"), length: typeof fd.length === "number" ? fd.length : 0 });
|
||||
}
|
||||
} else if (typeof info.length === "number") {
|
||||
files.push({ path: name, length: info.length });
|
||||
} else if (isV2 && asDict(info["file tree"])) {
|
||||
walkV2Tree(asDict(info["file tree"])!, [name], files);
|
||||
}
|
||||
return {
|
||||
hash: infohashV1 ?? infohashV2!.slice(0, 40),
|
||||
infohashV1,
|
||||
infohashV2,
|
||||
name,
|
||||
totalSize: files.reduce((a, f) => a + f.length, 0),
|
||||
files,
|
||||
private: info.private === 1,
|
||||
};
|
||||
}
|
||||
|
||||
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
function base32ToHex(s: string): string | null {
|
||||
let bits = "";
|
||||
for (const ch of s.toUpperCase()) {
|
||||
const i = B32.indexOf(ch);
|
||||
if (i < 0) return null;
|
||||
bits += i.toString(2).padStart(5, "0");
|
||||
}
|
||||
let hex = "";
|
||||
for (let i = 0; i + 4 <= bits.length; i += 4) hex += Number.parseInt(bits.slice(i, i + 4), 2).toString(16);
|
||||
return hex.slice(0, 40);
|
||||
}
|
||||
|
||||
export interface MagnetMeta {
|
||||
hash: string;
|
||||
infohashV1: string | null;
|
||||
infohashV2: string | null;
|
||||
displayName: string | null;
|
||||
}
|
||||
|
||||
/** 从磁力链接取 infohash(btih hex/base32,btmh sha256 multihash)。不是合法磁力返回 null。 */
|
||||
export function parseMagnet(uri: string): MagnetMeta | null {
|
||||
if (!uri.toLowerCase().startsWith("magnet:?")) return null;
|
||||
const params = new URLSearchParams(uri.slice(8));
|
||||
let v1: string | null = null;
|
||||
let v2: string | null = null;
|
||||
for (const xt of params.getAll("xt")) {
|
||||
const m1 = /^urn:btih:([0-9a-fA-F]{40}|[A-Za-z2-7]{32})$/.exec(xt);
|
||||
if (m1) {
|
||||
const h = m1[1]!;
|
||||
v1 = h.length === 40 ? h.toLowerCase() : base32ToHex(h);
|
||||
continue;
|
||||
}
|
||||
const m2 = /^urn:btmh:1220([0-9a-fA-F]{64})$/.exec(xt);
|
||||
if (m2) v2 = m2[1]!.toLowerCase();
|
||||
}
|
||||
if (!v1 && !v2) return null;
|
||||
return { hash: v1 ?? v2!.slice(0, 40), infohashV1: v1, infohashV2: v2, displayName: params.get("dn") };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# tmdb
|
||||
|
||||
TMDB v3 查询:多语言搜索(默认 zh-CN + en-US,按 ID 合并标题)、`searchMulti`、详情(别名 alternative_titles、译名 translations、外部 ID、季列表)、季集列表;内存 LRU + TTL 缓存(默认 1024 条/24h,空结果 30min)与并发合并,并发/间隔限流,429 按 Retry-After 退避。输出归一化候选 `MetadataCandidate`(provider `tmdb`,entityType,externalId,titles,year,aliases,seasons?)。`matchTmdb`/`matchTmdbMulti` 迁移 MoviePilot 的匹配链(名称规范化后完全相等、年份 ±1、季年份优先、别名/译名回退)。错误为 `TmdbError`(auth/network/timeout/notfound/rate/protocol),不会把查询失败当成无结果。
|
||||
|
||||
仅对 mock 与官方 API 文档验证,未连接真实实例。
|
||||
@@ -0,0 +1,93 @@
|
||||
/** 内存 LRU + TTL 缓存,带并发合并(同 key 在途请求只发一次)。 */
|
||||
export class TtlLruCache<V> {
|
||||
private readonly map = new Map<string, { value: V; expiresAt: number }>();
|
||||
private readonly inflight = new Map<string, Promise<V>>();
|
||||
hits = 0;
|
||||
misses = 0;
|
||||
coalesced = 0;
|
||||
|
||||
constructor(
|
||||
private readonly max: number,
|
||||
private readonly ttlMs: number,
|
||||
private readonly now: () => number = Date.now,
|
||||
) {}
|
||||
|
||||
get(key: string): V | undefined {
|
||||
const e = this.map.get(key);
|
||||
if (!e) return undefined;
|
||||
if (e.expiresAt <= this.now()) {
|
||||
this.map.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
this.map.delete(key);
|
||||
this.map.set(key, e);
|
||||
return e.value;
|
||||
}
|
||||
|
||||
set(key: string, value: V, ttlMs = this.ttlMs): void {
|
||||
this.map.delete(key);
|
||||
this.map.set(key, { value, expiresAt: this.now() + ttlMs });
|
||||
while (this.map.size > this.max) {
|
||||
const oldest = this.map.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
this.map.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/** 命中直接返回;未命中时合并同 key 的在途加载,成功才写缓存。 */
|
||||
async getOrLoad(key: string, load: () => Promise<V>, ttlFor?: (v: V) => number | undefined): Promise<V> {
|
||||
const hit = this.get(key);
|
||||
if (hit !== undefined) {
|
||||
this.hits++;
|
||||
return hit;
|
||||
}
|
||||
const pending = this.inflight.get(key);
|
||||
if (pending) {
|
||||
this.coalesced++;
|
||||
return pending;
|
||||
}
|
||||
this.misses++;
|
||||
const p = load()
|
||||
.then((v) => {
|
||||
this.set(key, v, ttlFor?.(v) ?? this.ttlMs);
|
||||
return v;
|
||||
})
|
||||
.finally(() => this.inflight.delete(key));
|
||||
this.inflight.set(key, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.map.size;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.map.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/** 并发上限 + 请求起始最小间隔的简单限流器。 */
|
||||
export class RequestLimiter {
|
||||
private active = 0;
|
||||
private readonly queue: (() => void)[] = [];
|
||||
private nextStart = 0;
|
||||
|
||||
constructor(
|
||||
private readonly maxConcurrent: number,
|
||||
private readonly minIntervalMs: number,
|
||||
) {}
|
||||
|
||||
async run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
if (this.active >= this.maxConcurrent) await new Promise<void>((r) => this.queue.push(r));
|
||||
this.active++;
|
||||
try {
|
||||
const wait = this.nextStart - Date.now();
|
||||
this.nextStart = Math.max(Date.now(), this.nextStart) + this.minIntervalMs;
|
||||
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
||||
return await fn();
|
||||
} finally {
|
||||
this.active--;
|
||||
this.queue.shift()?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* TMDB v3 查询适配。
|
||||
* 迁移来源:MoviePilot app/modules/themoviedb/tmdbapi.py(e6820a8)与 tmdbv3api/tmdb.py 的请求缓存:
|
||||
* search_movies/search_tvs/search_multiis → search/searchAll/searchMulti;
|
||||
* __get_movie_detail/__get_tv_detail(alternative_titles、translations、external_ids)→ details;
|
||||
* get_tv_season_detail → season;tmdb.py request 的 @cached(maxsize/ttl、空结果短 TTL、业务失败不缓存)→ TtlLruCache;
|
||||
* match/match_multi 的匹配链见 match.ts。
|
||||
* 调整:网络/鉴权/限流错误以 TmdbError 抛出而非返回 None,调用方不得把"查询失败"当成"没有结果"。
|
||||
*/
|
||||
import { RequestLimiter, TtlLruCache } from "./cache";
|
||||
|
||||
export type TmdbErrorKind = "auth" | "network" | "timeout" | "notfound" | "rate" | "protocol";
|
||||
|
||||
export class TmdbError extends Error {
|
||||
override readonly name = "TmdbError";
|
||||
constructor(
|
||||
readonly kind: TmdbErrorKind,
|
||||
message: string,
|
||||
readonly detail: { path?: string; status?: number; retryAfterMs?: number; cause?: unknown } = {},
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export type MediaEntityType = "movie" | "tv";
|
||||
|
||||
export interface CandidateTitle {
|
||||
text: string;
|
||||
/** BCP47,如 zh-CN;原名为原始语言;别名为 null */
|
||||
lang: string | null;
|
||||
kind: "localized" | "original" | "alias" | "translation";
|
||||
/** 别名/翻译的地区(iso_3166_1) */
|
||||
region?: string;
|
||||
}
|
||||
|
||||
export interface CandidateSeason {
|
||||
seasonNumber: number;
|
||||
episodeCount: number;
|
||||
name: string | null;
|
||||
airDate: string | null;
|
||||
}
|
||||
|
||||
/** 归一化的元数据候选(provider 无关结构,供 domain 身份匹配使用)。 */
|
||||
export interface MetadataCandidate {
|
||||
provider: "tmdb";
|
||||
entityType: MediaEntityType;
|
||||
externalId: string;
|
||||
/** 首选语言标题(配置 languages[0]) */
|
||||
title: string;
|
||||
originalTitle: string | null;
|
||||
originalLanguage: string | null;
|
||||
titles: CandidateTitle[];
|
||||
year: number | null;
|
||||
releaseDate: string | null;
|
||||
aliases: string[];
|
||||
originCountry: string[];
|
||||
popularity: number | null;
|
||||
overview: string | null;
|
||||
seasons?: CandidateSeason[];
|
||||
externalIds?: { imdb?: string; tvdb?: string };
|
||||
/** 是否已拉过详情(别名/季信息只在详情中完整) */
|
||||
detailed: boolean;
|
||||
}
|
||||
|
||||
export interface TmdbEpisode {
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
name: string | null;
|
||||
airDate: string | null;
|
||||
}
|
||||
|
||||
export interface TmdbSeasonDetail {
|
||||
tvId: string;
|
||||
seasonNumber: number;
|
||||
name: string | null;
|
||||
airDate: string | null;
|
||||
episodes: TmdbEpisode[];
|
||||
}
|
||||
|
||||
export interface TmdbConfig {
|
||||
baseUrl?: string;
|
||||
/** v3 API key(query api_key) */
|
||||
apiKey?: string;
|
||||
/** v4 read access token(Bearer) */
|
||||
accessToken?: string;
|
||||
/** 查询语言,默认 ["zh-CN", "en-US"];第一个为首选 */
|
||||
languages?: string[];
|
||||
timeoutMs?: number;
|
||||
/** 429/5xx/网络错误的重试次数,默认 3 */
|
||||
retries?: number;
|
||||
retryBaseMs?: number;
|
||||
/** 429 Retry-After 的最大等待,默认 10s */
|
||||
maxRetryAfterMs?: number;
|
||||
maxConcurrent?: number;
|
||||
minIntervalMs?: number;
|
||||
/** 默认 max 1024、ttl 24h;空结果(搜索 0 条)用 emptyTtlMs,默认 30min(对照 MP EMPTY_RESULT_CACHE_TTL) */
|
||||
cache?: { max?: number; ttlMs?: number; emptyTtlMs?: number };
|
||||
fetch?: typeof fetch;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const s = (v: unknown): string | null => (typeof v === "string" && v !== "" ? v : null);
|
||||
const n = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
||||
const yearOf = (d: string | null) => (d && /^\d{4}/.test(d) ? Number(d.slice(0, 4)) : null);
|
||||
|
||||
export class TmdbClient {
|
||||
private readonly base: string;
|
||||
private readonly langs: string[];
|
||||
private readonly cache: TtlLruCache<unknown>;
|
||||
private readonly limiter: RequestLimiter;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
|
||||
constructor(private readonly cfg: TmdbConfig) {
|
||||
if (!cfg.apiKey && !cfg.accessToken) throw new TypeError("TMDB apiKey or accessToken required");
|
||||
this.base = (cfg.baseUrl ?? "https://api.themoviedb.org/3").replace(/\/+$/, "");
|
||||
this.langs = cfg.languages?.length ? cfg.languages : ["zh-CN", "en-US"];
|
||||
this.cache = new TtlLruCache(cfg.cache?.max ?? 1024, cfg.cache?.ttlMs ?? 24 * 3600_000, cfg.now);
|
||||
this.limiter = new RequestLimiter(cfg.maxConcurrent ?? 8, cfg.minIntervalMs ?? 0);
|
||||
this.fetchImpl = cfg.fetch ?? fetch;
|
||||
}
|
||||
|
||||
cacheStats() {
|
||||
return { size: this.cache.size, hits: this.cache.hits, misses: this.cache.misses, coalesced: this.cache.coalesced };
|
||||
}
|
||||
|
||||
private async get<T>(path: string, params: Record<string, string | undefined>): Promise<T> {
|
||||
const qs = new URLSearchParams();
|
||||
for (const k of Object.keys(params).sort()) if (params[k] !== undefined) qs.set(k, params[k]!);
|
||||
const key = `${path}?${qs}`;
|
||||
const emptyTtl = this.cfg.cache?.emptyTtlMs ?? 30 * 60_000;
|
||||
return this.cache.getOrLoad(
|
||||
key,
|
||||
() => this.limiter.run(() => this.fetchWithRetry(path, qs)),
|
||||
(v) =>
|
||||
Array.isArray((v as Json | null)?.results) && ((v as Json).results as unknown[]).length === 0
|
||||
? emptyTtl
|
||||
: undefined,
|
||||
) as Promise<T>;
|
||||
}
|
||||
|
||||
private async fetchWithRetry(path: string, qs: URLSearchParams): Promise<unknown> {
|
||||
const retries = this.cfg.retries ?? 3;
|
||||
let last: TmdbError | null = null;
|
||||
for (let i = 0; i <= retries; i++) {
|
||||
try {
|
||||
return await this.fetchOnce(path, qs);
|
||||
} catch (e) {
|
||||
if (!(e instanceof TmdbError)) throw e;
|
||||
last = e;
|
||||
if (!["rate", "network", "timeout", "protocol"].includes(e.kind) || i === retries) break;
|
||||
const ra = e.detail.retryAfterMs;
|
||||
await sleep(ra ?? (this.cfg.retryBaseMs ?? 500) * 2 ** i);
|
||||
}
|
||||
}
|
||||
throw last!;
|
||||
}
|
||||
|
||||
private async fetchOnce(path: string, qs: URLSearchParams): Promise<unknown> {
|
||||
const q = new URLSearchParams(qs);
|
||||
const headers: Record<string, string> = { accept: "application/json" };
|
||||
if (this.cfg.accessToken) headers.authorization = `Bearer ${this.cfg.accessToken}`;
|
||||
else q.set("api_key", this.cfg.apiKey!);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await this.fetchImpl(`${this.base}${path}?${q}`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(this.cfg.timeoutMs ?? 10_000),
|
||||
});
|
||||
} catch (e) {
|
||||
const name = (e as { name?: string })?.name;
|
||||
if (name === "TimeoutError" || name === "AbortError")
|
||||
throw new TmdbError("timeout", "TMDB request timed out", { path });
|
||||
throw new TmdbError("network", `TMDB request failed: ${(e as Error)?.message}`, { path, cause: e });
|
||||
}
|
||||
if (res.status === 401) {
|
||||
await res.body?.cancel();
|
||||
throw new TmdbError("auth", "TMDB rejected credentials", { path, status: 401 });
|
||||
}
|
||||
if (res.status === 404) {
|
||||
await res.body?.cancel();
|
||||
throw new TmdbError("notfound", `TMDB resource not found: ${path}`, { path, status: 404 });
|
||||
}
|
||||
if (res.status === 429) {
|
||||
await res.body?.cancel();
|
||||
const ra = Number(res.headers.get("retry-after"));
|
||||
const err = new TmdbError("rate", "TMDB rate limited", { path, status: 429 });
|
||||
if (Number.isFinite(ra) && ra >= 0) {
|
||||
err.detail.retryAfterMs = Math.min(ra * 1000, this.cfg.maxRetryAfterMs ?? 10_000);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
if (!res.ok) {
|
||||
await res.body?.cancel();
|
||||
throw new TmdbError("protocol", `TMDB HTTP ${res.status}`, { path, status: res.status });
|
||||
}
|
||||
try {
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
throw new TmdbError("protocol", "TMDB returned invalid JSON", { path, cause: e });
|
||||
}
|
||||
}
|
||||
|
||||
/** 单语言原始搜索(匹配链使用,对照 MP search.movies/tv_shows,语言取首选)。 */
|
||||
async searchRaw(type: MediaEntityType, query: string, year?: string | number): Promise<MetadataCandidate[]> {
|
||||
const lang = this.langs[0]!;
|
||||
const p = await this.get<Json>(`/search/${type}`, {
|
||||
query: query.trim(),
|
||||
language: lang,
|
||||
include_adult: "false",
|
||||
page: "1",
|
||||
[type === "movie" ? "year" : "first_air_date_year"]: year !== undefined && year !== "" ? String(year) : undefined,
|
||||
});
|
||||
return ((Array.isArray(p.results) ? p.results : []) as Json[]).map((r) => this.fromBrief(type, r, lang));
|
||||
}
|
||||
|
||||
/** /search/multi,只保留 movie/tv(对照 _project_multi_search_results)。 */
|
||||
async searchMulti(query: string): Promise<MetadataCandidate[]> {
|
||||
const lang = this.langs[0]!;
|
||||
const p = await this.get<Json>("/search/multi", {
|
||||
query: query.trim(),
|
||||
language: lang,
|
||||
include_adult: "false",
|
||||
page: "1",
|
||||
});
|
||||
return ((Array.isArray(p.results) ? p.results : []) as Json[])
|
||||
.filter((r) => r.media_type === "movie" || r.media_type === "tv")
|
||||
.map((r) => this.fromBrief(r.media_type as MediaEntityType, r, lang));
|
||||
}
|
||||
|
||||
private fromBrief(type: MediaEntityType, r: Json, lang: string): MetadataCandidate {
|
||||
const title = s(type === "movie" ? r.title : r.name) ?? "";
|
||||
const original = s(type === "movie" ? r.original_title : r.original_name);
|
||||
const date = s(type === "movie" ? r.release_date : r.first_air_date);
|
||||
const origLang = s(r.original_language);
|
||||
const titles: CandidateTitle[] = [];
|
||||
if (title) titles.push({ text: title, lang, kind: "localized" });
|
||||
if (original) titles.push({ text: original, lang: origLang, kind: "original" });
|
||||
return {
|
||||
provider: "tmdb",
|
||||
entityType: type,
|
||||
externalId: String(r.id),
|
||||
title,
|
||||
originalTitle: original,
|
||||
originalLanguage: origLang,
|
||||
titles,
|
||||
year: yearOf(date),
|
||||
releaseDate: date,
|
||||
aliases: [],
|
||||
originCountry: Array.isArray(r.origin_country) ? (r.origin_country as string[]) : [],
|
||||
popularity: n(r.popularity),
|
||||
overview: s(r.overview),
|
||||
detailed: false,
|
||||
};
|
||||
}
|
||||
|
||||
private static mergeTitle(c: MetadataCandidate, t: CandidateTitle) {
|
||||
if (!c.titles.some((x) => x.text === t.text && x.lang === t.lang && x.kind === t.kind)) c.titles.push(t);
|
||||
}
|
||||
|
||||
/** 按配置的多语言搜索并按 ID 合并;首选语言结果顺序优先。 */
|
||||
async search(query: string, opts: { type: MediaEntityType; year?: number }): Promise<MetadataCandidate[]> {
|
||||
const q = query.trim();
|
||||
if (!q) return [];
|
||||
const yearKey = opts.type === "movie" ? "year" : "first_air_date_year";
|
||||
const pages = await Promise.all(
|
||||
this.langs.map((lang) =>
|
||||
this.get<Json>(`/search/${opts.type}`, {
|
||||
query: q,
|
||||
language: lang,
|
||||
include_adult: "false",
|
||||
page: "1",
|
||||
[yearKey]: opts.year !== undefined ? String(opts.year) : undefined,
|
||||
}).then((p) => ({ lang, results: (Array.isArray(p.results) ? p.results : []) as Json[] })),
|
||||
),
|
||||
);
|
||||
const byId = new Map<string, MetadataCandidate>();
|
||||
for (const { lang, results } of pages) {
|
||||
for (const r of results) {
|
||||
const c = this.fromBrief(opts.type, r, lang);
|
||||
const prev = byId.get(c.externalId);
|
||||
if (!prev) byId.set(c.externalId, c);
|
||||
else for (const t of c.titles) TmdbClient.mergeTitle(prev, t);
|
||||
}
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
/** 同时搜电影与剧集。 */
|
||||
async searchAll(query: string, opts: { year?: number } = {}): Promise<MetadataCandidate[]> {
|
||||
const [m, t] = await Promise.all([
|
||||
this.search(query, { type: "movie", ...opts }),
|
||||
this.search(query, { type: "tv", ...opts }),
|
||||
]);
|
||||
return [...m, ...t].sort((a, b) => (b.popularity ?? 0) - (a.popularity ?? 0));
|
||||
}
|
||||
|
||||
/** 详情:多语言标题、别名(alternative_titles)、外部 ID、季列表(剧集)。不存在抛 notfound。 */
|
||||
async details(type: MediaEntityType, id: string | number): Promise<MetadataCandidate> {
|
||||
const [first, ...rest] = await Promise.all(
|
||||
this.langs.map((lang, i) =>
|
||||
this.get<Json>(`/${type}/${id}`, {
|
||||
language: lang,
|
||||
append_to_response: i === 0 ? "alternative_titles,external_ids,translations" : undefined,
|
||||
}).then((j) => ({ lang, j })),
|
||||
),
|
||||
);
|
||||
const c = this.fromBrief(type, first!.j, first!.lang);
|
||||
for (const { lang, j } of rest) {
|
||||
const t = s(type === "movie" ? j.title : j.name);
|
||||
if (t) TmdbClient.mergeTitle(c, { text: t, lang, kind: "localized" });
|
||||
}
|
||||
const alt = first!.j.alternative_titles as Json | undefined;
|
||||
const altList = ((type === "movie" ? alt?.titles : alt?.results) ?? []) as Json[];
|
||||
const aliases = new Set<string>();
|
||||
for (const a of altList) {
|
||||
const text = s(a.title);
|
||||
if (!text) continue;
|
||||
aliases.add(text);
|
||||
const region = s(a.iso_3166_1);
|
||||
c.titles.push({ text, lang: null, kind: "alias", ...(region ? { region } : {}) });
|
||||
}
|
||||
// translations:MP __get_names 同样把各语言译名计入可匹配名称
|
||||
const trs = (((first!.j.translations as Json | undefined)?.translations ?? []) as Json[]).filter(Boolean);
|
||||
for (const tr of trs) {
|
||||
const data = tr.data as Json | undefined;
|
||||
const text = s(type === "movie" ? data?.title : data?.name);
|
||||
if (!text) continue;
|
||||
aliases.add(text);
|
||||
const l = s(tr.iso_639_1);
|
||||
const region = s(tr.iso_3166_1);
|
||||
TmdbClient.mergeTitle(c, {
|
||||
text,
|
||||
lang: l ? (region ? `${l}-${region}` : l) : null,
|
||||
kind: "translation",
|
||||
...(region ? { region } : {}),
|
||||
});
|
||||
}
|
||||
c.aliases = [...aliases];
|
||||
const ext = first!.j.external_ids as Json | undefined;
|
||||
if (ext) {
|
||||
const imdb = s(ext.imdb_id);
|
||||
const tvdb = ext.tvdb_id != null ? String(ext.tvdb_id) : null;
|
||||
c.externalIds = { ...(imdb ? { imdb } : {}), ...(tvdb ? { tvdb } : {}) };
|
||||
}
|
||||
if (type === "tv") {
|
||||
c.seasons = ((first!.j.seasons ?? []) as Json[]).map((x) => ({
|
||||
seasonNumber: n(x.season_number) ?? 0,
|
||||
episodeCount: n(x.episode_count) ?? 0,
|
||||
name: s(x.name),
|
||||
airDate: s(x.air_date),
|
||||
}));
|
||||
}
|
||||
c.detailed = true;
|
||||
return c;
|
||||
}
|
||||
|
||||
/** 季详情与集列表(首选语言)。 */
|
||||
async season(tvId: string | number, seasonNumber: number): Promise<TmdbSeasonDetail> {
|
||||
const j = await this.get<Json>(`/tv/${tvId}/season/${seasonNumber}`, { language: this.langs[0] });
|
||||
return {
|
||||
tvId: String(tvId),
|
||||
seasonNumber: n(j.season_number) ?? seasonNumber,
|
||||
name: s(j.name),
|
||||
airDate: s(j.air_date),
|
||||
episodes: ((j.episodes ?? []) as Json[]).map((e) => ({
|
||||
seasonNumber: n(e.season_number) ?? seasonNumber,
|
||||
episodeNumber: n(e.episode_number) ?? 0,
|
||||
name: s(e.name),
|
||||
airDate: s(e.air_date),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createTmdbClient(cfg: TmdbConfig): TmdbClient {
|
||||
return new TmdbClient(cfg);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { RequestLimiter, TtlLruCache } from "./cache";
|
||||
export * from "./client";
|
||||
export * from "./match";
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* TMDB 名称匹配链(API 侧行为),对照 MoviePilot app/modules/themoviedb/tmdbapi.py(e6820a8):
|
||||
* __compare_names + foundation/text.py remove_punctuation → normalizeTitleForCompare / compareNames
|
||||
* _generate_year_range → yearRange(year, year+1, year-1)
|
||||
* _build_match_plan / _match_resolution → matchTmdb 的步骤顺序(季年份步骤优先,再按名称+年份容差)
|
||||
* __search_movie_by_name / __search_tv_by_name / _build_name_candidate_plan → 按日期降序、年份前缀过滤、
|
||||
* 标题/原标题直达,否则逐个拉详情用别名+译名比较(_alias_matches)
|
||||
* __search_tv_by_season / _season_candidate_matches / _season_year_matches → 季年份匹配
|
||||
* match_multi / _sort_multi_results → matchTmdbMulti(电影优先、日期降序)
|
||||
* 调整:MP 查询失败返回 None 后继续下一步;这里 TMDB 错误直接抛出,避免把"查不到"与"查询失败"混为一谈。
|
||||
* 未迁移:剧集组(group_seasons)季年份判断。打分/身份判定在 packages/domain。
|
||||
*/
|
||||
import type { MediaEntityType, MetadataCandidate, TmdbClient } from "./client";
|
||||
|
||||
const PUNCT = /[、.。,,·::;;!!??'’"“”()()[\]【】「」\-—―+|\\_/&#~~]/g;
|
||||
|
||||
/** 对照 remove_punctuation(allow_space=False)+ upper:去标点、零宽字符与所有空白后转大写。 */
|
||||
export function normalizeTitleForCompare(v: string): string {
|
||||
return v.replace(PUNCT, "").replace(/[-]/g, "").replace(/\s+/g, "").toUpperCase();
|
||||
}
|
||||
|
||||
/** 规范化后完全相等才算匹配(不是模糊包含)。 */
|
||||
export function compareNames(
|
||||
name: string,
|
||||
candidates: (string | null | undefined)[] | string | null | undefined,
|
||||
): boolean {
|
||||
if (!name || !candidates) return false;
|
||||
const list = Array.isArray(candidates) ? candidates : [candidates];
|
||||
const n = normalizeTitleForCompare(name);
|
||||
return list.some((c) => !!c && normalizeTitleForCompare(c.trim()) === n);
|
||||
}
|
||||
|
||||
/** [year, year+1, year-1];无年份时 [undefined]。 */
|
||||
export function yearRange(year?: string | number | null): (string | undefined)[] {
|
||||
if (year === undefined || year === null || year === "") return [undefined];
|
||||
const y = Number(year);
|
||||
return [String(y), String(y + 1), String(y - 1)];
|
||||
}
|
||||
|
||||
const dateOf = (c: MetadataCandidate) => c.releaseDate ?? "0000-00-00";
|
||||
const titleMatches = (name: string, c: MetadataCandidate) => compareNames(name, [c.title, c.originalTitle]);
|
||||
|
||||
async function aliasResolve(
|
||||
tmdb: TmdbClient,
|
||||
candidates: MetadataCandidate[],
|
||||
accepts: (d: MetadataCandidate) => boolean,
|
||||
): Promise<MetadataCandidate | null> {
|
||||
for (const c of candidates) {
|
||||
const d = await tmdb.details(c.entityType, c.externalId);
|
||||
if (accepts(d)) return d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function searchByName(
|
||||
tmdb: TmdbClient,
|
||||
name: string,
|
||||
type: MediaEntityType,
|
||||
year: string | undefined,
|
||||
): Promise<MetadataCandidate | null> {
|
||||
const items = await tmdb.searchRaw(type, name, year);
|
||||
if (!items.length) return null;
|
||||
let cands = [...items].sort((a, b) => (dateOf(a) < dateOf(b) ? 1 : dateOf(a) > dateOf(b) ? -1 : 0));
|
||||
if (year) cands = cands.filter((c) => (c.releaseDate ?? "").slice(0, 4) === year);
|
||||
const direct = cands.find((c) => titleMatches(name, c));
|
||||
if (direct) return direct;
|
||||
return aliasResolve(tmdb, cands, (d) => compareNames(name, d.aliases));
|
||||
}
|
||||
|
||||
function seasonYearMatches(d: MetadataCandidate, seasonYear: string, seasonNumber: number): boolean {
|
||||
return (d.seasons ?? []).some((s) => s.seasonNumber === seasonNumber && (s.airDate ?? "").slice(0, 4) === seasonYear);
|
||||
}
|
||||
|
||||
async function searchBySeason(
|
||||
tmdb: TmdbClient,
|
||||
name: string,
|
||||
seasonYear: string,
|
||||
seasonNumber: number,
|
||||
): Promise<MetadataCandidate | null> {
|
||||
const items = await tmdb.searchRaw("tv", name);
|
||||
if (!items.length) return null;
|
||||
const cands = [...items].sort((a, b) => (dateOf(a) < dateOf(b) ? 1 : dateOf(a) > dateOf(b) ? -1 : 0));
|
||||
const direct = cands.find((c) => titleMatches(name, c) && (c.releaseDate ?? "").slice(0, 4) === seasonYear);
|
||||
if (direct) return direct;
|
||||
return aliasResolve(
|
||||
tmdb,
|
||||
cands,
|
||||
(d) =>
|
||||
(titleMatches(name, d) || compareNames(name, d.aliases)) &&
|
||||
((d.releaseDate ?? "").slice(0, 4) === seasonYear || seasonYearMatches(d, seasonYear, seasonNumber)),
|
||||
);
|
||||
}
|
||||
|
||||
export interface TmdbMatchInput {
|
||||
name: string;
|
||||
type: MediaEntityType;
|
||||
/** 电影上映年份 / 剧集首播年份 */
|
||||
year?: string | number;
|
||||
/** 当前季首播年份(与 seasonNumber 同时给出时先按季匹配) */
|
||||
seasonYear?: string | number;
|
||||
seasonNumber?: number;
|
||||
}
|
||||
|
||||
export interface TmdbMatchResult {
|
||||
candidate: MetadataCandidate;
|
||||
/** 命中的步骤,便于记录证据 */
|
||||
step: { target: "movie_name" | "tv_name" | "tv_season"; year: string | undefined };
|
||||
}
|
||||
|
||||
/** 对照 TmdbApi.match:按冻结计划逐步查询,首个命中短路;全部未命中返回 null。 */
|
||||
export async function matchTmdb(tmdb: TmdbClient, input: TmdbMatchInput): Promise<TmdbMatchResult | null> {
|
||||
if (!input.name) return null;
|
||||
const steps: TmdbMatchResult["step"][] = [];
|
||||
if (input.type === "tv" && input.seasonYear !== undefined && input.seasonNumber !== undefined) {
|
||||
steps.push({ target: "tv_season", year: String(input.seasonYear) });
|
||||
}
|
||||
for (const y of yearRange(input.year))
|
||||
steps.push({ target: input.type === "tv" ? "tv_name" : "movie_name", year: y });
|
||||
for (const step of steps) {
|
||||
const hit =
|
||||
step.target === "tv_season"
|
||||
? await searchBySeason(tmdb, input.name, step.year!, input.seasonNumber!)
|
||||
: await searchByName(tmdb, input.name, input.type, step.year);
|
||||
if (hit) return { candidate: hit, step };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 对照 TmdbApi.match_multi:无类型无年份时用 /search/multi;电影优先,日期降序。 */
|
||||
export async function matchTmdbMulti(tmdb: TmdbClient, name: string): Promise<MetadataCandidate | null> {
|
||||
if (!name) return null;
|
||||
const multis = await tmdb.searchMulti(name);
|
||||
if (!multis.length) return null;
|
||||
const key = (c: MetadataCandidate) => (c.entityType === "movie" ? "1" : "0") + dateOf(c);
|
||||
const cands = [...multis].sort((a, b) => (key(a) < key(b) ? 1 : key(a) > key(b) ? -1 : 0));
|
||||
const direct = cands.find((c) => titleMatches(name, c));
|
||||
if (direct) return direct;
|
||||
return aliasResolve(tmdb, cands, (d) => compareNames(name, d.aliases));
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { createMockTmdb, type MockTmdb } from "@umt/testkit";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
compareNames,
|
||||
createTmdbClient,
|
||||
matchTmdb,
|
||||
matchTmdbMulti,
|
||||
type TmdbConfig,
|
||||
TmdbError,
|
||||
yearRange,
|
||||
} from "./index";
|
||||
|
||||
let mock: MockTmdb | null = null;
|
||||
afterEach(async () => {
|
||||
await mock?.close();
|
||||
mock = null;
|
||||
});
|
||||
|
||||
async function setup(extra: Partial<TmdbConfig> = {}) {
|
||||
mock = await createMockTmdb();
|
||||
const tmdb = createTmdbClient({
|
||||
baseUrl: `${mock.url}/3`,
|
||||
apiKey: "test-tmdb-key",
|
||||
retries: 2,
|
||||
retryBaseMs: 5,
|
||||
timeoutMs: 300,
|
||||
...extra,
|
||||
});
|
||||
return { mock, tmdb };
|
||||
}
|
||||
|
||||
describe("TmdbClient 搜索与详情", () => {
|
||||
it("中文搜索电影,中英标题合并为一个候选", async () => {
|
||||
const { tmdb } = await setup();
|
||||
const r = await tmdb.search("奥本海默", { type: "movie" });
|
||||
expect(r).toHaveLength(1);
|
||||
expect(r[0]).toMatchObject({ provider: "tmdb", entityType: "movie", externalId: "872585", year: 2023 });
|
||||
expect(r[0]!.title).toBe("奥本海默");
|
||||
expect(r[0]!.titles.map((t) => `${t.kind}:${t.lang}:${t.text}`)).toEqual([
|
||||
"localized:zh-CN:奥本海默",
|
||||
"original:en:Oppenheimer",
|
||||
"localized:en-US:Oppenheimer",
|
||||
]);
|
||||
});
|
||||
|
||||
it("英文搜剧 + 年份过滤", async () => {
|
||||
const { tmdb } = await setup();
|
||||
const r = await tmdb.search("the bear", { type: "tv" });
|
||||
expect(r.map((c) => c.externalId)).toEqual(["136315"]);
|
||||
expect(await tmdb.search("the bear", { type: "tv", year: 2019 })).toEqual([]);
|
||||
const all = await tmdb.searchAll("流浪地球");
|
||||
expect(all.map((c) => `${c.entityType}:${c.externalId}`)).toEqual(["movie:842675"]);
|
||||
});
|
||||
|
||||
it("电影详情:别名与外部 ID", async () => {
|
||||
const { tmdb } = await setup();
|
||||
const d = await tmdb.details("movie", 693134);
|
||||
expect(d.detailed).toBe(true);
|
||||
expect(d.title).toBe("沙丘2");
|
||||
expect(d.aliases).toEqual(["Dune 2", "沙丘:第二部", "沙丘:第二部", "沙丘2", "Dune: Part Two"]);
|
||||
expect(d.titles.find((t) => t.kind === "alias" && t.region === "TW")?.text).toBe("沙丘:第二部");
|
||||
expect(d.externalIds).toEqual({ imdb: "tt15239678" });
|
||||
expect(d.seasons).toBeUndefined();
|
||||
});
|
||||
|
||||
it("剧集详情:季列表与集列表", async () => {
|
||||
const { tmdb } = await setup();
|
||||
const d = await tmdb.details("tv", 136315);
|
||||
expect(d.seasons?.map((s) => [s.seasonNumber, s.episodeCount])).toEqual([
|
||||
[1, 8],
|
||||
[2, 10],
|
||||
[3, 10],
|
||||
]);
|
||||
expect(d.externalIds).toEqual({ imdb: "tt14452776", tvdb: "401654" });
|
||||
const s2 = await tmdb.season(136315, 2);
|
||||
expect(s2.episodes).toHaveLength(10);
|
||||
expect(s2.episodes[0]).toMatchObject({ seasonNumber: 2, episodeNumber: 1, name: "第 1 集" });
|
||||
});
|
||||
|
||||
it("国产综艺:特别篇季 0、按期命名、简称别名可召回", async () => {
|
||||
const { tmdb } = await setup();
|
||||
const r = await tmdb.search("向往", { type: "tv" });
|
||||
expect(r.map((c) => c.externalId)).toEqual(["900001"]);
|
||||
const d = await tmdb.details("tv", 900001);
|
||||
expect(d.originalLanguage).toBe("zh");
|
||||
expect(d.seasons?.map((s) => s.seasonNumber)).toEqual([0, 1, 2]);
|
||||
expect(d.externalIds).toEqual({});
|
||||
const s1 = await tmdb.season(900001, 1);
|
||||
expect(s1.episodes.at(-1)).toMatchObject({ episodeNumber: 12, name: "第 12 期" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("TmdbClient 缓存/限流/错误分类", () => {
|
||||
it("LRU+TTL 缓存:命中不发请求,过期后重新拉取", async () => {
|
||||
let now = 1_000_000;
|
||||
const { mock, tmdb } = await setup({ now: () => now, cache: { ttlMs: 1000, max: 100 } });
|
||||
await tmdb.details("movie", 872585);
|
||||
const n1 = mock.calls.length;
|
||||
await tmdb.details("movie", 872585);
|
||||
expect(mock.calls.length).toBe(n1);
|
||||
expect(tmdb.cacheStats().hits).toBeGreaterThan(0);
|
||||
now += 5000;
|
||||
await tmdb.details("movie", 872585);
|
||||
expect(mock.calls.length).toBe(n1 * 2);
|
||||
});
|
||||
|
||||
it("并发合并:同一请求并发只发一次", async () => {
|
||||
const { mock, tmdb } = await setup();
|
||||
await Promise.all(Array.from({ length: 5 }, () => tmdb.season(100088, 1)));
|
||||
expect(mock.callsTo("/3/tv/100088/season/1")).toHaveLength(1);
|
||||
expect(tmdb.cacheStats().coalesced).toBe(4);
|
||||
});
|
||||
|
||||
it("429 按 Retry-After 退避后成功;持续 429 → rate", async () => {
|
||||
const { mock, tmdb } = await setup();
|
||||
mock.fault("/3/movie/842675", { status: 429, headers: { "retry-after": "0" }, times: 1 });
|
||||
const d = await tmdb.details("movie", 842675);
|
||||
expect(d.title).toBe("流浪地球2");
|
||||
mock.fault("/3/tv/100088", { status: 429, headers: { "retry-after": "0" }, times: 10 });
|
||||
const e = await tmdb.details("tv", 100088).catch((x) => x);
|
||||
expect(e).toBeInstanceOf(TmdbError);
|
||||
expect(e.kind).toBe("rate");
|
||||
});
|
||||
|
||||
it("失败不进缓存:5xx 后重试成功,notfound 分类", async () => {
|
||||
const { mock, tmdb } = await setup({ retries: 0 });
|
||||
mock.fault("/3/tv/136315/season/3", { status: 503 });
|
||||
expect((await tmdb.season(136315, 3).catch((e) => e)).kind).toBe("protocol");
|
||||
expect((await tmdb.season(136315, 3)).episodes).toHaveLength(10);
|
||||
expect((await tmdb.details("movie", 1).catch((e) => e)).kind).toBe("notfound");
|
||||
expect((await tmdb.season(136315, 9).catch((e) => e)).kind).toBe("notfound");
|
||||
});
|
||||
|
||||
it("凭据错误 → auth;超时 → timeout;连接失败 → network", async () => {
|
||||
const { mock } = await setup();
|
||||
const bad = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "wrong", retries: 0 });
|
||||
expect((await bad.search("x", { type: "movie" }).catch((e) => e)).kind).toBe("auth");
|
||||
const slow = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 0, timeoutMs: 50 });
|
||||
mock.fault("/3/search", { delayMs: 300, times: 2 });
|
||||
expect((await slow.search("dune", { type: "movie" }).catch((e) => e)).kind).toBe("timeout");
|
||||
const dead = createTmdbClient({ baseUrl: "http://127.0.0.1:1/3", apiKey: "k", retries: 0 });
|
||||
expect((await dead.details("movie", 1).catch((e) => e)).kind).toBe("network");
|
||||
});
|
||||
|
||||
it("Bearer token 鉴权可用", async () => {
|
||||
mock = await createMockTmdb({ accessToken: "v4-token" });
|
||||
const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, accessToken: "v4-token" });
|
||||
expect((await tmdb.search("last of us", { type: "tv" }))[0]?.externalId).toBe("100088");
|
||||
expect(mock.calls.every((c) => c.query.api_key === undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TMDB 匹配链(对照 MP TmdbApi.match)", () => {
|
||||
it("名称比较:去标点/空白、大小写不敏感、完全相等", () => {
|
||||
expect(compareNames("Dune Part Two", "Dune: Part Two")).toBe(true);
|
||||
expect(compareNames("沙丘 第二部", ["沙丘:第二部"])).toBe(true);
|
||||
expect(compareNames("Dune", "Dune: Part Two")).toBe(false);
|
||||
expect(yearRange(2023)).toEqual(["2023", "2024", "2022"]);
|
||||
expect(yearRange(undefined)).toEqual([undefined]);
|
||||
});
|
||||
|
||||
it("年份容差 ±1:2023 未命中后按 2024 命中", async () => {
|
||||
const { tmdb, mock } = await setup();
|
||||
const r = await matchTmdb(tmdb, { name: "沙丘2", type: "movie", year: 2023 });
|
||||
expect(r?.candidate.externalId).toBe("693134");
|
||||
expect(r?.step).toEqual({ target: "movie_name", year: "2024" });
|
||||
expect(mock.callsTo("/3/search/movie").map((c) => c.query.year)).toEqual(["2023", "2024"]);
|
||||
});
|
||||
|
||||
it("标题不直达时拉详情用别名/译名匹配", async () => {
|
||||
const { tmdb } = await setup();
|
||||
const r = await matchTmdb(tmdb, { name: "奥本海默传", type: "movie" });
|
||||
expect(r?.candidate).toMatchObject({ externalId: "872585", detailed: true });
|
||||
const ja = await matchTmdb(tmdb, { name: "オッペンハイマー", type: "movie", year: 2023 });
|
||||
expect(ja).toBeNull(); // mock 搜索不索引译名 → 搜不到候选,与 TMDB 行为一致:译名只在详情比较阶段参与
|
||||
expect(await matchTmdb(tmdb, { name: "完全不存在", type: "movie", year: 2000 })).toBeNull();
|
||||
});
|
||||
|
||||
it("季年份匹配优先:按季首播年份命中剧集;multi 电影优先", async () => {
|
||||
const { tmdb } = await setup();
|
||||
const r = await matchTmdb(tmdb, { name: "熊家餐馆", type: "tv", seasonYear: 2024, seasonNumber: 3, year: 2022 });
|
||||
expect(r?.candidate.externalId).toBe("136315");
|
||||
expect(r?.step.target).toBe("tv_season");
|
||||
const m = await matchTmdbMulti(tmdb, "The Last of Us");
|
||||
expect(m).toMatchObject({ entityType: "tv", externalId: "100088" });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user