docs(plan): on-demand site detail page parsing (HHClub details.php)

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-29 15:13:35 +08:00
1 parent 94fd48bff2
commit ac478673bc
39 files changed
+2446 -339

No files matched your search

+10
View File
@@ -2186,6 +2186,16 @@ export const Api = {
body: search.SearchReleasePlanRequest,
response: execution.ManualAcquisitionResult,
}),
tmdbDetail: ep({
method: "get",
path: "/media/tmdb/{type}/{id}",
scope: "read",
summary:
"仅 TMDB 作品的详情:TMDB 信息与季列表、本地库存对比、是否已建档、搜资源的查询词;include=mediaServers 同时查 Emby",
params: search.TmdbMediaParams,
query: search.TmdbMediaQuery,
response: search.TmdbMediaDetail,
}),
},
// ── 浏览器(C1-E):站点浏览器会话、原站视图、人工接管;写动作需人工持有控制权(接管) ──
+18 -2
View File
@@ -428,11 +428,27 @@ export const InventoryResponse = z.object({ items: z.array(InventoryEntry) });
export type InventoryResponse = z.infer<typeof InventoryResponse>;
/** Emby 兼容库存:按实例分别给出 present / not_found / unknown(未知 ≠ 缺失) */
export const MediaServerInventoryQuery = z.object({ mediaEntityId: Id });
/**
* 按本地作品(用它已接受的外部身份)或按 TMDB 条目(还没建档的作品,SEARCH-2 搜索页/详情页)查 Emby。
* 两者给其一;都给时以 mediaEntityId 为准。
*/
export const MediaServerInventoryQuery = z
.object({
mediaEntityId: Id.optional(),
tmdbType: z.enum(["movie", "tv"]).optional(),
tmdbId: z
.string()
.regex(/^\d{1,12}$/)
.optional(),
})
.refine((q) => !!q.mediaEntityId || (!!q.tmdbType && !!q.tmdbId), {
message: "mediaEntityId 或 tmdbType + tmdbId 必须给一个",
});
export type MediaServerInventoryQuery = z.infer<typeof MediaServerInventoryQuery>;
export const MediaServerInventory = z.object({
mediaEntityId: Id,
/** 按 TMDB 条目查且本地没有建档时为 null */
mediaEntityId: Id.nullable(),
instances: z.array(
z.object({
instanceId: Id,
+114 -4
View File
@@ -2,17 +2,19 @@ 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";
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 = 四组都查;命令面板先查 media/actions(快),资源组单独查(最慢一站可达 15s) */
/** all = 作品 + 操作(不查站点);资源只在 scope=releases 或 /search/releases 显式查询(最慢一站可达 15s) */
scope: SearchScope.default("all"),
/** 每组最多返回条数 */
limit: z.coerce.number().int().min(1).max(50).default(10),
@@ -171,6 +173,47 @@ 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,
@@ -183,6 +226,8 @@ export const SearchResponse = z.object({
configured: z.boolean(),
error: z.string().nullable(),
}),
/** 本地 + TMDB 合并去重(同一 TMDB 身份只出现一次,本地在前),带库存对比 */
works: z.array(SearchWork),
})
.nullable(),
releases: SearchGroupBase.extend({
@@ -206,9 +251,19 @@ export const ReleaseSearchSort = z.enum(["default", "seeders", "size", "uploaded
export type ReleaseSearchSort = z.infer<typeof ReleaseSearchSort>;
export const ReleaseSearchQuery = z.object({
q: z.string().trim().min(1).max(200),
/** 逗号分隔的站点 ID */
/** 关键词;按作品搜时可省略,由服务端按作品生成查询词 */
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… */
@@ -236,6 +291,8 @@ 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(),
/** 筛选后的条数 */
@@ -259,3 +316,56 @@ 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(),
});
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()),
});
export type TmdbMediaDetail = z.infer<typeof TmdbMediaDetail>;
+19
View File
@@ -357,6 +357,19 @@ export const BudgetSettings = z.object({
});
export type BudgetSettings = z.infer<typeof BudgetSettings>;
/**
* 资源搜索(SEARCH-2):GET /search/releases 不带 sites 时参与搜索的站点。
* default = 启用且「默认参与搜索」的站点;all = 全部启用站点;custom = siteIds 里仍启用的站点。
*/
export const SearchSiteMode = z.enum(["default", "all", "custom"]);
export type SearchSiteMode = z.infer<typeof SearchSiteMode>;
export const SearchSettings = z.object({
defaultSiteMode: SearchSiteMode,
/** custom 时的站点 ID */
siteIds: z.array(z.uuid()).max(200),
});
export type SearchSettings = z.infer<typeof SearchSettings>;
/**
* 身份识别(DEC-1 惰性识别,对照 MP:RSS / 列表资源只在匹配订阅或用户搜索时识别)。
* eagerTmdbLookup = true 恢复旧行为:每条新观察到的资源都查 TMDB。每条资源约 1–2 次搜索 + 命中时 1 次详情
@@ -382,6 +395,7 @@ export const GlobalSettings = z.object({
sites: SitesSettings,
naming: NamingSettings,
identity: IdentitySettings,
search: SearchSettings,
});
export type GlobalSettings = z.infer<typeof GlobalSettings>;
@@ -453,6 +467,7 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
},
naming: { ...DEFAULT_NAMING_TEMPLATES, lockFileNameForBound: false },
identity: { eagerTmdbLookup: false, poolArchiveDays: 30 },
search: { defaultSiteMode: "default", siteIds: [] },
};
/** PATCH /settings:按分组部分更新;服务端与现值合并后用 GlobalSettings 整体再校验 */
@@ -502,6 +517,7 @@ export const GlobalSettingsUpdate = z.object({
sites: SitesSettings.partial().optional(),
naming: NamingSettings.partial().optional(),
identity: IdentitySettings.partial().optional(),
search: SearchSettings.partial().optional(),
});
export type GlobalSettingsUpdate = z.infer<typeof GlobalSettingsUpdate>;
@@ -527,6 +543,7 @@ export function mergeSettings(current: GlobalSettings, patch: GlobalSettingsUpda
sites: { ...(current.sites ?? DEFAULT_SETTINGS.sites), ...patch.sites },
naming: { ...(current.naming ?? DEFAULT_SETTINGS.naming), ...patch.naming },
identity: { ...(current.identity ?? DEFAULT_SETTINGS.identity), ...patch.identity },
search: { ...(current.search ?? DEFAULT_SETTINGS.search), ...patch.search },
};
}
@@ -638,6 +655,8 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
"sites.userAgent",
"sites.proxyUrl",
"sites.userDataMaxSeedingPages",
"search.defaultSiteMode",
"search.siteIds",
],
resources: [],
},
+4
View File
@@ -50,6 +50,8 @@ export const Site = EntityBase.extend({
lastHealthAt: Timestamp.nullable(),
/** 使用全局代理(settings.sites.proxyUrl) */
proxy: z.boolean(),
/** 默认参与资源搜索(站点选择弹层的"默认"集合) */
searchDefault: z.boolean(),
});
export type Site = z.infer<typeof Site>;
export const SiteCreate = Site.pick({
@@ -63,6 +65,7 @@ export const SiteCreate = Site.pick({
snapshotEnabled: true,
snapshotIntervalSec: true,
proxy: true,
searchDefault: true,
}).partial({
enabled: true,
rateLimit: true,
@@ -70,6 +73,7 @@ export const SiteCreate = Site.pick({
snapshotEnabled: true,
snapshotIntervalSec: true,
proxy: true,
searchDefault: true,
});
export type SiteCreate = z.infer<typeof SiteCreate>;
export const SiteUpdate = SiteCreate.omit({ key: true }).partial();
+1 -1
View File
@@ -58,7 +58,7 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
DownloadTask: { row: C.DownloadTask },
DownloaderInstance: { row: C.DownloaderInstance },
ExternalIdentity: { row: C.ExternalIdentity, apiOnly: ["mediaEntityTitle"] },
FeedSource: { row: C.FeedSource, apiOnly: ["possibleGap"] },
FeedSource: { row: C.FeedSource, apiOnly: ["possibleGap", "warnings"] },
IdentityRevision: { row: C.IdentityRevision },
LibraryRoot: { row: C.LibraryRoot },
MediaAsset: { row: C.MediaAsset },
+1
View File
@@ -37,6 +37,7 @@ const STRING_CANDIDATES = [
"30 3 * * *",
"ALL",
"zh-hans",
"movie:157336",
];
const NUMBER_CANDIDATES = [1, 50, 2024, 0.5, 0, 4096, 128000];
@@ -0,0 +1,15 @@
import type { Kysely } from "kysely";
import { execDdl } from "../src/migration-helpers.ts";
// SEARCH-2:站点"默认参与搜索"。搜索资源时站点选择弹层的"默认"集合 = 启用且 search_default 的站点;
// 缺省 true,存量站点行为不变(原先全部启用站点都参与)。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(db, `alter table site add column search_default boolean not null default true;`);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(db, `alter table site drop column if exists search_default;`);
}
+1
View File
@@ -1359,6 +1359,7 @@ export interface SiteTable {
last_health_at: Timestamptz | null;
proxy: Generated<boolean>;
deleted_at: Timestamptz | null;
search_default: Generated<boolean>;
}
export interface SiteAccountTable {