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

@@ -476,25 +476,43 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
response: MediaServerInventory,
},
async (_c, { query }) => {
const e = await db()
.selectFrom("media_entity")
.select(["id", "kind", "title", "year"])
.where("id", "=", query.mediaEntityId)
.executeTakeFirst();
if (!e) notFound("作品");
const idents = await db()
.selectFrom("external_identity")
.select(["provider", "entity_type", "external_id"])
.where("media_entity_id", "=", e!.id)
.where("status", "=", "accepted")
.execute();
// 按 TMDB 条目查时:本地已建档(接受了该身份,合并跟到最终作品)就按作品查,否则只用这一个 TMDB ID
let entityId = query.mediaEntityId ?? null;
if (!entityId && query.tmdbType && query.tmdbId) {
const hit = await db()
.selectFrom("external_identity as i")
.innerJoin("media_entity as m", "m.id", "i.media_entity_id")
.select(["m.id", "m.status", "m.merged_into_id"])
.where("i.provider", "=", "tmdb")
.where("i.entity_type", "=", query.tmdbType)
.where("i.external_id", "=", query.tmdbId)
.where("i.status", "=", "accepted")
.executeTakeFirst();
entityId = hit ? (hit.status === "merged" && hit.merged_into_id ? hit.merged_into_id : hit.id) : null;
}
const e = entityId
? await db()
.selectFrom("media_entity")
.select(["id", "kind", "title", "year"])
.where("id", "=", entityId)
.executeTakeFirst()
: null;
if (entityId && !e) notFound("作品");
const idents = e
? await db()
.selectFrom("external_identity")
.select(["provider", "entity_type", "external_id"])
.where("media_entity_id", "=", e.id)
.where("status", "=", "accepted")
.execute()
: [{ provider: "tmdb", entity_type: query.tmdbType!, external_id: query.tmdbId! }];
const insts = await db()
.selectFrom("media_server_instance")
.selectAll()
.where("enabled", "=", true)
.orderBy("name")
.execute();
const type = e!.kind === "movie" ? "Movie" : "Series";
const type = (e ? e.kind === "movie" : query.tmdbType === "movie") ? "Movie" : "Series";
const usable = idents.filter((i) => MS_PROVIDER_KEY[i.provider]);
// 按实例并行(allSettled):单个 Emby 慢/挂只让自己 unknown,不拖住其它实例;每实例内部限并发 + 总超时
const settled = await Promise.allSettled(
@@ -514,7 +532,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
);
// partial:至少一个实例因查询失败/超时为 unknown(no_external_id 不算失败)
const partial = instances.some((x) => x.status === "unknown" && x.reason !== "no_external_id");
return { mediaEntityId: e!.id, instances, partial } satisfies MediaServerInventory;
return { mediaEntityId: e?.id ?? null, instances, partial } satisfies MediaServerInventory;
},
{ tag: TAG },
);
+1 -1
View File
@@ -199,7 +199,7 @@ export class TmdbSearch {
}
/** 已接受的 TMDB 身份 → 本地作品(合并过的跟到最终作品) */
async function localEntities(
export async function localEntities(
ctx: ServerContext,
refs: { entityType: string; externalId: string }[],
): Promise<Map<string, string>> {
+146 -53
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import type { SiteRelease as AdapterRelease } from "@umt/adapters";
import { SiteError } from "@umt/adapters";
import type {
@@ -15,8 +16,9 @@ import type { SourcesRuntime } from "../sources-identity/runtime.ts";
import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
import { raceAbort } from "./media.ts";
// 跨站资源搜索:已启用站点逐站调适配器 search(每站共享限流器 + 单站超时 + 站点级并发上限);
// 同一查询词并发合并、结果短缓存;结果 upsert 为资源(source_release,新资源照常发 release.observed 进识别流水线)。
// 跨站资源搜索:选中的已启用站点逐站调适配器 search(每站共享限流器 + 单站超时 + 站点级并发上限);
// 按(站点, 查询词)并发合并、短缓存 —— 换站点选择时已查过的站点直接复用;结果 upsert 为资源
// (source_release,新资源照常发 release.observed 进识别流水线)。
// 作品关联与库存叠加在每次请求时现算(不进缓存),识别完成后刷新即可看到。
/** 缓存里的资源条目(不含作品关联与库存叠加) */
@@ -37,46 +39,96 @@ export interface FanoutOptions {
export interface FanoutHit {
fanout: Fanout;
/** 全部(站点, 查询词)都命中缓存 */
cached: boolean;
/** 至少一个(站点, 查询词)与同时进行的相同搜索合并 */
coalesced: boolean;
}
interface SiteResult {
items: BaseRelease[];
coverage: SearchSiteCoverage;
fetchedAt: number;
}
const cacheKey = (q: string) => q.trim().toLowerCase().replace(/\s+/g, " ");
const COVERAGE_RANK: Record<SearchSiteCoverage["status"], number> = { ok: 0, timeout: 1, failed: 2, skipped: 3 };
export class ReleaseFanout {
private readonly cache = new Map<string, { expires: number; fanout: Fanout }>();
private readonly inflight = new Map<string, Promise<Fanout>>();
private readonly cache = new Map<string, { expires: number; result: SiteResult }>();
private readonly inflight = new Map<string, Promise<SiteResult>>();
constructor(
private readonly rt: () => SourcesRuntime,
private readonly opts: FanoutOptions,
) {}
async search(q: string, opts: { refresh?: boolean } = {}): Promise<FanoutHit> {
const key = cacheKey(q);
const hit = this.cache.get(key);
if (!opts.refresh && hit && hit.expires > this.opts.now())
return { fanout: hit.fanout, cached: true, coalesced: false };
const running = this.inflight.get(key);
if (running) return { fanout: await running, cached: false, coalesced: true };
const p = this.run(q.trim())
.then((fanout) => {
// 全部站点失败不缓存(下次直接重试)
if (fanout.sites.length === 0 || fanout.sites.some((s) => s.status === "ok")) {
this.prune();
this.cache.set(key, { expires: this.opts.now() + this.opts.ttlMs, fanout });
/** 参与搜索的候选站点:启用且未删除(按名称排序) */
async enabledSites(): Promise<SiteRow[]> {
return this.rt()
.ctx.db.selectFrom("site")
.selectAll()
.where("enabled", "=", true)
.where("deleted_at", "is", null)
.orderBy("name")
.execute();
}
/**
* 在给定站点上搜一组查询词:每站每词一次请求(同站多个词的结果按站点资源 ID 去重)。
* 缓存按(站点, 查询词):失败 / 超时也缓存,但一轮里全部站点都失败时不缓存(下次直接重试)。
*/
async search(terms: string[], sites: SiteRow[], opts: { refresh?: boolean } = {}): Promise<FanoutHit> {
const words = [...new Map(terms.map((t) => [cacheKey(t), t.trim()])).entries()].filter(([k]) => k);
let cached = true;
let coalesced = false;
const fresh: string[] = [];
const perSite = await mapLimit(sites, Math.max(1, this.opts.concurrency), async (site) => {
const parts: SiteResult[] = [];
for (const [k, term] of words) {
const key = `${site.id}|${k}`;
const hit = this.cache.get(key);
if (!opts.refresh && hit && hit.expires > this.opts.now()) {
parts.push(hit.result);
continue;
}
return fanout;
})
.finally(() => this.inflight.delete(key));
this.inflight.set(key, p);
return { fanout: await p, cached: false, coalesced: false };
cached = false;
const running = this.inflight.get(key);
if (running) {
coalesced = true;
parts.push(await running);
continue;
}
const p = this.searchSite(this.rt(), site, term).finally(() => this.inflight.delete(key));
this.inflight.set(key, p);
const r = await p;
this.cache.set(key, { expires: this.opts.now() + this.opts.ttlMs, result: r });
fresh.push(key);
parts.push(r);
}
return mergeSite(site, parts);
});
// 本轮新查的站点全部失败:撤掉这些缓存
const freshResults = fresh.map((k) => this.cache.get(k)?.result).filter((r): r is SiteResult => !!r);
if (freshResults.length && freshResults.every((r) => r.coverage.status !== "ok"))
for (const k of fresh) this.cache.delete(k);
this.prune();
const fetched = perSite.map((r) => r.fetchedAt);
return {
fanout: {
items: perSite.flatMap((r) => r.items),
sites: perSite.map((r) => r.coverage),
fetchedAt: new Date(fetched.length ? Math.min(...fetched) : this.opts.now()).toISOString(),
},
cached: cached && words.length > 0 && sites.length > 0,
coalesced,
};
}
/** 缓存里找一条站点资源(下载时资源行还没落库的兜底) */
findCached(siteId: string, siteReleaseId: string): { release: BaseRelease; raw: AdapterRelease } | null {
for (const { fanout } of this.cache.values()) {
const r = fanout.items.find((i) => i.siteId === siteId && i.siteReleaseId === siteReleaseId);
for (const { result } of this.cache.values()) {
const r = result.items.find((i) => i.siteId === siteId && i.siteReleaseId === siteReleaseId);
const raw = r ? this.raws.get(r.key) : undefined;
if (r && raw) return { release: r, raw };
}
@@ -87,32 +139,11 @@ export class ReleaseFanout {
private prune() {
const now = this.opts.now();
for (const [k, v] of this.cache) if (v.expires <= now) this.cache.delete(k);
while (this.cache.size > 200) this.cache.delete(this.cache.keys().next().value as string);
while (this.cache.size > 1000) this.cache.delete(this.cache.keys().next().value as string);
if (this.raws.size > 20_000) this.raws.clear();
}
private async run(q: string): Promise<Fanout> {
const rt = this.rt();
const sites = await rt.ctx.db
.selectFrom("site")
.selectAll()
.where("enabled", "=", true)
.where("deleted_at", "is", null)
.orderBy("name")
.execute();
const results = await mapLimit(sites, Math.max(1, this.opts.concurrency), (s) => this.searchSite(rt, s, q));
return {
items: results.flatMap((r) => r.items),
sites: results.map((r) => r.coverage),
fetchedAt: new Date(this.opts.now()).toISOString(),
};
}
private async searchSite(
rt: SourcesRuntime,
site: SiteRow,
q: string,
): Promise<{ items: BaseRelease[]; coverage: SearchSiteCoverage }> {
private async searchSite(rt: SourcesRuntime, site: SiteRow, q: string): Promise<SiteResult> {
const t0 = performance.now();
const cov = (status: SearchSiteCoverage["status"], count: number, error: string | null): SearchSiteCoverage => ({
siteId: site.id,
@@ -122,22 +153,27 @@ export class ReleaseFanout {
tookMs: Math.round(performance.now() - t0),
error,
});
const done = (items: BaseRelease[], coverage: SearchSiteCoverage): SiteResult => ({
items,
coverage,
fetchedAt: this.opts.now(),
});
let adapter: ReturnType<SourcesRuntime["siteAdapter"]>;
try {
adapter = rt.siteAdapter(site);
} catch (e) {
return { items: [], coverage: cov("skipped", 0, (e as Error).message) };
return done([], cov("skipped", 0, (e as Error).message));
}
if (!adapter.capabilities.search) return { items: [], coverage: cov("skipped", 0, "站点不支持搜索") };
if (!adapter.capabilities.search) return done([], cov("skipped", 0, "站点不支持搜索"));
const signal = AbortSignal.timeout(this.opts.timeoutMs);
const timeoutMsg = `${Math.round(this.opts.timeoutMs / 1000)} 秒内没有响应`;
let found: AdapterRelease[];
try {
found = (await raceAbort(adapter.search(q, { page: 1 }, { signal }), signal, timeoutMsg)).items;
} catch (e) {
if (signal.aborted) return { items: [], coverage: cov("timeout", 0, timeoutMsg) };
if (signal.aborted) return done([], cov("timeout", 0, timeoutMsg));
const msg = e instanceof SiteError ? `${e.kind}: ${e.message}` : (e as Error).message || "搜索失败";
return { items: [], coverage: cov("failed", 0, msg) };
return done([], cov("failed", 0, msg));
}
// 同站同 ID 去重(翻页边界可能重复)
const seen = new Set<string>();
@@ -152,13 +188,70 @@ export class ReleaseFanout {
ids.clear();
rt.ctx.logger.warn({ err: { message: (e as Error).message }, siteId: site.id }, "搜索结果写入资源表失败");
}
await this.resolveUnmatched(rt, [...ids.values()]);
const items = found.map((r) => {
const base = toBase(site, r, ids.get(r.siteReleaseId) ?? null);
this.raws.set(base.key, r);
return base;
});
return { items, coverage: cov("ok", items.length, null) };
return done(items, cov("ok", items.length, null));
}
/**
* 惰性识别(DEC-1):用户搜索到的资源算「有理由查 TMDB」,未识别且未人工锁定的入队 resolveObservation{trigger:"search"}。
* 同一资源一小时内只入队一次;入队失败只记警告(资源仍经 release.observed 按 observed 处理)。
*/
private async resolveUnmatched(rt: SourcesRuntime, sourceReleaseIds: string[]) {
if (sourceReleaseIds.length === 0) return;
try {
const rows = await rt.ctx.db
.selectFrom("source_release")
.select("id")
.where("id", "in", sourceReleaseIds)
.where("media_entity_id", "is", null)
.where("resolution_locked", "=", false)
.limit(RESOLVE_PER_SEARCH)
.execute();
const hour = Math.floor(this.opts.now() / 3_600_000);
for (const r of rows)
await rt.ctx.engine.enqueue(
"resolveObservation",
{ sourceReleaseId: r.id, trigger: "search" },
{ idempotencyKey: `resolve:search:${r.id}:${hour}`, traceId: randomUUID() },
);
} catch (e) {
rt.ctx.logger.warn({ err: { message: (e as Error).message } }, "搜索结果入队识别失败");
}
}
}
/** 单次站点搜索最多为多少条未识别资源入队识别 */
const RESOLVE_PER_SEARCH = 50;
/** 同一站点多个查询词的结果合并:资源按 key 去重;状态取最好的一次(有一次成功就算成功) */
function mergeSite(site: SiteRow, parts: SiteResult[]): SiteResult {
if (parts.length === 1) return parts[0]!;
if (parts.length === 0)
return {
items: [],
coverage: { siteId: site.id, siteName: site.name, status: "skipped", count: 0, tookMs: 0, error: "没有查询词" },
fetchedAt: Date.now(),
};
const seen = new Set<string>();
const items = parts.flatMap((p) => p.items).filter((i) => !seen.has(i.key) && !!seen.add(i.key));
const best = [...parts].sort((a, b) => COVERAGE_RANK[a.coverage.status] - COVERAGE_RANK[b.coverage.status])[0];
return {
items,
coverage: {
siteId: site.id,
siteName: site.name,
status: best?.coverage.status ?? "skipped",
count: items.length,
tookMs: parts.reduce((n, p) => n + p.coverage.tookMs, 0),
error: best?.coverage.status === "ok" ? null : (best?.coverage.error ?? null),
},
fetchedAt: Math.min(...parts.map((p) => p.fetchedAt)),
};
}
function toBase(site: Pick<SiteRow, "id" | "name">, r: AdapterRelease, sourceReleaseId: string | null): BaseRelease {
@@ -226,7 +319,7 @@ const list = (s: string | undefined) =>
.filter(Boolean);
export function filterReleases(items: BaseRelease[], q: ReleaseSearchQuery): BaseRelease[] {
const sites = new Set(list(q.sites));
const sites = new Set(list(q.siteFilter).length ? list(q.siteFilter) : list(q.sites));
const res = new Set(list(q.resolution).map((x) => x.toLowerCase()));
const src = new Set(list(q.source).map((x) => x.toLowerCase()));
return items.filter((r) => {
+171 -27
View File
@@ -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");
+270
View File
@@ -0,0 +1,270 @@
import type { MetadataCandidate } from "@umt/adapters";
import type {
InventoryEntry,
LocalMediaHit,
MediaKind,
SearchWork,
SlotStatusCounts,
TmdbCandidate,
TmdbMediaSeason,
WorkInventory,
} from "@umt/contracts";
import { sql } from "@umt/db";
import { normalizeTitle } from "@umt/domain";
import type { ServerContext } from "../types.ts";
// SEARCH-2 第一阶段:作品(本地 + TMDB 合并去重,本地优先)+ 本地库存对比;第二阶段搜资源的查询词生成。
const clip = (s: string | null | undefined, n = 300) => (s ? (s.length > n ? `${s.slice(0, n)}…` : s) : null);
export const emptySlotCounts = (): SlotStatusCounts => ({
empty: 0,
reserved: 0,
downloading: 0,
verifying: 0,
satisfied: 0,
upgrade_pending: 0,
attention: 0,
});
export interface LocalInfo {
id: string;
kind: MediaKind;
title: string;
originalTitle: string | null;
year: number | null;
status: string;
overview: string | null;
posterPath: string | null;
tmdb: { entityType: "movie" | "tv"; externalId: string } | null;
}
const metaValue = (metadata: unknown, field: string): string | null => {
const v = (metadata as Record<string, { value?: unknown }> | null)?.[field]?.value;
return typeof v === "string" && v ? v : null;
};
/** 本地作品展示信息:海报 / 简介取元数据最终值,TMDB 身份取已接受的那条 */
export async function localInfo(ctx: ServerContext, ids: string[]): Promise<Map<string, LocalInfo>> {
const out = new Map<string, LocalInfo>();
if (ids.length === 0) return out;
const [rows, idents] = await Promise.all([
ctx.db
.selectFrom("media_entity")
.select(["id", "kind", "title", "original_title", "year", "status", "metadata"])
.where("id", "in", ids)
.execute(),
ctx.db
.selectFrom("external_identity")
.select(["media_entity_id", "entity_type", "external_id"])
.where("media_entity_id", "in", ids)
.where("provider", "=", "tmdb")
.where("status", "=", "accepted")
.execute(),
]);
for (const r of rows) {
const t = idents.find((i) => i.media_entity_id === r.id);
out.set(r.id, {
id: r.id,
kind: r.kind as MediaKind,
title: r.title,
originalTitle: r.original_title,
year: r.year,
status: r.status,
overview: clip(metaValue(r.metadata, "overview")),
posterPath: metaValue(r.metadata, "posterPath"),
tmdb:
t && (t.entity_type === "movie" || t.entity_type === "tv")
? { entityType: t.entity_type, externalId: t.external_id }
: null,
});
}
return out;
}
/** 各作品版本目标按状态计数 */
export async function slotCountsFor(ctx: ServerContext, ids: string[]): Promise<Map<string, SlotStatusCounts>> {
const out = new Map<string, SlotStatusCounts>();
if (ids.length === 0) return out;
const rows = await ctx.db
.selectFrom("acquisition_slot")
.select(["media_entity_id", "status"])
.select(sql<number>`count(*)::int`.as("n"))
.where("media_entity_id", "in", ids)
.groupBy(["media_entity_id", "status"])
.execute();
for (const id of ids) out.set(id, emptySlotCounts());
for (const r of rows) {
const c = out.get(r.media_entity_id);
if (c) c[r.status as keyof SlotStatusCounts] = r.n;
}
return out;
}
/** 库存对比:没有本地作品 = 未收录;有下载中 > 有版本 > 仅建档 */
export function workInventory(
inv: InventoryEntry | undefined | null,
counts: SlotStatusCounts | undefined,
): WorkInventory {
if (!inv) {
return {
state: "not_collected",
subscribed: false,
versionCount: 0,
downloadingCount: 0,
slotTotal: 0,
slotSatisfied: 0,
missing: [],
};
}
const c = counts ?? emptySlotCounts();
const total = Object.values(c).reduce((a, b) => a + b, 0);
return {
state: inv.downloading.length ? "downloading" : inv.versions.length ? "owned" : "tracked",
subscribed: inv.subscribed,
versionCount: inv.versions.length,
downloadingCount: inv.downloading.length,
slotTotal: total,
slotSatisfied: c.satisfied,
missing: inv.gaps.map((g) => ({ slotId: g.slotId, name: g.name })),
};
}
type LocalHitBase = Omit<LocalMediaHit, "versionCount" | "downloadingCount" | "subscribed">;
/**
* 合并:本地命中在前(按得分),再补 TMDB 候选里已建档但没被本地召回的作品,最后是仅 TMDB 的;
* 同一 TMDB 身份 / 同一本地作品只出现一次。本地作品缺海报 / 简介时用对应 TMDB 候选的。
*/
export function buildWorks(
local: LocalHitBase[],
tmdb: TmdbCandidate[],
info: Map<string, LocalInfo>,
inv: Map<string, InventoryEntry>,
counts: Map<string, SlotStatusCounts>,
): SearchWork[] {
const out: SearchWork[] = [];
const seenLocal = new Set<string>();
const seenTmdb = new Set<string>();
const tmdbByLocal = new Map<string, TmdbCandidate>();
for (const c of tmdb) if (c.localEntityId && !tmdbByLocal.has(c.localEntityId)) tmdbByLocal.set(c.localEntityId, c);
const pushLocal = (id: string) => {
if (seenLocal.has(id)) return;
const e = info.get(id);
if (!e) return;
seenLocal.add(id);
const cand = tmdbByLocal.get(id);
const tm = e.tmdb ?? (cand ? { entityType: cand.entityType, externalId: cand.externalId } : null);
if (tm) seenTmdb.add(`${tm.entityType}:${tm.externalId}`);
out.push({
key: `local:${id}`,
source: "local",
kind: e.kind,
title: e.title,
originalTitle: e.originalTitle,
year: e.year,
overview: e.overview ?? cand?.overview ?? null,
posterPath: e.posterPath ?? cand?.posterPath ?? null,
localEntityId: id,
tmdb: tm,
inventory: workInventory(inv.get(id) ?? emptyInventory(id), counts.get(id)),
});
};
for (const h of local) pushLocal(h.entity.id);
for (const c of tmdb) if (c.localEntityId) pushLocal(c.localEntityId);
for (const c of tmdb) {
const k = `${c.entityType}:${c.externalId}`;
if (c.localEntityId || seenTmdb.has(k)) continue;
seenTmdb.add(k);
out.push({
key: `tmdb:${k}`,
source: "tmdb",
kind: c.entityType === "movie" ? "movie" : "series",
title: c.title,
originalTitle: c.originalTitle,
year: c.year,
overview: c.overview,
posterPath: c.posterPath ?? null,
localEntityId: null,
tmdb: { entityType: c.entityType, externalId: c.externalId },
inventory: workInventory(null, undefined),
});
}
return out;
}
const emptyInventory = (entityId: string): InventoryEntry => ({
entityId,
versions: [],
gaps: [],
downloading: [],
subscribed: false,
candidateReasons: [],
});
/**
* 搜资源的查询词(每个站点每个词查一次,最多 2 个,控制对站点的请求数):
* 原名(电影带年份,站点标题多为「原名 年份 …」)+ 译名(与原名归一化后不同才加)。
* 剧集不带年份(各季年份不同)。
*/
export function searchTerms(w: {
title: string;
originalTitle: string | null;
year: number | null;
movie: boolean;
}): string[] {
const out: string[] = [];
const seen = new Set<string>();
const add = (t: string | null, withYear: boolean) => {
const s = t?.trim();
if (!s) return;
const n = normalizeTitle(s);
if (!n || seen.has(n)) return;
seen.add(n);
out.push(withYear && w.year ? `${s} ${w.year}` : s);
};
const latin = (s: string | null) => !!s && /^[\p{Script=Latin}\d\s\p{P}\p{S}]+$/u.test(s);
add(w.originalTitle, w.movie && latin(w.originalTitle));
add(w.title, w.movie && latin(w.title) && out.length === 0);
return out.slice(0, 2);
}
/** 剧集各季本地已入库集数:集单元有可用资产覆盖的计数;季单元被整季资产覆盖时按全季计 */
export async function localSeasonHave(
ctx: ServerContext,
entityId: string,
seasons: { seasonNumber: number; episodeCount: number }[],
): Promise<Map<number, number>> {
const rows = await sql<{ kind: string; season_number: number | null; n: number }>`
select cu.kind, cu.season_number, count(distinct cu.id)::int as n
from content_unit cu
join asset_unit_coverage c on c.content_unit_id = cu.id
join media_asset a on a.id = c.media_asset_id and a.status = 'active'
where cu.media_entity_id = ${entityId} and cu.kind in ('episode', 'season') and cu.season_number is not null
group by cu.kind, cu.season_number`.execute(ctx.db);
const out = new Map<number, number>();
for (const s of seasons) out.set(s.seasonNumber, 0);
for (const r of rows.rows) {
if (r.season_number === null) continue;
if (r.kind === "episode") out.set(r.season_number, Math.max(out.get(r.season_number) ?? 0, r.n));
}
for (const r of rows.rows) {
if (r.kind !== "season" || r.season_number === null) continue;
const s = seasons.find((x) => x.seasonNumber === r.season_number);
if (s) out.set(r.season_number, Math.max(out.get(r.season_number) ?? 0, s.episodeCount));
}
return out;
}
export function detailSeasons(d: MetadataCandidate, have: Map<number, number> | null): TmdbMediaSeason[] {
return (d.seasons ?? [])
.filter((s) => s.seasonNumber >= 0)
.sort((a, b) => a.seasonNumber - b.seasonNumber)
.map((s) => ({
seasonNumber: s.seasonNumber,
episodeCount: Math.max(0, s.episodeCount),
name: s.name,
airDate: s.airDate,
localHave: have ? Math.min(have.get(s.seasonNumber) ?? 0, Math.max(0, s.episodeCount) || Infinity) : null,
}));
}
@@ -155,6 +155,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
snapshot_enabled: body.snapshotEnabled ?? false,
snapshot_interval_sec: body.snapshotIntervalSec ?? null,
proxy: body.proxy ?? false,
search_default: body.searchDefault ?? true,
})
.returningAll()
.executeTakeFirstOrThrow();
@@ -176,6 +177,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
if (body.snapshotEnabled !== undefined) patch.snapshot_enabled = body.snapshotEnabled;
if (body.snapshotIntervalSec !== undefined) patch.snapshot_interval_sec = body.snapshotIntervalSec;
if (body.proxy !== undefined) patch.proxy = body.proxy;
if (body.searchDefault !== undefined) patch.search_default = body.searchDefault;
if (body.enabled !== undefined) {
if (Object.keys(patch).length) {
await db().updateTable("site").set(patch).where("id", "=", params.id).execute();