diff --git a/apps/server/src/modules/acquisition-organize/__tests__/vertical.test.ts b/apps/server/src/modules/acquisition-organize/__tests__/vertical.test.ts index 5f76aaeb..a3606902 100644 --- a/apps/server/src/modules/acquisition-organize/__tests__/vertical.test.ts +++ b/apps/server/src/modules/acquisition-organize/__tests__/vertical.test.ts @@ -314,7 +314,12 @@ describe.skipIf(!hasPg)("acquisition-organize 纵切(真实 PG + mock qB + 临 .selectAll() .where("id", "=", sub.downloadTaskId!) .executeTakeFirstOrThrow(); - expect(row).toMatchObject({ origin: "adopted", managed: true, acquisition_plan_id: ev.plans[0] }); + expect(row).toMatchObject({ + origin: "adopted", + managed: true, + acquisition_plan_id: ev.plans[0], + media_entity_id: e, + }); expect(qb.callsTo("torrents/add").length).toBe(1); }, 30_000); @@ -460,6 +465,14 @@ describe.skipIf(!hasPg)("acquisition-organize 纵切(真实 PG + mock qB + 临 ); expect(det.body.data.candidates.some((c: { decision: string }) => c.decision === "reject")).toBe(true); expect(det.body.data.downloads[0].organizationRecordIds.length).toBeGreaterThan(0); + // QB-1:下载任务直接记作品;整理记录按作品列出 + const dune = det.body.data.downloads.find((x: { downloadTask: { name: string } }) => + x.downloadTask.name.startsWith("Dune.Part.Two"), + ); + expect(dune.downloadTask.mediaEntityId).toBe(movieId); + expect(det.body.data.organizationRecords.map((r: { id: string }) => r.id)).toEqual( + expect.arrayContaining(dune.organizationRecordIds), + ); const inv = await h.api("GET", `/inventory?entityIds=${movieId}`); expect(inv.body.data.items[0].versions[0]).toMatchObject({ resolution: "1080p", available: true }); const ms = await h.api("GET", `/inventory/media-servers?mediaEntityId=${movieId}`); diff --git a/apps/server/src/modules/acquisition-organize/routes/library.ts b/apps/server/src/modules/acquisition-organize/routes/library.ts index 7950c4fc..cb4b13ad 100644 --- a/apps/server/src/modules/acquisition-organize/routes/library.ts +++ b/apps/server/src/modules/acquisition-organize/routes/library.ts @@ -147,7 +147,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) { .select( sql`(select count(*) from download_task dt left join acquisition_plan p on p.id = dt.acquisition_plan_id left join acquisition_slot s on s.id = p.slot_id left join source_release sr on sr.id = dt.source_release_id - where (s.media_entity_id = e.id or sr.media_entity_id = e.id) and dt.removed_at is null + where (dt.media_entity_id = e.id or s.media_entity_id = e.id or sr.media_entity_id = e.id) and dt.removed_at is null and dt.status in ('queued','submitting','downloading','stalled','paused'))::int`.as("active_downloads"), ) .select(sql`e.metadata #>> '{posterPath,value}'`.as("poster_path")) @@ -205,7 +205,7 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) { if (query.filter === "downloading") q = q.where( sql`exists (select 1 from download_task dt left join acquisition_plan p on p.id = dt.acquisition_plan_id left join acquisition_slot s on s.id = p.slot_id - where s.media_entity_id = e.id and dt.removed_at is null and dt.status in ('queued','submitting','downloading','stalled','paused'))`, + where (dt.media_entity_id = e.id or s.media_entity_id = e.id) and dt.removed_at is null and dt.status in ('queued','submitting','downloading','stalled','paused'))`, ); const p = await paginate(q, query, "e."); const items: LibraryItem[] = p.rows.map((r) => ({ @@ -288,10 +288,36 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) { .leftJoin("acquisition_slot as s", "s.id", "p.slot_id") .leftJoin("source_release as sr", "sr.id", "dt.source_release_id") .selectAll("dt") - .where((eb) => eb.or([eb("s.media_entity_id", "=", id), eb("sr.media_entity_id", "=", id)])) + // 接管任务(无计划 / 站点资源)按识别 / 指定 / 整理回填的 dt.media_entity_id 归属 + .where((eb) => + eb.or([ + eb("dt.media_entity_id", "=", id), + eb("s.media_entity_id", "=", id), + eb("sr.media_entity_id", "=", id), + ]), + ) .orderBy("dt.created_at", "desc") .limit(100) .execute(); + const entityRecs = await db() + .selectFrom("organization_record") + .select([ + "id", + "status", + "action", + "trigger", + "download_task_id", + "source_path", + "target_path", + "file_succeeded", + "file_total", + "created_at", + "finished_at", + ]) + .where("media_entity_id", "=", id) + .orderBy("created_at", "desc") + .limit(100) + .execute(); const planIds = dts.map((t) => t.acquisition_plan_id).filter((x): x is string => !!x); const plans = planIds.length ? await db().selectFrom("acquisition_plan").selectAll().where("id", "in", planIds).execute() @@ -339,6 +365,19 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) { lastOrganizationStatus: mine[mine.length - 1]?.status ?? null, }; }), + organizationRecords: entityRecs.map((r) => ({ + id: r.id, + status: r.status, + action: r.action, + trigger: r.trigger, + downloadTaskId: r.download_task_id, + sourcePath: r.source_path, + targetPath: r.target_path, + fileSucceeded: r.file_succeeded, + fileTotal: r.file_total, + createdAt: new Date(r.created_at).toISOString(), + finishedAt: r.finished_at ? new Date(r.finished_at).toISOString() : null, + })), }; return detail; }, @@ -392,8 +431,14 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) { .leftJoin("acquisition_slot as s", "s.id", "p.slot_id") .leftJoin("source_release as sr", "sr.id", "dt.source_release_id") .select(["dt.id", "dt.name", "dt.status", "dt.progress"]) - .select(sql`coalesce(s.media_entity_id, sr.media_entity_id)`.as("entity_id")) - .where((eb) => eb.or([eb("s.media_entity_id", "in", ids), eb("sr.media_entity_id", "in", ids)])) + .select(sql`coalesce(dt.media_entity_id, s.media_entity_id, sr.media_entity_id)`.as("entity_id")) + .where((eb) => + eb.or([ + eb("dt.media_entity_id", "in", ids), + eb("s.media_entity_id", "in", ids), + eb("sr.media_entity_id", "in", ids), + ]), + ) .where("dt.removed_at", "is", null) .where("dt.status", "in", [...ACTIVE_DL]) .execute(), diff --git a/apps/server/src/modules/acquisition-organize/services/downloads.ts b/apps/server/src/modules/acquisition-organize/services/downloads.ts index 608a47fd..803ade94 100644 --- a/apps/server/src/modules/acquisition-organize/services/downloads.ts +++ b/apps/server/src/modules/acquisition-organize/services/downloads.ts @@ -313,15 +313,18 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?: } const r = res as Extract; const ins = await sql<{ id: string; inserted: boolean }>` - insert into download_task (instance_id, torrent_hash, infohash_v1, infohash_v2, acquisition_plan_id, source_release_id, name, + insert into download_task (instance_id, torrent_hash, infohash_v1, infohash_v2, acquisition_plan_id, source_release_id, + media_entity_id, name, save_path, content_path, category, tags, managed, origin, status, size_bytes, next_check_at) values (${topo.inst.id}, ${r.hash}, ${r.hash.length === 40 ? r.hash : null}, ${r.hash.length === 64 ? r.hash : null}, - ${plan.id}, ${rel.id}, ${r.name}, ${r.savePath}, ${r.contentPath}, ${topo.category}, + ${plan.id}, ${rel.id}, (select media_entity_id from acquisition_slot where id = ${plan.slot_id}), + ${r.name}, ${r.savePath}, ${r.contentPath}, ${topo.category}, ${r.tags}, true, ${r.kind === "already_exists" ? "adopted" : "submitted"}, ${r.kind === "accepted_unconfirmed" ? "submitting" : "queued"}, ${rel.size_bytes}, now()) on conflict (instance_id, torrent_hash) do update set acquisition_plan_id = coalesce(download_task.acquisition_plan_id, excluded.acquisition_plan_id), source_release_id = coalesce(download_task.source_release_id, excluded.source_release_id), + media_entity_id = coalesce(download_task.media_entity_id, excluded.media_entity_id), managed = true, tags = excluded.tags, updated_at = now() returning id, (xmax = 0) as inserted`.execute(trx); dtId = ins.rows[0]!.id; @@ -787,7 +790,7 @@ async function adopt(d: AoDeps, topo: InstanceTopology, t: QbTorrent, now: Date, const db = d.ctx.db; const rel = await db .selectFrom("source_release") - .select("id") + .select(["id", "media_entity_id"]) .where((eb) => eb.or([eb("infohash_v1", "=", t.hash), eb("infohash_v2", "=", t.hash)])) .executeTakeFirst(); const obs = mapQbState( @@ -804,6 +807,7 @@ async function adopt(d: AoDeps, topo: InstanceTopology, t: QbTorrent, now: Date, infohash_v1: t.infohashV1, infohash_v2: t.infohashV2, source_release_id: rel?.id ?? null, + media_entity_id: rel?.media_entity_id ?? null, name: t.name, save_path: t.savePath, content_path: t.contentPath, diff --git a/apps/server/src/modules/sources-identity/service/identity-admin.ts b/apps/server/src/modules/sources-identity/service/identity-admin.ts index 76ffec59..b357424c 100644 --- a/apps/server/src/modules/sources-identity/service/identity-admin.ts +++ b/apps/server/src/modules/sources-identity/service/identity-admin.ts @@ -440,6 +440,11 @@ export async function mergeEntities( .set({ resolved_media_entity_id: intoResolved }) .where("resolved_media_entity_id", "=", fromId) .execute(); + await trx + .updateTable("download_task") + .set({ media_entity_id: intoResolved }) + .where("media_entity_id", "=", fromId) + .execute(); await trx .updateTable("media_entity") .set({ status: "merged", merged_into_id: intoResolved }) diff --git a/apps/web/app/routes/console/library.$id.tsx b/apps/web/app/routes/console/library.$id.tsx index 42b00361..3939615d 100644 --- a/apps/web/app/routes/console/library.$id.tsx +++ b/apps/web/app/routes/console/library.$id.tsx @@ -815,7 +815,9 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) {

{d.plan ? `${d.plan.purpose === "upgrade" ? "升级" : d.plan.purpose === "fill" ? "补缺" : "换源"}计划 · ` - : ""} + : d.downloadTask.origin === "adopted" + ? "接管 · " + : ""} {formatDateTime(d.downloadTask.createdAt)}

