import { z } from "zod"; import { Id, NonNegInt, Timestamp } from "./common.ts"; import { MediaEntityStatus, MediaKind } from "./content.ts"; import { ManualAcquisitionRequest } from "./execution.ts"; import { InventoryEntry, SlotStatusCounts } from "./organization.ts"; // 全局搜索(W9-G,对标 MP 顶栏搜索 + 资源搜索页): // GET /search 聚合本地作品 / TMDB 候选 / 跨站资源 / 页面与操作;GET /search/releases 是资源结果页(筛选/排序/游标,复用同一缓存)。 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 = 四组都查;命令面板先查 media/actions(快),资源组单独查(最慢一站可达 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 身份时给出(跟随合并跳转),前端显示"查看"而不是"订阅" */ 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(), 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(), }); 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 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(), }); 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 }); 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(), }), }) .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), /** 逗号分隔的站点 ID */ sites: csv, /** 逗号分隔:2160p,1080p… */ resolution: csv, /** 逗号分隔:REMUX,WEB-DL,BluRay… */ source: csv, /** 字节 */ 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 FacetCount = z.object({ value: z.string(), label: z.string(), count: NonNegInt }); export type FacetCount = z.infer; export const ReleaseSearchPage = z .object({ q: z.string(), items: z.array(SearchRelease), nextCursor: z.string().nullable(), /** 筛选后的条数 */ total: NonNegInt, /** 未筛选的全部条数 */ unfilteredTotal: NonNegInt, /** 筛选项计数(基于未筛选的全部结果) */ facets: z.object({ 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;