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
@@ -1,19 +1,26 @@
|
||||
import { createRoute, type RouteConfig } from "@hono/zod-openapi";
|
||||
import { TmdbError } from "@umt/adapters";
|
||||
import {
|
||||
Api,
|
||||
type ApiScope,
|
||||
apiResponse,
|
||||
DEFAULT_SETTINGS,
|
||||
type EndpointDef,
|
||||
type InventoryEntry,
|
||||
type InventoryResponse,
|
||||
type ManualAcquisitionResult,
|
||||
type MediaServerInventory,
|
||||
type ReleaseSearchPage,
|
||||
type ReleaseSearchQuery,
|
||||
type SearchQuery,
|
||||
type SearchReleaseParams,
|
||||
type SearchReleasePlanRequest,
|
||||
type SearchResponse,
|
||||
type TmdbMediaDetail,
|
||||
type TmdbMediaParams,
|
||||
type TmdbMediaQuery,
|
||||
} from "@umt/contracts";
|
||||
import type { SiteRow } from "@umt/db";
|
||||
import type { Context } from "hono";
|
||||
import type { z } from "zod";
|
||||
import { type AuthEnv, requireScope } from "../../http/auth.ts";
|
||||
@@ -23,8 +30,17 @@ import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
|
||||
import type { ModuleApp, ServerContext } from "../types.ts";
|
||||
import { matchActions } from "./actions.ts";
|
||||
import { InternalDispatcher } from "./dispatch.ts";
|
||||
import { searchLocalMedia, TmdbSearch, withInventory } from "./media.ts";
|
||||
import { localEntities, raceAbort, searchLocalMedia, TmdbSearch, withInventory } from "./media.ts";
|
||||
import { decorate, facets, filterReleases, ReleaseFanout, sortReleases } from "./releases.ts";
|
||||
import {
|
||||
buildWorks,
|
||||
detailSeasons,
|
||||
localInfo,
|
||||
localSeasonHave,
|
||||
searchTerms,
|
||||
slotCountsFor,
|
||||
workInventory,
|
||||
} from "./works.ts";
|
||||
|
||||
export interface SearchDeps {
|
||||
runtime: () => SourcesRuntime;
|
||||
@@ -102,10 +118,71 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
|
||||
return out;
|
||||
};
|
||||
|
||||
const csvIds = (s: string | undefined) =>
|
||||
(s ?? "")
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
/** 参与搜索的站点:给了 sites 只查这些(仍须启用);否则按 settings.search.defaultSiteMode */
|
||||
const resolveSites = async (sites: string | undefined): Promise<SiteRow[]> => {
|
||||
const all = await fanout.enabledSites();
|
||||
const ids = csvIds(sites);
|
||||
if (ids.length) {
|
||||
const set = new Set(ids);
|
||||
return all.filter((s) => set.has(s.id));
|
||||
}
|
||||
const cfg = {
|
||||
...DEFAULT_SETTINGS.search,
|
||||
...(await ctx.settings.get("search").catch(() => DEFAULT_SETTINGS.search)),
|
||||
};
|
||||
if (cfg.defaultSiteMode === "all") return all;
|
||||
if (cfg.defaultSiteMode === "custom") {
|
||||
const set = new Set(cfg.siteIds);
|
||||
return all.filter((s) => set.has(s.id));
|
||||
}
|
||||
return all.filter((s) => s.search_default);
|
||||
};
|
||||
|
||||
const tmdbDetails = async (type: "movie" | "tv", id: string) => {
|
||||
const client = await d.runtime().tmdb();
|
||||
if (!client) throw new ApiException("precondition_failed", "未配置 TMDB。到设置的元数据分区填写 API Key");
|
||||
try {
|
||||
return await raceAbort(client.details(type, id), AbortSignal.timeout(d.siteTimeoutMs), "TMDB 查询超时");
|
||||
} catch (e) {
|
||||
if (e instanceof TmdbError && e.kind === "notfound")
|
||||
throw new ApiException("not_found", `TMDB 没有 ${type}/${id}`);
|
||||
throw new ApiException(
|
||||
"upstream_unavailable",
|
||||
`TMDB 查询失败:${e instanceof TmdbError ? e.kind : (e as Error).message}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** 查询词:给了 q 就只用 q;按作品搜时由作品名 / 原名 / 年份生成 */
|
||||
const releaseTerms = async (q: ReleaseSearchQuery): Promise<string[]> => {
|
||||
if (q.q) return [q.q];
|
||||
if (q.entityId) {
|
||||
const e = await ctx.db
|
||||
.selectFrom("media_entity")
|
||||
.select(["title", "original_title", "year", "kind"])
|
||||
.where("id", "=", q.entityId)
|
||||
.executeTakeFirst();
|
||||
if (!e) throw new ApiException("not_found", "作品不存在");
|
||||
return searchTerms({ title: e.title, originalTitle: e.original_title, year: e.year, movie: e.kind === "movie" });
|
||||
}
|
||||
if (q.tmdb) {
|
||||
const [type, id] = q.tmdb.split(":") as ["movie" | "tv", string];
|
||||
const m = await tmdbDetails(type, id);
|
||||
return searchTerms({ title: m.title, originalTitle: m.originalTitle, year: m.year, movie: type === "movie" });
|
||||
}
|
||||
throw new ApiException("validation_failed", "q、entityId、tmdb 至少给一个");
|
||||
};
|
||||
|
||||
route(app, Api.search.all, async ({ query, c }) => {
|
||||
const t0 = performance.now();
|
||||
const q = query as SearchQuery;
|
||||
const want = (s: SearchQuery["scope"]) => q.scope === "all" || q.scope === s;
|
||||
const want = (s: SearchQuery["scope"]) => q.scope === s || (q.scope === "all" && s !== "releases");
|
||||
const loadInv = inventory(c);
|
||||
|
||||
const media = want("media")
|
||||
@@ -113,37 +190,54 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
|
||||
(async () => {
|
||||
const t = performance.now();
|
||||
const hits = await searchLocalMedia(ctx, q.q, q.limit);
|
||||
const inv = hits.length ? await loadInv(hits.map((h) => h.entity.id)) : new Map();
|
||||
return { items: withInventory(hits, inv), tookMs: since(t) };
|
||||
return { hits, tookMs: since(t) };
|
||||
})(),
|
||||
(async () => {
|
||||
const t = performance.now();
|
||||
const r = await tmdb.search(q.q, q.limit);
|
||||
return { ...r, tookMs: since(t) };
|
||||
})(),
|
||||
]).then(([local, tm]) => ({ local, tmdb: tm }))
|
||||
]).then(async ([local, tm]) => {
|
||||
// 库存对比:本地命中 + TMDB 候选里已建档的作品一起查一次
|
||||
const ids = [
|
||||
...new Set([
|
||||
...local.hits.map((h) => h.entity.id),
|
||||
...tm.items.map((c) => c.localEntityId).filter((x): x is string => !!x),
|
||||
]),
|
||||
];
|
||||
const [inv, info, counts] = ids.length
|
||||
? await Promise.all([loadInv(ids), localInfo(ctx, ids), slotCountsFor(ctx, ids)])
|
||||
: [new Map(), new Map(), new Map()];
|
||||
return {
|
||||
local: { items: withInventory(local.hits, inv), tookMs: local.tookMs },
|
||||
tmdb: tm,
|
||||
works: buildWorks(local.hits, tm.items, info, inv, counts),
|
||||
};
|
||||
})
|
||||
: Promise.resolve(null);
|
||||
|
||||
const releases = want("releases")
|
||||
? (async () => {
|
||||
const t = performance.now();
|
||||
const hit = await fanout.search(q.q);
|
||||
const sorted = sortReleases(hit.fanout.items, "default", "desc");
|
||||
const items = await decorate(d.runtime(), sorted.slice(0, q.limit), loadInv);
|
||||
const failed = hit.fanout.sites.filter((s) => s.status === "failed" || s.status === "timeout");
|
||||
return {
|
||||
items,
|
||||
total: sorted.length,
|
||||
partial: failed.length > 0,
|
||||
sites: hit.fanout.sites,
|
||||
failedSites: failed,
|
||||
cached: hit.cached,
|
||||
coalesced: hit.coalesced,
|
||||
fetchedAt: hit.fanout.fetchedAt,
|
||||
tookMs: since(t),
|
||||
};
|
||||
})()
|
||||
: Promise.resolve(null);
|
||||
// SEARCH-2:资源只在 scope=releases 时查(all 不再自动查站点),站点按 settings.search 的默认集合
|
||||
const releases =
|
||||
q.scope === "releases"
|
||||
? (async () => {
|
||||
const t = performance.now();
|
||||
const hit = await fanout.search([q.q], await resolveSites(undefined));
|
||||
const sorted = sortReleases(hit.fanout.items, "default", "desc");
|
||||
const items = await decorate(d.runtime(), sorted.slice(0, q.limit), loadInv);
|
||||
const failed = hit.fanout.sites.filter((s) => s.status === "failed" || s.status === "timeout");
|
||||
return {
|
||||
items,
|
||||
total: sorted.length,
|
||||
partial: failed.length > 0,
|
||||
sites: hit.fanout.sites,
|
||||
failedSites: failed,
|
||||
cached: hit.cached,
|
||||
coalesced: hit.coalesced,
|
||||
fetchedAt: hit.fanout.fetchedAt,
|
||||
tookMs: since(t),
|
||||
};
|
||||
})()
|
||||
: Promise.resolve(null);
|
||||
|
||||
const actions = want("actions")
|
||||
? (() => {
|
||||
@@ -161,7 +255,8 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
|
||||
route(app, Api.search.releases, async ({ query, c }) => {
|
||||
const t0 = performance.now();
|
||||
const q = query as ReleaseSearchQuery;
|
||||
const hit = await fanout.search(q.q, { refresh: q.refresh === "true" });
|
||||
const terms = await releaseTerms(q);
|
||||
const hit = await fanout.search(terms, await resolveSites(q.sites), { refresh: q.refresh === "true" });
|
||||
const all = hit.fanout.items;
|
||||
const filtered = sortReleases(filterReleases(all, q), q.sort, q.order);
|
||||
const offset = decodeCursor(q.cursor);
|
||||
@@ -169,7 +264,8 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
|
||||
const next = offset + q.limit < filtered.length ? encodeCursor(offset + q.limit) : null;
|
||||
const failed = hit.fanout.sites.filter((s) => s.status === "failed" || s.status === "timeout");
|
||||
const out: ReleaseSearchPage = {
|
||||
q: q.q,
|
||||
q: q.q ?? terms[0]!,
|
||||
queries: terms,
|
||||
items: await decorate(d.runtime(), pageItems, inventory(c)),
|
||||
nextCursor: next,
|
||||
total: filtered.length,
|
||||
@@ -214,6 +310,54 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
|
||||
sourceReleaseId: row.id,
|
||||
});
|
||||
});
|
||||
|
||||
route(app, Api.search.tmdbDetail, async ({ params, query, c }) => {
|
||||
const p = params as TmdbMediaParams;
|
||||
const inc = (query as TmdbMediaQuery).include;
|
||||
const m = await tmdbDetails(p.type, p.id);
|
||||
const localId =
|
||||
(await localEntities(ctx, [{ entityType: p.type, externalId: p.id }])).get(`${p.type}:${p.id}`) ?? null;
|
||||
const [inv, counts] = localId
|
||||
? await Promise.all([inventory(c)([localId]), slotCountsFor(ctx, [localId])])
|
||||
: [new Map<string, InventoryEntry>(), new Map()];
|
||||
const have =
|
||||
p.type === "tv" && localId
|
||||
? await localSeasonHave(
|
||||
ctx,
|
||||
localId,
|
||||
(m.seasons ?? []).map((s) => ({ seasonNumber: s.seasonNumber, episodeCount: s.episodeCount })),
|
||||
)
|
||||
: null;
|
||||
const mediaServers =
|
||||
inc === "mediaServers"
|
||||
? await dispatch
|
||||
.call<MediaServerInventory>(c, "GET", `/inventory/media-servers?tmdbType=${p.type}&tmdbId=${p.id}`)
|
||||
.catch(() => null)
|
||||
: null;
|
||||
const out: TmdbMediaDetail = {
|
||||
provider: "tmdb",
|
||||
entityType: p.type,
|
||||
externalId: p.id,
|
||||
kind: p.type === "movie" ? "movie" : "series",
|
||||
title: m.title,
|
||||
originalTitle: m.originalTitle,
|
||||
originalLanguage: m.originalLanguage,
|
||||
year: m.year,
|
||||
releaseDate: m.releaseDate,
|
||||
overview: m.overview,
|
||||
posterPath: m.posterPath,
|
||||
backdropPath: m.backdropPath ?? null,
|
||||
originCountry: m.originCountry,
|
||||
aliases: [...new Set(m.aliases)].filter((x) => x !== m.title).slice(0, 20),
|
||||
externalIds: { imdb: m.externalIds?.imdb ?? null, tvdb: m.externalIds?.tvdb ?? null },
|
||||
seasons: detailSeasons(m, have),
|
||||
localEntityId: localId,
|
||||
inventory: workInventory(localId ? (inv.get(localId) ?? null) : null, localId ? counts.get(localId) : undefined),
|
||||
mediaServers,
|
||||
queries: searchTerms({ title: m.title, originalTitle: m.originalTitle, year: m.year, movie: p.type === "movie" }),
|
||||
};
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
const encodeCursor = (offset: number) => Buffer.from(String(offset), "utf8").toString("base64url");
|
||||
|
||||
Reference in new issue
Block a user