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
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -405,7 +405,7 @@ describe.skipIf(!up)("全局搜索", () => {
|
||||
expect(f("false")).toEqual(["b", "c"]);
|
||||
});
|
||||
|
||||
it("页面与操作跳转;scope=all 四组都在", async () => {
|
||||
it("页面与操作跳转;scope=all 只查作品与操作(不查站点)", async () => {
|
||||
const r = await req("/search?q=设置&scope=actions");
|
||||
expect(r.body.data.actions.items[0]).toMatchObject({
|
||||
id: "page.settings",
|
||||
@@ -414,10 +414,12 @@ describe.skipIf(!up)("全局搜索", () => {
|
||||
});
|
||||
const sub = await req("/search?q=订阅&scope=actions");
|
||||
expect(sub.body.data.actions.items.map((a: Loose) => a.id)).toContain("action.new-subscription");
|
||||
const all = await req("/search?q=Dune");
|
||||
const before = [nexusSearches(), mteamSearches(), slowCalls];
|
||||
const all = await req("/search?q=Dune%20Part%20Two&refresh=true");
|
||||
expect(all.body.data.media).not.toBeNull();
|
||||
expect(all.body.data.releases).not.toBeNull();
|
||||
expect(all.body.data.releases).toBeNull();
|
||||
expect(all.body.data.actions).not.toBeNull();
|
||||
expect([nexusSearches(), mteamSearches(), slowCalls]).toEqual(before);
|
||||
});
|
||||
|
||||
it("从搜索结果下载:转调 /acquisition-plans(同一鉴权、同一拒绝原因)", async () => {
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
import { createTmdbClient } from "@umt/adapters";
|
||||
import { type DB, type Kysely, migrateToLatest, sql } from "@umt/db";
|
||||
import { normalizeTitle } from "@umt/domain";
|
||||
import {
|
||||
AUDIENCES_FIXTURE_RELEASES,
|
||||
createMockNexusPhpSite,
|
||||
createMockTmdb,
|
||||
type MockNexusPhpSite,
|
||||
type MockTmdb,
|
||||
} from "@umt/testkit";
|
||||
import { Kysely as K, PostgresDialect } from "kysely";
|
||||
import pg from "pg";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createOutboxPort } from "../../src/context.ts";
|
||||
import { createApp } from "../../src/http/app.ts";
|
||||
import { EventBus } from "../../src/http/sse.ts";
|
||||
import { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
|
||||
import { createAgentModule } from "../../src/modules/agent/index.ts";
|
||||
import { createSearchModule } from "../../src/modules/search/index.ts";
|
||||
import { searchTerms } from "../../src/modules/search/works.ts";
|
||||
import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
|
||||
import type { ServerContext } from "../../src/modules/types.ts";
|
||||
import { DEV_KEY, memoryLogger } from "../helpers.ts";
|
||||
import { fakeEngine, fakeSecrets, PG_URL, pgAvailable, plainSettings } from "../sources-identity/harness.ts";
|
||||
|
||||
// SEARCH-2 两阶段搜索:第一阶段只查作品(本地 + TMDB 合并去重)与库存对比,不碰站点;
|
||||
// 第二阶段按作品显式搜资源(服务端生成查询词,站点按默认 / 全部 / 指定);仅 TMDB 作品的详情端点。
|
||||
|
||||
const up = await pgAvailable();
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 测试断言直接读 JSON
|
||||
type Loose = any;
|
||||
|
||||
describe("searchTerms", () => {
|
||||
it("电影:原名带年份 + 译名;同名只留一个;剧集不带年份", () => {
|
||||
expect(searchTerms({ title: "星际穿越", originalTitle: "Interstellar", year: 2014, movie: true })).toEqual([
|
||||
"Interstellar 2014",
|
||||
"星际穿越",
|
||||
]);
|
||||
expect(searchTerms({ title: "流浪地球2", originalTitle: "流浪地球2", year: 2023, movie: true })).toEqual([
|
||||
"流浪地球2",
|
||||
]);
|
||||
expect(searchTerms({ title: "熊家餐馆", originalTitle: "The Bear", year: 2022, movie: false })).toEqual([
|
||||
"The Bear",
|
||||
"熊家餐馆",
|
||||
]);
|
||||
expect(searchTerms({ title: "Oppenheimer", originalTitle: null, year: 2023, movie: true })).toEqual([
|
||||
"Oppenheimer 2023",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!up)("两阶段搜索(SEARCH-2)", () => {
|
||||
let db: Kysely<DB>;
|
||||
let admin: K<unknown>;
|
||||
let schema: string;
|
||||
let a: MockNexusPhpSite;
|
||||
let b: MockNexusPhpSite;
|
||||
let tmdb: MockTmdb;
|
||||
let app: ReturnType<typeof createApp>;
|
||||
const sites = { a: "", b: "", c: "" };
|
||||
let dune = "";
|
||||
const engine = fakeEngine();
|
||||
|
||||
const req = async (path: string, init: { method?: string; body?: unknown } = {}) => {
|
||||
const res = await app.request(`/api/v1${path}`, {
|
||||
method: init.method ?? (init.body !== undefined ? "POST" : "GET"),
|
||||
headers: {
|
||||
authorization: `Bearer ${DEV_KEY}`,
|
||||
...(init.body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
|
||||
});
|
||||
return { status: res.status, body: (await res.json()) as Loose };
|
||||
};
|
||||
const searches = (m: MockNexusPhpSite) => m.requests.filter((r) => r.path.includes("search=")).map((r) => r.path);
|
||||
const counts = () => [searches(a).length, searches(b).length];
|
||||
|
||||
beforeAll(async () => {
|
||||
const release = {
|
||||
id: "700001",
|
||||
title: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.HDR.HEVC.Atmos-GRP",
|
||||
subtitle: "沙丘2",
|
||||
catId: "401",
|
||||
catName: "电影",
|
||||
sizeText: "70.00 GB",
|
||||
seeders: "50",
|
||||
leechers: "1",
|
||||
snatched: "10",
|
||||
uploadedAt: "2026-09-20 10:00:00",
|
||||
};
|
||||
a = await createMockNexusPhpSite({ releases: [...AUDIENCES_FIXTURE_RELEASES, release] });
|
||||
b = await createMockNexusPhpSite({ releases: [{ ...release, id: "800001" }] });
|
||||
tmdb = await createMockTmdb();
|
||||
|
||||
schema = `search2_${process.pid.toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||
admin = new K<unknown>({
|
||||
dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: PG_URL, max: 1 }) }),
|
||||
});
|
||||
await sql.raw(`create schema ${schema}`).execute(admin);
|
||||
db = new K<DB>({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new pg.Pool({ connectionString: PG_URL, max: 8, options: `-c search_path=${schema},public` }),
|
||||
}),
|
||||
}) as unknown as Kysely<DB>;
|
||||
await migrateToLatest(db, { migrationTableSchema: schema });
|
||||
|
||||
const { logger } = memoryLogger();
|
||||
const secrets = fakeSecrets(() => db);
|
||||
const ctx = {
|
||||
role: "all",
|
||||
db,
|
||||
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY } as never,
|
||||
logger,
|
||||
events: new EventBus(5),
|
||||
engine,
|
||||
outbox: createOutboxPort(),
|
||||
secrets,
|
||||
settings: plainSettings(() => db),
|
||||
capabilities: {} as never,
|
||||
} as ServerContext;
|
||||
const sources = createSourcesIdentityModule({
|
||||
tmdb: () => createTmdbClient({ apiKey: "test-tmdb-key", baseUrl: `${tmdb.url}/3`, retries: 0 }),
|
||||
});
|
||||
app = createApp({
|
||||
config: { NODE_ENV: "development", DEV_ADMIN_KEY: DEV_KEY },
|
||||
logger,
|
||||
bus: new EventBus(5),
|
||||
role: "all",
|
||||
version: "0.0.0-test",
|
||||
modules: [
|
||||
sources,
|
||||
createAcquisitionOrganizeModule(),
|
||||
createAgentModule(),
|
||||
createSearchModule({ runtime: (c) => sources.runtime(c), siteTimeoutMs: 2000 }),
|
||||
],
|
||||
ctx,
|
||||
});
|
||||
|
||||
const addSite = async (key: string, name: string, m: MockNexusPhpSite | null, searchDefault: boolean) => {
|
||||
const id = (
|
||||
await db
|
||||
.insertInto("site")
|
||||
.values({
|
||||
key,
|
||||
name,
|
||||
adapter: "nexusphp",
|
||||
base_url: m ? `${m.baseUrl}/` : "http://never.invalid/",
|
||||
rate_limit: JSON.stringify({ minIntervalMs: 0, concurrency: 5 }),
|
||||
search_default: searchDefault,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
if (m) {
|
||||
const ref = await secrets.create({ purpose: "site_cookie", plaintext: m.cookie });
|
||||
await db
|
||||
.insertInto("site_account")
|
||||
.values({ site_id: id, label: "main", auth_kind: "cookie", secret_ref: ref })
|
||||
.execute();
|
||||
}
|
||||
return id;
|
||||
};
|
||||
sites.a = await addSite("audiences", "观众", a, true);
|
||||
sites.b = await addSite("hdsky", "天空", b, false);
|
||||
// 已关闭:任何模式都不查
|
||||
sites.c = await addSite("hdhome", "家园", null, true);
|
||||
await db.updateTable("site").set({ enabled: false }).where("id", "=", sites.c).execute();
|
||||
|
||||
// 本地已建档的沙丘2(接受了 TMDB 693134),有一个未满足的版本目标
|
||||
dune = (
|
||||
await db
|
||||
.insertInto("media_entity")
|
||||
.values({ kind: "movie", title: "沙丘2", original_title: "Dune: Part Two", year: 2024, status: "active" })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow()
|
||||
).id;
|
||||
await db
|
||||
.insertInto("title_alias")
|
||||
.values({
|
||||
media_entity_id: dune,
|
||||
alias: "沙丘2",
|
||||
normalized: normalizeTitle("沙丘2"),
|
||||
kind: "original",
|
||||
source: "tmdb",
|
||||
})
|
||||
.execute();
|
||||
await db
|
||||
.insertInto("external_identity")
|
||||
.values({
|
||||
media_entity_id: dune,
|
||||
provider: "tmdb",
|
||||
entity_type: "movie",
|
||||
external_id: "693134",
|
||||
status: "accepted",
|
||||
})
|
||||
.execute();
|
||||
await db
|
||||
.insertInto("acquisition_slot")
|
||||
.values({ media_entity_id: dune, name: "2160p Remux", status: "empty" })
|
||||
.execute();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.destroy();
|
||||
if (admin) {
|
||||
await sql.raw(`drop schema if exists ${schema} cascade`).execute(admin);
|
||||
await admin.destroy();
|
||||
}
|
||||
await a?.close();
|
||||
await b?.close();
|
||||
await tmdb?.close();
|
||||
});
|
||||
|
||||
it("第一阶段:作品本地 + TMDB 合并去重、本地在前、带库存对比;不请求任何站点", async () => {
|
||||
const before = counts();
|
||||
for (const scope of ["all", "media"]) {
|
||||
const r = await req(`/search?q=${encodeURIComponent("沙丘")}&scope=${scope}`);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.releases).toBeNull();
|
||||
const works = r.body.data.media.works as Loose[];
|
||||
// 同一 TMDB 身份只出现一次(本地那条),本地在前
|
||||
expect(works.filter((w) => w.tmdb?.externalId === "693134")).toHaveLength(1);
|
||||
expect(works[0]).toMatchObject({
|
||||
key: `local:${dune}`,
|
||||
source: "local",
|
||||
localEntityId: dune,
|
||||
tmdb: { entityType: "movie", externalId: "693134" },
|
||||
inventory: { state: "tracked", slotTotal: 1, slotSatisfied: 0, missing: [{ name: "2160p Remux" }] },
|
||||
});
|
||||
}
|
||||
const opp = await req(`/search?q=Oppenheimer&scope=media`);
|
||||
const w = (opp.body.data.media.works as Loose[]).find((x) => x.tmdb?.externalId === "872585");
|
||||
expect(w).toMatchObject({
|
||||
source: "tmdb",
|
||||
kind: "movie",
|
||||
localEntityId: null,
|
||||
year: 2023,
|
||||
inventory: { state: "not_collected", versionCount: 0 },
|
||||
});
|
||||
expect(w.overview).toBeTruthy();
|
||||
expect(counts()).toEqual(before);
|
||||
});
|
||||
|
||||
it("第二阶段:不带 sites 走默认集合(search_default),sites= 只查指定站点,关闭的站点不查", async () => {
|
||||
let before = counts();
|
||||
const def = await req(`/search/releases?q=Dune`);
|
||||
expect(def.status).toBe(200);
|
||||
expect(def.body.data.sites.map((s: Loose) => s.siteId)).toEqual([sites.a]);
|
||||
expect(counts()).toEqual([before[0]! + 1, before[1]]);
|
||||
// 惰性识别(DEC-1):搜到的未识别资源以 trigger=search 入队识别
|
||||
const resolves = engine.enqueued.filter((e) => e.task === "resolveObservation");
|
||||
expect(resolves.length).toBeGreaterThan(0);
|
||||
expect(resolves.every((e) => (e.input as { trigger?: string }).trigger === "search")).toBe(true);
|
||||
|
||||
before = counts();
|
||||
const only = await req(`/search/releases?q=Dune&sites=${sites.b},${sites.c}`);
|
||||
expect(only.body.data.sites.map((s: Loose) => s.siteId)).toEqual([sites.b]);
|
||||
// 结果只来自天空(其定义与 mock 页面结构不完全一致,条数不断言)
|
||||
expect(only.body.data.items.every((i: Loose) => i.siteId === sites.b)).toBe(true);
|
||||
expect(counts()).toEqual([before[0], before[1]! + 1]);
|
||||
|
||||
// 两个都选:两站的(站点, 查询词)都已缓存 → 不再请求
|
||||
before = counts();
|
||||
const both = await req(`/search/releases?q=Dune&sites=${sites.a},${sites.b}`);
|
||||
expect(both.body.data.cached).toBe(true);
|
||||
expect(both.body.data.sites).toHaveLength(2);
|
||||
expect(counts()).toEqual(before);
|
||||
});
|
||||
|
||||
it("settings.search.defaultSiteMode:all = 全部启用站点;custom = 指定站点", async () => {
|
||||
await db.deleteFrom("settings").where("key", "=", "search").execute();
|
||||
await db
|
||||
.insertInto("settings")
|
||||
.values({ key: "search", value: JSON.stringify({ defaultSiteMode: "all", siteIds: [] }) })
|
||||
.execute();
|
||||
const all = await req(`/search/releases?q=Oppenheimer`);
|
||||
expect(all.body.data.sites.map((s: Loose) => s.siteId).sort()).toEqual([sites.a, sites.b].sort());
|
||||
|
||||
await db
|
||||
.updateTable("settings")
|
||||
.set({ value: JSON.stringify({ defaultSiteMode: "custom", siteIds: [sites.b] }) })
|
||||
.where("key", "=", "search")
|
||||
.execute();
|
||||
const custom = await req(`/search/releases?q=Interstellar`);
|
||||
expect(custom.body.data.sites.map((s: Loose) => s.siteId)).toEqual([sites.b]);
|
||||
await db.deleteFrom("settings").where("key", "=", "search").execute();
|
||||
});
|
||||
|
||||
it("按作品搜资源:服务端用原名 + 年份 / 译名生成查询词,每站每词一次", async () => {
|
||||
const before = counts();
|
||||
const r = await req(`/search/releases?tmdb=movie:693134&sites=${sites.a}`);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.queries).toEqual(["Dune: Part Two 2024", "沙丘2"]);
|
||||
expect(r.body.data.q).toBe("Dune: Part Two 2024");
|
||||
const sent = searches(a).slice(before[0]);
|
||||
expect(sent).toHaveLength(2);
|
||||
expect(r.body.data.sites[0]).toMatchObject({ siteId: sites.a, status: "ok" });
|
||||
|
||||
const byEntity = await req(`/search/releases?entityId=${dune}&sites=${sites.a}`);
|
||||
expect(byEntity.body.data.queries).toEqual(["Dune: Part Two 2024", "沙丘2"]);
|
||||
expect(byEntity.body.data.cached).toBe(true);
|
||||
|
||||
expect((await req(`/search/releases?sites=${sites.a}`)).status).toBe(422);
|
||||
expect((await req(`/search/releases?tmdb=movie:999999999`)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET /media/tmdb/{type}/{id}:TMDB 信息 + 季列表 + 库存对比 + 是否已建档", async () => {
|
||||
const before = counts();
|
||||
const movie = await req(`/media/tmdb/movie/872585`);
|
||||
expect(movie.status).toBe(200);
|
||||
expect(movie.body.data).toMatchObject({
|
||||
provider: "tmdb",
|
||||
entityType: "movie",
|
||||
externalId: "872585",
|
||||
kind: "movie",
|
||||
year: 2023,
|
||||
originalTitle: "Oppenheimer",
|
||||
localEntityId: null,
|
||||
inventory: { state: "not_collected" },
|
||||
mediaServers: null,
|
||||
seasons: [],
|
||||
queries: ["Oppenheimer 2023", "奥本海默"],
|
||||
externalIds: { imdb: "tt15398776" },
|
||||
});
|
||||
|
||||
const local = await req(`/media/tmdb/movie/693134?include=mediaServers`);
|
||||
expect(local.body.data).toMatchObject({
|
||||
localEntityId: dune,
|
||||
inventory: { state: "tracked", missing: [{ name: "2160p Remux" }] },
|
||||
mediaServers: { mediaEntityId: dune, instances: [] },
|
||||
});
|
||||
|
||||
const tv = await req(`/media/tmdb/tv/136315`);
|
||||
expect(tv.body.data.kind).toBe("series");
|
||||
expect(tv.body.data.seasons.map((s: Loose) => [s.seasonNumber, s.episodeCount, s.localHave])).toEqual([
|
||||
[1, 8, null],
|
||||
[2, 10, null],
|
||||
[3, 10, null],
|
||||
]);
|
||||
expect(tv.body.data.queries).toEqual(["The Bear", "熊家餐馆"]);
|
||||
|
||||
expect((await req(`/media/tmdb/movie/999999999`)).status).toBe(404);
|
||||
expect((await req(`/media/tmdb/person/1`)).status).toBe(422);
|
||||
// 详情页不查站点
|
||||
expect(counts()).toEqual(before);
|
||||
});
|
||||
|
||||
it("GET /inventory/media-servers 可按 TMDB 条目查(未建档时 mediaEntityId = null)", async () => {
|
||||
const r = await req(`/inventory/media-servers?tmdbType=movie&tmdbId=872585`);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data).toMatchObject({ mediaEntityId: null, instances: [], partial: false });
|
||||
const linked = await req(`/inventory/media-servers?tmdbType=movie&tmdbId=693134`);
|
||||
expect(linked.body.data.mediaEntityId).toBe(dune);
|
||||
expect((await req(`/inventory/media-servers`)).status).toBe(422);
|
||||
});
|
||||
|
||||
it("站点「默认参与搜索」可在 PATCH /sites/{id} 修改,列表返回 searchDefault", async () => {
|
||||
const r = await req(`/sites/${sites.b}`, { method: "PATCH", body: { searchDefault: true } });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.data.searchDefault).toBe(true);
|
||||
const list = await req(`/sites`);
|
||||
const byId = Object.fromEntries(list.body.data.items.map((s: Loose) => [s.id, s.searchDefault]));
|
||||
expect(byId[sites.a]).toBe(true);
|
||||
expect(byId[sites.b]).toBe(true);
|
||||
await req(`/sites/${sites.b}`, { method: "PATCH", body: { searchDefault: false } });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user