Files
UHD_Media_Tool/packages/contracts/src/search.ts
T
rootandClaude Opus 5.5 6549ab92c3 fix(media): 详情页标语只显示简体中文,简中没有时不显示
- adapters 新增 simplifiedChineseTagline:取值语言为繁中 / 非中文、或文字是繁体特有字时不显示;原语言为中文(zh / cn)的作品按简中处理
- TMDB 富详情带回 taglineLanguage;发现页详情的 tagline 按上述口径给出
- 媒体库作品详情新增 displayTagline(存储的标语语言为 null,只能按文字判断);metadata.tagline 存储不变
- 补 adapters 单测与作品详情接口测试

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 01:32:00 +08:00

479 lines
20 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 { 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<typeof SearchScope>;
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<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 poster_path;前端经图片代理显示海报 */
posterPath: z.string().nullable().optional(),
/** 已有本地作品接受了这个 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(),
/** 多季合集的最后一季(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<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 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<typeof SearchRuleVerdict>;
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<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 });
// ── 作品(本地 + 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<typeof WorkInventoryState>;
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<typeof WorkInventory>;
export const SearchWork = z.object({
/** local:<作品 ID> 或 tmdb:<movie|tv>:<TMDB ID> */
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<typeof SearchWork>;
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<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).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<typeof ReleaseSearchQuery>;
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<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>;
// ── 仅 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<typeof TmdbMediaParams>;
export const TmdbMediaQuery = z.object({
/** mediaServers = 同时转调 GET /inventory/media-servers(最慢约 8s) */
include: z.enum(["mediaServers"]).optional(),
});
export type TmdbMediaQuery = z.infer<typeof TmdbMediaQuery>;
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<typeof TmdbMediaSeason>;
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<typeof TmdbMediaDetail>;
/** 一集的 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<typeof MediaEpisodeInfo>;
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<typeof TmdbSeasonParams>;
/** 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<typeof TmdbSeasonDetailView>;