From ef2e504a94e9dc2814f30ace84799afde7b95ccc Mon Sep 17 00:00:00 2001 From: root Date: Sun, 27 Sep 2026 18:07:48 +0800 Subject: [PATCH] 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 --- .../moviepilot-downloader-and-sites.md | 63 ++ packages/adapters/package.json | 3 + packages/adapters/src/emby/README.md | 5 + packages/adapters/src/emby/client.ts | 575 +++++++++++++ packages/adapters/src/emby/emby.test.ts | 158 ++++ packages/adapters/src/emby/index.ts | 1 + packages/adapters/src/fs/README.md | 5 + packages/adapters/src/fs/copy.ts | 213 +++++ packages/adapters/src/fs/errors.ts | 44 + packages/adapters/src/fs/ffprobe.ts | 181 +++++ packages/adapters/src/fs/fs.test.ts | 263 ++++++ packages/adapters/src/fs/index.ts | 6 + packages/adapters/src/fs/move.ts | 180 +++++ packages/adapters/src/fs/scan.ts | 156 ++++ packages/adapters/src/fs/stat.ts | 93 +++ packages/adapters/src/index.ts | 4 + packages/adapters/src/qbittorrent/README.md | 5 + packages/adapters/src/qbittorrent/client.ts | 754 ++++++++++++++++++ packages/adapters/src/qbittorrent/errors.ts | 29 + packages/adapters/src/qbittorrent/index.ts | 5 + packages/adapters/src/qbittorrent/paths.ts | 31 + .../src/qbittorrent/qbittorrent.test.ts | 338 ++++++++ packages/adapters/src/qbittorrent/state.ts | 71 ++ packages/adapters/src/qbittorrent/torrent.ts | 218 +++++ packages/adapters/src/tmdb/README.md | 5 + packages/adapters/src/tmdb/cache.ts | 93 +++ packages/adapters/src/tmdb/client.ts | 380 +++++++++ packages/adapters/src/tmdb/index.ts | 3 + packages/adapters/src/tmdb/match.ts | 139 ++++ packages/adapters/src/tmdb/tmdb.test.ts | 187 +++++ packages/testkit/src/emby/fixtures.ts | 166 ++++ packages/testkit/src/emby/index.ts | 2 + packages/testkit/src/emby/mock-emby.ts | 122 +++ packages/testkit/src/fs/index.ts | 1 + packages/testkit/src/fs/temp-tree.ts | 60 ++ packages/testkit/src/index.ts | 4 + packages/testkit/src/qbittorrent/bencode.ts | 130 +++ packages/testkit/src/qbittorrent/http-util.ts | 46 ++ packages/testkit/src/qbittorrent/index.ts | 8 + packages/testkit/src/qbittorrent/mock-qb.ts | 530 ++++++++++++ packages/testkit/src/tmdb/fixtures.ts | 219 +++++ packages/testkit/src/tmdb/index.ts | 3 + packages/testkit/src/tmdb/mock-http.ts | 104 +++ packages/testkit/src/tmdb/mock-tmdb.ts | 158 ++++ pnpm-lock.yaml | 6 +- 45 files changed, 5766 insertions(+), 1 deletion(-) create mode 100644 packages/adapters/src/emby/README.md create mode 100644 packages/adapters/src/emby/client.ts create mode 100644 packages/adapters/src/emby/emby.test.ts create mode 100644 packages/adapters/src/emby/index.ts create mode 100644 packages/adapters/src/fs/README.md create mode 100644 packages/adapters/src/fs/copy.ts create mode 100644 packages/adapters/src/fs/errors.ts create mode 100644 packages/adapters/src/fs/ffprobe.ts create mode 100644 packages/adapters/src/fs/fs.test.ts create mode 100644 packages/adapters/src/fs/index.ts create mode 100644 packages/adapters/src/fs/move.ts create mode 100644 packages/adapters/src/fs/scan.ts create mode 100644 packages/adapters/src/fs/stat.ts create mode 100644 packages/adapters/src/qbittorrent/README.md create mode 100644 packages/adapters/src/qbittorrent/client.ts create mode 100644 packages/adapters/src/qbittorrent/errors.ts create mode 100644 packages/adapters/src/qbittorrent/index.ts create mode 100644 packages/adapters/src/qbittorrent/paths.ts create mode 100644 packages/adapters/src/qbittorrent/qbittorrent.test.ts create mode 100644 packages/adapters/src/qbittorrent/state.ts create mode 100644 packages/adapters/src/qbittorrent/torrent.ts create mode 100644 packages/adapters/src/tmdb/README.md create mode 100644 packages/adapters/src/tmdb/cache.ts create mode 100644 packages/adapters/src/tmdb/client.ts create mode 100644 packages/adapters/src/tmdb/index.ts create mode 100644 packages/adapters/src/tmdb/match.ts create mode 100644 packages/adapters/src/tmdb/tmdb.test.ts create mode 100644 packages/testkit/src/emby/fixtures.ts create mode 100644 packages/testkit/src/emby/index.ts create mode 100644 packages/testkit/src/emby/mock-emby.ts create mode 100644 packages/testkit/src/fs/index.ts create mode 100644 packages/testkit/src/fs/temp-tree.ts create mode 100644 packages/testkit/src/qbittorrent/bencode.ts create mode 100644 packages/testkit/src/qbittorrent/http-util.ts create mode 100644 packages/testkit/src/qbittorrent/index.ts create mode 100644 packages/testkit/src/qbittorrent/mock-qb.ts create mode 100644 packages/testkit/src/tmdb/fixtures.ts create mode 100644 packages/testkit/src/tmdb/index.ts create mode 100644 packages/testkit/src/tmdb/mock-http.ts create mode 100644 packages/testkit/src/tmdb/mock-tmdb.ts diff --git a/docs/porting/moviepilot-downloader-and-sites.md b/docs/porting/moviepilot-downloader-and-sites.md index 2752afad..d370b810 100644 --- a/docs/porting/moviepilot-downloader-and-sites.md +++ b/docs/porting/moviepilot-downloader-and-sites.md @@ -545,3 +545,66 @@ MC 注册表 `MC:src/movieclaw_tracker/registry.py:172` `_load_site_yaml` 读取 | 观众促销/H&R 真实类名 | MC 未配置,需用真实页面(或用户提供的脱敏 HTML)核对后写入 `audiences.ts` | | M-Team `genDlToken` 返回直链的有效期与频率限制 | 两项目均未写明;mock 中按一次性处理 | | qB 5.x API Key 认证与 `added_torrent_ids` 字段的最小版本 | MP 以运行时探测兼容;需在 spike 中以 qB 4.6.x / 5.1.x 实例验证 | + +--- + +## W1-3a 实际迁移对照(qB / TMDB / Emby / 文件系统) + +上游:`/opt/moviepilot-plus/upstream/MoviePilot`,提交 `e6820a8`。只做行为对照与 TS 重写,不复制代码。 + +### qBittorrent(`packages/adapters/src/qbittorrent/`) + +| 上游函数 | TS | 保留 / 调整 | +| --- | --- | --- | +| `Qbittorrent.__init__` / `__login_qbittorrent` / `is_inactive` / `reconnect` | `QbClient` 构造 + `login()`;401/403 时自动重登一次 | 保留 API Key(Bearer)与账号密码两种认证;**不**调用 `__sync_incomplete_file_suffix`(QA6,只读 `preferences()`) | +| `__parse_add_torrent_response` | `add()` 响应解析 | 保留:文本 `Ok.` 或 5.x JSON(`success_count/pending_count/added_torrent_ids`) | +| `add_torrent` | `add()` | 保留 savepath/category/tags/paused(同时发 `stopped`)/rename/sequential/firstLastPiece(实例默认);调整:`autoTMM` 固定 false、显式 `contentLayout`(QA7);Cookie 同步(Q14)未迁移——.torrent 由站点适配器下载后以字节提交 | +| `get_torrents` | `list({hashes, tag, tags, category, filter})` | 调整:首个标签走 qB 原生 `tag` 参数,其余在客户端做子集过滤(QA4);按 hash 查询不受管理标签限制(QA3) | +| `get_completed_torrents` | `getCompleted()` + `isDownloadComplete()` | 保留严格完成条件与非终态集合(QK4);未知 state 也不算完成 | +| `get_downloading_torrents` | `getDownloading()` | 保留 | +| `__normalize_torrent_state`(`__init__.py`) | `mapQbState()` | 调整:error/missingFiles/unknown 独立状态,不兜底为 completed(QA5) | +| `delete_torrents_tag` / `remove_torrents_tag` / `set_torrents_tag` | `deleteTorrentsTag()` / `removeTags()` / `addTags()`;`deleteTagDefinitions()` | 保留 | +| `torrents_set_force_start` | `setForceStart()` | 保留;是否调用由上层按实例 `force_resume` 配置决定 | +| `get_torrent_id_by_tag` / `__get_last_add_torrentid_by_tag` | `submit()` 内探针标签轮询 | 保留 9 次 × 3s 默认(首查不等待)、用后删标签与全局定义;调整:探针前缀 `uhd-probe-`(QA13),hash 已知时不用探针(QK2) | +| `start_torrents` / `stop_torrents` | `resume()` / `pause()` | 5.x 端点改名(stop/start)时自动切换 | +| `delete_torrents` | `delete(hashes, { deleteFiles })` | 调整:`deleteFiles` 必须显式,删数据前的引用检查在上层(QA11/QA12) | +| `get_files` | `files(hash, { retries, intervalMs })` | 保留:空列表视为未就绪继续重试 | +| `set_files` | `setFilePriority()` | 保留:无 hash/无文件 ID 返回 false 不发请求 | +| `recheck_torrents` / `set_torrent_location` / `set_torrent_category` / `change_torrent` | `recheck()` / `setLocation()` / `setCategory()` / `changeTorrent()` | 保留 KB/s→B/s 换算、仅做种时长时 ratio=-2 | +| `get_content_layout` / `transfer_info` | `contentLayout()` / `transferInfo()` | 只读 | +| `QbittorrentModule.download` 步骤 1-7 | `submit()` | 调整:本地 infohash 预查已存在 → `already_exists` 不改其路径/标签(QA1/QA2);add 超时/断连 → 按 hash 回查(QA8),返回判别联合(QA14)。选集(步骤 8-9)属 domain `selectTorrentFiles`,本适配器只提供 `files`/`setFilePriority` | +| `_get_torrent_info`(`_base/downloader.py`) | `parseTorrent()` / `parseMagnet()` | 新增 v1/v2 infohash(原始 info 字节切片求哈希) | +| `normalize_path` / `normalize_return_path` | `toDownloaderPath()` / `fromDownloaderPath()` | 保留首条命中 | + +### TMDB(`packages/adapters/src/tmdb/`) + +| 上游函数 | TS | 保留 / 调整 | +| --- | --- | --- | +| `search_movies` / `search_tvs` / `search_multiis` | `search()`(多语言合并)/ `searchRaw()`(首选语言)/ `searchMulti()` | 电影年份参数 `year`,剧集 `first_air_date_year`;multi 只保留 movie/tv | +| `__compare_names` + `foundation/text.py remove_punctuation` | `compareNames()` / `normalizeTitleForCompare()` | 保留同一标点集、去零宽字符与空白、大写后**完全相等** | +| `_generate_year_range` | `yearRange()` | 保留 [year, year+1, year-1] | +| `match` / `_build_match_plan` / `_match_resolution` | `matchTmdb()` | 保留步骤顺序(季年份优先,再名称+年份容差)与首个命中短路;调整:查询异常抛 `TmdbError`,不当作"未找到"继续 | +| `__search_movie_by_name` / `__search_tv_by_name` / `_build_name_candidate_plan` / `_name_resolution` / `_alias_matches` / `__get_names` | `matchTmdb()` 内 `searchByName` | 保留:按日期降序、年份前缀过滤、标题/原标题直达,否则逐个拉详情用别名(alternative_titles)+ 译名(translations)比较 | +| `__search_tv_by_season` / `_season_candidate_matches` / `_season_year_matches` | `matchTmdb()` 内 `searchBySeason` | 保留;剧集组 `group_seasons` 未迁移 | +| `match_multi` / `_sort_multi_results` | `matchTmdbMulti()` | 保留电影优先、日期降序 | +| `__get_movie_detail` / `__get_tv_detail` | `details()` | 附带 `alternative_titles,external_ids,translations`;输出归一化 `MetadataCandidate` | +| `get_tv_season_detail` | `season()` | 保留 | +| `tmdbv3api/tmdb.py request` 的 `@cached` | `TtlLruCache` + 并发合并 | 保留 maxsize 1024 / TTL 24h(可配)/ 空结果 30min / 失败不缓存;新增并发合并与 429 Retry-After 退避 | +| `cache.py TmdbCache`(识别结果持久缓存) | 未迁移 | 识别结果持久化归 db/domain(`external_identity`) | + +### Emby / Jellyfin(`packages/adapters/src/emby/`) + +| 上游函数 | TS | 保留 / 调整 | +| --- | --- | --- | +| `Emby.get_user` | `resolveUserId()` | 保留:用户名匹配 → 管理员回退 | +| `__get_emby_series_id_by_name` | `findSeriesIdByName()` | 保留 SearchTerm + 名称完全相等 + 年份;调整:连不通 → `unknown`,未找到 → `ok(null)` | +| `get_movies` | `getMovies()` | 保留名称完全相等 + 年份 + 身份兼容 | +| `get_tv_episodes` | `getTvEpisodes()` | 保留 `IsMissing=false`、按 ParentIndexNumber/IndexNumber 汇总、缓存 itemId 失效时按标题重查;新增 Virtual 过滤与多集文件(IndexNumberEnd)展开 | +| `get_iteminfo` | `getItem()` | 单条目 404 → `ok(null)`(ID 失效),其他失败 → `unknown` | +| `MediaServerIdentityHelper.from_provider_ids` / `are_compatible` | `providerIdentity()` / `identitiesCompatible()` | 保留来源优先级与"同来源不同 ID 才冲突" | +| (新增) | `searchItems()` / `findByProviderId()` / `seasons()` / `episodes()` / `normalizeMediaSource()` | MediaSources 版本信息;查询截断时不给"不存在" | +| `jellyfin.py` 认证 | `flavor: "jellyfin"` | 保留 `Authorization: MediaBrowser Token=`、无 `/emby` 前缀;调整:Emby 也用请求头 `X-Emby-Token`,不把 api_key 放进 URL | + +### 文件系统(`packages/adapters/src/fs/`) + +对照 `docs/porting/moviepilot-transfer.md` §2.6:`_write_atomically`/`_partial_path`/`_cleanup_stale_partials` → `copyWithVerify`/`partialPathFor`/`sweepStalePartials`(新增 fsync、校验、no-clobber);`LocalStorage.move` → `moveSafe`(EXDEV 不降级)+ `crossDeviceMove`(三步可重入)。`statSafe`/`scanIncremental`/`ffprobe`/`diskUsage` 为本项目新增。 diff --git a/packages/adapters/package.json b/packages/adapters/package.json index 2e892b3a..5685ebb4 100644 --- a/packages/adapters/package.json +++ b/packages/adapters/package.json @@ -9,5 +9,8 @@ "scripts": { "typecheck": "tsc -p tsconfig.json", "test": "vitest run" + }, + "devDependencies": { + "@umt/testkit": "workspace:*" } } diff --git a/packages/adapters/src/emby/README.md b/packages/adapters/src/emby/README.md new file mode 100644 index 00000000..dd2e3a57 --- /dev/null +++ b/packages/adapters/src/emby/README.md @@ -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 文档验证,未连接真实实例。 diff --git a/packages/adapters/src/emby/client.ts b/packages/adapters/src/emby/client.ts new file mode 100644 index 00000000..61623611 --- /dev/null +++ b/packages/adapters/src/emby/client.ts @@ -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 = + | { 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; + 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; + 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 | 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; +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(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 = {}; + 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(e: unknown): Observed { + 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 = {}): Promise { + 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 = { 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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 } | null>> { + let id = opts.itemId; + const byName = async () => { + if (!opts.title) return { status: "ok", value: null, observedAt: this.now() } as Observed; + 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 = {}; + 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(); + 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()]; + } +} diff --git a/packages/adapters/src/emby/emby.test.ts b/packages/adapters/src/emby/emby.test.ts new file mode 100644 index 00000000..4da4ddb2 --- /dev/null +++ b/packages/adapters/src/emby/emby.test.ts @@ -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 = {}) { + 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); + }); +}); diff --git a/packages/adapters/src/emby/index.ts b/packages/adapters/src/emby/index.ts new file mode 100644 index 00000000..5ec76921 --- /dev/null +++ b/packages/adapters/src/emby/index.ts @@ -0,0 +1 @@ +export * from "./client"; diff --git a/packages/adapters/src/fs/README.md b/packages/adapters/src/fs/README.md new file mode 100644 index 00000000..b48f8870 --- /dev/null +++ b/packages/adapters/src/fs/README.md @@ -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/网络挂载上验证。 diff --git a/packages/adapters/src/fs/copy.ts b/packages/adapters/src/fs/copy.ts new file mode 100644 index 00000000..bca2fb97 --- /dev/null +++ b/packages/adapters/src/fs/copy.ts @@ -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 { + let fh: Awaited> | 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 { + const h = createHash("sha256"); + await pipeline(createReadStream(p), async (src: AsyncIterable) => { + 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 { + 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(); + 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 { + 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 { + const started = Date.now(); + const method = opts.hash ?? "size"; + let srcStat: Awaited>; + 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 { + 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 { + 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; +} diff --git a/packages/adapters/src/fs/errors.ts b/packages/adapters/src/fs/errors.ts new file mode 100644 index 00000000..6152c674 --- /dev/null +++ b/packages/adapters/src/fs/errors.ts @@ -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, + }); +} diff --git a/packages/adapters/src/fs/ffprobe.ts b/packages/adapters/src/fs/ffprobe.ts new file mode 100644 index 00000000..3b21ba41 --- /dev/null +++ b/packages/adapters/src/fs/ffprobe.ts @@ -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 & { + tags?: Record; + 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(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 { + 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; 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 { + 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) }; + } +} diff --git a/packages/adapters/src/fs/fs.test.ts b/packages/adapters/src/fs/fs.test.ts new file mode 100644 index 00000000..fd402021 --- /dev/null +++ b/packages/adapters/src/fs/fs.test.ts @@ -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) { + 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 = {}; + 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" }); + } + }); +}); diff --git a/packages/adapters/src/fs/index.ts b/packages/adapters/src/fs/index.ts new file mode 100644 index 00000000..e4663287 --- /dev/null +++ b/packages/adapters/src/fs/index.ts @@ -0,0 +1,6 @@ +export * from "./copy"; +export * from "./errors"; +export * from "./ffprobe"; +export * from "./move"; +export * from "./scan"; +export * from "./stat"; diff --git a/packages/adapters/src/fs/move.ts b/packages/adapters/src/fs/move.ts new file mode 100644 index 00000000..adca8b20 --- /dev/null +++ b/packages/adapters/src/fs/move.ts @@ -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 { + let s: Awaited>; + 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 { + 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 { + hash?: VerifyMethod; + /** 上次运行的步骤日志,用于中断续跑(只作参考,实际仍以文件系统观察为准) */ + previous?: CrossMoveStepLog[]; +} + +async function exists(p: string): Promise { + 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 { + 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 }; +} diff --git a/packages/adapters/src/fs/scan.ts b/packages/adapters/src/fs/scan.ts new file mode 100644 index 00000000..287a97c1 --- /dev/null +++ b/packages/adapters/src/fs/scan.ts @@ -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 { + 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 => { + 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; +} diff --git a/packages/adapters/src/fs/stat.ts b/packages/adapters/src/fs/stat.ts new file mode 100644 index 00000000..f8c854b1 --- /dev/null +++ b/packages/adapters/src/fs/stat.ts @@ -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 { + 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 { + 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>): 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 { + 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) }; + } +} diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts index bcfe54d8..97e0c2f9 100644 --- a/packages/adapters/src/index.ts +++ b/packages/adapters/src/index.ts @@ -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"; diff --git a/packages/adapters/src/qbittorrent/README.md b/packages/adapters/src/qbittorrent/README.md new file mode 100644 index 00000000..b889bd53 --- /dev/null +++ b/packages/adapters/src/qbittorrent/README.md @@ -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 文档验证,未连接真实实例。 diff --git a/packages/adapters/src/qbittorrent/client.ts b/packages/adapters/src/qbittorrent/client.ts new file mode 100644 index 00000000..afba2484 --- /dev/null +++ b/packages/adapters/src/qbittorrent/client.ts @@ -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>; + torrentsRemoved: string[]; + serverState: Record | null; +} + +/** 下载器端口(实例维度)。上层 tasks/agent 只依赖此接口。 */ +export interface DownloaderClient { + readonly instanceId: string; + version(): Promise<{ app: string; webApi: string }>; + add(req: AddTorrentRequest): Promise; + submit(req: AddTorrentRequest): Promise; + list(q?: TorrentQuery): Promise; + /** 严格完成判定后的已完成任务(对照 get_completed_torrents) */ + getCompleted(q?: Pick): Promise; + getDownloading(q?: Pick): Promise; + findByHash(hash: string): Promise; + files(hash: string, opts?: { retries?: number; intervalMs?: number }): Promise; + /** priority 0 不下载;fileIds 为空返回 false 且不发请求(对照 set_files) */ + setFilePriority(hash: string, fileIds: number[], priority: 0 | 1 | 6 | 7): Promise; + setForceStart(hashes: string[], enable?: boolean): Promise; + recheck(hashes: string[]): Promise; + setLocation(hashes: string[], location: string): Promise; + setCategory(hashes: string[], category: string): Promise; + changeTorrent(hash: string, opts: TorrentLimits): Promise; + /** 只读下载器全局偏好(不改写,见 QA6) */ + preferences(): Promise>; + contentLayout(): Promise<"Original" | "Subfolder" | "NoSubfolder">; + transferInfo(): Promise>; + pause(hashes: string[]): Promise; + resume(hashes: string[]): Promise; + addTags(hashes: string[], tags: string[]): Promise; + removeTags(hashes: string[], tags: string[]): Promise; + deleteTagDefinitions(tags: string[]): Promise; + /** 从任务移除标签并删除全局定义;hashes 为 null 时只删定义(对照 delete_torrents_tag) */ + deleteTorrentsTag(hashes: string[] | null, tags: string[]): Promise; + /** deleteFiles 必须显式给出;删数据前的引用检查由上层 assertSafeToDeleteData 负责 */ + delete(hashes: string[], opts: { deleteFiles: boolean }): Promise; + mainData(rid?: number): Promise; +} + +export interface TorrentLimits { + /** KB/s,0 不限速 */ + uploadLimitKBps?: number; + downloadLimitKBps?: number; + ratioLimit?: number; + /** 分钟 */ + seedingTimeLimitMin?: number; +} + +type Body = { kind: "form"; data: Record } | { kind: "multipart"; data: FormData }; + +interface ReqOpts { + query?: Record; + 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): 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 | 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 { + 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 { + if (this.cfg.apiKey) return; + if (!this.loginInFlight) { + this.loginInFlight = this.doLogin().finally(() => { + this.loginInFlight = null; + }); + } + return this.loginInFlight; + } + + private async doLogin(): Promise { + 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 { + 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 { + if (!this.cfg.apiKey && this.sid === null) await this.login(); + const headers: Record = { 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(endpoint: string, query?: Record): Promise { + 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, idempotent = true): Promise { + 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 { + 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 { + const need = [...(q.tag !== undefined ? [q.tag] : []), ...(q.tags ?? [])]; + const rows = await this.json[]>("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 = {}): Promise { + return (await this.list({ ...q, filter: "completed" })).filter((t) => t.complete); + } + + async getDownloading(q: Pick = {}): Promise { + return this.list({ ...q, filter: "downloading" }); + } + + /** 按 hash 精确查询,不受管理标签限制。查不到返回 null;下载器不可达抛错(不能当作不存在)。 */ + async findByHash(hash: string): Promise { + 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 { + const tries = Math.max(1, opts.retries ?? 1); + let rows: Record[] = []; + for (let i = 0; i < tries; i++) { + if (i > 0 && opts.intervalMs) await sleep(opts.intervalMs); + rows = await this.json[]>("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 { + 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 { + return this.pauseLike(hashes, "torrents/pause", "torrents/stop"); + } + resume(hashes: string[]): Promise { + return this.pauseLike(hashes, "torrents/resume", "torrents/start"); + } + + async addTags(hashes: string[], tags: string[]): Promise { + await this.post("torrents/addTags", { hashes: hashes.join("|"), tags: tags.join(",") }); + } + async removeTags(hashes: string[], tags: string[]): Promise { + await this.post("torrents/removeTags", { hashes: hashes.join("|"), tags: tags.join(",") }); + } + async deleteTagDefinitions(tags: string[]): Promise { + await this.post("torrents/deleteTags", { tags: tags.join(",") }); + } + async deleteTorrentsTag(hashes: string[] | null, tags: string[]): Promise { + if (hashes?.length) await this.removeTags(hashes, tags); + await this.deleteTagDefinitions(tags); + } + + async setFilePriority(hash: string, fileIds: number[], priority: 0 | 1 | 6 | 7): Promise { + 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 { + if (!hashes.length) return; + await this.post("torrents/setForceStart", { hashes: hashes.join("|"), value: String(enable) }); + } + + async recheck(hashes: string[]): Promise { + if (!hashes.length) return; + await this.post("torrents/recheck", { hashes: hashes.join("|") }); + } + + async setLocation(hashes: string[], location: string): Promise { + if (!hashes.length || !location) return; + await this.post("torrents/setLocation", { hashes: hashes.join("|"), location }); + } + + async setCategory(hashes: string[], category: string): Promise { + 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 { + 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> { + return this.json>("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> { + return this.json>("transfer/info"); + } + + async delete(hashes: string[], opts: { deleteFiles: boolean }): Promise { + 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 { + const j = await this.json>("sync/maindata", { rid: String(rid) }); + return { + rid: num(j.rid) ?? 0, + fullUpdate: j.full_update === true, + torrents: (j.torrents as Record>) ?? {}, + torrentsRemoved: (j.torrents_removed as string[]) ?? [], + serverState: (j.server_state as Record) ?? null, + }; + } + + private async pollHash(hash: string): Promise { + 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 { + 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 { + 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 { + 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 } { + return new QbClient(cfg); +} + +/** 多实例注册表:按 instanceId 取客户端。 */ +export class DownloaderRegistry { + private readonly clients = new Map(); + 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()]; + } +} diff --git a/packages/adapters/src/qbittorrent/errors.ts b/packages/adapters/src/qbittorrent/errors.ts new file mode 100644 index 00000000..8b606395 --- /dev/null +++ b/packages/adapters/src/qbittorrent/errors.ts @@ -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; +} diff --git a/packages/adapters/src/qbittorrent/index.ts b/packages/adapters/src/qbittorrent/index.ts new file mode 100644 index 00000000..13b1b2ad --- /dev/null +++ b/packages/adapters/src/qbittorrent/index.ts @@ -0,0 +1,5 @@ +export * from "./client"; +export * from "./errors"; +export * from "./paths"; +export * from "./state"; +export * from "./torrent"; diff --git a/packages/adapters/src/qbittorrent/paths.ts b/packages/adapters/src/qbittorrent/paths.ts new file mode 100644 index 00000000..7416da70 --- /dev/null +++ b/packages/adapters/src/qbittorrent/paths.ts @@ -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; +} diff --git a/packages/adapters/src/qbittorrent/qbittorrent.test.ts b/packages/adapters/src/qbittorrent/qbittorrent.test.ts new file mode 100644 index 00000000..b94da50f --- /dev/null +++ b/packages/adapters/src/qbittorrent/qbittorrent.test.ts @@ -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[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); + }); +}); diff --git a/packages/adapters/src/qbittorrent/state.ts b/packages/adapters/src/qbittorrent/state.ts new file mode 100644 index 00000000..3b5f4ace --- /dev/null +++ b/packages/adapters/src/qbittorrent/state.ts @@ -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 = { + 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; +} diff --git a/packages/adapters/src/qbittorrent/torrent.ts b/packages/adapters/src/qbittorrent/torrent.ts new file mode 100644 index 00000000..2186fa5d --- /dev/null +++ b/packages/adapters/src/qbittorrent/torrent.ts @@ -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") }; +} diff --git a/packages/adapters/src/tmdb/README.md b/packages/adapters/src/tmdb/README.md new file mode 100644 index 00000000..9fb7ebc5 --- /dev/null +++ b/packages/adapters/src/tmdb/README.md @@ -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 文档验证,未连接真实实例。 diff --git a/packages/adapters/src/tmdb/cache.ts b/packages/adapters/src/tmdb/cache.ts new file mode 100644 index 00000000..c85fa954 --- /dev/null +++ b/packages/adapters/src/tmdb/cache.ts @@ -0,0 +1,93 @@ +/** 内存 LRU + TTL 缓存,带并发合并(同 key 在途请求只发一次)。 */ +export class TtlLruCache { + private readonly map = new Map(); + private readonly inflight = new Map>(); + 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, ttlFor?: (v: V) => number | undefined): Promise { + 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(fn: () => Promise): Promise { + if (this.active >= this.maxConcurrent) await new Promise((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()?.(); + } + } +} diff --git a/packages/adapters/src/tmdb/client.ts b/packages/adapters/src/tmdb/client.ts new file mode 100644 index 00000000..9373f836 --- /dev/null +++ b/packages/adapters/src/tmdb/client.ts @@ -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; + +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; + 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(path: string, params: Record): Promise { + 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; + } + + private async fetchWithRetry(path: string, qs: URLSearchParams): Promise { + 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 { + const q = new URLSearchParams(qs); + const headers: Record = { 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 { + const lang = this.langs[0]!; + const p = await this.get(`/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 { + const lang = this.langs[0]!; + const p = await this.get("/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 { + 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(`/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(); + 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 { + 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 { + const [first, ...rest] = await Promise.all( + this.langs.map((lang, i) => + this.get(`/${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(); + 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 { + const j = await this.get(`/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); +} diff --git a/packages/adapters/src/tmdb/index.ts b/packages/adapters/src/tmdb/index.ts new file mode 100644 index 00000000..5420dad1 --- /dev/null +++ b/packages/adapters/src/tmdb/index.ts @@ -0,0 +1,3 @@ +export { RequestLimiter, TtlLruCache } from "./cache"; +export * from "./client"; +export * from "./match"; diff --git a/packages/adapters/src/tmdb/match.ts b/packages/adapters/src/tmdb/match.ts new file mode 100644 index 00000000..2923d769 --- /dev/null +++ b/packages/adapters/src/tmdb/match.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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)); +} diff --git a/packages/adapters/src/tmdb/tmdb.test.ts b/packages/adapters/src/tmdb/tmdb.test.ts new file mode 100644 index 00000000..9ae6c7a8 --- /dev/null +++ b/packages/adapters/src/tmdb/tmdb.test.ts @@ -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 = {}) { + 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" }); + }); +}); diff --git a/packages/testkit/src/emby/fixtures.ts b/packages/testkit/src/emby/fixtures.ts new file mode 100644 index 00000000..cd0e4467 --- /dev/null +++ b/packages/testkit/src/emby/fixtures.ts @@ -0,0 +1,166 @@ +/** mock Emby/Jellyfin 库存数据(字段形状按 Emby BaseItemDto,取值为虚构)。 */ +export interface EmbyFixtureItem { + Id: string; + Name: string; + Type: "Movie" | "Series" | "Season" | "Episode"; + OriginalTitle?: string; + ProductionYear?: number; + ProviderIds?: Record; + Path?: string; + SeriesId?: string; + SeriesName?: string; + SeasonId?: string; + ParentIndexNumber?: number; + IndexNumber?: number; + IndexNumberEnd?: number; + LocationType?: "FileSystem" | "Virtual"; + MediaSources?: Record[]; +} + +const src4k = { + Id: "ms-m1-4k", + Name: "2160p REMUX", + Path: "/media/movies/奥本海默 (2023)/奥本海默 (2023) - 2160p.mkv", + Container: "mkv", + Size: 80_000_000_000, + Bitrate: 72_000_000, + MediaStreams: [ + { Type: "Video", Codec: "hevc", Width: 3840, Height: 2160, VideoRange: "HDR", BitDepth: 10, Profile: "Main 10" }, + { Type: "Audio", Codec: "truehd", Channels: 8, Language: "eng", DisplayTitle: "TrueHD 7.1", IsDefault: true }, + { Type: "Audio", Codec: "ac3", Channels: 6, Language: "chi", Title: "国语" }, + { Type: "Subtitle", Codec: "subrip", Language: "chi", IsExternal: true }, + ], +}; +const src1080 = { + Id: "ms-m1-1080", + Name: "1080p", + Path: "/media/movies/奥本海默 (2023)/奥本海默 (2023) - 1080p.mkv", + Container: "mkv", + Size: 20_000_000_000, + MediaStreams: [ + { Type: "Video", Codec: "h264", Width: 1920, Height: 800, VideoRange: "SDR" }, + { Type: "Audio", Codec: "dts", Channels: 6, Language: "eng" }, + ], +}; + +function ep( + series: string, + seriesName: string, + season: string, + s: number, + e: number, + extra: Partial = {}, +) { + const virtual = extra.LocationType === "Virtual"; + return { + Id: `${series}-s${s}e${e}`, + Name: `第 ${e} 集`, + Type: "Episode" as const, + SeriesId: series, + SeriesName: seriesName, + SeasonId: season, + ParentIndexNumber: s, + IndexNumber: e, + LocationType: "FileSystem" as const, + ...(virtual + ? {} + : { + Path: `/media/tv/${seriesName}/Season ${s}/${seriesName} - S${String(s).padStart(2, "0")}E${String(e).padStart(2, "0")}.mkv`, + MediaSources: [ + { + Id: `ms-${series}-${s}-${e}`, + Container: "mkv", + MediaStreams: [ + { Type: "Video", Codec: "hevc", Width: 1920, Height: 1080 }, + { Type: "Audio", Codec: "eac3", Channels: 6 }, + ], + }, + ], + }), + ...extra, + }; +} + +export const EMBY_FIXTURE_USERS = [ + { Id: "u-admin", Name: "admin", Policy: { IsAdministrator: true } }, + { Id: "u-kid", Name: "kid", Policy: { IsAdministrator: false } }, +]; + +export const EMBY_FIXTURE_ITEMS: EmbyFixtureItem[] = [ + { + Id: "m1", + Name: "奥本海默", + OriginalTitle: "Oppenheimer", + Type: "Movie", + ProductionYear: 2023, + ProviderIds: { Tmdb: "872585", Imdb: "tt15398776" }, + Path: src4k.Path, + LocationType: "FileSystem", + MediaSources: [src4k, src1080], + }, + { + Id: "m2", + Name: "流浪地球2", + Type: "Movie", + ProviderIds: { Tmdb: "842675" }, + Path: "/media/movies/流浪地球2 (2023)/流浪地球2.mkv", + LocationType: "FileSystem", + // 故意缺字段:无 Size/VideoRange/音轨语言,用于验证"未知不补值" + MediaSources: [ + { + Id: "ms-m2", + Path: "/media/movies/流浪地球2 (2023)/流浪地球2.mkv", + MediaStreams: [{ Type: "Video", Codec: "hevc" }, { Type: "Audio" }], + }, + ], + }, + { + Id: "s1", + Name: "熊家餐馆", + OriginalTitle: "The Bear", + Type: "Series", + ProductionYear: 2022, + ProviderIds: { Tmdb: "136315", Tvdb: "401654" }, + Path: "/media/tv/熊家餐馆", + LocationType: "FileSystem", + }, + { + Id: "s1-se1", + Name: "第 1 季", + Type: "Season", + SeriesId: "s1", + SeriesName: "熊家餐馆", + IndexNumber: 1, + LocationType: "FileSystem", + }, + { + Id: "s1-se2", + Name: "第 2 季", + Type: "Season", + SeriesId: "s1", + SeriesName: "熊家餐馆", + IndexNumber: 2, + LocationType: "FileSystem", + }, + ...Array.from({ length: 8 }, (_, i) => ep("s1", "熊家餐馆", "s1-se1", 1, i + 1)), + ...[1, 2, 3].map((e) => ep("s1", "熊家餐馆", "s1-se2", 2, e)), + ep("s1", "熊家餐馆", "s1-se2", 2, 4, { LocationType: "Virtual" }), + { + Id: "s2", + Name: "向往的生活", + Type: "Series", + Path: "/media/variety/向往的生活", + LocationType: "FileSystem", + }, + { + Id: "s2-se1", + Name: "第 1 季", + Type: "Season", + SeriesId: "s2", + SeriesName: "向往的生活", + IndexNumber: 1, + LocationType: "FileSystem", + }, + ep("s2", "向往的生活", "s2-se1", 1, 1), + ep("s2", "向往的生活", "s2-se1", 1, 3, { IndexNumberEnd: 4 }), +]; diff --git a/packages/testkit/src/emby/index.ts b/packages/testkit/src/emby/index.ts new file mode 100644 index 00000000..9ce06bd1 --- /dev/null +++ b/packages/testkit/src/emby/index.ts @@ -0,0 +1,2 @@ +export * from "./fixtures"; +export * from "./mock-emby"; diff --git a/packages/testkit/src/emby/mock-emby.ts b/packages/testkit/src/emby/mock-emby.ts new file mode 100644 index 00000000..f22f0f23 --- /dev/null +++ b/packages/testkit/src/emby/mock-emby.ts @@ -0,0 +1,122 @@ +import { type MockHttp, type MockReply, startMockHttp } from "../tmdb/mock-http"; +import { EMBY_FIXTURE_ITEMS, EMBY_FIXTURE_USERS, type EmbyFixtureItem } from "./fixtures"; + +export interface MockEmbyOptions { + flavor?: "emby" | "jellyfin"; + apiKey?: string; + items?: EmbyFixtureItem[]; + users?: { Id: string; Name: string; Policy?: { IsAdministrator?: boolean } }[]; + port?: number; +} + +export type MockEmby = MockHttp & { items: EmbyFixtureItem[]; flavor: "emby" | "jellyfin" }; + +/** + * mock Emby / Jellyfin:/Users、/Users/{uid}/Items、/Items、/Users/{uid}/Items/{id}、/Shows/{id}/Seasons、 + * /Shows/{id}/Episodes、/System/Info/Public。Emby 支持 AnyProviderIdEquals;Jellyfin flavor 忽略该参数。 + * 路径可带 /emby 前缀。Fields 未请求的字段(ProviderIds/Path/MediaSources/OriginalTitle)不返回。 + */ +export async function createMockEmby(opts: MockEmbyOptions = {}): Promise { + const flavor = opts.flavor ?? "emby"; + const apiKey = opts.apiKey ?? "test-emby-key"; + const items = opts.items ?? EMBY_FIXTURE_ITEMS; + const users = opts.users ?? EMBY_FIXTURE_USERS; + + const project = (it: EmbyFixtureItem, fields: Set) => { + const { ProviderIds, Path, MediaSources, OriginalTitle, ...rest } = it; + return { + ...rest, + ...(fields.has("providerids") && ProviderIds ? { ProviderIds } : {}), + ...(fields.has("path") && Path ? { Path } : {}), + ...(fields.has("mediasources") && MediaSources ? { MediaSources } : {}), + ...(fields.has("originaltitle") && OriginalTitle ? { OriginalTitle } : {}), + }; + }; + const fieldsOf = (q: Record) => + new Set( + (q.Fields ?? q.fields ?? "") + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean), + ); + const page = (list: EmbyFixtureItem[], q: Record): MockReply => { + const start = Number(q.StartIndex ?? 0); + const limit = q.Limit ? Number(q.Limit) : list.length; + const f = fieldsOf(q); + return { + json: { Items: list.slice(start, start + limit).map((i) => project(i, f)), TotalRecordCount: list.length }, + }; + }; + const knownUser = (uid: string | undefined) => !uid || users.some((u) => u.Id === uid); + const unauthorizedUser: MockReply = { status: 400, json: "Invalid user" }; + + const queryItems = (q: Record) => { + let list = items; + const types = (q.IncludeItemTypes ?? "").split(",").filter(Boolean); + if (types.length) list = list.filter((i) => types.includes(i.Type)); + if (q.ParentId) list = list.filter((i) => i.SeriesId === q.ParentId || i.SeasonId === q.ParentId); + const term = (q.SearchTerm ?? q.searchTerm ?? "").toLowerCase(); + if (term) list = list.filter((i) => [i.Name, i.OriginalTitle].some((t) => t?.toLowerCase().includes(term))); + if (flavor === "emby" && q.AnyProviderIdEquals) { + const wanted = q.AnyProviderIdEquals.split(",").map((s) => s.toLowerCase()); + list = list.filter((i) => + Object.entries(i.ProviderIds ?? {}).some(([k, v]) => wanted.includes(`${k.toLowerCase()}.${v.toLowerCase()}`)), + ); + } + return list; + }; + + const r = (re: string) => new RegExp(`^(?:/emby)?${re}$`, "i"); + const http = await startMockHttp( + [ + [ + r("/System/Info/Public"), + () => ({ json: { ServerName: "mock", Version: flavor === "emby" ? "4.8.10.0" : "10.10.3", Id: "srv" } }), + ], + [r("/Users"), () => ({ json: users })], + [ + r("/Users/([^/]+)/Items/([^/]+)"), + (m, q) => { + if (!knownUser(m[1])) return unauthorizedUser; + const it = items.find((i) => i.Id === m[2]); + return it + ? { json: project(it, fieldsOf({ Fields: q.Fields ?? "ProviderIds,Path,MediaSources,OriginalTitle" })) } + : { status: 404, json: "Not Found" }; + }, + ], + [r("/Users/([^/]+)/Items"), (m, q) => (knownUser(m[1]) ? page(queryItems(q), q) : unauthorizedUser)], + [r("/Items"), (_m, q) => (knownUser(q.UserId ?? q.userId) ? page(queryItems(q), q) : unauthorizedUser)], + [ + r("/Shows/([^/]+)/Seasons"), + (m, q) => { + if (!items.some((i) => i.Id === m[1] && i.Type === "Series")) return { status: 404, json: "Not Found" }; + return page( + items.filter((i) => i.Type === "Season" && i.SeriesId === m[1]), + q, + ); + }, + ], + [ + r("/Shows/([^/]+)/Episodes"), + (m, q) => { + if (!items.some((i) => i.Id === m[1] && i.Type === "Series")) return { status: 404, json: "Not Found" }; + let list = items.filter((i) => i.Type === "Episode" && i.SeriesId === m[1]); + if (q.SeasonId) list = list.filter((i) => i.SeasonId === q.SeasonId); + if (q.Season) list = list.filter((i) => String(i.ParentIndexNumber) === q.Season); + return page(list, q); + }, + ], + ], + { + ...(opts.port !== undefined ? { port: opts.port } : {}), + guard: (req, q) => { + if (/\/System\/Info\/Public$/i.test(new URL(req.url ?? "/", "http://x").pathname)) return null; + const h = req.headers["x-emby-token"]; + const auth = /Token="([^"]+)"/.exec(req.headers.authorization ?? "")?.[1]; + const ok = h === apiKey || auth === apiKey || q.api_key === apiKey || q.ApiKey === apiKey; + return ok ? null : { status: 401, json: "Access token is invalid or expired." }; + }, + }, + ); + return Object.assign(http, { items, flavor }); +} diff --git a/packages/testkit/src/fs/index.ts b/packages/testkit/src/fs/index.ts new file mode 100644 index 00000000..542a4549 --- /dev/null +++ b/packages/testkit/src/fs/index.ts @@ -0,0 +1 @@ +export * from "./temp-tree"; diff --git a/packages/testkit/src/fs/temp-tree.ts b/packages/testkit/src/fs/temp-tree.ts new file mode 100644 index 00000000..415a792b --- /dev/null +++ b/packages/testkit/src/fs/temp-tree.ts @@ -0,0 +1,60 @@ +import { randomBytes } from "node:crypto"; +import { existsSync, statSync } from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +export interface TempTree { + root: string; + /** 根目录下的绝对路径 */ + path(rel: string): string; + /** 写文件(自动建父目录);content 为数字时写入该长度的随机字节 */ + write(rel: string, content: string | Buffer | number): Promise; + cleanup(): Promise; +} + +/** + * 临时目录夹具。spec 的 key 为相对路径(以 / 结尾表示目录),value 同 write。 + * sentinel 为真时写入库根哨兵 .uhd-root。 + */ +export async function createTempTree( + spec: Record = {}, + opts: { base?: string; sentinel?: boolean | string; prefix?: string } = {}, +): Promise { + const root = await mkdtemp(join(opts.base ?? tmpdir(), opts.prefix ?? "umt-fs-")); + const tree: TempTree = { + root, + path: (rel) => join(root, rel), + write: async (rel, content) => { + const p = join(root, rel); + await mkdir(dirname(p), { recursive: true }); + await writeFile(p, typeof content === "number" ? randomBytes(content) : content); + return p; + }, + cleanup: () => rm(root, { recursive: true, force: true }), + }; + if (opts.sentinel) { + await writeFile(join(root, typeof opts.sentinel === "string" ? opts.sentinel : ".uhd-root"), "sentinel\n"); + } + for (const [rel, v] of Object.entries(spec)) { + if (rel.endsWith("/") || v === null) await mkdir(join(root, rel), { recursive: true }); + else await tree.write(rel, v); + } + return tree; +} + +/** + * 找一个与 os.tmpdir() 不在同一设备上的可写目录(优先 /dev/shm),用于 EXDEV 测试。 + * 找不到返回 null,测试应 skip 并注明。 + */ +export function findCrossDeviceBase(candidates = ["/dev/shm", "/run/user", "/var/tmp"]): string | null { + const baseDev = statSync(tmpdir()).dev; + for (const c of candidates) { + try { + if (existsSync(c) && statSync(c).dev !== baseDev) return c; + } catch { + // ignore + } + } + return null; +} diff --git a/packages/testkit/src/index.ts b/packages/testkit/src/index.ts index 17bd6a81..8c2eea57 100644 --- a/packages/testkit/src/index.ts +++ b/packages/testkit/src/index.ts @@ -1,2 +1,6 @@ // @umt/testkit 公开入口;W1 起在此导出实现 export const PACKAGE_NAME = "@umt/testkit"; +export * from "./emby"; +export * from "./fs"; +export * from "./qbittorrent"; +export * from "./tmdb"; diff --git a/packages/testkit/src/qbittorrent/bencode.ts b/packages/testkit/src/qbittorrent/bencode.ts new file mode 100644 index 00000000..1dd6a127 --- /dev/null +++ b/packages/testkit/src/qbittorrent/bencode.ts @@ -0,0 +1,130 @@ +import { createHash } from "node:crypto"; + +/** 测试用 bencode 编解码与种子构造器(独立于 adapters 实现,用于交叉校验 infohash)。 */ +export type BVal = number | string | Uint8Array | BVal[] | { [k: string]: BVal }; + +export function bencode(v: BVal): Buffer { + if (typeof v === "number") return Buffer.from(`i${Math.trunc(v)}e`); + if (typeof v === "string") { + const b = Buffer.from(v, "utf8"); + return Buffer.concat([Buffer.from(`${b.length}:`), b]); + } + if (v instanceof Uint8Array) return Buffer.concat([Buffer.from(`${v.length}:`), v]); + if (Array.isArray(v)) return Buffer.concat([Buffer.from("l"), ...v.map(bencode), Buffer.from("e")]); + const keys = Object.keys(v).sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b))); + return Buffer.concat([Buffer.from("d"), ...keys.flatMap((k) => [bencode(k), bencode(v[k]!)]), Buffer.from("e")]); +} + +type Dec = number | Buffer | Dec[] | { [k: string]: Dec }; + +export function bdecodeLoose(buf: Buffer): Dec { + let pos = 0; + const readUntil = (ch: string) => { + const end = buf.indexOf(ch, pos); + if (end < 0) throw new Error("bad bencode"); + const s = buf.subarray(pos, end).toString(); + pos = end + 1; + return s; + }; + const val = (): Dec => { + const c = String.fromCharCode(buf[pos]!); + if (c === "i") { + pos++; + return Number(readUntil("e")); + } + if (c === "l") { + pos++; + const out: Dec[] = []; + while (buf[pos] !== 0x65) out.push(val()); + pos++; + return out; + } + if (c === "d") { + pos++; + const out: { [k: string]: Dec } = {}; + while (buf[pos] !== 0x65) { + const k = (val() as Buffer).toString(); + out[k] = val(); + } + pos++; + return out; + } + const n = Number(readUntil(":")); + const b = buf.subarray(pos, pos + n); + pos += n; + return b; + }; + return val(); +} + +export interface MakeTorrentInput { + name: string; + /** 省略则为单文件种子,长度用 length */ + files?: { path: string; length: number }[]; + length?: number; + pieceLength?: number; + private?: boolean; + announce?: string; + /** 改变伪 pieces 内容,用于构造同名同大小但 infohash 不同的种子 */ + pieceSeed?: number; +} + +export interface MadeTorrent { + bytes: Buffer; + hash: string; + name: string; + totalSize: number; + files: { path: string; length: number }[]; +} + +/** 构造一个合法的 v1 .torrent(pieces 为伪值,不可真正下载)。 */ +export function makeTorrent(input: MakeTorrentInput): MadeTorrent { + const pieceLength = input.pieceLength ?? 4 * 1024 * 1024; + const info: { [k: string]: BVal } = { name: input.name, "piece length": pieceLength }; + let files: { path: string; length: number }[]; + if (input.files) { + info.files = input.files.map((f) => ({ length: f.length, path: f.path.split("/") })); + files = input.files.map((f) => ({ path: `${input.name}/${f.path}`, length: f.length })); + } else { + info.length = input.length ?? 1024 * 1024 * 1024; + files = [{ path: input.name, length: info.length as number }]; + } + const total = files.reduce((a, f) => a + f.length, 0); + const pieces = Math.max(1, Math.ceil(total / pieceLength)); + info.pieces = Buffer.alloc(20 * Math.min(pieces, 4), input.pieceSeed ?? 7); + if (input.private) info.private = 1; + const root: { [k: string]: BVal } = { announce: input.announce ?? "https://tracker.example.invalid/announce", info }; + const bytes = bencode(root); + const hash = createHash("sha1").update(bencode(info)).digest("hex"); + return { bytes, hash, name: input.name, totalSize: total, files }; +} + +export function makeMagnet(hash: string, name?: string): string { + return `magnet:?xt=urn:btih:${hash}${name ? `&dn=${encodeURIComponent(name)}` : ""}`; +} + +/** 从 .torrent 字节解析(mock 服务器使用):hash = sha1(bencode(info)) */ +export function inspectTorrent(bytes: Buffer): MadeTorrent { + const root = bdecodeLoose(bytes) as { [k: string]: Dec }; + const info = root.info as { [k: string]: Dec }; + if (!info) throw new Error("no info"); + const reenc = (d: Dec): BVal => + Buffer.isBuffer(d) + ? d + : Array.isArray(d) + ? d.map(reenc) + : typeof d === "number" + ? d + : Object.fromEntries(Object.entries(d).map(([k, v]) => [k, reenc(v)])); + const hash = createHash("sha1") + .update(bencode(reenc(info))) + .digest("hex"); + const name = (info.name as Buffer).toString(); + const files = Array.isArray(info.files) + ? (info.files as { [k: string]: Dec }[]).map((f) => ({ + path: [name, ...(f.path as Buffer[]).map((p) => p.toString())].join("/"), + length: f.length as number, + })) + : [{ path: name, length: info.length as number }]; + return { bytes, hash, name, totalSize: files.reduce((a, f) => a + f.length, 0), files }; +} diff --git a/packages/testkit/src/qbittorrent/http-util.ts b/packages/testkit/src/qbittorrent/http-util.ts new file mode 100644 index 00000000..2179aee5 --- /dev/null +++ b/packages/testkit/src/qbittorrent/http-util.ts @@ -0,0 +1,46 @@ +import type { IncomingMessage } from "node:http"; + +export async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + return Buffer.concat(chunks); +} + +export interface MultipartPart { + name: string; + filename?: string; + data: Buffer; +} + +/** 极简 multipart/form-data 解析,仅供 mock 使用。 */ +export function parseMultipart(body: Buffer, contentType: string): MultipartPart[] { + const m = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType); + if (!m) return []; + const boundary = Buffer.from(`--${m[1] ?? m[2]}`); + const parts: MultipartPart[] = []; + let pos = body.indexOf(boundary); + while (pos >= 0) { + const start = pos + boundary.length; + if (body.subarray(start, start + 2).toString() === "--") break; + const next = body.indexOf(boundary, start); + if (next < 0) break; + const chunk = body.subarray(start + 2, next - 2); // 去掉起始 CRLF 与结尾 CRLF + const sep = chunk.indexOf("\r\n\r\n"); + const head = chunk.subarray(0, sep).toString(); + const data = chunk.subarray(sep + 4); + const name = /name="([^"]*)"/.exec(head)?.[1] ?? ""; + const filename = /filename="([^"]*)"/.exec(head)?.[1]; + parts.push({ name, ...(filename !== undefined ? { filename } : {}), data }); + pos = next; + } + return parts; +} + +export function parseCookies(header: string | undefined): Record { + const out: Record = {}; + for (const p of (header ?? "").split(";")) { + const i = p.indexOf("="); + if (i > 0) out[p.slice(0, i).trim()] = p.slice(i + 1).trim(); + } + return out; +} diff --git a/packages/testkit/src/qbittorrent/index.ts b/packages/testkit/src/qbittorrent/index.ts new file mode 100644 index 00000000..4c3dea70 --- /dev/null +++ b/packages/testkit/src/qbittorrent/index.ts @@ -0,0 +1,8 @@ +export { + inspectTorrent as inspectTorrentFixture, + type MadeTorrent, + type MakeTorrentInput, + makeMagnet, + makeTorrent, +} from "./bencode"; +export * from "./mock-qb"; diff --git a/packages/testkit/src/qbittorrent/mock-qb.ts b/packages/testkit/src/qbittorrent/mock-qb.ts new file mode 100644 index 00000000..36a5cd52 --- /dev/null +++ b/packages/testkit/src/qbittorrent/mock-qb.ts @@ -0,0 +1,530 @@ +import { randomBytes } from "node:crypto"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { inspectTorrent } from "./bencode"; +import { parseCookies, parseMultipart, readBody } from "./http-util"; + +export interface MockQbFile { + name: string; + size: number; + progress: number; + priority: number; +} + +export interface MockQbTorrent { + hash: string; + name: string; + state: string; + progress: number; + size: number; + total_size: number; + amount_left: number; + save_path: string; + content_path: string; + category: string; + tags: string[]; + added_on: number; + completion_on: number; + dlspeed: number; + upspeed: number; + eta: number; + ratio: number; + seeding_time: number; + infohash_v1: string; + infohash_v2: string; + force_start: boolean; + up_limit: number; + dl_limit: number; + ratio_limit: number; + seeding_time_limit: number; + files: MockQbFile[]; + /** 被 torrents/info 查询到之前还需隐藏的次数(模拟磁力异步出现) */ + hiddenPolls: number; +} + +export interface MockQbCall { + method: string; + endpoint: string; + query: Record; + form: Record; + status: number; +} + +export interface MockQbFault { + /** 返回该 HTTP 状态码(不执行动作) */ + status?: number; + body?: string; + /** 在执行动作之后销毁连接(模拟"已收单但响应丢失") */ + dropAfterApply?: boolean; + /** 不执行动作直接销毁连接 */ + drop?: boolean; + /** 延迟响应(先执行动作) */ + delayMs?: number; + /** 生效次数,默认 1 */ + times?: number; +} + +export interface MockQbOptions { + version?: "4.6.7" | "5.1.0"; + username?: string; + password?: string; + apiKey?: string; + /** add 响应格式;默认 4.x 为 text("Ok."),5.x 为 json(含 added_torrent_ids) */ + addResponse?: "text" | "json"; + /** 新加入的任务在 torrents/info 中隐藏的查询次数 */ + addVisibleAfterPolls?: number; + /** app/preferences 返回值(只读;客户端不应写入) */ + preferences?: Record; + /** 磁力无元数据时的默认大小 */ + magnetDefaultSize?: number; + port?: number; +} + +export interface MockQb { + url: string; + calls: MockQbCall[]; + close(): Promise; + torrents(): MockQbTorrent[]; + get(hash: string): MockQbTorrent | undefined; + tagDefinitions(): string[]; + seed(t: Partial & { hash: string; name: string }): MockQbTorrent; + setProgress(hash: string, progress: number): void; + complete(hash: string): void; + setState(hash: string, state: string): void; + setError(hash: string): void; + setMissingFiles(hash: string): void; + /** 为某个端点注入故障(endpoint 如 "torrents/add") */ + fault(endpoint: string, f: MockQbFault): void; + clearFaults(): void; + /** 让已有会话失效(模拟 qB 重启/会话过期) */ + expireSessions(): void; + callsTo(endpoint: string): MockQbCall[]; +} + +const now = () => Math.floor(Date.now() / 1000); + +export async function createMockQb(opts: MockQbOptions = {}): Promise { + const version = opts.version ?? "4.6.7"; + const v5 = version.startsWith("5"); + const user = opts.username ?? "admin"; + const pass = opts.password ?? "adminadmin"; + const addResponse = opts.addResponse ?? (v5 ? "json" : "text"); + const torrents = new Map(); + const tagDefs = new Set(); + const sessions = new Set(); + const faults = new Map(); + const calls: MockQbCall[] = []; + let rid = 0; + + const pausedState = (done: boolean) => (v5 ? (done ? "stoppedUP" : "stoppedDL") : done ? "pausedUP" : "pausedDL"); + + function mk(t: Partial & { hash: string; name: string }): MockQbTorrent { + const size = t.size ?? t.files?.reduce((a, f) => a + f.size, 0) ?? 1024; + const progress = t.progress ?? 0; + const savePath = t.save_path ?? "/downloads"; + return { + state: progress >= 1 ? "uploading" : "downloading", + progress, + size, + total_size: size, + amount_left: Math.round(size * (1 - progress)), + save_path: savePath, + content_path: `${savePath.replace(/\/$/, "")}/${t.name}`, + category: "", + tags: [], + added_on: now(), + completion_on: progress >= 1 ? now() : -1, + dlspeed: 0, + upspeed: 0, + eta: 8640000, + ratio: 0, + seeding_time: 0, + infohash_v1: t.hash, + infohash_v2: "", + force_start: false, + up_limit: -1, + dl_limit: -1, + ratio_limit: -2, + seeding_time_limit: -2, + files: [{ name: t.name, size, progress, priority: 1 }], + hiddenPolls: 0, + ...t, + }; + } + + function need(hash: string): MockQbTorrent { + const t = torrents.get(hash.toLowerCase()); + if (!t) throw new Error(`mock qb: no torrent ${hash}`); + return t; + } + + function setProgress(t: MockQbTorrent, p: number) { + t.progress = Math.max(0, Math.min(1, p)); + t.amount_left = Math.round(t.size * (1 - t.progress)); + for (const f of t.files) f.progress = f.priority === 0 ? 0 : t.progress; + if (t.progress >= 1) { + t.amount_left = 0; + t.state = + t.state.toLowerCase().startsWith("paused") || t.state.startsWith("stopped") ? pausedState(true) : "uploading"; + t.completion_on = now(); + } else if (!/^(paused|stopped)/i.test(t.state)) { + t.state = "downloading"; + } + } + + function toInfo(t: MockQbTorrent) { + const { files: _f, hiddenPolls: _h, tags, ...rest } = t; + return { ...rest, tags: tags.join(", ") }; + } + + function hashesOf(v: string | undefined): string[] | "all" { + if (!v) return []; + if (v === "all") return "all"; + return v.split("|").map((h) => h.toLowerCase()); + } + function select(v: string | undefined): MockQbTorrent[] { + const hs = hashesOf(v); + if (hs === "all") return [...torrents.values()]; + return hs.map((h) => torrents.get(h)).filter((t): t is MockQbTorrent => !!t); + } + function splitTags(v: string | undefined): string[] { + return (v ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + + function matchFilter(t: MockQbTorrent, filter: string): boolean { + const s = t.state.toLowerCase(); + switch (filter) { + case "all": + return true; + case "downloading": + return ["downloading", "stalleddl", "metadl", "forceddl", "queueddl", "pauseddl", "stoppeddl"].includes(s); + case "completed": + return t.progress >= 1; + case "seeding": + return ["uploading", "stalledup", "forcedup", "queuedup"].includes(s); + case "paused": + case "stopped": + return s.startsWith("paused") || s.startsWith("stopped"); + case "errored": + return s === "error" || s === "missingfiles"; + case "stalled": + return s.startsWith("stalled"); + default: + return true; + } + } + + type Handler = ( + q: Record, + form: Record, + raw: IncomingMessage, + body: Buffer, + ) => { + status?: number; + body: string; + json?: boolean; + headers?: Record; + }; + + const handlers: Record = { + "app/version": () => ({ body: `v${version}` }), + "app/webapiVersion": () => ({ body: v5 ? "2.11.4" : "2.9.3" }), + "torrents/info": (q) => { + let list = [...torrents.values()]; + if (q.hashes) { + const hs = hashesOf(q.hashes); + if (hs !== "all") list = list.filter((t) => hs.includes(t.hash)); + } + if (q.tag !== undefined) + list = list.filter((t) => (q.tag === "" ? t.tags.length === 0 : t.tags.includes(q.tag!))); + if (q.category !== undefined) list = list.filter((t) => t.category === q.category); + if (q.filter) list = list.filter((t) => matchFilter(t, q.filter!)); + const visible = list.filter((t) => { + if (t.hiddenPolls > 0) { + t.hiddenPolls--; + return false; + } + return true; + }); + return { body: JSON.stringify(visible.map(toInfo)), json: true }; + }, + "torrents/files": (q) => { + const t = torrents.get((q.hash ?? "").toLowerCase()); + if (!t) return { status: 404, body: "Torrent hash was not found" }; + return { + body: JSON.stringify( + t.files.map((f, index) => ({ index, ...f, is_seed: t.progress >= 1, availability: 1, piece_range: [0, 0] })), + ), + json: true, + }; + }, + "torrents/add": (_q, form, raw, body) => { + const parts = parseMultipart(body, raw.headers["content-type"] ?? ""); + const field = (n: string) => parts.find((p) => p.name === n)?.data.toString(); + const added: MockQbTorrent[] = []; + let failed = 0; + const base = { + save_path: field("savepath") || "/downloads", + category: field("category") ?? "", + tags: splitTags(field("tags")), + }; + const paused = (field(v5 ? "stopped" : "paused") ?? form.paused) === "true"; + const rename = field("rename"); + const create = (hash: string, name: string, files: { path: string; length: number }[]) => { + if (torrents.has(hash)) { + failed++; + return; + } + const nm = rename || name; + const size = files.reduce((a, f) => a + f.length, 0); + const t = mk({ + hash, + name: nm, + ...base, + size, + files: files.map((f) => ({ name: f.path, size: f.length, progress: 0, priority: 1 })), + hiddenPolls: opts.addVisibleAfterPolls ?? 0, + }); + if (paused) t.state = pausedState(false); + for (const tg of t.tags) tagDefs.add(tg); + torrents.set(hash, t); + added.push(t); + }; + for (const p of parts.filter((x) => x.name === "torrents")) { + try { + const m = inspectTorrent(p.data); + create(m.hash, m.name, m.files); + } catch { + return { status: 415, body: "Torrent file is not valid" }; + } + } + for (const u of (field("urls") ?? "").split("\n").map((s) => s.trim())) { + if (!u) continue; + const m = /xt=urn:btih:([0-9a-fA-F]{40})/.exec(u); + if (!m) { + failed++; + continue; + } + const hash = m[1]!.toLowerCase(); + const dn = new URLSearchParams(u.slice(u.indexOf("?") + 1)).get("dn") ?? hash; + create(hash, dn, [{ path: dn, length: opts.magnetDefaultSize ?? 1024 * 1024 * 1024 }]); + } + if (addResponse === "json") { + return { + body: JSON.stringify({ + success_count: added.length, + failure_count: failed, + pending_count: 0, + added_torrent_ids: added.map((t) => t.hash), + }), + json: true, + }; + } + return { body: added.length > 0 ? "Ok." : "Fails." }; + }, + "torrents/addTags": (_q, form) => { + const tags = splitTags(form.tags); + for (const tg of tags) tagDefs.add(tg); + for (const t of select(form.hashes)) for (const tg of tags) if (!t.tags.includes(tg)) t.tags.push(tg); + return { body: "" }; + }, + "torrents/removeTags": (_q, form) => { + const tags = splitTags(form.tags); + for (const t of select(form.hashes)) t.tags = t.tags.filter((x) => !tags.includes(x)); + return { body: "" }; + }, + "torrents/deleteTags": (_q, form) => { + const tags = splitTags(form.tags); + for (const tg of tags) tagDefs.delete(tg); + for (const t of torrents.values()) t.tags = t.tags.filter((x) => !tags.includes(x)); + return { body: "" }; + }, + "torrents/filePrio": (_q, form) => { + const t = torrents.get((form.hash ?? "").toLowerCase()); + if (!t) return { status: 404, body: "Torrent hash was not found" }; + const ids = (form.id ?? "").split("|").map(Number); + if (ids.some((i) => !Number.isInteger(i) || i < 0 || i >= t.files.length)) + return { status: 409, body: "invalid file id" }; + for (const i of ids) t.files[i]!.priority = Number(form.priority); + return { body: "" }; + }, + "torrents/setForceStart": (_q, form) => { + for (const t of select(form.hashes)) { + t.force_start = form.value === "true"; + if (t.force_start) t.state = t.progress >= 1 ? "forcedUP" : "forcedDL"; + } + return { body: "" }; + }, + "torrents/recheck": (_q, form) => { + for (const t of select(form.hashes)) t.state = t.progress >= 1 ? "checkingUP" : "checkingDL"; + return { body: "" }; + }, + "torrents/setLocation": (_q, form) => { + if (!form.location) return { status: 400, body: "location" }; + for (const t of select(form.hashes)) { + t.save_path = form.location; + t.content_path = `${form.location.replace(/\/$/, "")}/${t.name}`; + } + return { body: "" }; + }, + "torrents/setCategory": (_q, form) => { + for (const t of select(form.hashes)) t.category = form.category ?? ""; + return { body: "" }; + }, + "torrents/setUploadLimit": (_q, form) => { + for (const t of select(form.hashes)) t.up_limit = Number(form.limit); + return { body: "" }; + }, + "torrents/setDownloadLimit": (_q, form) => { + for (const t of select(form.hashes)) t.dl_limit = Number(form.limit); + return { body: "" }; + }, + "torrents/setShareLimits": (_q, form) => { + for (const t of select(form.hashes)) { + t.ratio_limit = Number(form.ratioLimit); + t.seeding_time_limit = Number(form.seedingTimeLimit); + } + return { body: "" }; + }, + "app/preferences": () => ({ + body: JSON.stringify({ torrent_content_layout: "Original", incomplete_files_ext: false, ...opts.preferences }), + json: true, + }), + "app/setPreferences": () => ({ status: 500, body: "mock: writing global preferences is not allowed" }), + "transfer/info": () => ({ + body: JSON.stringify({ dl_info_speed: 0, up_info_speed: 0, connection_status: "connected" }), + json: true, + }), + "torrents/tags": () => ({ body: JSON.stringify([...tagDefs]), json: true }), + "torrents/delete": (_q, form) => { + if (form.deleteFiles !== "true" && form.deleteFiles !== "false") return { status: 400, body: "deleteFiles" }; + for (const t of select(form.hashes)) torrents.delete(t.hash); + return { body: "" }; + }, + "sync/maindata": () => { + rid++; + return { + body: JSON.stringify({ + rid, + full_update: true, + torrents: Object.fromEntries([...torrents.values()].map((t) => [t.hash, toInfo(t)])), + server_state: { free_space_on_disk: 1e12 }, + }), + json: true, + }; + }, + }; + const pauseH: Handler = (_q, form) => { + for (const t of select(form.hashes)) t.state = pausedState(t.progress >= 1); + return { body: "" }; + }; + const resumeH: Handler = (_q, form) => { + for (const t of select(form.hashes)) t.state = t.progress >= 1 ? "stalledUP" : "downloading"; + return { body: "" }; + }; + if (v5) { + handlers["torrents/stop"] = pauseH; + handlers["torrents/start"] = resumeH; + } else { + handlers["torrents/pause"] = pauseH; + handlers["torrents/resume"] = resumeH; + } + + const server: Server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const u = new URL(req.url ?? "/", "http://mock"); + const endpoint = u.pathname.replace(/^\/api\/v2\//, ""); + const query = Object.fromEntries(u.searchParams); + const body = await readBody(req); + const ct = req.headers["content-type"] ?? ""; + const form = ct.includes("urlencoded") ? Object.fromEntries(new URLSearchParams(body.toString())) : {}; + const call: MockQbCall = { method: req.method ?? "GET", endpoint, query, form, status: 0 }; + calls.push(call); + const send = (status: number, text: string, headers: Record = {}) => { + call.status = status; + res.writeHead(status, { "content-type": "text/plain; charset=utf-8", ...headers }); + res.end(text); + }; + + const fq = faults.get(endpoint); + const fault = fq?.[0]; + if (fault) { + fault.times = (fault.times ?? 1) - 1; + if (fault.times <= 0) fq!.shift(); + if (fault.drop) { + call.status = -1; + req.socket.destroy(); + return; + } + if (fault.status) return send(fault.status, fault.body ?? ""); + } + + if (endpoint === "auth/login") { + if (form.username === user && form.password === pass) { + const sid = randomBytes(16).toString("hex"); + sessions.add(sid); + return send(200, "Ok.", { "set-cookie": `SID=${sid}; HttpOnly; path=/` }); + } + return send(200, "Fails."); + } + const bearer = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1]; + const sid = parseCookies(req.headers.cookie).SID; + const authed = (opts.apiKey && bearer === opts.apiKey) || (sid !== undefined && sessions.has(sid)); + if (!authed) return send(403, "Forbidden"); + + const h = handlers[endpoint]; + if (!h) return send(404, "Not Found"); + const r = h(query, form, req, body); + if (fault?.dropAfterApply) { + call.status = -1; + req.socket.destroy(); + return; + } + if (fault?.delayMs) await new Promise((ok) => setTimeout(ok, fault.delayMs)); + if (res.destroyed) return; + send(r.status ?? 200, r.body, r.json ? { "content-type": "application/json" } : {}); + }); + + await new Promise((ok) => server.listen(opts.port ?? 0, "127.0.0.1", ok)); + const { port } = server.address() as AddressInfo; + + return { + url: `http://127.0.0.1:${port}`, + calls, + close: () => + new Promise((ok) => { + server.closeAllConnections(); + server.close(() => ok()); + }), + torrents: () => [...torrents.values()], + get: (h) => torrents.get(h.toLowerCase()), + tagDefinitions: () => [...tagDefs], + seed: (t) => { + const m = mk({ ...t, hash: t.hash.toLowerCase() }); + for (const tg of m.tags) tagDefs.add(tg); + torrents.set(m.hash, m); + return m; + }, + setProgress: (h, p) => setProgress(need(h), p), + complete: (h) => setProgress(need(h), 1), + setState: (h, s) => { + need(h).state = s; + }, + setError: (h) => { + need(h).state = "error"; + }, + setMissingFiles: (h) => { + need(h).state = "missingFiles"; + }, + fault: (endpoint, f) => { + const q = faults.get(endpoint) ?? []; + q.push({ ...f }); + faults.set(endpoint, q); + }, + clearFaults: () => faults.clear(), + expireSessions: () => sessions.clear(), + callsTo: (e) => calls.filter((c) => c.endpoint === e), + }; +} diff --git a/packages/testkit/src/tmdb/fixtures.ts b/packages/testkit/src/tmdb/fixtures.ts new file mode 100644 index 00000000..1582a623 --- /dev/null +++ b/packages/testkit/src/tmdb/fixtures.ts @@ -0,0 +1,219 @@ +/** + * mock TMDB 固定数据。电影/剧集 ID 取自公开 TMDB 条目以便对照,但标题/集数/日期只保证"合理", + * 不作为真实数据的权威来源;国产综艺条目 ID(900001)为虚构。 + */ +export interface TmdbFixtureEpisode { + episode_number: number; + air_date: string | null; + names: Record; +} +export interface TmdbFixtureSeason { + season_number: number; + air_date: string | null; + names: Record; + episodes: TmdbFixtureEpisode[]; +} +export interface TmdbFixture { + type: "movie" | "tv"; + id: number; + original_title: string; + original_language: string; + origin_country: string[]; + date: string; + titles: Record; + overviews?: Record; + popularity: number; + alternative_titles: { iso_3166_1: string; title: string; type: string }[]; + external_ids: { imdb_id?: string | null; tvdb_id?: number | null }; + /** 额外译名(titles 中的语言会自动生成 translations) */ + extra_translations?: { iso_639_1: string; iso_3166_1: string; title: string }[]; + seasons?: TmdbFixtureSeason[]; +} + +function eps(n: number, start: string, zh: (i: number) => string, en: (i: number) => string): TmdbFixtureEpisode[] { + const base = Date.parse(start); + return Array.from({ length: n }, (_, k) => ({ + episode_number: k + 1, + air_date: new Date(base + k * 7 * 86400_000).toISOString().slice(0, 10), + names: { "zh-CN": zh(k + 1), "en-US": en(k + 1) }, + })); +} + +export const TMDB_FIXTURES: TmdbFixture[] = [ + { + type: "movie", + id: 872585, + original_title: "Oppenheimer", + original_language: "en", + origin_country: ["US"], + date: "2023-07-19", + titles: { "zh-CN": "奥本海默", "en-US": "Oppenheimer" }, + overviews: { "zh-CN": "原子弹之父的故事。", "en-US": "The story of J. Robert Oppenheimer." }, + popularity: 120.5, + alternative_titles: [ + { iso_3166_1: "TW", title: "奧本海默", type: "" }, + { iso_3166_1: "CN", title: "奥本海默传", type: "" }, + ], + external_ids: { imdb_id: "tt15398776" }, + extra_translations: [{ iso_639_1: "ja", iso_3166_1: "JP", title: "オッペンハイマー" }], + }, + { + type: "movie", + id: 693134, + original_title: "Dune: Part Two", + original_language: "en", + origin_country: ["US"], + date: "2024-02-27", + titles: { "zh-CN": "沙丘2", "en-US": "Dune: Part Two" }, + popularity: 210.1, + alternative_titles: [ + { iso_3166_1: "US", title: "Dune 2", type: "" }, + { iso_3166_1: "CN", title: "沙丘:第二部", type: "" }, + { iso_3166_1: "TW", title: "沙丘:第二部", type: "" }, + ], + external_ids: { imdb_id: "tt15239678" }, + }, + { + type: "movie", + id: 842675, + original_title: "流浪地球2", + original_language: "zh", + origin_country: ["CN"], + date: "2023-01-22", + titles: { "zh-CN": "流浪地球2", "en-US": "The Wandering Earth II" }, + popularity: 45.3, + alternative_titles: [ + { iso_3166_1: "US", title: "The Wandering Earth 2", type: "" }, + { iso_3166_1: "CN", title: "流浪地球 2", type: "" }, + ], + external_ids: { imdb_id: "tt13539646" }, + }, + { + type: "tv", + id: 136315, + original_title: "The Bear", + original_language: "en", + origin_country: ["US"], + date: "2022-06-23", + titles: { "zh-CN": "熊家餐馆", "en-US": "The Bear" }, + popularity: 88.2, + alternative_titles: [ + { iso_3166_1: "TW", title: "大熊餐廳", type: "" }, + { iso_3166_1: "CN", title: "熊家小馆", type: "" }, + ], + external_ids: { imdb_id: "tt14452776", tvdb_id: 401654 }, + seasons: [ + { + season_number: 1, + air_date: "2022-06-23", + names: { "zh-CN": "第 1 季", "en-US": "Season 1" }, + episodes: eps( + 8, + "2022-06-23", + (i) => `第 ${i} 集`, + (i) => `Episode ${i}`, + ), + }, + { + season_number: 2, + air_date: "2023-06-22", + names: { "zh-CN": "第 2 季", "en-US": "Season 2" }, + episodes: eps( + 10, + "2023-06-22", + (i) => `第 ${i} 集`, + (i) => `Episode ${i}`, + ), + }, + { + season_number: 3, + air_date: "2024-06-26", + names: { "zh-CN": "第 3 季", "en-US": "Season 3" }, + episodes: eps( + 10, + "2024-06-26", + (i) => `第 ${i} 集`, + (i) => `Episode ${i}`, + ), + }, + ], + }, + { + type: "tv", + id: 100088, + original_title: "The Last of Us", + original_language: "en", + origin_country: ["US"], + date: "2023-01-15", + titles: { "zh-CN": "最后生还者", "en-US": "The Last of Us" }, + popularity: 150.7, + alternative_titles: [ + { iso_3166_1: "CN", title: "最后的生还者", type: "" }, + { iso_3166_1: "TW", title: "最後生還者", type: "" }, + ], + external_ids: { imdb_id: "tt3581920", tvdb_id: 392256 }, + seasons: [ + { + season_number: 1, + air_date: "2023-01-15", + names: { "zh-CN": "第 1 季", "en-US": "Season 1" }, + episodes: eps( + 9, + "2023-01-15", + (i) => `第 ${i} 集`, + (i) => `Episode ${i}`, + ), + }, + ], + }, + { + type: "tv", + id: 900001, + original_title: "向往的生活", + original_language: "zh", + origin_country: ["CN"], + date: "2017-01-15", + titles: { "zh-CN": "向往的生活", "en-US": "Back to Field" }, + popularity: 12.4, + alternative_titles: [ + { iso_3166_1: "CN", title: "向往的生活 第一季", type: "" }, + { iso_3166_1: "CN", title: "向往", type: "简称" }, + ], + external_ids: { imdb_id: null, tvdb_id: null }, + seasons: [ + { + season_number: 0, + air_date: "2017-01-10", + names: { "zh-CN": "特别篇", "en-US": "Specials" }, + episodes: eps( + 2, + "2017-01-10", + (i) => `先导片 ${i}`, + (i) => `Special ${i}`, + ), + }, + { + season_number: 1, + air_date: "2017-01-15", + names: { "zh-CN": "第 1 季", "en-US": "Season 1" }, + episodes: eps( + 12, + "2017-01-15", + (i) => `第 ${i} 期`, + (i) => `Episode ${i}`, + ), + }, + { + season_number: 2, + air_date: "2018-04-20", + names: { "zh-CN": "第 2 季", "en-US": "Season 2" }, + episodes: eps( + 3, + "2018-04-20", + (i) => `第 ${i} 期`, + (i) => `Episode ${i}`, + ), + }, + ], + }, +]; diff --git a/packages/testkit/src/tmdb/index.ts b/packages/testkit/src/tmdb/index.ts new file mode 100644 index 00000000..2e0298d9 --- /dev/null +++ b/packages/testkit/src/tmdb/index.ts @@ -0,0 +1,3 @@ +export * from "./fixtures"; +export * from "./mock-http"; +export * from "./mock-tmdb"; diff --git a/packages/testkit/src/tmdb/mock-http.ts b/packages/testkit/src/tmdb/mock-http.ts new file mode 100644 index 00000000..c9bd1515 --- /dev/null +++ b/packages/testkit/src/tmdb/mock-http.ts @@ -0,0 +1,104 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; + +/** 通用 JSON mock 服务底座:路由、调用日志、可编程故障。供 mock TMDB / mock Emby 使用。 */ +export interface MockCall { + method: string; + path: string; + query: Record; + headers: Record; + status: number; +} + +export interface MockFault { + status?: number; + body?: unknown; + headers?: Record; + delayMs?: number; + /** 直接断开连接 */ + drop?: boolean; + times?: number; +} + +export interface MockReply { + status?: number; + json?: unknown; + headers?: Record; +} + +export type MockRoute = (m: RegExpMatchArray, q: Record, req: IncomingMessage) => MockReply; + +export interface MockHttp { + url: string; + calls: MockCall[]; + close(): Promise; + /** 对路径匹配 pattern(字符串前缀或正则)的请求注入故障 */ + fault(pattern: string | RegExp, f: MockFault): void; + clearFaults(): void; + callsTo(pattern: string | RegExp): MockCall[]; +} + +const matches = (p: string | RegExp, path: string) => (typeof p === "string" ? path.startsWith(p) : p.test(path)); + +export async function startMockHttp( + routes: [RegExp, MockRoute][], + opts: { port?: number; guard?: (req: IncomingMessage, q: Record) => MockReply | null } = {}, +): Promise { + const calls: MockCall[] = []; + const faults: { pattern: string | RegExp; f: MockFault }[] = []; + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + const u = new URL(req.url ?? "/", "http://mock"); + const q = Object.fromEntries(u.searchParams); + const call: MockCall = { method: req.method ?? "GET", path: u.pathname, query: q, headers: req.headers, status: 0 }; + calls.push(call); + const reply = (r: MockReply) => { + call.status = r.status ?? 200; + if (res.destroyed) return; + res.writeHead(call.status, { "content-type": "application/json", ...r.headers }); + res.end(r.json === undefined ? "" : JSON.stringify(r.json)); + }; + const fi = faults.findIndex((x) => matches(x.pattern, u.pathname)); + const fault = fi >= 0 ? faults[fi]!.f : undefined; + if (fault) { + fault.times = (fault.times ?? 1) - 1; + if (fault.times <= 0) faults.splice(fi, 1); + if (fault.delayMs) await new Promise((ok) => setTimeout(ok, fault.delayMs)); + if (fault.drop) { + call.status = -1; + req.socket.destroy(); + return; + } + if (fault.status) + return reply({ status: fault.status, json: fault.body, ...(fault.headers ? { headers: fault.headers } : {}) }); + } + const denied = opts.guard?.(req, q); + if (denied) return reply(denied); + for (const [re, h] of routes) { + const m = re.exec(u.pathname); + if (m) { + try { + return reply(h(m, q, req)); + } catch (e) { + return reply({ status: 500, json: { error: String(e) } }); + } + } + } + reply({ status: 404, json: { error: "no route" } }); + }); + await new Promise((ok) => server.listen(opts.port ?? 0, "127.0.0.1", ok)); + const { port } = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${port}`, + calls, + close: () => + new Promise((ok) => { + server.closeAllConnections(); + server.close(() => ok()); + }), + fault: (pattern, f) => faults.push({ pattern, f: { ...f } }), + clearFaults: () => { + faults.length = 0; + }, + callsTo: (p) => calls.filter((c) => matches(p, c.path)), + }; +} diff --git a/packages/testkit/src/tmdb/mock-tmdb.ts b/packages/testkit/src/tmdb/mock-tmdb.ts new file mode 100644 index 00000000..fff29090 --- /dev/null +++ b/packages/testkit/src/tmdb/mock-tmdb.ts @@ -0,0 +1,158 @@ +import { TMDB_FIXTURES, type TmdbFixture } from "./fixtures"; +import { type MockHttp, type MockReply, startMockHttp } from "./mock-http"; + +export interface MockTmdbOptions { + apiKey?: string; + accessToken?: string; + fixtures?: TmdbFixture[]; + port?: number; +} + +export type MockTmdb = MockHttp & { fixtures: TmdbFixture[] }; + +const norm = (s: string) => s.toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, ""); +const pick = (m: Record, lang: string | undefined, fb: string) => + m[lang ?? "en-US"] ?? m[(lang ?? "").split("-")[0] ?? ""] ?? m["en-US"] ?? fb; + +const notFound: MockReply = { + status: 404, + json: { success: false, status_code: 34, status_message: "The resource you requested could not be found." }, +}; + +/** mock TMDB v3:/3/search/{movie,tv}、/3/{movie,tv}/:id(append_to_response)、/3/tv/:id/season/:n */ +export async function createMockTmdb(opts: MockTmdbOptions = {}): Promise { + const fx = opts.fixtures ?? TMDB_FIXTURES; + const apiKey = opts.apiKey ?? "test-tmdb-key"; + const find = (type: string, id: string) => fx.find((f) => f.type === type && String(f.id) === id); + + const matchText = (f: TmdbFixture, needle: string) => + [f.original_title, ...Object.values(f.titles), ...f.alternative_titles.map((a) => a.title)].some((t) => + norm(t).includes(needle), + ); + const brief = (f: TmdbFixture, lang?: string) => { + const title = pick(f.titles, lang, f.original_title); + const overview = f.overviews ? pick(f.overviews, lang, "") : ""; + return f.type === "movie" + ? { + id: f.id, + title, + original_title: f.original_title, + release_date: f.date, + original_language: f.original_language, + overview, + popularity: f.popularity, + } + : { + id: f.id, + name: title, + original_name: f.original_title, + first_air_date: f.date, + original_language: f.original_language, + origin_country: f.origin_country, + overview, + popularity: f.popularity, + }; + }; + + const http = await startMockHttp( + [ + [ + /^\/3\/search\/multi$/, + (_m, q) => { + const needle = norm(q.query ?? ""); + const results = fx + .filter((f) => needle && matchText(f, needle)) + .sort((a, b) => b.popularity - a.popularity) + .map((f) => ({ ...brief(f, q.language), media_type: f.type })); + return { json: { page: 1, results, total_pages: 1, total_results: results.length } }; + }, + ], + [ + /^\/3\/search\/(movie|tv)$/, + (m, q) => { + const type = m[1]!; + const needle = norm(q.query ?? ""); + const year = q.year ?? q.primary_release_year ?? q.first_air_date_year; + const results = fx + .filter((f) => f.type === type) + .filter((f) => !year || f.date.startsWith(year)) + .filter((f) => !!needle && matchText(f, needle)) + .sort((a, b) => b.popularity - a.popularity) + .map((f) => brief(f, q.language)); + return { json: { page: 1, results, total_pages: 1, total_results: results.length } }; + }, + ], + [ + /^\/3\/tv\/(\d+)\/season\/(\d+)$/, + (m, q) => { + const f = find("tv", m[1]!); + const s = f?.seasons?.find((x) => String(x.season_number) === m[2]); + if (!f || !s) return notFound; + return { + json: { + id: f.id * 100 + s.season_number, + season_number: s.season_number, + name: pick(s.names, q.language, `Season ${s.season_number}`), + air_date: s.air_date, + episodes: s.episodes.map((e) => ({ + episode_number: e.episode_number, + season_number: s.season_number, + name: pick(e.names, q.language, ""), + air_date: e.air_date, + })), + }, + }; + }, + ], + [ + /^\/3\/(movie|tv)\/(\d+)$/, + (m, q) => { + const f = find(m[1]!, m[2]!); + if (!f) return notFound; + const append = new Set((q.append_to_response ?? "").split(",").filter(Boolean)); + const out: Record = { ...brief(f, q.language), origin_country: f.origin_country }; + if (f.type === "tv") { + out.number_of_seasons = f.seasons?.filter((s) => s.season_number > 0).length ?? 0; + out.seasons = (f.seasons ?? []).map((s) => ({ + season_number: s.season_number, + episode_count: s.episodes.length, + name: pick(s.names, q.language, ""), + air_date: s.air_date, + })); + } + if (append.has("alternative_titles")) { + out.alternative_titles = + f.type === "movie" ? { titles: f.alternative_titles } : { results: f.alternative_titles }; + } + if (append.has("external_ids")) out.external_ids = f.external_ids; + if (append.has("translations")) { + const key = f.type === "movie" ? "title" : "name"; + out.translations = { + translations: [ + ...Object.entries(f.titles).map(([lang, t]) => { + const [l, r] = lang.split("-"); + return { iso_639_1: l, iso_3166_1: r ?? "", data: { [key]: t } }; + }), + ...(f.extra_translations ?? []).map((x) => ({ + iso_639_1: x.iso_639_1, + iso_3166_1: x.iso_3166_1, + data: { [key]: x.title }, + })), + ], + }; + } + return { json: out }; + }, + ], + ], + { + ...(opts.port !== undefined ? { port: opts.port } : {}), + guard: (req, q) => { + const bearer = /^Bearer (.+)$/.exec(req.headers.authorization ?? "")?.[1]; + const ok = q.api_key === apiKey || (opts.accessToken !== undefined && bearer === opts.accessToken); + return ok ? null : { status: 401, json: { success: false, status_code: 7, status_message: "Invalid API key" } }; + }, + }, + ); + return Object.assign(http, { fixtures: fx }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 578dac38..0222b09f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,7 +152,11 @@ importers: specifier: ^5.1.4 version: 5.1.4(typescript@5.9.3)(vite@7.3.6(@types/node@24.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.23.15)(yaml@2.9.1)) - packages/adapters: {} + packages/adapters: + devDependencies: + '@umt/testkit': + specifier: workspace:* + version: link:../testkit packages/agent: {}