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:
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));
|
||||
|
||||
Reference in new issue
Block a user