Files
UHD_Media_Tool/packages/contracts/src/search.ts
T
rootandClaude Opus 5.5 a0946bf480 feat(search): global search aggregation backend (W9-G)
- 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]>
2026-09-28 15:05:58 +08:00

260 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;