- GET /search?q=&scope=all|media|releases|actions&limit=: local works (title_alias
exact/prefix/trgm + name prefix, slot status summary), TMDB candidates (cached,
localEntityId), cross-site releases, page/action jump list; tookMs per group
- cross-site: enabled sites via shared per-site limiter, 15s AbortSignal timeout,
site concurrency cap 4, same-query coalescing, 30s cache; partial + failedSites;
results upserted to source_release; inventory overlay via GET /inventory
- GET /search/releases: site/resolution/source/size/free/H&R filters, sort, cursor,
facets; shares the cache
- POST /search/releases/{siteId}/{siteReleaseId}/plan: forwards in-process to
POST /acquisition-plans (same auth, validation, audit, rejections)
- contracts/search.ts, runbook, tests (partial/timeout, coalesce, cache, overlay,
filters/sort, plan)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
260 lines
9.6 KiB
TypeScript
260 lines
9.6 KiB
TypeScript
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<typeof SearchScope>;
|
||
|
||
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<typeof SearchQuery>;
|
||
|
||
// ── 本地作品 ──
|
||
|
||
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<typeof LocalMediaHit>;
|
||
|
||
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<typeof TmdbCandidate>;
|
||
|
||
// ── 跨站资源 ──
|
||
|
||
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<typeof SearchReleaseParsed>;
|
||
|
||
/**
|
||
* 库存叠加状态(只在关联到作品时给出):
|
||
* downloading 有下载中任务 > gap 有未满足的版本目标 > owned 已有版本 > subscribed 只有订阅 > none 作品已登记但无库存/目标
|
||
*/
|
||
export const SearchInventoryState = z.enum(["downloading", "gap", "owned", "subscribed", "none"]);
|
||
export type SearchInventoryState = z.infer<typeof SearchInventoryState>;
|
||
|
||
export const SearchReleaseInventory = InventoryEntry.extend({
|
||
state: SearchInventoryState,
|
||
});
|
||
export type SearchReleaseInventory = z.infer<typeof SearchReleaseInventory>;
|
||
|
||
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<typeof SearchRelease>;
|
||
|
||
export const SearchSiteStatus = z.enum(["ok", "failed", "timeout", "skipped"]);
|
||
export type SearchSiteStatus = z.infer<typeof SearchSiteStatus>;
|
||
|
||
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<typeof SearchSiteCoverage>;
|
||
|
||
/** 跨站搜索的覆盖信息:部分站点失败不影响其它站点结果 */
|
||
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<typeof SearchReleaseCoverage>;
|
||
|
||
// ── 页面与操作跳转(命令面板) ──
|
||
|
||
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<typeof SearchAction>;
|
||
|
||
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<typeof SearchResponse>;
|
||
|
||
// ── 资源结果页 ──
|
||
|
||
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<typeof ReleaseSearchSort>;
|
||
|
||
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<typeof ReleaseSearchQuery>;
|
||
|
||
export const FacetCount = z.object({ value: z.string(), label: z.string(), count: NonNegInt });
|
||
export type FacetCount = z.infer<typeof FacetCount>;
|
||
|
||
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<typeof ReleaseSearchPage>;
|
||
|
||
export const SearchReleaseParams = z.object({ siteId: Id, siteReleaseId: z.string().min(1).max(200) });
|
||
export type SearchReleaseParams = z.infer<typeof SearchReleaseParams>;
|
||
|
||
/** 从搜索结果直接下载:与 POST /acquisition-plans 同一请求体,资源由路径定位 */
|
||
export const SearchReleasePlanRequest = ManualAcquisitionRequest.omit({ sourceReleaseId: true });
|
||
export type SearchReleasePlanRequest = z.infer<typeof SearchReleasePlanRequest>;
|