Files
UHD_Media_Tool/packages/contracts/src/search.ts
T

424 lines
16 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";
// 全局搜索(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(),
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 });
// ── 作品(本地 + 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… */
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(),
/** 实际发给站点的查询词(按作品搜时由服务端生成,每个站点每个词查一次) */
queries: z.array(z.string()).optional(),
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>;
// ── 仅 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(),
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>;