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:
1 parent
94fd48bff2
commit
ac478673bc
39 files changed
+2446
-339
No files matched your search
@@ -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):站点浏览器会话、原站视图、人工接管;写动作需人工持有控制权(接管) ──
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
@@ -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: [],
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;`);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user