docs(plan): on-demand site detail page parsing (HHClub details.php)

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-29 15:13:35 +08:00
1 parent 94fd48bff2
commit ac478673bc
39 files changed
+2446 -339

No files matched your search

@@ -476,25 +476,43 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
response: MediaServerInventory, response: MediaServerInventory,
}, },
async (_c, { query }) => { async (_c, { query }) => {
const e = await db() // 按 TMDB 条目查时:本地已建档(接受了该身份,合并跟到最终作品)就按作品查,否则只用这一个 TMDB ID
.selectFrom("media_entity") let entityId = query.mediaEntityId ?? null;
.select(["id", "kind", "title", "year"]) if (!entityId && query.tmdbType && query.tmdbId) {
.where("id", "=", query.mediaEntityId) const hit = await db()
.executeTakeFirst(); .selectFrom("external_identity as i")
if (!e) notFound("作品"); .innerJoin("media_entity as m", "m.id", "i.media_entity_id")
const idents = await db() .select(["m.id", "m.status", "m.merged_into_id"])
.selectFrom("external_identity") .where("i.provider", "=", "tmdb")
.select(["provider", "entity_type", "external_id"]) .where("i.entity_type", "=", query.tmdbType)
.where("media_entity_id", "=", e!.id) .where("i.external_id", "=", query.tmdbId)
.where("status", "=", "accepted") .where("i.status", "=", "accepted")
.execute(); .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() const insts = await db()
.selectFrom("media_server_instance") .selectFrom("media_server_instance")
.selectAll() .selectAll()
.where("enabled", "=", true) .where("enabled", "=", true)
.orderBy("name") .orderBy("name")
.execute(); .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]); const usable = idents.filter((i) => MS_PROVIDER_KEY[i.provider]);
// 按实例并行(allSettled):单个 Emby 慢/挂只让自己 unknown,不拖住其它实例;每实例内部限并发 + 总超时 // 按实例并行(allSettled):单个 Emby 慢/挂只让自己 unknown,不拖住其它实例;每实例内部限并发 + 总超时
const settled = await Promise.allSettled( const settled = await Promise.allSettled(
@@ -514,7 +532,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
); );
// partial:至少一个实例因查询失败/超时为 unknown(no_external_id 不算失败) // partial:至少一个实例因查询失败/超时为 unknown(no_external_id 不算失败)
const partial = instances.some((x) => x.status === "unknown" && x.reason !== "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 }, { tag: TAG },
); );
+1 -1
View File
@@ -199,7 +199,7 @@ export class TmdbSearch {
} }
/** 已接受的 TMDB 身份 → 本地作品(合并过的跟到最终作品) */ /** 已接受的 TMDB 身份 → 本地作品(合并过的跟到最终作品) */
async function localEntities( export async function localEntities(
ctx: ServerContext, ctx: ServerContext,
refs: { entityType: string; externalId: string }[], refs: { entityType: string; externalId: string }[],
): Promise<Map<string, string>> { ): Promise<Map<string, string>> {
+146 -53
View File
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import type { SiteRelease as AdapterRelease } from "@umt/adapters"; import type { SiteRelease as AdapterRelease } from "@umt/adapters";
import { SiteError } from "@umt/adapters"; import { SiteError } from "@umt/adapters";
import type { import type {
@@ -15,8 +16,9 @@ import type { SourcesRuntime } from "../sources-identity/runtime.ts";
import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts"; import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
import { raceAbort } from "./media.ts"; import { raceAbort } from "./media.ts";
// 跨站资源搜索:已启用站点逐站调适配器 search(每站共享限流器 + 单站超时 + 站点级并发上限); // 跨站资源搜索:选中的已启用站点逐站调适配器 search(每站共享限流器 + 单站超时 + 站点级并发上限);
// 同一查询词并发合并、结果短缓存;结果 upsert 为资源(source_release,新资源照常发 release.observed 进识别流水线)。 // 按(站点, 查询词)并发合并、短缓存 —— 换站点选择时已查过的站点直接复用;结果 upsert 为资源
// (source_release,新资源照常发 release.observed 进识别流水线)。
// 作品关联与库存叠加在每次请求时现算(不进缓存),识别完成后刷新即可看到。 // 作品关联与库存叠加在每次请求时现算(不进缓存),识别完成后刷新即可看到。
/** 缓存里的资源条目(不含作品关联与库存叠加) */ /** 缓存里的资源条目(不含作品关联与库存叠加) */
@@ -37,46 +39,96 @@ export interface FanoutOptions {
export interface FanoutHit { export interface FanoutHit {
fanout: Fanout; fanout: Fanout;
/** 全部(站点, 查询词)都命中缓存 */
cached: boolean; cached: boolean;
/** 至少一个(站点, 查询词)与同时进行的相同搜索合并 */
coalesced: boolean; coalesced: boolean;
} }
interface SiteResult {
items: BaseRelease[];
coverage: SearchSiteCoverage;
fetchedAt: number;
}
const cacheKey = (q: string) => q.trim().toLowerCase().replace(/\s+/g, " "); 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 { export class ReleaseFanout {
private readonly cache = new Map<string, { expires: number; fanout: Fanout }>(); private readonly cache = new Map<string, { expires: number; result: SiteResult }>();
private readonly inflight = new Map<string, Promise<Fanout>>(); private readonly inflight = new Map<string, Promise<SiteResult>>();
constructor( constructor(
private readonly rt: () => SourcesRuntime, private readonly rt: () => SourcesRuntime,
private readonly opts: FanoutOptions, private readonly opts: FanoutOptions,
) {} ) {}
async search(q: string, opts: { refresh?: boolean } = {}): Promise<FanoutHit> { /** 参与搜索的候选站点:启用且未删除(按名称排序) */
const key = cacheKey(q); async enabledSites(): Promise<SiteRow[]> {
const hit = this.cache.get(key); return this.rt()
if (!opts.refresh && hit && hit.expires > this.opts.now()) .ctx.db.selectFrom("site")
return { fanout: hit.fanout, cached: true, coalesced: false }; .selectAll()
const running = this.inflight.get(key); .where("enabled", "=", true)
if (running) return { fanout: await running, cached: false, coalesced: true }; .where("deleted_at", "is", null)
const p = this.run(q.trim()) .orderBy("name")
.then((fanout) => { .execute();
// 全部站点失败不缓存(下次直接重试) }
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 }); * 在给定站点上搜一组查询词:每站每词一次请求(同站多个词的结果按站点资源 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; cached = false;
}) const running = this.inflight.get(key);
.finally(() => this.inflight.delete(key)); if (running) {
this.inflight.set(key, p); coalesced = true;
return { fanout: await p, cached: false, coalesced: false }; 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 { findCached(siteId: string, siteReleaseId: string): { release: BaseRelease; raw: AdapterRelease } | null {
for (const { fanout } of this.cache.values()) { for (const { result } of this.cache.values()) {
const r = fanout.items.find((i) => i.siteId === siteId && i.siteReleaseId === siteReleaseId); const r = result.items.find((i) => i.siteId === siteId && i.siteReleaseId === siteReleaseId);
const raw = r ? this.raws.get(r.key) : undefined; const raw = r ? this.raws.get(r.key) : undefined;
if (r && raw) return { release: r, raw }; if (r && raw) return { release: r, raw };
} }
@@ -87,32 +139,11 @@ export class ReleaseFanout {
private prune() { private prune() {
const now = this.opts.now(); const now = this.opts.now();
for (const [k, v] of this.cache) if (v.expires <= now) this.cache.delete(k); 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(); if (this.raws.size > 20_000) this.raws.clear();
} }
private async run(q: string): Promise<Fanout> { private async searchSite(rt: SourcesRuntime, site: SiteRow, q: string): Promise<SiteResult> {
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 }> {
const t0 = performance.now(); const t0 = performance.now();
const cov = (status: SearchSiteCoverage["status"], count: number, error: string | null): SearchSiteCoverage => ({ const cov = (status: SearchSiteCoverage["status"], count: number, error: string | null): SearchSiteCoverage => ({
siteId: site.id, siteId: site.id,
@@ -122,22 +153,27 @@ export class ReleaseFanout {
tookMs: Math.round(performance.now() - t0), tookMs: Math.round(performance.now() - t0),
error, error,
}); });
const done = (items: BaseRelease[], coverage: SearchSiteCoverage): SiteResult => ({
items,
coverage,
fetchedAt: this.opts.now(),
});
let adapter: ReturnType<SourcesRuntime["siteAdapter"]>; let adapter: ReturnType<SourcesRuntime["siteAdapter"]>;
try { try {
adapter = rt.siteAdapter(site); adapter = rt.siteAdapter(site);
} catch (e) { } 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 signal = AbortSignal.timeout(this.opts.timeoutMs);
const timeoutMsg = `${Math.round(this.opts.timeoutMs / 1000)} 秒内没有响应`; const timeoutMsg = `${Math.round(this.opts.timeoutMs / 1000)} 秒内没有响应`;
let found: AdapterRelease[]; let found: AdapterRelease[];
try { try {
found = (await raceAbort(adapter.search(q, { page: 1 }, { signal }), signal, timeoutMsg)).items; found = (await raceAbort(adapter.search(q, { page: 1 }, { signal }), signal, timeoutMsg)).items;
} catch (e) { } 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 || "搜索失败"; 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 去重(翻页边界可能重复) // 同站同 ID 去重(翻页边界可能重复)
const seen = new Set<string>(); const seen = new Set<string>();
@@ -152,13 +188,70 @@ export class ReleaseFanout {
ids.clear(); ids.clear();
rt.ctx.logger.warn({ err: { message: (e as Error).message }, siteId: site.id }, "搜索结果写入资源表失败"); 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 items = found.map((r) => {
const base = toBase(site, r, ids.get(r.siteReleaseId) ?? null); const base = toBase(site, r, ids.get(r.siteReleaseId) ?? null);
this.raws.set(base.key, r); this.raws.set(base.key, r);
return base; 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 { function toBase(site: Pick<SiteRow, "id" | "name">, r: AdapterRelease, sourceReleaseId: string | null): BaseRelease {
@@ -226,7 +319,7 @@ const list = (s: string | undefined) =>
.filter(Boolean); .filter(Boolean);
export function filterReleases(items: BaseRelease[], q: ReleaseSearchQuery): BaseRelease[] { 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 res = new Set(list(q.resolution).map((x) => x.toLowerCase()));
const src = new Set(list(q.source).map((x) => x.toLowerCase())); const src = new Set(list(q.source).map((x) => x.toLowerCase()));
return items.filter((r) => { return items.filter((r) => {
+171 -27
View File
@@ -1,19 +1,26 @@
import { createRoute, type RouteConfig } from "@hono/zod-openapi"; import { createRoute, type RouteConfig } from "@hono/zod-openapi";
import { TmdbError } from "@umt/adapters";
import { import {
Api, Api,
type ApiScope, type ApiScope,
apiResponse, apiResponse,
DEFAULT_SETTINGS,
type EndpointDef, type EndpointDef,
type InventoryEntry, type InventoryEntry,
type InventoryResponse, type InventoryResponse,
type ManualAcquisitionResult, type ManualAcquisitionResult,
type MediaServerInventory,
type ReleaseSearchPage, type ReleaseSearchPage,
type ReleaseSearchQuery, type ReleaseSearchQuery,
type SearchQuery, type SearchQuery,
type SearchReleaseParams, type SearchReleaseParams,
type SearchReleasePlanRequest, type SearchReleasePlanRequest,
type SearchResponse, type SearchResponse,
type TmdbMediaDetail,
type TmdbMediaParams,
type TmdbMediaQuery,
} from "@umt/contracts"; } from "@umt/contracts";
import type { SiteRow } from "@umt/db";
import type { Context } from "hono"; import type { Context } from "hono";
import type { z } from "zod"; import type { z } from "zod";
import { type AuthEnv, requireScope } from "../../http/auth.ts"; 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 type { ModuleApp, ServerContext } from "../types.ts";
import { matchActions } from "./actions.ts"; import { matchActions } from "./actions.ts";
import { InternalDispatcher } from "./dispatch.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 { decorate, facets, filterReleases, ReleaseFanout, sortReleases } from "./releases.ts";
import {
buildWorks,
detailSeasons,
localInfo,
localSeasonHave,
searchTerms,
slotCountsFor,
workInventory,
} from "./works.ts";
export interface SearchDeps { export interface SearchDeps {
runtime: () => SourcesRuntime; runtime: () => SourcesRuntime;
@@ -102,10 +118,71 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
return out; 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 }) => { route(app, Api.search.all, async ({ query, c }) => {
const t0 = performance.now(); const t0 = performance.now();
const q = query as SearchQuery; 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 loadInv = inventory(c);
const media = want("media") const media = want("media")
@@ -113,37 +190,54 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
(async () => { (async () => {
const t = performance.now(); const t = performance.now();
const hits = await searchLocalMedia(ctx, q.q, q.limit); const hits = await searchLocalMedia(ctx, q.q, q.limit);
const inv = hits.length ? await loadInv(hits.map((h) => h.entity.id)) : new Map(); return { hits, tookMs: since(t) };
return { items: withInventory(hits, inv), tookMs: since(t) };
})(), })(),
(async () => { (async () => {
const t = performance.now(); const t = performance.now();
const r = await tmdb.search(q.q, q.limit); const r = await tmdb.search(q.q, q.limit);
return { ...r, tookMs: since(t) }; 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); : Promise.resolve(null);
const releases = want("releases") // SEARCH-2:资源只在 scope=releases 时查(all 不再自动查站点),站点按 settings.search 的默认集合
? (async () => { const releases =
const t = performance.now(); q.scope === "releases"
const hit = await fanout.search(q.q); ? (async () => {
const sorted = sortReleases(hit.fanout.items, "default", "desc"); const t = performance.now();
const items = await decorate(d.runtime(), sorted.slice(0, q.limit), loadInv); const hit = await fanout.search([q.q], await resolveSites(undefined));
const failed = hit.fanout.sites.filter((s) => s.status === "failed" || s.status === "timeout"); const sorted = sortReleases(hit.fanout.items, "default", "desc");
return { const items = await decorate(d.runtime(), sorted.slice(0, q.limit), loadInv);
items, const failed = hit.fanout.sites.filter((s) => s.status === "failed" || s.status === "timeout");
total: sorted.length, return {
partial: failed.length > 0, items,
sites: hit.fanout.sites, total: sorted.length,
failedSites: failed, partial: failed.length > 0,
cached: hit.cached, sites: hit.fanout.sites,
coalesced: hit.coalesced, failedSites: failed,
fetchedAt: hit.fanout.fetchedAt, cached: hit.cached,
tookMs: since(t), coalesced: hit.coalesced,
}; fetchedAt: hit.fanout.fetchedAt,
})() tookMs: since(t),
: Promise.resolve(null); };
})()
: Promise.resolve(null);
const actions = want("actions") 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 }) => { route(app, Api.search.releases, async ({ query, c }) => {
const t0 = performance.now(); const t0 = performance.now();
const q = query as ReleaseSearchQuery; 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 all = hit.fanout.items;
const filtered = sortReleases(filterReleases(all, q), q.sort, q.order); const filtered = sortReleases(filterReleases(all, q), q.sort, q.order);
const offset = decodeCursor(q.cursor); 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 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 failed = hit.fanout.sites.filter((s) => s.status === "failed" || s.status === "timeout");
const out: ReleaseSearchPage = { const out: ReleaseSearchPage = {
q: q.q, q: q.q ?? terms[0]!,
queries: terms,
items: await decorate(d.runtime(), pageItems, inventory(c)), items: await decorate(d.runtime(), pageItems, inventory(c)),
nextCursor: next, nextCursor: next,
total: filtered.length, total: filtered.length,
@@ -214,6 +310,54 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
sourceReleaseId: row.id, 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"); const encodeCursor = (offset: number) => Buffer.from(String(offset), "utf8").toString("base64url");
+270
View File
@@ -0,0 +1,270 @@
import type { MetadataCandidate } from "@umt/adapters";
import type {
InventoryEntry,
LocalMediaHit,
MediaKind,
SearchWork,
SlotStatusCounts,
TmdbCandidate,
TmdbMediaSeason,
WorkInventory,
} from "@umt/contracts";
import { sql } from "@umt/db";
import { normalizeTitle } from "@umt/domain";
import type { ServerContext } from "../types.ts";
// SEARCH-2 第一阶段:作品(本地 + TMDB 合并去重,本地优先)+ 本地库存对比;第二阶段搜资源的查询词生成。
const clip = (s: string | null | undefined, n = 300) => (s ? (s.length > n ? `${s.slice(0, n)}…` : s) : null);
export const emptySlotCounts = (): SlotStatusCounts => ({
empty: 0,
reserved: 0,
downloading: 0,
verifying: 0,
satisfied: 0,
upgrade_pending: 0,
attention: 0,
});
export interface LocalInfo {
id: string;
kind: MediaKind;
title: string;
originalTitle: string | null;
year: number | null;
status: string;
overview: string | null;
posterPath: string | null;
tmdb: { entityType: "movie" | "tv"; externalId: string } | null;
}
const metaValue = (metadata: unknown, field: string): string | null => {
const v = (metadata as Record<string, { value?: unknown }> | null)?.[field]?.value;
return typeof v === "string" && v ? v : null;
};
/** 本地作品展示信息:海报 / 简介取元数据最终值,TMDB 身份取已接受的那条 */
export async function localInfo(ctx: ServerContext, ids: string[]): Promise<Map<string, LocalInfo>> {
const out = new Map<string, LocalInfo>();
if (ids.length === 0) return out;
const [rows, idents] = await Promise.all([
ctx.db
.selectFrom("media_entity")
.select(["id", "kind", "title", "original_title", "year", "status", "metadata"])
.where("id", "in", ids)
.execute(),
ctx.db
.selectFrom("external_identity")
.select(["media_entity_id", "entity_type", "external_id"])
.where("media_entity_id", "in", ids)
.where("provider", "=", "tmdb")
.where("status", "=", "accepted")
.execute(),
]);
for (const r of rows) {
const t = idents.find((i) => i.media_entity_id === r.id);
out.set(r.id, {
id: r.id,
kind: r.kind as MediaKind,
title: r.title,
originalTitle: r.original_title,
year: r.year,
status: r.status,
overview: clip(metaValue(r.metadata, "overview")),
posterPath: metaValue(r.metadata, "posterPath"),
tmdb:
t && (t.entity_type === "movie" || t.entity_type === "tv")
? { entityType: t.entity_type, externalId: t.external_id }
: null,
});
}
return out;
}
/** 各作品版本目标按状态计数 */
export async function slotCountsFor(ctx: ServerContext, ids: string[]): Promise<Map<string, SlotStatusCounts>> {
const out = new Map<string, SlotStatusCounts>();
if (ids.length === 0) return out;
const rows = await ctx.db
.selectFrom("acquisition_slot")
.select(["media_entity_id", "status"])
.select(sql<number>`count(*)::int`.as("n"))
.where("media_entity_id", "in", ids)
.groupBy(["media_entity_id", "status"])
.execute();
for (const id of ids) out.set(id, emptySlotCounts());
for (const r of rows) {
const c = out.get(r.media_entity_id);
if (c) c[r.status as keyof SlotStatusCounts] = r.n;
}
return out;
}
/** 库存对比:没有本地作品 = 未收录;有下载中 > 有版本 > 仅建档 */
export function workInventory(
inv: InventoryEntry | undefined | null,
counts: SlotStatusCounts | undefined,
): WorkInventory {
if (!inv) {
return {
state: "not_collected",
subscribed: false,
versionCount: 0,
downloadingCount: 0,
slotTotal: 0,
slotSatisfied: 0,
missing: [],
};
}
const c = counts ?? emptySlotCounts();
const total = Object.values(c).reduce((a, b) => a + b, 0);
return {
state: inv.downloading.length ? "downloading" : inv.versions.length ? "owned" : "tracked",
subscribed: inv.subscribed,
versionCount: inv.versions.length,
downloadingCount: inv.downloading.length,
slotTotal: total,
slotSatisfied: c.satisfied,
missing: inv.gaps.map((g) => ({ slotId: g.slotId, name: g.name })),
};
}
type LocalHitBase = Omit<LocalMediaHit, "versionCount" | "downloadingCount" | "subscribed">;
/**
* 合并:本地命中在前(按得分),再补 TMDB 候选里已建档但没被本地召回的作品,最后是仅 TMDB 的;
* 同一 TMDB 身份 / 同一本地作品只出现一次。本地作品缺海报 / 简介时用对应 TMDB 候选的。
*/
export function buildWorks(
local: LocalHitBase[],
tmdb: TmdbCandidate[],
info: Map<string, LocalInfo>,
inv: Map<string, InventoryEntry>,
counts: Map<string, SlotStatusCounts>,
): SearchWork[] {
const out: SearchWork[] = [];
const seenLocal = new Set<string>();
const seenTmdb = new Set<string>();
const tmdbByLocal = new Map<string, TmdbCandidate>();
for (const c of tmdb) if (c.localEntityId && !tmdbByLocal.has(c.localEntityId)) tmdbByLocal.set(c.localEntityId, c);
const pushLocal = (id: string) => {
if (seenLocal.has(id)) return;
const e = info.get(id);
if (!e) return;
seenLocal.add(id);
const cand = tmdbByLocal.get(id);
const tm = e.tmdb ?? (cand ? { entityType: cand.entityType, externalId: cand.externalId } : null);
if (tm) seenTmdb.add(`${tm.entityType}:${tm.externalId}`);
out.push({
key: `local:${id}`,
source: "local",
kind: e.kind,
title: e.title,
originalTitle: e.originalTitle,
year: e.year,
overview: e.overview ?? cand?.overview ?? null,
posterPath: e.posterPath ?? cand?.posterPath ?? null,
localEntityId: id,
tmdb: tm,
inventory: workInventory(inv.get(id) ?? emptyInventory(id), counts.get(id)),
});
};
for (const h of local) pushLocal(h.entity.id);
for (const c of tmdb) if (c.localEntityId) pushLocal(c.localEntityId);
for (const c of tmdb) {
const k = `${c.entityType}:${c.externalId}`;
if (c.localEntityId || seenTmdb.has(k)) continue;
seenTmdb.add(k);
out.push({
key: `tmdb:${k}`,
source: "tmdb",
kind: c.entityType === "movie" ? "movie" : "series",
title: c.title,
originalTitle: c.originalTitle,
year: c.year,
overview: c.overview,
posterPath: c.posterPath ?? null,
localEntityId: null,
tmdb: { entityType: c.entityType, externalId: c.externalId },
inventory: workInventory(null, undefined),
});
}
return out;
}
const emptyInventory = (entityId: string): InventoryEntry => ({
entityId,
versions: [],
gaps: [],
downloading: [],
subscribed: false,
candidateReasons: [],
});
/**
* 搜资源的查询词(每个站点每个词查一次,最多 2 个,控制对站点的请求数):
* 原名(电影带年份,站点标题多为「原名 年份 …」)+ 译名(与原名归一化后不同才加)。
* 剧集不带年份(各季年份不同)。
*/
export function searchTerms(w: {
title: string;
originalTitle: string | null;
year: number | null;
movie: boolean;
}): string[] {
const out: string[] = [];
const seen = new Set<string>();
const add = (t: string | null, withYear: boolean) => {
const s = t?.trim();
if (!s) return;
const n = normalizeTitle(s);
if (!n || seen.has(n)) return;
seen.add(n);
out.push(withYear && w.year ? `${s} ${w.year}` : s);
};
const latin = (s: string | null) => !!s && /^[\p{Script=Latin}\d\s\p{P}\p{S}]+$/u.test(s);
add(w.originalTitle, w.movie && latin(w.originalTitle));
add(w.title, w.movie && latin(w.title) && out.length === 0);
return out.slice(0, 2);
}
/** 剧集各季本地已入库集数:集单元有可用资产覆盖的计数;季单元被整季资产覆盖时按全季计 */
export async function localSeasonHave(
ctx: ServerContext,
entityId: string,
seasons: { seasonNumber: number; episodeCount: number }[],
): Promise<Map<number, number>> {
const rows = await sql<{ kind: string; season_number: number | null; n: number }>`
select cu.kind, cu.season_number, count(distinct cu.id)::int as n
from content_unit cu
join asset_unit_coverage c on c.content_unit_id = cu.id
join media_asset a on a.id = c.media_asset_id and a.status = 'active'
where cu.media_entity_id = ${entityId} and cu.kind in ('episode', 'season') and cu.season_number is not null
group by cu.kind, cu.season_number`.execute(ctx.db);
const out = new Map<number, number>();
for (const s of seasons) out.set(s.seasonNumber, 0);
for (const r of rows.rows) {
if (r.season_number === null) continue;
if (r.kind === "episode") out.set(r.season_number, Math.max(out.get(r.season_number) ?? 0, r.n));
}
for (const r of rows.rows) {
if (r.kind !== "season" || r.season_number === null) continue;
const s = seasons.find((x) => x.seasonNumber === r.season_number);
if (s) out.set(r.season_number, Math.max(out.get(r.season_number) ?? 0, s.episodeCount));
}
return out;
}
export function detailSeasons(d: MetadataCandidate, have: Map<number, number> | null): TmdbMediaSeason[] {
return (d.seasons ?? [])
.filter((s) => s.seasonNumber >= 0)
.sort((a, b) => a.seasonNumber - b.seasonNumber)
.map((s) => ({
seasonNumber: s.seasonNumber,
episodeCount: Math.max(0, s.episodeCount),
name: s.name,
airDate: s.airDate,
localHave: have ? Math.min(have.get(s.seasonNumber) ?? 0, Math.max(0, s.episodeCount) || Infinity) : null,
}));
}
@@ -155,6 +155,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
snapshot_enabled: body.snapshotEnabled ?? false, snapshot_enabled: body.snapshotEnabled ?? false,
snapshot_interval_sec: body.snapshotIntervalSec ?? null, snapshot_interval_sec: body.snapshotIntervalSec ?? null,
proxy: body.proxy ?? false, proxy: body.proxy ?? false,
search_default: body.searchDefault ?? true,
}) })
.returningAll() .returningAll()
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
@@ -176,6 +177,7 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
if (body.snapshotEnabled !== undefined) patch.snapshot_enabled = body.snapshotEnabled; if (body.snapshotEnabled !== undefined) patch.snapshot_enabled = body.snapshotEnabled;
if (body.snapshotIntervalSec !== undefined) patch.snapshot_interval_sec = body.snapshotIntervalSec; if (body.snapshotIntervalSec !== undefined) patch.snapshot_interval_sec = body.snapshotIntervalSec;
if (body.proxy !== undefined) patch.proxy = body.proxy; if (body.proxy !== undefined) patch.proxy = body.proxy;
if (body.searchDefault !== undefined) patch.search_default = body.searchDefault;
if (body.enabled !== undefined) { if (body.enabled !== undefined) {
if (Object.keys(patch).length) { if (Object.keys(patch).length) {
await db().updateTable("site").set(patch).where("id", "=", params.id).execute(); await db().updateTable("site").set(patch).where("id", "=", params.id).execute();
+5 -3
View File
@@ -405,7 +405,7 @@ describe.skipIf(!up)("全局搜索", () => {
expect(f("false")).toEqual(["b", "c"]); expect(f("false")).toEqual(["b", "c"]);
}); });
it("页面与操作跳转;scope=all 四组都在", async () => { it("页面与操作跳转;scope=all 只查作品与操作(不查站点)", async () => {
const r = await req("/search?q=设置&scope=actions"); const r = await req("/search?q=设置&scope=actions");
expect(r.body.data.actions.items[0]).toMatchObject({ expect(r.body.data.actions.items[0]).toMatchObject({
id: "page.settings", id: "page.settings",
@@ -414,10 +414,12 @@ describe.skipIf(!up)("全局搜索", () => {
}); });
const sub = await req("/search?q=订阅&scope=actions"); const sub = await req("/search?q=订阅&scope=actions");
expect(sub.body.data.actions.items.map((a: Loose) => a.id)).toContain("action.new-subscription"); 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.media).not.toBeNull();
expect(all.body.data.releases).not.toBeNull(); expect(all.body.data.releases).toBeNull();
expect(all.body.data.actions).not.toBeNull(); expect(all.body.data.actions).not.toBeNull();
expect([nexusSearches(), mteamSearches(), slowCalls]).toEqual(before);
}); });
it("从搜索结果下载:转调 /acquisition-plans(同一鉴权、同一拒绝原因)", async () => { it("从搜索结果下载:转调 /acquisition-plans(同一鉴权、同一拒绝原因)", async () => {
+368
View File
@@ -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 } });
});
});
+39 -4
View File
@@ -1,21 +1,26 @@
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
ManualAcquisitionResult, ManualAcquisitionResult,
MediaServerInventory,
ReleaseSearchPage, ReleaseSearchPage,
type ReleaseSearchSort, type ReleaseSearchSort,
type SearchReleasePlanRequest, type SearchReleasePlanRequest,
SearchResponse, SearchResponse,
type SearchScope, type SearchScope,
TmdbMediaDetail,
} from "@umt/contracts"; } from "@umt/contracts";
import { api } from "~/lib/api.ts"; import { api } from "~/lib/api.ts";
import { getParsed, parseOrThrow } from "./library.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 = { export const searchKeys = {
all: ["search"] as const, all: ["search"] as const,
aggregate: (q: string, scope: SearchScope, limit: number) => ["search", "aggregate", q, scope, limit] as const, aggregate: (q: string, scope: SearchScope, limit: number) => ["search", "aggregate", q, scope, limit] as const,
releases: (f: ReleaseSearchFilters) => ["search", "releases", f] 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 为空不发请求 */ /** 聚合搜索;q 为空不发请求 */
@@ -31,9 +36,16 @@ export function useSearch(q: string, scope: SearchScope, limit = 8, enabled = tr
} }
export type ReleaseSearchFilters = { export type ReleaseSearchFilters = {
q: string; /** 关键词;按作品搜时省略,由服务端生成查询词 */
/** 逗号分隔 */ q?: string;
/** 按本地作品搜 */
entityId?: string;
/** 按 TMDB 条目搜:movie:157336 */
tmdb?: string;
/** 逗号分隔:只查这些站点 */
sites?: string; sites?: string;
/** 逗号分隔:只筛选结果 */
siteFilter?: string;
resolution?: string; resolution?: string;
source?: string; source?: string;
/** 字节 */ /** 字节 */
@@ -49,7 +61,7 @@ export type ReleaseSearchFilters = {
export function useReleaseSearch(filters: ReleaseSearchFilters, enabled = true) { export function useReleaseSearch(filters: ReleaseSearchFilters, enabled = true) {
return useInfiniteQuery({ return useInfiniteQuery({
queryKey: searchKeys.releases(filters), 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, initialPageParam: undefined as string | undefined,
queryFn: ({ pageParam, signal }) => queryFn: ({ pageParam, signal }) =>
getParsed(ReleaseSearchPage, "/search/releases", { ...filters, cursor: pageParam, limit: 50 }, 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,
});
}
+5 -1
View File
@@ -166,5 +166,9 @@ export function findNavItem(pathname: string): NavItem | undefined {
/** 顶栏标题:导航项名,或不在侧栏的页面名 */ /** 顶栏标题:导航项名,或不在侧栏的页面名 */
export function pageTitle(pathname: string): string | undefined { export function pageTitle(pathname: string): string | undefined {
const path = pathname.replace(/\/+$/, ""); const path = pathname.replace(/\/+$/, "");
return findNavItem(pathname)?.label ?? EXTRA_TITLES[path]; return (
findNavItem(pathname)?.label ??
EXTRA_TITLES[path] ??
(path.startsWith("/console/media/tmdb/") ? "作品详情" : undefined)
);
} }
@@ -1,5 +1,5 @@
import { type LocalMediaHit, type SearchAction, type TmdbCandidate, tmdbImageUrl } from "@umt/contracts"; import { type SearchAction, type SearchWork, tmdbImageUrl } from "@umt/contracts";
import { ArrowRight, CornerDownLeft, Film, Globe, LoaderCircle, type LucideIcon, Plus, Search } from "lucide-react"; import { ArrowRight, CornerDownLeft, Film, Globe, LoaderCircle, type LucideIcon, Search } from "lucide-react";
import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react"; import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react";
import { Link, useNavigate } from "react-router"; import { Link, useNavigate } from "react-router";
import { useSearch } from "~/api/search.ts"; 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 { Dialog, DialogContent, DialogDescription, DialogTitle } from "~/components/ui/dialog.tsx";
import { errorMessage } from "~/lib/api.ts"; import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts"; import { cn } from "~/lib/utils.ts";
import { actionIcon, mediaKindLabel, slotSummary } from "./search-labels.ts"; import { actionIcon, mediaKindLabel } from "./search-labels.ts";
import { useSubscribeTmdb } from "./search-sections.tsx";
import { import {
isSearchShortcut, isSearchShortcut,
openSearchPalette, openSearchPalette,
@@ -16,9 +15,10 @@ import {
setSearchPaletteOpen, setSearchPaletteOpen,
useSearchPalette, useSearchPalette,
} from "./search-store.ts"; } from "./search-store.ts";
import { inventoryBadge, workHref } from "./work-card.tsx";
// 命令面板(Ctrl/⌘+K):输入即查本地作品 / TMDB 候选 / 页面与操作,最后一项跳资源结果页。 // 命令面板(Ctrl/⌘+K):输入即查作品(本地 + TMDB 合并,带库内状态)/ 页面与操作;选中作品打开作品详情页。
// 资源组最慢要等 15s,面板里不查,回车进结果页。 // 面板里不订阅、不查站点;最后一项进搜索结果页。
function useDebounced<T>(v: T, ms: number): T { function useDebounced<T>(v: T, ms: number): T {
const [d, setD] = useState(v); const [d, setD] = useState(v);
@@ -68,7 +68,7 @@ export function CommandPalette() {
)} )}
> >
<DialogTitle className="sr-only">搜索</DialogTitle> <DialogTitle className="sr-only">搜索</DialogTitle>
<DialogDescription className="sr-only">搜索作品、TMDB 和页面;回车在资源中搜索</DialogDescription> <DialogDescription className="sr-only">搜索作品和页面;选中作品打开详情</DialogDescription>
{open ? <PaletteBody initial={initial} /> : null} {open ? <PaletteBody initial={initial} /> : null}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -82,7 +82,6 @@ function PaletteBody({ initial }: { initial: string }) {
const term = useDebounced(q.trim(), 150); const term = useDebounced(q.trim(), 150);
const media = useSearch(term, "media", 6); const media = useSearch(term, "media", 6);
const actions = useSearch(term, "actions", 6); const actions = useSearch(term, "actions", 6);
const tmdbSub = useSubscribeTmdb();
const [active, setActive] = useState(0); const [active, setActive] = useState(0);
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
@@ -92,36 +91,32 @@ function PaletteBody({ initial }: { initial: string }) {
navigate(to); navigate(to);
}; };
const subscribe = (c: TmdbCandidate) => tmdbSub.subscribe(c, close);
const stale = q.trim() !== term; 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 tmdb = term ? media.data?.media?.tmdb : undefined;
const acts = term ? (actions.data?.actions?.items ?? []) : []; const acts = term ? (actions.data?.actions?.items ?? []) : [];
// biome-ignore lint/correctness/useExhaustiveDependencies: 只随数据变化重建(go/subscribe 每次渲染新建) // biome-ignore lint/correctness/useExhaustiveDependencies: 只随数据变化重建(go 每次渲染新建)
const items = useMemo<Item[]>(() => { const items = useMemo<Item[]>(() => {
const out: Item[] = []; const out: Item[] = [];
for (const h of local) out.push(localItem(h, () => go(`/console/library/${h.entity.id}`))); for (const w of works) out.push(workItem(w, () => go(workHref(w))));
for (const c of tmdb?.items ?? [])
out.push(tmdbItem(c, () => go(`/console/library/${c.localEntityId}`), subscribe));
for (const a of acts) out.push(actionItem(a, () => go(a.href))); for (const a of acts) out.push(actionItem(a, () => go(a.href)));
if (term) if (term)
out.push({ out.push({
key: "releases", key: "releases",
group: "资源", group: "搜索页",
icon: Search, icon: Search,
title: ( title: (
<span> <span>
在资源中搜索 <span className="font-medium">“{term}”</span> 查看「<span className="font-medium">{term}</span>」的全部结果
</span> </span>
), ),
meta: "查询全部已启用站点,最慢约 15 秒", meta: "作品卡上可以选站点搜资源",
hint: "打开", hint: "打开",
run: () => go(searchPagePath(term)), run: () => go(searchPagePath(term)),
}); });
return out; return out;
}, [local, tmdb, acts, term]); }, [works, acts, term]);
// biome-ignore lint/correctness/useExhaustiveDependencies: 结果变化时高亮回到第一项 // biome-ignore lint/correctness/useExhaustiveDependencies: 结果变化时高亮回到第一项
useEffect(() => setActive(0), [items.length, term]); useEffect(() => setActive(0), [items.length, term]);
@@ -160,7 +155,7 @@ function PaletteBody({ initial }: { initial: string }) {
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
placeholder="搜索作品、TMDB、页面" placeholder="搜索作品、页面"
role="combobox" role="combobox"
aria-expanded={items.length > 0} aria-expanded={items.length > 0}
aria-controls={listId} aria-controls={listId}
@@ -190,7 +185,7 @@ function PaletteBody({ initial }: { initial: string }) {
> >
{!term ? ( {!term ? (
<p className="px-3 py-6 text-center text-caption text-muted-foreground"> <p className="px-3 py-6 text-center text-caption text-muted-foreground">
输入作品名、别名或页面名称。回车在资源中搜索。 输入作品名、别名或页面名称。选中作品打开详情页。
</p> </p>
) : ( ) : (
<> <>
@@ -261,30 +256,36 @@ function PaletteBody({ initial }: { initial: string }) {
<span>↵ 打开</span> <span>↵ 打开</span>
<span className="ml-auto inline-flex items-center gap-1"> <span className="ml-auto inline-flex items-center gap-1">
<ArrowRight className="h-3 w-3" /> <ArrowRight className="h-3 w-3" />
回车在资源中搜索 搜资源在作品详情页
</span> </span>
</div> </div>
</div> </div>
); );
} }
function localItem(h: LocalMediaHit, run: () => void): Item { function workItem(w: SearchWork, run: () => void): Item {
const e = h.entity; const inv = inventoryBadge(w.inventory);
const parts = [mediaKindLabel(e.kind), e.year ? String(e.year) : null, slotSummary(h)].filter(Boolean); const parts = [
mediaKindLabel(w.kind),
w.year ? String(w.year) : null,
inv.label,
w.inventory.subscribed ? "已订阅" : null,
];
return { return {
key: `local-${e.id}`, key: `work-${w.key}`,
group: "本地作品", group: "作品",
icon: Film, icon: w.localEntityId ? Film : Globe,
posterPath: w.posterPath,
title: ( title: (
<span> <span>
{e.title} {w.title}
{h.matchedAlias && h.matchedAlias !== e.title ? ( {w.originalTitle && w.originalTitle !== w.title ? (
<span className="ml-1.5 text-muted-foreground">· {h.matchedAlias}</span> <span className="ml-1.5 text-muted-foreground">· {w.originalTitle}</span>
) : null} ) : null}
</span> </span>
), ),
meta: parts.join(" · "), meta: parts.filter(Boolean).join(" · "),
hint: "打开", hint: "详情",
run, run,
}; };
} }
@@ -306,27 +307,6 @@ function ItemIcon({ it }: { it: Item }) {
return <it.icon className="h-4 w-4 shrink-0 text-muted-foreground" />; return <it.icon className="h-4 w-4 shrink-0 text-muted-foreground" />;
} }
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: (
<span>
{c.title}
{c.year ? <span className="text-muted-foreground"> ({c.year})</span> : null}
</span>
),
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 { function actionItem(a: SearchAction, run: () => void): Item {
return { return {
key: `action-${a.id}`, key: `action-${a.id}`,
@@ -29,7 +29,8 @@ import { cn, formatBytes } from "~/lib/utils.ts";
import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx"; import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx";
import { gbToBytes, INVENTORY_STATE, promoLabel, SITE_STATUS, SORT_OPTIONS } from "./search-labels.ts"; import { gbToBytes, INVENTORY_STATE, promoLabel, SITE_STATUS, SORT_OPTIONS } from "./search-labels.ts";
// 资源段:跨站结果的筛选(站点/分辨率/来源/大小/免费/H&R)、排序、游标;行操作同站点工作台。 // 资源段(SEARCH-2 第二阶段):按作品或关键词、在选中的站点上搜;筛选(站点/分辨率/来源/大小/免费/H&R)、排序、游标;
// 行操作同站点工作台(库存叠加、下载、委托 Agent)。
/** URL 里的资源筛选(字符串形态,空串 = 不筛) */ /** URL 里的资源筛选(字符串形态,空串 = 不筛) */
export type ReleaseFilterState = { export type ReleaseFilterState = {
@@ -55,12 +56,25 @@ export const RELEASE_FILTER_KEYS = [
"order", "order",
] as const satisfies readonly (keyof ReleaseFilterState)[]; ] 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:<id> / tmdb:<type>:<id> / 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 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; const sort = SORT_OPTIONS.some((o) => o.value === f.sort) ? (f.sort as ReleaseSearchFilters["sort"]) : undefined;
return { return {
q, ...target,
sites: f.sites || undefined, sites: siteIds || undefined,
siteFilter: f.sites || undefined,
resolution: f.resolution || undefined, resolution: f.resolution || undefined,
source: f.source || undefined, source: f.source || undefined,
minSize: gbToBytes(f.minGb), 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) : []); const csvList = (s: string) => (s ? s.split(",").filter(Boolean) : []);
export function ReleaseResults({ export function ReleaseResults({
q, target,
siteIds,
filters, filters,
setFilter, setFilter,
resetFilters, resetFilters,
onChangeSites,
}: { }: {
q: string; target: ReleaseTarget;
/** 选中的站点(逗号分隔);空 = 服务端按默认站点设置 */
siteIds: string;
onChangeSites?: () => void;
filters: ReleaseFilterState; filters: ReleaseFilterState;
setFilter: (k: keyof ReleaseFilterState, v: string) => void; setFilter: (k: keyof ReleaseFilterState, v: string) => void;
resetFilters: () => 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 res = useReleaseSearch(api);
const refresh = useRefreshReleaseSearch(); const refresh = useRefreshReleaseSearch();
const pages = res.data?.pages ?? []; const pages = res.data?.pages ?? [];
@@ -109,20 +128,29 @@ export function ReleaseResults({
) : null} ) : null}
</h2> </h2>
{first ? ( {first ? (
<p className="mt-0.5 text-meta text-muted-foreground tabular-nums"> <p className="mt-0.5 text-meta break-words text-muted-foreground tabular-nums">
{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("")}` : ""}
</p> </p>
) : null} ) : null}
</div> </div>
<Button <div className="flex flex-wrap gap-1">
variant="outline" {onChangeSites ? (
size="sm" <Button variant="ghost" size="sm" onClick={onChangeSites}>
disabled={refresh.isPending || res.isFetching} 换站点
onClick={() => refresh.mutate(api, { onError: (e) => toast.error(errorMessage(e, "刷新失败")) })} </Button>
> ) : null}
<RefreshCw className={cn(refresh.isPending && "animate-spin")} /> <Button
{refresh.isPending ? "查询中…" : "刷新"} variant="outline"
</Button> size="sm"
disabled={refresh.isPending || res.isFetching}
onClick={() => refresh.mutate(api, { onError: (e) => toast.error(errorMessage(e, "刷新失败")) })}
>
<RefreshCw className={cn(refresh.isPending && "animate-spin")} />
{refresh.isPending ? "查询中…" : "刷新"}
</Button>
</div>
</div> </div>
{first ? <CoverageNotice page={first} /> : null} {first ? <CoverageNotice page={first} /> : null}
@@ -143,8 +171,8 @@ export function ReleaseResults({
<EmptyState <EmptyState
plain plain
icon={PackageSearch} icon={PackageSearch}
title={filtered ? "没有符合筛选条件的资源" : "各站点都没有找到资源"} title={filtered ? "没有符合筛选条件的资源" : "所选站点都没有找到资源"}
description={filtered ? "放宽筛选条件再看。" : "换个关键词,或到站点工作台检查站点状态。"} description={filtered ? "放宽筛选条件再看。" : "换站点或换关键词再搜,或到站点页检查站点状态。"}
className="py-12" className="py-12"
/> />
) : ( ) : (
@@ -1,18 +1,17 @@
import type { TmdbCandidate } from "@umt/contracts"; import type { SearchWork } from "@umt/contracts";
import { ArrowRight, Film, Plus } from "lucide-react"; import { ArrowRight, Film, PackageSearch } from "lucide-react";
import { useState } from "react";
import { Link, useNavigate } from "react-router"; import { Link, useNavigate } from "react-router";
import { toast } from "sonner"; import { mediaServers } from "~/api/instances.ts";
import { useEntityFromCandidate } from "~/api/files.ts";
import { useSearch } from "~/api/search.ts"; import { useSearch } from "~/api/search.ts";
import { useCreateSubscription } from "~/api/subscriptions.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { settingsPath } from "~/components/console/nav.ts"; import { settingsPath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts"; import { actionIcon } from "./search-labels.ts";
import { actionIcon, mediaKindLabel, slotSummary } 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 }) { function SectionTitle({ id, title, extra }: { id: string; title: string; extra?: string }) {
return ( return (
@@ -23,131 +22,76 @@ function SectionTitle({ id, title, extra }: { id: string; title: string; extra?:
); );
} }
/** 从 TMDB 候选建订阅:先按 TMDB id 建档(季/集与总集数随之就位)再订阅作品;建档失败退回按标题追踪 */ /** 「搜索资源」:先选站点,再跳到第二阶段(同一搜索页带 work / in 参数) */
export function useSubscribeTmdb() { export function useReleaseSearchLauncher() {
const create = useCreateSubscription();
const entity = useEntityFromCandidate();
const navigate = useNavigate(); const navigate = useNavigate();
const [target, setTarget] = useState<{ title: string; ref: string } | null>(null);
return { return {
pending: create.isPending || entity.isPending, open: (t: { title: string; ref: string }) => setTarget(t),
subscribe: async (c: TmdbCandidate, onDone?: () => void) => { dialog: (
const kind = c.entityType === "movie" ? "movie" : "series"; <SitePickerDialog
const mediaEntityId = open={!!target}
c.localEntityId ?? onOpenChange={(o) => !o && setTarget(null)}
(await entity title={target?.title ?? ""}
.mutateAsync({ provider: "tmdb", externalId: c.externalId, kind: c.entityType === "movie" ? "movie" : "tv" }) onConfirm={(ids) => target && navigate(releaseSearchPath(target, ids))}
.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, "订阅失败")),
},
);
},
}; };
} }
export function MediaSection({ q }: { q: string }) { export function WorksSection({ q }: { q: string }) {
const res = useSearch(q, "media", 20); const res = useSearch(q, "media", 20);
const sub = useSubscribeTmdb(); const ms = mediaServers.useList();
const local = res.data?.media?.local.items ?? []; 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 tmdb = res.data?.media?.tmdb;
const searchReleases = (w: SearchWork) => {
const ref = workRef(w);
if (ref) launcher.open({ title: w.title, ref });
};
return ( return (
<section aria-labelledby="search-media" className="min-w-0"> <section aria-labelledby="search-media" className="min-w-0">
<SectionTitle <div className="mb-3 flex flex-wrap items-end justify-between gap-2">
id="search-media" <SectionTitle id="search-media" title="作品" extra={res.data ? `${works.length} 部` : undefined} />
title="作品" <Button variant="ghost" size="sm" onClick={() => launcher.open({ title: q, ref: `q:${q}` })}>
extra={res.data ? `${local.length + (tmdb?.items.length ?? 0)} 条` : undefined} <PackageSearch />
/> 按关键词搜资源
<div className="pg-slab min-w-0 overflow-hidden rounded-xl"> </Button>
{res.isPending ? ( </div>
<LoadingState rows={2} className="p-4" /> {res.isPending ? (
) : res.isError ? ( <LoadingState rows={3} className="pg-slab rounded-xl p-4" />
) : res.isError ? (
<div className="pg-slab rounded-xl">
<ErrorState plain error={res.error} onRetry={() => res.refetch()} /> <ErrorState plain error={res.error} onRetry={() => res.refetch()} />
) : local.length === 0 && !tmdb?.items.length ? ( </div>
) : works.length === 0 ? (
<div className="pg-slab rounded-xl">
<EmptyState <EmptyState
plain plain
icon={Film} icon={Film}
title="没有匹配的作品" title="没有匹配的作品"
description="换个译名、原名或别名再搜。" description="换个译名、原名或别名再搜;资源标题可以点「按关键词搜资源」。"
className="py-8" className="py-8"
/> />
) : ( </div>
<ul className="divide-y divide-border/60"> ) : (
{local.map((h) => ( <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<li key={h.entity.id}> {works.map((w) => (
<Link <WorkCard key={w.key} w={w} emby={emby} onSearchReleases={searchReleases} />
to={`/console/library/${h.entity.id}`} ))}
className="flex min-w-0 items-center gap-3 px-4 py-3 transition-colors duration-micro hover:bg-foreground/[0.04]" </div>
> )}
<Film className="h-4 w-4 shrink-0 text-muted-foreground" /> {tmdb && !tmdb.configured ? (
<div className="min-w-0 flex-1"> <p className="mt-2 text-meta text-muted-foreground">
<p className="truncate text-sm font-medium text-foreground"> TMDB 未配置,只搜了本地作品。
{h.entity.title} <Link to={settingsPath("metadata")} className="ml-1 underline underline-offset-4">
{h.entity.year ? <span className="text-muted-foreground"> ({h.entity.year})</span> : null} 去配置
</p> </Link>
<p className="truncate text-meta text-muted-foreground"> </p>
{mediaKindLabel(h.entity.kind)} · {slotSummary(h)} ) : null}
{h.matchedAlias && h.matchedAlias !== h.entity.title ? ` · 命中别名 ${h.matchedAlias}` : ""} {tmdb?.error ? <p className="mt-2 text-meta text-warning">TMDB 查询失败:{tmdb.error}</p> : null}
</p> {launcher.dialog}
</div>
<StatusBadge>本地</StatusBadge>
</Link>
</li>
))}
{(tmdb?.items ?? []).map((c) => (
<li key={`${c.entityType}-${c.externalId}`} className="flex min-w-0 items-center gap-3 px-4 py-3">
<Plus className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{c.title}
{c.year ? <span className="text-muted-foreground"> ({c.year})</span> : null}
</p>
<p className="line-clamp-1 text-meta text-muted-foreground">
TMDB · {c.entityType === "movie" ? "电影" : "剧集"}
{c.originalTitle && c.originalTitle !== c.title ? ` · ${c.originalTitle}` : ""}
{c.overview ? ` · ${c.overview}` : ""}
</p>
</div>
{c.localEntityId ? (
<Button asChild variant="outline" size="sm">
<Link to={`/console/library/${c.localEntityId}`}>查看</Link>
</Button>
) : (
<Button variant="outline" size="sm" disabled={sub.pending} onClick={() => sub.subscribe(c)}>
订阅
</Button>
)}
</li>
))}
</ul>
)}
{tmdb && !tmdb.configured ? (
<p className="border-t border-border/60 px-4 py-2.5 text-meta text-muted-foreground">
TMDB 未配置,只搜了本地作品。
<Link to={settingsPath("metadata")} className="ml-1 underline underline-offset-4">
去配置
</Link>
</p>
) : null}
{tmdb?.error ? (
<p className="border-t border-border/60 px-4 py-2.5 text-meta text-warning">TMDB 查询失败:{tmdb.error}</p>
) : null}
</div>
</section> </section>
); );
} }
@@ -22,7 +22,7 @@ export function SearchTrigger({ className }: { className?: string }) {
)} )}
> >
<Search className="h-3.5 w-3.5 shrink-0" /> <Search className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-left">搜索作品、资源、页面</span> <span className="flex-1 truncate text-left">搜索作品、页面</span>
<kbd className="rounded-tight border border-border/70 px-1 font-sans text-label leading-4 font-medium"> <kbd className="rounded-tight border border-border/70 px-1 font-sans text-label leading-4 font-medium">
{shortcut} {shortcut}
</kbd> </kbd>
@@ -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>): 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(/<SubscriptionFormDialog[\s\S]*initialTarget=/);
}
});
});
@@ -0,0 +1,171 @@
import type { SearchSiteMode, Site } from "@umt/contracts";
import { PackageSearch } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router";
import { useSiteOptions } from "~/api/instances.ts";
import { useSettings } from "~/api/settings.ts";
import { consolePath } from "~/components/console/nav.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 { cn } from "~/lib/utils.ts";
// 搜资源前选站点:默认(站点页勾了「默认参与搜索」的)/ 全部启用站点 / 指定站点。
// 初始值 = 本机上次的选择,没有就用 settings.search.defaultSiteMode。
export type SitePick = { mode: SearchSiteMode; siteIds: string[] };
const STORE_KEY = "umt.search.sitePick";
export function loadSitePick(): SitePick | null {
try {
const raw = globalThis.localStorage?.getItem(STORE_KEY);
if (!raw) return null;
const v = JSON.parse(raw) as Partial<SitePick>;
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<Site, "id" | "enabled" | "searchDefault">[]): 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<SitePick | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>搜索资源</DialogTitle>
<DialogDescription>在所选站点搜「{title}」。每个站点按作品原名和译名各查一次。</DialogDescription>
</DialogHeader>
<fieldset aria-label="站点范围" className="m-0 grid grid-cols-3 gap-1 rounded-lg border border-border/70 p-1">
{MODES.map((m) => (
<button
key={m.value}
type="button"
aria-pressed={cur.mode === m.value}
onClick={() =>
setPick({
mode: m.value,
// 切到「指定」时从当前集合起步
siteIds: m.value === "custom" && cur.mode !== "custom" ? ids : cur.siteIds,
})
}
className={cn(
"cursor-pointer rounded-md px-2 py-1.5 text-caption transition-colors duration-micro",
cur.mode === m.value
? "bg-foreground/[0.08] font-medium text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{m.label}
{m.value !== "custom" && sitesQ.data ? (
<span className="ml-1 text-meta text-muted-foreground tabular-nums">{count(m.value)}</span>
) : null}
</button>
))}
</fieldset>
{sitesQ.isPending ? (
<p className="text-caption text-muted-foreground">加载站点…</p>
) : sites.length === 0 ? (
<p className="text-caption text-muted-foreground">
还没有启用的站点。
<Link to={consolePath("sites")} className="ml-1 underline underline-offset-4">
去站点页
</Link>
</p>
) : cur.mode === "custom" ? (
<ChipToggleGroup
label="站点"
options={sites.map((s) => ({ value: s.id, label: s.name }))}
value={cur.siteIds.filter((id) => sites.some((s) => s.id === id))}
onChange={(v) => setPick({ mode: "custom", siteIds: v })}
/>
) : (
<p className="text-caption break-words text-muted-foreground">
{ids.length
? sites
.filter((s) => ids.includes(s.id))
.map((s) => s.name)
.join("、")
: "没有站点勾选「默认参与搜索」。在站点页打开,或改选全部站点。"}
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={confirm} disabled={ids.length === 0}>
<PackageSearch />
搜索 {ids.length} 个站点
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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<SearchWork, "localEntityId" | "tmdb">): 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:<id> / tmdb:<type>:<id> */
export function workRef(w: Pick<SearchWork, "localEntityId" | "tmdb">): 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 (
<>
<StatusBadge tone={b.tone}>{b.label}</StatusBadge>
{inv.subscribed ? <StatusBadge>已订阅</StatusBadge> : 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 ? <StatusBadge tone={s.tone}>{s.label}</StatusBadge> : null;
}
export function msRefOf(w: Pick<SearchWork, "localEntityId" | "tmdb">): 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 (
<article className="pg-slab flex min-w-0 gap-3 rounded-xl p-3">
<Link to={href} className="shrink-0" aria-label={`${w.title} 详情`}>
<CoverPlaceholder title={w.title} kind={w.kind} posterPath={w.posterPath} className="h-32 w-[5.5rem]" />
</Link>
<div className="flex min-w-0 flex-1 flex-col">
<Link to={href} className="min-w-0 hover:underline">
<h3 className="truncate text-sm font-semibold text-foreground">
{w.title}
{w.year ? <span className="font-normal text-muted-foreground"> ({w.year})</span> : null}
</h3>
</Link>
<p className="truncate text-meta text-muted-foreground">
{mediaKindLabel(w.kind)}
{w.originalTitle && w.originalTitle !== w.title ? ` · ${w.originalTitle}` : ""}
{w.source === "tmdb" ? " · TMDB" : ""}
</p>
<div className="mt-1.5 flex flex-wrap gap-1">
<InventoryBadges inv={w.inventory} />
{emby ? <EmbyBadge refOf={msRefOf(w)} /> : null}
</div>
{line ? <p className="mt-1 line-clamp-1 text-meta text-muted-foreground">{line}</p> : null}
{w.overview ? <p className="mt-1 line-clamp-2 text-meta text-muted-foreground">{w.overview}</p> : null}
<div className="mt-auto flex flex-wrap justify-end gap-1 pt-2">
<Button asChild variant="ghost" size="sm">
<Link to={href}>
详情
<ArrowRight />
</Link>
</Button>
<Button variant="outline" size="sm" onClick={() => onSearchReleases(w)} disabled={!workRef(w)}>
<PackageSearch />
搜索资源
</Button>
</div>
</div>
</article>
);
}
@@ -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<SearchSiteMode>("default");
const [ids, setIds] = useState<string[]>([]);
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>搜索站点</DialogTitle>
<DialogDescription>搜资源时默认查哪些站点。每次搜索前仍可改选,本机会记住上次的选择。</DialogDescription>
</DialogHeader>
<fieldset aria-label="默认范围" className="m-0 space-y-1.5 border-0 p-0">
{MODES.map((m) => (
<button
key={m.value}
type="button"
aria-pressed={mode === m.value}
onClick={() => setMode(m.value)}
className={cn(
"block w-full cursor-pointer rounded-lg border px-3 py-2 text-left transition-colors duration-micro",
mode === m.value ? "pill-selected" : "border-border hover:bg-foreground/[0.04]",
)}
>
<span className="block text-sm font-medium text-foreground">{m.label}</span>
<span className="block text-meta text-muted-foreground">
{m.hint}
{m.value === "default"
? `(${enabled.filter((s) => s.searchDefault !== false).length} 个)`
: m.value === "all"
? `(${enabled.length} 个)`
: ""}
</span>
</button>
))}
</fieldset>
{mode === "custom" ? (
<ChipToggleGroup
label="站点"
options={enabled.map((s) => ({ value: s.id, label: s.name }))}
value={ids.filter((id) => enabled.some((s) => s.id === id))}
onChange={setIds}
/>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={update.isPending}>
取消
</Button>
<Button onClick={save} disabled={update.isPending || (mode === "custom" && ids.length === 0)}>
{update.isPending ? <LoaderCircle className="animate-spin" /> : null}
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+27 -1
View File
@@ -14,7 +14,7 @@ import {
import { type ReactNode, useState } from "react"; import { type ReactNode, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { toast } from "sonner"; 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 { DataTable } from "~/components/console/data-table.tsx";
import { consolePath } from "~/components/console/nav.ts"; import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx"; 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 (
<span className="mr-auto flex items-center gap-2 text-meta text-muted-foreground">
<Switch
id={`site-sd-${site.id}`}
checked={on}
disabled={update.isPending}
onCheckedChange={(v) =>
update.mutate(
{ id: site.id, body: { searchDefault: v } },
{
onSuccess: () => toast.success(v ? `${site.name} 默认参与搜索` : `${site.name} 不再默认参与搜索`),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
<label htmlFor={`site-sd-${site.id}`}>默认参与搜索</label>
</span>
);
}
/** "更多"菜单:更新凭证、编辑、快照、启停、删除 */ /** "更多"菜单:更新凭证、编辑、快照、启停、删除 */
export function SiteMoreMenu({ site, onDialog }: { site: SiteListItem; onDialog: (d: SiteDialog) => void }) { export function SiteMoreMenu({ site, onDialog }: { site: SiteListItem; onDialog: (d: SiteDialog) => void }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -305,6 +330,7 @@ export function SiteCard({ site, checks, onDialog }: Props) {
</dl> </dl>
<footer className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3"> <footer className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
<SearchDefaultSwitch site={site} />
{acc ? ( {acc ? (
<CheckButtons site={site} checks={checks} /> <CheckButtons site={site} checks={checks} />
) : ( ) : (
@@ -323,6 +323,7 @@ export function SiteEditSheet({ site, onClose }: { site: SiteListItem; onClose:
snapshotEnabled: site.snapshotEnabled, snapshotEnabled: site.snapshotEnabled,
intervalMin: site.snapshotIntervalSec ? String(Math.round(site.snapshotIntervalSec / 60)) : "", intervalMin: site.snapshotIntervalSec ? String(Math.round(site.snapshotIntervalSec / 60)) : "",
proxy: site.proxy, proxy: site.proxy,
searchDefault: site.searchDefault !== false,
}); });
const [errors, setErrors] = useState<Record<string, string>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
const set = <K extends keyof typeof v>(k: K, val: (typeof v)[K]) => setV((s) => ({ ...s, [k]: val })); const set = <K extends keyof typeof v>(k: K, val: (typeof v)[K]) => setV((s) => ({ ...s, [k]: val }));
@@ -364,6 +365,7 @@ export function SiteEditSheet({ site, onClose }: { site: SiteListItem; onClose:
snapshotEnabled: v.snapshotEnabled, snapshotEnabled: v.snapshotEnabled,
snapshotIntervalSec: interval ? interval * 60 : null, snapshotIntervalSec: interval ? interval * 60 : null,
proxy: v.proxy, proxy: v.proxy,
searchDefault: v.searchDefault,
}, },
}); });
toast.success(`${v.name.trim()} 已保存`); toast.success(`${v.name.trim()} 已保存`);
@@ -453,6 +455,20 @@ export function SiteEditSheet({ site, onClose }: { site: SiteListItem; onClose:
<span className="block text-muted-foreground">走「站点默认」里的代理地址;没填代理地址时不生效</span> <span className="block text-muted-foreground">走「站点默认」里的代理地址;没填代理地址时不生效</span>
</label> </label>
</div> </div>
<div className="mb-2 flex items-start gap-3">
<Switch
id="se-search"
checked={v.searchDefault}
onCheckedChange={(x) => set("searchDefault", x)}
disabled={busy}
/>
<label htmlFor="se-search" className="text-caption">
<span className="block font-medium text-foreground">默认参与搜索</span>
<span className="block text-muted-foreground">
搜资源选「默认站点」时查这个站点;全部站点或指定站点时另选
</span>
</label>
</div>
<p className="text-meta text-muted-foreground">User-Agent 随凭证保存,在"更新凭证"里填写。</p> <p className="text-meta text-muted-foreground">User-Agent 随凭证保存,在"更新凭证"里填写。</p>
</div> </div>
</DetailSheet> </DetailSheet>
@@ -66,9 +66,19 @@ import { SlotOrderList } from "./slot-order.tsx";
// 新建 / 编辑订阅(设计 §3):基础 / 进阶两栏,对齐 MP 编辑对话框。作品用全局搜索的 media 段(本地 + TMDB)。 // 新建 / 编辑订阅(设计 §3):基础 / 进阶两栏,对齐 MP 编辑对话框。作品用全局搜索的 media 段(本地 + TMDB)。
type Target = /** 订阅的作品:媒体库作品 / TMDB 条目(保存时先建档,SEARCH-2 详情页入口)/ 按标题追踪 */
export type SubscriptionTarget =
| { kind: "entity"; id: string; title: string; year: number | null; mediaKind: SubscriptionMediaKind } | { kind: "entity"; id: string; title: string; year: number | null; mediaKind: SubscriptionMediaKind }
| {
kind: "tmdb";
entityType: "movie" | "tv";
externalId: string;
title: string;
year: number | null;
mediaKind: SubscriptionMediaKind;
}
| { kind: "title"; titleQuery: string; mediaKind: SubscriptionMediaKind; note?: string }; | { kind: "title"; titleQuery: string; mediaKind: SubscriptionMediaKind; note?: string };
type Target = SubscriptionTarget;
const toSubKind = (k: string): SubscriptionMediaKind => const toSubKind = (k: string): SubscriptionMediaKind =>
k === "movie" ? "movie" : k === "variety" ? "variety" : "series"; k === "movie" ? "movie" : k === "variety" ? "variety" : "series";
@@ -263,11 +273,14 @@ function WorkPicker({ onPick, error }: { onPick: (t: Target) => void; error?: st
export function SubscriptionFormDialog({ export function SubscriptionFormDialog({
sub, sub,
title, title,
initialTarget,
onClose, onClose,
onCreated, onCreated,
}: { }: {
/** 缺省 = 新建 */ /** 缺省 = 新建 */
sub?: Subscription | null; sub?: Subscription | null;
/** 新建时预填作品(作品详情页的「订阅」),不再显示作品搜索 */
initialTarget?: SubscriptionTarget;
/** 编辑时的作品名(单条订阅接口不带标题) */ /** 编辑时的作品名(单条订阅接口不带标题) */
title?: string; title?: string;
onClose: () => void; onClose: () => void;
@@ -278,8 +291,9 @@ export function SubscriptionFormDialog({
const create = useCreateSubscription(); const create = useCreateSubscription();
const update = useUpdateSubscription(); const update = useUpdateSubscription();
const setOrder = useSetSlotOrder(); const setOrder = useSetSlotOrder();
const [target, setTarget] = useState<Target | null>(null); const [target, setTarget] = useState<Target | null>(initialTarget ?? null);
const [tab, setTab] = useState("basic"); const [tab, setTab] = useState("basic");
const entityFromTmdb = useEntityFromCandidate();
const slotsQ = useSlots({ subscriptionId: sub?.id }, editing); const slotsQ = useSlots({ subscriptionId: sub?.id }, editing);
const subSlots = useMemo( const subSlots = useMemo(
() => () =>
@@ -289,7 +303,19 @@ export function SubscriptionFormDialog({
[slotsQ.data, sub?.id], [slotsQ.data, sub?.id],
); );
const initialUpgrade: UpgradeStrategy = sub ? upgradeOf(subSlots, sub.mediaKind) : "off"; const initialUpgrade: UpgradeStrategy = sub ? upgradeOf(subSlots, sub.mediaKind) : "off";
const [d, setD] = useState<SubscriptionDraft>(() => (sub ? draftFromSubscription(sub, "off") : emptyDraft("series"))); const [d, setD] = useState<SubscriptionDraft>(() => {
if (sub) return draftFromSubscription(sub, "off");
const k = initialTarget?.mediaKind ?? "series";
const base = emptyDraft(k);
return initialTarget
? {
...base,
mediaKind: k,
mode: k === "movie" ? "once" : "continuous",
seasonScope: k === "movie" ? "current_season" : "following_seasons",
}
: base;
});
const [order, setOrderState] = useState<{ id: string; name: string }[] | null>(null); const [order, setOrderState] = useState<{ id: string; name: string }[] | null>(null);
const [upgradeTouched, setUpgradeTouched] = useState(false); const [upgradeTouched, setUpgradeTouched] = useState(false);
const [totalTouched, setTotalTouched] = useState(false); const [totalTouched, setTotalTouched] = useState(false);
@@ -340,7 +366,7 @@ export function SubscriptionFormDialog({
}); });
}; };
const pending = create.isPending || update.isPending || setOrder.isPending; const pending = create.isPending || update.isPending || setOrder.isPending || entityFromTmdb.isPending;
const submit = async () => { const submit = async () => {
const { params, errors: errs } = draftToParams(d, sub?.params ?? {}, { const { params, errors: errs } = draftToParams(d, sub?.params ?? {}, {
@@ -372,10 +398,23 @@ export function SubscriptionFormDialog({
toast.success("订阅已保存"); toast.success("订阅已保存");
onClose(); onClose();
} else if (target) { } else if (target) {
// TMDB 条目:订阅时隐式建档(季/集与总集数随之就位),同一 TMDB id 已建档则复用
const tmdbEntity =
target.kind === "tmdb"
? (
await entityFromTmdb.mutateAsync({
provider: "tmdb",
externalId: target.externalId,
kind: target.entityType,
})
).mediaEntityId
: null;
const created = await create.mutateAsync({ const created = await create.mutateAsync({
...(target.kind === "entity" ...(target.kind === "entity"
? { mediaEntityId: target.id, seasonUnitId: d.seasonUnitId || undefined } ? { mediaEntityId: target.id, seasonUnitId: d.seasonUnitId || undefined }
: { titleQuery: target.titleQuery.trim() }), : tmdbEntity
? { mediaEntityId: tmdbEntity }
: { titleQuery: target.kind === "title" ? target.titleQuery.trim() : target.title }),
mediaKind: d.mediaKind, mediaKind: d.mediaKind,
mode: d.mode, mode: d.mode,
seasonScope: isMovie ? "current_season" : d.seasonScope, seasonScope: isMovie ? "current_season" : d.seasonScope,
@@ -405,7 +444,7 @@ export function SubscriptionFormDialog({
const f = (k: string) => `${id}-${k}`; const f = (k: string) => `${id}-${k}`;
const titleText = sub const titleText = sub
? (title ?? sub.mediaEntityTitle ?? sub.titleQuery ?? "订阅") ? (title ?? sub.mediaEntityTitle ?? sub.titleQuery ?? "订阅")
: target?.kind === "entity" : target?.kind === "entity" || target?.kind === "tmdb"
? `${target.title}${target.year ? ` (${target.year})` : ""}` ? `${target.title}${target.year ? ` (${target.year})` : ""}`
: (target?.titleQuery ?? ""); : (target?.titleQuery ?? "");
@@ -415,7 +454,7 @@ export function SubscriptionFormDialog({
<DialogHeader> <DialogHeader>
<DialogTitle>{editing ? "编辑订阅" : "新建订阅"}</DialogTitle> <DialogTitle>{editing ? "编辑订阅" : "新建订阅"}</DialogTitle>
<DialogDescription> <DialogDescription>
{editing ? titleText : "从媒体库或 TMDB 选作品;选 TMDB 条目会先建档。"} {editing ? titleText : initialTarget ? titleText : "从媒体库或 TMDB 选作品;选 TMDB 条目会先建档。"}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{dup ? ( {dup ? (
@@ -455,13 +494,19 @@ export function SubscriptionFormDialog({
<p className="truncate text-sm font-medium text-foreground">{titleText}</p> <p className="truncate text-sm font-medium text-foreground">{titleText}</p>
)} )}
<p className="mt-0.5 text-meta text-muted-foreground"> <p className="mt-0.5 text-meta text-muted-foreground">
{target.kind === "entity" ? "媒体库作品" : (target.note ?? "按标题追踪,尚未建档")} {target.kind === "entity"
? "媒体库作品"
: target.kind === "tmdb"
? "TMDB 条目,保存时建档"
: (target.note ?? "按标题追踪,尚未建档")}
</p> </p>
</div> </div>
<Button variant="ghost" size="sm" onClick={() => setTarget(null)}> {initialTarget ? null : (
<X /> <Button variant="ghost" size="sm" onClick={() => setTarget(null)}>
更换 <X />
</Button> 更换
</Button>
)}
</div> </div>
) : ( ) : (
<WorkPicker onPick={pickTarget} error={errors.target} /> <WorkPicker onPick={pickTarget} error={errors.target} />
@@ -479,7 +524,7 @@ export function SubscriptionFormDialog({
value: v, value: v,
label: SUB_KIND_LABEL[v], label: SUB_KIND_LABEL[v],
}))} }))}
disabled={editing || target?.kind === "entity"} disabled={editing || target?.kind === "entity" || target?.kind === "tmdb"}
/> />
</FormField> </FormField>
<FormField label="方式" htmlFor={f("mode")}> <FormField label="方式" htmlFor={f("mode")}>
+1
View File
@@ -12,6 +12,7 @@ export default [
route("inbox", "routes/console/inbox.tsx"), route("inbox", "routes/console/inbox.tsx"),
route("library", "routes/console/library.tsx"), route("library", "routes/console/library.tsx"),
route("library/:id", "routes/console/library.$id.tsx"), route("library/:id", "routes/console/library.$id.tsx"),
route("media/tmdb/:type/:id", "routes/console/media.tmdb.$type.$id.tsx"),
route("identity", "routes/console/identity.tsx"), route("identity", "routes/console/identity.tsx"),
route("organize", "routes/console/organize.tsx"), route("organize", "routes/console/organize.tsx"),
route("organize/:id", "routes/console/organize.$id.tsx"), route("organize/:id", "routes/console/organize.$id.tsx"),
@@ -11,11 +11,13 @@ import { EntityMetadataField as FieldEnum, FinalFieldValue } from "@umt/contract
import { import {
ArrowLeft, ArrowLeft,
CalendarClock, CalendarClock,
CalendarPlus,
ChevronRight, ChevronRight,
FolderTree, FolderTree,
GitMerge, GitMerge,
Lock, Lock,
LockOpen, LockOpen,
PackageSearch,
PencilLine, PencilLine,
RefreshCw, RefreshCw,
SearchX, SearchX,
@@ -23,6 +25,7 @@ import {
import { useState } from "react"; import { useState } from "react";
import { Link, useParams } from "react-router"; import { Link, useParams } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { mediaServers } from "~/api/instances.ts";
import { import {
useEntityDetail, useEntityDetail,
useLibraryDetail, useLibraryDetail,
@@ -63,6 +66,9 @@ import {
} from "~/components/library/parts.tsx"; } from "~/components/library/parts.tsx";
import { SlotCard } from "~/components/library/slot-card.tsx"; import { SlotCard } from "~/components/library/slot-card.tsx";
import { EntityNamingButton } from "~/components/naming/entity-naming-dialog.tsx"; import { EntityNamingButton } from "~/components/naming/entity-naming-dialog.tsx";
import { useReleaseSearchLauncher } from "~/components/search/search-sections.tsx";
import { EmbyBadge } from "~/components/search/work-card.tsx";
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts"; import { errorMessage } from "~/lib/api.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts"; import { formatBytes, formatDateTime } from "~/lib/utils.ts";
@@ -576,6 +582,9 @@ export default function LibraryDetailPage() {
const detail = useLibraryDetail(id); const detail = useLibraryDetail(id);
const entity = useEntityDetail(id); const entity = useEntityDetail(id);
const [merging, setMerging] = useState(false); const [merging, setMerging] = useState(false);
const [subscribing, setSubscribing] = useState(false);
const launcher = useReleaseSearchLauncher();
const ms = mediaServers.useList();
const back = ( const back = (
<Link <Link
@@ -645,8 +654,29 @@ export default function LibraryDetailPage() {
{e.originalTitle ? ` · ${e.originalTitle}` : ""} {e.originalTitle ? ` · ${e.originalTitle}` : ""}
</p> </p>
<p className="mt-1 font-mono text-meta break-all text-muted-foreground">{e.id}</p> <p className="mt-1 font-mono text-meta break-all text-muted-foreground">{e.id}</p>
{(ms.data?.items.length ?? 0) > 0 ? (
<div className="mt-1.5 flex flex-wrap gap-1">
<EmbyBadge refOf={{ mediaEntityId: e.id }} />
</div>
) : null}
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{e.status !== "merged" ? (
<>
<Button size="sm" onClick={() => setSubscribing(true)}>
<CalendarPlus />
订阅
</Button>
<Button
variant="outline"
size="sm"
onClick={() => launcher.open({ title: e.title, ref: `local:${e.id}` })}
>
<PackageSearch />
搜索资源
</Button>
</>
) : null}
<Button asChild variant="outline" size="sm"> <Button asChild variant="outline" size="sm">
<Link to={`${consolePath("subscriptions")}?mediaEntityId=${e.id}`}> <Link to={`${consolePath("subscriptions")}?mediaEntityId=${e.id}`}>
<CalendarClock /> <CalendarClock />
@@ -676,6 +706,19 @@ export default function LibraryDetailPage() {
<DownloadsPanel detail={d} /> <DownloadsPanel detail={d} />
</div> </div>
<MergeDialog entity={e} open={merging} onOpenChange={setMerging} /> <MergeDialog entity={e} open={merging} onOpenChange={setMerging} />
{subscribing ? (
<SubscriptionFormDialog
initialTarget={{
kind: "entity",
id: e.id,
title: e.title,
year: e.year,
mediaKind: e.kind === "movie" ? "movie" : e.kind === "variety" ? "variety" : "series",
}}
onClose={() => setSubscribing(false)}
/>
) : null}
{launcher.dialog}
</> </>
); );
} }
@@ -0,0 +1,294 @@
import type { MediaServerInventory, TmdbMediaDetail } from "@umt/contracts";
import { ArrowLeft, BookmarkPlus, CalendarPlus, FolderPlus, PackageSearch } from "lucide-react";
import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { toast } from "sonner";
import { useEntityFromCandidate } from "~/api/files.ts";
import { mediaServers } from "~/api/instances.ts";
import { useMediaServerInventory, useTmdbMedia } from "~/api/search.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath, settingsPath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { MEDIA_KIND } from "~/components/library/labels.ts";
import { CoverPlaceholder, Panel } from "~/components/library/parts.tsx";
import { useReleaseSearchLauncher } from "~/components/search/search-sections.tsx";
import { InventoryBadges, mediaServerSummary, slotLine } from "~/components/search/work-card.tsx";
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
// 作品详情(仅 TMDB,SEARCH-2):TMDB 信息与季列表 + 本地账本 / Emby 对比;在这里「订阅」「搜索资源」「建档不订阅」。
// 已建档的作品同时给媒体库详情入口。
export const meta = () => [{ title: "作品详情 · UHD 媒体自动化控制台" }];
const pad = (n: number) => String(n).padStart(2, "0");
export default function TmdbMediaPage() {
const { type, id } = useParams();
const q = useTmdbMedia(type, id);
const navigate = useNavigate();
const back = (
<button
type="button"
onClick={() => navigate(-1)}
className="mb-4 inline-flex cursor-pointer items-center gap-1 text-caption text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
返回
</button>
);
if (q.isPending) {
return (
<>
{back}
<LoadingState rows={6} />
</>
);
}
if (q.isError) {
const code = (q.error as { code?: string }).code;
return (
<>
{back}
<ErrorState
title={code === "not_found" ? "TMDB 没有这个条目" : "作品加载失败"}
error={q.error}
onRetry={code === "not_found" ? undefined : () => q.refetch()}
/>
</>
);
}
return (
<>
{back}
<Detail d={q.data} />
</>
);
}
function Detail({ d }: { d: TmdbMediaDetail }) {
const navigate = useNavigate();
const [subscribing, setSubscribing] = useState(false);
const entity = useEntityFromCandidate();
const launcher = useReleaseSearchLauncher();
const kind = d.entityType === "movie" ? "movie" : "series";
const ref = d.localEntityId ? `local:${d.localEntityId}` : `tmdb:${d.entityType}:${d.externalId}`;
const createEntity = () =>
entity.mutate(
{ provider: "tmdb", externalId: d.externalId, kind: d.entityType },
{
onSuccess: (r) => {
toast.success(r.created ? `已建档「${r.title}」` : `「${r.title}」已在媒体库`);
navigate(consolePath("library", r.mediaEntityId));
},
onError: (e) => toast.error(errorMessage(e, "建档失败")),
},
);
return (
<>
<header className="mb-6 flex flex-wrap items-start gap-4">
<CoverPlaceholder title={d.title} kind={kind} posterPath={d.posterPath} className="h-40 w-28 sm:h-48 sm:w-32" />
<div className="min-w-0 flex-1 basis-64">
<h1 className="text-title-lg font-semibold tracking-tight break-words text-foreground">
{d.title}
{d.year ? <span className="font-normal text-muted-foreground"> ({d.year})</span> : null}
</h1>
<p className="mt-1 text-body break-words text-muted-foreground">
{[
MEDIA_KIND[kind],
d.originalTitle && d.originalTitle !== d.title ? d.originalTitle : null,
d.releaseDate,
d.originCountry.join(" / ") || null,
`TMDB ${d.externalId}`,
]
.filter(Boolean)
.join(" · ")}
</p>
<div className="mt-2 flex flex-wrap gap-1">
<InventoryBadges inv={d.inventory} />
</div>
{d.overview ? <p className="mt-3 max-w-3xl text-caption text-foreground">{d.overview}</p> : null}
<div className="mt-4 flex flex-wrap gap-2">
<Button onClick={() => setSubscribing(true)}>
<CalendarPlus />
订阅
</Button>
<Button variant="outline" onClick={() => launcher.open({ title: d.title, ref })}>
<PackageSearch />
搜索资源
</Button>
{d.localEntityId ? (
<Button asChild variant="outline">
<Link to={consolePath("library", d.localEntityId)}>
<BookmarkPlus />
媒体库详情
</Link>
</Button>
) : (
<Button variant="outline" onClick={createEntity} disabled={entity.isPending}>
<FolderPlus />
{entity.isPending ? "建档中…" : "建档不订阅"}
</Button>
)}
</div>
</div>
</header>
<div className="space-y-5">
<InventoryPanel d={d} />
{d.entityType === "tv" ? <SeasonsPanel d={d} /> : null}
{d.aliases.length || d.externalIds.imdb || d.externalIds.tvdb ? (
<Panel title="别名与外部身份">
<p className="text-caption break-words text-muted-foreground">
{[
d.externalIds.imdb ? `IMDb ${d.externalIds.imdb}` : null,
d.externalIds.tvdb ? `TVDB ${d.externalIds.tvdb}` : null,
]
.filter(Boolean)
.join(" · ")}
</p>
{d.aliases.length ? (
<p className="mt-1 text-caption break-words text-foreground">{d.aliases.join("、")}</p>
) : null}
</Panel>
) : null}
</div>
{subscribing ? (
<SubscriptionFormDialog
initialTarget={
d.localEntityId
? { kind: "entity", id: d.localEntityId, title: d.title, year: d.year, mediaKind: kind }
: {
kind: "tmdb",
entityType: d.entityType,
externalId: d.externalId,
title: d.title,
year: d.year,
mediaKind: kind,
}
}
onClose={() => setSubscribing(false)}
onCreated={() => toast.success(`已订阅「${d.title}」`)}
/>
) : null}
{launcher.dialog}
</>
);
}
function InventoryPanel({ d }: { d: TmdbMediaDetail }) {
const ms = mediaServers.useList();
const hasEmby = (ms.data?.items.length ?? 0) > 0;
const emby = useMediaServerInventory({ tmdbType: d.entityType, tmdbId: d.externalId }, hasEmby);
const inv = d.inventory;
const line = slotLine(inv);
return (
<Panel title="库内对比" description="本地账本按 TMDB 身份对应;Emby 查询失败记为未知,不等于缺失">
<dl className="grid gap-4 sm:grid-cols-2">
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">本地账本</dt>
<dd className="mt-1 space-y-1 text-caption text-foreground">
{inv.state === "not_collected" ? (
<p>未收录。订阅或建档后开始追踪。</p>
) : (
<>
<p>
{inv.versionCount} 个版本已入库
{inv.downloadingCount ? ` · ${inv.downloadingCount} 个正在下载` : ""}
{inv.subscribed ? " · 已订阅" : ""}
</p>
{line ? <p className="text-muted-foreground">{line}</p> : null}
</>
)}
</dd>
</div>
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">Emby</dt>
<dd className="mt-1 text-caption text-foreground">
{!hasEmby ? (
<p className="text-muted-foreground">
没有配置媒体服务器。
<Link to={settingsPath("media-servers")} className="ml-1 underline underline-offset-4">
去配置
</Link>
</p>
) : emby.isPending ? (
<p className="text-muted-foreground">查询中…</p>
) : emby.isError ? (
<p className="text-warning">{errorMessage(emby.error, "查询失败")}</p>
) : (
<EmbyInstances ms={emby.data} />
)}
</dd>
</div>
</dl>
</Panel>
);
}
function EmbyInstances({ ms }: { ms: MediaServerInventory }) {
const s = mediaServerSummary(ms);
return (
<div className="space-y-1">
{s ? <StatusBadge tone={s.tone}>{s.label}</StatusBadge> : null}
<ul className="space-y-0.5">
{ms.instances.map((i) => (
<li key={i.instanceId} className="break-words text-muted-foreground">
{i.name}:
{i.status === "present"
? i.items.map((x) => `${x.name}${x.year ? ` (${x.year})` : ""}`).join("、") || "已有"
: i.status === "not_found"
? "没有"
: `未知${i.reason ? `(${i.reason})` : ""}`}
</li>
))}
</ul>
</div>
);
}
function SeasonsPanel({ d }: { d: TmdbMediaDetail }) {
if (d.seasons.length === 0) {
return (
<Panel title="季">
<EmptyState plain title="TMDB 还没有季信息" className="py-6" />
</Panel>
);
}
return (
<Panel title="季" description={d.localEntityId ? "本地一列是已入库集数" : "未收录,本地一列不显示"}>
<table className="w-full text-caption">
<thead>
<tr className="text-left text-meta text-muted-foreground">
<th className="py-1.5 pr-3 font-normal">季</th>
<th className="py-1.5 pr-3 font-normal">名称</th>
<th className="py-1.5 pr-3 font-normal">首播</th>
<th className="py-1.5 pr-3 text-right font-normal">集数</th>
{d.localEntityId ? <th className="py-1.5 text-right font-normal">本地</th> : null}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
{d.seasons.map((s) => (
<tr key={s.seasonNumber}>
<td className="py-1.5 pr-3 font-mono text-foreground">S{pad(s.seasonNumber)}</td>
<td className="py-1.5 pr-3 break-words text-foreground">{s.name ?? "—"}</td>
<td className="py-1.5 pr-3 text-muted-foreground tabular-nums">{s.airDate ?? "—"}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{s.episodeCount}</td>
{d.localEntityId ? (
<td className="py-1.5 text-right tabular-nums">
{s.localHave === null ? "—" : `${s.localHave}/${s.episodeCount}`}
</td>
) : null}
</tr>
))}
</tbody>
</table>
</Panel>
);
}
+55 -13
View File
@@ -1,43 +1,57 @@
import { Search } from "lucide-react"; import { ArrowLeft, Search } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react"; import { type FormEvent, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router"; import { Link, useNavigate, useSearchParams } from "react-router";
import { EmptyState } from "~/components/console/empty-state.tsx"; import { EmptyState } from "~/components/console/empty-state.tsx";
import { PageHeader } from "~/components/console/page-header.tsx"; import { PageHeader } from "~/components/console/page-header.tsx";
import { useQueryState } from "~/components/console/use-query-state.ts"; import { useQueryState } from "~/components/console/use-query-state.ts";
import { RELEASE_FILTER_KEYS, type ReleaseFilterState, ReleaseResults } from "~/components/search/release-results.tsx"; import {
import { ActionsSection, MediaSection } from "~/components/search/search-sections.tsx"; parseWorkRef,
RELEASE_FILTER_KEYS,
type ReleaseFilterState,
ReleaseResults,
} from "~/components/search/release-results.tsx";
import { ActionsSection, WorksSection } from "~/components/search/search-sections.tsx";
import { SitePickerDialog } from "~/components/search/site-picker-dialog.tsx";
import { releaseSearchPath } from "~/components/search/work-card.tsx";
import { Button } from "~/components/ui/button.tsx"; import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx"; import { Input } from "~/components/ui/input.tsx";
// 全局搜索结果页(W9-G):/console/search?q= 三段 —— 作品 / 资源 / 操作;q 与资源筛选都写在 URL 里。 // 全局搜索(SEARCH-2 两阶段):/console/search?q= 先列作品(本地 + TMDB,带库内状态)与操作,不查站点;
// 点「搜索资源」选站点后进第二阶段 ?q=&work=&in=&t=(work = local:<id> / tmdb:<type>:<id> / q:<关键词>,in = 站点 ID)。
export const meta = () => [{ title: "搜索 · UHD 媒体自动化控制台" }]; export const meta = () => [{ title: "搜索 · UHD 媒体自动化控制台" }];
export default function SearchPage() { export default function SearchPage() {
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const navigate = useNavigate();
const q = (params.get("q") ?? "").trim(); const q = (params.get("q") ?? "").trim();
const work = params.get("work") ?? "";
const title = params.get("t") ?? q;
const siteIds = params.get("in") ?? "";
const target = useMemo(() => (work ? parseWorkRef(work) : null), [work]);
const [input, setInput] = useState(q); const [input, setInput] = useState(q);
const [picking, setPicking] = useState(false);
useEffect(() => setInput(q), [q]); useEffect(() => setInput(q), [q]);
const f = useQueryState(RELEASE_FILTER_KEYS); const f = useQueryState(RELEASE_FILTER_KEYS);
const submit = (e: FormEvent) => { const submit = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
const next = input.trim(); const next = input.trim();
if (!next || next === q) return; if (!next || (next === q && !work)) return;
// 换关键词时筛选随之清空(站点/分辨率分面随结果变化) // 换关键词回到第一阶段,筛选随之清空
setParams(new URLSearchParams({ q: next })); setParams(new URLSearchParams({ q: next }));
}; };
return ( return (
<> <>
<PageHeader title="搜索" description="本地作品、TMDB、各站点资源和控制台页面。" /> <PageHeader title="搜索" description="先看作品和库内状态,需要时再到站点搜资源。" />
<form onSubmit={submit} className="mb-6 flex gap-2"> <form onSubmit={submit} className="mb-6 flex gap-2">
<div className="relative min-w-0 flex-1 sm:max-w-xl"> <div className="relative min-w-0 flex-1 sm:max-w-xl">
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
placeholder="作品名、别名或资源标题" placeholder="作品名、原名或别名"
aria-label="搜索关键词" aria-label="搜索关键词"
className="pl-9" className="pl-9"
/> />
@@ -48,11 +62,39 @@ export default function SearchPage() {
</form> </form>
{!q ? ( {!q ? (
<EmptyState icon={Search} title="输入关键词开始搜索" description="也可以在任意页面按 Ctrl/⌘ K 打开搜索。" /> <EmptyState icon={Search} title="输入作品名开始搜索" description="也可以在任意页面按 Ctrl/⌘ K 打开搜索。" />
) : target ? (
<div key={`${work}|${siteIds}`} className="space-y-4">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<Link
to={`/console/search?${new URLSearchParams({ q }).toString()}`}
className="inline-flex items-center gap-1 text-caption text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
返回作品
</Link>
<h2 className="min-w-0 text-body font-semibold break-words text-foreground">
{target.q ? `关键词「${target.q}」` : `「${title}」`}的资源
</h2>
</div>
<ReleaseResults
target={target}
siteIds={siteIds}
filters={f.values as ReleaseFilterState}
setFilter={f.set}
resetFilters={f.reset}
onChangeSites={() => setPicking(true)}
/>
<SitePickerDialog
open={picking}
onOpenChange={setPicking}
title={target.q ?? title}
onConfirm={(ids) => navigate(releaseSearchPath({ title, ref: work }, ids))}
/>
</div>
) : ( ) : (
<div key={q} className="space-y-8"> <div key={q} className="space-y-8">
<MediaSection q={q} /> <WorksSection q={q} />
<ReleaseResults q={q} filters={f.values as ReleaseFilterState} setFilter={f.set} resetFilters={f.reset} />
<ActionsSection q={q} /> <ActionsSection q={q} />
</div> </div>
)} )}
+8 -1
View File
@@ -1,10 +1,11 @@
import { Globe, LayoutGrid, List, Plus, RefreshCw } from "lucide-react"; import { Globe, LayoutGrid, List, PackageSearch, Plus, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
import { useSites } from "~/api/sites.ts"; import { useSites } from "~/api/sites.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterSelect } from "~/components/console/filter-bar.tsx"; import { FilterSelect } from "~/components/console/filter-bar.tsx";
import { PageHeader } from "~/components/console/page-header.tsx"; import { PageHeader } from "~/components/console/page-header.tsx";
import { SearchDefaultDialog } from "~/components/sites/search-default-dialog.tsx";
import { SiteAddDialog } from "~/components/sites/site-add-dialog.tsx"; import { SiteAddDialog } from "~/components/sites/site-add-dialog.tsx";
import { SiteCard, SiteTable } from "~/components/sites/site-card.tsx"; import { SiteCard, SiteTable } from "~/components/sites/site-card.tsx";
import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx"; import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx";
@@ -88,6 +89,7 @@ export default function SitesPage() {
}, [credFor, sites.data]); }, [credFor, sites.data]);
const all = sites.data?.items ?? []; const all = sites.data?.items ?? [];
const [searchDefaults, setSearchDefaults] = useState(false);
const attention = all.filter(needsAttention).length; const attention = all.filter(needsAttention).length;
const shown = sortSites( const shown = sortSites(
all.filter((s) => (filter === "enabled" ? s.enabled : filter === "attention" ? needsAttention(s) : true)), all.filter((s) => (filter === "enabled" ? s.enabled : filter === "attention" ? needsAttention(s) : true)),
@@ -105,6 +107,10 @@ export default function SitesPage() {
<RefreshCw className={sites.isFetching ? "animate-spin" : undefined} /> <RefreshCw className={sites.isFetching ? "animate-spin" : undefined} />
刷新 刷新
</Button> </Button>
<Button variant="outline" size="sm" onClick={() => setSearchDefaults(true)} disabled={!all.length}>
<PackageSearch />
搜索站点
</Button>
<Button size="sm" onClick={() => setAdding(true)}> <Button size="sm" onClick={() => setAdding(true)}>
<Plus /> <Plus />
添加站点 添加站点
@@ -181,6 +187,7 @@ export default function SitesPage() {
{adding ? <SiteAddDialog onClose={() => setAdding(false)} /> : null} {adding ? <SiteAddDialog onClose={() => setAdding(false)} /> : null}
<SiteDialogHost dialog={dialog} onClose={() => setDialog(null)} /> <SiteDialogHost dialog={dialog} onClose={() => setDialog(null)} />
<SearchDefaultDialog open={searchDefaults} onOpenChange={setSearchDefaults} sites={all} />
</> </>
); );
} }
+1
View File
@@ -164,3 +164,4 @@ NFO / 图片投影、身份巡检与挂载根因聚合、HTTP 失败自动切浏
- **短剧等 TMDB 缺失作品的自助补全(META-1,用户 2026-09-29)**:能力 `metadata.enrich`(propose):Brave 搜索 + 受限抓取(平台页/百科类)→ 模型抽字段(标题/别名/年份/平台/集数/简介/海报 URL)带来源与证据等级 → 写本地临时实体与字段级元数据(source=web/agent,可锁可改,不冒充 TMDB);触发:池条目"补全"、订阅/规则命中但 TMDB 无结果、Agent 委托;按归一化标题缓存、每日预算;后续 TMDB 收录由身份巡检补映射;不改路径不下载。DEC-1 之后实施。 - **短剧等 TMDB 缺失作品的自助补全(META-1,用户 2026-09-29)**:能力 `metadata.enrich`(propose):Brave 搜索 + 受限抓取(平台页/百科类)→ 模型抽字段(标题/别名/年份/平台/集数/简介/海报 URL)带来源与证据等级 → 写本地临时实体与字段级元数据(source=web/agent,可锁可改,不冒充 TMDB);触发:池条目"补全"、订阅/规则命中但 TMDB 无结果、Agent 委托;按归一化标题缓存、每日预算;后续 TMDB 收录由身份巡检补映射;不改路径不下载。DEC-1 之后实施。
- **Agent 快捷面板与会话排版(AGUI-2,用户 2026-09-29)**:抽屉内「当前/历史」Tab,历史列表可搜索筛选并在抽屉内打开(可续跑),"在页面打开"次要;会话页:阅读列宽、同回合分组、工具卡默认一行折叠、步骤清单改右侧栏/顶部折叠条不再 sticky 遮挡、预算在右栏底、长汇报折叠、输入区固定底部。 - **Agent 快捷面板与会话排版(AGUI-2,用户 2026-09-29)**:抽屉内「当前/历史」Tab,历史列表可搜索筛选并在抽屉内打开(可续跑),"在页面打开"次要;会话页:阅读列宽、同回合分组、工具卡默认一行折叠、步骤清单改右侧栏/顶部折叠条不再 sticky 遮挡、预算在右栏底、长汇报折叠、输入区固定底部。
- **凭据统一注入浏览器(BR-2,用户 2026-09-29)**:打开站点浏览器会话时服务端把账号 Cookie(手填或自动登录得到的)注入 camofox profile,截图/桌面模式无需再登录;交还/关闭/定时从浏览器回写 Cookie 到凭据存储,HTTP 抓取/RSS/快照共用;账号显示登录态来源与时间;Cookie 不进日志/模型。 - **凭据统一注入浏览器(BR-2,用户 2026-09-29)**:打开站点浏览器会话时服务端把账号 Cookie(手填或自动登录得到的)注入 camofox profile,截图/桌面模式无需再登录;交还/关闭/定时从浏览器回写 Cookie 到凭据存储,HTTP 抓取/RSS/快照共用;账号显示登录态来源与时间;Cookie 不进日志/模型。
- **站点详情页抓取(用户 2026-09-29,憨憨 details.php)**:`SiteReleaseDetail` 扩展简介/MediaInfo/截图(经图片代理)/IMDb·豆瓣·TMDB ID/文件清单/标签/上传者;只按需抓(点开详情或评估缺字段),kv_cache 24 小时;详情抽屉展示;IMDb/豆瓣 ID 作为强证据进身份解析(TMDB find)。
+21 -8
View File
@@ -1,18 +1,27 @@
# 全局搜索运行手册(W9-G) # 全局搜索运行手册(W9-G、SEARCH-2)
实现:`apps/server/src/modules/search/`(`routes.ts` 端点、`releases.ts` 跨站搜索/缓存/筛选/叠加、`media.ts` 本地作品与 TMDB、`actions.ts` 页面与操作清单、`dispatch.ts` 进程内转调);合同 `packages/contracts/src/search.ts`;测试 `apps/server/test/search/`。 实现:`apps/server/src/modules/search/`(`routes.ts` 端点、`releases.ts` 跨站搜索/缓存/筛选/叠加、`media.ts` 本地作品与 TMDB、`works.ts` 作品合并/库存对比/查询词、`actions.ts` 页面与操作清单、`dispatch.ts` 进程内转调);合同 `packages/contracts/src/search.ts`;测试 `apps/server/test/search/`(`search2.test.ts` 为两阶段)。
## 两阶段流程(SEARCH-2)
1. **作品**:`GET /search?scope=all|media` 只查本地账本 + TMDB,合并去重成 `media.works`(同一 TMDB 身份只出现一次,本地在前),每部带库存对比 `inventory`。**不请求任何站点**。
2. **资源**:用户在作品卡或作品详情页点「搜索资源」→ 选站点(默认 / 全部 / 指定,本机记住上次选择)→ `GET /search/releases?entityId=|tmdb=&sites=`,服务端生成查询词。
3. **订阅**只在作品详情页:本地作品 `/console/library/:id`,仅 TMDB 的 `/console/media/tmdb/:type/:id`。搜索结果与 ⌘K 面板没有订阅按钮;⌘K 选中作品打开详情页。
## 端点 ## 端点
| 端点 | scope | 说明 | | 端点 | scope | 说明 |
|---|---|---| |---|---|---|
| `GET /search?q=&scope=all\|media\|releases\|actions&limit=` | read | 聚合搜索。`limit` 是每组条数(1–50,默认 10)。不查的组返回 `null` | | `GET /search?q=&scope=all\|media\|releases\|actions&limit=` | read | 聚合搜索。`limit` 是每组条数(1–50,默认 10)。不查的组返回 `null`。`all` = 作品 + 操作,不查站点;资源只在 `scope=releases` 时查(站点按默认设置) |
| `GET /search/releases?q=&sites=&resolution=&source=&minSize=&maxSize=&free=&hr=&sort=&order=&refresh=&cursor=&limit=` | read | 资源结果页。与 `/search` 共用缓存 | | `GET /search/releases?q=\|entityId=\|tmdb=&sites=&siteFilter=&resolution=&source=&minSize=&maxSize=&free=&hr=&sort=&order=&refresh=&cursor=&limit=` | read | 资源结果页。`q` / `entityId` / `tmdb`(`movie:157336`)至少给一个,都没有 422。与 `/search` 共用缓存 |
| `GET /media/tmdb/{type}/{id}?include=mediaServers` | read | 仅 TMDB 作品详情:TMDB 信息与季列表、本地对比、是否已建档(`localEntityId`)、查询词;`include=mediaServers` 同时转调 Emby(最慢约 8s)。没配 TMDB 412,TMDB 没有该条目 404 |
| `GET /inventory/media-servers?mediaEntityId=\|tmdbType=&tmdbId=` | read | Emby 对比。按 TMDB 条目查时本地已建档就按作品查,否则只用这一个 TMDB ID,`mediaEntityId = null` |
| `POST /search/releases/{siteId}/{siteReleaseId}/plan {slotId?, entityId?, note?}` | organize.execute | 从搜索结果下载,走 `POST /acquisition-plans` 同一流程 | | `POST /search/releases/{siteId}/{siteReleaseId}/plan {slotId?, entityId?, note?}` | organize.execute | 从搜索结果下载,走 `POST /acquisition-plans` 同一流程 |
### `/search` 响应 ### `/search` 响应
- `media.local`:本地作品。召回方式有三种:`title_alias.normalized` 精确命中(score 1)、前缀命中(0.9)、trgm 相似度 ≥ 0.3;另外按作品名/原名前缀补召回没有别名的手建作品。每条带 `slotCounts`(版本目标各状态计数)、`versionCount`、`downloadingCount`、`subscribed`。 - `media.local`:本地作品。召回方式有三种:`title_alias.normalized` 精确命中(score 1)、前缀命中(0.9)、trgm 相似度 ≥ 0.3;另外按作品名/原名前缀补召回没有别名的手建作品。每条带 `slotCounts`(版本目标各状态计数)、`versionCount`、`downloadingCount`、`subscribed`。
- `media.works`:作品卡(SEARCH-2)。`key` 为 `local:<id>` 或 `tmdb:<type>:<id>`;海报 / 简介本地缺时用 TMDB 候选的。`inventory.state`:`not_collected` 未收录(本地没有)/ `tracked` 已建档但没有入库版本 / `owned` 已有版本 / `downloading` 正在下载;另有 `versionCount`、`slotTotal` / `slotSatisfied`、`missing`(未满足的版本目标)、`subscribed`。Emby 另查(未知 ≠ 缺失)。
- `media.tmdb`:TMDB 候选。`configured=false` 表示没配置 TMDB,不代表没有结果;查询失败时 `error` 有值、`items` 为空。已有本地作品接受了该 TMDB 身份时,`localEntityId` 给出本地作品 ID(合并过的作品跟到最终作品)。 - `media.tmdb`:TMDB 候选。`configured=false` 表示没配置 TMDB,不代表没有结果;查询失败时 `error` 有值、`items` 为空。已有本地作品接受了该 TMDB 身份时,`localEntityId` 给出本地作品 ID(合并过的作品跟到最终作品)。
- `releases`:跨站资源,只返回前 `limit` 条(按默认排序),`total` 是全部条数。覆盖信息见下文。 - `releases`:跨站资源,只返回前 `limit` 条(按默认排序),`total` 是全部条数。覆盖信息见下文。
- `actions`:页面与操作跳转的静态清单,按 IA-3 定稿导航分组(定时任务在任务页"计划"Tab;运行状态页路由确定后补)。标题前缀命中排在前面,关键词或路径包含查询词也算命中。 - `actions`:页面与操作跳转的静态清单,按 IA-3 定稿导航分组(定时任务在任务页"计划"Tab;运行状态页路由确定后补)。标题前缀命中排在前面,关键词或路径包含查询词也算命中。
@@ -20,12 +29,13 @@
## 跨站资源搜索 ## 跨站资源搜索
- **站点范围**:`enabled=true` 且未删除的站点,全部参与,`sites=` 筛选在拿到结果之后才做。不支持搜索或没有适配器定义的站点记为 `skipped`。 - **站点范围**:只查选中的站点(`enabled=true` 且未删除)。`sites=` 给了就只查这些;没给按 `settings.search.defaultSiteMode`:`default` = 站点「默认参与搜索」(`site.search_default`,迁移 0048,缺省 true)的站点,`all` = 全部启用站点,`custom` = `settings.search.siteIds` 里仍启用的站点。`siteFilter=` 只筛选结果、不改查询的站点(前端站点分面用)。不支持搜索或没有适配器定义的站点记为 `skipped`。
- **查询词**:给了 `q` 只用 `q`;按作品(`entityId` / `tmdb`)时由服务端生成,最多 2 个:原名(电影且原名是拉丁字母时带年份,如 `Interstellar 2014`)+ 译名(归一化后与原名不同才加);剧集不带年份。每个站点每个词查一次,同站结果按站点资源 ID 去重,响应 `queries` 给出实际查询词。
- **限流**:沿用 sources-identity 运行时里每个站点的共享限流器,和快照、工作台、详情、下载用的是同一个令牌桶。 - **限流**:沿用 sources-identity 运行时里每个站点的共享限流器,和快照、工作台、详情、下载用的是同一个令牌桶。
- **超时**:单站 `AbortSignal.timeout(15s)`,限流等待也算在内;超时记为 `timeout`。 - **超时**:单站 `AbortSignal.timeout(15s)`,限流等待也算在内;超时记为 `timeout`。
- **站点级并发**:同时最多搜索 4 个站点(`createSearchModule({ siteConcurrency })`)。 - **站点级并发**:同时最多搜索 4 个站点(`createSearchModule({ siteConcurrency })`)。
- **并发合并**:相同查询词(去首尾空白、小写、合并空格)同时只会发一轮站点请求,后到的请求等同一个结果,`coalesced=true`。 - **并发合并**:按(站点, 查询词)合并,查询词去首尾空白、小写、合并空格;同一对同时只会发一次请求,后到的等同一个结果,`coalesced=true`。
- **短缓存**:结果缓存 30s,命中时 `cached=true`。只要有一个站点成功就缓存;全部失败不缓存。`refresh=true` 跳过缓存,但仍然参与合并。进程内缓存,多个 api 实例之间不共享。 - **短缓存**:按(站点, 查询词)缓存 30s,换站点选择时已查过的站点直接复用;全部命中时 `cached=true`。失败 / 超时也缓存,但一轮里新查的站点全部失败时不缓存。`refresh=true` 跳过缓存,但仍然参与合并。进程内缓存,多个 api 实例之间不共享。
- **部分失败**:任一站点失败或超时,`partial=true`,`failedSites` 列出这些站点(status 为 `failed` 或 `timeout`,附已脱敏的错误)。其它站点的结果照常返回。`sites` 是每个站点的覆盖情况:status、count、tookMs、error。 - **部分失败**:任一站点失败或超时,`partial=true`,`failedSites` 列出这些站点(status 为 `failed` 或 `timeout`,附已脱敏的错误)。其它站点的结果照常返回。`sites` 是每个站点的覆盖情况:status、count、tookMs、error。
- **落库**:搜索结果按 site + 站点资源 ID upsert 到资源表(`source_release`),新资源照常发 `release.observed`,进入识别流水线,和工作台实时补查是同一条路径。`sourceReleaseId` 给站点工作台、下载、委托 Agent 用。落库失败只记警告,仍然返回结果,此时 `sourceReleaseId=null`。搜索不写 `site_snapshot`,也不改站点健康状态。 - **落库**:搜索结果按 site + 站点资源 ID upsert 到资源表(`source_release`),新资源照常发 `release.observed`,进入识别流水线,和工作台实时补查是同一条路径。`sourceReleaseId` 给站点工作台、下载、委托 Agent 用。落库失败只记警告,仍然返回结果,此时 `sourceReleaseId=null`。搜索不写 `site_snapshot`,也不改站点健康状态。
@@ -57,7 +67,10 @@
## 前端要点 ## 前端要点
- 命令面板用 `scope=media` 和 `scope=actions` 快速出结果(毫秒级,TMDB 首次查询约 1 秒)。资源组单独请求 `scope=releases`,或者直接跳到结果页 `/console/search?q=`。最慢的站点可能要等到 15s 超时。 - 搜索页 `/console/search?q=` 第一阶段:作品卡(海报 / 年份 / 类型 / 简介 + 库内状态;有媒体服务器实例时卡上另查 Emby)与操作。「按关键词搜资源」用原关键词搜站点(资源标题类查询)。
- 第二阶段 `/console/search?q=&work=&in=&t=`:`work` = `local:<id>` / `tmdb:<type>:<id>` / `q:<关键词>`,`in` = 选中的站点 ID,`t` = 显示的作品名。站点选择弹层的初始值 = 本机上次选择(localStorage `umt.search.sitePick`)> `settings.search`;站点页「搜索站点」改全局默认,站点卡 / 编辑里改「默认参与搜索」。
- 命令面板用 `scope=media` 和 `scope=actions` 快速出结果(毫秒级,TMDB 首次查询约 1 秒);选中作品打开详情页,最后一项进搜索页。面板不查站点、不订阅。
- 作品详情页有「订阅」(订阅表单预填作品;TMDB 条目保存时隐式建档)、「搜索资源」、「建档不订阅」(仅 TMDB 的作品)。
- 资源行的操作和站点工作台一致。`sourceReleaseId` 可以直接给工作台详情和委托 Agent 用;下载用 `POST /search/releases/{siteId}/{siteReleaseId}/plan`。 - 资源行的操作和站点工作台一致。`sourceReleaseId` 可以直接给工作台详情和委托 Agent 用;下载用 `POST /search/releases/{siteId}/{siteReleaseId}/plan`。
- `partial=true` 时列出 `failedSites`,提示哪些站点没有结果。`cached` / `fetchedAt` 用来标注数据时间,"刷新"按钮带 `refresh=true`。 - `partial=true` 时列出 `failedSites`,提示哪些站点没有结果。`cached` / `fetchedAt` 用来标注数据时间,"刷新"按钮带 `refresh=true`。
+5
View File
@@ -65,6 +65,11 @@
注:定时任务 key 只允许小写(注册表校验),所以规格里的 `sites.userData` / `sites.healthCheck` 落为下划线形式。 注:定时任务 key 只允许小写(注册表校验),所以规格里的 `sites.userData` / `sites.healthCheck` 落为下划线形式。
## 默认参与搜索(SEARCH-2)
`site.search_default`(迁移 0048,缺省 true;`POST/PATCH /sites` 的 `searchDefault`):搜资源时「默认站点」集合 = 启用且打开此项的站点。站点卡底部和编辑面板都有开关。
全局默认范围在 `settings.search`(站点页「搜索站点」):`defaultSiteMode = default | all | custom`,`custom` 时 `siteIds` 生效;`GET /search/releases` 不带 `sites` 时按它选站点。详见 `docs/runbook/search.md`。
## 站点默认(`settings.sites`) ## 站点默认(`settings.sites`)
| 字段 | 缺省 | 说明 | | 字段 | 缺省 | 说明 |
+5
View File
@@ -6,6 +6,11 @@
前端:`apps/web/app/routes/console/subscriptions.tsx`(`?view=calendar`)、`components/subscriptions/{calendar.tsx, actions.tsx, subscription-form.tsx, model.ts}`。 前端:`apps/web/app/routes/console/subscriptions.tsx`(`?view=calendar`)、`components/subscriptions/{calendar.tsx, actions.tsx, subscription-form.tsx, model.ts}`。
设计:`docs/design/subscriptions.md` §1–§3、§5;规则组见 `docs/runbook/rules.md`;W5-C 账本/完成/搜索见 `docs/plan/05-subscription-gap.md` §4。 设计:`docs/design/subscriptions.md` §1–§3、§5;规则组见 `docs/runbook/rules.md`;W5-C 账本/完成/搜索见 `docs/plan/05-subscription-gap.md` §4。
## 入口(SEARCH-2)
订阅只从作品详情页发起:本地作品 `/console/library/:id`、仅 TMDB 的 `/console/media/tmdb/:type/:id`,「订阅」打开订阅表单并预填作品(`SubscriptionFormDialog initialTarget`)。
TMDB 条目保存时先调 `POST /files/entity-from-candidate` 建档(同一 TMDB id 复用),再按作品建订阅。搜索结果与 ⌘K 面板不再有订阅按钮;订阅页「新建订阅」仍可搜作品。
## 数据 ## 数据
| 列 | 含义 | | 列 | 含义 |
+10
View File
@@ -2186,6 +2186,16 @@ export const Api = {
body: search.SearchReleasePlanRequest, body: search.SearchReleasePlanRequest,
response: execution.ManualAcquisitionResult, response: execution.ManualAcquisitionResult,
}), }),
tmdbDetail: ep({
method: "get",
path: "/media/tmdb/{type}/{id}",
scope: "read",
summary:
"仅 TMDB 作品的详情:TMDB 信息与季列表、本地库存对比、是否已建档、搜资源的查询词;include=mediaServers 同时查 Emby",
params: search.TmdbMediaParams,
query: search.TmdbMediaQuery,
response: search.TmdbMediaDetail,
}),
}, },
// ── 浏览器(C1-E):站点浏览器会话、原站视图、人工接管;写动作需人工持有控制权(接管) ── // ── 浏览器(C1-E):站点浏览器会话、原站视图、人工接管;写动作需人工持有控制权(接管) ──
+18 -2
View File
@@ -428,11 +428,27 @@ export const InventoryResponse = z.object({ items: z.array(InventoryEntry) });
export type InventoryResponse = z.infer<typeof InventoryResponse>; export type InventoryResponse = z.infer<typeof InventoryResponse>;
/** Emby 兼容库存:按实例分别给出 present / not_found / unknown(未知 ≠ 缺失) */ /** Emby 兼容库存:按实例分别给出 present / not_found / unknown(未知 ≠ 缺失) */
export const MediaServerInventoryQuery = z.object({ mediaEntityId: Id }); /**
* 按本地作品(用它已接受的外部身份)或按 TMDB 条目(还没建档的作品,SEARCH-2 搜索页/详情页)查 Emby。
* 两者给其一;都给时以 mediaEntityId 为准。
*/
export const MediaServerInventoryQuery = z
.object({
mediaEntityId: Id.optional(),
tmdbType: z.enum(["movie", "tv"]).optional(),
tmdbId: z
.string()
.regex(/^\d{1,12}$/)
.optional(),
})
.refine((q) => !!q.mediaEntityId || (!!q.tmdbType && !!q.tmdbId), {
message: "mediaEntityId 或 tmdbType + tmdbId 必须给一个",
});
export type MediaServerInventoryQuery = z.infer<typeof MediaServerInventoryQuery>; export type MediaServerInventoryQuery = z.infer<typeof MediaServerInventoryQuery>;
export const MediaServerInventory = z.object({ export const MediaServerInventory = z.object({
mediaEntityId: Id, /** 按 TMDB 条目查且本地没有建档时为 null */
mediaEntityId: Id.nullable(),
instances: z.array( instances: z.array(
z.object({ z.object({
instanceId: Id, instanceId: Id,
+114 -4
View File
@@ -2,17 +2,19 @@ import { z } from "zod";
import { Id, NonNegInt, Timestamp } from "./common.ts"; import { Id, NonNegInt, Timestamp } from "./common.ts";
import { MediaEntityStatus, MediaKind } from "./content.ts"; import { MediaEntityStatus, MediaKind } from "./content.ts";
import { ManualAcquisitionRequest } from "./execution.ts"; import { ManualAcquisitionRequest } from "./execution.ts";
import { InventoryEntry, SlotStatusCounts } from "./organization.ts"; import { InventoryEntry, MediaServerInventory, SlotStatusCounts } from "./organization.ts";
// 全局搜索(W9-G,对标 MP 顶栏搜索 + 资源搜索页): // 全局搜索(W9-G,对标 MP 顶栏搜索 + 资源搜索页):
// GET /search 聚合本地作品 / TMDB 候选 / 跨站资源 / 页面与操作;GET /search/releases 是资源结果页(筛选/排序/游标,复用同一缓存)。 // GET /search 聚合本地作品 / TMDB 候选 / 跨站资源 / 页面与操作;GET /search/releases 是资源结果页(筛选/排序/游标,复用同一缓存)。
// SEARCH-2 两阶段:先查作品并与库存对比(scope=all 不再查站点),需要时再按作品显式搜资源(站点可选);
// 仅 TMDB 的作品详情走 GET /media/tmdb/{type}/{id}。
export const SearchScope = z.enum(["all", "media", "releases", "actions"]); export const SearchScope = z.enum(["all", "media", "releases", "actions"]);
export type SearchScope = z.infer<typeof SearchScope>; export type SearchScope = z.infer<typeof SearchScope>;
export const SearchQuery = z.object({ export const SearchQuery = z.object({
q: z.string().trim().min(1).max(200), q: z.string().trim().min(1).max(200),
/** all = 四组都查;命令面板先查 media/actions(快),资源组单独查(最慢一站可达 15s) */ /** all = 作品 + 操作(不查站点);资源只在 scope=releases 或 /search/releases 显式查询(最慢一站可达 15s) */
scope: SearchScope.default("all"), scope: SearchScope.default("all"),
/** 每组最多返回条数 */ /** 每组最多返回条数 */
limit: z.coerce.number().int().min(1).max(50).default(10), limit: z.coerce.number().int().min(1).max(50).default(10),
@@ -171,6 +173,47 @@ export type SearchAction = z.infer<typeof SearchAction>;
export const SearchGroupBase = z.object({ tookMs: NonNegInt }); export const SearchGroupBase = z.object({ tookMs: NonNegInt });
// ── 作品(本地 + TMDB 合并去重,本地优先)与库存对比 ──
/**
* 库内状态:not_collected 未收录(本地没有这部作品)> tracked 已建档但没有入库版本 > owned 已有版本 > downloading 正在下载。
* 只看本地账本;Emby 另查 GET /inventory/media-servers(未知 ≠ 缺失)。
*/
export const WorkInventoryState = z.enum(["not_collected", "tracked", "owned", "downloading"]);
export type WorkInventoryState = z.infer<typeof WorkInventoryState>;
export const WorkInventory = z.object({
state: WorkInventoryState,
subscribed: z.boolean(),
/** 已入库版本数(主视频资产) */
versionCount: NonNegInt,
downloadingCount: NonNegInt,
/** 版本目标:总数 / 已满足 */
slotTotal: NonNegInt,
slotSatisfied: NonNegInt,
/** 未满足的版本目标(empty / attention / upgrade_pending) */
missing: z.array(z.object({ slotId: Id, name: z.string() })),
});
export type WorkInventory = z.infer<typeof WorkInventory>;
export const SearchWork = z.object({
/** local:<作品 ID> 或 tmdb:<movie|tv>:<TMDB ID> */
key: z.string(),
source: z.enum(["local", "tmdb"]),
kind: MediaKind,
title: z.string(),
originalTitle: z.string().nullable(),
year: z.number().int().nullable(),
/** 简介摘要(≤ 300 字) */
overview: z.string().nullable(),
posterPath: z.string().nullable(),
/** 本地作品 ID(合并过的跟到最终作品);null = 未收录 */
localEntityId: Id.nullable(),
tmdb: z.object({ entityType: z.enum(["movie", "tv"]), externalId: z.string() }).nullable(),
inventory: WorkInventory,
});
export type SearchWork = z.infer<typeof SearchWork>;
export const SearchResponse = z.object({ export const SearchResponse = z.object({
q: z.string(), q: z.string(),
scope: SearchScope, scope: SearchScope,
@@ -183,6 +226,8 @@ export const SearchResponse = z.object({
configured: z.boolean(), configured: z.boolean(),
error: z.string().nullable(), error: z.string().nullable(),
}), }),
/** 本地 + TMDB 合并去重(同一 TMDB 身份只出现一次,本地在前),带库存对比 */
works: z.array(SearchWork),
}) })
.nullable(), .nullable(),
releases: SearchGroupBase.extend({ releases: SearchGroupBase.extend({
@@ -206,9 +251,19 @@ export const ReleaseSearchSort = z.enum(["default", "seeders", "size", "uploaded
export type ReleaseSearchSort = z.infer<typeof ReleaseSearchSort>; export type ReleaseSearchSort = z.infer<typeof ReleaseSearchSort>;
export const ReleaseSearchQuery = z.object({ export const ReleaseSearchQuery = z.object({
q: z.string().trim().min(1).max(200), /** 关键词;按作品搜时可省略,由服务端按作品生成查询词 */
/** 逗号分隔的站点 ID */ q: z.string().trim().min(1).max(200).optional(),
/** 按本地作品搜:用作品名 / 原名 / 年份生成查询词 */
entityId: Id.optional(),
/** 按 TMDB 条目搜:`movie:157336` / `tv:1399` */
tmdb: z
.string()
.regex(/^(movie|tv):\d{1,12}$/)
.optional(),
/** 逗号分隔的站点 ID:只查这些站点;省略时按 settings.search.defaultSiteMode */
sites: csv, sites: csv,
/** 逗号分隔的站点 ID:只筛选结果(仍查 sites 里的全部站点,分面计数不变) */
siteFilter: csv,
/** 逗号分隔:2160p,1080p… */ /** 逗号分隔:2160p,1080p… */
resolution: csv, resolution: csv,
/** 逗号分隔:REMUX,WEB-DL,BluRay… */ /** 逗号分隔:REMUX,WEB-DL,BluRay… */
@@ -236,6 +291,8 @@ export type FacetCount = z.infer<typeof FacetCount>;
export const ReleaseSearchPage = z export const ReleaseSearchPage = z
.object({ .object({
q: z.string(), q: z.string(),
/** 实际发给站点的查询词(按作品搜时由服务端生成,每个站点每个词查一次) */
queries: z.array(z.string()).optional(),
items: z.array(SearchRelease), items: z.array(SearchRelease),
nextCursor: z.string().nullable(), nextCursor: z.string().nullable(),
/** 筛选后的条数 */ /** 筛选后的条数 */
@@ -259,3 +316,56 @@ export type SearchReleaseParams = z.infer<typeof SearchReleaseParams>;
/** 从搜索结果直接下载:与 POST /acquisition-plans 同一请求体,资源由路径定位 */ /** 从搜索结果直接下载:与 POST /acquisition-plans 同一请求体,资源由路径定位 */
export const SearchReleasePlanRequest = ManualAcquisitionRequest.omit({ sourceReleaseId: true }); export const SearchReleasePlanRequest = ManualAcquisitionRequest.omit({ sourceReleaseId: true });
export type SearchReleasePlanRequest = z.infer<typeof SearchReleasePlanRequest>; export type SearchReleasePlanRequest = z.infer<typeof SearchReleasePlanRequest>;
// ── 仅 TMDB 作品的详情(SEARCH-2) ──
export const TmdbMediaParams = z.object({
type: z.enum(["movie", "tv"]),
id: z.string().regex(/^\d{1,12}$/),
});
export type TmdbMediaParams = z.infer<typeof TmdbMediaParams>;
export const TmdbMediaQuery = z.object({
/** mediaServers = 同时转调 GET /inventory/media-servers(最慢约 8s) */
include: z.enum(["mediaServers"]).optional(),
});
export type TmdbMediaQuery = z.infer<typeof TmdbMediaQuery>;
export const TmdbMediaSeason = z.object({
seasonNumber: z.number().int(),
episodeCount: NonNegInt,
name: z.string().nullable(),
airDate: z.string().nullable(),
/** 本地已入库的集数(整季资产按全季计);未收录为 null */
localHave: NonNegInt.nullable(),
});
export type TmdbMediaSeason = z.infer<typeof TmdbMediaSeason>;
export const TmdbMediaDetail = z.object({
provider: z.literal("tmdb"),
entityType: z.enum(["movie", "tv"]),
externalId: z.string(),
kind: MediaKind,
title: z.string(),
originalTitle: z.string().nullable(),
originalLanguage: z.string().nullable(),
year: z.number().int().nullable(),
releaseDate: z.string().nullable(),
overview: z.string().nullable(),
posterPath: z.string().nullable(),
backdropPath: z.string().nullable(),
originCountry: z.array(z.string()),
/** 别名 / 译名(最多 20 个) */
aliases: z.array(z.string()),
externalIds: z.object({ imdb: z.string().nullable(), tvdb: z.string().nullable() }),
seasons: z.array(TmdbMediaSeason),
/** 已建档:本地作品 ID(跟随合并) */
localEntityId: Id.nullable(),
/** 本地库存对比;未收录时 state = not_collected */
inventory: WorkInventory,
/** include=mediaServers 时给出;未知 ≠ 缺失 */
mediaServers: MediaServerInventory.nullable(),
/** 搜资源时服务端会用的查询词 */
queries: z.array(z.string()),
});
export type TmdbMediaDetail = z.infer<typeof TmdbMediaDetail>;
+19
View File
@@ -357,6 +357,19 @@ export const BudgetSettings = z.object({
}); });
export type BudgetSettings = z.infer<typeof BudgetSettings>; export type BudgetSettings = z.infer<typeof BudgetSettings>;
/**
* 资源搜索(SEARCH-2):GET /search/releases 不带 sites 时参与搜索的站点。
* default = 启用且「默认参与搜索」的站点;all = 全部启用站点;custom = siteIds 里仍启用的站点。
*/
export const SearchSiteMode = z.enum(["default", "all", "custom"]);
export type SearchSiteMode = z.infer<typeof SearchSiteMode>;
export const SearchSettings = z.object({
defaultSiteMode: SearchSiteMode,
/** custom 时的站点 ID */
siteIds: z.array(z.uuid()).max(200),
});
export type SearchSettings = z.infer<typeof SearchSettings>;
/** /**
* 身份识别(DEC-1 惰性识别,对照 MP:RSS / 列表资源只在匹配订阅或用户搜索时识别)。 * 身份识别(DEC-1 惰性识别,对照 MP:RSS / 列表资源只在匹配订阅或用户搜索时识别)。
* eagerTmdbLookup = true 恢复旧行为:每条新观察到的资源都查 TMDB。每条资源约 1–2 次搜索 + 命中时 1 次详情 * eagerTmdbLookup = true 恢复旧行为:每条新观察到的资源都查 TMDB。每条资源约 1–2 次搜索 + 命中时 1 次详情
@@ -382,6 +395,7 @@ export const GlobalSettings = z.object({
sites: SitesSettings, sites: SitesSettings,
naming: NamingSettings, naming: NamingSettings,
identity: IdentitySettings, identity: IdentitySettings,
search: SearchSettings,
}); });
export type GlobalSettings = z.infer<typeof GlobalSettings>; export type GlobalSettings = z.infer<typeof GlobalSettings>;
@@ -453,6 +467,7 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
}, },
naming: { ...DEFAULT_NAMING_TEMPLATES, lockFileNameForBound: false }, naming: { ...DEFAULT_NAMING_TEMPLATES, lockFileNameForBound: false },
identity: { eagerTmdbLookup: false, poolArchiveDays: 30 }, identity: { eagerTmdbLookup: false, poolArchiveDays: 30 },
search: { defaultSiteMode: "default", siteIds: [] },
}; };
/** PATCH /settings:按分组部分更新;服务端与现值合并后用 GlobalSettings 整体再校验 */ /** PATCH /settings:按分组部分更新;服务端与现值合并后用 GlobalSettings 整体再校验 */
@@ -502,6 +517,7 @@ export const GlobalSettingsUpdate = z.object({
sites: SitesSettings.partial().optional(), sites: SitesSettings.partial().optional(),
naming: NamingSettings.partial().optional(), naming: NamingSettings.partial().optional(),
identity: IdentitySettings.partial().optional(), identity: IdentitySettings.partial().optional(),
search: SearchSettings.partial().optional(),
}); });
export type GlobalSettingsUpdate = z.infer<typeof GlobalSettingsUpdate>; export type GlobalSettingsUpdate = z.infer<typeof GlobalSettingsUpdate>;
@@ -527,6 +543,7 @@ export function mergeSettings(current: GlobalSettings, patch: GlobalSettingsUpda
sites: { ...(current.sites ?? DEFAULT_SETTINGS.sites), ...patch.sites }, sites: { ...(current.sites ?? DEFAULT_SETTINGS.sites), ...patch.sites },
naming: { ...(current.naming ?? DEFAULT_SETTINGS.naming), ...patch.naming }, naming: { ...(current.naming ?? DEFAULT_SETTINGS.naming), ...patch.naming },
identity: { ...(current.identity ?? DEFAULT_SETTINGS.identity), ...patch.identity }, identity: { ...(current.identity ?? DEFAULT_SETTINGS.identity), ...patch.identity },
search: { ...(current.search ?? DEFAULT_SETTINGS.search), ...patch.search },
}; };
} }
@@ -638,6 +655,8 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
"sites.userAgent", "sites.userAgent",
"sites.proxyUrl", "sites.proxyUrl",
"sites.userDataMaxSeedingPages", "sites.userDataMaxSeedingPages",
"search.defaultSiteMode",
"search.siteIds",
], ],
resources: [], resources: [],
}, },
+4
View File
@@ -50,6 +50,8 @@ export const Site = EntityBase.extend({
lastHealthAt: Timestamp.nullable(), lastHealthAt: Timestamp.nullable(),
/** 使用全局代理(settings.sites.proxyUrl) */ /** 使用全局代理(settings.sites.proxyUrl) */
proxy: z.boolean(), proxy: z.boolean(),
/** 默认参与资源搜索(站点选择弹层的"默认"集合) */
searchDefault: z.boolean(),
}); });
export type Site = z.infer<typeof Site>; export type Site = z.infer<typeof Site>;
export const SiteCreate = Site.pick({ export const SiteCreate = Site.pick({
@@ -63,6 +65,7 @@ export const SiteCreate = Site.pick({
snapshotEnabled: true, snapshotEnabled: true,
snapshotIntervalSec: true, snapshotIntervalSec: true,
proxy: true, proxy: true,
searchDefault: true,
}).partial({ }).partial({
enabled: true, enabled: true,
rateLimit: true, rateLimit: true,
@@ -70,6 +73,7 @@ export const SiteCreate = Site.pick({
snapshotEnabled: true, snapshotEnabled: true,
snapshotIntervalSec: true, snapshotIntervalSec: true,
proxy: true, proxy: true,
searchDefault: true,
}); });
export type SiteCreate = z.infer<typeof SiteCreate>; export type SiteCreate = z.infer<typeof SiteCreate>;
export const SiteUpdate = SiteCreate.omit({ key: true }).partial(); export const SiteUpdate = SiteCreate.omit({ key: true }).partial();
+1 -1
View File
@@ -58,7 +58,7 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
DownloadTask: { row: C.DownloadTask }, DownloadTask: { row: C.DownloadTask },
DownloaderInstance: { row: C.DownloaderInstance }, DownloaderInstance: { row: C.DownloaderInstance },
ExternalIdentity: { row: C.ExternalIdentity, apiOnly: ["mediaEntityTitle"] }, ExternalIdentity: { row: C.ExternalIdentity, apiOnly: ["mediaEntityTitle"] },
FeedSource: { row: C.FeedSource, apiOnly: ["possibleGap"] }, FeedSource: { row: C.FeedSource, apiOnly: ["possibleGap", "warnings"] },
IdentityRevision: { row: C.IdentityRevision }, IdentityRevision: { row: C.IdentityRevision },
LibraryRoot: { row: C.LibraryRoot }, LibraryRoot: { row: C.LibraryRoot },
MediaAsset: { row: C.MediaAsset }, MediaAsset: { row: C.MediaAsset },
+1
View File
@@ -37,6 +37,7 @@ const STRING_CANDIDATES = [
"30 3 * * *", "30 3 * * *",
"ALL", "ALL",
"zh-hans", "zh-hans",
"movie:157336",
]; ];
const NUMBER_CANDIDATES = [1, 50, 2024, 0.5, 0, 4096, 128000]; const NUMBER_CANDIDATES = [1, 50, 2024, 0.5, 0, 4096, 128000];
@@ -0,0 +1,15 @@
import type { Kysely } from "kysely";
import { execDdl } from "../src/migration-helpers.ts";
// SEARCH-2:站点"默认参与搜索"。搜索资源时站点选择弹层的"默认"集合 = 启用且 search_default 的站点;
// 缺省 true,存量站点行为不变(原先全部启用站点都参与)。
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(db, `alter table site add column search_default boolean not null default true;`);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
await execDdl(db, `alter table site drop column if exists search_default;`);
}
+1
View File
@@ -1359,6 +1359,7 @@ export interface SiteTable {
last_health_at: Timestamptz | null; last_health_at: Timestamptz | null;
proxy: Generated<boolean>; proxy: Generated<boolean>;
deleted_at: Timestamptz | null; deleted_at: Timestamptz | null;
search_default: Generated<boolean>;
} }
export interface SiteAccountTable { export interface SiteAccountTable {