import { z } from "zod"; import { Id, NonNegInt, Timestamp } from "./common.ts"; import { MediaCredit, MediaEntityStatus, MediaKind } from "./content.ts"; import { ManualAcquisitionRequest } from "./execution.ts"; import { InventoryEntry, MediaServerInventory, SlotStatusCounts } from "./organization.ts"; import { FacetCount, QuickFilterParam, ReleaseQuickFacets } from "./release-facets.ts"; export { FacetCount, QuickFilterParam, ReleaseQuickFacets } from "./release-facets.ts"; // 全局搜索(W9-G,对标 MP 顶栏搜索 + 资源搜索页): // GET /search 聚合本地作品 / TMDB 候选 / 跨站资源 / 页面与操作;GET /search/releases 是资源结果页(筛选/排序/游标,复用同一缓存)。 // SEARCH-2 两阶段:先查作品并与库存对比(scope=all 不再查站点),需要时再按作品显式搜资源(站点可选); // 仅 TMDB 的作品详情走 GET /media/tmdb/{type}/{id}。 export const SearchScope = z.enum(["all", "media", "releases", "actions"]); export type SearchScope = z.infer; export const SearchQuery = z.object({ q: z.string().trim().min(1).max(200), /** all = 作品 + 操作(不查站点);资源只在 scope=releases 或 /search/releases 显式查询(最慢一站可达 15s) */ scope: SearchScope.default("all"), /** 每组最多返回条数 */ limit: z.coerce.number().int().min(1).max(50).default(10), }); export type SearchQuery = z.infer; // ── 本地作品 ── export const LocalMediaHit = z.object({ entity: z.object({ id: Id, kind: MediaKind, title: z.string(), originalTitle: z.string().nullable(), year: z.number().int().nullable(), status: MediaEntityStatus, }), /** 0–1;精确命中 1,名称前缀 ≥ 0.9,其余为 trgm 相似度 */ score: z.number(), matchedBy: z.enum(["exact", "prefix", "similar"]), /** 命中的别名原文(可能是译名/原名/别名) */ matchedAlias: z.string().nullable(), /** 收录版本状态摘要 + 库存 */ slotCounts: SlotStatusCounts, versionCount: NonNegInt, downloadingCount: NonNegInt, subscribed: z.boolean(), }); export type LocalMediaHit = z.infer; export const TmdbCandidate = z.object({ provider: z.literal("tmdb"), entityType: z.enum(["movie", "tv"]), externalId: z.string(), title: z.string(), originalTitle: z.string().nullable(), year: z.number().int().nullable(), overview: z.string().nullable(), popularity: z.number().nullable(), /** TMDB poster_path;前端经图片代理显示海报 */ posterPath: z.string().nullable().optional(), /** 已有本地作品接受了这个 TMDB 身份时给出(跟随合并跳转),前端显示"查看"而不是"订阅" */ localEntityId: Id.nullable(), }); export type TmdbCandidate = z.infer; // ── 跨站资源 ── export const SearchReleaseParsed = z.object({ titleCn: z.string().nullable(), titleEn: z.string().nullable(), year: z.number().int().nullable(), season: z.number().int().nullable(), /** 多季合集的最后一季(S01-S03 = 3);单季 / 电影为 null */ seasonEnd: z.number().int().nullable().optional(), episodes: z.object({ start: z.number().int(), end: z.number().int() }).nullable(), resolution: z.string().nullable(), source: z.string().nullable(), videoCodec: z.string().nullable(), hdr: z.array(z.string()), releaseGroup: z.string().nullable(), /** 音轨(`TrueHD`、`Atmos 7.1`…,SEARCH-4 快速筛选用);空 = 标题没写 */ audio: z.array(z.string()).optional(), /** 字幕语言 zh / zh-Hans / zh-Hant / en / ja / ko;null = 标题没写 */ subtitles: z.array(z.string()).nullable().optional(), /** 配音语言 cmn 国语 / yue 粤语 / en 英语;null = 标题没写 */ audioLangs: z.array(z.string()).nullable().optional(), bitDepth: z.number().int().nullable().optional(), fps: z.number().nullable().optional(), /** 剪辑版(加长版、导演剪辑版…) */ cut: z.string().nullable().optional(), }); export type SearchReleaseParsed = z.infer; /** * 库存叠加状态(只在关联到作品时给出): * downloading 有下载中任务 > gap 有未满足的收录版本 > owned 已有版本 > subscribed 只有订阅 > none 作品已登记但无库存/目标 */ export const SearchInventoryState = z.enum(["downloading", "gap", "owned", "subscribed", "none"]); export type SearchInventoryState = z.infer; export const SearchReleaseInventory = InventoryEntry.extend({ state: SearchInventoryState, }); export type SearchReleaseInventory = z.infer; /** * 规则判定(只在关联到本地作品、且作品的收录版本配置了条件或规则组时给出): * 按收录版本的评估口径(硬条件 + 规则组链 + 阶梯)判断这条资源,多个收录版本时取最好的一个。 */ export const SearchRuleVerdict = z.object({ slotId: Id, slotName: z.string(), /** 作品共有几个收录版本参与判定;>1 时界面写出收录版本名 */ slotCount: z.number().int().positive(), /** hit = 满足;miss = 不满足;unknown = 标题缺少判定需要的字段 */ outcome: z.enum(["hit", "miss", "unknown"]), /** 命中的档位(1 起);没有分档或未命中为 null */ tier: z.number().int().positive().nullable(), /** 档位名(规则组名·第 N 层 / 阶梯层名) */ tierName: z.string().nullable(), /** 不满足(或缺字段)的维度:分辨率 / 来源 / 编码 / HDR / 字幕 / 体积 / 制作组 / 关键字 / 规则组…,去重 */ failed: z.array(z.string()), /** 逐条说明(悬停提示) */ details: z.array(z.string()), /** * 与库里该收录版本当前版本相比:higher / same / lower;steps = 相差几档(按档位比较时给出)。 * 没有当前版本或无法比较时为 null。 */ vsLibrary: z .object({ cmp: z.enum(["higher", "same", "lower"]), steps: z.number().int().nonnegative().nullable() }) .nullable(), }); export type SearchRuleVerdict = z.infer; export const SearchRelease = z.object({ /** `${siteId}:${siteReleaseId}`,前端列表 key */ key: z.string(), /** 结果已写入资源表(source_release)的 ID;站点工作台 / 下载 / 委托 Agent 用它 */ sourceReleaseId: Id.nullable(), siteId: Id, siteName: z.string(), siteReleaseId: z.string(), title: z.string(), subtitle: z.string().nullable(), category: z.object({ id: z.string().nullable(), name: z.string().nullable() }), sizeBytes: NonNegInt.nullable(), seeders: z.number().int().nullable(), leechers: z.number().int().nullable(), snatched: z.number().int().nullable(), promotion: z.object({ type: z.string(), downloadFactor: z.number().nullable(), uploadFactor: z.number().nullable(), expiresAt: Timestamp.nullable(), }), /** 下载计量为 0(免费 / 2x 免费) */ free: z.boolean(), /** H&R 三态;unknown 在清理判定中按有义务处理 */ hr: z.union([z.boolean(), z.literal("unknown")]), uploadedAt: Timestamp.nullable(), /** 不含凭证的详情页 */ detailUrl: z.string(), observedAt: Timestamp, parsed: SearchReleaseParsed, mediaEntityId: Id.nullable(), /** resolved = 资源已识别到作品;alias = 解析出的标题精确命中唯一作品别名(未确认) */ entityMatch: z.enum(["resolved", "alias"]).nullable(), entity: z.object({ id: Id, title: z.string(), year: z.number().int().nullable(), kind: MediaKind }).nullable(), /** 库存叠加;candidateReasons 只保留针对本条资源的评估 */ inventory: SearchReleaseInventory.nullable(), /** 与本地作品收录规则的关系;没关联作品或没有规则时为 null */ ruleVerdict: SearchRuleVerdict.nullable(), }); export type SearchRelease = z.infer; export const SearchSiteStatus = z.enum(["ok", "failed", "timeout", "skipped"]); export type SearchSiteStatus = z.infer; export const SearchSiteCoverage = z.object({ siteId: Id, siteName: z.string(), status: SearchSiteStatus, count: NonNegInt, tookMs: NonNegInt, /** 失败/超时/跳过原因(已脱敏) */ error: z.string().nullable(), }); export type SearchSiteCoverage = z.infer; /** 跨站搜索的覆盖信息:部分站点失败不影响其它站点结果 */ export const SearchReleaseCoverage = z.object({ /** 有站点失败/超时 */ partial: z.boolean(), sites: z.array(SearchSiteCoverage), failedSites: z.array(SearchSiteCoverage), /** 命中 30s 短缓存 */ cached: z.boolean(), /** 与同时进行的相同搜索合并(没有重复请求站点) */ coalesced: z.boolean(), /** 站点结果抓取时间 */ fetchedAt: Timestamp, }); export type SearchReleaseCoverage = z.infer; // ── 页面与操作跳转(命令面板) ── export const SearchAction = z.object({ id: z.string(), kind: z.enum(["page", "action"]), /** 分组(导航一级分类):概览 / 资源 / 订阅 / 整理 / 系统 */ group: z.string(), title: z.string(), /** 控制台内相对路径 */ href: z.string(), keywords: z.array(z.string()), }); export type SearchAction = z.infer; export const SearchGroupBase = z.object({ tookMs: NonNegInt }); // ── 作品(本地 + TMDB 合并去重,本地优先)与库存对比 ── /** * 库内状态:not_collected 未收录(本地没有这部作品)> tracked 已建档但没有入库版本 > owned 已有版本 > downloading 正在下载。 * 只看本地账本;Emby 另查 GET /inventory/media-servers(未知 ≠ 缺失)。 */ export const WorkInventoryState = z.enum(["not_collected", "tracked", "owned", "downloading"]); export type WorkInventoryState = z.infer; export const WorkInventory = z.object({ state: WorkInventoryState, subscribed: z.boolean(), /** 已入库版本数(主视频资产) */ versionCount: NonNegInt, downloadingCount: NonNegInt, /** 收录版本:总数 / 已满足 */ slotTotal: NonNegInt, slotSatisfied: NonNegInt, /** 未满足的收录版本(empty / attention / upgrade_pending) */ missing: z.array(z.object({ slotId: Id, name: z.string() })), }); export type WorkInventory = z.infer; export const SearchWork = z.object({ /** local:<作品 ID> 或 tmdb:: */ key: z.string(), source: z.enum(["local", "tmdb"]), kind: MediaKind, title: z.string(), originalTitle: z.string().nullable(), year: z.number().int().nullable(), /** 简介摘要(≤ 300 字) */ overview: z.string().nullable(), posterPath: z.string().nullable(), /** 本地作品 ID(合并过的跟到最终作品);null = 未收录 */ localEntityId: Id.nullable(), tmdb: z.object({ entityType: z.enum(["movie", "tv"]), externalId: z.string() }).nullable(), inventory: WorkInventory, }); export type SearchWork = z.infer; export const SearchResponse = z.object({ q: z.string(), scope: SearchScope, media: z .object({ local: SearchGroupBase.extend({ items: z.array(LocalMediaHit) }), tmdb: SearchGroupBase.extend({ items: z.array(TmdbCandidate), /** 未配置 TMDB 时为 false(不是"没有结果") */ configured: z.boolean(), error: z.string().nullable(), }), /** 本地 + TMDB 合并去重(同一 TMDB 身份只出现一次,本地在前),带库存对比 */ works: z.array(SearchWork), }) .nullable(), releases: SearchGroupBase.extend({ items: z.array(SearchRelease), /** 全部站点结果条数(items 只取前 limit 条,完整列表走 /search/releases) */ total: NonNegInt, }) .extend(SearchReleaseCoverage.shape) .nullable(), actions: SearchGroupBase.extend({ items: z.array(SearchAction) }).nullable(), tookMs: NonNegInt, }); export type SearchResponse = z.infer; // ── 资源结果页 ── const csv = z.string().max(4000).optional(); const bool = z.enum(["true", "false"]).optional(); export const ReleaseSearchSort = z.enum(["default", "seeders", "size", "uploadedAt", "site"]); export type ReleaseSearchSort = z.infer; export const ReleaseSearchQuery = z.object({ /** 关键词;按作品搜时可省略,由服务端按作品生成查询词 */ q: z.string().trim().min(1).max(200).optional(), /** 按本地作品搜:用作品名 / 原名 / 年份生成查询词 */ entityId: Id.optional(), /** 按 TMDB 条目搜:`movie:157336` / `tv:1399` */ tmdb: z .string() .regex(/^(movie|tv):\d{1,12}$/) .optional(), /** 逗号分隔的站点 ID:只查这些站点;省略时按 settings.search.defaultSiteMode */ sites: csv, /** 逗号分隔的站点 ID:只筛选结果(仍查 sites 里的全部站点,分面计数不变) */ siteFilter: csv, /** 逗号分隔的解析原值:2160p,1080p…(旧参数;快速筛选用 qf) */ resolution: csv, /** 逗号分隔的解析原值:REMUX,WEB-DL,BluRay…(旧参数;快速筛选用 qf) */ source: csv, /** 快速筛选(SEARCH-3):`维度:值,值;维度:值`,同维度 OR、不同维度 AND,见 ReleaseQuickFacets */ qf: QuickFilterParam, /** 字节 */ minSize: z.coerce.number().int().nonnegative().optional(), maxSize: z.coerce.number().int().nonnegative().optional(), /** true 只要免费;false 排除免费 */ free: bool, /** true 只要 H&R;false 排除明确 H&R 的(unknown 保留) */ hr: bool, /** default = 做种数降序、同分按发布时间新→旧 */ sort: ReleaseSearchSort.default("default"), order: z.enum(["asc", "desc"]).default("desc"), /** true 跳过 30s 缓存重新查站点(仍会合并并发) */ refresh: bool, cursor: z.string().min(1).optional(), limit: z.coerce.number().int().min(1).max(200).default(50), }); export type ReleaseSearchQuery = z.infer; export const ReleaseSearchPage = z .object({ q: z.string(), /** 实际发给站点的查询词(按作品搜时由服务端生成,每个站点每个词查一次) */ queries: z.array(z.string()).optional(), items: z.array(SearchRelease), nextCursor: z.string().nullable(), /** 筛选后的条数 */ total: NonNegInt, /** 未筛选的全部条数(按作品搜时只算属于这部作品的) */ unfilteredTotal: NonNegInt, /** * 按作品搜(entityId / tmdb)时判定不属于这部作品的资源(对照 MP match_torrent:识别到别的作品, * 或名字、类型、年份、季集对不上);不参与筛选与分面,只在第一页给出(最多 200 条)。关键词搜索没有这两项 */ unmatched: z.array(SearchRelease).optional(), unmatchedTotal: NonNegInt.optional(), /** * sites / resolutions / sources:旧的筛选项计数(基于未筛选的全部结果); * 其余为快速筛选分面(SEARCH-3,在其它筛选条件之后、按其它维度的已选条件计数) */ facets: ReleaseQuickFacets.extend({ sites: z.array(FacetCount), resolutions: z.array(FacetCount), sources: z.array(FacetCount), }), tookMs: NonNegInt, }) .extend(SearchReleaseCoverage.shape); export type ReleaseSearchPage = z.infer; export const SearchReleaseParams = z.object({ siteId: Id, siteReleaseId: z.string().min(1).max(200) }); export type SearchReleaseParams = z.infer; /** 从搜索结果直接下载:与 POST /acquisition-plans 同一请求体,资源由路径定位 */ export const SearchReleasePlanRequest = ManualAcquisitionRequest.omit({ sourceReleaseId: true }); export type SearchReleasePlanRequest = z.infer; // ── 仅 TMDB 作品的详情(SEARCH-2) ── export const TmdbMediaParams = z.object({ type: z.enum(["movie", "tv"]), id: z.string().regex(/^\d{1,12}$/), }); export type TmdbMediaParams = z.infer; export const TmdbMediaQuery = z.object({ /** mediaServers = 同时转调 GET /inventory/media-servers(最慢约 8s) */ include: z.enum(["mediaServers"]).optional(), }); export type TmdbMediaQuery = z.infer; export const TmdbMediaSeason = z.object({ seasonNumber: z.number().int(), episodeCount: NonNegInt, name: z.string().nullable(), airDate: z.string().nullable(), /** 本地已入库的集数(整季资产按全季计);未收录为 null */ localHave: NonNegInt.nullable(), overview: z.string().nullable().optional(), posterPath: z.string().nullable().optional(), }); export type TmdbMediaSeason = z.infer; export const TmdbMediaDetail = z.object({ provider: z.literal("tmdb"), entityType: z.enum(["movie", "tv"]), externalId: z.string(), kind: MediaKind, title: z.string(), originalTitle: z.string().nullable(), originalLanguage: z.string().nullable(), year: z.number().int().nullable(), releaseDate: z.string().nullable(), overview: z.string().nullable(), posterPath: z.string().nullable(), backdropPath: z.string().nullable(), originCountry: z.array(z.string()), /** 别名 / 译名(最多 20 个) */ aliases: z.array(z.string()), externalIds: z.object({ imdb: z.string().nullable(), tvdb: z.string().nullable() }), seasons: z.array(TmdbMediaSeason), /** 已建档:本地作品 ID(跟随合并) */ localEntityId: Id.nullable(), /** 本地库存对比;未收录时 state = not_collected */ inventory: WorkInventory, /** include=mediaServers 时给出;未知 ≠ 缺失 */ mediaServers: MediaServerInventory.nullable(), /** 搜资源时服务端会用的查询词 */ queries: z.array(z.string()), // MD-1:按语言回退链取的详情字段(媒体详情页与媒体库详情共用展示) /** 中文名:回退链中第一个中文值(zh-CN 优先);没有为 null */ titleZh: z.string().nullable().optional(), /** 标语(展示用):只取简体中文(原语言为中文的按简中),没有为 null,不退到繁中 */ tagline: z.string().nullable().optional(), genres: z.array(z.string()).optional(), voteAverage: z.number().nullable().optional(), voteCount: z.number().int().nullable().optional(), /** TMDB status 原文(Returning Series / Ended / Released …) */ airStatus: z.string().nullable().optional(), /** 分钟:电影片长;剧集为单集时长 */ runtime: z.number().int().nullable().optional(), episodeCount: z.number().int().nullable().optional(), lastAirDate: z.string().nullable().optional(), platform: z.string().nullable().optional(), cast: z.array(MediaCredit).optional(), crew: z.array(MediaCredit).optional(), }); export type TmdbMediaDetail = z.infer; /** 一集的 TMDB 信息(标题 / 简介按语言回退链,language 记实际取到的语言) */ export const MediaEpisodeInfo = z.object({ seasonNumber: z.number().int(), episodeNumber: z.number().int(), name: z.string().nullable(), nameLanguage: z.string().nullable(), overview: z.string().nullable(), overviewLanguage: z.string().nullable(), airDate: z.string().nullable(), runtime: z.number().int().nullable(), stillPath: z.string().nullable(), voteAverage: z.number().nullable(), voteCount: z.number().int().nullable(), }); export type MediaEpisodeInfo = z.infer; export const TmdbSeasonParams = z.object({ id: z.string().regex(/^\d{1,12}$/), season: z.coerce.number().int().min(0).max(999), }); export type TmdbSeasonParams = z.infer; /** GET /media/tmdb/tv/{id}/season/{season}:季详情与集列表(仅 TMDB 作品详情页按季展开时拉取) */ export const TmdbSeasonDetailView = z.object({ tvId: z.string(), seasonNumber: z.number().int(), name: z.string().nullable(), overview: z.string().nullable(), airDate: z.string().nullable(), posterPath: z.string().nullable(), episodes: z.array(MediaEpisodeInfo), }); export type TmdbSeasonDetailView = z.infer;