@@ -872,6 +874,9 @@ function DownloadsPanel({ detail }: { detail: LibraryDetail }) { ), }, ]; + // 没有下载任务的整理记录(文件管理按路径手动整理等),下载任务的记录已在上表 + const listed = new Set(detail.downloads.map((d) => d.downloadTask.id)); + const manual = (detail.organizationRecords ?? []).filter((r) => !r.downloadTaskId || !listed.has(r.downloadTaskId)); return ( d.downloadTask.id} - empty={{ title: "还没有下载记录" }} + empty={{ title: manual.length ? "没有下载任务,只有手动整理记录" : "还没有下载记录" }} /> + {manual.length ? ( +
+

其他整理记录

+
    + {manual.map((r) => ( +
  • + + + {r.targetPath ?? r.sourcePath ?? r.id} + + + {r.trigger === "manual" ? "手动" : r.trigger === "agent" ? "Agent" : "自动"} · {r.fileSucceeded}/ + {r.fileTotal} 个文件 · {formatDateTime(r.createdAt)} + +
  • + ))} +
+
+ ) : null}
); } diff --git a/packages/contracts/src/execution.ts b/packages/contracts/src/execution.ts index 8bf5d8df..06b808e6 100644 --- a/packages/contracts/src/execution.ts +++ b/packages/contracts/src/execution.ts @@ -189,6 +189,8 @@ export const DownloadTask = EntityBase.extend({ infohashV2: z.string().nullable(), acquisitionPlanId: Id.nullable(), sourceReleaseId: Id.nullable(), + /** 关联作品:计划 / 站点资源 / 接管任务按文件名识别 / 手动指定 / 手动整理后回填(QB-1) */ + mediaEntityId: Id.nullable().optional(), name: z.string(), savePath: z.string().nullable(), contentPath: z.string().nullable(), diff --git a/packages/contracts/src/organization.ts b/packages/contracts/src/organization.ts index 4b1e043b..b658e2c7 100644 --- a/packages/contracts/src/organization.ts +++ b/packages/contracts/src/organization.ts @@ -374,6 +374,27 @@ export const LibraryDetail = z.object({ slots: z.array(AcquisitionSlot), candidates: z.array(CandidateEvaluation), downloads: z.array(LibraryDownloadView), + /** + * 该作品的整理记录(最近 100 条,新→旧):含没有下载任务的手动整理(文件管理按路径整理)。 + * 下载历史里已按下载任务列出的记录也在这里,前端按 downloadTaskId 去重展示。 + */ + organizationRecords: z + .array( + z.object({ + id: Id, + status: OrganizationStatus, + action: OrganizationAction, + trigger: OrganizationTrigger, + downloadTaskId: Id.nullable(), + sourcePath: z.string().nullable(), + targetPath: z.string().nullable(), + fileSucceeded: NonNegInt, + fileTotal: NonNegInt, + createdAt: Timestamp, + finishedAt: Timestamp.nullable(), + }), + ) + .optional(), }); export type LibraryDetail = z.infer; diff --git a/packages/db/migrations/0053_download_task_entity.ts b/packages/db/migrations/0053_download_task_entity.ts new file mode 100644 index 00000000..a891dfcc --- /dev/null +++ b/packages/db/migrations/0053_download_task_entity.ts @@ -0,0 +1,29 @@ +import type { Kysely } from "kysely"; +import { execDdl } from "../src/migration-helpers.ts"; + +// QB-1:下载任务直接记关联作品(接管任务按文件名识别 / 手动指定 / 手动整理后回填),作品详情"下载历史"据此列出 +// 没有计划与站点资源的接管任务。已有行按 计划 Slot → 站点资源 → 最近一次整理记录 回填。 + +// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型 +export async function up(db: Kysely): Promise { + await execDdl( + db, + `alter table download_task add column if not exists media_entity_id uuid references media_entity (id) on delete set null; + create index if not exists download_task_media_entity_idx on download_task (media_entity_id) where media_entity_id is not null; + update download_task dt set media_entity_id = coalesce( + (select s.media_entity_id from acquisition_plan p join acquisition_slot s on s.id = p.slot_id where p.id = dt.acquisition_plan_id), + (select sr.media_entity_id from source_release sr where sr.id = dt.source_release_id), + (select r.media_entity_id from organization_record r + where r.download_task_id = dt.id and r.media_entity_id is not null order by r.created_at desc limit 1)) + where dt.media_entity_id is null;`, + ); +} + +// biome-ignore lint/suspicious/noExplicitAny: 同上 +export async function down(db: Kysely): Promise { + await execDdl( + db, + `drop index if exists download_task_media_entity_idx; + alter table download_task drop column if exists media_entity_id;`, + ); +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index e244423f..a0e6b14d 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -701,6 +701,7 @@ export interface DownloadTaskTable { last_error: Json | null; created_at: GeneratedTimestamptz; updated_at: GeneratedTimestamptz; + media_entity_id: string | null; } export interface DownloaderInstanceTable {