From ac478673bcbb3f9facebe2d9708e26f024c80217 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 29 Sep 2026 15:13:35 +0800 Subject: [PATCH] docs(plan): on-demand site detail page parsing (HHClub details.php) Co-Authored-By: Claude Fable 5.1 --- .../acquisition-organize/routes/library.ts | 46 ++- apps/server/src/modules/search/media.ts | 2 +- apps/server/src/modules/search/releases.ts | 199 +++++++--- apps/server/src/modules/search/routes.ts | 198 ++++++++-- apps/server/src/modules/search/works.ts | 270 +++++++++++++ .../sources-identity/routes/sources.ts | 2 + apps/server/test/search/search.test.ts | 8 +- apps/server/test/search/search2.test.ts | 368 ++++++++++++++++++ apps/web/app/api/search.ts | 43 +- apps/web/app/components/console/nav.ts | 6 +- .../app/components/search/command-palette.tsx | 88 ++--- .../app/components/search/release-results.tsx | 68 +++- .../app/components/search/search-sections.tsx | 180 +++------ .../app/components/search/search-trigger.tsx | 2 +- .../web/app/components/search/search2.test.ts | 124 ++++++ .../components/search/site-picker-dialog.tsx | 171 ++++++++ apps/web/app/components/search/work-card.tsx | 140 +++++++ .../sites/search-default-dialog.tsx | 110 ++++++ apps/web/app/components/sites/site-card.tsx | 28 +- .../web/app/components/sites/site-dialogs.tsx | 16 + .../subscriptions/subscription-form.tsx | 71 +++- apps/web/app/routes.ts | 1 + apps/web/app/routes/console/library.$id.tsx | 43 ++ .../routes/console/media.tmdb.$type.$id.tsx | 294 ++++++++++++++ apps/web/app/routes/console/search.tsx | 68 +++- apps/web/app/routes/console/sites.tsx | 9 +- docs/plan/00-first-version-plan.md | 1 + docs/runbook/search.md | 29 +- docs/runbook/sites.md | 5 + docs/runbook/subscriptions.md | 5 + packages/contracts/src/api.ts | 10 + packages/contracts/src/organization.ts | 20 +- packages/contracts/src/search.ts | 118 +++++- packages/contracts/src/settings.ts | 19 + packages/contracts/src/sources.ts | 4 + packages/contracts/test/db-sync.test.ts | 2 +- packages/contracts/test/schemas.test.ts | 1 + .../db/migrations/0048_site_search_default.ts | 15 + packages/db/src/types.ts | 1 + 39 files changed, 2446 insertions(+), 339 deletions(-) create mode 100644 apps/server/src/modules/search/works.ts create mode 100644 apps/server/test/search/search2.test.ts create mode 100644 apps/web/app/components/search/search2.test.ts create mode 100644 apps/web/app/components/search/site-picker-dialog.tsx create mode 100644 apps/web/app/components/search/work-card.tsx create mode 100644 apps/web/app/components/sites/search-default-dialog.tsx create mode 100644 apps/web/app/routes/console/media.tmdb.$type.$id.tsx create mode 100644 packages/db/migrations/0048_site_search_default.ts diff --git a/apps/server/src/modules/acquisition-organize/routes/library.ts b/apps/server/src/modules/acquisition-organize/routes/library.ts index baf07399..7950c4fc 100644 --- a/apps/server/src/modules/acquisition-organize/routes/library.ts +++ b/apps/server/src/modules/acquisition-organize/routes/library.ts @@ -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 }, ); diff --git a/apps/server/src/modules/search/media.ts b/apps/server/src/modules/search/media.ts index 21e980a6..04f32fdc 100644 --- a/apps/server/src/modules/search/media.ts +++ b/apps/server/src/modules/search/media.ts @@ -199,7 +199,7 @@ export class TmdbSearch { } /** 已接受的 TMDB 身份 → 本地作品(合并过的跟到最终作品) */ -async function localEntities( +export async function localEntities( ctx: ServerContext, refs: { entityType: string; externalId: string }[], ): Promise> { diff --git a/apps/server/src/modules/search/releases.ts b/apps/server/src/modules/search/releases.ts index dc490606..01327edc 100644 --- a/apps/server/src/modules/search/releases.ts +++ b/apps/server/src/modules/search/releases.ts @@ -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 = { ok: 0, timeout: 1, failed: 2, skipped: 3 }; export class ReleaseFanout { - private readonly cache = new Map(); - private readonly inflight = new Map>(); + private readonly cache = new Map(); + private readonly inflight = new Map>(); constructor( private readonly rt: () => SourcesRuntime, private readonly opts: FanoutOptions, ) {} - async search(q: string, opts: { refresh?: boolean } = {}): Promise { - 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 { + 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 { + 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 { - 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 { 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; 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(); @@ -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(); + 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, 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) => { diff --git a/apps/server/src/modules/search/routes.ts b/apps/server/src/modules/search/routes.ts index 7f0770fc..ce9f0927 100644 --- a/apps/server/src/modules/search/routes.ts +++ b/apps/server/src/modules/search/routes.ts @@ -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 => { + 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 => { + 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(), 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(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"); diff --git a/apps/server/src/modules/search/works.ts b/apps/server/src/modules/search/works.ts new file mode 100644 index 00000000..af40dc2e --- /dev/null +++ b/apps/server/src/modules/search/works.ts @@ -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 | null)?.[field]?.value; + return typeof v === "string" && v ? v : null; +}; + +/** 本地作品展示信息:海报 / 简介取元数据最终值,TMDB 身份取已接受的那条 */ +export async function localInfo(ctx: ServerContext, ids: string[]): Promise> { + const out = new Map(); + 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> { + const out = new Map(); + if (ids.length === 0) return out; + const rows = await ctx.db + .selectFrom("acquisition_slot") + .select(["media_entity_id", "status"]) + .select(sql`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; + +/** + * 合并:本地命中在前(按得分),再补 TMDB 候选里已建档但没被本地召回的作品,最后是仅 TMDB 的; + * 同一 TMDB 身份 / 同一本地作品只出现一次。本地作品缺海报 / 简介时用对应 TMDB 候选的。 + */ +export function buildWorks( + local: LocalHitBase[], + tmdb: TmdbCandidate[], + info: Map, + inv: Map, + counts: Map, +): SearchWork[] { + const out: SearchWork[] = []; + const seenLocal = new Set(); + const seenTmdb = new Set(); + const tmdbByLocal = new Map(); + 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(); + 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> { + 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(); + 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 | 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, + })); +} diff --git a/apps/server/src/modules/sources-identity/routes/sources.ts b/apps/server/src/modules/sources-identity/routes/sources.ts index 1ca2ebc7..a95e5771 100644 --- a/apps/server/src/modules/sources-identity/routes/sources.ts +++ b/apps/server/src/modules/sources-identity/routes/sources.ts @@ -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(); diff --git a/apps/server/test/search/search.test.ts b/apps/server/test/search/search.test.ts index ddd2aaab..872a139a 100644 --- a/apps/server/test/search/search.test.ts +++ b/apps/server/test/search/search.test.ts @@ -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 () => { diff --git a/apps/server/test/search/search2.test.ts b/apps/server/test/search/search2.test.ts new file mode 100644 index 00000000..70bc59ae --- /dev/null +++ b/apps/server/test/search/search2.test.ts @@ -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; + let admin: K; + let schema: string; + let a: MockNexusPhpSite; + let b: MockNexusPhpSite; + let tmdb: MockTmdb; + let app: ReturnType; + 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({ + dialect: new PostgresDialect({ pool: new pg.Pool({ connectionString: PG_URL, max: 1 }) }), + }); + await sql.raw(`create schema ${schema}`).execute(admin); + db = new K({ + dialect: new PostgresDialect({ + pool: new pg.Pool({ connectionString: PG_URL, max: 8, options: `-c search_path=${schema},public` }), + }), + }) as unknown as Kysely; + 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 } }); + }); +}); diff --git a/apps/web/app/api/search.ts b/apps/web/app/api/search.ts index 392947a2..8dda8bc2 100644 --- a/apps/web/app/api/search.ts +++ b/apps/web/app/api/search.ts @@ -1,21 +1,26 @@ import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ManualAcquisitionResult, + MediaServerInventory, ReleaseSearchPage, type ReleaseSearchSort, type SearchReleasePlanRequest, SearchResponse, type SearchScope, + TmdbMediaDetail, } from "@umt/contracts"; import { api } from "~/lib/api.ts"; import { getParsed, parseOrThrow } from "./library.ts"; -// 全局搜索(W9-G):命令面板查 media/actions(快);资源结果页走 /search/releases(筛选/排序/游标,与 /search 共用 30s 缓存)。 +// 全局搜索(W9-G / SEARCH-2):先查作品(本地 + TMDB,带库存对比,不碰站点);需要时按作品显式搜资源 +// /search/releases(站点可选,筛选/排序/游标,(站点, 查询词)30s 缓存)。仅 TMDB 的作品详情 /media/tmdb/{type}/{id}。 export const searchKeys = { all: ["search"] as const, aggregate: (q: string, scope: SearchScope, limit: number) => ["search", "aggregate", q, scope, limit] as const, releases: (f: ReleaseSearchFilters) => ["search", "releases", f] as const, + tmdb: (type: string, id: string, ms: boolean) => ["search", "tmdb", type, id, ms] as const, + mediaServers: (ref: MediaServerRef) => ["search", "media-servers", ref] as const, }; /** 聚合搜索;q 为空不发请求 */ @@ -31,9 +36,16 @@ export function useSearch(q: string, scope: SearchScope, limit = 8, enabled = tr } export type ReleaseSearchFilters = { - q: string; - /** 逗号分隔 */ + /** 关键词;按作品搜时省略,由服务端生成查询词 */ + q?: string; + /** 按本地作品搜 */ + entityId?: string; + /** 按 TMDB 条目搜:movie:157336 */ + tmdb?: string; + /** 逗号分隔:只查这些站点 */ sites?: string; + /** 逗号分隔:只筛选结果 */ + siteFilter?: string; resolution?: string; source?: string; /** 字节 */ @@ -49,7 +61,7 @@ export type ReleaseSearchFilters = { export function useReleaseSearch(filters: ReleaseSearchFilters, enabled = true) { return useInfiniteQuery({ queryKey: searchKeys.releases(filters), - enabled: enabled && filters.q.trim().length > 0, + enabled: enabled && (!!filters.q?.trim() || !!filters.entityId || !!filters.tmdb), initialPageParam: undefined as string | undefined, queryFn: ({ pageParam, signal }) => getParsed(ReleaseSearchPage, "/search/releases", { ...filters, cursor: pageParam, limit: 50 }, signal), @@ -91,3 +103,26 @@ export function usePlanFromSearch() { }, }); } + +/** 仅 TMDB 作品的详情(TMDB 信息 + 季 + 本地对比);Emby 对比另走 useMediaServerInventory */ +export function useTmdbMedia(type: string | undefined, id: string | undefined) { + return useQuery({ + queryKey: searchKeys.tmdb(type ?? "", id ?? "", false), + enabled: (type === "movie" || type === "tv") && !!id && /^\d+$/.test(id), + queryFn: ({ signal }) => getParsed(TmdbMediaDetail, `/media/tmdb/${type}/${id}`, {}, signal), + staleTime: 60_000, + }); +} + +export type MediaServerRef = { mediaEntityId: string } | { tmdbType: "movie" | "tv"; tmdbId: string }; + +/** Emby 库存对比(按本地作品或 TMDB 条目);实例失败 / 超时为 unknown,不等于缺失 */ +export function useMediaServerInventory(ref: MediaServerRef | null, enabled = true) { + return useQuery({ + queryKey: searchKeys.mediaServers(ref ?? { mediaEntityId: "" }), + enabled: enabled && !!ref, + queryFn: ({ signal }) => getParsed(MediaServerInventory, "/inventory/media-servers", ref ?? {}, signal), + staleTime: 5 * 60_000, + retry: false, + }); +} diff --git a/apps/web/app/components/console/nav.ts b/apps/web/app/components/console/nav.ts index 9bd9e489..88b46ebf 100644 --- a/apps/web/app/components/console/nav.ts +++ b/apps/web/app/components/console/nav.ts @@ -166,5 +166,9 @@ export function findNavItem(pathname: string): NavItem | undefined { /** 顶栏标题:导航项名,或不在侧栏的页面名 */ export function pageTitle(pathname: string): string | undefined { const path = pathname.replace(/\/+$/, ""); - return findNavItem(pathname)?.label ?? EXTRA_TITLES[path]; + return ( + findNavItem(pathname)?.label ?? + EXTRA_TITLES[path] ?? + (path.startsWith("/console/media/tmdb/") ? "作品详情" : undefined) + ); } diff --git a/apps/web/app/components/search/command-palette.tsx b/apps/web/app/components/search/command-palette.tsx index 33597c53..799284e9 100644 --- a/apps/web/app/components/search/command-palette.tsx +++ b/apps/web/app/components/search/command-palette.tsx @@ -1,5 +1,5 @@ -import { type LocalMediaHit, type SearchAction, type TmdbCandidate, tmdbImageUrl } from "@umt/contracts"; -import { ArrowRight, CornerDownLeft, Film, Globe, LoaderCircle, type LucideIcon, Plus, Search } from "lucide-react"; +import { type SearchAction, type SearchWork, tmdbImageUrl } from "@umt/contracts"; +import { ArrowRight, CornerDownLeft, Film, Globe, LoaderCircle, type LucideIcon, Search } from "lucide-react"; import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react"; import { Link, useNavigate } from "react-router"; import { useSearch } from "~/api/search.ts"; @@ -7,8 +7,7 @@ import { settingsPath } from "~/components/console/nav.ts"; import { Dialog, DialogContent, DialogDescription, DialogTitle } from "~/components/ui/dialog.tsx"; import { errorMessage } from "~/lib/api.ts"; import { cn } from "~/lib/utils.ts"; -import { actionIcon, mediaKindLabel, slotSummary } from "./search-labels.ts"; -import { useSubscribeTmdb } from "./search-sections.tsx"; +import { actionIcon, mediaKindLabel } from "./search-labels.ts"; import { isSearchShortcut, openSearchPalette, @@ -16,9 +15,10 @@ import { setSearchPaletteOpen, useSearchPalette, } from "./search-store.ts"; +import { inventoryBadge, workHref } from "./work-card.tsx"; -// 命令面板(Ctrl/⌘+K):输入即查本地作品 / TMDB 候选 / 页面与操作,最后一项跳资源结果页。 -// 资源组最慢要等 15s,面板里不查,回车进结果页。 +// 命令面板(Ctrl/⌘+K):输入即查作品(本地 + TMDB 合并,带库内状态)/ 页面与操作;选中作品打开作品详情页。 +// 面板里不订阅、不查站点;最后一项进搜索结果页。 function useDebounced(v: T, ms: number): T { const [d, setD] = useState(v); @@ -68,7 +68,7 @@ export function CommandPalette() { )} > 搜索 - 搜索作品、TMDB 和页面;回车在资源中搜索 + 搜索作品和页面;选中作品打开详情 {open ? : null} @@ -82,7 +82,6 @@ function PaletteBody({ initial }: { initial: string }) { const term = useDebounced(q.trim(), 150); const media = useSearch(term, "media", 6); const actions = useSearch(term, "actions", 6); - const tmdbSub = useSubscribeTmdb(); const [active, setActive] = useState(0); const listRef = useRef(null); @@ -92,36 +91,32 @@ function PaletteBody({ initial }: { initial: string }) { navigate(to); }; - const subscribe = (c: TmdbCandidate) => tmdbSub.subscribe(c, close); - const stale = q.trim() !== term; - const local = term ? (media.data?.media?.local.items ?? []) : []; + const works = term ? (media.data?.media?.works ?? []) : []; const tmdb = term ? media.data?.media?.tmdb : undefined; const acts = term ? (actions.data?.actions?.items ?? []) : []; - // biome-ignore lint/correctness/useExhaustiveDependencies: 只随数据变化重建(go/subscribe 每次渲染新建) + // biome-ignore lint/correctness/useExhaustiveDependencies: 只随数据变化重建(go 每次渲染新建) const items = useMemo(() => { const out: Item[] = []; - for (const h of local) out.push(localItem(h, () => go(`/console/library/${h.entity.id}`))); - for (const c of tmdb?.items ?? []) - out.push(tmdbItem(c, () => go(`/console/library/${c.localEntityId}`), subscribe)); + for (const w of works) out.push(workItem(w, () => go(workHref(w)))); for (const a of acts) out.push(actionItem(a, () => go(a.href))); if (term) out.push({ key: "releases", - group: "资源", + group: "搜索页", icon: Search, title: ( - 在资源中搜索 “{term}” + 查看「{term}」的全部结果 ), - meta: "查询全部已启用站点,最慢约 15 秒", + meta: "作品卡上可以选站点搜资源", hint: "打开", run: () => go(searchPagePath(term)), }); return out; - }, [local, tmdb, acts, term]); + }, [works, acts, term]); // biome-ignore lint/correctness/useExhaustiveDependencies: 结果变化时高亮回到第一项 useEffect(() => setActive(0), [items.length, term]); @@ -160,7 +155,7 @@ function PaletteBody({ initial }: { initial: string }) { value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={onKeyDown} - placeholder="搜索作品、TMDB、页面" + placeholder="搜索作品、页面" role="combobox" aria-expanded={items.length > 0} aria-controls={listId} @@ -190,7 +185,7 @@ function PaletteBody({ initial }: { initial: string }) { > {!term ? (

- 输入作品名、别名或页面名称。回车在资源中搜索。 + 输入作品名、别名或页面名称。选中作品打开详情页。

) : ( <> @@ -261,30 +256,36 @@ function PaletteBody({ initial }: { initial: string }) { ↵ 打开 - 回车在资源中搜索 + 搜资源在作品详情页 ); } -function localItem(h: LocalMediaHit, run: () => void): Item { - const e = h.entity; - const parts = [mediaKindLabel(e.kind), e.year ? String(e.year) : null, slotSummary(h)].filter(Boolean); +function workItem(w: SearchWork, run: () => void): Item { + const inv = inventoryBadge(w.inventory); + const parts = [ + mediaKindLabel(w.kind), + w.year ? String(w.year) : null, + inv.label, + w.inventory.subscribed ? "已订阅" : null, + ]; return { - key: `local-${e.id}`, - group: "本地作品", - icon: Film, + key: `work-${w.key}`, + group: "作品", + icon: w.localEntityId ? Film : Globe, + posterPath: w.posterPath, title: ( - {e.title} - {h.matchedAlias && h.matchedAlias !== e.title ? ( - · {h.matchedAlias} + {w.title} + {w.originalTitle && w.originalTitle !== w.title ? ( + · {w.originalTitle} ) : null} ), - meta: parts.join(" · "), - hint: "打开", + meta: parts.filter(Boolean).join(" · "), + hint: "详情", run, }; } @@ -306,27 +307,6 @@ function ItemIcon({ it }: { it: Item }) { return ; } -function tmdbItem(c: TmdbCandidate, view: () => void, subscribe: (c: TmdbCandidate) => void): Item { - const local = !!c.localEntityId; - return { - key: `tmdb-${c.entityType}-${c.externalId}`, - group: "TMDB", - icon: local ? Globe : Plus, - posterPath: c.posterPath, - title: ( - - {c.title} - {c.year ? ({c.year}) : null} - - ), - meta: [c.entityType === "movie" ? "电影" : "剧集", local ? "本地已有" : c.originalTitle, c.overview] - .filter(Boolean) - .join(" · "), - hint: local ? "查看" : "订阅", - run: local ? view : () => subscribe(c), - }; -} - function actionItem(a: SearchAction, run: () => void): Item { return { key: `action-${a.id}`, diff --git a/apps/web/app/components/search/release-results.tsx b/apps/web/app/components/search/release-results.tsx index 4d850dfa..989b761d 100644 --- a/apps/web/app/components/search/release-results.tsx +++ b/apps/web/app/components/search/release-results.tsx @@ -29,7 +29,8 @@ import { cn, formatBytes } from "~/lib/utils.ts"; import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx"; import { gbToBytes, INVENTORY_STATE, promoLabel, SITE_STATUS, SORT_OPTIONS } from "./search-labels.ts"; -// 资源段:跨站结果的筛选(站点/分辨率/来源/大小/免费/H&R)、排序、游标;行操作同站点工作台。 +// 资源段(SEARCH-2 第二阶段):按作品或关键词、在选中的站点上搜;筛选(站点/分辨率/来源/大小/免费/H&R)、排序、游标; +// 行操作同站点工作台(库存叠加、下载、委托 Agent)。 /** URL 里的资源筛选(字符串形态,空串 = 不筛) */ export type ReleaseFilterState = { @@ -55,12 +56,25 @@ export const RELEASE_FILTER_KEYS = [ "order", ] as const satisfies readonly (keyof ReleaseFilterState)[]; -export function toApiFilters(q: string, f: ReleaseFilterState): ReleaseSearchFilters { +/** 搜什么:关键词,或按作品(服务端生成查询词) */ +export type ReleaseTarget = { q?: string; entityId?: string; tmdb?: string }; + +/** URL 参数 work → 搜索目标:local: / tmdb:: / q:<关键词> */ +export function parseWorkRef(ref: string): ReleaseTarget | null { + if (ref.startsWith("local:")) return { entityId: ref.slice(6) }; + const m = /^tmdb:(movie|tv):(\d+)$/.exec(ref); + if (m) return { tmdb: `${m[1]}:${m[2]}` }; + if (ref.startsWith("q:") && ref.slice(2).trim()) return { q: ref.slice(2).trim() }; + return null; +} + +export function toApiFilters(target: ReleaseTarget, f: ReleaseFilterState, siteIds = ""): ReleaseSearchFilters { const bool = (v: string) => (v === "true" || v === "false" ? v : undefined); const sort = SORT_OPTIONS.some((o) => o.value === f.sort) ? (f.sort as ReleaseSearchFilters["sort"]) : undefined; return { - q, - sites: f.sites || undefined, + ...target, + sites: siteIds || undefined, + siteFilter: f.sites || undefined, resolution: f.resolution || undefined, source: f.source || undefined, minSize: gbToBytes(f.minGb), @@ -75,17 +89,22 @@ export function toApiFilters(q: string, f: ReleaseFilterState): ReleaseSearchFil const csvList = (s: string) => (s ? s.split(",").filter(Boolean) : []); export function ReleaseResults({ - q, + target, + siteIds, filters, setFilter, resetFilters, + onChangeSites, }: { - q: string; + target: ReleaseTarget; + /** 选中的站点(逗号分隔);空 = 服务端按默认站点设置 */ + siteIds: string; + onChangeSites?: () => void; filters: ReleaseFilterState; setFilter: (k: keyof ReleaseFilterState, v: string) => void; resetFilters: () => void; }) { - const api = useMemo(() => toApiFilters(q, filters), [q, filters]); + const api = useMemo(() => toApiFilters(target, filters, siteIds), [target, filters, siteIds]); const res = useReleaseSearch(api); const refresh = useRefreshReleaseSearch(); const pages = res.data?.pages ?? []; @@ -109,20 +128,29 @@ export function ReleaseResults({ ) : null} {first ? ( -

- {first.cached ? "缓存结果" : "站点实时结果"} · 抓取于 {relTime(first.fetchedAt)} · {first.tookMs} ms +

+ {first.sites.length} 个站点 · {first.cached ? "缓存结果" : "站点实时结果"} · 抓取于{" "} + {relTime(first.fetchedAt)} · {first.tookMs} ms + {first.queries?.length ? ` · 查询词 ${first.queries.map((x) => `「${x}」`).join("")}` : ""}

) : null} - +
+ {onChangeSites ? ( + + ) : null} + +
{first ? : null} @@ -143,8 +171,8 @@ export function ReleaseResults({ ) : ( diff --git a/apps/web/app/components/search/search-sections.tsx b/apps/web/app/components/search/search-sections.tsx index 14128a00..9f59befa 100644 --- a/apps/web/app/components/search/search-sections.tsx +++ b/apps/web/app/components/search/search-sections.tsx @@ -1,18 +1,17 @@ -import type { TmdbCandidate } from "@umt/contracts"; -import { ArrowRight, Film, Plus } from "lucide-react"; +import type { SearchWork } from "@umt/contracts"; +import { ArrowRight, Film, PackageSearch } from "lucide-react"; +import { useState } from "react"; import { Link, useNavigate } from "react-router"; -import { toast } from "sonner"; -import { useEntityFromCandidate } from "~/api/files.ts"; +import { mediaServers } from "~/api/instances.ts"; import { useSearch } from "~/api/search.ts"; -import { useCreateSubscription } from "~/api/subscriptions.ts"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { settingsPath } from "~/components/console/nav.ts"; -import { StatusBadge } from "~/components/console/status-badge.tsx"; import { Button } from "~/components/ui/button.tsx"; -import { errorMessage } from "~/lib/api.ts"; -import { actionIcon, mediaKindLabel, slotSummary } from "./search-labels.ts"; +import { actionIcon } from "./search-labels.ts"; +import { SitePickerDialog } from "./site-picker-dialog.tsx"; +import { releaseSearchPath, WorkCard, workRef } from "./work-card.tsx"; -// 结果页的"作品"与"操作"两段(资源段见 release-results.tsx) +// 结果页第一阶段:作品(本地 + TMDB 合并,带库存对比)与操作;资源要点「搜索资源」选站点后才查(release-results.tsx) function SectionTitle({ id, title, extra }: { id: string; title: string; extra?: string }) { return ( @@ -23,131 +22,76 @@ function SectionTitle({ id, title, extra }: { id: string; title: string; extra?: ); } -/** 从 TMDB 候选建订阅:先按 TMDB id 建档(季/集与总集数随之就位)再订阅作品;建档失败退回按标题追踪 */ -export function useSubscribeTmdb() { - const create = useCreateSubscription(); - const entity = useEntityFromCandidate(); +/** 「搜索资源」:先选站点,再跳到第二阶段(同一搜索页带 work / in 参数) */ +export function useReleaseSearchLauncher() { const navigate = useNavigate(); + const [target, setTarget] = useState<{ title: string; ref: string } | null>(null); return { - pending: create.isPending || entity.isPending, - subscribe: async (c: TmdbCandidate, onDone?: () => void) => { - const kind = c.entityType === "movie" ? "movie" : "series"; - const mediaEntityId = - c.localEntityId ?? - (await entity - .mutateAsync({ provider: "tmdb", externalId: c.externalId, kind: c.entityType === "movie" ? "movie" : "tv" }) - .then((r) => r.mediaEntityId) - .catch(() => null)); - create.mutate( - { - ...(mediaEntityId ? { mediaEntityId } : { titleQuery: c.year ? `${c.title} ${c.year}` : c.title }), - mediaKind: kind, - mode: kind === "movie" ? "once" : "continuous", - seasonScope: kind === "movie" ? "current_season" : "following_seasons", - priority: "new_work", - origin: "user", - }, - { - onSuccess: () => { - toast.success(`已订阅「${c.title}」`, { - action: { label: "查看", onClick: () => navigate("/console/subscriptions") }, - }); - onDone?.(); - }, - onError: (e) => toast.error(errorMessage(e, "订阅失败")), - }, - ); - }, + open: (t: { title: string; ref: string }) => setTarget(t), + dialog: ( + !o && setTarget(null)} + title={target?.title ?? ""} + onConfirm={(ids) => target && navigate(releaseSearchPath(target, ids))} + /> + ), }; } -export function MediaSection({ q }: { q: string }) { +export function WorksSection({ q }: { q: string }) { const res = useSearch(q, "media", 20); - const sub = useSubscribeTmdb(); - const local = res.data?.media?.local.items ?? []; + const ms = mediaServers.useList(); + const emby = (ms.data?.items ?? []).some((i) => (i as { enabled?: boolean }).enabled !== false); + const launcher = useReleaseSearchLauncher(); + const works = res.data?.media?.works ?? []; const tmdb = res.data?.media?.tmdb; + const searchReleases = (w: SearchWork) => { + const ref = workRef(w); + if (ref) launcher.open({ title: w.title, ref }); + }; return (
- -
- {res.isPending ? ( - - ) : res.isError ? ( +
+ + +
+ {res.isPending ? ( + + ) : res.isError ? ( +
res.refetch()} /> - ) : local.length === 0 && !tmdb?.items.length ? ( +
+ ) : works.length === 0 ? ( +
- ) : ( -
    - {local.map((h) => ( -
  • - - -
    -

    - {h.entity.title} - {h.entity.year ? ({h.entity.year}) : null} -

    -

    - {mediaKindLabel(h.entity.kind)} · {slotSummary(h)} - {h.matchedAlias && h.matchedAlias !== h.entity.title ? ` · 命中别名 ${h.matchedAlias}` : ""} -

    -
    - 本地 - -
  • - ))} - {(tmdb?.items ?? []).map((c) => ( -
  • - -
    -

    - {c.title} - {c.year ? ({c.year}) : null} -

    -

    - TMDB · {c.entityType === "movie" ? "电影" : "剧集"} - {c.originalTitle && c.originalTitle !== c.title ? ` · ${c.originalTitle}` : ""} - {c.overview ? ` · ${c.overview}` : ""} -

    -
    - {c.localEntityId ? ( - - ) : ( - - )} -
  • - ))} -
- )} - {tmdb && !tmdb.configured ? ( -

- TMDB 未配置,只搜了本地作品。 - - 去配置 - -

- ) : null} - {tmdb?.error ? ( -

TMDB 查询失败:{tmdb.error}

- ) : null} -
+
+ ) : ( +
+ {works.map((w) => ( + + ))} +
+ )} + {tmdb && !tmdb.configured ? ( +

+ TMDB 未配置,只搜了本地作品。 + + 去配置 + +

+ ) : null} + {tmdb?.error ?

TMDB 查询失败:{tmdb.error}

: null} + {launcher.dialog}
); } diff --git a/apps/web/app/components/search/search-trigger.tsx b/apps/web/app/components/search/search-trigger.tsx index f4434a43..21d2afc8 100644 --- a/apps/web/app/components/search/search-trigger.tsx +++ b/apps/web/app/components/search/search-trigger.tsx @@ -22,7 +22,7 @@ export function SearchTrigger({ className }: { className?: string }) { )} > - 搜索作品、资源、页面 + 搜索作品、页面 {shortcut} diff --git a/apps/web/app/components/search/search2.test.ts b/apps/web/app/components/search/search2.test.ts new file mode 100644 index 00000000..0b257212 --- /dev/null +++ b/apps/web/app/components/search/search2.test.ts @@ -0,0 +1,124 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import type { MediaServerInventory, WorkInventory } from "@umt/contracts"; +import { describe, expect, it } from "vitest"; +import { parseWorkRef, type ReleaseFilterState, toApiFilters } from "./release-results.tsx"; +import { resolvePick } from "./site-picker-dialog.tsx"; +import { inventoryBadge, mediaServerSummary, releaseSearchPath, slotLine, workHref, workRef } from "./work-card.tsx"; + +// SEARCH-2:作品 → 详情 / 搜资源的地址,站点选择解析,资源查询参数,订阅入口只在作品详情页。 + +const inv = (p: Partial): WorkInventory => ({ + state: "not_collected", + subscribed: false, + versionCount: 0, + downloadingCount: 0, + slotTotal: 0, + slotSatisfied: 0, + missing: [], + ...p, +}); +const ID = "11111111-1111-4111-8111-111111111111"; + +describe("作品地址", () => { + it("本地作品进媒体库详情;仅 TMDB 的进 TMDB 详情", () => { + expect(workHref({ localEntityId: ID, tmdb: { entityType: "movie", externalId: "1" } })).toBe( + `/console/library/${ID}`, + ); + expect(workHref({ localEntityId: null, tmdb: { entityType: "tv", externalId: "1399" } })).toBe( + "/console/media/tmdb/tv/1399", + ); + }); + + it("搜资源引用与第二阶段地址可以往返", () => { + const ref = workRef({ localEntityId: null, tmdb: { entityType: "movie", externalId: "157336" } }); + expect(ref).toBe("tmdb:movie:157336"); + expect(parseWorkRef(ref!)).toEqual({ tmdb: "movie:157336" }); + expect(parseWorkRef(`local:${ID}`)).toEqual({ entityId: ID }); + expect(parseWorkRef("q:Dune 2024")).toEqual({ q: "Dune 2024" }); + expect(parseWorkRef("nope")).toBeNull(); + const url = new URL(releaseSearchPath({ title: "星际穿越", ref: ref! }, ["a", "b"]), "http://x"); + expect(url.pathname).toBe("/console/search"); + expect(Object.fromEntries(url.searchParams)).toEqual({ q: "星际穿越", work: ref, t: "星际穿越", in: "a,b" }); + }); +}); + +describe("站点选择", () => { + const sites = [ + { id: "a", enabled: true, searchDefault: true }, + { id: "b", enabled: true, searchDefault: false }, + { id: "c", enabled: false, searchDefault: true }, + ]; + it("默认 = 启用且默认参与;全部 = 全部启用;指定 = 选中里仍启用的", () => { + expect(resolvePick({ mode: "default", siteIds: [] }, sites)).toEqual(["a"]); + expect(resolvePick({ mode: "all", siteIds: [] }, sites)).toEqual(["a", "b"]); + expect(resolvePick({ mode: "custom", siteIds: ["b", "c"] }, sites)).toEqual(["b"]); + }); + + it("资源查询:选中的站点进 sites,站点分面只进 siteFilter;按作品搜不带 q", () => { + const f: ReleaseFilterState = { + sites: "b", + resolution: "", + source: "", + minGb: "", + maxGb: "", + free: "", + hr: "", + sort: "", + order: "", + }; + expect(toApiFilters({ tmdb: "movie:1" }, f, "a,b")).toMatchObject({ + tmdb: "movie:1", + sites: "a,b", + siteFilter: "b", + }); + expect(toApiFilters({ tmdb: "movie:1" }, { ...f, sites: "" }, "")).not.toHaveProperty("q"); + }); +}); + +describe("库内状态文案", () => { + it("未收录 / 已建档 / 已收录 / 正在下载;版本目标满足与缺失", () => { + expect(inventoryBadge(inv({})).label).toBe("未收录"); + expect(inventoryBadge(inv({ state: "tracked" })).label).toBe("已建档 · 未入库"); + expect(inventoryBadge(inv({ state: "owned", versionCount: 2 })).label).toBe("已收录 · 2 个版本"); + expect(inventoryBadge(inv({ state: "downloading", downloadingCount: 1 })).label).toBe("正在下载 1 个"); + expect( + slotLine(inv({ state: "owned", slotTotal: 2, slotSatisfied: 1, missing: [{ slotId: ID, name: "2160p" }] })), + ).toBe("版本目标满足 1/2 · 缺「2160p」"); + expect(slotLine(inv({}))).toBeNull(); + }); + + it("Emby:任一实例有 = 已有;全部没有 = 没有;有失败 = 未知(不等于缺失)", () => { + const ms = (...st: ("present" | "not_found" | "unknown")[]): MediaServerInventory => ({ + mediaEntityId: null, + partial: false, + instances: st.map((status, i) => ({ + instanceId: `${ID.slice(0, -1)}${i}`, + name: `E${i}`, + status, + reason: null, + items: [], + observedAt: "2026-09-29T00:00:00.000Z", + })), + }); + expect(mediaServerSummary(ms())).toBeNull(); + expect(mediaServerSummary(ms("present"))?.label).toBe("Emby 已有"); + expect(mediaServerSummary(ms("not_found", "not_found"))?.label).toBe("Emby 没有"); + expect(mediaServerSummary(ms("not_found", "unknown"))?.label).toBe("Emby 未知"); + }); +}); + +describe("订阅入口位置", () => { + const src = (rel: string) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), "utf8"); + it("搜索结果与 ⌘K 面板不能直接订阅", () => { + for (const f of ["./command-palette.tsx", "./search-sections.tsx", "./work-card.tsx"]) { + const s = src(f); + expect(s, f).not.toMatch(/useCreateSubscription|SubscriptionFormDialog|useSubscribeTmdb/); + } + }); + it("作品详情页(本地 / 仅 TMDB)有订阅入口", () => { + for (const f of ["../../routes/console/library.$id.tsx", "../../routes/console/media.tmdb.$type.$id.tsx"]) { + expect(src(f), f).toMatch(/; + if (v.mode !== "default" && v.mode !== "all" && v.mode !== "custom") return null; + return { mode: v.mode, siteIds: Array.isArray(v.siteIds) ? v.siteIds.filter((x) => typeof x === "string") : [] }; + } catch { + return null; + } +} + +function saveSitePick(p: SitePick) { + try { + globalThis.localStorage?.setItem(STORE_KEY, JSON.stringify(p)); + } catch { + // 隐私模式等存不了:只是下次不记得 + } +} + +/** 按模式解析出站点 ID(只含启用站点) */ +export function resolvePick(pick: SitePick, sites: Pick[]): string[] { + const on = sites.filter((s) => s.enabled); + if (pick.mode === "all") return on.map((s) => s.id); + if (pick.mode === "custom") { + const set = new Set(pick.siteIds); + return on.filter((s) => set.has(s.id)).map((s) => s.id); + } + return on.filter((s) => s.searchDefault !== false).map((s) => s.id); +} + +const MODES: { value: SearchSiteMode; label: string }[] = [ + { value: "default", label: "默认站点" }, + { value: "all", label: "全部站点" }, + { value: "custom", label: "指定站点" }, +]; + +export function SitePickerDialog({ + open, + onOpenChange, + title, + onConfirm, +}: { + open: boolean; + onOpenChange: (o: boolean) => void; + /** 作品名,显示在说明里 */ + title: string; + onConfirm: (siteIds: string[]) => void; +}) { + const sitesQ = useSiteOptions(); + const settings = useSettings(); + const sites = useMemo(() => (sitesQ.data?.items ?? []).filter((s) => s.enabled), [sitesQ.data]); + const [pick, setPick] = useState(null); + // 打开时取初始值:本机上次 > 全局设置 > 默认 + useEffect(() => { + if (!open) return; + const cfg = settings.data?.search; + setPick( + loadSitePick() ?? (cfg ? { mode: cfg.defaultSiteMode, siteIds: cfg.siteIds } : { mode: "default", siteIds: [] }), + ); + }, [open, settings.data]); + + const cur = pick ?? { mode: "default" as const, siteIds: [] }; + const ids = resolvePick(cur, sites); + const count = (m: SearchSiteMode) => resolvePick({ mode: m, siteIds: cur.siteIds }, sites).length; + + const confirm = () => { + saveSitePick(cur); + onConfirm(ids); + onOpenChange(false); + }; + + return ( + + + + 搜索资源 + 在所选站点搜「{title}」。每个站点按作品原名和译名各查一次。 + +
+ {MODES.map((m) => ( + + ))} +
+ {sitesQ.isPending ? ( +

加载站点…

+ ) : sites.length === 0 ? ( +

+ 还没有启用的站点。 + + 去站点页 + +

+ ) : cur.mode === "custom" ? ( + ({ value: s.id, label: s.name }))} + value={cur.siteIds.filter((id) => sites.some((s) => s.id === id))} + onChange={(v) => setPick({ mode: "custom", siteIds: v })} + /> + ) : ( +

+ {ids.length + ? sites + .filter((s) => ids.includes(s.id)) + .map((s) => s.name) + .join("、") + : "没有站点勾选「默认参与搜索」。在站点页打开,或改选全部站点。"} +

+ )} + + + + +
+
+ ); +} diff --git a/apps/web/app/components/search/work-card.tsx b/apps/web/app/components/search/work-card.tsx new file mode 100644 index 00000000..76014d59 --- /dev/null +++ b/apps/web/app/components/search/work-card.tsx @@ -0,0 +1,140 @@ +import type { MediaServerInventory, SearchWork, WorkInventory } from "@umt/contracts"; +import { ArrowRight, PackageSearch } from "lucide-react"; +import { Link } from "react-router"; +import { type MediaServerRef, useMediaServerInventory } from "~/api/search.ts"; +import { consolePath } from "~/components/console/nav.ts"; +import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; +import { CoverPlaceholder } from "~/components/library/parts.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { mediaKindLabel } from "./search-labels.ts"; + +// 作品卡(SEARCH-2 第一阶段):TMDB 海报 / 年份 / 类型 / 简介摘要 + 库内状态;「详情」进作品详情页,「搜索资源」先选站点。 + +/** 作品详情页:本地作品走媒体库详情,仅 TMDB 的走 TMDB 详情 */ +export function workHref(w: Pick): string { + if (w.localEntityId) return consolePath("library", w.localEntityId); + if (w.tmdb) return `/console/media/tmdb/${w.tmdb.entityType}/${w.tmdb.externalId}`; + return consolePath("library"); +} + +/** 搜资源用的作品引用(URL 参数 work):local: / tmdb:: */ +export function workRef(w: Pick): string | null { + if (w.localEntityId) return `local:${w.localEntityId}`; + if (w.tmdb) return `tmdb:${w.tmdb.entityType}:${w.tmdb.externalId}`; + return null; +} + +/** 第二阶段地址:搜索页带作品引用 + 选中的站点 */ +export function releaseSearchPath(w: { title: string; ref: string }, siteIds: string[]): string { + const p = new URLSearchParams({ q: w.title, work: w.ref, t: w.title, in: siteIds.join(",") }); + return `/console/search?${p.toString()}`; +} + +export function inventoryBadge(inv: WorkInventory): { label: string; tone: StatusTone } { + switch (inv.state) { + case "not_collected": + return { label: "未收录", tone: "neutral" }; + case "downloading": + return { label: `正在下载 ${inv.downloadingCount} 个`, tone: "warning" }; + case "owned": + return { label: `已收录 · ${inv.versionCount} 个版本`, tone: "success" }; + default: + return { label: "已建档 · 未入库", tone: "neutral" }; + } +} + +/** 版本目标满足情况一行:满足 a/b · 缺「x」「y」 */ +export function slotLine(inv: WorkInventory): string | null { + if (inv.state === "not_collected" || inv.slotTotal === 0) return null; + const parts = [`版本目标满足 ${inv.slotSatisfied}/${inv.slotTotal}`]; + if (inv.missing.length) parts.push(`缺${inv.missing.map((m) => `「${m.name}」`).join("")}`); + return parts.join(" · "); +} + +export function InventoryBadges({ inv }: { inv: WorkInventory }) { + const b = inventoryBadge(inv); + return ( + <> + {b.label} + {inv.subscribed ? 已订阅 : null} + + ); +} + +/** Emby 对比摘要:任一实例有 = 已有;全部没有 = 没有;有实例失败 / 超时 = 未知(不等于缺失) */ +export function mediaServerSummary(ms: MediaServerInventory): { label: string; tone: StatusTone } | null { + if (ms.instances.length === 0) return null; + const present = ms.instances.filter((i) => i.status === "present"); + if (present.length) + return { + label: `Emby 已有${ms.instances.length > 1 ? ` ${present.length}/${ms.instances.length}` : ""}`, + tone: "success", + }; + if (ms.instances.every((i) => i.status === "not_found")) return { label: "Emby 没有", tone: "neutral" }; + return { label: "Emby 未知", tone: "warning" }; +} + +export function EmbyBadge({ refOf, enabled = true }: { refOf: MediaServerRef | null; enabled?: boolean }) { + const q = useMediaServerInventory(refOf, enabled); + if (!q.data) return null; + const s = mediaServerSummary(q.data); + return s ? {s.label} : null; +} + +export function msRefOf(w: Pick): MediaServerRef | null { + if (w.localEntityId) return { mediaEntityId: w.localEntityId }; + if (w.tmdb) return { tmdbType: w.tmdb.entityType, tmdbId: w.tmdb.externalId }; + return null; +} + +export function WorkCard({ + w, + emby, + onSearchReleases, +}: { + w: SearchWork; + /** 有媒体服务器实例时才查 Emby */ + emby: boolean; + onSearchReleases: (w: SearchWork) => void; +}) { + const line = slotLine(w.inventory); + const href = workHref(w); + return ( +
+ + + +
+ +

+ {w.title} + {w.year ? ({w.year}) : null} +

+ +

+ {mediaKindLabel(w.kind)} + {w.originalTitle && w.originalTitle !== w.title ? ` · ${w.originalTitle}` : ""} + {w.source === "tmdb" ? " · TMDB" : ""} +

+
+ + {emby ? : null} +
+ {line ?

{line}

: null} + {w.overview ?

{w.overview}

: null} +
+ + +
+
+
+ ); +} diff --git a/apps/web/app/components/sites/search-default-dialog.tsx b/apps/web/app/components/sites/search-default-dialog.tsx new file mode 100644 index 00000000..4bb4a238 --- /dev/null +++ b/apps/web/app/components/sites/search-default-dialog.tsx @@ -0,0 +1,110 @@ +import type { SearchSiteMode, SiteListItem } from "@umt/contracts"; +import { LoaderCircle } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { useSettings, useUpdateSettings } from "~/api/settings.ts"; +import { ChipToggleGroup } from "~/components/library/parts.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog.tsx"; +import { errorMessage } from "~/lib/api.ts"; +import { cn } from "~/lib/utils.ts"; + +// 站点页「搜索站点」:settings.search.defaultSiteMode —— 搜资源时站点选择的初始值,也是 API 不带 sites 时的站点范围。 + +const MODES: { value: SearchSiteMode; label: string; hint: string }[] = [ + { value: "default", label: "默认站点", hint: "站点卡上打开了「默认参与搜索」的站点" }, + { value: "all", label: "全部站点", hint: "全部已启用站点" }, + { value: "custom", label: "指定站点", hint: "只查下面选中的站点" }, +]; + +export function SearchDefaultDialog({ + open, + onOpenChange, + sites, +}: { + open: boolean; + onOpenChange: (o: boolean) => void; + sites: SiteListItem[]; +}) { + const settings = useSettings(); + const update = useUpdateSettings(); + const [mode, setMode] = useState("default"); + const [ids, setIds] = useState([]); + useEffect(() => { + if (!open || !settings.data) return; + setMode(settings.data.search?.defaultSiteMode ?? "default"); + setIds(settings.data.search?.siteIds ?? []); + }, [open, settings.data]); + const enabled = sites.filter((s) => s.enabled); + + const save = () => + update.mutate( + { search: { defaultSiteMode: mode, siteIds: mode === "custom" ? ids : [] } }, + { + onSuccess: () => { + toast.success("搜索站点已保存"); + onOpenChange(false); + }, + onError: (e) => toast.error(errorMessage(e, "保存失败")), + }, + ); + + return ( + + + + 搜索站点 + 搜资源时默认查哪些站点。每次搜索前仍可改选,本机会记住上次的选择。 + +
+ {MODES.map((m) => ( + + ))} +
+ {mode === "custom" ? ( + ({ value: s.id, label: s.name }))} + value={ids.filter((id) => enabled.some((s) => s.id === id))} + onChange={setIds} + /> + ) : null} + + + + +
+
+ ); +} diff --git a/apps/web/app/components/sites/site-card.tsx b/apps/web/app/components/sites/site-card.tsx index 2596f7e4..5e04be17 100644 --- a/apps/web/app/components/sites/site-card.tsx +++ b/apps/web/app/components/sites/site-card.tsx @@ -14,7 +14,7 @@ import { import { type ReactNode, useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; -import { snapshotStale, useSnapshotSite, useToggleSite } from "~/api/sites.ts"; +import { snapshotStale, useSnapshotSite, useToggleSite, useUpdateSite } from "~/api/sites.ts"; import { DataTable } from "~/components/console/data-table.tsx"; import { consolePath } from "~/components/console/nav.ts"; import { StatusBadge } from "~/components/console/status-badge.tsx"; @@ -76,6 +76,31 @@ function EnableSwitch({ site, withLabel }: { site: SiteListItem; withLabel?: boo ); } +/** 默认参与搜索:搜资源时「默认站点」集合 */ +function SearchDefaultSwitch({ site }: { site: SiteListItem }) { + const update = useUpdateSite(); + const on = site.searchDefault !== false; + return ( + + + update.mutate( + { id: site.id, body: { searchDefault: v } }, + { + onSuccess: () => toast.success(v ? `${site.name} 默认参与搜索` : `${site.name} 不再默认参与搜索`), + onError: (e) => toast.error(errorMessage(e)), + }, + ) + } + /> + + + ); +} + /** "更多"菜单:更新凭证、编辑、快照、启停、删除 */ export function SiteMoreMenu({ site, onDialog }: { site: SiteListItem; onDialog: (d: SiteDialog) => void }) { const [open, setOpen] = useState(false); @@ -305,6 +330,7 @@ export function SiteCard({ site, checks, onDialog }: Props) {