docs(plan): on-demand site detail page parsing (HHClub details.php)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
94fd48bff2
commit
ac478673bc
39 files changed
+2446
-339
No files matched your search
@@ -476,25 +476,43 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
response: MediaServerInventory,
|
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 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>> {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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")}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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`。
|
||||||
|
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|
||||||
| 字段 | 缺省 | 说明 |
|
| 字段 | 缺省 | 说明 |
|
||||||
|
|||||||
@@ -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 面板不再有订阅按钮;订阅页「新建订阅」仍可搜作品。
|
||||||
|
|
||||||
## 数据
|
## 数据
|
||||||
|
|
||||||
| 列 | 含义 |
|
| 列 | 含义 |
|
||||||
|
|||||||
@@ -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):站点浏览器会话、原站视图、人工接管;写动作需人工持有控制权(接管) ──
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -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: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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;`);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user