feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)

生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-02 15:47:17 +08:00
1 parent 8013196e3f
commit ceb66f8c77
265 files changed
+17751 -2483

No files matched your search

@@ -142,14 +142,13 @@ describe.skipIf(!up)("浏览器 HTTP 端点", () => {
expect(d.artifact.sourceUrl).not.toContain(PASSKEY);
expect(d.verification).toMatchObject({ valid: true, infohashV1: h.torrent.infoHash, private: true });
expect(d.sourceReleaseId).toBe(h.releaseId);
// 资源未识别:与 POST /acquisition-plans 相同的拒绝,产物照常返回
expect(d.submission).toBeNull();
expect(d.note).toContain("提交未通过");
// 资源未识别:与 POST /acquisition-plans 相同的确认结果,产物照常返回
expect(d.submission).toMatchObject({ submitted: false, requiresConfirmation: true });
const files = await readdir(h.stagingDir);
expect(files).toHaveLength(1);
expect(new Uint8Array(await readFile(join(h.stagingDir, files[0]!)))).toEqual(h.torrent.bytes);
// 识别到作品后再下载:走同一流程(无收录版本 → no_slot);同一文件第二次捕获标 duplicate、不提交
// 识别到作品后再下载:同一文件第二次捕获标 duplicate、不提交
const entity = await h.db
.insertInto("media_entity")
.values({ kind: "movie", title: "奥本海默", year: 2023, status: "active" })
@@ -0,0 +1,74 @@
import { getSiteDefinition } from "@umt/adapters";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { resourceIdFromUrl } from "../resource.ts";
import { type BrowserHarness, createBrowserHarness, ORIGIN, pgAvailable, READ_KEY } from "./harness.ts";
const def = getSiteDefinition("audiences")!;
describe("原站资源详情定位", () => {
it("仅按本站详情路径定位,不把列表、登录或别站同 id 当作资源", () => {
expect(resourceIdFromUrl(`${ORIGIN}/details.php?id=73`, `${ORIGIN}/`, def)).toBe("73");
expect(resourceIdFromUrl(`${ORIGIN}/torrents.php?id=73`, `${ORIGIN}/`, def)).toBeNull();
expect(resourceIdFromUrl(`${ORIGIN}/login.php?id=73`, `${ORIGIN}/`, def)).toBeNull();
expect(resourceIdFromUrl("https://else.example/details.php?id=73", `${ORIGIN}/`, def)).toBeNull();
});
});
const up = await pgAvailable();
describe.skipIf(!up)("原站详情页交接(隔离 PG 与浏览器 fixture)", () => {
let h: BrowserHarness;
let sid: string;
beforeAll(async () => {
h = await createBrowserHarness("resource", {
html: () => `<!doctype html><html><head><title>资源详情</title></head><body>
<h1 id="top">Example.Movie.2024.2160p.WEB-DL.DV</h1>
<table><tr><td class="rowhead">副标题</td><td class="rowfollow">测试译名与简介</td></tr>
<tr><td class="rowhead">基本信息</td><td class="rowfollow">大小: 2 GB 类型: 电影</td></tr></table>
<div id="kdescr">详情页提供的影片简介 <a href="https://www.imdb.com/title/tt1234567/">IMDb</a></div>
</body></html>`,
});
h.mock.loggedIn.add(`umt-site-${h.siteId}-acct-${h.accountId}`);
const session = await h.req("/browser/sessions", {
body: { siteId: h.siteId, url: `${ORIGIN}/details.php?id=73` },
});
expect(session.status).toBe(200);
sid = session.body.data.id;
}, 120_000);
afterAll(async () => {
await h?.close();
});
it("未在快照/搜索中的详情可直接登记,缓存PT内容供元数据兜底,不点击原站下载按钮", async () => {
const before = h.mock.actions.length;
const r = await h.req(`/browser/sessions/${sid}/resource`, { body: {} });
expect(r.status, JSON.stringify(r.body)).toBe(200);
expect(r.body.data.release).toMatchObject({
siteId: h.siteId,
siteReleaseId: "73",
title: "Example.Movie.2024.2160p.WEB-DL.DV",
});
expect(r.body.data.detailUrl).toBe(`${ORIGIN}/details.php?id=73`);
const row = await h.db
.selectFrom("source_release")
.selectAll()
.where("id", "=", r.body.data.release.id)
.executeTakeFirstOrThrow();
expect(row.detail_fetched_at).not.toBeNull();
expect(row.raw).toMatchObject({ _detail: { imdbId: "tt1234567", sourceUrl: `${ORIGIN}/details.php?id=73` } });
expect(h.mock.actions.slice(before).some((a) => a.action === "click")).toBe(false);
const again = await h.req(`/browser/sessions/${sid}/resource`, { body: {} });
expect(again.body.data.release.id).toBe(row.id);
});
it("使用当前页面而非旧会话地址;列表页明确提示进入详情", async () => {
const tab = [...h.mock.tabs.values()][0]!;
tab.url = `${ORIGIN}/torrents.php`;
const r = await h.req(`/browser/sessions/${sid}/resource`, { body: {} });
expect(r.status).toBe(400);
expect(r.body.error.message).toContain("详情页");
});
it("只读账号不能通过该入口登记下载资源", async () => {
const r = await h.req(`/browser/sessions/${sid}/resource`, { body: {}, key: READ_KEY });
expect(r.status).toBe(403);
});
});
@@ -0,0 +1,85 @@
import {
getSiteDefinition,
type IndexerDefinition,
parseDetailPage,
type SiteDefinition,
type SiteReleaseDetail,
} from "@umt/adapters";
import type { BrowserResourceResult } from "@umt/contracts";
import { sql } from "@umt/db";
import { saveDetailPage } from "../sources-identity/service/detail-page.ts";
import { mergeDetailOverList, upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
import { releaseDetail } from "../sources-identity/service/workbench.ts";
import { type Actor, type BrowserService, BrowserServiceError } from "./service.ts";
/** 详情页定位遵循站点定义;列表、登录等页面不能作为资源提交。 */
export function resourceIdFromUrl(value: string, baseUrl: string, def: SiteDefinition): string | null {
const url = new URL(value);
const base = new URL(baseUrl);
if (url.origin !== base.origin) return null;
if (def.umt.kind === "nexusphp") {
const expected = new URL(def.umt.detailPath, base);
if (url.pathname !== expected.pathname) return null;
const id = url.searchParams.get("id");
return id && /^\d{1,20}$/.test(id) ? id : null;
}
const match = /^\/detail\/(\d{1,20})\/?$/.exec(url.pathname);
return match?.[1] ?? null;
}
export async function currentResource(
service: BrowserService,
sessionId: string,
actor: Actor,
tabId?: string,
): Promise<BrowserResourceResult> {
const runtime = service.opts.runtime?.(service.ctx);
if (!runtime) throw new BrowserServiceError("precondition", "站点详情解析不可用");
const current = await service.run(
sessionId,
actor,
async (provider, ref, context) => {
const page = provider.pageContent ? await provider.pageContent(ref, { currentVisible: true }) : null;
if (!page?.url) throw new BrowserServiceError("precondition", "无法读取当前页面,请重新连接浏览器");
const def = getSiteDefinition(context.site.key);
if (!def) throw new BrowserServiceError("precondition", "这个站点还没有详情页解析配置");
const resourceId = resourceIdFromUrl(page.url, context.site.base_url, def);
if (!resourceId) throw new BrowserServiceError("bad_request", "请先进入本站资源详情页,再点击下载");
let detail: SiteReleaseDetail;
if (def.umt.kind === "nexusphp") {
const parsed = parseDetailPage(page.html, def as IndexerDefinition, resourceId, service.now().toISOString());
if (parsed.kind === "login") throw new BrowserServiceError("precondition", "请先在原站登录");
if (!parsed.detail)
throw new BrowserServiceError("precondition", "当前详情页未能解析出资源,请检查站点详情配置");
detail = parsed.detail;
} else {
detail = await runtime.siteAdapter(context.site).detail(resourceId);
}
return { site: context.site, detail, url: page.url };
},
{ write: false, ...(tabId ? { tabId } : {}) },
);
const old = await runtime.ctx.db
.selectFrom("source_release")
.selectAll()
.where("site_id", "=", current.site.id)
.where("site_release_id", "=", current.detail.siteReleaseId)
.executeTakeFirst();
const { page, descriptionText: _description, ...plain } = current.detail;
const detail = old ? mergeDetailOverList(plain, old) : plain;
const { detailCacheSec } = await runtime.siteAccess(current.site.key);
const id = await runtime.ctx.db.transaction().execute(async (trx) => {
const row = await upsertSiteRelease(runtime, trx, current.site, detail);
await trx
.updateTable("source_release")
.set({ detail_fetched_at: runtime.now() })
.where("id", "=", row.id)
.execute();
if (page) await saveDetailPage(runtime, trx, row.id, page, detailCacheSec);
await sql`update source_release set raw = jsonb_set(raw, '{_detail}',
coalesce(raw->'_detail', '{}'::jsonb) || jsonb_build_object('sourceUrl', ${current.url}::text))
where id = ${row.id}`.execute(trx);
return row.id;
});
return { release: (await releaseDetail(runtime, current.site.id, id, false)).release, detailUrl: current.url };
}
@@ -15,6 +15,7 @@ import { type AuthEnv, requireScope } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import { InternalDispatcher } from "../search/dispatch.ts";
import type { ModuleApp } from "../types.ts";
import { currentResource } from "./resource.ts";
import { type Actor, type BrowserService, BrowserServiceError, imageSize } from "./service.ts";
// C1-E 浏览器 HTTP 端点:原站视图与外部程序共用。读(会话/快照/截图)任何时候可用;
@@ -85,6 +86,8 @@ export function registerBrowserRoutes(app: ModuleApp, svc: () => BrowserService)
const dispatch = new InternalDispatcher(app);
const B = Api.browser;
route(app, B.resource, ({ params, body, c }) => currentResource(svc(), params.id, human(c), body.tabId));
route(app, B.providers, async ({ query }) => ({ items: await svc().listProviders(query.probe === "true") }));
route(app, B.createProvider, async ({ body }) => svc().createProvider(body));
route(app, B.updateProvider, async ({ params, body }) => svc().updateProvider(params.id, body));