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
+17455
-2187
No files matched your search
@@ -15,3 +15,6 @@ docs
|
|||||||
*.md
|
*.md
|
||||||
!apps/**/README.md
|
!apps/**/README.md
|
||||||
data
|
data
|
||||||
|
.local
|
||||||
|
.browser-verify-out
|
||||||
|
docker-compose.override.yml
|
||||||
+17
-6
@@ -2,10 +2,13 @@
|
|||||||
# PostgreSQL 不在本镜像内,见 docker-compose.yml。
|
# PostgreSQL 不在本镜像内,见 docker-compose.yml。
|
||||||
# 构建:docker build -t uhd-media-tool . 运行时版本锁定 Deno 2.9.7(ADR-004)
|
# 构建:docker build -t uhd-media-tool . 运行时版本锁定 Deno 2.9.7(ADR-004)
|
||||||
# ffprobe 取自静态构建镜像(单个 static-pie 二进制,约 135 MB;Debian 的 ffmpeg 包要拉 200+ 个依赖、约 450 MB)
|
# ffprobe 取自静态构建镜像(单个 static-pie 二进制,约 135 MB;Debian 的 ffmpeg 包要拉 200+ 个依赖、约 450 MB)
|
||||||
ARG FFPROBE_IMAGE=mwader/static-ffmpeg:9.0.2
|
ARG FFPROBE_IMAGE=mwader/static-ffmpeg:9.0.2@sha256:7d9bdaaf887f7e6ce6151f67325c344074b5ff1fb75316011c3376503e449a7b
|
||||||
FROM ${FFPROBE_IMAGE} AS ffprobe
|
FROM ${FFPROBE_IMAGE} AS ffprobe
|
||||||
|
|
||||||
FROM denoland/deno:2.9.7 AS build
|
# 与 PG 18 服务端同主版本的备份工具,避免 Debian 默认 17 客户端拒绝导出 PG 18。
|
||||||
|
FROM postgres:18.6-trixie@sha256:5a5a84b19854a9ffaa54082c166ff4ec27473a361e496e5ea167f298f2da9722 AS postgres-tools
|
||||||
|
|
||||||
|
FROM denoland/deno:2.9.7@sha256:fa335acdf6b72106eda2cb6a8cb5f4187e7630e357467489db4b2e7352d5e432 AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN deno install --frozen
|
RUN deno install --frozen
|
||||||
@@ -19,19 +22,23 @@ RUN deno task check:tokens && cd apps/web && deno task build
|
|||||||
# 编译后立即自检:空环境跑 --role preflight,模块图缺件(ERR_MODULE_NOT_FOUND)在构建期就失败,不留到容器启动
|
# 编译后立即自检:空环境跑 --role preflight,模块图缺件(ERR_MODULE_NOT_FOUND)在构建期就失败,不留到容器启动
|
||||||
RUN cd apps/server && deno task compile && cd /src && sh scripts/check-compiled-binary.sh
|
RUN cd apps/server && deno task compile && cd /src && sh scripts/check-compiled-binary.sh
|
||||||
|
|
||||||
FROM debian:trixie-slim
|
FROM debian:trixie-slim@sha256:a99cfc517144bc59b1978475ec53b46ecabec7e43635402ee5b77cc54cd1b20a
|
||||||
# gosu:root 修完 DATA_DIR 属主后降权;postgresql-client:备份/恢复脚本用 pg_dump/pg_restore/psql
|
# gosu 用于降权;PG 18 客户端来自固定的官方同发行版镜像,这里只安装动态库。
|
||||||
# (trixie 自带 17 版客户端,可备份/恢复 16 版服务端)。Deno 自带根证书,不装 ca-certificates;健康探测用二进制自身的 fetch
|
# Deno 自带根证书;健康探测用二进制自身的 fetch。
|
||||||
# 国内网络可 --build-arg APT_MIRROR=mirrors.ustc.edu.cn 换源
|
# 国内网络可 --build-arg APT_MIRROR=mirrors.ustc.edu.cn 换源
|
||||||
ARG APT_MIRROR=""
|
ARG APT_MIRROR=""
|
||||||
RUN if [ -n "$APT_MIRROR" ]; then sed -i "s|deb.debian.org|$APT_MIRROR|g" /etc/apt/sources.list.d/debian.sources; fi \
|
RUN if [ -n "$APT_MIRROR" ]; then sed -i "s|deb.debian.org|$APT_MIRROR|g" /etc/apt/sources.list.d/debian.sources; fi \
|
||||||
&& apt-get update \
|
&& apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends gosu postgresql-client \
|
&& apt-get upgrade -y --no-install-recommends \
|
||||||
|
&& apt-get install -y --no-install-recommends gosu libssl3t64 libgssapi-krb5-2 libldap2 libzstd1 liblz4-1 libreadline8t64 \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& groupadd --system --gid 10001 umt \
|
&& groupadd --system --gid 10001 umt \
|
||||||
&& useradd --system --uid 10001 --gid 10001 --home-dir /data --no-create-home --shell /usr/sbin/nologin umt \
|
&& useradd --system --uid 10001 --gid 10001 --home-dir /data --no-create-home --shell /usr/sbin/nologin umt \
|
||||||
&& mkdir -p /data && chown 10001:10001 /data
|
&& mkdir -p /data && chown 10001:10001 /data
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY --from=postgres-tools /usr/lib/postgresql/18/bin/pg_dump /usr/lib/postgresql/18/bin/pg_restore /usr/lib/postgresql/18/bin/psql /usr/local/bin/
|
||||||
|
COPY --from=postgres-tools /usr/lib/*/libpq.so.5.18 /usr/local/lib/libpq.so.5
|
||||||
|
RUN ldconfig && pg_dump --version && pg_restore --version && psql --version
|
||||||
# /app 全部 root 属主、只读:运行用户(缺省 10001,可用 PUID/PGID 覆盖)改不了二进制与静态产物
|
# /app 全部 root 属主、只读:运行用户(缺省 10001,可用 PUID/PGID 覆盖)改不了二进制与静态产物
|
||||||
COPY --from=ffprobe --chown=root:root --chmod=0555 /ffprobe /usr/local/bin/ffprobe
|
COPY --from=ffprobe --chown=root:root --chmod=0555 /ffprobe /usr/local/bin/ffprobe
|
||||||
COPY --from=build --chown=root:root --chmod=0555 /src/dist/umt-server /app/umt-server
|
COPY --from=build --chown=root:root --chmod=0555 /src/dist/umt-server /app/umt-server
|
||||||
@@ -46,6 +53,10 @@ ENV NODE_ENV=production \
|
|||||||
DATA_DIR=/data \
|
DATA_DIR=/data \
|
||||||
PUID=10001 \
|
PUID=10001 \
|
||||||
PGID=10001
|
PGID=10001
|
||||||
|
ARG SOURCE_REVISION=""
|
||||||
|
ARG APP_VERSION=""
|
||||||
|
LABEL org.opencontainers.image.version=${APP_VERSION} \
|
||||||
|
org.opencontainers.image.revision=${SOURCE_REVISION}
|
||||||
# 以 root 启动只为修 /data 属主,entrypoint 随即 gosu 降权;api/worker/备份脚本都不以 root 运行
|
# 以 root 启动只为修 /data 属主,entrypoint 随即 gosu 降权;api/worker/备份脚本都不以 root 运行
|
||||||
VOLUME ["/data"]
|
VOLUME ["/data"]
|
||||||
EXPOSE 4380
|
EXPOSE 4380
|
||||||
|
|||||||
@@ -2,16 +2,27 @@
|
|||||||
|
|
||||||
内部使用、Agent 自动化优先的媒体整理入库平台。需求基线见 [media_automation_requirements_architecture_v0.7.md](media_automation_requirements_architecture_v0.7.md),v0.1 工程任务书见 [docs/plan/00-first-version-plan.md](docs/plan/00-first-version-plan.md)。
|
内部使用、Agent 自动化优先的媒体整理入库平台。需求基线见 [media_automation_requirements_architecture_v0.7.md](media_automation_requirements_architecture_v0.7.md),v0.1 工程任务书见 [docs/plan/00-first-version-plan.md](docs/plan/00-first-version-plan.md)。
|
||||||
|
|
||||||
|
当前部署为 v0.3.13,媒体整理与手动下载入口见 [发布记录](docs/plan/18-organize-release.md)。依赖与容器升级见 [完整升级记录](docs/plan/17-full-upgrade.md);[开发情况基线](docs/plan/13-development-baseline.md) 保留升级前 v0.3.7 的证据,早期计划和验收表不作为当前完成度统计。恢复与验收调整见 [工程检查](docs/plan/14-engineering-review.md)。
|
||||||
|
|
||||||
|
逐项业务核对见 [MoviePilot v3 对照](docs/plan/15-moviepilot-v3-comparison.md):固定参考版本,按用户确认的需求实施。新一批独立手动下载、串流外详情页下载、TMDB/Local 元数据与一次性洗版已于 2026-10-01 部署为 v0.3.10;实现与验收见该文第 5.6 节,关联迁移 `0060`。
|
||||||
|
|
||||||
|
依赖、容器与运行程序的版本检查见 [依赖与程序版本报告](docs/plan/16-dependency-version-review.md):包含完整依赖清单、安全公告适用性、PG 18 迁移和 Pi 原生 MCP 的评估建议;本轮没有执行升级。
|
||||||
|
|
||||||
|
v0.3.13 将手动、文件管理、下载详情与历史重整统一为 MP 风格整理弹窗;支持电影/剧集、TMDB 搜索与 ID 定位、复制/移动、目标路径及本次刮削选项,仅加入后台整理队列。整理页分队列与记录,成功历史默认不跳过,成功移动后从目标重整;同时统一分页条数选择器并调整通知数字角标。
|
||||||
|
|
||||||
|
v0.3.14 已补充原地入库代码与迁移,保留路径/文件名,可选就地刮削;当前会话受权限限制尚未部署,生产仍为上述 v0.3.13。见 [发布记录](docs/plan/18-organize-release.md)。
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
已实现(全部只在 mock 服务上验证过,没有连过真实 PT、qB、Emby、camofox 与模型上游;逐条对照见 [docs/plan/11-requirements-coverage.md](docs/plan/11-requirements-coverage.md)):
|
已实现(有自动化回归及部分真实环境 / 脱敏样本记录,四站与真实下载、媒体服务器的完整验收尚未完成;验证边界见 [开发情况基线](docs/plan/13-development-baseline.md)):
|
||||||
|
|
||||||
- 来源:多 RSS(条件请求、重叠窗口、多层去重、补漏)、站点中心(观众 / 馒头 / HHClub / CHD 等 23 个站点定义,凭证、账号数据、健康检查)、站点快照与实时补查、全局搜索。
|
- 来源:多 RSS(条件请求、重叠窗口、多层去重、补漏)、站点中心(观众 / 馒头 / HHClub / CHD 等 23 个站点定义,凭证、账号数据、健康检查)、站点快照与实时补查、全局搜索。
|
||||||
- 识别:MP 对齐的标题解析、TMDB + 本地别名召回、本地实体优先、临时季集单元、待识别池、人工纠错与字段锁。
|
- 识别:MP 对齐的标题解析、TMDB + 本地别名召回、本地实体优先、临时季集单元、待识别池、人工纠错与字段锁。
|
||||||
- 订阅与规则:单次 / 持续追更、多版本目标(Slot)、总集数账本、覆盖矩阵、日历;规则组(兼容 MP 规则字符串)、偏好标签与抑制记录;新作品 / 追更 / 升级三种收录模式。
|
- 订阅与规则:单次 / 持续追更、多版本目标(Slot)、总集数账本、覆盖矩阵、日历;规则组(兼容 MP 规则字符串)、偏好标签与抑制记录;新作品 / 追更 / 升级三种收录模式。
|
||||||
- 下载:多 qB、管理标签接管、缓存卷硬阈值与恢复水位、无进展只记录不换源、缓存清理(入库已验证 + 保留期 + H&R)。
|
- 下载:多 qB、管理标签接管、缓存卷硬阈值与恢复水位、无进展只记录不换源、缓存清理(入库已验证 + 保留期 + H&R)。
|
||||||
- 整理:Copy / Move(跨卷可恢复、fsproxy 看门狗)、路径绑定固定、命名模板、文件管理 v2 与整理向导、回收站、公开整理记录增量接口。
|
- 整理:Copy / Move(跨卷可恢复、fsproxy 看门狗)、路径绑定固定、命名模板、文件管理 v2 与统一整理弹窗、回收站、公开整理记录增量接口。
|
||||||
- Agent:Pi 内核、27 个能力、服务端授权(持续授权 / 一次性审批)、步骤清单 / 方案选择 / 内联审批、子会话、后台自动调查与授权内恢复、41 个上游 provider 与订阅登录。
|
- 刮削:任意视频 / 目录就地识别并写 NFO 与图片、候选选择、覆盖策略、入库后自动刮削、每次刮削一条记录。
|
||||||
|
- Agent:Pi 内核、能力目录(当前源码 42 项)、服务端授权(持续授权 / 一次性审批)、步骤清单 / 方案选择 / 内联审批、子会话、后台自动调查与授权内恢复、42 个内置上游 provider 与订阅登录(未逐家联网实测)。
|
||||||
- 接口:REST + OpenAPI、MCP 服务端(API Key 鉴权)、SSE 带序号补读。
|
- 接口:REST + OpenAPI、MCP 服务端(API Key 鉴权)、SSE 带序号补读。
|
||||||
- 浏览器:camofox BrowserProvider、原站视图(截图 + 坐标输入)、接管租约、下载捕获走统一提交。
|
- 浏览器:camofox BrowserProvider、原站视图(截图 + 坐标输入)、接管租约、下载捕获走统一提交。
|
||||||
- 运维:管理员登录(TOTP / Passkey)、API Key 分级、主密钥轮换、定时任务看板、统一任务页、运行状态页、审计、备份与只读对账恢复。
|
- 运维:管理员登录(TOTP / Passkey)、API Key 分级、主密钥轮换、定时任务看板、统一任务页、运行状态页、审计、备份与只读对账恢复。
|
||||||
@@ -24,13 +35,13 @@
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
deno task db:up # postgres:16-alpine,端口 55433
|
deno task db:up # postgres:18.6-alpine,端口 55433
|
||||||
deno install # 按 deno.lock 安装依赖到 node_modules
|
deno install # 按 deno.lock 安装依赖到 node_modules
|
||||||
deno task db:migrate
|
deno task db:migrate
|
||||||
deno task dev # API http://localhost:4380 控制台 http://localhost:5180
|
deno task dev # API http://localhost:4380 控制台 http://localhost:5180
|
||||||
```
|
```
|
||||||
|
|
||||||
提交前:`deno task ci`(typecheck:tsc 7.0.2 + deno check → biome → vitest → check:tokens → web 构建 + server 编译)。
|
提交前:`deno task ci`(typecheck:tsc 7.0.2 + deno check → biome → vitest → check:tokens → check:copy → web 构建 + server 编译 → check:binary)。
|
||||||
|
|
||||||
### 前端冒烟(`deno task test:web:smoke`)
|
### 前端冒烟(`deno task test:web:smoke`)
|
||||||
|
|
||||||
@@ -45,13 +56,15 @@ SMOKE_BASE_URL=http://127.0.0.1:5180 SMOKE_USER=<管理员> SMOKE_PASSWORD=<密
|
|||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
除 PostgreSQL 外全部在一个容器里(api + worker + worker:agent 三个进程 + web 静态,单端口 4380);compose 只有 `app` + `postgres`。
|
核心应用在一个容器里(api + worker + worker:agent 三个进程 + web 静态,单端口 4380);compose 默认服务为 `app` + `postgres` + `camofox`,可选 `tls` profile 的 Caddy 提供 HTTPS。
|
||||||
|
|
||||||
|
首次部署默认浏览器前,先按 [浏览器镜像构建说明](docs/runbook/browser.md#11-部署自带缺省) 构建 `camofox-browser:1.18.0` 基础镜像,compose 再据此构建桌面镜像。
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cp docker/compose.env.example .env # 填 SECRET_MASTER_KEY=$(openssl rand -hex 32)、POSTGRES_PASSWORD
|
cp docker/compose.env.example .env.deploy # 填 SECRET_MASTER_KEY=$(openssl rand -hex 32)、POSTGRES_PASSWORD
|
||||||
docker compose up -d --build
|
docker compose --env-file .env.deploy up -d --build
|
||||||
docker compose logs app | grep 'setup token' # 首次初始化管理员
|
docker compose --env-file .env.deploy logs app | grep 'setup token' # 首次初始化管理员
|
||||||
docker compose exec app umt backup # 备份到 /data/backups/<时间戳>/
|
docker compose --env-file .env.deploy exec app umt backup # 备份到 /data/backups/<时间戳>/
|
||||||
```
|
```
|
||||||
|
|
||||||
- 应用数据卷 `/data`(Agent 会话、备份、导出);媒体库根与下载目录自行 bind mount,需对 `PUID:PGID`(缺省 10001)可读写。
|
- 应用数据卷 `/data`(Agent 会话、备份、导出);媒体库根与下载目录自行 bind mount,需对 `PUID:PGID`(缺省 10001)可读写。
|
||||||
@@ -64,7 +77,7 @@ docker compose exec app umt backup # 备份到 /data/backups/<时间
|
|||||||
|
|
||||||
| 路径 | 职责 |
|
| 路径 | 职责 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `apps/web` | React Router 7 SPA 控制台,沿用 uhd_web 设计系统 |
|
| `apps/web` | React Router 8 SPA 控制台,沿用 uhd_web 设计系统 |
|
||||||
| `apps/server` | Hono API(`/api/v1`)+ SSE + worker 入口;业务按 `src/modules/<area>/` 分模块(见 apps/server/README.md) |
|
| `apps/server` | Hono API(`/api/v1`)+ SSE + worker 入口;业务按 `src/modules/<area>/` 分模块(见 apps/server/README.md) |
|
||||||
| `packages/contracts` | zod schema:API/事件/能力的唯一定义 |
|
| `packages/contracts` | zod schema:API/事件/能力的唯一定义 |
|
||||||
| `packages/db` | Kysely 客户端、表类型与迁移 |
|
| `packages/db` | Kysely 客户端、表类型与迁移 |
|
||||||
@@ -82,4 +95,8 @@ docker compose exec app umt backup # 备份到 /data/backups/<时间
|
|||||||
| `scripts/` | 备份 / 恢复脚本(镜像内 `/app/scripts/`) |
|
| `scripts/` | 备份 / 恢复脚本(镜像内 `/app/scripts/`) |
|
||||||
| `spikes/` | 独立兼容性验证(不属于 workspace) |
|
| `spikes/` | 独立兼容性验证(不属于 workspace) |
|
||||||
|
|
||||||
技术选型:Deno 2.9.7(锁定)+ TypeScript 7.0.2 strict,deno.json workspace(`nodeModulesDir: auto`,npm 依赖版本统一登记在根 `deno.json` 的 `imports`,`deno.lock` 锁定)。前端 Vite 7 + React Router 7 由 Deno 执行。决策见 [ADR-004](docs/adr/ADR-004-deno-runtime.md)(取代 ADR-001)。
|
技术选型:Deno 2.9.7(锁定)+ TypeScript 7.0.2 strict,deno.json workspace(`nodeModulesDir: auto`,npm 依赖版本统一登记在根 `deno.json` 的 `imports`,`deno.lock` 锁定)。前端 Vite 8 + React Router 8 由 Deno 执行。决策见 [ADR-004](docs/adr/ADR-004-deno-runtime.md)(取代 ADR-001)。
|
||||||
|
|
||||||
|
首页现展示存储和下载器概况,记录列表统一支持每页条数和翻页,搜索页与快捷搜索共用历史,右上角通知与控制台样式一致,见 [v0.3.12 发布记录](docs/plan/18-organize-release.md#v0312-列表分页搜索历史与首页概况)。
|
||||||
|
|
||||||
|
源站视图上下黑边与搜索资源详情入口已在 v0.3.11 发布中修复:浏览器窗口自动铺满桌面,搜索结果标题打开同一站点详情抽屉,见 [发布记录](docs/plan/18-organize-release.md#v0311-搜索详情与桌面黑边修复)。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@umt/server",
|
"name": "@umt/server",
|
||||||
"version": "0.3.7",
|
"version": "0.3.14",
|
||||||
"exports": { ".": "./src/main.ts" },
|
"exports": { ".": "./src/main.ts" },
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"dev": "deno run -A --watch --env-file=../../.env src/main.ts",
|
"dev": "deno run -A --watch --env-file=../../.env src/main.ts",
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
||||||
|
import type { TmdbClient } from "@umt/adapters";
|
||||||
|
import { DEFAULT_CLASSIFICATION_YAML } from "@umt/domain";
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { AuthEnv } from "../../../http/auth.ts";
|
||||||
|
import { createSettingsReader } from "../../auth-settings/provided.ts";
|
||||||
|
import { registerSettingsRoutes } from "../../auth-settings/settings-routes.ts";
|
||||||
|
import { SourcesRuntime } from "../../sources-identity/runtime.ts";
|
||||||
|
import { classifyEntity } from "../services/classification.ts";
|
||||||
|
import { createHarness, type Harness, pgAvailable, seedEntity } from "./harness.ts";
|
||||||
|
|
||||||
|
const hasPg = await pgAvailable();
|
||||||
|
describe.skipIf(!hasPg)("TMDB classification API and accepted identities (real PG)", () => {
|
||||||
|
let h: Harness;
|
||||||
|
beforeAll(async () => {
|
||||||
|
h = await createHarness("classification");
|
||||||
|
const settingsApp = new OpenAPIHono<AuthEnv>();
|
||||||
|
registerSettingsRoutes(settingsApp, h.ctx);
|
||||||
|
h.app.route("/api/v1", settingsApp);
|
||||||
|
}, 60_000);
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
afterAll(async () => await h?.close());
|
||||||
|
|
||||||
|
it("legacy stored organize settings receive default YAML without losing preferences", async () => {
|
||||||
|
await h.setSettings("organize", { minSizeBytes: 456789, conflictDefault: "skip" });
|
||||||
|
const reader = createSettingsReader({ db: h.db, logger: h.ctx.logger });
|
||||||
|
expect(await reader.get("organize")).toMatchObject({
|
||||||
|
minSizeBytes: 456789,
|
||||||
|
classificationYaml: DEFAULT_CLASSIFICATION_YAML,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ordered categories and raw preview do not write settings", async () => {
|
||||||
|
const view = await h.api("GET", "/classification");
|
||||||
|
expect(view.status).toBe(200);
|
||||||
|
expect(view.body.data.yaml).toBe(DEFAULT_CLASSIFICATION_YAML);
|
||||||
|
expect(view.body.data.categories.movie).toEqual(["动画电影", "演唱会", "华语电影", "亚洲电影", "欧美电影"]);
|
||||||
|
const preview = await h.api("POST", "/classification/preview", {
|
||||||
|
yaml: "movie:\n 日本片:\n production_countries: JP\ntv: {}\n",
|
||||||
|
mediaType: "movie",
|
||||||
|
tmdb: { production_countries: [{ iso_3166_1: "JP" }] },
|
||||||
|
});
|
||||||
|
expect(preview.status).toBe(200);
|
||||||
|
expect(preview.body.data).toEqual({ category: "日本片", matchedFields: ["production_countries"], fallback: false });
|
||||||
|
expect((await h.ctx.settings.get("organize")).classificationYaml).toBe(DEFAULT_CLASSIFICATION_YAML);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PUT preserves organize preferences, audits and emits settings change; invalid writes reject", async () => {
|
||||||
|
await h.setSettings("organize", { ...(await h.ctx.settings.get("organize")), minSizeBytes: 765432 });
|
||||||
|
const publish = vi.spyOn(h.ctx.events, "publish");
|
||||||
|
const yaml = "movie:\n 新分类:\n original_language: zh\n 其它: null\ntv: {}\n";
|
||||||
|
const saved = await h.api("PUT", "/classification", { yaml });
|
||||||
|
expect(saved.status).toBe(200);
|
||||||
|
expect(saved.body.data.categories.movie).toEqual(["新分类", "其它"]);
|
||||||
|
expect(await h.ctx.settings.get("organize")).toMatchObject({ classificationYaml: yaml, minSizeBytes: 765432 });
|
||||||
|
expect(publish).toHaveBeenCalledWith("settings.changed", { keys: ["organize"] });
|
||||||
|
expect(
|
||||||
|
(await h.db.selectFrom("audit_event").selectAll().where("action", "=", "settings.update").execute()).length,
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
for (const invalid of ["movie:\n ../escape: null\ntv: {}", "movie:\n 同名: null\n 同名: null\ntv: {}"]) {
|
||||||
|
expect((await h.api("PUT", "/classification", { yaml: invalid })).status).toBe(422);
|
||||||
|
expect((await h.api("PATCH", "/settings", { organize: { classificationYaml: invalid } })).status).toBe(422);
|
||||||
|
}
|
||||||
|
expect((await h.ctx.settings.get("organize")).classificationYaml).toBe(yaml);
|
||||||
|
await h.api("PUT", "/classification", { yaml: DEFAULT_CLASSIFICATION_YAML });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PUT merges current row after waiting rather than overwriting a newer source edit", async () => {
|
||||||
|
const source = {
|
||||||
|
id: "33333333-3333-4333-8333-333333333333",
|
||||||
|
name: "Concurrent source",
|
||||||
|
enabled: true,
|
||||||
|
source: { root: "lib:11111111-1111-4111-8111-111111111111", path: "" },
|
||||||
|
target: { root: "lib:22222222-2222-4222-8222-222222222222", path: "" },
|
||||||
|
mediaType: "all",
|
||||||
|
category: null,
|
||||||
|
action: "copy",
|
||||||
|
trigger: "manual",
|
||||||
|
downloaderInstanceId: null,
|
||||||
|
tag: null,
|
||||||
|
intervalSec: 300,
|
||||||
|
};
|
||||||
|
const originalAll = h.ctx.settings.all.bind(h.ctx.settings);
|
||||||
|
vi.spyOn(h.ctx.settings, "all").mockImplementationOnce(async () => {
|
||||||
|
const stale = await originalAll();
|
||||||
|
await h.setSettings("organize", { ...stale.organize, minSizeBytes: 654321, sources: [source] });
|
||||||
|
return stale;
|
||||||
|
});
|
||||||
|
expect((await h.api("PUT", "/classification", { yaml: DEFAULT_CLASSIFICATION_YAML })).status).toBe(200);
|
||||||
|
const stored = await h.db
|
||||||
|
.selectFrom("settings")
|
||||||
|
.select("value")
|
||||||
|
.where("key", "=", "organize")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
expect(stored.value).toMatchObject({
|
||||||
|
minSizeBytes: 654321,
|
||||||
|
sources: [source],
|
||||||
|
classificationYaml: DEFAULT_CLASSIFICATION_YAML,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires accepted movie/tv identity, uses raw detail, and fails without fabricating fallback", async () => {
|
||||||
|
await h.setSettings("organize", {
|
||||||
|
...(await h.ctx.settings.get("organize")),
|
||||||
|
classificationYaml: DEFAULT_CLASSIFICATION_YAML,
|
||||||
|
});
|
||||||
|
const id = await seedEntity(h.db, { kind: "movie", title: "测试片", year: 2020 });
|
||||||
|
const entity = await h.db.selectFrom("media_entity").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||||
|
const getJson = vi.fn().mockResolvedValue({ genres: [{ id: 16 }], original_language: "zh" });
|
||||||
|
const factory = vi.spyOn(SourcesRuntime.prototype, "tmdb").mockResolvedValue({ getJson } as unknown as TmdbClient);
|
||||||
|
expect((await classifyEntity(h.d, entity)).category).toBeNull();
|
||||||
|
expect(factory).not.toHaveBeenCalled();
|
||||||
|
const identity = await h.db
|
||||||
|
.insertInto("external_identity")
|
||||||
|
.values({ media_entity_id: id, provider: "tmdb", entity_type: "movie", external_id: "123", status: "proposed" })
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
expect((await classifyEntity(h.d, entity)).category).toBeNull();
|
||||||
|
await h.db.updateTable("external_identity").set({ status: "accepted" }).where("id", "=", identity.id).execute();
|
||||||
|
expect(await classifyEntity(h.d, entity)).toEqual({
|
||||||
|
category: "动画电影",
|
||||||
|
matchedFields: ["genre_ids"],
|
||||||
|
fallback: false,
|
||||||
|
});
|
||||||
|
expect(getJson).toHaveBeenCalledWith("/movie/123");
|
||||||
|
getJson.mockRejectedValue(new Error("offline"));
|
||||||
|
expect(await classifyEntity(h.d, entity)).toEqual({ category: null, matchedFields: [], fallback: false });
|
||||||
|
getJson.mockResolvedValue({});
|
||||||
|
expect((await classifyEntity(h.d, entity)).category).toBeNull();
|
||||||
|
getJson.mockResolvedValue({ genres: [{ id: 10764 }], origin_country: ["CN"] });
|
||||||
|
const tvId = await seedEntity(h.db, { kind: "series", title: "测试剧", year: 2020 });
|
||||||
|
const tv = await h.db.selectFrom("media_entity").selectAll().where("id", "=", tvId).executeTakeFirstOrThrow();
|
||||||
|
await h.db
|
||||||
|
.insertInto("external_identity")
|
||||||
|
.values({ media_entity_id: tvId, provider: "tmdb", entity_type: "tv", external_id: "456", status: "accepted" })
|
||||||
|
.execute();
|
||||||
|
expect((await classifyEntity(h.d, tv)).category).toBe("综艺");
|
||||||
|
expect(getJson).toHaveBeenCalledWith("/tv/456");
|
||||||
|
factory.mockResolvedValue(null);
|
||||||
|
expect((await classifyEntity(h.d, entity)).category).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import type { TaskRunView } from "@umt/tasks";
|
||||||
|
import { createTempTree, type TempTree } from "@umt/testkit";
|
||||||
|
import { sql } from "kysely";
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { enqueueSweep, enqueueTracked } from "../services/common.ts";
|
||||||
|
import { type QueueScrapeInput, queueScrape, runScrapeRecord } from "../services/scrape.ts";
|
||||||
|
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||||
|
|
||||||
|
const hasPg = await pgAvailable();
|
||||||
|
const runView = (runId: string, input: unknown, status: TaskRunView["status"] = "pending"): TaskRunView => ({
|
||||||
|
runId,
|
||||||
|
taskName: "scrapeMedia",
|
||||||
|
version: 2,
|
||||||
|
status,
|
||||||
|
attempts: 0,
|
||||||
|
idempotencyKey: null,
|
||||||
|
traceId: null,
|
||||||
|
priority: 0,
|
||||||
|
input,
|
||||||
|
output: null,
|
||||||
|
error: null,
|
||||||
|
cancelRequestedAt: null,
|
||||||
|
cancelReason: null,
|
||||||
|
availableAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
startedAt: null,
|
||||||
|
finishedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!hasPg)("入队关联与巡检恢复", () => {
|
||||||
|
let h: Harness;
|
||||||
|
let lib: TempTree;
|
||||||
|
let rootId: string;
|
||||||
|
let entityId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
lib = await createTempTree({}, { sentinel: true });
|
||||||
|
h = await createHarness("enqueue_recovery");
|
||||||
|
({ rootId } = await seedBase(h.db, { libraryPath: lib.root, cachePath: lib.root, qbUrl: "http://127.0.0.1:9" }));
|
||||||
|
entityId = await seedEntity(h.db, { kind: "movie", title: "恢复测试", year: 2020 });
|
||||||
|
await sql`create function reject_association() returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
if new.engine_run_id is not null and new.engine_run_id is distinct from old.engine_run_id
|
||||||
|
then raise exception 'injected association failure'; end if;
|
||||||
|
return new;
|
||||||
|
end $$`.execute(h.db);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function rejectAssociation(table: "task" | "scrape_record") {
|
||||||
|
await sql
|
||||||
|
.raw(`create trigger reject_association before update on ${table}
|
||||||
|
for each row execute function reject_association()`)
|
||||||
|
.execute(h.db);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function allowAssociation() {
|
||||||
|
for (const table of ["task", "scrape_record"])
|
||||||
|
await sql.raw(`drop trigger if exists reject_association on ${table}`).execute(h.db);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
await allowAssociation();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await h?.close();
|
||||||
|
await lib?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
const task = (key: string) => ({ kind: "reconcileLibrary" as const, idempotencyKey: key, input: {} });
|
||||||
|
const scrape = (path: string, idempotencyKey?: string): QueueScrapeInput => ({
|
||||||
|
rootId: `lib:${rootId}`,
|
||||||
|
rootPath: lib.root,
|
||||||
|
libraryRootId: rootId,
|
||||||
|
path,
|
||||||
|
match: {
|
||||||
|
mediaEntityId: entityId,
|
||||||
|
provider: null,
|
||||||
|
externalId: null,
|
||||||
|
kind: "movie",
|
||||||
|
title: "恢复测试",
|
||||||
|
year: 2020,
|
||||||
|
source: "chosen",
|
||||||
|
},
|
||||||
|
trigger: "manual",
|
||||||
|
mode: "missing",
|
||||||
|
...(idempotencyKey ? { idempotencyKey } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
it("引擎查询异常跳过本轮,下一轮仍查原任务", async () => {
|
||||||
|
const t = task("query_error");
|
||||||
|
const q = await enqueueTracked(h.d, t);
|
||||||
|
const enqueue = vi.spyOn(h.engine, "enqueue");
|
||||||
|
const get = vi
|
||||||
|
.spyOn(h.engine, "get")
|
||||||
|
.mockRejectedValueOnce(new Error("temporary engine outage"))
|
||||||
|
.mockResolvedValue(runView(q.runId, {}, "running"));
|
||||||
|
expect(await enqueueSweep(h.d, t, "next")).toBeNull();
|
||||||
|
expect(await enqueueSweep(h.d, t, "later")).toBeNull();
|
||||||
|
expect(enqueue).not.toHaveBeenCalled();
|
||||||
|
expect(get).toHaveBeenNthCalledWith(2, q.runId);
|
||||||
|
expect(
|
||||||
|
(await h.db.selectFrom("task").select("engine_run_id").where("id", "=", q.taskId).executeTakeFirstOrThrow())
|
||||||
|
.engine_run_id,
|
||||||
|
).toBe(q.runId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["missing", "completed"])("明确 %s 时允许补投", async (state) => {
|
||||||
|
const t = task(`retry_${state}`);
|
||||||
|
const q = await enqueueTracked(h.d, t);
|
||||||
|
vi.spyOn(h.engine, "get").mockResolvedValue(state === "missing" ? null : runView(q.runId, {}, "completed"));
|
||||||
|
const enqueue = vi.spyOn(h.engine, "enqueue");
|
||||||
|
expect(await enqueueSweep(h.d, t, "next")).not.toBeNull();
|
||||||
|
expect(enqueue).toHaveBeenCalledOnce();
|
||||||
|
expect(enqueue.mock.calls[0]?.[2].idempotencyKey).toBe(`${t.kind}:${t.idempotencyKey}:next`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([false, true])("关联写回失败后巡检按原 taskId 找回已接受的 run (旧关联=%s)", async (oldAssociation) => {
|
||||||
|
const t = task(`association_${oldAssociation}`);
|
||||||
|
if (oldAssociation) await enqueueTracked(h.d, t);
|
||||||
|
await rejectAssociation("task");
|
||||||
|
const q = await enqueueTracked(h.d, t, { engineKey: "new-slot" });
|
||||||
|
await h.db
|
||||||
|
.insertInto("tasks_run_meta")
|
||||||
|
.values({ run_id: q.runId, task_name: t.kind, task_version: 1, idempotency_key: "new-slot", trace_id: q.taskId })
|
||||||
|
.execute();
|
||||||
|
await allowAssociation();
|
||||||
|
vi.spyOn(h.engine, "get").mockResolvedValue(runView(q.runId, {}, "running"));
|
||||||
|
const enqueue = vi.spyOn(h.engine, "enqueue");
|
||||||
|
expect(await enqueueSweep(h.d, t, "later")).toBeNull();
|
||||||
|
expect(enqueue).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
(await h.db.selectFrom("task").select("engine_run_id").where("id", "=", q.taskId).executeTakeFirstOrThrow())
|
||||||
|
.engine_run_id,
|
||||||
|
).toBe(q.runId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("刮削已接受而关联失败时保持 queued,重试复用记录与原幂等键", async () => {
|
||||||
|
const input = scrape("association-retry");
|
||||||
|
await rejectAssociation("scrape_record");
|
||||||
|
const q = await queueScrape(h.d, input);
|
||||||
|
expect(
|
||||||
|
await h.db
|
||||||
|
.selectFrom("scrape_record")
|
||||||
|
.select(["status", "reason", "engine_run_id"])
|
||||||
|
.where("id", "=", q.recordId)
|
||||||
|
.executeTakeFirstOrThrow(),
|
||||||
|
).toMatchObject({ status: "queued", reason: null, engine_run_id: null });
|
||||||
|
const key = h.engine.enqueued.at(-1)?.key;
|
||||||
|
await allowAssociation();
|
||||||
|
const enqueue = vi.spyOn(h.engine, "enqueue").mockResolvedValue({ runId: q.taskId!, deduplicated: true });
|
||||||
|
vi.spyOn(h.engine, "get").mockResolvedValue(runView(q.taskId!, { recordId: q.recordId }));
|
||||||
|
expect(await queueScrape(h.d, input)).toEqual({ recordId: q.recordId, taskId: q.taskId, deduplicated: true });
|
||||||
|
expect(enqueue.mock.calls[0]?.[2].idempotencyKey).toBe(key);
|
||||||
|
expect((await h.db.selectFrom("scrape_record").select("id").where("path", "=", input.path).execute()).length).toBe(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("刮削处理器开始时补关联,不会把已接受的工作误标为入队失败", async () => {
|
||||||
|
await rejectAssociation("scrape_record");
|
||||||
|
const q = await queueScrape(h.d, scrape("worker-backfill"));
|
||||||
|
await allowAssociation();
|
||||||
|
await h.db.updateTable("library_root").set({ management_mode: "register_only" }).where("id", "=", rootId).execute();
|
||||||
|
const r = await runScrapeRecord(h.d, q.recordId, undefined, q.taskId!);
|
||||||
|
expect(r.reason).toBe("scrape_root_read_only");
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await h.db
|
||||||
|
.selectFrom("scrape_record")
|
||||||
|
.select("engine_run_id")
|
||||||
|
.where("id", "=", q.recordId)
|
||||||
|
.executeTakeFirstOrThrow()
|
||||||
|
).engine_run_id,
|
||||||
|
).toBe(q.taskId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("确实入队失败的新刮削记录标失败", async () => {
|
||||||
|
vi.spyOn(h.engine, "enqueue").mockRejectedValue(new Error("enqueue rejected"));
|
||||||
|
const input = scrape("enqueue-failed");
|
||||||
|
await expect(queueScrape(h.d, input)).rejects.toThrow("enqueue rejected");
|
||||||
|
expect(
|
||||||
|
await h.db
|
||||||
|
.selectFrom("scrape_record")
|
||||||
|
.select(["status", "reason"])
|
||||||
|
.where("path", "=", input.path)
|
||||||
|
.executeTakeFirstOrThrow(),
|
||||||
|
).toMatchObject({ status: "failed", reason: "task_enqueue_failed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("自动重放从原 run 输入找回未关联记录,只删除本次多建的记录", async () => {
|
||||||
|
const input = scrape("auto-replay", "auto-stable-key");
|
||||||
|
await rejectAssociation("scrape_record");
|
||||||
|
const q = await queueScrape(h.d, input);
|
||||||
|
await allowAssociation();
|
||||||
|
await h.db.updateTable("scrape_record").set({ status: "succeeded" }).where("id", "=", q.recordId).execute();
|
||||||
|
vi.spyOn(h.engine, "enqueue").mockResolvedValue({ runId: q.taskId!, deduplicated: true });
|
||||||
|
vi.spyOn(h.engine, "get").mockResolvedValue(runView(q.taskId!, { recordId: q.recordId }, "completed"));
|
||||||
|
expect(await queueScrape(h.d, input)).toEqual({ recordId: q.recordId, taskId: q.taskId, deduplicated: true });
|
||||||
|
const recs = await h.db
|
||||||
|
.selectFrom("scrape_record")
|
||||||
|
.select(["id", "status", "engine_run_id"])
|
||||||
|
.where("path", "=", input.path)
|
||||||
|
.execute();
|
||||||
|
expect(recs).toEqual([{ id: q.recordId, status: "succeeded", engine_run_id: q.taskId }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import type { DB } from "@umt/db";
|
|||||||
import type { Kysely } from "kysely";
|
import type { Kysely } from "kysely";
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { backfillVersionSpecs } from "../services/version-backfill.ts";
|
import { backfillVersionSpecs } from "../services/version-backfill.ts";
|
||||||
import { createHarness, type Harness, pgAvailable, seedEntity, seedSlot } from "./harness.ts";
|
import { createHarness, type Harness, pgAvailable, seedEntity, seedRelease, seedSlot } from "./harness.ts";
|
||||||
|
|
||||||
// LIBV-1:入库版本 API(每集多个版本、季汇总、单目标比对)与规格回填(dry-run / apply)。
|
// LIBV-1:入库版本 API(每集多个版本、季汇总、单目标比对)与规格回填(dry-run / apply)。
|
||||||
|
|
||||||
@@ -272,4 +272,68 @@ describe.skipIf(!hasPg)("LIBV-1 入库版本", () => {
|
|||||||
const dv = r.body.data.seasons[0].episodes[0].versions.find((x: { assetId: string }) => x.assetId === ids.e1dv);
|
const dv = r.body.data.seasons[0].episodes[0].versions.find((x: { assetId: string }) => x.assetId === ids.e1dv);
|
||||||
expect(dv).toMatchObject({ specStored: true, hdr: ["DV", "HDR10"], releaseGroup: "FLUX" });
|
expect(dv).toMatchObject({ specStored: true, hdr: ["DV", "HDR10"], releaseGroup: "FLUX" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("作品详情保留跨过旧条数上限的整理、下载和失败计划供分页查看", async () => {
|
||||||
|
const entityId = await seedEntity(h.db, { kind: "movie", title: "历史分页测试", year: 2026 });
|
||||||
|
const instance = await h.db
|
||||||
|
.insertInto("downloader_instance")
|
||||||
|
.values({ name: "历史下载器", base_url: "http://127.0.0.1:1" })
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
await h.db
|
||||||
|
.insertInto("download_task")
|
||||||
|
.values(
|
||||||
|
Array.from({ length: 105 }, (_, i) => ({
|
||||||
|
instance_id: instance.id,
|
||||||
|
media_entity_id: entityId,
|
||||||
|
torrent_hash: String(i + 1).padStart(40, "0"),
|
||||||
|
name: `历史下载 ${i + 1}`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
const slotId = await seedSlot(h.db, { entityId, name: "历史版本" });
|
||||||
|
const site = await h.db
|
||||||
|
.insertInto("site")
|
||||||
|
.values({ key: "history-source", name: "历史来源", base_url: "http://127.0.0.1:1", adapter: "nexusphp" })
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
const releaseId = await seedRelease(h.db, {
|
||||||
|
siteId: site.id,
|
||||||
|
entityId,
|
||||||
|
title: "历史分页测试",
|
||||||
|
size: 1000,
|
||||||
|
hash: "a".repeat(40),
|
||||||
|
});
|
||||||
|
await h.db
|
||||||
|
.insertInto("acquisition_plan")
|
||||||
|
.values(
|
||||||
|
Array.from({ length: 51 }, (_, i) => ({
|
||||||
|
slot_id: slotId,
|
||||||
|
source_release_id: releaseId,
|
||||||
|
status: "failed" as const,
|
||||||
|
payload_hash: `history-${i}`,
|
||||||
|
idempotency_key: `history:${i}`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
await h.db
|
||||||
|
.insertInto("organization_record")
|
||||||
|
.values(
|
||||||
|
Array.from({ length: 105 }, (_, i) => ({
|
||||||
|
media_entity_id: entityId,
|
||||||
|
trigger: "manual" as const,
|
||||||
|
actor_type: "user" as const,
|
||||||
|
action: "copy" as const,
|
||||||
|
status: "succeeded" as const,
|
||||||
|
source_path: `/history/${i}.mkv`,
|
||||||
|
target_path: `/library/history/${i}.mkv`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
const response = await h.api("GET", `/library/${entityId}`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.data.organizationRecords).toHaveLength(105);
|
||||||
|
expect(response.body.data.downloads).toHaveLength(105);
|
||||||
|
expect(response.body.data.droppedPlans).toHaveLength(51);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { ManualAcquisitionRequest } from "@umt/contracts";
|
||||||
|
import { createMockQb, createTempTree, makeTorrent } from "@umt/testkit";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { evaluateAcquisition } from "../services/acquisition.ts";
|
||||||
|
import { submitDownload } from "../services/downloads.ts";
|
||||||
|
import { planManualAcquisition } from "../services/manual-acquisition.ts";
|
||||||
|
import { createHarness, fakeDisk, pgAvailable, seedBase, seedEntity, seedRelease, seedSlot } from "./harness.ts";
|
||||||
|
|
||||||
|
const hasPg = await pgAvailable();
|
||||||
|
describe.skipIf(!hasPg)("独立手动下载", () => {
|
||||||
|
it("无订阅下载,未知资源确认,同hash沿用原路径", async () => {
|
||||||
|
const qb = await createMockQb({ addVisibleAfterPolls: 0 });
|
||||||
|
const cache = await createTempTree({});
|
||||||
|
const lib = await createTempTree({}, { sentinel: true });
|
||||||
|
const torrents = new Map<string, Uint8Array>();
|
||||||
|
const disk = fakeDisk(10_000_000);
|
||||||
|
disk.setPct(20);
|
||||||
|
const h = await createHarness("manual_acquisition", {
|
||||||
|
diskUsage: disk.fn,
|
||||||
|
torrentSource: async ({ release }) => ({ kind: "file", bytes: torrents.get(release.id)! }),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
|
||||||
|
const entityId = await seedEntity(h.db, { kind: "movie", title: "Manual Movie", year: 2026 });
|
||||||
|
const torrent = makeTorrent({ name: "Manual.Movie.2026.1080p.mkv", length: 300_000 });
|
||||||
|
const relId = await seedRelease(h.db, {
|
||||||
|
siteId: base.siteId,
|
||||||
|
entityId,
|
||||||
|
title: torrent.name,
|
||||||
|
size: 300_000,
|
||||||
|
hash: torrent.hash,
|
||||||
|
});
|
||||||
|
torrents.set(relId, torrent.bytes);
|
||||||
|
const request = (id: string, opts = {}) => ManualAcquisitionRequest.parse({ sourceReleaseId: id, ...opts });
|
||||||
|
const first = await planManualAcquisition(h.ctx, request(relId));
|
||||||
|
expect(first).toMatchObject({ submitted: true, slotId: null });
|
||||||
|
expect(await h.db.selectFrom("subscription").selectAll().execute()).toHaveLength(0);
|
||||||
|
expect(await h.db.selectFrom("acquisition_slot").selectAll().execute()).toHaveLength(0);
|
||||||
|
const sub = await submitDownload(h.d, first.plan!.id);
|
||||||
|
expect(sub.status).toBe("submitted");
|
||||||
|
const rel2 = await seedRelease(h.db, {
|
||||||
|
siteId: base.siteId,
|
||||||
|
entityId,
|
||||||
|
title: "Other.Release.2160p.mkv",
|
||||||
|
size: 300_000,
|
||||||
|
hash: torrent.hash,
|
||||||
|
});
|
||||||
|
torrents.set(rel2, torrent.bytes);
|
||||||
|
const second = await planManualAcquisition(h.ctx, request(rel2, { savePath: `${cache.root}/other` }));
|
||||||
|
const reused = await submitDownload(h.d, second.plan!.id);
|
||||||
|
expect(reused).toMatchObject({ reason: "already_exists", downloadTaskId: sub.downloadTaskId });
|
||||||
|
const dt = await h.db
|
||||||
|
.selectFrom("download_task")
|
||||||
|
.selectAll()
|
||||||
|
.where("id", "=", sub.downloadTaskId!)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
expect(dt.save_path).toBe(cache.root);
|
||||||
|
expect(dt.media_entity_id).toBe(entityId);
|
||||||
|
const unknown = makeTorrent({ name: "Unknown.1080p.mkv", length: 300_000, pieceSeed: 42 });
|
||||||
|
const uid = await seedRelease(h.db, {
|
||||||
|
siteId: base.siteId,
|
||||||
|
entityId,
|
||||||
|
title: unknown.name,
|
||||||
|
size: 300_000,
|
||||||
|
hash: unknown.hash,
|
||||||
|
});
|
||||||
|
await h.db.updateTable("source_release").set({ media_entity_id: null }).where("id", "=", uid).execute();
|
||||||
|
torrents.set(uid, unknown.bytes);
|
||||||
|
expect(await planManualAcquisition(h.ctx, request(uid))).toMatchObject({
|
||||||
|
submitted: false,
|
||||||
|
requiresConfirmation: true,
|
||||||
|
});
|
||||||
|
const confirmed = await planManualAcquisition(h.ctx, request(uid, { allowUnrecognized: true }));
|
||||||
|
const submitted = await submitDownload(h.d, confirmed.plan!.id);
|
||||||
|
expect(submitted.status).toBe("submitted");
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await h.db
|
||||||
|
.selectFrom("download_task")
|
||||||
|
.select("media_entity_id")
|
||||||
|
.where("id", "=", submitted.downloadTaskId!)
|
||||||
|
.executeTakeFirstOrThrow()
|
||||||
|
).media_entity_id,
|
||||||
|
).toBeNull();
|
||||||
|
const autoEntity = await seedEntity(h.db, { kind: "movie", title: "Auto Movie", year: 2026 });
|
||||||
|
const slotId = await seedSlot(h.db, { entityId: autoEntity });
|
||||||
|
const autoTorrent = makeTorrent({
|
||||||
|
name: "Auto.Movie.2026.1080p.BluRay.x264-GRP.mkv",
|
||||||
|
length: 300_000,
|
||||||
|
pieceSeed: 100,
|
||||||
|
});
|
||||||
|
const autoRelease = await seedRelease(h.db, {
|
||||||
|
siteId: base.siteId,
|
||||||
|
entityId: autoEntity,
|
||||||
|
title: autoTorrent.name,
|
||||||
|
size: 300_000,
|
||||||
|
hash: autoTorrent.hash,
|
||||||
|
});
|
||||||
|
torrents.set(autoRelease, autoTorrent.bytes);
|
||||||
|
const auto = await evaluateAcquisition(h.d, { sourceReleaseId: autoRelease });
|
||||||
|
expect(auto.plans).toHaveLength(1);
|
||||||
|
const autoTask = await submitDownload(h.d, auto.plans[0]!);
|
||||||
|
const oldAsset = await h.db
|
||||||
|
.insertInto("media_asset")
|
||||||
|
.values({ media_entity_id: autoEntity, kind: "video", size_bytes: 300_000, status: "active" })
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
await h.db
|
||||||
|
.updateTable("download_task")
|
||||||
|
.set({ status: "imported" })
|
||||||
|
.where("id", "=", autoTask.downloadTaskId!)
|
||||||
|
.execute();
|
||||||
|
const unchanged = await planManualAcquisition(h.ctx, request(autoRelease));
|
||||||
|
expect(unchanged.plan!.id).toBe(auto.plans[0]);
|
||||||
|
const wash = await planManualAcquisition(
|
||||||
|
h.ctx,
|
||||||
|
request(autoRelease, { importMode: "replace", replacementAssetIds: [oldAsset.id] }),
|
||||||
|
);
|
||||||
|
const washed = await submitDownload(h.d, wash.plan!.id);
|
||||||
|
expect(washed.downloadTaskId).toBe(autoTask.downloadTaskId);
|
||||||
|
const linked = await h.db
|
||||||
|
.selectFrom("download_task")
|
||||||
|
.selectAll()
|
||||||
|
.where("id", "=", autoTask.downloadTaskId!)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
expect(linked).toMatchObject({
|
||||||
|
acquisition_plan_id: auto.plans[0],
|
||||||
|
import_plan_id: wash.plan!.id,
|
||||||
|
status: "completed",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await h.db
|
||||||
|
.selectFrom("acquisition_plan")
|
||||||
|
.select("slot_id")
|
||||||
|
.where("id", "=", auto.plans[0]!)
|
||||||
|
.executeTakeFirstOrThrow()
|
||||||
|
).slot_id,
|
||||||
|
).toBe(slotId);
|
||||||
|
expect(h.engine.enqueued).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
task: "verifyAndImport",
|
||||||
|
input: expect.objectContaining({ downloadTaskId: autoTask.downloadTaskId }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await h.close();
|
||||||
|
await qb.close();
|
||||||
|
await cache.cleanup();
|
||||||
|
await lib.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { TmdbClient } from "@umt/adapters";
|
||||||
|
import { DEFAULT_SETTINGS, type OrganizeSourceConfig } from "@umt/contracts";
|
||||||
|
import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit";
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { SourcesRuntime } from "../../sources-identity/runtime.ts";
|
||||||
|
import { syncDownloads } from "../services/downloads.ts";
|
||||||
|
import { executeRecord, planImport } from "../services/organize.ts";
|
||||||
|
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||||
|
|
||||||
|
const hasPg = await pgAvailable();
|
||||||
|
const FILE = "Example.2020.1080p.BluRay.x264-GRP.mkv";
|
||||||
|
describe.skipIf(!hasPg)("configured organize sources (real PG, files, mock qB)", () => {
|
||||||
|
let h: Harness;
|
||||||
|
let lib: TempTree;
|
||||||
|
let cache: TempTree;
|
||||||
|
let qb: MockQb;
|
||||||
|
let otherQb: MockQb;
|
||||||
|
let base: Awaited<ReturnType<typeof seedBase>>;
|
||||||
|
let otherInstance: string;
|
||||||
|
beforeAll(async () => {
|
||||||
|
lib = await createTempTree({}, { sentinel: true });
|
||||||
|
cache = await createTempTree({ [`a/${FILE}`]: 200_000, [`b/${FILE}`]: 200_000, [`tagged/${FILE}`]: 200_000 });
|
||||||
|
qb = await createMockQb();
|
||||||
|
otherQb = await createMockQb();
|
||||||
|
h = await createHarness("source_config");
|
||||||
|
base = await seedBase(h.db, { libraryPath: lib.root, cachePath: cache.root, qbUrl: qb.url });
|
||||||
|
otherInstance = (
|
||||||
|
await h.db
|
||||||
|
.insertInto("downloader_instance")
|
||||||
|
.values({
|
||||||
|
name: "other-qb",
|
||||||
|
base_url: otherQb.url,
|
||||||
|
username: "admin",
|
||||||
|
settings: JSON.stringify({ savePath: cache.root }),
|
||||||
|
})
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow()
|
||||||
|
).id;
|
||||||
|
}, 60_000);
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
afterAll(async () => {
|
||||||
|
await h?.close();
|
||||||
|
await qb?.close();
|
||||||
|
await otherQb?.close();
|
||||||
|
await lib?.cleanup();
|
||||||
|
await cache?.cleanup();
|
||||||
|
});
|
||||||
|
const profile = (patch: Partial<OrganizeSourceConfig> = {}): OrganizeSourceConfig => ({
|
||||||
|
id: randomUUID(),
|
||||||
|
name: "整理来源",
|
||||||
|
enabled: true,
|
||||||
|
source: { root: `dl:${base.instanceId}:0`, path: "a" },
|
||||||
|
target: { root: `lib:${base.rootId}`, path: "华语电影" },
|
||||||
|
mediaType: "movie",
|
||||||
|
category: "华语电影",
|
||||||
|
action: "copy",
|
||||||
|
trigger: "manual",
|
||||||
|
downloaderInstanceId: null,
|
||||||
|
tag: null,
|
||||||
|
intervalSec: 300,
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
const settings = (
|
||||||
|
sources: OrganizeSourceConfig[],
|
||||||
|
classificationYaml = DEFAULT_SETTINGS.organize.classificationYaml,
|
||||||
|
) => h.setSettings("organize", { ...DEFAULT_SETTINGS.organize, minSizeBytes: 100_000, sources, classificationYaml });
|
||||||
|
const request = (entityId: string, source: OrganizeSourceConfig, folder = "a") => ({
|
||||||
|
sourcePaths: [cache.path(`${folder}/${FILE}`)],
|
||||||
|
mediaEntityId: entityId,
|
||||||
|
sourceConfigId: source.id,
|
||||||
|
trigger: "manual" as const,
|
||||||
|
actorType: "user" as const,
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fixed category adds no duplicate; explicit same-category source selects own target and rejects wrong source", async () => {
|
||||||
|
const a = profile();
|
||||||
|
const b = profile({
|
||||||
|
source: { root: `dl:${base.instanceId}:0`, path: "b" },
|
||||||
|
target: { root: `lib:${base.rootId}`, path: "另一个目录/华语电影" },
|
||||||
|
});
|
||||||
|
await settings([a, b]);
|
||||||
|
const movie = await seedEntity(h.db, { kind: "movie", title: "固定片", year: 2020 });
|
||||||
|
const first = await planImport(h.d, request(movie, a));
|
||||||
|
expect(first.plan?.sourceConfigId).toBe(a.id);
|
||||||
|
expect(first.plan?.newBindings[0]?.relativePath).toBe("华语电影/固定片 (2020)");
|
||||||
|
const explicit = await planImport(h.d, { ...request(movie, a), targetDir: join(lib.root, "华语电影") });
|
||||||
|
expect(explicit.plan?.newBindings).toEqual([]);
|
||||||
|
const explicitDst = explicit.plan?.organize.files.find((f) => f.role === "main")?.dst;
|
||||||
|
expect(explicitDst?.startsWith(`${join(lib.root, "华语电影")}/`)).toBe(true);
|
||||||
|
expect(explicitDst?.includes("华语电影/华语电影")).toBe(false);
|
||||||
|
const second = await planImport(h.d, request(movie, b, "b"));
|
||||||
|
expect(second.plan?.sourceConfigId).toBe(b.id);
|
||||||
|
expect(second.plan?.newBindings[0]?.relativePath).toBe("另一个目录/华语电影/固定片 (2020)");
|
||||||
|
const mismatch = await planImport(h.d, request(movie, a, "b"));
|
||||||
|
expect(mismatch.reason).toBe("source_config_mismatch");
|
||||||
|
expect(mismatch.plan).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("automatic category prefixes new binding; changed YAML/target preserves existing binding", async () => {
|
||||||
|
const automatic = profile({ category: null, target: { root: `lib:${base.rootId}`, path: "自动" } });
|
||||||
|
await settings([automatic]);
|
||||||
|
const movie = await seedEntity(h.db, { kind: "movie", title: "动画片", year: 2020 });
|
||||||
|
await h.db
|
||||||
|
.insertInto("external_identity")
|
||||||
|
.values({
|
||||||
|
media_entity_id: movie,
|
||||||
|
provider: "tmdb",
|
||||||
|
entity_type: "movie",
|
||||||
|
external_id: "987",
|
||||||
|
status: "accepted",
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
const getJson = vi.fn().mockResolvedValue({ genres: [{ id: 16 }], original_language: "zh" });
|
||||||
|
vi.spyOn(SourcesRuntime.prototype, "tmdb").mockResolvedValue({ getJson } as unknown as TmdbClient);
|
||||||
|
const plan = await planImport(h.d, { ...request(movie, automatic), dryRun: false });
|
||||||
|
expect(plan.status).toBe("running");
|
||||||
|
const record = await h.db
|
||||||
|
.selectFrom("organization_record")
|
||||||
|
.select("plan")
|
||||||
|
.where("id", "=", plan.recordId!)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
expect(record.plan).toMatchObject({
|
||||||
|
classification: { category: "动画电影" },
|
||||||
|
newBindings: [{ relativePath: "自动/动画电影/动画片 (2020)" }],
|
||||||
|
});
|
||||||
|
expect((await executeRecord(h.d, plan.recordId!)).status).toBe("succeeded");
|
||||||
|
await settings(
|
||||||
|
[{ ...automatic, target: { ...automatic.target, path: "新目录" } }],
|
||||||
|
"movie:\n 新分类: null\ntv: {}\n",
|
||||||
|
);
|
||||||
|
const updated = await planImport(h.d, request(movie, automatic));
|
||||||
|
expect(updated.plan?.classification?.category).toBe("新分类");
|
||||||
|
expect(updated.plan?.newBindings).toEqual([]);
|
||||||
|
expect(updated.plan?.organize.files.find((f) => f.role === "main")?.dst).toContain(
|
||||||
|
join(lib.root, "自动", "动画电影", "动画片 (2020)"),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await h.db
|
||||||
|
.selectFrom("path_binding")
|
||||||
|
.select("relative_path")
|
||||||
|
.where("media_entity_id", "=", movie)
|
||||||
|
.executeTakeFirstOrThrow()
|
||||||
|
).relative_path,
|
||||||
|
).toBe("自动/动画电影/动画片 (2020)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explicit root creates an unbound path there and refuses to relocate an existing binding", async () => {
|
||||||
|
const alternate = await createTempTree({}, { sentinel: true });
|
||||||
|
const alternateRoot = await h.db
|
||||||
|
.insertInto("library_root")
|
||||||
|
.values({ name: "alternate", media_kind: "mixed", local_path: alternate.root, management_mode: "managed" })
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
try {
|
||||||
|
const source = profile();
|
||||||
|
await settings([source]);
|
||||||
|
const fresh = await seedEntity(h.db, { kind: "movie", title: "新根片", year: 2020 });
|
||||||
|
const override = await planImport(h.d, { ...request(fresh, source), targetLibraryRootId: alternateRoot.id });
|
||||||
|
expect(override.plan?.libraryRootId).toBe(alternateRoot.id);
|
||||||
|
expect(override.plan?.newBindings[0]?.relativePath).toBe("新根片 (2020)");
|
||||||
|
const existing = await seedEntity(h.db, { kind: "movie", title: "已绑定片", year: 2020 });
|
||||||
|
const first = await planImport(h.d, { ...request(existing, source), dryRun: false });
|
||||||
|
expect((await executeRecord(h.d, first.recordId!)).status).toBe("succeeded");
|
||||||
|
const attempted = await planImport(h.d, { ...request(existing, source), targetLibraryRootId: alternateRoot.id });
|
||||||
|
expect(attempted.reason).toBe("binding_on_other_root");
|
||||||
|
expect(
|
||||||
|
await h.db
|
||||||
|
.selectFrom("path_binding")
|
||||||
|
.select(["library_root_id", "relative_path"])
|
||||||
|
.where("media_entity_id", "=", existing)
|
||||||
|
.executeTakeFirstOrThrow(),
|
||||||
|
).toMatchObject({ library_root_id: base.rootId, relative_path: "华语电影/已绑定片 (2020)" });
|
||||||
|
} finally {
|
||||||
|
await h.db.updateTable("library_root").set({ enabled: false }).where("id", "=", alternateRoot.id).execute();
|
||||||
|
await alternate.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tag and source path adopt only matching instance; removing tag stops management", async () => {
|
||||||
|
const tag = "source-only-tag";
|
||||||
|
await settings([
|
||||||
|
profile({
|
||||||
|
source: { root: `dl:${base.instanceId}:0`, path: "tagged" },
|
||||||
|
trigger: "qb_tag",
|
||||||
|
downloaderInstanceId: base.instanceId,
|
||||||
|
tag,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const hash = (n: number) => n.toString(16).padStart(40, "0");
|
||||||
|
qb.seed({ hash: hash(1), name: FILE, save_path: cache.path("tagged"), tags: [tag] });
|
||||||
|
qb.seed({ hash: hash(2), name: "No.Tag.mkv", save_path: cache.path("tagged"), tags: [] });
|
||||||
|
qb.seed({ hash: hash(3), name: "Wrong.Path.mkv", save_path: cache.path("b"), tags: [tag] });
|
||||||
|
otherQb.seed({ hash: hash(1), name: FILE, save_path: cache.path("tagged"), tags: [tag] });
|
||||||
|
const result = await syncDownloads(h.d);
|
||||||
|
expect(result.adopted).toBe(1);
|
||||||
|
const rows = await h.db.selectFrom("download_task").selectAll().execute();
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0]).toMatchObject({ instance_id: base.instanceId, torrent_hash: hash(1), managed: true });
|
||||||
|
expect(rows.some((r) => r.instance_id === otherInstance)).toBe(false);
|
||||||
|
qb.get(hash(1))!.tags = [];
|
||||||
|
await syncDownloads(h.d);
|
||||||
|
expect(
|
||||||
|
(await h.db.selectFrom("download_task").selectAll().where("id", "=", rows[0]!.id).executeTakeFirstOrThrow())
|
||||||
|
.managed,
|
||||||
|
).toBe(false);
|
||||||
|
qb.get(hash(1))!.tags = [tag];
|
||||||
|
await syncDownloads(h.d);
|
||||||
|
expect(
|
||||||
|
(await h.db.selectFrom("download_task").selectAll().where("id", "=", rows[0]!.id).executeTakeFirstOrThrow())
|
||||||
|
.managed,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
it("automatic source with movie-only media type refuses a TV entity instead of generic organization", async () => {
|
||||||
|
const tag = "movie-source";
|
||||||
|
await settings([
|
||||||
|
profile({
|
||||||
|
source: { root: `dl:${base.instanceId}:0`, path: "tagged" },
|
||||||
|
trigger: "qb_tag",
|
||||||
|
downloaderInstanceId: base.instanceId,
|
||||||
|
tag,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const hash = "4".padStart(40, "0");
|
||||||
|
const name = "Only.TV.S01E01.1080p.WEB-DL.x264-GRP.mkv";
|
||||||
|
qb.seed({ hash, name, save_path: cache.path("tagged"), tags: [tag] });
|
||||||
|
await qb.complete(hash, { files: [{ path: name, size: 200_000 }] });
|
||||||
|
await syncDownloads(h.d);
|
||||||
|
const row = await h.db
|
||||||
|
.selectFrom("download_task")
|
||||||
|
.selectAll()
|
||||||
|
.where("torrent_hash", "=", hash)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
const entity = await seedEntity(h.db, { kind: "series", title: "Only TV", year: 2020 });
|
||||||
|
await h.db.updateTable("download_task").set({ media_entity_id: entity }).where("id", "=", row.id).execute();
|
||||||
|
const planned = await planImport(h.d, {
|
||||||
|
downloadTaskId: row.id,
|
||||||
|
trigger: "auto",
|
||||||
|
actorType: "system",
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
expect(planned.reason).toBe("source_config_mismatch");
|
||||||
|
expect(planned.plan).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -108,6 +108,33 @@ describe.skipIf(skip)("整理记录筛选与统计", () => {
|
|||||||
expect(movies.body.data).toMatchObject({ total: 1, succeeded: 1, failed: 0 });
|
expect(movies.body.data).toMatchObject({ total: 1, succeeded: 1, failed: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("历史分页和统计排除排队与运行记录,显式active仍可查询", async () => {
|
||||||
|
const inserted = await h.db
|
||||||
|
.insertInto("organization_record")
|
||||||
|
.values([
|
||||||
|
{ trigger: "manual", actor_type: "user", action: "copy", status: "queued", source_path: "/queued.mkv" },
|
||||||
|
{ trigger: "manual", actor_type: "user", action: "move", status: "running", source_path: "/running.mkv" },
|
||||||
|
])
|
||||||
|
.returning("id")
|
||||||
|
.execute();
|
||||||
|
try {
|
||||||
|
expect(await list("phase=history")).toHaveLength(4);
|
||||||
|
expect(idsOf(await list("phase=active"))).toEqual(inserted.map((item) => item.id).sort());
|
||||||
|
expect(await list("phase=history&status=queued")).toHaveLength(0);
|
||||||
|
const stats = await h.api("GET", "/organization-records/stats?phase=history");
|
||||||
|
expect(stats.body.data).toMatchObject({ total: 4, running: 0 });
|
||||||
|
} finally {
|
||||||
|
await h.db
|
||||||
|
.deleteFrom("organization_record")
|
||||||
|
.where(
|
||||||
|
"id",
|
||||||
|
"in",
|
||||||
|
inserted.map((item) => item.id),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("列表项带海报、大小、下载器名称与 hash", async () => {
|
it("列表项带海报、大小、下载器名称与 hash", async () => {
|
||||||
const [film] = await list("q=Arrival.2016");
|
const [film] = await list("q=Arrival.2016");
|
||||||
expect(film).toMatchObject({
|
expect(film).toMatchObject({
|
||||||
|
|||||||
@@ -0,0 +1,440 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
import { DEFAULT_SETTINGS } from "@umt/contracts";
|
||||||
|
import { createMockQb, createTempTree, type MockQb, type TempTree } from "@umt/testkit";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { executeRecord, planImport } from "../services/organize.ts";
|
||||||
|
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||||
|
|
||||||
|
const hasPg = await pgAvailable();
|
||||||
|
describe.skipIf(!hasPg)("one-time replacement (real PG and library files)", () => {
|
||||||
|
let h: Harness;
|
||||||
|
let lib: TempTree;
|
||||||
|
let src: TempTree;
|
||||||
|
let rootId: string;
|
||||||
|
let instanceId: string;
|
||||||
|
let siteId: string;
|
||||||
|
let qb: MockQb;
|
||||||
|
beforeAll(async () => {
|
||||||
|
lib = await createTempTree({}, { sentinel: true });
|
||||||
|
src = await createTempTree({
|
||||||
|
"Movie.2020.1080p.BluRay.x264-GRP.mkv": 200_000,
|
||||||
|
"Same.2020.1080p.BluRay.x264-GRP.mkv": 200_000,
|
||||||
|
...Object.fromEntries(
|
||||||
|
Array.from({ length: 6 }, (_, i) => [
|
||||||
|
`Show.S01E${String(i + 1).padStart(2, "0")}.1080p.WEB-DL.x264-GRP.mkv`,
|
||||||
|
200_000,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
qb = await createMockQb();
|
||||||
|
h = await createHarness("replacement");
|
||||||
|
({ rootId, instanceId, siteId } = await seedBase(h.db, {
|
||||||
|
libraryPath: lib.root,
|
||||||
|
cachePath: src.root,
|
||||||
|
qbUrl: qb.url,
|
||||||
|
}));
|
||||||
|
await h.setSettings("naming", {
|
||||||
|
tv: "{{title}} ({{year}})/Season {{season}}/{{title}} - {{season_episode}} - {{videoFormat}}{{fileExt}}",
|
||||||
|
});
|
||||||
|
}, 60_000);
|
||||||
|
afterAll(async () => {
|
||||||
|
await h?.close();
|
||||||
|
await qb?.close();
|
||||||
|
await lib?.cleanup();
|
||||||
|
await src?.cleanup();
|
||||||
|
});
|
||||||
|
const old = async (entityId: string, path: string, parent?: string) => {
|
||||||
|
await mkdir(dirname(lib.path(path)), { recursive: true });
|
||||||
|
await writeFile(lib.path(path), Buffer.alloc(200_001, 7));
|
||||||
|
const asset = await h.db
|
||||||
|
.insertInto("media_asset")
|
||||||
|
.values({
|
||||||
|
media_entity_id: entityId,
|
||||||
|
kind: parent ? "subtitle" : "video",
|
||||||
|
parent_asset_id: parent ?? null,
|
||||||
|
status: "active",
|
||||||
|
size_bytes: 200_001,
|
||||||
|
})
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
await h.db
|
||||||
|
.insertInto("asset_location")
|
||||||
|
.values({
|
||||||
|
media_asset_id: asset.id,
|
||||||
|
location_kind: "library",
|
||||||
|
library_root_id: rootId,
|
||||||
|
relative_path: path,
|
||||||
|
status: "available",
|
||||||
|
is_primary: true,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
return asset.id;
|
||||||
|
};
|
||||||
|
const request = (entityId: string, paths: string[], assetIds: string[] = []) => ({
|
||||||
|
mediaEntityId: entityId,
|
||||||
|
sourcePaths: paths.map((p) => src.path(p)),
|
||||||
|
trigger: "manual" as const,
|
||||||
|
actorType: "user" as const,
|
||||||
|
action: "copy" as const,
|
||||||
|
importMode: "replace" as const,
|
||||||
|
replacementAssetIds: assetIds,
|
||||||
|
});
|
||||||
|
const status = async (id: string) =>
|
||||||
|
(await h.db.selectFrom("media_asset").select("status").where("id", "=", id).executeTakeFirstOrThrow()).status;
|
||||||
|
it("new movie registers first, retires only selected old and its private subtitle, preserving unselected/shared/cache", async () => {
|
||||||
|
const e = await seedEntity(h.db, {
|
||||||
|
kind: "movie",
|
||||||
|
title: "Movie",
|
||||||
|
year: 2020,
|
||||||
|
});
|
||||||
|
const path = "Movie (2020)/Movie (2020) - 720p.mkv";
|
||||||
|
const selected = await old(e, path);
|
||||||
|
const subtitle = await old(e, path.replace(".mkv", ".en.srt"), selected);
|
||||||
|
const kept = await old(e, "Movie (2020)/Movie (2020) - 2160p.mkv");
|
||||||
|
await writeFile(lib.path("Movie (2020)/movie.nfo"), "shared");
|
||||||
|
const planned = await planImport(h.d, request(e, ["Movie.2020.1080p.BluRay.x264-GRP.mkv"], [selected]));
|
||||||
|
expect(planned.status).toBe("running");
|
||||||
|
expect(existsSync(lib.path(path))).toBe(true);
|
||||||
|
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded");
|
||||||
|
expect(await status(selected)).toBe("retired");
|
||||||
|
expect(await status(subtitle)).toBe("retired");
|
||||||
|
expect(await status(kept)).toBe("active");
|
||||||
|
expect(existsSync(lib.path(path))).toBe(false);
|
||||||
|
expect(existsSync(lib.path("Movie (2020)/movie.nfo"))).toBe(true);
|
||||||
|
expect(existsSync(src.path("Movie.2020.1080p.BluRay.x264-GRP.mkv"))).toBe(true);
|
||||||
|
expect(await h.db.selectFrom("trash_item").selectAll().execute()).toHaveLength(2);
|
||||||
|
await executeRecord(h.d, planned.recordId!);
|
||||||
|
expect(await h.db.selectFrom("trash_item").selectAll().execute()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
it("same-path replaces selected old asset with a fresh asset, but normal/unselected cannot overwrite", async () => {
|
||||||
|
const e = await seedEntity(h.db, {
|
||||||
|
kind: "movie",
|
||||||
|
title: "Same",
|
||||||
|
year: 2020,
|
||||||
|
});
|
||||||
|
const selected = await old(e, "Same (2020)/Same (2020) - 1080p.mkv");
|
||||||
|
const normal = await planImport(h.d, {
|
||||||
|
...request(e, ["Same.2020.1080p.BluRay.x264-GRP.mkv"]),
|
||||||
|
importMode: "normal",
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
expect(normal.plan?.organize.files[0]?.action).toBe("hold");
|
||||||
|
const planned = await planImport(h.d, request(e, ["Same.2020.1080p.BluRay.x264-GRP.mkv"], [selected]));
|
||||||
|
expect(planned.status).toBe("running");
|
||||||
|
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded");
|
||||||
|
expect(await status(selected)).toBe("retired");
|
||||||
|
const active = await h.db
|
||||||
|
.selectFrom("media_asset")
|
||||||
|
.select("id")
|
||||||
|
.where("media_entity_id", "=", e)
|
||||||
|
.where("status", "=", "active")
|
||||||
|
.execute();
|
||||||
|
expect(active).toHaveLength(1);
|
||||||
|
expect(active[0]!.id).not.toBe(selected);
|
||||||
|
});
|
||||||
|
it("selected grants cannot overwrite an unselected path, and changed old files survive cleanup", async () => {
|
||||||
|
const e = await seedEntity(h.db, {
|
||||||
|
kind: "movie",
|
||||||
|
title: "Protected",
|
||||||
|
year: 2020,
|
||||||
|
});
|
||||||
|
const lower = await old(e, "Protected (2020)/Protected (2020) - 720p.mkv");
|
||||||
|
const occupied = await old(e, "Protected (2020)/Protected (2020) - 1080p.mkv");
|
||||||
|
const blocked = await planImport(h.d, {
|
||||||
|
...request(e, ["Movie.2020.1080p.BluRay.x264-GRP.mkv"], [lower]),
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
expect(blocked.plan?.organize.files[0]?.action).toBe("hold");
|
||||||
|
expect(await status(occupied)).toBe("active");
|
||||||
|
const other = await seedEntity(h.db, {
|
||||||
|
kind: "movie",
|
||||||
|
title: "Stale",
|
||||||
|
year: 2020,
|
||||||
|
});
|
||||||
|
const oldPath = "Stale (2020)/Stale (2020) - 720p.mkv";
|
||||||
|
const stale = await old(other, oldPath);
|
||||||
|
const planned = await planImport(h.d, request(other, ["Movie.2020.1080p.BluRay.x264-GRP.mkv"], [stale]));
|
||||||
|
await writeFile(lib.path(oldPath), "changed-after-plan");
|
||||||
|
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded");
|
||||||
|
expect(await status(stale)).toBe("active");
|
||||||
|
expect((await readFile(lib.path(oldPath))).toString()).toBe("changed-after-plan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("same-path failed final probe restores old bytes and asset, even with delete policy", async () => {
|
||||||
|
const e = await seedEntity(h.db, {
|
||||||
|
kind: "movie",
|
||||||
|
title: "Same",
|
||||||
|
year: 2021,
|
||||||
|
});
|
||||||
|
const path = "Same (2021)/Same (2021) - 1080p.mkv";
|
||||||
|
const selected = await old(e, path);
|
||||||
|
const savedProbe = h.d.ports.probe;
|
||||||
|
const savedConfig = h.ctx.config;
|
||||||
|
h.ctx.config = { ...savedConfig, FS_REPLACE_OLD: "delete" };
|
||||||
|
h.d.ports.probe = async () => ({
|
||||||
|
status: "ok",
|
||||||
|
video: [],
|
||||||
|
audio: [],
|
||||||
|
subtitles: [],
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const planned = await planImport(h.d, request(e, ["Same.2020.1080p.BluRay.x264-GRP.mkv"], [selected]));
|
||||||
|
expect(planned.status).toBe("running");
|
||||||
|
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("failed");
|
||||||
|
expect((await readFile(lib.path(path))).equals(Buffer.alloc(200_001, 7))).toBe(true);
|
||||||
|
expect(await status(selected)).toBe("active");
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await h.db
|
||||||
|
.selectFrom("asset_location")
|
||||||
|
.select("status")
|
||||||
|
.where("media_asset_id", "=", selected)
|
||||||
|
.executeTakeFirstOrThrow()
|
||||||
|
).status,
|
||||||
|
).toBe("available");
|
||||||
|
await executeRecord(h.d, planned.recordId!);
|
||||||
|
expect((await readFile(lib.path(path))).length).toBe(200_001);
|
||||||
|
} finally {
|
||||||
|
h.d.ports.probe = savedProbe;
|
||||||
|
h.ctx.config = savedConfig;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("old E01-E10 only loses successful new E01-E06; failed E04 and old merged range survive", async () => {
|
||||||
|
const e = await seedEntity(h.db, {
|
||||||
|
kind: "series",
|
||||||
|
title: "Show",
|
||||||
|
year: 2020,
|
||||||
|
});
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let i = 1; i <= 10; i++) {
|
||||||
|
ids.push(await old(e, `Show (2020)/Season 1/Show - S01E${String(i).padStart(2, "0")} - 720p.mkv`));
|
||||||
|
}
|
||||||
|
const merged = await old(e, "Show (2020)/Season 1/Show - S01E01-E10 - 480p.mkv");
|
||||||
|
const saved = h.d.ports.probe;
|
||||||
|
h.d.ports.probe = async (path) =>
|
||||||
|
path.includes("E04")
|
||||||
|
? { status: "ok", video: [], audio: [], subtitles: [] }
|
||||||
|
: { status: "unknown", reason: "ffprobe_unavailable" };
|
||||||
|
try {
|
||||||
|
const paths = Array.from(
|
||||||
|
{ length: 6 },
|
||||||
|
(_, i) => `Show.S01E${String(i + 1).padStart(2, "0")}.1080p.WEB-DL.x264-GRP.mkv`,
|
||||||
|
);
|
||||||
|
const planned = await planImport(h.d, request(e, paths, [...ids, merged]));
|
||||||
|
expect(planned.status).toBe("running");
|
||||||
|
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("partial");
|
||||||
|
for (let i = 1; i <= 10; i++) {
|
||||||
|
expect(await status(ids[i - 1]!)).toBe(i <= 6 && i !== 4 ? "retired" : "active");
|
||||||
|
}
|
||||||
|
expect(await status(merged)).toBe("active");
|
||||||
|
} finally {
|
||||||
|
h.d.ports.probe = saved;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("unknown manual task resolves Local, classifies and registers in the same import, then explicit reused-hash replacement uses its new plan", async () => {
|
||||||
|
const name = "Unknown.2022.1080p.WEB-DL.x264-GRP.mkv";
|
||||||
|
const hash = "a".repeat(40);
|
||||||
|
qb.seed({ hash, name, tags: ["umt"], save_path: src.root });
|
||||||
|
await qb.complete(hash, {
|
||||||
|
writeFiles: true,
|
||||||
|
files: [{ path: name, size: 200_000 }],
|
||||||
|
});
|
||||||
|
const release = await h.db
|
||||||
|
.insertInto("source_release")
|
||||||
|
.values({
|
||||||
|
site_id: siteId,
|
||||||
|
site_release_id: randomUUID(),
|
||||||
|
title: name,
|
||||||
|
size_bytes: 200_000,
|
||||||
|
raw: JSON.stringify({}),
|
||||||
|
resolution_status: "unresolved",
|
||||||
|
})
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
const makePlan = async (evaluation: object) =>
|
||||||
|
(
|
||||||
|
await h.db
|
||||||
|
.insertInto("acquisition_plan")
|
||||||
|
.values({
|
||||||
|
slot_id: null,
|
||||||
|
source_release_id: release.id,
|
||||||
|
status: "submitted",
|
||||||
|
payload_hash: randomUUID(),
|
||||||
|
idempotency_key: randomUUID(),
|
||||||
|
evaluation: JSON.stringify(evaluation),
|
||||||
|
})
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow()
|
||||||
|
).id;
|
||||||
|
const initialPlan = await makePlan({
|
||||||
|
manual: true,
|
||||||
|
mediaEntityId: null,
|
||||||
|
importMode: "normal",
|
||||||
|
replacementAssetIds: [],
|
||||||
|
});
|
||||||
|
const dt = await h.db
|
||||||
|
.insertInto("download_task")
|
||||||
|
.values({
|
||||||
|
instance_id: instanceId,
|
||||||
|
torrent_hash: hash,
|
||||||
|
name,
|
||||||
|
save_path: src.root,
|
||||||
|
content_path: src.path(name),
|
||||||
|
managed: true,
|
||||||
|
tags: ["umt"],
|
||||||
|
status: "completed",
|
||||||
|
source_release_id: release.id,
|
||||||
|
acquisition_plan_id: initialPlan,
|
||||||
|
import_plan_id: initialPlan,
|
||||||
|
})
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
const local = await seedEntity(h.db, {
|
||||||
|
kind: "movie",
|
||||||
|
title: "本地作品",
|
||||||
|
year: 2022,
|
||||||
|
});
|
||||||
|
await h.db
|
||||||
|
.updateTable("media_entity")
|
||||||
|
.set({
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
title: {
|
||||||
|
value: "本地作品",
|
||||||
|
source: "web",
|
||||||
|
evidence: { sourceUrl: "https://example.test/detail" },
|
||||||
|
},
|
||||||
|
originalLanguage: { value: "zh", source: "web" },
|
||||||
|
genres: { value: ["剧情"], source: "web" },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.where("id", "=", local)
|
||||||
|
.execute();
|
||||||
|
const sourceId = randomUUID();
|
||||||
|
await h.setSettings("organize", {
|
||||||
|
...DEFAULT_SETTINGS.organize,
|
||||||
|
minSizeBytes: 100_000,
|
||||||
|
scrapeAfterImport: false,
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: sourceId,
|
||||||
|
name: "Local来源",
|
||||||
|
enabled: true,
|
||||||
|
source: { root: `dl:${instanceId}:0`, path: "" },
|
||||||
|
target: { root: `lib:${rootId}`, path: "" },
|
||||||
|
mediaType: "movie",
|
||||||
|
category: null,
|
||||||
|
action: "copy",
|
||||||
|
trigger: "manual",
|
||||||
|
downloaderInstanceId: null,
|
||||||
|
tag: null,
|
||||||
|
intervalSec: 300,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const has = vi.spyOn(h.ctx.capabilities, "has").mockReturnValue(true);
|
||||||
|
const invoke = vi.spyOn(h.ctx.capabilities, "invoke").mockResolvedValue({
|
||||||
|
mediaEntityId: local,
|
||||||
|
mode: "local",
|
||||||
|
enrichmentId: randomUUID(),
|
||||||
|
reason: null,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const planned = await planImport(h.d, {
|
||||||
|
downloadTaskId: dt.id,
|
||||||
|
sourceConfigId: sourceId,
|
||||||
|
trigger: "auto",
|
||||||
|
actorType: "system",
|
||||||
|
});
|
||||||
|
expect(planned.status).toBe("running");
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await h.db
|
||||||
|
.selectFrom("organization_record")
|
||||||
|
.select("plan")
|
||||||
|
.where("id", "=", planned.recordId!)
|
||||||
|
.executeTakeFirstOrThrow()
|
||||||
|
).plan,
|
||||||
|
).toMatchObject({ metadataMode: "local" });
|
||||||
|
expect(invoke).toHaveBeenCalledWith(
|
||||||
|
"metadata.resolve_organize",
|
||||||
|
{ sourceReleaseId: release.id },
|
||||||
|
expect.objectContaining({ source: "task" }),
|
||||||
|
);
|
||||||
|
expect((await executeRecord(h.d, planned.recordId!)).status).toBe("succeeded");
|
||||||
|
expect(
|
||||||
|
await h.db
|
||||||
|
.selectFrom("media_asset")
|
||||||
|
.select("id")
|
||||||
|
.where("media_entity_id", "=", local)
|
||||||
|
.where("status", "=", "active")
|
||||||
|
.execute(),
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await h.db
|
||||||
|
.selectFrom("path_binding")
|
||||||
|
.select("relative_path")
|
||||||
|
.where("media_entity_id", "=", local)
|
||||||
|
.executeTakeFirstOrThrow()
|
||||||
|
).relative_path,
|
||||||
|
).toBe("华语电影/本地作品 (2022)");
|
||||||
|
expect(h.engine.enqueued.some((j) => j.task === "scrapeMedia")).toBe(true);
|
||||||
|
const selected = await old(local, "华语电影/本地作品 (2022)/本地作品 (2022) - 720p.mkv");
|
||||||
|
const secondPlan = await makePlan({
|
||||||
|
manual: true,
|
||||||
|
mediaEntityId: local,
|
||||||
|
importMode: "replace",
|
||||||
|
replacementAssetIds: [selected],
|
||||||
|
});
|
||||||
|
await h.db
|
||||||
|
.updateTable("download_task")
|
||||||
|
.set({ import_plan_id: secondPlan, status: "completed" })
|
||||||
|
.where("id", "=", dt.id)
|
||||||
|
.execute();
|
||||||
|
const rerun = await planImport(h.d, {
|
||||||
|
downloadTaskId: dt.id,
|
||||||
|
sourceConfigId: sourceId,
|
||||||
|
trigger: "auto",
|
||||||
|
actorType: "system",
|
||||||
|
});
|
||||||
|
expect(rerun.status).toBe("running");
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await h.db
|
||||||
|
.selectFrom("organization_record")
|
||||||
|
.select("plan")
|
||||||
|
.where("id", "=", rerun.recordId!)
|
||||||
|
.executeTakeFirstOrThrow()
|
||||||
|
).plan,
|
||||||
|
).toMatchObject({ metadataMode: "local" });
|
||||||
|
expect(invoke).toHaveBeenCalledTimes(1);
|
||||||
|
await executeRecord(h.d, rerun.recordId!);
|
||||||
|
expect(await status(selected)).toBe("retired");
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await h.db
|
||||||
|
.selectFrom("acquisition_plan")
|
||||||
|
.select("status")
|
||||||
|
.where("id", "=", secondPlan)
|
||||||
|
.executeTakeFirstOrThrow()
|
||||||
|
).status,
|
||||||
|
).toBe("completed");
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await h.db
|
||||||
|
.selectFrom("download_task")
|
||||||
|
.select("acquisition_plan_id")
|
||||||
|
.where("id", "=", dt.id)
|
||||||
|
.executeTakeFirstOrThrow()
|
||||||
|
).acquisition_plan_id,
|
||||||
|
).toBe(initialPlan);
|
||||||
|
} finally {
|
||||||
|
has.mockRestore();
|
||||||
|
invoke.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ import { join, relative } from "node:path";
|
|||||||
import { createTempTree, type TempTree } from "@umt/testkit";
|
import { createTempTree, type TempTree } from "@umt/testkit";
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { executeRecord, planImport } from "../services/organize.ts";
|
import { executeRecord, planImport } from "../services/organize.ts";
|
||||||
import { runScrapeRecord, SCRAPE_TASK, scrapeEntity } from "../services/scrape.ts";
|
import { entityScrapeLocations, runScrapeRecord, SCRAPE_TASK, scrapeEntity } from "../services/scrape.ts";
|
||||||
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "./harness.ts";
|
||||||
|
|
||||||
// 刮削(SCR-1 / SCR-2):入库成功后自动对作品目录排 scrapeMedia → 写 NFO 与图片(临时库根);只补缺失 / 全部覆盖;
|
// 刮削(SCR-1 / SCR-2):入库成功后自动对作品目录排 scrapeMedia → 写 NFO 与图片(临时库根);只补缺失 / 全部覆盖;
|
||||||
@@ -299,6 +299,21 @@ describe.skipIf(!hasPg)("刮削:NFO 与图片写进媒体库目录", () => {
|
|||||||
expect(nfo).toContain("<runtime>169</runtime>");
|
expect(nfo).toContain("<runtime>169</runtime>");
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
|
it("蓝光原盘资产:原盘目录本身就是刮削目录,不取上一级", async () => {
|
||||||
|
const disc = await seedEntity(h.db, { kind: "movie", title: "原盘", year: 2020 });
|
||||||
|
const root = await h.db.selectFrom("library_root").select("id").executeTakeFirstOrThrow();
|
||||||
|
const asset = await h.db
|
||||||
|
.insertInto("media_asset")
|
||||||
|
.values({ media_entity_id: disc, kind: "bluray_dir" })
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
await h.db
|
||||||
|
.insertInto("asset_location")
|
||||||
|
.values({ media_asset_id: asset.id, library_root_id: root.id, relative_path: "Movies/原盘 (2020)" })
|
||||||
|
.execute();
|
||||||
|
expect((await entityScrapeLocations(h.d, disc, "movie")).map((l) => l.path)).toEqual(["Movies/原盘 (2020)"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("写入失败记原因;只登记库根不写文件;没有入库文件的作品 412", async () => {
|
it("写入失败记原因;只登记库根不写文件;没有入库文件的作品 412", async () => {
|
||||||
const files = await tree(lib.root);
|
const files = await tree(lib.root);
|
||||||
const video = files.find((p) => p.includes("星际穿越") && p.endsWith(".mkv"))!;
|
const video = files.find((p) => p.includes("星际穿越") && p.endsWith(".mkv"))!;
|
||||||
|
|||||||
@@ -249,16 +249,19 @@ describe.skipIf(!hasPg)("订阅增强(真实 PG + mock qB + 临时目录)", () =
|
|||||||
.transaction()
|
.transaction()
|
||||||
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 3, now: new Date() }));
|
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 3, now: new Date() }));
|
||||||
const r2 = await release(entityId, "Unknown.Total.S01E02.1080p.WEB-DL.x264-GRP");
|
const r2 = await release(entityId, "Unknown.Total.S01E02.1080p.WEB-DL.x264-GRP");
|
||||||
await completeAndImport(r2.hash, await submit(await planFor(r2.id)));
|
const acceptedTask = await submit(await planFor(r2.id));
|
||||||
|
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
|
||||||
|
expect((await slotRow(slotId)).status).toBe("downloading");
|
||||||
|
await completeAndImport(r2.hash, acceptedTask);
|
||||||
expect((await slotRow(slotId)).status).toBe("satisfied");
|
expect((await slotRow(slotId)).status).toBe("satisfied");
|
||||||
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "slot_satisfied" });
|
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
|
||||||
const hist = await db()
|
const hist = await db()
|
||||||
.selectFrom("subscription_history")
|
.selectFrom("subscription_history")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.where("subscription_id", "=", subId)
|
.where("subscription_id", "=", subId)
|
||||||
.execute();
|
.execute();
|
||||||
expect(hist).toHaveLength(1);
|
expect(hist).toHaveLength(1);
|
||||||
expect(hist[0]).toMatchObject({ reason: "slot_satisfied", media_entity_id: entityId, mode: "once" });
|
expect(hist[0]).toMatchObject({ reason: "download_accepted", media_entity_id: entityId, mode: "once" });
|
||||||
expect((hist[0]!.progress as { seasons: { complete: boolean }[] }).seasons[0]!.complete).toBe(true);
|
expect((hist[0]!.progress as { seasons: { complete: boolean }[] }).seasons[0]!.complete).toBe(true);
|
||||||
// 历史 API + 重新订阅(改回 active)
|
// 历史 API + 重新订阅(改回 active)
|
||||||
const api = await h.api("GET", `/subscription-history?subscriptionId=${subId}`);
|
const api = await h.api("GET", `/subscription-history?subscriptionId=${subId}`);
|
||||||
@@ -267,6 +270,32 @@ describe.skipIf(!hasPg)("订阅增强(真实 PG + mock qB + 临时目录)", () =
|
|||||||
expect(re.body.data.status).toBe("active");
|
expect(re.body.data.status).toBe("active");
|
||||||
}, 90_000);
|
}, 90_000);
|
||||||
|
|
||||||
|
it("多个目标版本按各自已接收单元完成,单版本入库不提前完成订阅", async () => {
|
||||||
|
const { entityId, subId } = await seriesSlot("双版本剧", { mode: "once" });
|
||||||
|
await db()
|
||||||
|
.insertInto("acquisition_slot")
|
||||||
|
.values({
|
||||||
|
media_entity_id: entityId,
|
||||||
|
subscription_id: subId,
|
||||||
|
name: "720p",
|
||||||
|
hard_conditions: JSON.stringify({ resolutions: ["720p"] }),
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
await db()
|
||||||
|
.transaction()
|
||||||
|
.execute((trx) => applyEpisodeTotal(trx, { entityId, season: 1, fresh: 1, now: new Date() }));
|
||||||
|
const first = await release(entityId, "Two.Versions.S01E01.1080p.WEB-DL.x264-GRP");
|
||||||
|
const firstTask = await submit(await planFor(first.id));
|
||||||
|
expect((await subRow(subId)).status).toBe("active");
|
||||||
|
await completeAndImport(first.hash, firstTask);
|
||||||
|
expect((await subRow(subId)).status).toBe("active");
|
||||||
|
const second = await release(entityId, "Two.Versions.S01E01.720p.WEB-DL.x264-GRP");
|
||||||
|
await submit(await planFor(second.id));
|
||||||
|
expect(await subRow(subId)).toMatchObject({ status: "completed", status_reason: "download_accepted" });
|
||||||
|
await qb.complete(second.hash, { writeFiles: true });
|
||||||
|
await syncDownloads(h.d);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
it("pending(TMDB 未就绪)照常下载但不完成;暂停的订阅不评估", async () => {
|
it("pending(TMDB 未就绪)照常下载但不完成;暂停的订阅不评估", async () => {
|
||||||
const { entityId, slotId, subId } = await seriesSlot("待定剧", { mode: "once", status: "pending" });
|
const { entityId, slotId, subId } = await seriesSlot("待定剧", { mode: "once", status: "pending" });
|
||||||
await db()
|
await db()
|
||||||
|
|||||||
@@ -532,7 +532,8 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
|||||||
if (!dt) throw new CapabilityError("not_found", "下载任务不存在");
|
if (!dt) throw new CapabilityError("not_found", "下载任务不存在");
|
||||||
if (!["completed", "importing", "imported"].includes(dt.status))
|
if (!["completed", "importing", "imported"].includes(dt.status))
|
||||||
throw new CapabilityError("precondition", `下载任务尚未完成(${dt.status})`);
|
throw new CapabilityError("precondition", `下载任务尚未完成(${dt.status})`);
|
||||||
const action: "copy" | "move" = input.action === "move" ? "move" : "copy";
|
const action: "copy" | "move" | "in_place" =
|
||||||
|
input.action === "in_place" ? "in_place" : input.action === "move" ? "move" : "copy";
|
||||||
const request = {
|
const request = {
|
||||||
downloadTaskId: input.downloadTaskId,
|
downloadTaskId: input.downloadTaskId,
|
||||||
action,
|
action,
|
||||||
@@ -603,7 +604,7 @@ export function registerAoCapabilities(registry: CapabilityRegistry, deps: () =>
|
|||||||
if (!art) throw new CapabilityError("not_found", "整理计划不存在");
|
if (!art) throw new CapabilityError("not_found", "整理计划不存在");
|
||||||
if (art.content_hash !== input.payloadHash) throw new CapabilityError("conflict", "payloadHash 与计划不一致");
|
if (art.content_hash !== input.payloadHash) throw new CapabilityError("conflict", "payloadHash 与计划不一致");
|
||||||
const content = art.content as {
|
const content = art.content as {
|
||||||
request: { downloadTaskId: string; action: "copy" | "move"; targetLibraryRootId?: string };
|
request: { downloadTaskId: string; action: "copy" | "move" | "in_place"; targetLibraryRootId?: string };
|
||||||
};
|
};
|
||||||
const req = content.request;
|
const req = content.request;
|
||||||
// 已执行过(同计划)→ 返回已有记录
|
// 已执行过(同计划)→ 返回已有记录
|
||||||
|
|||||||
@@ -45,13 +45,7 @@ async function taskRow(
|
|||||||
runId,
|
runId,
|
||||||
...(subject ? { subjectType: subject.type, subjectId: subject.id } : {}),
|
...(subject ? { subjectType: subject.type, subjectId: subject.id } : {}),
|
||||||
}));
|
}));
|
||||||
if (given)
|
if (given) await d.ctx.db.updateTable("task").set({ engine_run_id: runId }).where("id", "=", given).execute();
|
||||||
await d.ctx.db
|
|
||||||
.updateTable("task")
|
|
||||||
.set({ engine_run_id: runId })
|
|
||||||
.where("id", "=", given)
|
|
||||||
.where("engine_run_id", "is", null)
|
|
||||||
.execute();
|
|
||||||
await markTask(d.ctx.db, taskId, "running");
|
await markTask(d.ctx.db, taskId, "running");
|
||||||
return taskId;
|
return taskId;
|
||||||
}
|
}
|
||||||
@@ -197,7 +191,7 @@ export function registerAoTasks(registry: TaskRegistry, d: AoDeps) {
|
|||||||
retryPolicy: { maximumAttempts: 1 },
|
retryPolicy: { maximumAttempts: 1 },
|
||||||
handler: async (t) =>
|
handler: async (t) =>
|
||||||
t.step.run("scrape", async () => {
|
t.step.run("scrape", async () => {
|
||||||
const r = await runScrapeRecord(d, t.input.recordId, t.abortSignal);
|
const r = await runScrapeRecord(d, t.input.recordId, t.abortSignal, t.runId);
|
||||||
return { status: r.status, written: r.written, skipped: r.skipped, failed: r.failed, reason: r.reason };
|
return { status: r.status, written: r.written, skipped: r.skipped, failed: r.failed, reason: r.reason };
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { registerAoCapabilities } from "./capabilities.ts";
|
|||||||
import { registerAoTasks } from "./handlers.ts";
|
import { registerAoTasks } from "./handlers.ts";
|
||||||
import { type AoOptions, type AoPorts, createPorts } from "./ports.ts";
|
import { type AoOptions, type AoPorts, createPorts } from "./ports.ts";
|
||||||
import { registerAssetRoutes } from "./routes/assets.ts";
|
import { registerAssetRoutes } from "./routes/assets.ts";
|
||||||
|
import { registerClassificationRoutes } from "./routes/classification.ts";
|
||||||
import { registerDashboardRoutes } from "./routes/dashboard.ts";
|
import { registerDashboardRoutes } from "./routes/dashboard.ts";
|
||||||
import { registerDownloadRoutes } from "./routes/downloads.ts";
|
import { registerDownloadRoutes } from "./routes/downloads.ts";
|
||||||
import { registerLibraryRoutes } from "./routes/library.ts";
|
import { registerLibraryRoutes } from "./routes/library.ts";
|
||||||
@@ -77,6 +78,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
|
|||||||
registerDashboardRoutes(app, d);
|
registerDashboardRoutes(app, d);
|
||||||
registerAssetRoutes(app, d);
|
registerAssetRoutes(app, d);
|
||||||
registerNamingRoutes(app, d);
|
registerNamingRoutes(app, d);
|
||||||
|
registerClassificationRoutes(app, d);
|
||||||
},
|
},
|
||||||
registerTasks(registry, ctx) {
|
registerTasks(registry, ctx) {
|
||||||
registerAoTasks(registry, lazy(ctx));
|
registerAoTasks(registry, lazy(ctx));
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
createEntityFromTmdbCandidate,
|
createEntityFromTmdbCandidate,
|
||||||
enqueueNewEntityRefresh,
|
enqueueNewEntityRefresh,
|
||||||
} from "../sources-identity/service/entity-from-candidate.ts";
|
} from "../sources-identity/service/entity-from-candidate.ts";
|
||||||
|
import { fetchPublicImage } from "../sources-identity/service/image-proxy.ts";
|
||||||
import { metadataLanguages, metadataTextOptions } from "../sources-identity/service/metadata-rich.ts";
|
import { metadataLanguages, metadataTextOptions } from "../sources-identity/service/metadata-rich.ts";
|
||||||
import { identifyParsed, type NameIdentifyResult } from "../sources-identity/service/resolve.ts";
|
import { identifyParsed, type NameIdentifyResult } from "../sources-identity/service/resolve.ts";
|
||||||
import type { ServerContext } from "../types.ts";
|
import type { ServerContext } from "../types.ts";
|
||||||
@@ -200,6 +201,11 @@ export function createPorts(
|
|||||||
return { kind: "url", url: t.url };
|
return { kind: "url", url: t.url };
|
||||||
},
|
},
|
||||||
async scrapeImage(tmdbPath) {
|
async scrapeImage(tmdbPath) {
|
||||||
|
if (/^https?:\/\//i.test(tmdbPath)) {
|
||||||
|
const got = await fetchPublicImage(externalFetch, tmdbPath, { maxBytes: 8 * 1024 * 1024, label: "封面" });
|
||||||
|
if (!got.ok) throw new Error(got.message);
|
||||||
|
return { content: got.bytes, mime: got.mime };
|
||||||
|
}
|
||||||
images ??= new ImageCache({
|
images ??= new ImageCache({
|
||||||
dir: join(dataPaths(ctx.config).dataDir, "cache", "images"),
|
dir: join(dataPaths(ctx.config).dataDir, "cache", "images"),
|
||||||
fetch: externalFetch,
|
fetch: externalFetch,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Api, type ClassificationConfigView } from "@umt/contracts";
|
||||||
|
import { sql } from "@umt/db";
|
||||||
|
import { classifyTmdb, parseClassificationYaml } from "@umt/domain";
|
||||||
|
import { ApiException } from "../../../http/errors.ts";
|
||||||
|
import { audit, endpoint } from "../../auth-settings/endpoint.ts";
|
||||||
|
import { type CachedSettingsReader, SETTINGS_CHANGED_EVENT } from "../../auth-settings/provided.ts";
|
||||||
|
import type { ModuleApp } from "../../types.ts";
|
||||||
|
import type { AoDeps } from "../services/common.ts";
|
||||||
|
|
||||||
|
const isCached = (s: unknown): s is CachedSettingsReader =>
|
||||||
|
typeof (s as CachedSettingsReader)?.invalidate === "function";
|
||||||
|
|
||||||
|
export function classificationView(yaml: string): ClassificationConfigView {
|
||||||
|
try {
|
||||||
|
const config = parseClassificationYaml(yaml);
|
||||||
|
return { yaml, categories: { movie: config.movie.map((r) => r.category), tv: config.tv.map((r) => r.category) } };
|
||||||
|
} catch (err) {
|
||||||
|
throw new ApiException("validation_failed", err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerClassificationRoutes(app: ModuleApp, d: AoDeps) {
|
||||||
|
const settings = async () =>
|
||||||
|
isCached(d.ctx.settings) ? (await d.ctx.settings.fresh()).organize : await d.ctx.settings.get("organize");
|
||||||
|
endpoint(app, { ...Api.classification.get, tags: ["classification"] }, async () =>
|
||||||
|
classificationView((await settings()).classificationYaml),
|
||||||
|
);
|
||||||
|
endpoint(app, { ...Api.classification.put, tags: ["classification"] }, async ({ body, actor }) => {
|
||||||
|
const view = classificationView(body.yaml);
|
||||||
|
const defaults = await settings();
|
||||||
|
await d.ctx.db.transaction().execute(async (trx) => {
|
||||||
|
await sql`select pg_advisory_xact_lock(hashtext('organize.sources'))`.execute(trx);
|
||||||
|
const row = await trx
|
||||||
|
.selectFrom("settings")
|
||||||
|
.select("value")
|
||||||
|
.where("key", "=", "organize")
|
||||||
|
.forUpdate()
|
||||||
|
.executeTakeFirst();
|
||||||
|
const current = row?.value && typeof row.value === "object" && !Array.isArray(row.value) ? row.value : {};
|
||||||
|
const next = { ...defaults, ...current, classificationYaml: body.yaml };
|
||||||
|
await trx
|
||||||
|
.insertInto("settings")
|
||||||
|
.values({ key: "organize", value: JSON.stringify(next), updated_at: new Date() })
|
||||||
|
.onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify(next), updated_at: new Date() }))
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
if (isCached(d.ctx.settings)) d.ctx.settings.invalidate();
|
||||||
|
void d.ctx.events.publish(SETTINGS_CHANGED_EVENT, { keys: ["organize"] });
|
||||||
|
await audit(
|
||||||
|
d.ctx,
|
||||||
|
actor,
|
||||||
|
"settings.update",
|
||||||
|
{ type: "settings", id: "organize" },
|
||||||
|
{ patch: { organize: { classificationYaml: body.yaml } } },
|
||||||
|
);
|
||||||
|
return view;
|
||||||
|
});
|
||||||
|
endpoint(app, { ...Api.classification.preview, tags: ["classification"] }, async ({ body }) => {
|
||||||
|
const yaml = body.yaml ?? (await settings()).classificationYaml;
|
||||||
|
classificationView(yaml);
|
||||||
|
return classifyTmdb(parseClassificationYaml(yaml), body.mediaType, body.tmdb);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -302,7 +302,6 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
.orderBy("dt.created_at", "desc")
|
.orderBy("dt.created_at", "desc")
|
||||||
.limit(100)
|
|
||||||
.execute();
|
.execute();
|
||||||
const entityRecs = await db()
|
const entityRecs = await db()
|
||||||
.selectFrom("organization_record")
|
.selectFrom("organization_record")
|
||||||
@@ -321,7 +320,6 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
])
|
])
|
||||||
.where("media_entity_id", "=", id)
|
.where("media_entity_id", "=", id)
|
||||||
.orderBy("created_at", "desc")
|
.orderBy("created_at", "desc")
|
||||||
.limit(100)
|
|
||||||
.execute();
|
.execute();
|
||||||
const planIds = dts.map((t) => t.acquisition_plan_id).filter((x): x is string => !!x);
|
const planIds = dts.map((t) => t.acquisition_plan_id).filter((x): x is string => !!x);
|
||||||
const plans = planIds.length
|
const plans = planIds.length
|
||||||
@@ -356,7 +354,6 @@ export function registerLibraryRoutes(app: ModuleApp, d: AoDeps) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy("p.updated_at", "desc")
|
.orderBy("p.updated_at", "desc")
|
||||||
.limit(50)
|
|
||||||
.execute()
|
.execute()
|
||||||
: [];
|
: [];
|
||||||
const rootPath = new Map(roots.map((r) => [r.id, r.local_path]));
|
const rootPath = new Map(roots.map((r) => [r.id, r.local_path]));
|
||||||
|
|||||||
@@ -266,11 +266,13 @@ function recordFilters<Q extends SelectQueryBuilder<DB, "organization_record", a
|
|||||||
): Q {
|
): Q {
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: kysely 泛型在链式 where 后无法保持 Q
|
// biome-ignore lint/suspicious/noExplicitAny: kysely 泛型在链式 where 后无法保持 Q
|
||||||
let q = q0 as any;
|
let q = q0 as any;
|
||||||
|
if (query.phase === "active") q = q.where("status", "in", ["queued", "running"]);
|
||||||
|
else if (query.phase === "history") q = q.where("status", "not in", ["queued", "running"]);
|
||||||
if (query.trigger) q = q.where("trigger", "=", query.trigger);
|
if (query.trigger) q = q.where("trigger", "=", query.trigger);
|
||||||
if (query.mediaEntityId) q = q.where("media_entity_id", "=", query.mediaEntityId);
|
if (query.mediaEntityId) q = q.where("media_entity_id", "=", query.mediaEntityId);
|
||||||
if (query.downloadTaskId) q = q.where("download_task_id", "=", query.downloadTaskId);
|
if (query.downloadTaskId) q = q.where("download_task_id", "=", query.downloadTaskId);
|
||||||
if (query.since) q = q.where("created_at", ">=", new Date(query.since));
|
if (query.since) q = q.where("created_at", ">=", new Date(query.since));
|
||||||
if (query.organizeOnly) q = q.where("action", "in", ["copy", "move", "hardlink"]);
|
if (query.organizeOnly) q = q.where("action", "in", ["copy", "move", "in_place", "hardlink"]);
|
||||||
if (query.kind === "none") q = q.where("media_entity_id", "is", null);
|
if (query.kind === "none") q = q.where("media_entity_id", "is", null);
|
||||||
else if (query.kind)
|
else if (query.kind)
|
||||||
q = q.where("media_entity_id", "in", (eb: ExpressionBuilder<DB, "organization_record">) =>
|
q = q.where("media_entity_id", "in", (eb: ExpressionBuilder<DB, "organization_record">) =>
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type { ClassificationPreviewResult } from "@umt/contracts";
|
||||||
|
import type { MediaEntityRow } from "@umt/db";
|
||||||
|
import { classifyTmdb, DEFAULT_CLASSIFICATION_YAML, parseClassificationYaml } from "@umt/domain";
|
||||||
|
import { SourcesRuntime } from "../../sources-identity/runtime.ts";
|
||||||
|
import type { AoDeps } from "./common.ts";
|
||||||
|
|
||||||
|
/** Use accepted TMDB identity and the existing shared raw-detail cache. */
|
||||||
|
export async function classifyEntity(d: AoDeps, entity: MediaEntityRow): Promise<ClassificationPreviewResult> {
|
||||||
|
const none: ClassificationPreviewResult = {
|
||||||
|
category: null,
|
||||||
|
matchedFields: [],
|
||||||
|
fallback: false,
|
||||||
|
};
|
||||||
|
const config = parseClassificationYaml(
|
||||||
|
(await d.ctx.settings.get("organize")).classificationYaml ?? DEFAULT_CLASSIFICATION_YAML,
|
||||||
|
);
|
||||||
|
const mediaType = entity.kind === "movie" ? "movie" : "tv";
|
||||||
|
if (!config[mediaType].length) return none;
|
||||||
|
const identity = await d.ctx.db
|
||||||
|
.selectFrom("external_identity")
|
||||||
|
.select("external_id")
|
||||||
|
.where("media_entity_id", "=", entity.id)
|
||||||
|
.where("provider", "=", "tmdb")
|
||||||
|
.where("entity_type", "=", mediaType)
|
||||||
|
.where("content_unit_id", "is", null)
|
||||||
|
.where("status", "=", "accepted")
|
||||||
|
.orderBy("id")
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!identity) {
|
||||||
|
const metadata = entity.metadata as Record<string, unknown>;
|
||||||
|
const value = (field: string) => {
|
||||||
|
const entry = metadata[field];
|
||||||
|
return entry && typeof entry === "object" && "value" in entry ? (entry as { value: unknown }).value : undefined;
|
||||||
|
};
|
||||||
|
if (!Object.values(metadata).some((v) => v && typeof v === "object" && "value" in v)) return none;
|
||||||
|
const genres = value("genres");
|
||||||
|
const country = value("originCountry");
|
||||||
|
const year = value("year") ?? entity.year;
|
||||||
|
const genreIds: Record<string, number> = {
|
||||||
|
动画: 16,
|
||||||
|
动漫: 16,
|
||||||
|
animation: 16,
|
||||||
|
儿童: 10762,
|
||||||
|
kids: 10762,
|
||||||
|
纪录: 99,
|
||||||
|
纪录片: 99,
|
||||||
|
documentary: 99,
|
||||||
|
综艺: 10764,
|
||||||
|
reality: 10764,
|
||||||
|
talk: 10767,
|
||||||
|
音乐: 10402,
|
||||||
|
music: 10402,
|
||||||
|
动作: 28,
|
||||||
|
action: 28,
|
||||||
|
冒险: 12,
|
||||||
|
adventure: 12,
|
||||||
|
喜剧: 35,
|
||||||
|
comedy: 35,
|
||||||
|
犯罪: 80,
|
||||||
|
crime: 80,
|
||||||
|
剧情: 18,
|
||||||
|
drama: 18,
|
||||||
|
家庭: 10751,
|
||||||
|
family: 10751,
|
||||||
|
奇幻: 14,
|
||||||
|
fantasy: 14,
|
||||||
|
历史: 36,
|
||||||
|
history: 36,
|
||||||
|
恐怖: 27,
|
||||||
|
horror: 27,
|
||||||
|
悬疑: 9648,
|
||||||
|
mystery: 9648,
|
||||||
|
爱情: 10749,
|
||||||
|
romance: 10749,
|
||||||
|
科幻: 878,
|
||||||
|
"science fiction": 878,
|
||||||
|
惊悚: 53,
|
||||||
|
thriller: 53,
|
||||||
|
战争: 10752,
|
||||||
|
war: 10752,
|
||||||
|
西部: 37,
|
||||||
|
western: 37,
|
||||||
|
};
|
||||||
|
const raw = {
|
||||||
|
original_language: value("originalLanguage"),
|
||||||
|
origin_country: country,
|
||||||
|
production_countries: country,
|
||||||
|
genre_ids: (Array.isArray(genres) ? genres : genres ? [genres] : []).map(
|
||||||
|
(g) => genreIds[String(g).toLowerCase()] ?? g,
|
||||||
|
),
|
||||||
|
...(year ? { release_date: String(year), first_air_date: String(year) } : {}),
|
||||||
|
};
|
||||||
|
return classifyTmdb(config, mediaType, raw);
|
||||||
|
}
|
||||||
|
if (!/^\d+$/.test(identity.external_id)) return none;
|
||||||
|
try {
|
||||||
|
const tmdb = await new SourcesRuntime(d.ctx).tmdb();
|
||||||
|
if (!tmdb) return none;
|
||||||
|
const raw = await tmdb.getJson<Record<string, unknown>>(`/${mediaType}/${identity.external_id}`);
|
||||||
|
return classifyTmdb(config, mediaType, raw);
|
||||||
|
} catch (err) {
|
||||||
|
d.ctx.logger.warn({ mediaEntityId: entity.id, err }, "TMDB 分类数据读取失败");
|
||||||
|
return none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { type DB, type DownloaderInstanceRow, type Kysely, type LibraryRootRow,
|
|||||||
import { type PathMapping, toDownloaderPath, toHostPath } from "@umt/domain";
|
import { type PathMapping, toDownloaderPath, toHostPath } from "@umt/domain";
|
||||||
import type { ServerContext } from "../../types.ts";
|
import type { ServerContext } from "../../types.ts";
|
||||||
import type { AoPorts } from "../ports.ts";
|
import type { AoPorts } from "../ports.ts";
|
||||||
import { hashJson, jsonb } from "../util.ts";
|
import { errMessage, hashJson, jsonb } from "../util.ts";
|
||||||
|
|
||||||
// 本模块各服务共用:决策(待处理)、候选评估记录、业务任务行、实例拓扑(路径映射/缓存卷)。
|
// 本模块各服务共用:决策(待处理)、候选评估记录、业务任务行、实例拓扑(路径映射/缓存卷)。
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ export async function markTask(
|
|||||||
* 巡检补投等需要"同一业务行、按时间槽重投"的场景传 engineKey。
|
* 巡检补投等需要"同一业务行、按时间槽重投"的场景传 engineKey。
|
||||||
*/
|
*/
|
||||||
export async function enqueueTracked(
|
export async function enqueueTracked(
|
||||||
d: AoDeps,
|
d: Pick<AoDeps, "ctx">,
|
||||||
t: Omit<TaskRowInput, "runId">,
|
t: Omit<TaskRowInput, "runId">,
|
||||||
opts: { engineKey?: string } = {},
|
opts: { engineKey?: string } = {},
|
||||||
): Promise<{ taskId: string; runId: string }> {
|
): Promise<{ taskId: string; runId: string }> {
|
||||||
@@ -145,7 +145,12 @@ export async function enqueueTracked(
|
|||||||
traceId: taskId,
|
traceId: taskId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
try {
|
||||||
await d.ctx.db.updateTable("task").set({ engine_run_id: runId }).where("id", "=", taskId).execute();
|
await d.ctx.db.updateTable("task").set({ engine_run_id: runId }).where("id", "=", taskId).execute();
|
||||||
|
} catch (err) {
|
||||||
|
// run 已被接受;处理器和下一轮巡检会按 taskId 补回关联。
|
||||||
|
d.ctx.logger.warn({ taskId, runId, err: { message: errMessage(err) } }, "任务已入队,业务关联暂未写回");
|
||||||
|
}
|
||||||
return { taskId, runId };
|
return { taskId, runId };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,14 +168,35 @@ export async function enqueueSweep(
|
|||||||
): Promise<{ taskId: string; runId: string } | null> {
|
): Promise<{ taskId: string; runId: string } | null> {
|
||||||
const prev = await d.ctx.db
|
const prev = await d.ctx.db
|
||||||
.selectFrom("task")
|
.selectFrom("task")
|
||||||
.select(["engine_run_id"])
|
.select(["id", "engine_run_id"])
|
||||||
.where("kind", "=", t.kind)
|
.where("kind", "=", t.kind)
|
||||||
.where("idempotency_key", "=", t.idempotencyKey)
|
.where("idempotency_key", "=", t.idempotencyKey)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (prev?.engine_run_id) {
|
if (prev) {
|
||||||
const run = await d.ctx.engine.get(prev.engine_run_id).catch(() => null);
|
try {
|
||||||
|
// 关联写回失败时,用入队已有的 traceId 找回 run,不随时间槽生成另一个任务。
|
||||||
|
const runId =
|
||||||
|
(
|
||||||
|
await d.ctx.db
|
||||||
|
.selectFrom("tasks_run_meta")
|
||||||
|
.select("run_id")
|
||||||
|
.where("task_name", "=", t.kind)
|
||||||
|
.where("trace_id", "=", prev.id)
|
||||||
|
.orderBy("created_at", "desc")
|
||||||
|
.executeTakeFirst()
|
||||||
|
)?.run_id ?? prev.engine_run_id;
|
||||||
|
if (runId) {
|
||||||
|
const run = await d.ctx.engine.get(runId);
|
||||||
|
if (prev.engine_run_id !== runId && run) {
|
||||||
|
await d.ctx.db.updateTable("task").set({ engine_run_id: runId }).where("id", "=", prev.id).execute();
|
||||||
|
}
|
||||||
if (run && ACTIVE_RUN.has(run.status)) return null;
|
if (run && ACTIVE_RUN.has(run.status)) return null;
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
d.ctx.logger.warn({ taskId: prev.id, err: { message: errMessage(err) } }, "任务状态查询失败,跳过本轮补投");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
return enqueueTracked(d, t, { engineKey: `${t.kind}:${t.idempotencyKey}:${slot}` });
|
return enqueueTracked(d, t, { engineKey: `${t.kind}:${t.idempotencyKey}:${slot}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ export async function buildSubscriptionModels(
|
|||||||
: r.dt_status === "completed" || r.dt_status === "importing"
|
: r.dt_status === "completed" || r.dt_status === "importing"
|
||||||
? "verifying"
|
? "verifying"
|
||||||
: "downloading";
|
: "downloading";
|
||||||
|
if (!r.slot_id) continue;
|
||||||
const list = inflightBySlot.get(r.slot_id) ?? [];
|
const list = inflightBySlot.get(r.slot_id) ?? [];
|
||||||
const prev = list.find((x) => x.id === r.id);
|
const prev = list.find((x) => x.id === r.id);
|
||||||
if (!prev) list.push({ id: r.id, stage, unitKeys: r.unit_keys });
|
if (!prev) list.push({ id: r.id, stage, unitKeys: r.unit_keys });
|
||||||
|
|||||||
@@ -167,14 +167,14 @@ export function planWaitOf(status: string, deferred: string | null): PlanWait {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 等待中的计划 → 面板行 */
|
/** 等待中的计划 → 面板行 */
|
||||||
async function planItems(db: Exec, rows: { id: string; slot_id: string; source_release_id: string }[]) {
|
async function planItems(db: Exec, rows: { id: string; slot_id: string | null; source_release_id: string }[]) {
|
||||||
if (!rows.length)
|
if (!rows.length)
|
||||||
return new Map<string, Omit<DownloadBoardPlanItem, "kind" | "id" | "createdAt" | "purpose" | "status">>();
|
return new Map<string, Omit<DownloadBoardPlanItem, "kind" | "id" | "createdAt" | "purpose" | "status">>();
|
||||||
const ids = rows.map((r) => r.id);
|
const ids = rows.map((r) => r.id);
|
||||||
const [extra, approvals, tasks] = await Promise.all([
|
const [extra, approvals, tasks] = await Promise.all([
|
||||||
db
|
db
|
||||||
.selectFrom("acquisition_plan as p")
|
.selectFrom("acquisition_plan as p")
|
||||||
.innerJoin("acquisition_slot as s", "s.id", "p.slot_id")
|
.leftJoin("acquisition_slot as s", "s.id", "p.slot_id")
|
||||||
.leftJoin("media_entity as e", "e.id", "s.media_entity_id")
|
.leftJoin("media_entity as e", "e.id", "s.media_entity_id")
|
||||||
.leftJoin("source_release as r", "r.id", "p.source_release_id")
|
.leftJoin("source_release as r", "r.id", "p.source_release_id")
|
||||||
.select([
|
.select([
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
downloaderPathOf,
|
downloaderPathOf,
|
||||||
type Exec,
|
type Exec,
|
||||||
enqueueSweep,
|
enqueueSweep,
|
||||||
|
enqueueTracked,
|
||||||
hostPathOf,
|
hostPathOf,
|
||||||
type InstanceTopology,
|
type InstanceTopology,
|
||||||
loadInstances,
|
loadInstances,
|
||||||
@@ -46,8 +47,10 @@ import {
|
|||||||
timeSlot,
|
timeSlot,
|
||||||
upsertDecision,
|
upsertDecision,
|
||||||
} from "./common.ts";
|
} from "./common.ts";
|
||||||
|
import { matchesQbSource, qbSourceTags } from "./organize-config.ts";
|
||||||
import { hnrSatisfied, retentionElapsed } from "./retention.ts";
|
import { hnrSatisfied, retentionElapsed } from "./retention.ts";
|
||||||
import {
|
import {
|
||||||
|
afterDownloadAccepted,
|
||||||
afterSlotVerified,
|
afterSlotVerified,
|
||||||
type SlotCompletion,
|
type SlotCompletion,
|
||||||
slotComplete,
|
slotComplete,
|
||||||
@@ -107,10 +110,11 @@ async function verifiedContext(
|
|||||||
export async function applySlotEvent(
|
export async function applySlotEvent(
|
||||||
d: AoDeps,
|
d: AoDeps,
|
||||||
trx: Exec,
|
trx: Exec,
|
||||||
slotId: string,
|
slotId: string | null,
|
||||||
event: SlotEvent,
|
event: SlotEvent,
|
||||||
extra: { currentAssetId?: string | null; reason?: string; planId?: string | null } = {},
|
extra: { currentAssetId?: string | null; reason?: string; planId?: string | null } = {},
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
if (!slotId) return false;
|
||||||
const slot = await trx
|
const slot = await trx
|
||||||
.selectFrom("acquisition_slot")
|
.selectFrom("acquisition_slot")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
@@ -217,10 +221,21 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
|
|||||||
const db = d.ctx.db;
|
const db = d.ctx.db;
|
||||||
const plan = await db.selectFrom("acquisition_plan").selectAll().where("id", "=", planId).executeTakeFirst();
|
const plan = await db.selectFrom("acquisition_plan").selectAll().where("id", "=", planId).executeTakeFirst();
|
||||||
if (!plan) return { status: "skipped", reason: "plan_not_found" };
|
if (!plan) return { status: "skipped", reason: "plan_not_found" };
|
||||||
if (plan.status !== "reserved") return { status: "skipped", reason: `plan_${plan.status}` };
|
if (plan.status !== "reserved") {
|
||||||
|
if (["submitted", "completed"].includes(plan.status) && plan.status_reason !== "accepted_unconfirmed")
|
||||||
|
await afterDownloadAccepted(d, plan.id);
|
||||||
|
return { status: "skipped", reason: `plan_${plan.status}` };
|
||||||
|
}
|
||||||
const blocked = await automationBlock(d);
|
const blocked = await automationBlock(d);
|
||||||
if (blocked) return { status: "deferred", reason: blocked };
|
if (blocked) return { status: "deferred", reason: blocked };
|
||||||
const ev = (plan.evaluation ?? {}) as { instanceId?: string; volumeKey?: string; savePath?: string };
|
const ev = (plan.evaluation ?? {}) as {
|
||||||
|
instanceId?: string;
|
||||||
|
volumeKey?: string;
|
||||||
|
savePath?: string;
|
||||||
|
mediaEntityId?: string | null;
|
||||||
|
manual?: boolean;
|
||||||
|
importMode?: string;
|
||||||
|
};
|
||||||
const topo = (await loadInstances(db)).find((t) => t.inst.id === ev.instanceId);
|
const topo = (await loadInstances(db)).find((t) => t.inst.id === ev.instanceId);
|
||||||
if (!topo?.savePath) return failPlan(d, plan.id, plan.slot_id, "downloader_unavailable");
|
if (!topo?.savePath) return failPlan(d, plan.id, plan.slot_id, "downloader_unavailable");
|
||||||
|
|
||||||
@@ -313,16 +328,17 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
|
|||||||
}
|
}
|
||||||
const r = res as Extract<SubmitResult, { hash: string }>;
|
const r = res as Extract<SubmitResult, { hash: string }>;
|
||||||
const ins = await sql<{ id: string; inserted: boolean }>`
|
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,
|
insert into download_task (instance_id, torrent_hash, infohash_v1, infohash_v2, acquisition_plan_id, import_plan_id, source_release_id,
|
||||||
media_entity_id, name,
|
media_entity_id, name,
|
||||||
save_path, content_path, category, tags, managed, origin, status, size_bytes, next_check_at)
|
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},
|
values (${topo.inst.id}, ${r.hash}, ${r.hash.length === 40 ? r.hash : null}, ${r.hash.length === 64 ? r.hash : null},
|
||||||
${plan.id}, ${rel.id}, (select media_entity_id from acquisition_slot where id = ${plan.slot_id}),
|
${plan.id}, ${ev.manual ? plan.id : null}, ${rel.id}, coalesce((select media_entity_id from acquisition_slot where id = ${plan.slot_id}), ${ev.mediaEntityId ?? rel.media_entity_id}::uuid),
|
||||||
${r.name}, ${r.savePath}, ${r.contentPath}, ${topo.category},
|
${r.name}, ${r.savePath}, ${r.contentPath}, ${topo.category},
|
||||||
${r.tags}, true, ${r.kind === "already_exists" ? "adopted" : "submitted"},
|
${r.tags}, true, ${r.kind === "already_exists" ? "adopted" : "submitted"},
|
||||||
${r.kind === "accepted_unconfirmed" ? "submitting" : "queued"}, ${rel.size_bytes}, now())
|
${r.kind === "accepted_unconfirmed" ? "submitting" : "queued"}, ${rel.size_bytes}, now())
|
||||||
on conflict (instance_id, torrent_hash) do update
|
on conflict (instance_id, torrent_hash) do update
|
||||||
set acquisition_plan_id = coalesce(download_task.acquisition_plan_id, excluded.acquisition_plan_id),
|
set acquisition_plan_id = coalesce(download_task.acquisition_plan_id, excluded.acquisition_plan_id),
|
||||||
|
import_plan_id = coalesce(excluded.import_plan_id, download_task.import_plan_id),
|
||||||
source_release_id = coalesce(download_task.source_release_id, excluded.source_release_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),
|
media_entity_id = coalesce(download_task.media_entity_id, excluded.media_entity_id),
|
||||||
managed = true, tags = excluded.tags, updated_at = now()
|
managed = true, tags = excluded.tags, updated_at = now()
|
||||||
@@ -357,6 +373,7 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
|
|||||||
d.ctx.logger.warn({ planId: plan.id, err: { message: errMessage(err) } }, "季包选文件失败,按整包下载"),
|
d.ctx.logger.warn({ planId: plan.id, err: { message: errMessage(err) } }, "季包选文件失败,按整包下载"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (result.kind !== "accepted_unconfirmed") await afterDownloadAccepted(d, plan.id);
|
||||||
if (!dtId) {
|
if (!dtId) {
|
||||||
// 账本已有结果(重放):按 hash 找回任务行
|
// 账本已有结果(重放):按 hash 找回任务行
|
||||||
dtId = (
|
dtId = (
|
||||||
@@ -368,6 +385,20 @@ export async function submitDownload(d: AoDeps, planId: string, opts: { runId?:
|
|||||||
.executeTakeFirst()
|
.executeTakeFirst()
|
||||||
)?.id;
|
)?.id;
|
||||||
}
|
}
|
||||||
|
if (result.kind === "already_exists" && dtId && ev.manual && ev.importMode === "replace") {
|
||||||
|
const existing = await db.selectFrom("download_task").select("status").where("id", "=", dtId).executeTakeFirst();
|
||||||
|
if (existing?.status === "imported") {
|
||||||
|
await db.updateTable("download_task").set({ status: "completed" }).where("id", "=", dtId).execute();
|
||||||
|
await enqueueTracked(d, {
|
||||||
|
kind: "verifyAndImport",
|
||||||
|
idempotencyKey: `manual-import:${plan.id}`,
|
||||||
|
input: { downloadTaskId: dtId },
|
||||||
|
subjectType: "download_task",
|
||||||
|
subjectId: dtId,
|
||||||
|
triggeredBy: "user",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
return { status: "submitted", reason: result.kind, ...(dtId ? { downloadTaskId: dtId } : {}) };
|
return { status: "submitted", reason: result.kind, ...(dtId ? { downloadTaskId: dtId } : {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,6 +416,7 @@ async function selectPackFiles(
|
|||||||
plan: AcquisitionPlanRow,
|
plan: AcquisitionPlanRow,
|
||||||
rel: SourceReleaseRow,
|
rel: SourceReleaseRow,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
if (!plan.slot_id) return;
|
||||||
const parsed = parsedOf(rel);
|
const parsed = parsedOf(rel);
|
||||||
const isPack = !parsed.episodes && !parsed.airDate;
|
const isPack = !parsed.episodes && !parsed.airDate;
|
||||||
const multi = !!parsed.episodes && parsed.episodes.end > parsed.episodes.start;
|
const multi = !!parsed.episodes && parsed.episodes.end > parsed.episodes.start;
|
||||||
@@ -465,7 +497,7 @@ async function selectPackFiles(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function failPlanTx(d: AoDeps, trx: Exec, planId: string, slotId: string, reason: string) {
|
async function failPlanTx(d: AoDeps, trx: Exec, planId: string, slotId: string | null, reason: string) {
|
||||||
await trx
|
await trx
|
||||||
.updateTable("acquisition_plan")
|
.updateTable("acquisition_plan")
|
||||||
.set({ status: "failed", status_reason: reason.slice(0, 500) })
|
.set({ status: "failed", status_reason: reason.slice(0, 500) })
|
||||||
@@ -502,7 +534,7 @@ async function releaseRemovedPlan(d: AoDeps, trx: Exec, planId: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function failPlan(d: AoDeps, planId: string, slotId: string, reason: string): Promise<SubmitOutcome> {
|
async function failPlan(d: AoDeps, planId: string, slotId: string | null, reason: string): Promise<SubmitOutcome> {
|
||||||
await d.ctx.db.transaction().execute((trx) => failPlanTx(d, trx, planId, slotId, reason));
|
await d.ctx.db.transaction().execute((trx) => failPlanTx(d, trx, planId, slotId, reason));
|
||||||
return { status: "failed", reason };
|
return { status: "failed", reason };
|
||||||
}
|
}
|
||||||
@@ -593,9 +625,14 @@ async function syncInstance(d: AoDeps, topo: InstanceTopology, stats: SyncStats)
|
|||||||
const db = d.ctx.db;
|
const db = d.ctx.db;
|
||||||
const now = d.ports.now();
|
const now = d.ports.now();
|
||||||
const client = await d.ports.downloader(topo.inst);
|
const client = await d.ports.downloader(topo.inst);
|
||||||
|
const sourceTags = await qbSourceTags(d, topo);
|
||||||
let list: QbTorrent[];
|
let list: QbTorrent[];
|
||||||
try {
|
try {
|
||||||
list = await client.list({ tag: topo.inst.managed_tag });
|
const tags = [...new Set([topo.inst.managed_tag, ...sourceTags.map((s) => s.config.tag!)])];
|
||||||
|
const batches = await Promise.all(tags.map((tag) => client.list({ tag })));
|
||||||
|
list = [...new Map(batches.flat().map((t) => [t.hash, t])).values()].filter(
|
||||||
|
(t) => t.tags.includes(topo.inst.managed_tag) || matchesQbSource(t, topo, sourceTags),
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 下载器失联:不改任何任务状态(失败 ≠ 不存在)
|
// 下载器失联:不改任何任务状态(失败 ≠ 不存在)
|
||||||
stats.unreachable.push(topo.inst.id);
|
stats.unreachable.push(topo.inst.id);
|
||||||
@@ -722,6 +759,7 @@ async function observeLocked(
|
|||||||
.set({
|
.set({
|
||||||
status: merged.status,
|
status: merged.status,
|
||||||
pause_reason: merged.status === "paused" ? merged.pauseReason : null,
|
pause_reason: merged.status === "paused" ? merged.pauseReason : null,
|
||||||
|
managed: true,
|
||||||
raw_state: t.rawState,
|
raw_state: t.rawState,
|
||||||
progress: Math.min(1, Math.max(0, t.progress)),
|
progress: Math.min(1, Math.max(0, t.progress)),
|
||||||
size_bytes: sizeOf(t),
|
size_bytes: sizeOf(t),
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import type { ManualAcquisitionRequest, ManualAcquisitionResult } from "@umt/contracts";
|
||||||
|
import { sql } from "@umt/db";
|
||||||
|
import { ApiException } from "../../../http/errors.ts";
|
||||||
|
import type { ServerContext } from "../../types.ts";
|
||||||
|
import { hashJson, jsonb, toApi } from "../util.ts";
|
||||||
|
import { subscriptionSavePath } from "./acquisition.ts";
|
||||||
|
import { enqueueTracked, loadInstances } from "./common.ts";
|
||||||
|
|
||||||
|
/** Manual choice is a one-off plan, independent of subscription filters and slots. */
|
||||||
|
export async function planManualAcquisition(
|
||||||
|
ctx: ServerContext,
|
||||||
|
body: ManualAcquisitionRequest,
|
||||||
|
): Promise<ManualAcquisitionResult> {
|
||||||
|
const db = ctx.db;
|
||||||
|
const rel = await db
|
||||||
|
.selectFrom("source_release")
|
||||||
|
.selectAll()
|
||||||
|
.where("id", "=", body.sourceReleaseId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!rel) throw new ApiException("not_found", "资源不存在");
|
||||||
|
const entityId = body.entityId ?? rel.media_entity_id;
|
||||||
|
if (!entityId && !body.allowUnrecognized)
|
||||||
|
return {
|
||||||
|
submitted: false,
|
||||||
|
requiresConfirmation: true,
|
||||||
|
plan: null,
|
||||||
|
slotId: null,
|
||||||
|
taskId: null,
|
||||||
|
rejected: [
|
||||||
|
{ slotId: null, code: "unrecognized", message: "资源尚未识别作品,确认后可以先下载,整理时补全元数据" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
if (entityId && !(await db.selectFrom("media_entity").select("id").where("id", "=", entityId).executeTakeFirst()))
|
||||||
|
throw new ApiException("not_found", "作品不存在");
|
||||||
|
const instances = await loadInstances(db, body.downloaderInstanceId ? { ids: [body.downloaderInstanceId] } : {});
|
||||||
|
const topo = instances.find((i) => i.savePath);
|
||||||
|
if (!topo?.savePath) throw new ApiException("precondition_failed", "没有可用的下载器或保存目录");
|
||||||
|
const replacementIds = body.importMode === "replace" ? [...new Set(body.replacementAssetIds)] : [];
|
||||||
|
if (body.importMode === "replace" && (!entityId || replacementIds.length === 0))
|
||||||
|
throw new ApiException("bad_request", "洗版入库需要明确作品及要替换的已有版本");
|
||||||
|
if (replacementIds.length) {
|
||||||
|
const assets = await db
|
||||||
|
.selectFrom("media_asset")
|
||||||
|
.select(["id", "media_entity_id"])
|
||||||
|
.where("id", "in", replacementIds)
|
||||||
|
.execute();
|
||||||
|
if (assets.length !== replacementIds.length || assets.some((a) => a.media_entity_id !== entityId))
|
||||||
|
throw new ApiException("bad_request", "要替换的版本必须属于本次下载的作品");
|
||||||
|
}
|
||||||
|
const knownHash = rel.infohash_v1 ?? rel.infohash_v2;
|
||||||
|
if (knownHash && body.importMode !== "replace") {
|
||||||
|
const existing = await db
|
||||||
|
.selectFrom("download_task")
|
||||||
|
.select(["id", "acquisition_plan_id", "import_plan_id"])
|
||||||
|
.where("instance_id", "=", topo.inst.id)
|
||||||
|
.where("torrent_hash", "=", knownHash)
|
||||||
|
.where("status", "=", "imported")
|
||||||
|
.where("removed_at", "is", null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (existing) {
|
||||||
|
const id = existing.import_plan_id ?? existing.acquisition_plan_id;
|
||||||
|
const p = id
|
||||||
|
? await db.selectFrom("acquisition_plan").selectAll().where("id", "=", id).executeTakeFirst()
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
submitted: true,
|
||||||
|
requiresConfirmation: false,
|
||||||
|
plan: p ? (toApi(p) as ManualAcquisitionResult["plan"]) : null,
|
||||||
|
slotId: p?.slot_id ?? null,
|
||||||
|
taskId: p?.task_id ?? null,
|
||||||
|
rejected: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const savePath = body.savePath ? subscriptionSavePath(topo, body.savePath) : topo.savePath;
|
||||||
|
if (!savePath) throw new ApiException("bad_request", "保存目录不在该下载器的路径映射范围内");
|
||||||
|
const evaluation = {
|
||||||
|
manual: true,
|
||||||
|
mediaEntityId: entityId,
|
||||||
|
instanceId: topo.inst.id,
|
||||||
|
volumeKey: topo.volumeKey,
|
||||||
|
savePath,
|
||||||
|
importMode: body.importMode,
|
||||||
|
replacementAssetIds: replacementIds,
|
||||||
|
reasons: ["manual_selection"],
|
||||||
|
note: body.note ?? null,
|
||||||
|
};
|
||||||
|
// Pending requests converge. Completed/failed plans do not prevent another explicit choice.
|
||||||
|
const key = `manual:${hashJson({ release: rel.id, ...evaluation })}`;
|
||||||
|
const plan = await db.transaction().execute(async (trx) => {
|
||||||
|
await sql`select pg_advisory_xact_lock(hashtextextended(${key}, 0))`.execute(trx);
|
||||||
|
const existing = await trx
|
||||||
|
.selectFrom("acquisition_plan")
|
||||||
|
.selectAll()
|
||||||
|
.where("source_release_id", "=", rel.id)
|
||||||
|
.where("slot_id", "is", null)
|
||||||
|
.where("status", "in", ["reserved", "submitted"])
|
||||||
|
.where("payload_hash", "=", hashJson(evaluation))
|
||||||
|
.where(sql<boolean>`evaluation->>'instanceId' = ${topo.inst.id}`)
|
||||||
|
.orderBy("created_at", "desc")
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (existing) return existing;
|
||||||
|
return trx
|
||||||
|
.insertInto("acquisition_plan")
|
||||||
|
.values({
|
||||||
|
slot_id: null,
|
||||||
|
source_release_id: rel.id,
|
||||||
|
task_id: null,
|
||||||
|
purpose: body.importMode === "replace" ? "upgrade" : "fill",
|
||||||
|
status: "reserved",
|
||||||
|
priority: "immediate",
|
||||||
|
evaluation: jsonb(evaluation),
|
||||||
|
reserved_bytes: rel.size_bytes ?? 0,
|
||||||
|
budget_reservation: jsonb({}),
|
||||||
|
payload_hash: hashJson(evaluation),
|
||||||
|
idempotency_key: `${key}:${crypto.randomUUID()}`,
|
||||||
|
expires_at: null,
|
||||||
|
status_reason: "manual_selection",
|
||||||
|
file_selection: null,
|
||||||
|
})
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
});
|
||||||
|
const { taskId } = await enqueueTracked(
|
||||||
|
{ ctx },
|
||||||
|
{
|
||||||
|
kind: "submitDownload",
|
||||||
|
idempotencyKey: `plan:${plan.id}`,
|
||||||
|
input: { acquisitionPlanId: plan.id },
|
||||||
|
subjectType: "acquisition_plan",
|
||||||
|
subjectId: plan.id,
|
||||||
|
triggeredBy: "user",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const updated = await db
|
||||||
|
.updateTable("acquisition_plan")
|
||||||
|
.set({ task_id: taskId })
|
||||||
|
.where("id", "=", plan.id)
|
||||||
|
.returningAll()
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
return {
|
||||||
|
submitted: true,
|
||||||
|
requiresConfirmation: false,
|
||||||
|
plan: toApi(updated) as ManualAcquisitionResult["plan"],
|
||||||
|
slotId: null,
|
||||||
|
taskId,
|
||||||
|
rejected: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { join } from "node:path";
|
||||||
|
import type { OrganizeSourceConfig } from "@umt/contracts";
|
||||||
|
import { loadRootDefs, within } from "../../file-manager/common.ts";
|
||||||
|
import type { AoDeps, InstanceTopology } from "./common.ts";
|
||||||
|
import { hostPathOf } from "./common.ts";
|
||||||
|
|
||||||
|
export async function configuredSources(d: AoDeps) {
|
||||||
|
const settings = await d.ctx.settings.get("organize");
|
||||||
|
if (!settings.sources?.length) return [];
|
||||||
|
const roots = await loadRootDefs(d.ctx.db);
|
||||||
|
return (settings.sources ?? []).flatMap((config) => {
|
||||||
|
const root = roots.find((r) => r.id === config.source.root);
|
||||||
|
return root ? [{ config, sourcePath: join(root.hostPath, config.source.path) }] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveOrganizeSource(
|
||||||
|
d: AoDeps,
|
||||||
|
input: {
|
||||||
|
id?: string | undefined;
|
||||||
|
paths: string[];
|
||||||
|
mediaType: "movie" | "tv";
|
||||||
|
instanceId?: string | undefined;
|
||||||
|
tags?: string[] | undefined;
|
||||||
|
},
|
||||||
|
): Promise<OrganizeSourceConfig | null> {
|
||||||
|
const sources = await configuredSources(d);
|
||||||
|
const compatible = sources.filter(
|
||||||
|
({ config, sourcePath }) =>
|
||||||
|
(config.mediaType === "all" || config.mediaType === input.mediaType) &&
|
||||||
|
input.paths.length > 0 &&
|
||||||
|
input.paths.every((p) => within(p, sourcePath)),
|
||||||
|
);
|
||||||
|
if (input.id) {
|
||||||
|
const selected = compatible.find(({ config }) => config.id === input.id);
|
||||||
|
if (!selected) throw new Error("整理来源不存在,或文件/媒体类型不属于该来源");
|
||||||
|
return selected.config;
|
||||||
|
}
|
||||||
|
// qB automation selects only its configured tag; manual rows require explicit selection.
|
||||||
|
const selected = sources
|
||||||
|
.filter(
|
||||||
|
({ config, sourcePath }) =>
|
||||||
|
config.enabled &&
|
||||||
|
config.trigger === "qb_tag" &&
|
||||||
|
config.downloaderInstanceId === input.instanceId &&
|
||||||
|
!!config.tag &&
|
||||||
|
input.tags?.includes(config.tag) &&
|
||||||
|
input.paths.length > 0 &&
|
||||||
|
input.paths.every((p) => within(p, sourcePath)),
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.sourcePath.length - a.sourcePath.length)[0]?.config;
|
||||||
|
if (selected && selected.mediaType !== "all" && selected.mediaType !== input.mediaType)
|
||||||
|
throw new Error("媒体类型与 qB 标签来源配置不匹配");
|
||||||
|
return selected ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function qbSourceTags(d: AoDeps, topology: InstanceTopology) {
|
||||||
|
return (await configuredSources(d)).filter(
|
||||||
|
({ config }) =>
|
||||||
|
config.enabled && config.trigger === "qb_tag" && config.downloaderInstanceId === topology.inst.id && !!config.tag,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesQbSource(
|
||||||
|
t: { tags: string[]; contentPath: string | null; savePath: string; name: string },
|
||||||
|
topology: InstanceTopology,
|
||||||
|
sources: Awaited<ReturnType<typeof qbSourceTags>>,
|
||||||
|
) {
|
||||||
|
const path = hostPathOf(topology, t.contentPath ?? join(t.savePath, t.name));
|
||||||
|
return (
|
||||||
|
!!path &&
|
||||||
|
sources.some(({ config, sourcePath }) => !!config.tag && t.tags.includes(config.tag) && within(path, sourcePath))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -135,7 +135,7 @@ export function requestKey(body: {
|
|||||||
sourcePaths?: string[] | undefined;
|
sourcePaths?: string[] | undefined;
|
||||||
mediaEntityId?: string | undefined;
|
mediaEntityId?: string | undefined;
|
||||||
contentUnitId?: string | undefined;
|
contentUnitId?: string | undefined;
|
||||||
action: "copy" | "move";
|
action: "copy" | "move" | "in_place";
|
||||||
targetLibraryRootId?: string | undefined;
|
targetLibraryRootId?: string | undefined;
|
||||||
}): string {
|
}): string {
|
||||||
return canonicalJson({
|
return canonicalJson({
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { AoDeps } from "./common.ts";
|
||||||
|
|
||||||
|
/** Count complete successful histories for each local main-video source. */
|
||||||
|
export async function successfulOrganizeCounts(db: AoDeps["ctx"]["db"], paths: string[]) {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
if (!paths.length) return counts;
|
||||||
|
const rows = await db
|
||||||
|
.selectFrom("organization_file_result as f")
|
||||||
|
.innerJoin("organization_record as r", "r.id", "f.organization_record_id")
|
||||||
|
.select(["f.source_path", "r.id"])
|
||||||
|
.distinct()
|
||||||
|
.where("r.status", "=", "succeeded")
|
||||||
|
.where("r.source_path_view", "=", "local")
|
||||||
|
.where("f.role", "=", "main")
|
||||||
|
.where("f.status", "=", "succeeded")
|
||||||
|
.where("f.source_path", "in", paths)
|
||||||
|
.execute();
|
||||||
|
for (const row of rows) counts.set(row.source_path, (counts.get(row.source_path) ?? 0) + 1);
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -30,7 +30,7 @@ export async function planWhys(db: Exec, planIds: readonly string[]): Promise<Ma
|
|||||||
if (!ids.length) return out;
|
if (!ids.length) return out;
|
||||||
const plans = await db
|
const plans = await db
|
||||||
.selectFrom("acquisition_plan as p")
|
.selectFrom("acquisition_plan as p")
|
||||||
.innerJoin("acquisition_slot as s", "s.id", "p.slot_id")
|
.leftJoin("acquisition_slot as s", "s.id", "p.slot_id")
|
||||||
.leftJoin("source_release as r", "r.id", "p.source_release_id")
|
.leftJoin("source_release as r", "r.id", "p.source_release_id")
|
||||||
.leftJoin("site", "site.id", "r.site_id")
|
.leftJoin("site", "site.id", "r.site_id")
|
||||||
.select([
|
.select([
|
||||||
@@ -100,7 +100,7 @@ export async function planWhys(db: Exec, planIds: readonly string[]): Promise<Ma
|
|||||||
if (agent?.agent_run_id) {
|
if (agent?.agent_run_id) {
|
||||||
const goal = (agent.goal ?? "").trim().replace(/\s+/g, " ");
|
const goal = (agent.goal ?? "").trim().replace(/\s+/g, " ");
|
||||||
source = { kind: "agent", runId: agent.agent_run_id, name: goal.length > 40 ? `${goal.slice(0, 40)}…` : goal };
|
source = { kind: "agent", runId: agent.agent_run_id, name: goal.length > 40 ? `${goal.slice(0, 40)}…` : goal };
|
||||||
} else if (inv.length) source = { kind: "manual" };
|
} else if (inv.length || evOf(p).manual === true) source = { kind: "manual" };
|
||||||
else if (sub) {
|
else if (sub) {
|
||||||
const title = sub.title ?? sub.title_query ?? "";
|
const title = sub.title ?? sub.title_query ?? "";
|
||||||
source = {
|
source = {
|
||||||
|
|||||||
@@ -0,0 +1,502 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdir, readdir, realpath, rm } from "node:fs/promises";
|
||||||
|
import { basename, dirname, join, relative } from "node:path";
|
||||||
|
import { DEFAULT_ROOT_SENTINEL, type FileFacts } from "@umt/adapters";
|
||||||
|
import { sql } from "@umt/db";
|
||||||
|
import { type ReplacementScope, replacementCovers, replacementScope } from "@umt/domain";
|
||||||
|
import { jsonb } from "../util.ts";
|
||||||
|
import { type AoDeps, upsertDecision } from "./common.ts";
|
||||||
|
import { type FrozenPlan, fsRuntime } from "./organize.ts";
|
||||||
|
|
||||||
|
export interface ReplacementFile {
|
||||||
|
assetId: string;
|
||||||
|
locationId: string;
|
||||||
|
rootId: string;
|
||||||
|
rootPath: string;
|
||||||
|
sentinel: string;
|
||||||
|
path: string;
|
||||||
|
facts: Pick<FileFacts, "size" | "mtimeMs" | "ino" | "dev">;
|
||||||
|
trashId: string;
|
||||||
|
trashDay: string;
|
||||||
|
}
|
||||||
|
export interface ReplacementAsset {
|
||||||
|
assetId: string;
|
||||||
|
scope: ReplacementScope;
|
||||||
|
files: ReplacementFile[];
|
||||||
|
}
|
||||||
|
export interface FrozenReplacement {
|
||||||
|
mode: "replace";
|
||||||
|
assets: ReplacementAsset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function freezeReplacement(
|
||||||
|
d: AoDeps,
|
||||||
|
entityId: string,
|
||||||
|
mediaType: "movie" | "tv",
|
||||||
|
ids: string[],
|
||||||
|
): Promise<FrozenReplacement> {
|
||||||
|
const selected = [...new Set(ids)];
|
||||||
|
if (!selected.length) throw new Error("洗版入库必须选择旧版本");
|
||||||
|
const assets = await d.ctx.db.selectFrom("media_asset").selectAll().where("id", "in", selected).execute();
|
||||||
|
if (
|
||||||
|
assets.length !== selected.length ||
|
||||||
|
assets.some(
|
||||||
|
(a) => a.media_entity_id !== entityId || a.status !== "active" || !["video", "bluray_dir"].includes(a.kind),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error("旧版本必须是当前作品的有效库内视频资产");
|
||||||
|
}
|
||||||
|
const out: ReplacementAsset[] = [];
|
||||||
|
for (const asset of assets) {
|
||||||
|
const locations = await d.ctx.db
|
||||||
|
.selectFrom("asset_location as l")
|
||||||
|
.innerJoin("library_root as r", "r.id", "l.library_root_id")
|
||||||
|
.select([
|
||||||
|
"l.id",
|
||||||
|
"l.media_asset_id",
|
||||||
|
"l.relative_path",
|
||||||
|
"r.id as root_id",
|
||||||
|
"r.local_path",
|
||||||
|
"r.sentinel_file",
|
||||||
|
"r.management_mode",
|
||||||
|
])
|
||||||
|
.where("l.media_asset_id", "=", asset.id)
|
||||||
|
.where("l.location_kind", "=", "library")
|
||||||
|
.where("l.status", "=", "available")
|
||||||
|
.execute();
|
||||||
|
if (!locations.length || locations.some((l) => l.management_mode !== "managed")) {
|
||||||
|
throw new Error("旧版本必须有可写的库内位置");
|
||||||
|
}
|
||||||
|
const units = await d.ctx.db
|
||||||
|
.selectFrom("asset_unit_coverage as c")
|
||||||
|
.innerJoin("content_unit as u", "u.id", "c.content_unit_id")
|
||||||
|
.select(["u.season_number", "u.episode_number"])
|
||||||
|
.where("c.media_asset_id", "=", asset.id)
|
||||||
|
.where("c.coverage", "=", "full")
|
||||||
|
.execute();
|
||||||
|
const seasons = new Set(units.map((u) => u.season_number).filter((s) => s !== null));
|
||||||
|
const explicit =
|
||||||
|
seasons.size === 1
|
||||||
|
? {
|
||||||
|
season: [...seasons][0]!,
|
||||||
|
episodes: units.flatMap((u) => (u.episode_number === null ? [] : [u.episode_number])),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
const scope = replacementScope(
|
||||||
|
locations[0]!.relative_path,
|
||||||
|
mediaType,
|
||||||
|
explicit?.episodes.length ? explicit : undefined,
|
||||||
|
);
|
||||||
|
if (seasons.size > 1) scope.episodes = [];
|
||||||
|
const files: ReplacementFile[] = [];
|
||||||
|
for (const loc of locations) {
|
||||||
|
const children = await d.ctx.db
|
||||||
|
.selectFrom("media_asset as a")
|
||||||
|
.innerJoin("asset_location as l", "l.media_asset_id", "a.id")
|
||||||
|
.select(["a.id as media_asset_id", "l.id", "l.relative_path"])
|
||||||
|
.where("a.parent_asset_id", "=", asset.id)
|
||||||
|
.where("a.status", "=", "active")
|
||||||
|
.where("l.library_root_id", "=", loc.root_id)
|
||||||
|
.where("l.status", "=", "available")
|
||||||
|
.execute();
|
||||||
|
const stem = basename(loc.relative_path).replace(/\.[^.]+$/, "");
|
||||||
|
const privateChildren = children.filter(
|
||||||
|
(c) =>
|
||||||
|
dirname(c.relative_path) === dirname(loc.relative_path) && basename(c.relative_path).startsWith(`${stem}.`),
|
||||||
|
);
|
||||||
|
for (const file of [loc, ...privateChildren]) {
|
||||||
|
const path = join(loc.local_path, file.relative_path);
|
||||||
|
const st = await fsRuntime(d).proxy.statSafe(path, {
|
||||||
|
root: loc.local_path,
|
||||||
|
sentinel: loc.sentinel_file ?? DEFAULT_ROOT_SENTINEL,
|
||||||
|
});
|
||||||
|
if (st.kind !== "ok" || !st.facts.isFile || st.facts.isSymbolicLink) {
|
||||||
|
throw new Error(`旧版本文件不可核验:${file.relative_path}`);
|
||||||
|
}
|
||||||
|
files.push({
|
||||||
|
assetId: file.media_asset_id,
|
||||||
|
locationId: file.id,
|
||||||
|
rootId: loc.root_id,
|
||||||
|
rootPath: loc.local_path,
|
||||||
|
sentinel: loc.sentinel_file ?? DEFAULT_ROOT_SENTINEL,
|
||||||
|
path: file.relative_path,
|
||||||
|
facts: {
|
||||||
|
size: st.facts.size,
|
||||||
|
mtimeMs: st.facts.mtimeMs,
|
||||||
|
ino: st.facts.ino,
|
||||||
|
dev: st.facts.dev,
|
||||||
|
},
|
||||||
|
trashId: randomUUID(),
|
||||||
|
trashDay: d.ports.now().toISOString().slice(0, 10).replace(/-/g, ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push({ assetId: asset.id, scope, files });
|
||||||
|
}
|
||||||
|
return { mode: "replace", assets: out };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called after successful new assets commit; only library locations and private sidecars are eligible. */
|
||||||
|
export async function cleanupReplacement(d: AoDeps, frozen: FrozenPlan, recordId: string): Promise<void> {
|
||||||
|
if (!frozen.replacement) return;
|
||||||
|
const db = d.ctx.db;
|
||||||
|
const successful = await db
|
||||||
|
.selectFrom("organization_file_result as f")
|
||||||
|
.innerJoin("media_asset as a", "a.id", "f.media_asset_id")
|
||||||
|
.select(["f.source_path", "f.target_path", "f.media_asset_id"])
|
||||||
|
.where("f.organization_record_id", "=", recordId)
|
||||||
|
.where("f.role", "=", "main")
|
||||||
|
.where((eb) =>
|
||||||
|
eb.or([
|
||||||
|
eb("f.status", "=", "succeeded"),
|
||||||
|
eb.and([eb("f.status", "=", "skipped"), eb("f.skip_reason", "=", "already_in_library")]),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.where("a.status", "=", "active")
|
||||||
|
.execute();
|
||||||
|
const registeredUnits = successful.length
|
||||||
|
? await db
|
||||||
|
.selectFrom("asset_unit_coverage as c")
|
||||||
|
.innerJoin("content_unit as u", "u.id", "c.content_unit_id")
|
||||||
|
.select(["c.media_asset_id", "u.season_number", "u.episode_number"])
|
||||||
|
.where(
|
||||||
|
"c.media_asset_id",
|
||||||
|
"in",
|
||||||
|
successful.map((f) => f.media_asset_id!),
|
||||||
|
)
|
||||||
|
.where("c.coverage", "=", "full")
|
||||||
|
.execute()
|
||||||
|
: [];
|
||||||
|
const scopes = successful.flatMap((f) => {
|
||||||
|
const units = registeredUnits.filter(
|
||||||
|
(u) => u.media_asset_id === f.media_asset_id && u.season_number !== null && u.episode_number !== null,
|
||||||
|
);
|
||||||
|
if (units.length) {
|
||||||
|
return [...new Set(units.map((u) => u.season_number!))].map((season) =>
|
||||||
|
replacementScope(f.source_path, frozen.mediaType, {
|
||||||
|
season,
|
||||||
|
episodes: units.filter((u) => u.season_number === season).map((u) => u.episode_number!),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const facts = frozen.facts[f.source_path];
|
||||||
|
return [
|
||||||
|
replacementScope(
|
||||||
|
f.source_path,
|
||||||
|
frozen.mediaType,
|
||||||
|
facts?.episodes
|
||||||
|
? {
|
||||||
|
season: facts.season,
|
||||||
|
episodes: Array.from(
|
||||||
|
{ length: facts.episodes.end - facts.episodes.start + 1 },
|
||||||
|
(_, i) => facts.episodes!.start + i,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
for (const old of frozen.replacement.assets) {
|
||||||
|
if (successful.some((f) => f.media_asset_id === old.assetId)) continue;
|
||||||
|
if (!replacementCovers(old.scope, scopes)) {
|
||||||
|
if (successful.length) {
|
||||||
|
await upsertDecision(d, db, {
|
||||||
|
kind: "other",
|
||||||
|
severity: "info",
|
||||||
|
title: "旧版本未完整覆盖,保留文件",
|
||||||
|
summary: "本次成功文件未明确覆盖旧文件的全部季集或 Part;旧文件保留,可核对范围后处理",
|
||||||
|
subjectType: "media_asset",
|
||||||
|
subjectId: old.assetId,
|
||||||
|
dedupeKey: `replacement_scope:${recordId}:${old.assetId}`,
|
||||||
|
evidence: { recordId, oldScope: old.scope, successfulScopes: scopes },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const file of old.files) {
|
||||||
|
const opId = `replacement:${recordId}:${file.locationId}`;
|
||||||
|
const existing = await db
|
||||||
|
.selectFrom("asset_operation")
|
||||||
|
.select(["status", "result"])
|
||||||
|
.where("operation_id", "=", opId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (existing?.status === "succeeded") continue;
|
||||||
|
const location = await db
|
||||||
|
.selectFrom("asset_location")
|
||||||
|
.select(["status", "media_asset_id", "relative_path"])
|
||||||
|
.where("id", "=", file.locationId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!location || location.media_asset_id !== file.assetId || location.relative_path !== file.path) continue;
|
||||||
|
// A selected same-path replacement has already been quarantined by copyWithVerify and its old location retired.
|
||||||
|
if (location.status === "removed") continue;
|
||||||
|
if (successful.some((f) => frozen.libraryRootId === file.rootId && f.target_path === file.path)) continue;
|
||||||
|
try {
|
||||||
|
const fs = fsRuntime(d);
|
||||||
|
if ((await fs.proxy.checkRoot(file.rootPath, file.sentinel)).kind !== "online") {
|
||||||
|
throw new Error("旧版本库根离线");
|
||||||
|
}
|
||||||
|
const rootReal = await realpath(file.rootPath);
|
||||||
|
const abs = join(rootReal, file.path);
|
||||||
|
const parent = await realpath(dirname(abs));
|
||||||
|
const rel = relative(rootReal, parent);
|
||||||
|
if (rel.startsWith("..") || file.path.split("/").some((s) => s === "..")) throw new Error("旧文件位置越界");
|
||||||
|
const trash = join(rootReal, ".umt-trash", file.trashDay, file.trashId, file.path);
|
||||||
|
const st = await fs.proxy.statSafe(abs, {
|
||||||
|
root: rootReal,
|
||||||
|
sentinel: file.sentinel,
|
||||||
|
});
|
||||||
|
const moved = await fs.proxy.statSafe(trash);
|
||||||
|
const resumed =
|
||||||
|
existing?.status === "started" &&
|
||||||
|
st.kind === "missing" &&
|
||||||
|
(fs.replaceOld === "delete" || moved.kind === "ok");
|
||||||
|
if (
|
||||||
|
!resumed &&
|
||||||
|
(st.kind !== "ok" ||
|
||||||
|
st.facts.isSymbolicLink ||
|
||||||
|
Object.entries(file.facts).some(([k, v]) => st.facts[k as keyof typeof file.facts] !== v))
|
||||||
|
) {
|
||||||
|
throw new Error("旧文件已变化,保留待核对");
|
||||||
|
}
|
||||||
|
await db
|
||||||
|
.insertInto("asset_operation")
|
||||||
|
.values({
|
||||||
|
operation_id: opId,
|
||||||
|
phase: "cleanup",
|
||||||
|
kind: "delete_target",
|
||||||
|
status: "started",
|
||||||
|
organization_record_id: recordId,
|
||||||
|
media_asset_id: file.assetId,
|
||||||
|
intent: jsonb({ file, mode: fs.replaceOld }),
|
||||||
|
started_at: d.ports.now(),
|
||||||
|
})
|
||||||
|
.onConflict((oc) => oc.column("operation_id").doUpdateSet({ status: "started" }))
|
||||||
|
.execute();
|
||||||
|
if (!resumed) {
|
||||||
|
if (fs.replaceOld === "delete") await rm(abs);
|
||||||
|
else {
|
||||||
|
await mkdir(dirname(trash), { recursive: true });
|
||||||
|
const result = await fs.proxy.moveSafe(abs, trash);
|
||||||
|
if (result.status !== "moved") {
|
||||||
|
throw new Error(`旧文件回收失败:${result.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await db.transaction().execute(async (trx) => {
|
||||||
|
if (fs.replaceOld === "trash") {
|
||||||
|
await trx
|
||||||
|
.insertInto("trash_item")
|
||||||
|
.values({
|
||||||
|
id: file.trashId,
|
||||||
|
root_id: `lib:${file.rootId}`,
|
||||||
|
library_root_id: file.rootId,
|
||||||
|
root_host_path: rootReal,
|
||||||
|
original_path: file.path,
|
||||||
|
trash_path: trash,
|
||||||
|
is_dir: false,
|
||||||
|
size_bytes: file.facts.size,
|
||||||
|
actor: "system:organize",
|
||||||
|
deleted_at: d.ports.now(),
|
||||||
|
expires_at: new Date(d.ports.now().getTime() + 7 * 86_400_000),
|
||||||
|
asset_snapshot: jsonb({
|
||||||
|
source: "one_time_replacement",
|
||||||
|
recordId,
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
assetId: file.assetId,
|
||||||
|
locationId: file.locationId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.onConflict((oc) => oc.column("id").doNothing())
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
await trx
|
||||||
|
.updateTable("asset_location")
|
||||||
|
.set({ status: "removed" })
|
||||||
|
.where("id", "=", file.locationId)
|
||||||
|
.execute();
|
||||||
|
await sql`update media_asset set status='retired' where id=${file.assetId} and not exists(select 1 from asset_location where media_asset_id=${file.assetId} and status <> 'removed')`.execute(
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
await trx
|
||||||
|
.updateTable("asset_operation")
|
||||||
|
.set({
|
||||||
|
status: "succeeded",
|
||||||
|
result: jsonb({
|
||||||
|
disposition: fs.replaceOld,
|
||||||
|
path: fs.replaceOld === "trash" ? trash : null,
|
||||||
|
}),
|
||||||
|
finished_at: d.ports.now(),
|
||||||
|
})
|
||||||
|
.where("operation_id", "=", opId)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await upsertDecision(d, db, {
|
||||||
|
kind: "other",
|
||||||
|
title: "旧版本清理未完成",
|
||||||
|
summary: error instanceof Error ? error.message : String(error),
|
||||||
|
subjectType: "media_asset",
|
||||||
|
subjectId: file.assetId,
|
||||||
|
dedupeKey: `replacement_cleanup:${recordId}:${file.locationId}`,
|
||||||
|
evidence: { recordId, file },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the old same-path file until the new asset has passed final validation. */
|
||||||
|
export async function settleSamePathReplacement(d: AoDeps, frozen: FrozenPlan, recordId: string): Promise<void> {
|
||||||
|
if (!frozen.replacement) return;
|
||||||
|
const results = await d.ctx.db
|
||||||
|
.selectFrom("organization_file_result")
|
||||||
|
.selectAll()
|
||||||
|
.where("organization_record_id", "=", recordId)
|
||||||
|
.execute();
|
||||||
|
const bySource = new Map(results.map((f) => [f.source_path, f]));
|
||||||
|
for (const pf of frozen.organize.files) {
|
||||||
|
if (pf.action !== "replace") continue;
|
||||||
|
const old = frozen.replacement.assets.flatMap((a) => a.files).find((f) => join(f.rootPath, f.path) === pf.dst);
|
||||||
|
if (!old) continue;
|
||||||
|
const main = bySource.get(pf.mainSrc ?? pf.src);
|
||||||
|
const file = bySource.get(pf.src);
|
||||||
|
if (!file) continue;
|
||||||
|
const opId = `org:${recordId}:${file.ordinal}`;
|
||||||
|
const op = await d.ctx.db
|
||||||
|
.selectFrom("asset_operation")
|
||||||
|
.select("result")
|
||||||
|
.where("operation_id", "=", opId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
const result = op?.result as
|
||||||
|
| {
|
||||||
|
replaced?: { disposition: string; path?: string; trashItemId?: string };
|
||||||
|
rollback?: boolean;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
const backup = result?.replaced;
|
||||||
|
if (!backup?.path || backup.disposition !== "trash" || result?.rollback) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const fs = fsRuntime(d);
|
||||||
|
if (main?.status === "succeeded" && main.media_asset_id) {
|
||||||
|
if (fs.replaceOld === "delete") {
|
||||||
|
await rm(backup.path, { force: true });
|
||||||
|
if (backup.trashItemId) {
|
||||||
|
await d.ctx.db.deleteFrom("trash_item").where("id", "=", backup.trashItemId).execute();
|
||||||
|
}
|
||||||
|
await d.ctx.db
|
||||||
|
.updateTable("asset_operation")
|
||||||
|
.set({
|
||||||
|
result: jsonb({
|
||||||
|
...result,
|
||||||
|
replaced: { ...backup, disposition: "deleted" },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.where("operation_id", "=", opId)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (main?.status !== "failed") continue;
|
||||||
|
if ((await fs.proxy.checkRoot(old.rootPath, old.sentinel)).kind !== "online") {
|
||||||
|
throw new Error("旧版本库根离线,保留回收副本");
|
||||||
|
}
|
||||||
|
const rejected = join(old.rootPath, ".umt-trash", old.trashDay, old.trashId, old.path);
|
||||||
|
const oldBackup = await fs.proxy.statSafe(backup.path);
|
||||||
|
const target = await fs.proxy.statSafe(pf.dst);
|
||||||
|
const alreadyRestored =
|
||||||
|
oldBackup.kind === "missing" &&
|
||||||
|
target.kind === "ok" &&
|
||||||
|
Object.entries(old.facts).every(([key, value]) => target.facts[key as keyof typeof old.facts] === value);
|
||||||
|
if (!alreadyRestored) {
|
||||||
|
if (
|
||||||
|
oldBackup.kind !== "ok" ||
|
||||||
|
oldBackup.facts.size !== old.facts.size ||
|
||||||
|
oldBackup.facts.ino !== old.facts.ino
|
||||||
|
) {
|
||||||
|
throw new Error("旧文件回收副本已变化");
|
||||||
|
}
|
||||||
|
if (!file.checksum?.startsWith("sha256:")) {
|
||||||
|
throw new Error("失败目标缺少全量校验,旧文件留在回收站");
|
||||||
|
}
|
||||||
|
if (target.kind === "ok") {
|
||||||
|
if ((await fs.proxy.sha256(pf.dst)).digest !== file.checksum.slice(7)) {
|
||||||
|
throw new Error("失败目标已变化,旧文件留在回收站");
|
||||||
|
}
|
||||||
|
await mkdir(dirname(rejected), { recursive: true });
|
||||||
|
const parked = await fs.proxy.moveSafe(pf.dst, rejected);
|
||||||
|
if (parked.status !== "moved") throw new Error("失败新文件无法暂存");
|
||||||
|
} else if (target.kind !== "missing" || (await fs.proxy.sha256(rejected)).digest !== file.checksum.slice(7)) {
|
||||||
|
throw new Error("失败新文件暂存无法核验");
|
||||||
|
}
|
||||||
|
const restored = await fs.proxy.moveSafe(backup.path, pf.dst);
|
||||||
|
if (restored.status !== "moved") {
|
||||||
|
throw new Error("旧文件恢复未完成,副本保留");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await d.ctx.db.transaction().execute(async (trx) => {
|
||||||
|
if (backup.trashItemId) {
|
||||||
|
await trx.deleteFrom("trash_item").where("id", "=", backup.trashItemId).execute();
|
||||||
|
}
|
||||||
|
await trx
|
||||||
|
.insertInto("trash_item")
|
||||||
|
.values({
|
||||||
|
id: old.trashId,
|
||||||
|
root_id: `lib:${old.rootId}`,
|
||||||
|
library_root_id: old.rootId,
|
||||||
|
root_host_path: old.rootPath,
|
||||||
|
original_path: old.path,
|
||||||
|
trash_path: rejected,
|
||||||
|
is_dir: false,
|
||||||
|
size_bytes: pf.size,
|
||||||
|
actor: "system:organize",
|
||||||
|
deleted_at: d.ports.now(),
|
||||||
|
expires_at: new Date(d.ports.now().getTime() + 7 * 86_400_000),
|
||||||
|
asset_snapshot: jsonb({ source: "rejected_replacement", recordId }),
|
||||||
|
})
|
||||||
|
.onConflict((oc) => oc.column("id").doNothing())
|
||||||
|
.execute();
|
||||||
|
await trx
|
||||||
|
.updateTable("asset_operation")
|
||||||
|
.set({
|
||||||
|
result: jsonb({
|
||||||
|
...result,
|
||||||
|
rollback: true,
|
||||||
|
replaced: { ...backup, disposition: "restored" },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.where("operation_id", "=", opId)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await upsertDecision(d, d.ctx.db, {
|
||||||
|
kind: "other",
|
||||||
|
title: "洗版旧文件恢复待核对",
|
||||||
|
summary: error instanceof Error ? error.message : String(error),
|
||||||
|
subjectType: "media_asset",
|
||||||
|
subjectId: old.assetId,
|
||||||
|
dedupeKey: `replacement_restore:${recordId}:${old.locationId}`,
|
||||||
|
evidence: { recordId, old, backup },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stagedReplacementBackup(d: AoDeps, file: ReplacementFile): Promise<string | null> {
|
||||||
|
const stage = join(file.rootPath, ".umt-trash", "replacement-staging", file.trashId);
|
||||||
|
for (const dir of await readdir(stage).catch(() => [] as string[])) {
|
||||||
|
const candidate = join(stage, dir, basename(file.path));
|
||||||
|
const st = await fsRuntime(d).proxy.statSafe(candidate);
|
||||||
|
if (
|
||||||
|
st.kind === "ok" &&
|
||||||
|
!st.facts.isSymbolicLink &&
|
||||||
|
Object.entries(file.facts).every(([key, value]) => st.facts[key as keyof typeof file.facts] === value)
|
||||||
|
)
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -23,7 +23,13 @@ const credits = (v: unknown): Credit[] => (Array.isArray(v) ? (v as Credit[]) :
|
|||||||
const person = (c: Credit): NfoPerson | null => {
|
const person = (c: Credit): NfoPerson | null => {
|
||||||
const name = str(c.name);
|
const name = str(c.name);
|
||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
return { tmdbId: numOf(c.id), name, role: str(c.role), profilePath: str(c.profilePath) };
|
const id = numOf(c.id);
|
||||||
|
return {
|
||||||
|
tmdbId: id !== null && Number.isInteger(id) && id > 0 ? id : null,
|
||||||
|
name,
|
||||||
|
role: str(c.role),
|
||||||
|
profilePath: str(c.profilePath),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 制片国:ISO 3166-1 代码 → 中文名(Intl);认不出的保留原码 */
|
/** 制片国:ISO 3166-1 代码 → 中文名(Intl);认不出的保留原码 */
|
||||||
@@ -132,7 +138,10 @@ export function nfoEpisode(season: number, episode: number, u: UnitForNfo | unde
|
|||||||
|
|
||||||
/** 图片路径:作品海报 / 背景图,季海报,集剧照 */
|
/** 图片路径:作品海报 / 背景图,季海报,集剧照 */
|
||||||
export function imagePathsOf(entityMeta: unknown) {
|
export function imagePathsOf(entityMeta: unknown) {
|
||||||
return { posterPath: str(val(entityMeta, "posterPath")), backdropPath: str(val(entityMeta, "backdropPath")) };
|
return {
|
||||||
|
posterPath: str(val(entityMeta, "posterPath")) ?? str(val(entityMeta, "posterUrl")),
|
||||||
|
backdropPath: str(val(entityMeta, "backdropPath")),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export const unitPosterPath = (m: unknown) => str(val(m, "posterPath"));
|
export const unitPosterPath = (m: unknown) => str(val(m, "posterPath"));
|
||||||
export const unitStillPath = (m: unknown) => str(val(m, "stillPath"));
|
export const unitStillPath = (m: unknown) => str(val(m, "stillPath"));
|
||||||
|
|||||||
@@ -170,7 +170,10 @@ export async function queueScrape(
|
|||||||
(cur.media_entity_id ?? null) === input.match.mediaEntityId &&
|
(cur.media_entity_id ?? null) === input.match.mediaEntityId &&
|
||||||
((cur.match as ScrapeMatch | null)?.externalId ?? null) === input.match.externalId;
|
((cur.match as ScrapeMatch | null)?.externalId ?? null) === input.match.externalId;
|
||||||
if (cur && sameMatch && cur.engine_run_id) return { recordId: cur.id, taskId: cur.engine_run_id, deduplicated: true };
|
if (cur && sameMatch && cur.engine_run_id) return { recordId: cur.id, taskId: cur.engine_run_id, deduplicated: true };
|
||||||
const rec = await db
|
const reused = !!cur && sameMatch;
|
||||||
|
const rec = reused
|
||||||
|
? cur
|
||||||
|
: await db
|
||||||
.insertInto("scrape_record")
|
.insertInto("scrape_record")
|
||||||
.values({
|
.values({
|
||||||
root_id: input.rootId,
|
root_id: input.rootId,
|
||||||
@@ -188,33 +191,54 @@ export async function queueScrape(
|
|||||||
})
|
})
|
||||||
.returning("id")
|
.returning("id")
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
let queued: { runId: string; deduplicated: boolean };
|
||||||
try {
|
try {
|
||||||
const { runId, deduplicated } = await d.ctx.engine.enqueue(
|
queued = await d.ctx.engine.enqueue(
|
||||||
SCRAPE_TASK,
|
SCRAPE_TASK,
|
||||||
{ recordId: rec.id },
|
{ recordId: rec.id },
|
||||||
{ idempotencyKey: input.idempotencyKey ?? `${SCRAPE_TASK}:${rec.id}`, traceId: randomUUID() },
|
{ idempotencyKey: input.idempotencyKey ?? `${SCRAPE_TASK}:${rec.id}`, traceId: randomUUID() },
|
||||||
);
|
);
|
||||||
if (deduplicated) {
|
|
||||||
// 同一幂等键已排过(自动刮削重放):这条记录作废,返回已有的
|
|
||||||
await db.deleteFrom("scrape_record").where("id", "=", rec.id).execute();
|
|
||||||
const prev = await db
|
|
||||||
.selectFrom("scrape_record")
|
|
||||||
.select(["id"])
|
|
||||||
.where("engine_run_id", "=", runId)
|
|
||||||
.executeTakeFirst();
|
|
||||||
return { recordId: prev?.id ?? rec.id, taskId: runId, deduplicated: true };
|
|
||||||
}
|
|
||||||
await db.updateTable("scrape_record").set({ engine_run_id: runId }).where("id", "=", rec.id).execute();
|
|
||||||
return { recordId: rec.id, taskId: runId, deduplicated: false };
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// 只有新记录的入队失败才标失败;复用的记录可能已有接受但未关联的 run。
|
||||||
|
if (!reused)
|
||||||
await db
|
await db
|
||||||
.updateTable("scrape_record")
|
.updateTable("scrape_record")
|
||||||
.set({ status: "failed", reason: "task_enqueue_failed", finished_at: new Date() })
|
.set({ status: "failed", reason: "task_enqueue_failed", finished_at: new Date() })
|
||||||
.where("id", "=", rec.id)
|
.where("id", "=", rec.id)
|
||||||
|
.where("status", "=", "queued")
|
||||||
.execute()
|
.execute()
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
const { runId, deduplicated } = queued;
|
||||||
|
let recordId = rec.id;
|
||||||
|
if (deduplicated) {
|
||||||
|
// 自动刮削重放时,返回引擎真正持有的记录;确认之前不能删除新记录。
|
||||||
|
const prev = await db
|
||||||
|
.selectFrom("scrape_record")
|
||||||
|
.select("id")
|
||||||
|
.where("engine_run_id", "=", runId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (prev) recordId = prev.id;
|
||||||
|
else {
|
||||||
|
const run = await d.ctx.engine.get(runId);
|
||||||
|
const original = (run?.input as { recordId?: unknown } | undefined)?.recordId;
|
||||||
|
if (typeof original !== "string") throw new Error(`刮削任务缺少记录:${runId}`);
|
||||||
|
const existing = await db.selectFrom("scrape_record").select("id").where("id", "=", original).executeTakeFirst();
|
||||||
|
if (!existing) throw new Error(`刮削记录不存在:${original}`);
|
||||||
|
recordId = existing.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await db.transaction().execute(async (trx) => {
|
||||||
|
await trx.updateTable("scrape_record").set({ engine_run_id: runId }).where("id", "=", recordId).execute();
|
||||||
|
if (recordId !== rec.id) await trx.deleteFrom("scrape_record").where("id", "=", rec.id).execute();
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// run 已被接受,保持 queued;处理器开始时补回关联,重试也复用原记录/幂等键。
|
||||||
|
d.ctx.logger.warn({ recordId, runId, err: { message: errMessage(err) } }, "刮削已入队,记录关联暂未写回");
|
||||||
|
}
|
||||||
|
return { recordId, taskId: runId, deduplicated };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 作品 → 目录(作品详情「重新刮削」、入库后自动刮削) ──
|
// ── 作品 → 目录(作品详情「重新刮削」、入库后自动刮削) ──
|
||||||
@@ -267,7 +291,7 @@ export async function entityScrapeLocations(
|
|||||||
.selectFrom("media_asset as a")
|
.selectFrom("media_asset as a")
|
||||||
.innerJoin("asset_location as l", "l.media_asset_id", "a.id")
|
.innerJoin("asset_location as l", "l.media_asset_id", "a.id")
|
||||||
.innerJoin("library_root as r", "r.id", "l.library_root_id")
|
.innerJoin("library_root as r", "r.id", "l.library_root_id")
|
||||||
.select(["l.relative_path", "r.id as root_id", "r.local_path"])
|
.select(["a.kind", "l.relative_path", "r.id as root_id", "r.local_path"])
|
||||||
.where("a.media_entity_id", "=", entityId)
|
.where("a.media_entity_id", "=", entityId)
|
||||||
.where("a.status", "=", "active")
|
.where("a.status", "=", "active")
|
||||||
.where("a.kind", "in", ["video", "bluray_dir"])
|
.where("a.kind", "in", ["video", "bluray_dir"])
|
||||||
@@ -291,7 +315,10 @@ export async function entityScrapeLocations(
|
|||||||
!!b.relative_path &&
|
!!b.relative_path &&
|
||||||
(l.relative_path === b.relative_path || l.relative_path.startsWith(`${b.relative_path}/`)),
|
(l.relative_path === b.relative_path || l.relative_path.startsWith(`${b.relative_path}/`)),
|
||||||
);
|
);
|
||||||
const path = bound?.relative_path ?? inferWorkDir(kind, l.relative_path) ?? l.relative_path;
|
// 原盘目录本身就是作品目录(MP:BDMV 只对原盘根目录刮削)
|
||||||
|
const path =
|
||||||
|
bound?.relative_path ??
|
||||||
|
(l.kind === "bluray_dir" ? l.relative_path : (inferWorkDir(kind, l.relative_path) ?? l.relative_path));
|
||||||
const key = `${l.root_id}\u0000${path}`;
|
const key = `${l.root_id}\u0000${path}`;
|
||||||
if (!out.has(key))
|
if (!out.has(key))
|
||||||
out.set(key, { rootId: `lib:${l.root_id}`, rootPath: l.local_path, libraryRootId: l.root_id, path });
|
out.set(key, { rootId: `lib:${l.root_id}`, rootPath: l.local_path, libraryRootId: l.root_id, path });
|
||||||
@@ -335,12 +362,53 @@ export async function scrapeEntity(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 整理入库成功后:设置打开时对作品目录自动刮削(失败只记日志,不影响整理结果) */
|
/** 整理入库成功后:设置打开或本次 Local 补全要求写本地元数据时,复用作品目录刮削任务。 */
|
||||||
export async function scrapeAfterImport(d: AoDeps, entityId: string | null, recordId: string): Promise<void> {
|
export async function scrapeAfterImport(
|
||||||
|
d: AoDeps,
|
||||||
|
entityId: string | null,
|
||||||
|
recordId: string,
|
||||||
|
opts: { force?: boolean; enabled?: boolean | undefined } = {},
|
||||||
|
): Promise<void> {
|
||||||
if (!entityId) return;
|
if (!entityId) return;
|
||||||
|
if (opts.enabled === false) return;
|
||||||
try {
|
try {
|
||||||
const s = await scrapeSettings(d);
|
const s = await scrapeSettings(d);
|
||||||
if (!s.scrapeAfterImport) return;
|
if (!s.scrapeAfterImport && !opts.force && opts.enabled !== true) return;
|
||||||
|
const organization = await d.ctx.db
|
||||||
|
.selectFrom("organization_record")
|
||||||
|
.select(["action", "target_library_root_id"])
|
||||||
|
.where("id", "=", recordId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (organization?.action === "in_place" && organization.target_library_root_id) {
|
||||||
|
const match = await entityScrapeMatch(d, entityId, "library");
|
||||||
|
const root = await d.ctx.db
|
||||||
|
.selectFrom("library_root")
|
||||||
|
.selectAll()
|
||||||
|
.where("id", "=", organization.target_library_root_id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!match || !root || root.management_mode !== "managed") return;
|
||||||
|
const files = await d.ctx.db
|
||||||
|
.selectFrom("organization_file_result")
|
||||||
|
.select("target_path")
|
||||||
|
.where("organization_record_id", "=", recordId)
|
||||||
|
.where("role", "=", "main")
|
||||||
|
.where("status", "=", "succeeded")
|
||||||
|
.orderBy("ordinal")
|
||||||
|
.execute();
|
||||||
|
for (const file of files)
|
||||||
|
if (file.target_path)
|
||||||
|
await queueScrape(d, {
|
||||||
|
rootId: `lib:${root.id}`,
|
||||||
|
rootPath: root.local_path,
|
||||||
|
libraryRootId: root.id,
|
||||||
|
path: file.target_path,
|
||||||
|
match,
|
||||||
|
trigger: "auto",
|
||||||
|
organizationRecordId: recordId,
|
||||||
|
idempotencyKey: `${SCRAPE_TASK}:record:${recordId}:${file.target_path}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
await scrapeEntity(d, entityId, { trigger: "auto", organizationRecordId: recordId });
|
await scrapeEntity(d, entityId, { trigger: "auto", organizationRecordId: recordId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
d.ctx.logger.warn({ entityId, recordId, err: { message: errMessage(err) } }, "入库后自动刮削没排上");
|
d.ctx.logger.warn({ entityId, recordId, err: { message: errMessage(err) } }, "入库后自动刮削没排上");
|
||||||
@@ -469,7 +537,12 @@ interface Fields {
|
|||||||
episodeUnit: Map<string, UnitForNfo>;
|
episodeUnit: Map<string, UnitForNfo>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runScrapeRecord(d: AoDeps, recordId: string, signal?: AbortSignal): Promise<ScrapeRecord> {
|
export async function runScrapeRecord(
|
||||||
|
d: AoDeps,
|
||||||
|
recordId: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
runId?: string,
|
||||||
|
): Promise<ScrapeRecord> {
|
||||||
const db = d.ctx.db;
|
const db = d.ctx.db;
|
||||||
const rec = await db.selectFrom("scrape_record").selectAll().where("id", "=", recordId).executeTakeFirst();
|
const rec = await db.selectFrom("scrape_record").selectAll().where("id", "=", recordId).executeTakeFirst();
|
||||||
if (!rec) throw new Error(`刮削记录不存在:${recordId}`);
|
if (!rec) throw new Error(`刮削记录不存在:${recordId}`);
|
||||||
@@ -477,7 +550,7 @@ export async function runScrapeRecord(d: AoDeps, recordId: string, signal?: Abor
|
|||||||
const now = () => d.ports.now();
|
const now = () => d.ports.now();
|
||||||
await db
|
await db
|
||||||
.updateTable("scrape_record")
|
.updateTable("scrape_record")
|
||||||
.set({ status: "running", started_at: now(), reason: null })
|
.set({ status: "running", started_at: now(), reason: null, ...(runId ? { engine_run_id: runId } : {}) })
|
||||||
.where("id", "=", recordId)
|
.where("id", "=", recordId)
|
||||||
.execute();
|
.execute();
|
||||||
const settings = await scrapeSettings(d);
|
const settings = await scrapeSettings(d);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
episodeKey,
|
episodeKey,
|
||||||
movieCompletion,
|
movieCompletion,
|
||||||
type ParsedRelease,
|
type ParsedRelease,
|
||||||
|
parseRelease,
|
||||||
parseUnitKey,
|
parseUnitKey,
|
||||||
priorityLadder,
|
priorityLadder,
|
||||||
type QualityFacts,
|
type QualityFacts,
|
||||||
@@ -319,8 +320,19 @@ export async function slotComplete(
|
|||||||
>,
|
>,
|
||||||
mediaType: "movie" | "tv",
|
mediaType: "movie" | "tv",
|
||||||
newAssets: readonly string[],
|
newAssets: readonly string[],
|
||||||
|
opts: { includeSubmitted?: boolean } = {},
|
||||||
): Promise<SlotCompletion> {
|
): Promise<SlotCompletion> {
|
||||||
const completion = (slot.completion ?? {}) as { kind?: string; cuts?: string[] };
|
const completion = (slot.completion ?? {}) as { kind?: string; cuts?: string[] };
|
||||||
|
const accepted = opts.includeSubmitted
|
||||||
|
? await exec
|
||||||
|
.selectFrom("acquisition_plan as p")
|
||||||
|
.innerJoin("source_release as r", "r.id", "p.source_release_id")
|
||||||
|
.select(["p.id as plan_id", "p.unit_keys", "r.parsed", "r.title"])
|
||||||
|
.where("p.slot_id", "=", slot.id)
|
||||||
|
.where("p.status", "in", ["submitted", "completed"])
|
||||||
|
.where("p.status_reason", "!=", "accepted_unconfirmed")
|
||||||
|
.execute()
|
||||||
|
: [];
|
||||||
if (mediaType === "movie") {
|
if (mediaType === "movie") {
|
||||||
const assets = newAssets.length
|
const assets = newAssets.length
|
||||||
? await exec
|
? await exec
|
||||||
@@ -329,14 +341,20 @@ export async function slotComplete(
|
|||||||
.where("id", "in", [...newAssets])
|
.where("id", "in", [...newAssets])
|
||||||
.execute()
|
.execute()
|
||||||
: [];
|
: [];
|
||||||
const complete = movieCompletion(
|
const complete = movieCompletion(completion.cuts as never, [
|
||||||
completion.cuts as never,
|
...assets.map((a) => ({
|
||||||
assets.map((a) => ({
|
|
||||||
assetId: a.id,
|
assetId: a.id,
|
||||||
verified: true,
|
verified: true,
|
||||||
...(a.content_edition ? { cut: a.content_edition as never } : {}),
|
...(a.content_edition ? { cut: a.content_edition as never } : {}),
|
||||||
})),
|
})),
|
||||||
).complete;
|
...accepted.map((p) => ({
|
||||||
|
assetId: p.plan_id,
|
||||||
|
verified: true,
|
||||||
|
...(((p.parsed as { cut?: string } | null)?.cut ?? parseRelease(p.title).cut)
|
||||||
|
? { cut: ((p.parsed as { cut?: string } | null)?.cut ?? parseRelease(p.title).cut) as never }
|
||||||
|
: {}),
|
||||||
|
})),
|
||||||
|
]).complete;
|
||||||
return { complete, seasons: [], suspectedMissing: [] };
|
return { complete, seasons: [], suspectedMissing: [] };
|
||||||
}
|
}
|
||||||
const sub = slot.subscription_id
|
const sub = slot.subscription_id
|
||||||
@@ -357,6 +375,12 @@ export async function slotComplete(
|
|||||||
const u = parseUnitKey(k);
|
const u = parseUnitKey(k);
|
||||||
if (u?.episode !== undefined) inflightEps.set(u.season, [...(inflightEps.get(u.season) ?? []), u.episode]);
|
if (u?.episode !== undefined) inflightEps.set(u.season, [...(inflightEps.get(u.season) ?? []), u.episode]);
|
||||||
}
|
}
|
||||||
|
for (const p of accepted)
|
||||||
|
for (const key of p.unit_keys) {
|
||||||
|
const unit = parseUnitKey(key);
|
||||||
|
if (unit?.episode !== undefined)
|
||||||
|
covered.episodes.set(unit.season, new Set([...(covered.episodes.get(unit.season) ?? []), unit.episode]));
|
||||||
|
}
|
||||||
const ledgers = await seasonLedgers(exec, slot.media_entity_id);
|
const ledgers = await seasonLedgers(exec, slot.media_entity_id);
|
||||||
const seasons = await scopeSeasons(exec, slot, sub, covered.episodes, ledgers);
|
const seasons = await scopeSeasons(exec, slot, sub, covered.episodes, ledgers);
|
||||||
const range = rangeOf(sub);
|
const range = rangeOf(sub);
|
||||||
@@ -490,6 +514,24 @@ export async function afterSlotVerified(
|
|||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
const seasonDone = sub?.media_kind !== "movie" && sub?.season_scope === "current_season";
|
const seasonDone = sub?.media_kind !== "movie" && sub?.season_scope === "current_season";
|
||||||
if (sub && sub.status === "active" && (sub.mode === "once" || seasonDone)) {
|
if (sub && sub.status === "active" && (sub.mode === "once" || seasonDone)) {
|
||||||
|
const siblings = await trx
|
||||||
|
.selectFrom("acquisition_slot")
|
||||||
|
.selectAll()
|
||||||
|
.where("subscription_id", "=", sub.id)
|
||||||
|
.where("id", "!=", slot.id)
|
||||||
|
.execute();
|
||||||
|
const others = await Promise.all(
|
||||||
|
siblings.map((s) =>
|
||||||
|
slotComplete(
|
||||||
|
trx,
|
||||||
|
s,
|
||||||
|
sub.media_kind === "movie" ? "movie" : "tv",
|
||||||
|
s.current_asset_id ? [s.current_asset_id] : [],
|
||||||
|
{ includeSubmitted: true },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (others.every((p) => p.complete))
|
||||||
await completeSubscription(d, trx, sub, "slot_satisfied", { seasons: input.completion.seasons });
|
await completeSubscription(d, trx, sub, "slot_satisfied", { seasons: input.completion.seasons });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -856,3 +898,46 @@ export async function subscriptionProgress(exec: Exec, subId: string): Promise<S
|
|||||||
(sub.media_kind === "movie" || (seasons.length > 0 && seasons.every((s) => s.totalKnown)));
|
(sub.media_kind === "movie" || (seasons.length > 0 && seasons.every((s) => s.totalKnown)));
|
||||||
return { subscriptionId: sub.id, status: sub.status, completable, seasons, slots: slotOut };
|
return { subscriptionId: sub.id, status: sub.status, completable, seasons, slots: slotOut };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** MP ordinary subscription progress is settled on confirmed downloader acceptance.
|
||||||
|
* Reserved/uncertain tasks and season wildcard claims never count as covered episodes.
|
||||||
|
*/
|
||||||
|
export async function afterDownloadAccepted(d: AoDeps, planId: string): Promise<void> {
|
||||||
|
await d.ctx.db.transaction().execute(async (trx) => {
|
||||||
|
const plan = await trx
|
||||||
|
.selectFrom("acquisition_plan")
|
||||||
|
.select(["slot_id"])
|
||||||
|
.where("id", "=", planId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!plan?.slot_id) return;
|
||||||
|
const slot = await trx.selectFrom("acquisition_slot").selectAll().where("id", "=", plan.slot_id).executeTakeFirst();
|
||||||
|
if (!slot?.subscription_id) return;
|
||||||
|
const sub = await trx
|
||||||
|
.selectFrom("subscription")
|
||||||
|
.selectAll()
|
||||||
|
.where("id", "=", slot.subscription_id)
|
||||||
|
.forUpdate()
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (sub?.status !== "active") return;
|
||||||
|
await touchProgress(trx, { subscriptionId: sub.id }, d.ports.now());
|
||||||
|
if (sub.mode !== "once" && sub.season_scope !== "current_season") return;
|
||||||
|
const slots = await trx.selectFrom("acquisition_slot").selectAll().where("subscription_id", "=", sub.id).execute();
|
||||||
|
if (!slots.length) return;
|
||||||
|
const progress = await Promise.all(
|
||||||
|
slots.map((s) =>
|
||||||
|
slotComplete(
|
||||||
|
trx,
|
||||||
|
s,
|
||||||
|
sub.media_kind === "movie" ? "movie" : "tv",
|
||||||
|
s.current_asset_id ? [s.current_asset_id] : [],
|
||||||
|
{ includeSubmitted: true },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (progress.every((p) => p.complete))
|
||||||
|
await completeSubscription(d, trx, sub, "download_accepted", {
|
||||||
|
slots: progress,
|
||||||
|
seasons: progress.flatMap((p) => p.seasons),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -55,8 +55,10 @@ export async function planBinding(db: Kysely<DB>, planType: string, planId: stri
|
|||||||
if (planType === "acquisition_plan") {
|
if (planType === "acquisition_plan") {
|
||||||
const r = await db
|
const r = await db
|
||||||
.selectFrom("acquisition_plan as p")
|
.selectFrom("acquisition_plan as p")
|
||||||
.innerJoin("acquisition_slot as s", "s.id", "p.slot_id")
|
.leftJoin("acquisition_slot as s", "s.id", "p.slot_id")
|
||||||
.innerJoin("media_entity as e", "e.id", "s.media_entity_id")
|
.leftJoin("media_entity as e", (j) =>
|
||||||
|
j.onRef("e.id", "=", sql<string>`coalesce(s.media_entity_id, (p.evaluation->>'mediaEntityId')::uuid)`),
|
||||||
|
)
|
||||||
.select(["p.payload_hash", "p.expected_revision", "e.id as entity_id", "e.kind", "p.reserved_bytes"])
|
.select(["p.payload_hash", "p.expected_revision", "e.id as entity_id", "e.kind", "p.reserved_bytes"])
|
||||||
.where("p.id", "=", planId)
|
.where("p.id", "=", planId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { CapabilityError, type CapabilityRegistry } from "@umt/agent";
|
|||||||
import { capabilitySpec, type DownloadStatus } from "@umt/contracts";
|
import { capabilitySpec, type DownloadStatus } from "@umt/contracts";
|
||||||
import type { DownloaderInstanceRow } from "@umt/db";
|
import type { DownloaderInstanceRow } from "@umt/db";
|
||||||
import type { ServerContext } from "../types.ts";
|
import type { ServerContext } from "../types.ts";
|
||||||
|
import { planBinding } from "./authorize.ts";
|
||||||
import type { AgentModuleOptions } from "./runner.ts";
|
import type { AgentModuleOptions } from "./runner.ts";
|
||||||
|
|
||||||
// download.recheck(REQ §8.4.2 / §10.7 授权内恢复):对已纳管的下载任务发起 qB 强制重新校验或恢复暂停。
|
// download.recheck(REQ §8.4.2 / §10.7 授权内恢复):对已纳管的下载任务发起 qB 强制重新校验或恢复暂停。
|
||||||
@@ -29,15 +30,8 @@ async function checkGrantScope(ctx: ServerContext, grantId: string, planId: stri
|
|||||||
const g = await ctx.db.selectFrom("automation_grant").select("scope").where("id", "=", grantId).executeTakeFirst();
|
const g = await ctx.db.selectFrom("automation_grant").select("scope").where("id", "=", grantId).executeTakeFirst();
|
||||||
const scope = (g?.scope ?? {}) as { mediaEntityIds?: string[]; mediaKinds?: string[] };
|
const scope = (g?.scope ?? {}) as { mediaEntityIds?: string[]; mediaKinds?: string[] };
|
||||||
if (!scope.mediaEntityIds?.length && !scope.mediaKinds?.length) return;
|
if (!scope.mediaEntityIds?.length && !scope.mediaKinds?.length) return;
|
||||||
const e = planId
|
const binding = planId ? await planBinding(ctx.db, "acquisition_plan", planId) : null;
|
||||||
? await ctx.db
|
const e = binding?.mediaEntityId && binding.mediaKind ? { id: binding.mediaEntityId, kind: binding.mediaKind } : null;
|
||||||
.selectFrom("acquisition_plan as p")
|
|
||||||
.innerJoin("acquisition_slot as s", "s.id", "p.slot_id")
|
|
||||||
.innerJoin("media_entity as e", "e.id", "s.media_entity_id")
|
|
||||||
.select(["e.id", "e.kind"])
|
|
||||||
.where("p.id", "=", planId)
|
|
||||||
.executeTakeFirst()
|
|
||||||
: undefined;
|
|
||||||
if (!e) throw new CapabilityError("unauthorized", `授权 ${grantId} 限定了对象范围,但下载任务没有关联作品`);
|
if (!e) throw new CapabilityError("unauthorized", `授权 ${grantId} 限定了对象范围,但下载任务没有关联作品`);
|
||||||
if (scope.mediaEntityIds?.length && !scope.mediaEntityIds.includes(e.id))
|
if (scope.mediaEntityIds?.length && !scope.mediaEntityIds.includes(e.id))
|
||||||
throw new CapabilityError("unauthorized", `授权 ${grantId} 的对象范围不含目标作品`);
|
throw new CapabilityError("unauthorized", `授权 ${grantId} 的对象范围不含目标作品`);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import { pgCacheFor } from "@umt/db";
|
import { pgCacheFor } from "@umt/db";
|
||||||
import { resolveRunUpstream } from "../pi/index.ts";
|
import { resolveRunUpstream } from "../pi/index.ts";
|
||||||
|
import { SourcesRuntime } from "../sources-identity/runtime.ts";
|
||||||
import {
|
import {
|
||||||
AUTO_ENRICH_ACTOR,
|
AUTO_ENRICH_ACTOR,
|
||||||
type EnrichDeps,
|
type EnrichDeps,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
suggestEnrichment,
|
suggestEnrichment,
|
||||||
} from "../sources-identity/service/enrich.ts";
|
} from "../sources-identity/service/enrich.ts";
|
||||||
import { robotsAllows } from "../sources-identity/service/enrich-sources.ts";
|
import { robotsAllows } from "../sources-identity/service/enrich-sources.ts";
|
||||||
|
import { resolveOrganizeMetadata } from "../sources-identity/service/organize-metadata.ts";
|
||||||
import type { ServerContext } from "../types.ts";
|
import type { ServerContext } from "../types.ts";
|
||||||
import { agentSettings } from "./runner.ts";
|
import { agentSettings } from "./runner.ts";
|
||||||
|
|
||||||
@@ -129,6 +131,37 @@ export function registerMetadataCapabilities(
|
|||||||
ctx: ServerContext,
|
ctx: ServerContext,
|
||||||
opts: MetadataCapabilityOptions = {},
|
opts: MetadataCapabilityOptions = {},
|
||||||
): void {
|
): void {
|
||||||
|
registry.register(capabilitySpec("metadata.resolve_organize"), async (input, c) => {
|
||||||
|
const sub = (scope: string) => ({
|
||||||
|
actorId: scope,
|
||||||
|
taskId: c.taskId,
|
||||||
|
scopeIds: [],
|
||||||
|
signal: c.signal,
|
||||||
|
source: "task" as const,
|
||||||
|
});
|
||||||
|
const deps: EnrichDeps = {
|
||||||
|
async search(query, scope) {
|
||||||
|
const out = (await registry.invoke("web.search", { query }, sub(scope))) as WebSearchOutput;
|
||||||
|
return out.results.map((r) => ({ title: r.title || null, url: r.url, snippet: r.snippet || null }));
|
||||||
|
},
|
||||||
|
async fetch(url, scope) {
|
||||||
|
const p = (await registry.invoke("web.fetch", { url }, sub(scope))) as WebFetchOutput;
|
||||||
|
return { url: p.url, finalUrl: p.finalUrl, title: p.title, text: p.text, image: p.image ?? null };
|
||||||
|
},
|
||||||
|
complete: opts.complete ?? ((system, user, signal) => upstreamComplete(ctx, system, user, signal)),
|
||||||
|
robotsAllowed: (url, signal) => robotsAllowedFor(ctx, url, { ...opts, ...(signal ? { signal } : {}) }),
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
return await resolveOrganizeMetadata(new SourcesRuntime(ctx), input.sourceReleaseId, deps, {
|
||||||
|
actor: { type: "system", id: c.actorId },
|
||||||
|
signal: c.signal,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (c.signal.aborted) throw e;
|
||||||
|
// Budget/provider errors leave the normal identity decision pending for manual resolution.
|
||||||
|
return { mediaEntityId: null, mode: null, enrichmentId: null, reason: (e as Error).message.slice(0, 500) };
|
||||||
|
}
|
||||||
|
});
|
||||||
registry.register(capabilitySpec("metadata.enrich"), async (input, c) => {
|
registry.register(capabilitySpec("metadata.enrich"), async (input, c) => {
|
||||||
// 网页搜索没开就不占预算
|
// 网页搜索没开就不占预算
|
||||||
const agent = await ctx.settings.get("agent").catch(() => null);
|
const agent = await ctx.settings.get("agent").catch(() => null);
|
||||||
|
|||||||
@@ -258,14 +258,16 @@ async function actionDetail(db: Kysely<DB>, planType: string, planId: string): P
|
|||||||
if (planType === "acquisition_plan") {
|
if (planType === "acquisition_plan") {
|
||||||
const r = await db
|
const r = await db
|
||||||
.selectFrom("acquisition_plan as p")
|
.selectFrom("acquisition_plan as p")
|
||||||
.innerJoin("acquisition_slot as s", "s.id", "p.slot_id")
|
.leftJoin("acquisition_slot as s", "s.id", "p.slot_id")
|
||||||
.innerJoin("media_entity as e", "e.id", "s.media_entity_id")
|
.leftJoin("media_entity as e", (j) =>
|
||||||
|
j.onRef("e.id", "=", sql<string>`coalesce(s.media_entity_id, (p.evaluation->>'mediaEntityId')::uuid)`),
|
||||||
|
)
|
||||||
.select(["e.title as entity", "p.reserved_bytes"])
|
.select(["e.title as entity", "p.reserved_bytes"])
|
||||||
.where("p.id", "=", planId)
|
.where("p.id", "=", planId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!r) return null;
|
if (!r) return null;
|
||||||
const gb = Number(r.reserved_bytes ?? 0) / 1024 ** 3;
|
const gb = Number(r.reserved_bytes ?? 0) / 1024 ** 3;
|
||||||
return safe(`《${r.entity}》${gb > 0 ? `· ${gb.toFixed(1)} GB` : ""}`, 200);
|
return safe(`《${r.entity ?? "未识别资源"}》${gb > 0 ? `· ${gb.toFixed(1)} GB` : ""}`, 200);
|
||||||
}
|
}
|
||||||
if (planType === "organize_plan") {
|
if (planType === "organize_plan") {
|
||||||
const r = await db.selectFrom("artifact").select("content").where("id", "=", planId).executeTakeFirst();
|
const r = await db.selectFrom("artifact").select("content").where("id", "=", planId).executeTakeFirst();
|
||||||
@@ -471,15 +473,20 @@ async function planSummary(db: Kysely<DB>, planType: string, planId: string): Pr
|
|||||||
if (planType === "acquisition_plan") {
|
if (planType === "acquisition_plan") {
|
||||||
const r = await db
|
const r = await db
|
||||||
.selectFrom("acquisition_plan as p")
|
.selectFrom("acquisition_plan as p")
|
||||||
.innerJoin("acquisition_slot as s", "s.id", "p.slot_id")
|
.leftJoin("acquisition_slot as s", "s.id", "p.slot_id")
|
||||||
.innerJoin("media_entity as e", "e.id", "s.media_entity_id")
|
.leftJoin("media_entity as e", (j) =>
|
||||||
|
j.onRef("e.id", "=", sql<string>`coalesce(s.media_entity_id, (p.evaluation->>'mediaEntityId')::uuid)`),
|
||||||
|
)
|
||||||
.leftJoin("source_release as r", "r.id", "p.source_release_id")
|
.leftJoin("source_release as r", "r.id", "p.source_release_id")
|
||||||
.select(["e.title as entity", "r.title as release", "p.reserved_bytes"])
|
.select(["e.title as entity", "r.title as release", "p.reserved_bytes"])
|
||||||
.where("p.id", "=", planId)
|
.where("p.id", "=", planId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (r) {
|
if (r) {
|
||||||
const gb = Number(r.reserved_bytes ?? 0) / 1024 ** 3;
|
const gb = Number(r.reserved_bytes ?? 0) / 1024 ** 3;
|
||||||
return safe(`下载《${r.entity}》:${r.release ?? "资源"}${gb > 0 ? `(${gb.toFixed(1)} GB)` : ""}`, 300);
|
return safe(
|
||||||
|
`下载《${r.entity ?? "未识别资源"}》:${r.release ?? "资源"}${gb > 0 ? `(${gb.toFixed(1)} GB)` : ""}`,
|
||||||
|
300,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (planType === "organize_plan") {
|
if (planType === "organize_plan") {
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import {
|
|||||||
Api,
|
Api,
|
||||||
CAPABILITY_CATALOG,
|
CAPABILITY_CATALOG,
|
||||||
type CapabilityDescriptor,
|
type CapabilityDescriptor,
|
||||||
type DownloadPlanOutput,
|
|
||||||
type KnownCapabilityId,
|
type KnownCapabilityId,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import { type AgentRunRow, sql } from "@umt/db";
|
import { type AgentRunRow, sql } from "@umt/db";
|
||||||
import { hasScope } from "../../http/auth.ts";
|
import { hasScope } from "../../http/auth.ts";
|
||||||
import { ApiException } from "../../http/errors.ts";
|
import { ApiException } from "../../http/errors.ts";
|
||||||
|
import { planManualAcquisition } from "../acquisition-organize/services/manual-acquisition.ts";
|
||||||
import type { ModuleApp, ServerContext } from "../types.ts";
|
import type { ModuleApp, ServerContext } from "../types.ts";
|
||||||
import { planBinding } from "./authorize.ts";
|
import { planBinding } from "./authorize.ts";
|
||||||
import { cancelTask, retryTask, taskToApi } from "./capabilities.ts";
|
import { cancelTask, retryTask, taskToApi } from "./capabilities.ts";
|
||||||
@@ -426,73 +426,48 @@ export function registerAgentRoutes(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 手动提交下载:同一条 download.plan → download.submit 能力路径(授权/审计/幂等与 Agent 一致)──
|
// 手动资源选择不依赖订阅,提交后仍走共同下载及整理任务。
|
||||||
route(app, Api.acquisitionPlans.create, async ({ body, actorId, c }) => {
|
route(app, Api.acquisitionPlans.create, async ({ body, actorId, c }) => {
|
||||||
const rel = await db()
|
const rel = await db()
|
||||||
.selectFrom("source_release")
|
.selectFrom("source_release")
|
||||||
.select(["id", "media_entity_id"])
|
.select("media_entity_id")
|
||||||
.where("id", "=", body.sourceReleaseId)
|
.where("id", "=", body.sourceReleaseId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!rel) throw new ApiException("not_found", "资源不存在");
|
if (
|
||||||
let slotIds: string[];
|
rel &&
|
||||||
if (body.slotId) {
|
!rel.media_entity_id &&
|
||||||
const s = await db().selectFrom("acquisition_slot").select("id").where("id", "=", body.slotId).executeTakeFirst();
|
!body.entityId &&
|
||||||
if (!s) throw new ApiException("not_found", "收录版本不存在");
|
!body.allowUnrecognized &&
|
||||||
slotIds = [s.id];
|
ctx.capabilities.get("identity.resolve")
|
||||||
} else {
|
) {
|
||||||
const entityId = body.entityId ?? rel.media_entity_id;
|
try {
|
||||||
if (!entityId)
|
await ctx.capabilities.invoke(
|
||||||
throw new ApiException("precondition_failed", "资源还没有关联本地作品,先在「身份与元数据」指定作品");
|
"identity.resolve",
|
||||||
slotIds = (
|
{ sourceReleaseId: body.sourceReleaseId },
|
||||||
await db()
|
{
|
||||||
.selectFrom("acquisition_slot")
|
|
||||||
.select("id")
|
|
||||||
.where("media_entity_id", "=", entityId)
|
|
||||||
.orderBy("created_at")
|
|
||||||
.execute()
|
|
||||||
).map((r) => r.id);
|
|
||||||
}
|
|
||||||
const rejected: { slotId: string | null; code: string; message: string }[] = [];
|
|
||||||
if (slotIds.length === 0)
|
|
||||||
rejected.push({ slotId: null, code: "no_slot", message: "这个作品没有收录版本,先在订阅里新建" });
|
|
||||||
const invokeCtx = {
|
|
||||||
actorId,
|
actorId,
|
||||||
taskId: `http:${c.get("requestId") ?? "req"}`,
|
taskId: `http:${c.get("requestId") ?? "req"}`,
|
||||||
scopeIds: [],
|
scopeIds: [],
|
||||||
signal: c.req.raw.signal,
|
signal: c.req.raw.signal,
|
||||||
source: "web" as const,
|
source: "web",
|
||||||
};
|
},
|
||||||
try {
|
);
|
||||||
for (const slotId of slotIds) {
|
} catch (err) {
|
||||||
const out = (await ctx.capabilities.invoke(
|
ctx.logger.warn(
|
||||||
"download.plan",
|
{ sourceReleaseId: body.sourceReleaseId, error: err instanceof Error ? err.message : String(err) },
|
||||||
{ slotId, sourceReleaseId: rel.id },
|
"手动下载识别未完成,允许确认后下载",
|
||||||
invokeCtx,
|
);
|
||||||
)) as DownloadPlanOutput;
|
|
||||||
if (!out.plan) {
|
|
||||||
for (const r of out.rejected) rejected.push({ slotId, ...r });
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
const sub = (await ctx.capabilities.invoke("download.submit", { planId: out.plan.id }, invokeCtx)) as {
|
}
|
||||||
taskId: string;
|
const result = await planManualAcquisition(ctx, body);
|
||||||
};
|
if (result.plan)
|
||||||
const plan = await db()
|
await audit(ctx, actorId, "acquisition_plan.manual_submit", result.plan.id, {
|
||||||
.selectFrom("acquisition_plan")
|
sourceReleaseId: body.sourceReleaseId,
|
||||||
.selectAll()
|
taskId: result.taskId,
|
||||||
.where("id", "=", out.plan.id)
|
importMode: body.importMode,
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
await audit(ctx, actorId, "acquisition_plan.manual_submit", plan.id, {
|
|
||||||
sourceReleaseId: rel.id,
|
|
||||||
slotId,
|
|
||||||
taskId: sub.taskId,
|
|
||||||
note: body.note ?? null,
|
note: body.note ?? null,
|
||||||
});
|
});
|
||||||
return { submitted: true, plan: camel(plan), slotId, taskId: sub.taskId, rejected };
|
return result;
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
capabilityHttpError(err);
|
|
||||||
}
|
|
||||||
return { submitted: false, plan: null, slotId: null, taskId: null, rejected };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 产物(整理计划 / 汇报 / 证据):只读 ──
|
// ── 产物(整理计划 / 汇报 / 证据):只读 ──
|
||||||
|
|||||||
@@ -128,7 +128,13 @@ export function engineQueries(db: Kysely<DB>, schema = "openworkflow"): EngineQu
|
|||||||
const cur: Cursor | null = decodeCursor(q.cursor);
|
const cur: Cursor | null = decodeCursor(q.cursor);
|
||||||
const conds = [sql`namespace_id = ${NAMESPACE}`];
|
const conds = [sql`namespace_id = ${NAMESPACE}`];
|
||||||
if (q.status?.length) conds.push(sql`status = any(${q.status}::text[])`);
|
if (q.status?.length) conds.push(sql`status = any(${q.status}::text[])`);
|
||||||
if (q.workflowName) conds.push(sql`workflow_name = ${q.workflowName}`);
|
if (q.workflowName) {
|
||||||
|
const workflows = q.workflowName
|
||||||
|
.split(",")
|
||||||
|
.map((name) => name.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
conds.push(sql`workflow_name = any(${workflows}::text[])`);
|
||||||
|
}
|
||||||
if (q.from) conds.push(sql`created_at >= ${new Date(q.from)}`);
|
if (q.from) conds.push(sql`created_at >= ${new Date(q.from)}`);
|
||||||
if (q.to) conds.push(sql`created_at < ${new Date(q.to)}`);
|
if (q.to) conds.push(sql`created_at < ${new Date(q.to)}`);
|
||||||
conds.push(...businessConds(q));
|
conds.push(...businessConds(q));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
type AuthenticationResponseJSON,
|
type AuthenticationResponseJSON,
|
||||||
type AuthenticatorTransportFuture,
|
type AuthenticatorTransport,
|
||||||
generateAuthenticationOptions,
|
generateAuthenticationOptions,
|
||||||
generateRegistrationOptions,
|
generateRegistrationOptions,
|
||||||
type RegistrationResponseJSON,
|
type RegistrationResponseJSON,
|
||||||
@@ -108,8 +108,8 @@ async function takeChallenge(
|
|||||||
|
|
||||||
// ── 登记的 Passkey ──
|
// ── 登记的 Passkey ──
|
||||||
|
|
||||||
const transportsOf = (v: unknown): AuthenticatorTransportFuture[] =>
|
const transportsOf = (v: unknown): AuthenticatorTransport[] =>
|
||||||
Array.isArray(v) ? (v.filter((x) => typeof x === "string") as AuthenticatorTransportFuture[]) : [];
|
Array.isArray(v) ? (v.filter((x) => typeof x === "string") as AuthenticatorTransport[]) : [];
|
||||||
|
|
||||||
export function toPasskeyInfo(r: AdminPasskeyRow): PasskeyInfo {
|
export function toPasskeyInfo(r: AdminPasskeyRow): PasskeyInfo {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
type SettingsKey,
|
type SettingsKey,
|
||||||
secretInput,
|
secretInput,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
|
import { sql } from "@umt/db";
|
||||||
|
import { parseClassificationYaml } from "@umt/domain";
|
||||||
import { ApiException } from "../../http/errors.ts";
|
import { ApiException } from "../../http/errors.ts";
|
||||||
import type { ModuleApp, ServerContext } from "../types.ts";
|
import type { ModuleApp, ServerContext } from "../types.ts";
|
||||||
import { audit, endpoint } from "./endpoint.ts";
|
import { audit, endpoint } from "./endpoint.ts";
|
||||||
@@ -39,6 +41,13 @@ export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) {
|
|||||||
throw new ApiException("validation_failed", msg);
|
throw new ApiException("validation_failed", msg);
|
||||||
}
|
}
|
||||||
const next = parsed.data;
|
const next = parsed.data;
|
||||||
|
if (patch.organize?.classificationYaml !== undefined) {
|
||||||
|
try {
|
||||||
|
parseClassificationYaml(next.organize.classificationYaml);
|
||||||
|
} catch (err) {
|
||||||
|
throw new ApiException("validation_failed", err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
const created: string[] = [];
|
const created: string[] = [];
|
||||||
const dropped: string[] = [];
|
const dropped: string[] = [];
|
||||||
for (const sl of slots) {
|
for (const sl of slots) {
|
||||||
@@ -58,6 +67,20 @@ export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) {
|
|||||||
try {
|
try {
|
||||||
await ctx.db.transaction().execute(async (trx) => {
|
await ctx.db.transaction().execute(async (trx) => {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
|
if (key === "organize") {
|
||||||
|
await sql`select pg_advisory_xact_lock(hashtext('organize.sources'))`.execute(trx);
|
||||||
|
const stored = await trx
|
||||||
|
.selectFrom("settings")
|
||||||
|
.select("value")
|
||||||
|
.where("key", "=", key)
|
||||||
|
.forUpdate()
|
||||||
|
.executeTakeFirst();
|
||||||
|
next.organize = {
|
||||||
|
...next.organize,
|
||||||
|
...((stored?.value as Partial<typeof next.organize>) ?? {}),
|
||||||
|
...patch.organize,
|
||||||
|
};
|
||||||
|
}
|
||||||
await trx
|
await trx
|
||||||
.insertInto("settings")
|
.insertInto("settings")
|
||||||
.values({ key, value: JSON.stringify(next[key]), updated_at: new Date() })
|
.values({ key, value: JSON.stringify(next[key]), updated_at: new Date() })
|
||||||
|
|||||||
@@ -142,14 +142,13 @@ describe.skipIf(!up)("浏览器 HTTP 端点", () => {
|
|||||||
expect(d.artifact.sourceUrl).not.toContain(PASSKEY);
|
expect(d.artifact.sourceUrl).not.toContain(PASSKEY);
|
||||||
expect(d.verification).toMatchObject({ valid: true, infohashV1: h.torrent.infoHash, private: true });
|
expect(d.verification).toMatchObject({ valid: true, infohashV1: h.torrent.infoHash, private: true });
|
||||||
expect(d.sourceReleaseId).toBe(h.releaseId);
|
expect(d.sourceReleaseId).toBe(h.releaseId);
|
||||||
// 资源未识别:与 POST /acquisition-plans 相同的拒绝,产物照常返回
|
// 资源未识别:与 POST /acquisition-plans 相同的确认结果,产物照常返回
|
||||||
expect(d.submission).toBeNull();
|
expect(d.submission).toMatchObject({ submitted: false, requiresConfirmation: true });
|
||||||
expect(d.note).toContain("提交未通过");
|
|
||||||
const files = await readdir(h.stagingDir);
|
const files = await readdir(h.stagingDir);
|
||||||
expect(files).toHaveLength(1);
|
expect(files).toHaveLength(1);
|
||||||
expect(new Uint8Array(await readFile(join(h.stagingDir, files[0]!)))).toEqual(h.torrent.bytes);
|
expect(new Uint8Array(await readFile(join(h.stagingDir, files[0]!)))).toEqual(h.torrent.bytes);
|
||||||
|
|
||||||
// 识别到作品后再下载:走同一流程(无收录版本 → no_slot);同一文件第二次捕获标 duplicate、不提交
|
// 识别到作品后再下载:同一文件第二次捕获标 duplicate、不提交
|
||||||
const entity = await h.db
|
const entity = await h.db
|
||||||
.insertInto("media_entity")
|
.insertInto("media_entity")
|
||||||
.values({ kind: "movie", title: "奥本海默", year: 2023, status: "active" })
|
.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 { ApiException } from "../../http/errors.ts";
|
||||||
import { InternalDispatcher } from "../search/dispatch.ts";
|
import { InternalDispatcher } from "../search/dispatch.ts";
|
||||||
import type { ModuleApp } from "../types.ts";
|
import type { ModuleApp } from "../types.ts";
|
||||||
|
import { currentResource } from "./resource.ts";
|
||||||
import { type Actor, type BrowserService, BrowserServiceError, imageSize } from "./service.ts";
|
import { type Actor, type BrowserService, BrowserServiceError, imageSize } from "./service.ts";
|
||||||
|
|
||||||
// C1-E 浏览器 HTTP 端点:原站视图与外部程序共用。读(会话/快照/截图)任何时候可用;
|
// C1-E 浏览器 HTTP 端点:原站视图与外部程序共用。读(会话/快照/截图)任何时候可用;
|
||||||
@@ -85,6 +86,8 @@ export function registerBrowserRoutes(app: ModuleApp, svc: () => BrowserService)
|
|||||||
const dispatch = new InternalDispatcher(app);
|
const dispatch = new InternalDispatcher(app);
|
||||||
const B = Api.browser;
|
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.providers, async ({ query }) => ({ items: await svc().listProviders(query.probe === "true") }));
|
||||||
route(app, B.createProvider, async ({ body }) => svc().createProvider(body));
|
route(app, B.createProvider, async ({ body }) => svc().createProvider(body));
|
||||||
route(app, B.updateProvider, async ({ params, body }) => svc().updateProvider(params.id, body));
|
route(app, B.updateProvider, async ({ params, body }) => svc().updateProvider(params.id, body));
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from "@umt/domain";
|
} from "@umt/domain";
|
||||||
import { localIdentityCandidate } from "@umt/mapping";
|
import { localIdentityCandidate } from "@umt/mapping";
|
||||||
import type { Principal } from "../../http/auth.ts";
|
import type { Principal } from "../../http/auth.ts";
|
||||||
|
import { successfulOrganizeCounts } from "../acquisition-organize/services/organize-history.ts";
|
||||||
import { EXTERNAL_INVOKE_TIMEOUT_MS, sharedInvokeLimiter } from "../agent/invoke-limits.ts";
|
import { EXTERNAL_INVOKE_TIMEOUT_MS, sharedInvokeLimiter } from "../agent/invoke-limits.ts";
|
||||||
import { actorOfP, errMessage, FilesError, type FmDeps, resolveIn } from "./common.ts";
|
import { actorOfP, errMessage, FilesError, type FmDeps, resolveIn } from "./common.ts";
|
||||||
import { kindOfName } from "./list.ts";
|
import { kindOfName } from "./list.ts";
|
||||||
@@ -218,6 +219,10 @@ export async function identifyCore(
|
|||||||
order by f.source_path, r.created_at desc`.execute(db)
|
order by f.source_path, r.created_at desc`.execute(db)
|
||||||
: { rows: [] };
|
: { rows: [] };
|
||||||
const recordsBy = new Map(recordRows.rows.map((r) => [r.source_path, r]));
|
const recordsBy = new Map(recordRows.rows.map((r) => [r.source_path, r]));
|
||||||
|
const successfulCounts = await successfulOrganizeCounts(
|
||||||
|
db,
|
||||||
|
videos.map((v) => v.abs),
|
||||||
|
);
|
||||||
const localCache = new Map<string, IdentityCandidate[]>();
|
const localCache = new Map<string, IdentityCandidate[]>();
|
||||||
const remoteCache = new Map<string, { list: IdentityCandidate[]; warning: string | null; pending?: boolean }>();
|
const remoteCache = new Map<string, { list: IdentityCandidate[]; warning: string | null; pending?: boolean }>();
|
||||||
const entityKinds = new Map<string, string>();
|
const entityKinds = new Map<string, string>();
|
||||||
@@ -371,6 +376,7 @@ export async function identifyCore(
|
|||||||
count: rec?.n ?? 0,
|
count: rec?.n ?? 0,
|
||||||
lastRecordId: rec?.last_id ?? null,
|
lastRecordId: rec?.last_id ?? null,
|
||||||
lastStatus: (rec?.last_status ?? null) as OrganizationStatus | null,
|
lastStatus: (rec?.last_status ?? null) as OrganizationStatus | null,
|
||||||
|
successfulCount: successfulCounts.get(v.abs) ?? 0,
|
||||||
};
|
};
|
||||||
const parsed0 = parseReleasePath(segments(v.rel));
|
const parsed0 = parseReleasePath(segments(v.rel));
|
||||||
const parsed: ParsedRelease = {
|
const parsed: ParsedRelease = {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import { SourcesRuntime } from "../sources-identity/runtime.ts";
|
|||||||
import type { ServerContext, ServerModule } from "../types.ts";
|
import type { ServerContext, ServerModule } from "../types.ts";
|
||||||
import { defaultOptions, type FmDeps, type FmOptions } from "./common.ts";
|
import { defaultOptions, type FmDeps, type FmOptions } from "./common.ts";
|
||||||
import { IDENTIFY_REMOTE_TASK, runIdentifyRemote } from "./identify.ts";
|
import { IDENTIFY_REMOTE_TASK, runIdentifyRemote } from "./identify.ts";
|
||||||
|
import { registerOrganizeSourceRoutes } from "./routes/organize-sources.ts";
|
||||||
import { registerFileRoutes } from "./routes.ts";
|
import { registerFileRoutes } from "./routes.ts";
|
||||||
|
import { queueDueSourceScans, scanOrganizeSource } from "./source-monitor.ts";
|
||||||
import { purgeTrash } from "./trash.ts";
|
import { purgeTrash } from "./trash.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,9 +54,18 @@ export function createFileManagerModule(overrides: FmOverrides = {}): ServerModu
|
|||||||
},
|
},
|
||||||
registerRoutes(app, ctx) {
|
registerRoutes(app, ctx) {
|
||||||
registerFileRoutes(app, lazy(ctx));
|
registerFileRoutes(app, lazy(ctx));
|
||||||
|
registerOrganizeSourceRoutes(app, lazy(ctx));
|
||||||
},
|
},
|
||||||
registerTasks(registry, ctx) {
|
registerTasks(registry, ctx) {
|
||||||
registry.register(
|
registry.register(
|
||||||
|
defineTask({
|
||||||
|
name: "scanOrganizeSource",
|
||||||
|
version: 1,
|
||||||
|
lane: "maintenance",
|
||||||
|
inputSchema: z.object({ sourceId: z.string().uuid() }),
|
||||||
|
retryPolicy: { maximumAttempts: 3, initialInterval: "5s" },
|
||||||
|
handler: async (t) => t.step.run("scan", () => scanOrganizeSource(lazy(ctx), t.input)),
|
||||||
|
}),
|
||||||
defineTask({
|
defineTask({
|
||||||
name: "purgeTrash",
|
name: "purgeTrash",
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -84,7 +95,18 @@ export function createFileManagerModule(overrides: FmOverrides = {}): ServerModu
|
|||||||
},
|
},
|
||||||
start(ctx) {
|
start(ctx) {
|
||||||
const d = lazy(ctx);
|
const d = lazy(ctx);
|
||||||
if (!d.options.backgroundLoops || d.options.purgeIntervalMs <= 0) return;
|
if (!d.options.backgroundLoops) return;
|
||||||
|
registerScheduledJob(ctx, {
|
||||||
|
key: "organize.sources",
|
||||||
|
defaults: {
|
||||||
|
name: "整理来源扫描",
|
||||||
|
description: "检查来源目录的新文件",
|
||||||
|
group: "maintain",
|
||||||
|
schedule: { type: "interval", everyMs: 60_000 },
|
||||||
|
},
|
||||||
|
run: async (c) => queueDueSourceScans(d, c.slot),
|
||||||
|
});
|
||||||
|
if (d.options.purgeIntervalMs <= 0) return;
|
||||||
registerScheduledJob(ctx, {
|
registerScheduledJob(ctx, {
|
||||||
key: "trash.purge",
|
key: "trash.purge",
|
||||||
defaults: {
|
defaults: {
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { constants } from "node:fs";
|
||||||
|
import { access } from "node:fs/promises";
|
||||||
|
import type { OrganizeSourceConfig } from "@umt/contracts";
|
||||||
|
import { sql } from "@umt/db";
|
||||||
|
import { SETTINGS_CHANGED_EVENT } from "../auth-settings/provided.ts";
|
||||||
|
import { FilesError, type FmDeps, jsonb, resolveIn } from "./common.ts";
|
||||||
|
import { isWritableTarget } from "./list.ts";
|
||||||
|
|
||||||
|
export async function sourceConfigs(d: FmDeps): Promise<OrganizeSourceConfig[]> {
|
||||||
|
const org = await d.ctx.settings.get("organize");
|
||||||
|
return (org as { sources?: OrganizeSourceConfig[] }).sources ?? [];
|
||||||
|
}
|
||||||
|
export async function sourceConfig(d: FmDeps, id: string): Promise<OrganizeSourceConfig> {
|
||||||
|
const found = (await sourceConfigs(d)).find((s) => s.id === id);
|
||||||
|
if (!found) throw new FilesError("not_found", "整理来源不存在");
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
const iso = (v: Date | string | null | undefined) => (v ? new Date(v).toISOString() : null);
|
||||||
|
export async function listOrganizeSources(d: FmDeps) {
|
||||||
|
const rows = await d.ctx.db.selectFrom("organize_source_snapshot").selectAll().execute();
|
||||||
|
return {
|
||||||
|
items: (await sourceConfigs(d)).map((s) => {
|
||||||
|
const row = rows.find((r) => r.source_id === s.id);
|
||||||
|
const files = (row?.snapshot as { files?: Record<string, unknown> } | undefined)?.files ?? {};
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
baselineAt: iso(row?.baseline_at),
|
||||||
|
lastScanAt: iso(row?.last_scan_at),
|
||||||
|
lastError: row?.last_error ?? null,
|
||||||
|
fileCount: Object.keys(files).length,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editSources(
|
||||||
|
d: FmDeps,
|
||||||
|
edit: (items: OrganizeSourceConfig[]) => OrganizeSourceConfig[],
|
||||||
|
resetId?: string,
|
||||||
|
lockId = resetId,
|
||||||
|
) {
|
||||||
|
const defaults = await d.ctx.settings.get("organize");
|
||||||
|
await d.ctx.db.transaction().execute(async (trx) => {
|
||||||
|
if (lockId) await sql`select pg_advisory_xact_lock(hashtext(${`organize-source:${lockId}`}))`.execute(trx);
|
||||||
|
// Serialize even the first insert, where no settings row exists to lock.
|
||||||
|
await sql`select pg_advisory_xact_lock(hashtext('organize.sources'))`.execute(trx);
|
||||||
|
const row = await trx
|
||||||
|
.selectFrom("settings")
|
||||||
|
.select("value")
|
||||||
|
.where("key", "=", "organize")
|
||||||
|
.forUpdate()
|
||||||
|
.executeTakeFirst();
|
||||||
|
const org = (row?.value ?? defaults) as typeof defaults & { sources?: OrganizeSourceConfig[] };
|
||||||
|
await trx
|
||||||
|
.insertInto("settings")
|
||||||
|
.values({ key: "organize", value: jsonb({ ...org, sources: edit(org.sources ?? []) }), updated_at: new Date() })
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc
|
||||||
|
.column("key")
|
||||||
|
.doUpdateSet({ value: jsonb({ ...org, sources: edit(org.sources ?? []) }), updated_at: new Date() }),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
if (resetId) await trx.deleteFrom("organize_source_snapshot").where("source_id", "=", resetId).execute();
|
||||||
|
});
|
||||||
|
const settings = d.ctx.settings as typeof d.ctx.settings & { invalidate?: () => void };
|
||||||
|
settings.invalidate?.();
|
||||||
|
await d.ctx.events.publish(SETTINGS_CHANGED_EVENT, { keys: ["organize"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveOrganizeSource(d: FmDeps, id: string, input: Omit<OrganizeSourceConfig, "id">) {
|
||||||
|
const source = await resolveIn(d, input.source.root, input.source.path);
|
||||||
|
if (source.isSymlink || !source.lst?.isDirectory()) throw new FilesError("bad_request", "来源必须为可访问的目录");
|
||||||
|
const target = await resolveIn(d, input.target.root, input.target.path);
|
||||||
|
if (
|
||||||
|
target.isSymlink ||
|
||||||
|
!target.lst?.isDirectory() ||
|
||||||
|
!(await isWritableTarget(target.def, target.rootReal)) ||
|
||||||
|
!(await access(target.abs, constants.W_OK).then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
throw new FilesError("not_writable", "目标必须为可写的纳管媒体库目录");
|
||||||
|
if (input.trigger === "qb_tag") {
|
||||||
|
if (!input.downloaderInstanceId || !input.tag?.trim())
|
||||||
|
throw new FilesError("bad_request", "标签触发需要下载器和标签");
|
||||||
|
const instance = await d.ctx.db
|
||||||
|
.selectFrom("downloader_instance")
|
||||||
|
.select("id")
|
||||||
|
.where("id", "=", input.downloaderInstanceId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!instance || (source.def.instanceId && source.def.instanceId !== instance.id))
|
||||||
|
throw new FilesError("bad_request", "来源和下载器不匹配");
|
||||||
|
}
|
||||||
|
const previous = (await sourceConfigs(d)).find((s) => s.id === id);
|
||||||
|
const reset = !previous || previous.source.root !== input.source.root || previous.source.path !== input.source.path;
|
||||||
|
const config = { ...input, id };
|
||||||
|
await editSources(d, (items) => [...items.filter((s) => s.id !== id), config], reset ? id : undefined, id);
|
||||||
|
if (reset)
|
||||||
|
await d.ctx.engine.enqueue(
|
||||||
|
"scanOrganizeSource",
|
||||||
|
{ sourceId: id },
|
||||||
|
{ idempotencyKey: `source-baseline:${id}:${randomUUID()}`, traceId: randomUUID() },
|
||||||
|
);
|
||||||
|
return (await listOrganizeSources(d)).items.find((s) => s.id === id)!;
|
||||||
|
}
|
||||||
|
export async function deleteOrganizeSource(d: FmDeps, id: string) {
|
||||||
|
const deleted = (await sourceConfigs(d)).some((s) => s.id === id);
|
||||||
|
await editSources(d, (items) => items.filter((s) => s.id !== id), id);
|
||||||
|
return { deleted };
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ type Req = {
|
|||||||
sourcePaths: string[];
|
sourcePaths: string[];
|
||||||
mediaEntityId: string;
|
mediaEntityId: string;
|
||||||
contentUnitId?: string | undefined;
|
contentUnitId?: string | undefined;
|
||||||
action: "copy" | "move";
|
action: "copy" | "move" | "in_place";
|
||||||
targetLibraryRootId?: string | undefined;
|
targetLibraryRootId?: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ interface Frozen {
|
|||||||
interface Group {
|
interface Group {
|
||||||
entity: string;
|
entity: string;
|
||||||
unit: string | null;
|
unit: string | null;
|
||||||
action: "copy" | "move";
|
action: "copy" | "move" | "in_place";
|
||||||
targetDir: string | null;
|
targetDir: string | null;
|
||||||
targetLibraryRootId: string | null;
|
targetLibraryRootId: string | null;
|
||||||
slotId: string | null;
|
slotId: string | null;
|
||||||
@@ -67,7 +67,7 @@ function filesOf(plan: FrozenPlan | undefined): GroupFile[] {
|
|||||||
const heldByExisting = new Set(
|
const heldByExisting = new Set(
|
||||||
plan.organize.conflicts.filter((c) => c.kind === "target_exists" && c.blocking).map((c) => c.src),
|
plan.organize.conflicts.filter((c) => c.kind === "target_exists" && c.blocking).map((c) => c.src),
|
||||||
);
|
);
|
||||||
return plan.organize.files.map((f) => {
|
const active = plan.organize.files.map((f) => {
|
||||||
const existing: GroupFile["existing"] =
|
const existing: GroupFile["existing"] =
|
||||||
f.action === "replace"
|
f.action === "replace"
|
||||||
? "replace"
|
? "replace"
|
||||||
@@ -88,16 +88,36 @@ function filesOf(plan: FrozenPlan | undefined): GroupFile[] {
|
|||||||
skipReason: f.skipReason ?? null,
|
skipReason: f.skipReason ?? null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
return [
|
||||||
|
...active,
|
||||||
|
...(plan.skippedSuccessful ?? []).map((f) => ({
|
||||||
|
source: f.src,
|
||||||
|
target: f.dst ?? null,
|
||||||
|
kind: f.kind,
|
||||||
|
size: f.size,
|
||||||
|
action: "skip" as const,
|
||||||
|
existing: null,
|
||||||
|
skipReason: "successful_record",
|
||||||
|
})),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 被排除的文件(G2:too_small → below_min_size) */
|
/** 被排除的文件(G2:too_small → below_min_size) */
|
||||||
function excludedOf(plan: FrozenPlan | undefined): NonNullable<FilesOrganizeGroup["excluded"]> {
|
function excludedOf(plan: FrozenPlan | undefined): NonNullable<FilesOrganizeGroup["excluded"]> {
|
||||||
return (plan?.organize.excluded ?? []).map((x) => ({
|
return [
|
||||||
|
...(plan?.organize.excluded ?? []).map((x) => ({
|
||||||
source: x.src,
|
source: x.src,
|
||||||
kind: x.kind,
|
kind: x.kind,
|
||||||
size: x.reason === "too_small" && x.detail && /^\d+$/.test(x.detail) ? Number(x.detail) : null,
|
size: x.reason === "too_small" && x.detail && /^\d+$/.test(x.detail) ? Number(x.detail) : null,
|
||||||
reason: x.reason === "too_small" ? "below_min_size" : x.reason,
|
reason: x.reason === "too_small" ? "below_min_size" : x.reason,
|
||||||
}));
|
})),
|
||||||
|
...(plan?.skippedSuccessful ?? []).map((f) => ({
|
||||||
|
source: f.src,
|
||||||
|
kind: f.kind,
|
||||||
|
size: f.size,
|
||||||
|
reason: "successful_record",
|
||||||
|
})),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 目标目录分段(相对库根;G4):剧集末段为季目录;bound = 沿用已有路径绑定(本次不新建) */
|
/** 目标目录分段(相对库根;G4):剧集末段为季目录;bound = 沿用已有路径绑定(本次不新建) */
|
||||||
@@ -105,7 +125,8 @@ function segmentsOf(plan: FrozenPlan | undefined, overrideDir: boolean): FilesOr
|
|||||||
const dir = plan?.targetDirs[0];
|
const dir = plan?.targetDirs[0];
|
||||||
if (!plan || !dir) return [];
|
if (!plan || !dir) return [];
|
||||||
const parts = relative(plan.rootPath, dir).split("/").filter(Boolean);
|
const parts = relative(plan.rootPath, dir).split("/").filter(Boolean);
|
||||||
if (overrideDir) return parts.map((text) => ({ text, role: "dir" as const, bound: false }));
|
if (overrideDir || plan.organize.operation === "in_place")
|
||||||
|
return parts.map((text) => ({ text, role: "dir" as const, bound: false }));
|
||||||
const newEntity = plan.newBindings.some((b) => b.scope === "entity");
|
const newEntity = plan.newBindings.some((b) => b.scope === "entity");
|
||||||
const newSeason = plan.newBindings.some((b) => b.scope === "season");
|
const newSeason = plan.newBindings.some((b) => b.scope === "season");
|
||||||
const rootIdx = plan.mediaType === "tv" ? parts.length - 2 : parts.length - 1;
|
const rootIdx = plan.mediaType === "tv" ? parts.length - 2 : parts.length - 1;
|
||||||
@@ -152,11 +173,30 @@ export async function organizeFiles(
|
|||||||
const actorType = principal?.kind === "api_key" ? "api_key" : "user";
|
const actorType = principal?.kind === "api_key" ? "api_key" : "user";
|
||||||
const actor = actorOfP(principal);
|
const actor = actorOfP(principal);
|
||||||
|
|
||||||
// G1:外部候选 → 本地作品(就地建,幂等);每项解析出 mediaEntityId
|
// 先核验整批人工集号,避免无效输入创建外部作品或冻结计划。数字范围由请求 schema 校验。
|
||||||
const created = new Map<string, { entityId: string; created: boolean; title: string; externalId: string }>();
|
const localIds = [...new Set(body.items.flatMap((it) => (it.mediaEntityId ? [it.mediaEntityId] : [])))];
|
||||||
|
const localKinds = new Map(
|
||||||
|
localIds.length
|
||||||
|
? (await db.selectFrom("media_entity").select(["id", "kind"]).where("id", "in", localIds).execute()).map((e) => [
|
||||||
|
e.id,
|
||||||
|
e.kind,
|
||||||
|
])
|
||||||
|
: [],
|
||||||
|
);
|
||||||
for (const it of body.items) {
|
for (const it of body.items) {
|
||||||
if (!!it.mediaEntityId === !!it.external)
|
if (!!it.mediaEntityId === !!it.external)
|
||||||
throw new FilesError("bad_request", `整理项 ${it.path || "/"} 需要且只能指定 mediaEntityId 或 external 之一`);
|
throw new FilesError("bad_request", `整理项 ${it.path || "/"} 需要且只能指定 mediaEntityId 或 external 之一`);
|
||||||
|
const kind = it.external?.kind ?? localKinds.get(it.mediaEntityId!);
|
||||||
|
if (!it.episodes?.length || kind === undefined || kind === "movie") continue;
|
||||||
|
if (!episodeRange(it.episodes))
|
||||||
|
throw new FilesError("bad_request", `${it.path}:集号 ${it.episodes.join(",")} 不连续,只支持连续区间`);
|
||||||
|
const r = await resolveIn(d, it.root, it.path);
|
||||||
|
if (r.lst?.isDirectory()) throw new FilesError("bad_request", `${r.rel}:目录项不能指定集号,请展开后逐个视频指定`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// G1:外部候选 → 本地作品(就地建,幂等);每项解析出 mediaEntityId
|
||||||
|
const created = new Map<string, { entityId: string; created: boolean; title: string; externalId: string }>();
|
||||||
|
for (const it of body.items) {
|
||||||
if (!it.external) continue;
|
if (!it.external) continue;
|
||||||
const k = externalKey(it.external);
|
const k = externalKey(it.external);
|
||||||
if (created.has(k)) continue;
|
if (created.has(k)) continue;
|
||||||
@@ -228,7 +268,13 @@ export async function organizeFiles(
|
|||||||
// 目标目录覆写:writable 库根下已存在的目录
|
// 目标目录覆写:writable 库根下已存在的目录
|
||||||
let targetDir: string | null = null;
|
let targetDir: string | null = null;
|
||||||
let targetLib: string | null = body.targetLibraryRootId ?? null;
|
let targetLib: string | null = body.targetLibraryRootId ?? null;
|
||||||
if (it.targetDir) {
|
if (action === "in_place") {
|
||||||
|
// 本次保留实际库根与路径,不使用来源配置的搬运目标。
|
||||||
|
if (!r.def.libraryRootId)
|
||||||
|
throw new FilesError("bad_request", "原地入库需要文件位于已登记的媒体库目录;请先在设置中登记该目录");
|
||||||
|
targetLib = r.def.libraryRootId;
|
||||||
|
}
|
||||||
|
if (action !== "in_place" && it.targetDir) {
|
||||||
const t = await resolveIn(d, it.targetDir.root, it.targetDir.path);
|
const t = await resolveIn(d, it.targetDir.root, it.targetDir.path);
|
||||||
if (!t.lst?.isDirectory() || t.isSymlink || !t.def.libraryRootId)
|
if (!t.lst?.isDirectory() || t.isSymlink || !t.def.libraryRootId)
|
||||||
throw new FilesError("bad_request", `目标目录必须是媒体库目录下已存在的目录:${it.targetDir.path || "/"}`);
|
throw new FilesError("bad_request", `目标目录必须是媒体库目录下已存在的目录:${it.targetDir.path || "/"}`);
|
||||||
@@ -244,9 +290,9 @@ export async function organizeFiles(
|
|||||||
if (it.season !== undefined) ov.season = it.season;
|
if (it.season !== undefined) ov.season = it.season;
|
||||||
if (it.episodes?.length) {
|
if (it.episodes?.length) {
|
||||||
const range = episodeRange(it.episodes);
|
const range = episodeRange(it.episodes);
|
||||||
if (!range) warnings.push(`${r.rel}:集号 ${it.episodes.join(",")} 不连续,未应用(只支持连续区间)`);
|
if (!range) throw new FilesError("bad_request", `${r.rel}:集号不连续,只支持连续区间`);
|
||||||
else if (isDir) warnings.push(`${r.rel}:目录项不能指定集号(会套到其下所有视频),未应用`);
|
if (isDir) throw new FilesError("bad_request", `${r.rel}:目录项不能指定集号,请展开后逐个视频指定`);
|
||||||
else ov.episodes = range;
|
ov.episodes = range;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,6 +385,7 @@ export async function organizeFiles(
|
|||||||
const hasOverrides = Object.keys(g.overrides).length > 0;
|
const hasOverrides = Object.keys(g.overrides).length > 0;
|
||||||
const reqHash = hashJson({
|
const reqHash = hashJson({
|
||||||
key: requestKey(req),
|
key: requestKey(req),
|
||||||
|
...(body.sourceConfigId && g.action !== "in_place" ? { sourceConfigId: body.sourceConfigId } : {}),
|
||||||
...(hasOverrides ? { overrides: g.overrides } : {}),
|
...(hasOverrides ? { overrides: g.overrides } : {}),
|
||||||
...(g.targetDir ? { targetDir: g.targetDir } : {}),
|
...(g.targetDir ? { targetDir: g.targetDir } : {}),
|
||||||
...(g.slotId ? { slotId: g.slotId } : {}),
|
...(g.slotId ? { slotId: g.slotId } : {}),
|
||||||
@@ -346,6 +393,8 @@ export async function organizeFiles(
|
|||||||
...(minSizeBytes !== undefined ? { minSizeBytes } : {}),
|
...(minSizeBytes !== undefined ? { minSizeBytes } : {}),
|
||||||
...(onExisting !== "conflict" ? { onExisting } : {}),
|
...(onExisting !== "conflict" ? { onExisting } : {}),
|
||||||
...(namingMode !== "template" ? { naming: namingMode } : {}),
|
...(namingMode !== "template" ? { naming: namingMode } : {}),
|
||||||
|
...(body.skipSuccessfulRecords ? { skipSuccessfulRecords: true } : {}),
|
||||||
|
...(body.scrapeAfterImport !== undefined ? { scrapeAfterImport: body.scrapeAfterImport } : {}),
|
||||||
});
|
});
|
||||||
const ext = fromExternal.get(g.entity);
|
const ext = fromExternal.get(g.entity);
|
||||||
const base = {
|
const base = {
|
||||||
@@ -363,6 +412,7 @@ export async function organizeFiles(
|
|||||||
};
|
};
|
||||||
const importReq = (dryRun: boolean, taskId?: string) => ({
|
const importReq = (dryRun: boolean, taskId?: string) => ({
|
||||||
...req,
|
...req,
|
||||||
|
...(body.sourceConfigId && g.action !== "in_place" ? { sourceConfigId: body.sourceConfigId } : {}),
|
||||||
...(hasOverrides ? { fileOverrides: g.overrides } : {}),
|
...(hasOverrides ? { fileOverrides: g.overrides } : {}),
|
||||||
...(g.targetDir ? { targetDir: g.targetDir } : {}),
|
...(g.targetDir ? { targetDir: g.targetDir } : {}),
|
||||||
...(g.slotId ? { slotId: g.slotId } : {}),
|
...(g.slotId ? { slotId: g.slotId } : {}),
|
||||||
@@ -371,6 +421,8 @@ export async function organizeFiles(
|
|||||||
// 向导里显式选择"替换"即为人工授权;替换走 replace_with_verify(新文件核验通过后旧文件进回收站)
|
// 向导里显式选择"替换"即为人工授权;替换走 replace_with_verify(新文件核验通过后旧文件进回收站)
|
||||||
...(onExisting === "replace_with_verify" ? { grantReplace: true } : {}),
|
...(onExisting === "replace_with_verify" ? { grantReplace: true } : {}),
|
||||||
namingMode,
|
namingMode,
|
||||||
|
skipSuccessfulRecords: body.skipSuccessfulRecords,
|
||||||
|
scrapeAfterImport: body.scrapeAfterImport,
|
||||||
trigger: "manual" as const,
|
trigger: "manual" as const,
|
||||||
actorType: actorType as "api_key" | "user",
|
actorType: actorType as "api_key" | "user",
|
||||||
actorId: actor,
|
actorId: actor,
|
||||||
@@ -421,6 +473,20 @@ export async function organizeFiles(
|
|||||||
const nowPairs = pairsOf(again).map(({ src, dst }) => ({ src, dst }));
|
const nowPairs = pairsOf(again).map(({ src, dst }) => ({ src, dst }));
|
||||||
if (hashJson(nowPairs) !== hashJson(frozen.pairs))
|
if (hashJson(nowPairs) !== hashJson(frozen.pairs))
|
||||||
throw new FilesError("plan_stale", "目标目录或绑定已变化,与预览不一致。重新预览");
|
throw new FilesError("plan_stale", "目标目录或绑定已变化,与预览不一致。重新预览");
|
||||||
|
if (again.plan?.skippedSuccessful?.length && !again.plan.organize.files.length) {
|
||||||
|
await retirePlan(d, planId);
|
||||||
|
out.push({
|
||||||
|
...base,
|
||||||
|
...planExtras(again.plan, !!g.targetDir),
|
||||||
|
planId,
|
||||||
|
files: filesOf(again.plan),
|
||||||
|
truncated: false,
|
||||||
|
recordId: null,
|
||||||
|
taskId: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (again.status === "failed") throw new FilesError("bad_request", `计划不可执行:${again.reason ?? "blocked"}`);
|
if (again.status === "failed") throw new FilesError("bad_request", `计划不可执行:${again.reason ?? "blocked"}`);
|
||||||
|
|
||||||
if (g.action === "move") {
|
if (g.action === "move") {
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export function registerFileRoutes(app: ModuleApp, d: FmDeps) {
|
|||||||
mount(app, F.scrape, async (c, { body }) =>
|
mount(app, F.scrape, async (c, { body }) =>
|
||||||
idem(d, c, "scrape", body, () => scrapeFiles(d, c.get("principal"), body)),
|
idem(d, c, "scrape", body, () => scrapeFiles(d, c.get("principal"), body)),
|
||||||
);
|
);
|
||||||
mount(app, F.scrapeRecords, async (_c, { query }) => scrapeRecords(d, query.ids));
|
mount(app, F.scrapeRecords, async (_c, { query }) => scrapeRecords(d, query.ids, query.organizationRecordId));
|
||||||
mount(app, F.probe, async (_c, { body }) => probeFiles(d, body));
|
mount(app, F.probe, async (_c, { body }) => probeFiles(d, body));
|
||||||
mount(app, F.records, async (_c, { query }) => findRecords(d, query));
|
mount(app, F.records, async (_c, { query }) => findRecords(d, query));
|
||||||
mount(app, F.download, async (c, { query }) => downloadFile(d, c.get("principal"), query), true);
|
mount(app, F.download, async (c, { query }) => downloadFile(d, c.get("principal"), query), true);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Api } from "@umt/contracts";
|
||||||
|
import { ApiException } from "../../../http/errors.ts";
|
||||||
|
import { endpoint } from "../../auth-settings/endpoint.ts";
|
||||||
|
import type { ModuleApp } from "../../types.ts";
|
||||||
|
import { type FmDeps, fsErrorToFiles } from "../common.ts";
|
||||||
|
import { deleteOrganizeSource, listOrganizeSources, saveOrganizeSource } from "../organize-sources.ts";
|
||||||
|
import { scanStock } from "../source-monitor.ts";
|
||||||
|
|
||||||
|
async function guarded<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
const converted = fsErrorToFiles(error);
|
||||||
|
if (converted) throw new ApiException(converted.code, `${converted.reason}: ${converted.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function registerOrganizeSourceRoutes(app: ModuleApp, d: FmDeps) {
|
||||||
|
const defs = Api.organizeSources;
|
||||||
|
endpoint(app, defs.list, () => guarded(() => listOrganizeSources(d)));
|
||||||
|
endpoint(app, defs.save, ({ params, body }) => guarded(() => saveOrganizeSource(d, params.id, body)));
|
||||||
|
endpoint(app, defs.remove, ({ params }) => guarded(() => deleteOrganizeSource(d, params.id)));
|
||||||
|
endpoint(app, defs.stock, ({ params, body }) => guarded(() => scanStock(d, params.id, body)));
|
||||||
|
}
|
||||||
@@ -204,8 +204,23 @@ export async function scrapeFiles(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function scrapeRecords(d: FmDeps, idsParam: string) {
|
export async function scrapeRecords(d: FmDeps, idsParam?: string, organizationRecordId?: string) {
|
||||||
const ids = [...new Set(idsParam.split(",").map((s) => s.trim()))].filter((s) => /^[0-9a-f-]{36}$/i.test(s));
|
let ids =
|
||||||
if (!ids.length || ids.length > 100) throw new FilesError("bad_request", "ids 需要 1–100 个刮削记录 id");
|
idsParam === undefined
|
||||||
|
? []
|
||||||
|
: [...new Set(idsParam.split(",").map((s) => s.trim()))].filter((s) => /^[0-9a-f-]{36}$/i.test(s));
|
||||||
|
if (idsParam !== undefined && (!ids.length || ids.length > 100))
|
||||||
|
throw new FilesError("bad_request", "ids 需要 1–100 个刮削记录 id");
|
||||||
|
if (!idsParam && !organizationRecordId) throw new FilesError("bad_request", "需要 ids 或 organizationRecordId");
|
||||||
|
if (organizationRecordId) {
|
||||||
|
let query = d.ctx.db
|
||||||
|
.selectFrom("scrape_record")
|
||||||
|
.select("id")
|
||||||
|
.where("organization_record_id", "=", organizationRecordId);
|
||||||
|
if (idsParam !== undefined) query = query.where("id", "in", ids);
|
||||||
|
const matched = await query.orderBy("requested_at").orderBy("id").execute();
|
||||||
|
const allowed = new Set(matched.map((r) => r.id));
|
||||||
|
ids = idsParam === undefined ? matched.map((r) => r.id) : ids.filter((id) => allowed.has(id));
|
||||||
|
}
|
||||||
return { records: await loadScrapeRecords(d.ao, ids) };
|
return { records: await loadScrapeRecords(d.ao, ids) };
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
import { lstat, opendir, realpath } from "node:fs/promises";
|
||||||
|
import { basename, join } from "node:path";
|
||||||
|
import { isDownloadComplete } from "@umt/adapters";
|
||||||
|
import type { FileRef, OrganizeSourceConfig } from "@umt/contracts";
|
||||||
|
import { sql } from "@umt/db";
|
||||||
|
import { hostPathOf, loadInstances, upsertDecision } from "../acquisition-organize/services/common.ts";
|
||||||
|
import { planImport } from "../acquisition-organize/services/organize.ts";
|
||||||
|
import { downloadTasksUnder } from "../acquisition-organize/services/takeover.ts";
|
||||||
|
import { entityFromCandidate } from "./candidate.ts";
|
||||||
|
import {
|
||||||
|
FilesError,
|
||||||
|
type FmDeps,
|
||||||
|
hashJson,
|
||||||
|
isTempName,
|
||||||
|
jsonb,
|
||||||
|
recheck,
|
||||||
|
relOf,
|
||||||
|
resolveIn,
|
||||||
|
rootDef,
|
||||||
|
within,
|
||||||
|
} from "./common.ts";
|
||||||
|
import { candidateKey, identifyCore } from "./identify.ts";
|
||||||
|
import { kindOfName } from "./list.ts";
|
||||||
|
import { sourceConfig, sourceConfigs } from "./organize-sources.ts";
|
||||||
|
|
||||||
|
export interface SnapshotFile {
|
||||||
|
size: number;
|
||||||
|
mtimeMs: number;
|
||||||
|
accepted: boolean;
|
||||||
|
}
|
||||||
|
interface Snapshot {
|
||||||
|
files: Record<string, SnapshotFile>;
|
||||||
|
}
|
||||||
|
export const SOURCE_SCAN_LIMIT = 5000;
|
||||||
|
/** A changed file becomes eligible only after two observations of the same size and mtime. */
|
||||||
|
export function advanceSnapshot(previous: Snapshot | null, observed: Record<string, Omit<SnapshotFile, "accepted">>) {
|
||||||
|
const files: Snapshot["files"] = {};
|
||||||
|
const eligible: string[] = [];
|
||||||
|
for (const [path, fact] of Object.entries(observed)) {
|
||||||
|
const old = previous?.files[path];
|
||||||
|
const same = old?.size === fact.size && old.mtimeMs === fact.mtimeMs;
|
||||||
|
const accepted = previous === null || (same && old.accepted);
|
||||||
|
files[path] = { ...fact, accepted };
|
||||||
|
if (same && !accepted) eligible.push(path);
|
||||||
|
}
|
||||||
|
return { snapshot: { files }, eligible };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function walkSource(d: FmDeps, config: OrganizeSourceConfig, path = config.source.path) {
|
||||||
|
if (!within(path, config.source.path) && config.source.path !== "")
|
||||||
|
throw new FilesError("path_escape", "扫描路径必须在来源目录内");
|
||||||
|
const start = await resolveIn(d, config.source.root, path);
|
||||||
|
if (start.isSymlink || !start.lst?.isDirectory()) throw new FilesError("bad_request", "来源必须为目录");
|
||||||
|
const files: Record<string, Omit<SnapshotFile, "accepted">> = {};
|
||||||
|
const stack = [start.abs];
|
||||||
|
let seen = 0;
|
||||||
|
let truncated = false;
|
||||||
|
while (stack.length && !truncated) {
|
||||||
|
const directory = await opendir(stack.pop()!);
|
||||||
|
for await (const entry of directory) {
|
||||||
|
if (++seen > SOURCE_SCAN_LIMIT) {
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (entry.name.startsWith(".") || isTempName(entry.name) || entry.isSymbolicLink()) continue;
|
||||||
|
const abs = join(directory.path, entry.name);
|
||||||
|
// Re-resolve parents before traversing to keep directory replacement races inside the root guard.
|
||||||
|
const resolved = await resolveIn(d, config.source.root, relOf(start.rootReal, abs));
|
||||||
|
if (resolved.isSymlink) continue;
|
||||||
|
if (resolved.lst?.isDirectory()) stack.push(resolved.abs);
|
||||||
|
else if (resolved.lst?.isFile() && kindOfName(entry.name) === "video") {
|
||||||
|
const fact = await lstat(resolved.abs);
|
||||||
|
if (!fact.isFile() || fact.isSymbolicLink() || fact.size <= 0) continue;
|
||||||
|
files[relOf(start.rootReal, resolved.abs)] = { size: Number(fact.size), mtimeMs: Number(fact.mtimeMs) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { files, truncated, rootReal: start.rootReal };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query unfiltered QB state once: an untagged download may never have a DB task row. */
|
||||||
|
async function activeDownloaderPaths(d: FmDeps, config: OrganizeSourceConfig): Promise<string[]> {
|
||||||
|
const source = await rootDef(d.ctx.db, config.source.root);
|
||||||
|
const instanceIds = [...new Set([config.downloaderInstanceId, source.instanceId].filter((id): id is string => !!id))];
|
||||||
|
if (!instanceIds.length) return [];
|
||||||
|
const topology = await loadInstances(d.ctx.db, { enabledOnly: false });
|
||||||
|
const paths: string[] = [];
|
||||||
|
for (const instanceId of instanceIds) {
|
||||||
|
const instance = topology.find((t) => t.inst.id === instanceId);
|
||||||
|
if (!instance) throw new FilesError("upstream", "来源下载器不存在,无法确认文件是否下载完成");
|
||||||
|
try {
|
||||||
|
const torrents = await (await d.ao.ports.downloader(instance.inst)).list();
|
||||||
|
for (const torrent of torrents) {
|
||||||
|
if (isDownloadComplete({ progress: torrent.progress, amountLeft: torrent.amountLeft, state: torrent.rawState }))
|
||||||
|
continue;
|
||||||
|
const content = torrent.contentPath || join(torrent.savePath, torrent.name);
|
||||||
|
const host = hostPathOf(instance, content);
|
||||||
|
if (!host) throw new Error("下载器内容路径未映射,无法确认源文件是否下载完成");
|
||||||
|
paths.push(await realpath(host).catch(() => host));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new FilesError(
|
||||||
|
"upstream",
|
||||||
|
`无法查询来源下载器,暂停本次整理:${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function organizedPaths(
|
||||||
|
d: FmDeps,
|
||||||
|
paths: string[],
|
||||||
|
scan: { files: Record<string, Omit<SnapshotFile, "accepted">>; rootReal: string },
|
||||||
|
) {
|
||||||
|
if (!paths.length) return new Set<string>();
|
||||||
|
const rows = await d.ctx.db
|
||||||
|
.selectFrom("organization_file_result as f")
|
||||||
|
.innerJoin("organization_record as r", "r.id", "f.organization_record_id")
|
||||||
|
.select([
|
||||||
|
"f.source_path",
|
||||||
|
"f.source_size_bytes",
|
||||||
|
"f.status as file_status",
|
||||||
|
"f.skip_reason",
|
||||||
|
"r.status as record_status",
|
||||||
|
"r.plan",
|
||||||
|
])
|
||||||
|
.where("f.source_path", "in", paths)
|
||||||
|
.where("r.status", "in", ["running", "queued", "succeeded", "partial"])
|
||||||
|
.execute();
|
||||||
|
const done = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (["running", "queued"].includes(row.record_status)) {
|
||||||
|
done.add(row.source_path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (row.file_status !== "succeeded" && !(row.file_status === "skipped" && row.skip_reason === "already_in_library"))
|
||||||
|
continue;
|
||||||
|
const observed = scan.files[relOf(scan.rootReal, row.source_path)];
|
||||||
|
if (!observed) continue;
|
||||||
|
const recorded = (row.plan as { sourceFacts?: Record<string, { size: number; mtimeMs: number }> })?.sourceFacts?.[
|
||||||
|
row.source_path
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
recorded
|
||||||
|
? recorded.size === observed.size && recorded.mtimeMs === observed.mtimeMs
|
||||||
|
: row.source_size_bytes === null || Number(row.source_size_bytes) === observed.size
|
||||||
|
)
|
||||||
|
done.add(row.source_path);
|
||||||
|
}
|
||||||
|
return done;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanStock(d: FmDeps, id: string, input: { path?: string } = {}) {
|
||||||
|
const config = await sourceConfig(d, id);
|
||||||
|
const liveActive = await activeDownloaderPaths(d, config);
|
||||||
|
const scan = await walkSource(d, config, input.path ?? config.source.path);
|
||||||
|
const paths = Object.keys(scan.files);
|
||||||
|
const done = await organizedPaths(
|
||||||
|
d,
|
||||||
|
paths.map((p) => join(scan.rootReal, p)),
|
||||||
|
scan,
|
||||||
|
);
|
||||||
|
const items: FileRef[] = [];
|
||||||
|
let skipped = 0;
|
||||||
|
let unavailable = 0;
|
||||||
|
let truncated = scan.truncated;
|
||||||
|
const stockLimit = Math.min(200, d.options.identifyMaxVideos);
|
||||||
|
const known = await downloadTasksUnder(
|
||||||
|
d.ctx.db,
|
||||||
|
paths.map((p) => join(scan.rootReal, p)),
|
||||||
|
);
|
||||||
|
const topology = await loadInstances(d.ctx.db, { enabledOnly: false });
|
||||||
|
const active = known
|
||||||
|
.filter((t) => !["completed", "seeding", "removed"].includes(t.status))
|
||||||
|
.flatMap((t) => {
|
||||||
|
const instance = topology.find((i) => i.inst.id === t.instance_id);
|
||||||
|
const host = instance && t.content_path ? hostPathOf(instance, t.content_path) : null;
|
||||||
|
return host ? [host] : [];
|
||||||
|
});
|
||||||
|
active.push(...liveActive);
|
||||||
|
for (const path of paths) {
|
||||||
|
const abs = join(scan.rootReal, path);
|
||||||
|
if (done.has(abs)) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (active.some((p) => within(abs, p))) {
|
||||||
|
unavailable++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (items.length >= stockLimit) {
|
||||||
|
truncated = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.push({ root: config.source.root as FileRef["root"], path });
|
||||||
|
}
|
||||||
|
return { items, skipped, unavailable, truncated };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scanOrganizeSource(d: FmDeps, input: { sourceId: string }) {
|
||||||
|
const config = await sourceConfig(d, input.sourceId).catch(() => null);
|
||||||
|
if (!config) return { baseline: false, eligible: 0, queued: 0 };
|
||||||
|
// Cross-worker lock covers observations and planning. A duplicate scan never counts as a second stable observation.
|
||||||
|
return d.ctx.db.transaction().execute(async (trx) => {
|
||||||
|
const lock = await sql<{
|
||||||
|
locked: boolean;
|
||||||
|
}>`select pg_try_advisory_xact_lock(hashtext(${`organize-source:${config.id}`})) as locked`.execute(trx);
|
||||||
|
if (!lock.rows[0]?.locked) return { baseline: false, eligible: 0, queued: 0 };
|
||||||
|
const stored = await trx.selectFrom("settings").select("value").where("key", "=", "organize").executeTakeFirst();
|
||||||
|
const freshConfig = (
|
||||||
|
(stored?.value as { sources?: OrganizeSourceConfig[] })?.sources ?? (await sourceConfigs(d))
|
||||||
|
).find((s) => s.id === config.id);
|
||||||
|
if (!freshConfig || hashJson(freshConfig) !== hashJson(config)) return { baseline: false, eligible: 0, queued: 0 };
|
||||||
|
const row = await trx
|
||||||
|
.selectFrom("organize_source_snapshot")
|
||||||
|
.selectAll()
|
||||||
|
.where("source_id", "=", config.id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
const now = new Date();
|
||||||
|
try {
|
||||||
|
const liveActive = await activeDownloaderPaths(d, config);
|
||||||
|
const scan = await walkSource(d, config);
|
||||||
|
if (scan.truncated) throw new FilesError("too_large", "来源超过单次扫描上限,请选择更小的目录");
|
||||||
|
const baseline = !row?.baseline_at;
|
||||||
|
const advance = advanceSnapshot(baseline ? null : (row!.snapshot as unknown as Snapshot), scan.files);
|
||||||
|
let queued = 0;
|
||||||
|
const automation = await d.ctx.settings.get("automation");
|
||||||
|
if (
|
||||||
|
!baseline &&
|
||||||
|
config.enabled &&
|
||||||
|
config.trigger === "snapshot" &&
|
||||||
|
automation.enabled &&
|
||||||
|
!automation.readOnlyReconcile
|
||||||
|
) {
|
||||||
|
const done = await organizedPaths(
|
||||||
|
d,
|
||||||
|
advance.eligible.map((p) => join(scan.rootReal, p)),
|
||||||
|
scan,
|
||||||
|
);
|
||||||
|
for (const path of advance.eligible) {
|
||||||
|
const abs = join(scan.rootReal, path);
|
||||||
|
if (done.has(abs)) {
|
||||||
|
advance.snapshot.files[path]!.accepted = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (liveActive.some((p) => within(abs, p))) continue;
|
||||||
|
// Any known QB task stays under QB's completion/tag policy, including unmanaged and untagged tasks.
|
||||||
|
const known = await downloadTasksUnder(d.ctx.db, [abs]);
|
||||||
|
if (known.length) continue;
|
||||||
|
const identified = await identifyCore(
|
||||||
|
d,
|
||||||
|
{
|
||||||
|
items: [{ root: config.source.root as FileRef["root"], path }],
|
||||||
|
hints: config.mediaType === "all" ? {} : { mediaKind: config.mediaType === "tv" ? "series" : "movie" },
|
||||||
|
},
|
||||||
|
{ mode: "remote", actorId: "system:source-monitor" },
|
||||||
|
);
|
||||||
|
const key = identified.autoPicks.get(`${config.source.root}\u0000${path}`);
|
||||||
|
const candidate = identified.result.items[0]?.candidates.find((c) => candidateKey(c) === key);
|
||||||
|
if (!candidate) {
|
||||||
|
const dedupeKey = `source_identity:${config.id}:${hashJson(path)}`;
|
||||||
|
const existing = await trx
|
||||||
|
.selectFrom("decision")
|
||||||
|
.select("id")
|
||||||
|
.where("dedupe_key", "=", dedupeKey)
|
||||||
|
.where("status", "in", ["open", "investigating", "awaiting_user"])
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!existing)
|
||||||
|
await upsertDecision(d.ao, trx, {
|
||||||
|
kind: "identity_ambiguous",
|
||||||
|
title: `整理来源待识别:${basename(path)}`,
|
||||||
|
subjectType: "organize_source",
|
||||||
|
subjectId: config.id,
|
||||||
|
dedupeKey,
|
||||||
|
evidence: { sourceConfigId: config.id, root: config.source.root, path },
|
||||||
|
options: [{ key: "dismiss", label: "忽略" }],
|
||||||
|
});
|
||||||
|
advance.snapshot.files[path]!.accepted = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const entity =
|
||||||
|
candidate.mediaEntityId ??
|
||||||
|
(
|
||||||
|
await entityFromCandidate(d, undefined, {
|
||||||
|
provider: "tmdb",
|
||||||
|
externalId: candidate.externalId!,
|
||||||
|
kind: candidate.kind === "movie" ? "movie" : "tv",
|
||||||
|
})
|
||||||
|
).mediaEntityId;
|
||||||
|
const organizeSettings = await d.ctx.settings.get("organize");
|
||||||
|
const fingerprint = hashJson({
|
||||||
|
config,
|
||||||
|
classificationYaml: organizeSettings.classificationYaml,
|
||||||
|
path,
|
||||||
|
...scan.files[path],
|
||||||
|
});
|
||||||
|
const result = await planImport(d.ao, {
|
||||||
|
sourcePaths: [abs],
|
||||||
|
pathGuard: (p) => recheck(p, scan.rootReal),
|
||||||
|
mediaEntityId: entity,
|
||||||
|
action: config.action,
|
||||||
|
trigger: "auto",
|
||||||
|
actorType: "system",
|
||||||
|
sourceConfigId: config.id,
|
||||||
|
idempotencyKey: `source:${fingerprint}`,
|
||||||
|
});
|
||||||
|
if (result.recordId && ["running", "queued"].includes(result.status)) {
|
||||||
|
await d.ctx.engine.enqueue(
|
||||||
|
"verifyAndImport",
|
||||||
|
{ recordId: result.recordId },
|
||||||
|
{ idempotencyKey: `verifyAndImport:source:${fingerprint}`, traceId: config.id },
|
||||||
|
);
|
||||||
|
queued++;
|
||||||
|
}
|
||||||
|
if (result.status === "failed" && result.reason !== "classification_unavailable") {
|
||||||
|
const dedupeKey = `source_blocked:${config.id}:${hashJson(path)}`;
|
||||||
|
const existing = await trx
|
||||||
|
.selectFrom("decision")
|
||||||
|
.select("id")
|
||||||
|
.where("dedupe_key", "=", dedupeKey)
|
||||||
|
.where("status", "in", ["open", "investigating", "awaiting_user"])
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!existing)
|
||||||
|
await upsertDecision(d.ao, trx, {
|
||||||
|
kind: "other",
|
||||||
|
title: `来源整理未完成:${basename(path)}`,
|
||||||
|
summary: `原因:${result.reason ?? "整理计划失败"}。可从整理来源的存量整理向导处理这个文件。`,
|
||||||
|
subjectType: "organize_source",
|
||||||
|
subjectId: config.id,
|
||||||
|
dedupeKey,
|
||||||
|
evidence: {
|
||||||
|
sourceConfigId: config.id,
|
||||||
|
root: config.source.root,
|
||||||
|
path,
|
||||||
|
recordId: result.recordId,
|
||||||
|
reason: result.reason,
|
||||||
|
fingerprint,
|
||||||
|
},
|
||||||
|
options: [{ key: "dismiss", label: "忽略" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
advance.snapshot.files[path]!.accepted = result.reason !== "classification_unavailable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A source edit/delete while scanning invalidates the observation instead of restoring the old baseline.
|
||||||
|
const settings = await trx
|
||||||
|
.selectFrom("settings")
|
||||||
|
.select("value")
|
||||||
|
.where("key", "=", "organize")
|
||||||
|
.executeTakeFirst();
|
||||||
|
const current = (
|
||||||
|
(settings?.value as { sources?: OrganizeSourceConfig[] })?.sources ?? (await sourceConfigs(d))
|
||||||
|
).find((s) => s.id === config.id);
|
||||||
|
if (!current || hashJson(current.source) !== hashJson(config.source))
|
||||||
|
return { baseline: false, eligible: 0, queued };
|
||||||
|
await trx
|
||||||
|
.insertInto("organize_source_snapshot")
|
||||||
|
.values({
|
||||||
|
source_id: config.id,
|
||||||
|
snapshot: jsonb(advance.snapshot),
|
||||||
|
baseline_at: row?.baseline_at ?? now,
|
||||||
|
last_scan_at: now,
|
||||||
|
last_error: null,
|
||||||
|
})
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc.column("source_id").doUpdateSet({
|
||||||
|
snapshot: jsonb(advance.snapshot),
|
||||||
|
baseline_at: row?.baseline_at ?? now,
|
||||||
|
last_scan_at: now,
|
||||||
|
last_error: null,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
return { baseline, eligible: advance.eligible.length, queued };
|
||||||
|
} catch (error) {
|
||||||
|
await trx
|
||||||
|
.insertInto("organize_source_snapshot")
|
||||||
|
.values({
|
||||||
|
source_id: config.id,
|
||||||
|
snapshot: jsonb({ files: {} }),
|
||||||
|
baseline_at: null,
|
||||||
|
last_scan_at: now,
|
||||||
|
last_error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
.onConflict((oc) =>
|
||||||
|
oc
|
||||||
|
.column("source_id")
|
||||||
|
.doUpdateSet({ last_scan_at: now, last_error: error instanceof Error ? error.message : String(error) }),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
return { baseline: false, eligible: 0, queued: 0 };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queueDueSourceScans(d: FmDeps, slot: string | number) {
|
||||||
|
const rows = await d.ctx.db.selectFrom("organize_source_snapshot").select(["source_id", "last_scan_at"]).execute();
|
||||||
|
let queued = 0;
|
||||||
|
for (const config of await sourceConfigs(d)) {
|
||||||
|
if (!config.enabled || config.trigger !== "snapshot") continue;
|
||||||
|
const last = rows.find((r) => r.source_id === config.id)?.last_scan_at;
|
||||||
|
if (last && Date.now() - new Date(last).getTime() < config.intervalSec * 1000) continue;
|
||||||
|
await d.ctx.engine.enqueue(
|
||||||
|
"scanOrganizeSource",
|
||||||
|
{ sourceId: config.id },
|
||||||
|
{ idempotencyKey: `source-scan:${config.id}:${slot}`, traceId: config.id },
|
||||||
|
);
|
||||||
|
queued++;
|
||||||
|
}
|
||||||
|
return { summary: `已入队 ${queued} 个来源扫描` };
|
||||||
|
}
|
||||||
@@ -76,13 +76,23 @@ export async function recordSources(d: FmDeps, recordId: string): Promise<FilesR
|
|||||||
const db = d.ctx.db;
|
const db = d.ctx.db;
|
||||||
const rec = await db
|
const rec = await db
|
||||||
.selectFrom("organization_record")
|
.selectFrom("organization_record")
|
||||||
.select(["id", "media_entity_id", "action", "status", "source_path", "source_path_view"])
|
.select([
|
||||||
|
"id",
|
||||||
|
"media_entity_id",
|
||||||
|
"action",
|
||||||
|
"status",
|
||||||
|
"source_path",
|
||||||
|
"source_path_view",
|
||||||
|
"target_path",
|
||||||
|
"target_library_root_id",
|
||||||
|
"plan",
|
||||||
|
])
|
||||||
.where("id", "=", recordId)
|
.where("id", "=", recordId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!rec) throw new FilesError("not_found", "整理记录不存在");
|
if (!rec) throw new FilesError("not_found", "整理记录不存在");
|
||||||
const mains = await db
|
const mains = await db
|
||||||
.selectFrom("organization_file_result")
|
.selectFrom("organization_file_result")
|
||||||
.select(["source_path"])
|
.select(["source_path", "target_path", "target_library_root_id"])
|
||||||
.where("organization_record_id", "=", rec.id)
|
.where("organization_record_id", "=", rec.id)
|
||||||
.where("role", "=", "main")
|
.where("role", "=", "main")
|
||||||
.orderBy("ordinal")
|
.orderBy("ordinal")
|
||||||
@@ -95,13 +105,20 @@ export async function recordSources(d: FmDeps, recordId: string): Promise<FilesR
|
|||||||
? { root: def.id as FileRootId, path: abs === def.hostPath ? "" : abs.slice(def.hostPath.length + 1) }
|
? { root: def.id as FileRootId, path: abs === def.hostPath ? "" : abs.slice(def.hostPath.length + 1) }
|
||||||
: null;
|
: null;
|
||||||
};
|
};
|
||||||
// 多个主视频都在记录的源目录下、且源目录不是登记目录本身 → 给目录(向导按目录展开);否则逐个主视频
|
// Successful moves have removed their inputs: reorganize the existing library files.
|
||||||
let sources = mains.map((m) => m.source_path);
|
const moved = rec.action === "move" && rec.status === "succeeded";
|
||||||
const dir = rec.source_path;
|
const targetOf = (rootId: string | null, path: string | null) => {
|
||||||
|
const root = defs.find((d) => d.libraryRootId === rootId);
|
||||||
|
return root && path && within(join(root.hostPath, path), root.hostPath) ? join(root.hostPath, path) : null;
|
||||||
|
};
|
||||||
|
let sources = moved
|
||||||
|
? mains.map((m) => targetOf(m.target_library_root_id, m.target_path)).filter((p): p is string => !!p)
|
||||||
|
: mains.map((m) => m.source_path);
|
||||||
|
const dir = moved ? targetOf(rec.target_library_root_id, rec.target_path) : rec.source_path;
|
||||||
if (
|
if (
|
||||||
dir &&
|
dir &&
|
||||||
mains.length > 1 &&
|
sources.length > 1 &&
|
||||||
mains.every((m) => within(m.source_path, dir)) &&
|
sources.every((p) => within(p, dir)) &&
|
||||||
!defs.some((x) => x.hostPath === dir.replace(/\/+$/, ""))
|
!defs.some((x) => x.hostPath === dir.replace(/\/+$/, ""))
|
||||||
)
|
)
|
||||||
sources = [dir];
|
sources = [dir];
|
||||||
@@ -109,7 +126,7 @@ export async function recordSources(d: FmDeps, recordId: string): Promise<FilesR
|
|||||||
const items: FilesRecordSourcesResult["items"] = [];
|
const items: FilesRecordSourcesResult["items"] = [];
|
||||||
const unresolved: string[] = [];
|
const unresolved: string[] = [];
|
||||||
for (const abs of [...new Set(sources)]) {
|
for (const abs of [...new Set(sources)]) {
|
||||||
const ref = rec.source_path_view === "local" ? toRef(abs) : null;
|
const ref = moved || rec.source_path_view === "local" ? toRef(abs) : null;
|
||||||
const exists = ref
|
const exists = ref
|
||||||
? await lstat(abs).then(
|
? await lstat(abs).then(
|
||||||
() => true,
|
() => true,
|
||||||
@@ -126,5 +143,11 @@ export async function recordSources(d: FmDeps, recordId: string): Promise<FilesR
|
|||||||
status: rec.status as OrganizationStatus,
|
status: rec.status as OrganizationStatus,
|
||||||
items,
|
items,
|
||||||
unresolved,
|
unresolved,
|
||||||
|
...(rec.plan &&
|
||||||
|
typeof rec.plan === "object" &&
|
||||||
|
"sourceConfigId" in rec.plan &&
|
||||||
|
typeof rec.plan.sourceConfigId === "string"
|
||||||
|
? { sourceConfigId: rec.plan.sourceConfigId }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -108,6 +108,10 @@ export interface SuggestOptions {
|
|||||||
trigger: EnrichmentTrigger;
|
trigger: EnrichmentTrigger;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
/** Authenticated PT detail already fetched by the site adapter; never fetch arbitrary URLs here. */
|
||||||
|
seedPages?: EnrichPage[];
|
||||||
|
seedCandidates?: Candidate[];
|
||||||
|
kind?: "movie" | "series";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 域名白名单 ──
|
// ── 域名白名单 ──
|
||||||
@@ -502,6 +506,9 @@ export async function suggestEnrichment(
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
logger: ctx.logger,
|
logger: ctx.logger,
|
||||||
doubanSubjects,
|
doubanSubjects,
|
||||||
|
seedPages: opts.seedPages,
|
||||||
|
seedCandidates: opts.seedCandidates,
|
||||||
|
kind: opts.kind,
|
||||||
...(opts.signal ? { signal: opts.signal } : {}),
|
...(opts.signal ? { signal: opts.signal } : {}),
|
||||||
},
|
},
|
||||||
usage,
|
usage,
|
||||||
@@ -539,6 +546,9 @@ export async function suggestEnrichment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PipelineInput {
|
export interface PipelineInput {
|
||||||
|
seedPages?: EnrichPage[];
|
||||||
|
seedCandidates?: Candidate[];
|
||||||
|
kind?: "movie" | "series";
|
||||||
q: { title: string; year: number | null; hints: string | null };
|
q: { title: string; year: number | null; hints: string | null };
|
||||||
settings: { domains: string[]; snippetAllSites: boolean };
|
settings: { domains: string[]; snippetAllSites: boolean };
|
||||||
/** 已登记站点的域名:一律不读、不用摘要 */
|
/** 已登记站点的域名:一律不读、不用摘要 */
|
||||||
@@ -573,7 +583,8 @@ export async function runEnrichPipeline(
|
|||||||
const siteFilter = siteFilterDomains(settings.domains, SITE_FILTER_DOMAINS)
|
const siteFilter = siteFilterDomains(settings.domains, SITE_FILTER_DOMAINS)
|
||||||
.map((d) => `site:${d}`)
|
.map((d) => `site:${d}`)
|
||||||
.join(" OR ");
|
.join(" OR ");
|
||||||
const queries = [`${baseTitle} 短剧`, `${baseTitle} 短剧${siteFilter ? ` ${siteFilter}` : ""}`].map((x) =>
|
const term = input.kind ? (input.kind === "movie" ? "电影" : "电视剧") : "短剧";
|
||||||
|
const queries = [`${baseTitle} ${term}`, `${baseTitle} ${term}${siteFilter ? ` ${siteFilter}` : ""}`].map((x) =>
|
||||||
x.slice(0, 400),
|
x.slice(0, 400),
|
||||||
);
|
);
|
||||||
const sources: EnrichSource[] = [];
|
const sources: EnrichSource[] = [];
|
||||||
@@ -583,7 +594,7 @@ export async function runEnrichPipeline(
|
|||||||
/** 交给模型、参与核对的搜索摘要(白名单网站相关的 + 摘要模式用上的) */
|
/** 交给模型、参与核对的搜索摘要(白名单网站相关的 + 摘要模式用上的) */
|
||||||
const snippets: EnrichSearchHit[] = [];
|
const snippets: EnrichSearchHit[] = [];
|
||||||
const snippetEv: EvidenceSource[] = [];
|
const snippetEv: EvidenceSource[] = [];
|
||||||
const cands: Candidate[] = [];
|
const cands: Candidate[] = [...(input.seedCandidates ?? [])];
|
||||||
const results: Partial<Record<SourceKind, number>> = {};
|
const results: Partial<Record<SourceKind, number>> = {};
|
||||||
const fetchedKinds: Partial<Record<SourceKind, number>> = {};
|
const fetchedKinds: Partial<Record<SourceKind, number>> = {};
|
||||||
const kindOf = new Map<string, SourceKind>();
|
const kindOf = new Map<string, SourceKind>();
|
||||||
@@ -598,7 +609,11 @@ export async function runEnrichPipeline(
|
|||||||
const scope = `system:enrich:${id}:${i}`;
|
const scope = `system:enrich:${id}:${i}`;
|
||||||
used.push(query);
|
used.push(query);
|
||||||
usage.searchCalls++;
|
usage.searchCalls++;
|
||||||
const hits = await deps.search(query, scope, signal);
|
const hits = await deps.search(query, scope, signal).catch((e) => {
|
||||||
|
if (signal?.aborted || !input.seedPages?.length) throw e;
|
||||||
|
logger.warn({ enrichmentId: id }, "网页搜索不可用,使用已获取的 PT 详情继续补全");
|
||||||
|
return [];
|
||||||
|
});
|
||||||
for (const h of hits) {
|
for (const h of hits) {
|
||||||
const host = hostOf(h.url);
|
const host = hostOf(h.url);
|
||||||
if (!host || sources.some((x) => x.url === h.url)) continue;
|
if (!host || sources.some((x) => x.url === h.url)) continue;
|
||||||
@@ -651,8 +666,37 @@ export async function runEnrichPipeline(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const pages: EnrichPage[] = [];
|
const pages: EnrichPage[] = [...(input.seedPages ?? [])];
|
||||||
const pageEv: EvidenceSource[] = [];
|
const pageEv: EvidenceSource[] = pages.map((p) => ({
|
||||||
|
url: p.finalUrl,
|
||||||
|
host: hostOf(p.finalUrl) ?? "",
|
||||||
|
kind: "other",
|
||||||
|
page: true,
|
||||||
|
text: p.text,
|
||||||
|
image: p.image,
|
||||||
|
}));
|
||||||
|
for (const p of pages) {
|
||||||
|
sources.push({
|
||||||
|
url: p.finalUrl,
|
||||||
|
title: p.title,
|
||||||
|
domain: hostOf(p.finalUrl) ?? "",
|
||||||
|
allowed: true,
|
||||||
|
fetched: true,
|
||||||
|
skipped: null,
|
||||||
|
error: null,
|
||||||
|
kind: "other",
|
||||||
|
});
|
||||||
|
cands.push(
|
||||||
|
...extractFromPage(q.title, {
|
||||||
|
url: p.finalUrl,
|
||||||
|
host: hostOf(p.finalUrl) ?? "",
|
||||||
|
kind: "other",
|
||||||
|
title: p.title,
|
||||||
|
text: p.text,
|
||||||
|
image: p.image,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
// 先读标题完全对得上的,再按白名单顺序(百科、平台靠前)
|
// 先读标题完全对得上的,再按白名单顺序(百科、平台靠前)
|
||||||
const rank = (x: EnrichSource) => settings.domains.findIndex((d) => x.domain === d || x.domain.endsWith(`.${d}`));
|
const rank = (x: EnrichSource) => settings.domains.findIndex((d) => x.domain === d || x.domain.endsWith(`.${d}`));
|
||||||
const toFetch = sources
|
const toFetch = sources
|
||||||
@@ -753,7 +797,7 @@ export async function runEnrichPipeline(
|
|||||||
suggestion: {
|
suggestion: {
|
||||||
fields,
|
fields,
|
||||||
// 补全面向短剧 / 网剧:缺省按剧集建档,应用时可改为电影
|
// 补全面向短剧 / 网剧:缺省按剧集建档,应用时可改为电影
|
||||||
kind: "series",
|
kind: input.kind ?? "series",
|
||||||
queries: used,
|
queries: used,
|
||||||
sources,
|
sources,
|
||||||
extractor,
|
extractor,
|
||||||
@@ -846,6 +890,14 @@ export async function applyEnrichment(
|
|||||||
const same = await sameTitleEntities(trx, [title, q.title ?? ""]);
|
const same = await sameTitleEntities(trx, [title, q.title ?? ""]);
|
||||||
const hit = same.find((e) => year === null || e.year === null || e.year === year);
|
const hit = same.find((e) => year === null || e.year === null || e.year === year);
|
||||||
entityId = hit?.mediaEntityId ?? null;
|
entityId = hit?.mediaEntityId ?? null;
|
||||||
|
if (entityId && actor.type === "system") {
|
||||||
|
const existing = await trx
|
||||||
|
.selectFrom("media_entity")
|
||||||
|
.select("kind")
|
||||||
|
.where("id", "=", entityId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (existing?.kind !== kind) entityId = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!entityId) {
|
if (!entityId) {
|
||||||
const e = await trx
|
const e = await trx
|
||||||
@@ -919,7 +971,7 @@ export async function applyEnrichment(
|
|||||||
}
|
}
|
||||||
let units = 0;
|
let units = 0;
|
||||||
const episodes = want.has("episodeCount") ? (f.episodeCount?.value ?? null) : null;
|
const episodes = want.has("episodeCount") ? (f.episodeCount?.value ?? null) : null;
|
||||||
if (kind === "series") {
|
if (kind === "series" && actor.type !== "system") {
|
||||||
const parsed = {
|
const parsed = {
|
||||||
season: 1,
|
season: 1,
|
||||||
...(episodes ? { episodes: { start: 1, end: Math.min(episodes, 200) } } : {}),
|
...(episodes ? { episodes: { start: 1, end: Math.min(episodes, 200) } } : {}),
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { recallTmdbCandidates } from "@umt/adapters";
|
||||||
|
import type { OrganizeMetadataOutput } from "@umt/contracts";
|
||||||
|
import { parseRelease, resolveIdentity } from "@umt/domain";
|
||||||
|
import { jsonb } from "../rows.ts";
|
||||||
|
import type { SourcesRuntime } from "../runtime.ts";
|
||||||
|
import { type Actor, audit } from "./common.ts";
|
||||||
|
import { detailScreenshotUrl, loadDetailPage } from "./detail-page.ts";
|
||||||
|
import { applyEnrichment, type EnrichDeps, type EnrichPage, suggestEnrichment } from "./enrich.ts";
|
||||||
|
import type { Candidate } from "./enrich-sources.ts";
|
||||||
|
import { assignReleaseIdentity } from "./identity-admin.ts";
|
||||||
|
import { type EnrichmentFieldWrite, project, writeEnrichmentFields } from "./metadata.ts";
|
||||||
|
import { resolveRelease } from "./resolve.ts";
|
||||||
|
import { tmdbToCandidate } from "./tmdb-candidate.ts";
|
||||||
|
|
||||||
|
/** Only values explicitly present in a fetched PT page are considered facts. */
|
||||||
|
export function ptDetailFacts(text: string, url: string) {
|
||||||
|
const lines = text
|
||||||
|
.replace(/◎/g, "\n")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const label = (re: RegExp) => {
|
||||||
|
const line = lines.find((s) => re.test(s));
|
||||||
|
return (
|
||||||
|
line
|
||||||
|
?.replace(re, "")
|
||||||
|
.replace(/^\s*[::]\s*/, "")
|
||||||
|
.trim() || null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const split = (s: string | null) => (s ? s.split(/\s*[/、|,,]\s*/).filter(Boolean) : []);
|
||||||
|
const title = label(/^(?:译\s*名|中文名|中文片名|片\s*名)\s*/);
|
||||||
|
const original = label(/^(?:原\s*名|原片名|外文名)\s*/);
|
||||||
|
const names = [
|
||||||
|
...new Set([title, original, ...split(label(/^(?:又\s*名|别\s*名)\s*/))].filter((s): s is string => !!s)),
|
||||||
|
];
|
||||||
|
const yearText = label(/^(?:年\s*代|年\s*份|上映年份)\s*/);
|
||||||
|
const year = yearText?.match(/\b(19\d{2}|20\d{2})\b/)?.[0];
|
||||||
|
const synopsisAt = lines.findIndex((s) => /^(?:简\s*介|剧情简介|内容简介)/.test(s));
|
||||||
|
const synopsis =
|
||||||
|
synopsisAt >= 0
|
||||||
|
? lines
|
||||||
|
.slice(synopsisAt)
|
||||||
|
.join("\n")
|
||||||
|
.replace(/^(?:简\s*介|剧情简介|内容简介)\s*[::]?\s*/, "")
|
||||||
|
.slice(0, 2000)
|
||||||
|
: null;
|
||||||
|
const base = {
|
||||||
|
sourceUrl: url,
|
||||||
|
host: new URL(url).hostname,
|
||||||
|
kind: "other" as const,
|
||||||
|
confidence: 0.8,
|
||||||
|
verbatim: true,
|
||||||
|
by: "rule" as const,
|
||||||
|
};
|
||||||
|
const candidates: Candidate[] = [];
|
||||||
|
const add = (field: Candidate["field"], value: string | number | null) => {
|
||||||
|
if (value !== null && value !== "") candidates.push({ ...base, field, value });
|
||||||
|
};
|
||||||
|
add("title", title);
|
||||||
|
for (const name of names) add("aliases", name);
|
||||||
|
add("year", year ? Number(year) : null);
|
||||||
|
const episodeCount = label(/^(?:集\s*数|总集数)\s*/)?.match(/\d+/)?.[0];
|
||||||
|
if (episodeCount && Number(episodeCount) > 0 && Number(episodeCount) <= 2000)
|
||||||
|
add("episodeCount", Number(episodeCount));
|
||||||
|
add("synopsis", synopsis);
|
||||||
|
for (const g of split(label(/^(?:类\s*别|类\s*型)\s*/))) add("genre", g);
|
||||||
|
for (const name of split(label(/^(?:主\s*演|演员)\s*/))) add("cast", name);
|
||||||
|
const countries = split(label(/^(?:产\s*地|制片国家\/地区|国家\/地区|地\s*区)\s*/));
|
||||||
|
const language = label(/^(?:语\s*言|对白语言)\s*/);
|
||||||
|
const langCodes: Record<string, string> = {
|
||||||
|
汉语普通话: "zh",
|
||||||
|
普通话: "zh",
|
||||||
|
中文: "zh",
|
||||||
|
粤语: "zh",
|
||||||
|
英语: "en",
|
||||||
|
日语: "ja",
|
||||||
|
韩语: "ko",
|
||||||
|
法语: "fr",
|
||||||
|
德语: "de",
|
||||||
|
西班牙语: "es",
|
||||||
|
};
|
||||||
|
const countryCodes: Record<string, string> = {
|
||||||
|
中国大陆: "CN",
|
||||||
|
中国: "CN",
|
||||||
|
中国香港: "HK",
|
||||||
|
香港: "HK",
|
||||||
|
中国台湾: "TW",
|
||||||
|
台湾: "TW",
|
||||||
|
美国: "US",
|
||||||
|
英国: "GB",
|
||||||
|
日本: "JP",
|
||||||
|
韩国: "KR",
|
||||||
|
法国: "FR",
|
||||||
|
德国: "DE",
|
||||||
|
};
|
||||||
|
const extras: { field: EnrichmentFieldWrite["field"]; value: unknown }[] = [];
|
||||||
|
if (original) extras.push({ field: "originalTitle", value: original });
|
||||||
|
if (countries.length) extras.push({ field: "originCountry", value: countries.map((s) => countryCodes[s] ?? s) });
|
||||||
|
const languageName = split(language)[0];
|
||||||
|
if (languageName && langCodes[languageName])
|
||||||
|
extras.push({ field: "originalLanguage", value: langCodes[languageName] });
|
||||||
|
const directors = split(label(/^(?:导\s*演)\s*/));
|
||||||
|
if (directors.length)
|
||||||
|
extras.push({
|
||||||
|
field: "crew",
|
||||||
|
value: directors.map((name, i) => ({
|
||||||
|
id: -(i + 1),
|
||||||
|
name,
|
||||||
|
originalName: null,
|
||||||
|
role: "Director",
|
||||||
|
profilePath: null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
return { candidates, extras, names, title, year: year ? Number(year) : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reuses identity matching, audited enrichment, field projection and the existing local entity identity. */
|
||||||
|
export async function resolveOrganizeMetadata(
|
||||||
|
rt: SourcesRuntime,
|
||||||
|
sourceReleaseId: string,
|
||||||
|
deps: EnrichDeps,
|
||||||
|
opts: { actor: Actor; signal?: AbortSignal },
|
||||||
|
): Promise<OrganizeMetadataOutput> {
|
||||||
|
const { ctx } = rt;
|
||||||
|
const result = (
|
||||||
|
mediaEntityId: string | null,
|
||||||
|
mode: "tmdb" | "local" | null,
|
||||||
|
reason: string | null,
|
||||||
|
enrichmentId: string | null = null,
|
||||||
|
): OrganizeMetadataOutput => ({ mediaEntityId, mode, reason, enrichmentId });
|
||||||
|
const modeOf = async (id: string) =>
|
||||||
|
(await ctx.db
|
||||||
|
.selectFrom("external_identity")
|
||||||
|
.select("id")
|
||||||
|
.where("media_entity_id", "=", id)
|
||||||
|
.where("provider", "=", "tmdb")
|
||||||
|
.where("status", "=", "accepted")
|
||||||
|
.executeTakeFirst())
|
||||||
|
? ("tmdb" as const)
|
||||||
|
: ("local" as const);
|
||||||
|
const release = await ctx.db
|
||||||
|
.selectFrom("source_release")
|
||||||
|
.selectAll()
|
||||||
|
.where("id", "=", sourceReleaseId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!release) return result(null, null, "release_not_found");
|
||||||
|
if (release.media_entity_id) return result(release.media_entity_id, await modeOf(release.media_entity_id), null);
|
||||||
|
// Failure to query TMDB is not evidence that no TMDB record exists.
|
||||||
|
const normal = await resolveRelease(rt, { sourceReleaseId, force: true, trigger: "manual", escalate: true });
|
||||||
|
if (normal.mediaEntityId) return result(normal.mediaEntityId, await modeOf(normal.mediaEntityId), null);
|
||||||
|
if (normal.outcome === "lookup_failed" || /error|failed|unavailable/.test(normal.reason ?? ""))
|
||||||
|
return result(null, null, normal.reason);
|
||||||
|
const pool = await ctx.db
|
||||||
|
.selectFrom("identity_candidate")
|
||||||
|
.select("id")
|
||||||
|
.where("source_release_id", "=", sourceReleaseId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!pool) return result(null, null, "identity_pool_missing");
|
||||||
|
const page = await loadDetailPage(rt, sourceReleaseId);
|
||||||
|
const site = await ctx.db.selectFrom("site").select("base_url").where("id", "=", release.site_id).executeTakeFirst();
|
||||||
|
const raw = release.raw as { _site?: { detailUrl?: string }; _detail?: { sourceUrl?: string } };
|
||||||
|
const candidateUrl = raw?._detail?.sourceUrl ?? raw?._site?.detailUrl;
|
||||||
|
let seedUrl: string | null = null;
|
||||||
|
if (site?.base_url) {
|
||||||
|
const u = new URL(
|
||||||
|
candidateUrl || `details.php?id=${encodeURIComponent(release.site_release_id)}`,
|
||||||
|
`${site.base_url.replace(/\/$/, "")}/`,
|
||||||
|
);
|
||||||
|
if (u.origin === new URL(site.base_url).origin) {
|
||||||
|
u.username = "";
|
||||||
|
u.password = "";
|
||||||
|
u.hash = "";
|
||||||
|
for (const key of [...u.searchParams.keys()])
|
||||||
|
if (/passkey|token|cookie|secret|api.?key|auth/i.test(key)) u.searchParams.delete(key);
|
||||||
|
seedUrl = u.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const facts = page?.descriptionText && seedUrl ? ptDetailFacts(page.descriptionText, seedUrl) : null;
|
||||||
|
const seedPages: EnrichPage[] =
|
||||||
|
page?.descriptionText && seedUrl
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
url: seedUrl,
|
||||||
|
finalUrl: seedUrl,
|
||||||
|
title: facts?.title ?? release.title,
|
||||||
|
text: page.descriptionText,
|
||||||
|
image: await detailScreenshotUrl(rt, sourceReleaseId, 0),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
if (facts?.title && seedPages[0]?.image && seedUrl)
|
||||||
|
facts.candidates.push({
|
||||||
|
field: "posterUrl",
|
||||||
|
value: seedPages[0].image,
|
||||||
|
sourceUrl: seedUrl,
|
||||||
|
host: new URL(seedUrl).hostname,
|
||||||
|
kind: "other",
|
||||||
|
confidence: 0.6,
|
||||||
|
verbatim: true,
|
||||||
|
by: "rule",
|
||||||
|
});
|
||||||
|
const parsed = parseRelease(release.title, release.subtitle ? { subtitle: release.subtitle } : {});
|
||||||
|
const kind =
|
||||||
|
parsed.mediaTypeHint === "tv" ||
|
||||||
|
parsed.season !== undefined ||
|
||||||
|
/剧集|电视剧|连续剧/.test(release.category_raw ?? "") ||
|
||||||
|
facts?.candidates.some((c) => c.field === "episodeCount")
|
||||||
|
? ("series" as const)
|
||||||
|
: ("movie" as const);
|
||||||
|
const out = await suggestEnrichment(
|
||||||
|
ctx,
|
||||||
|
deps,
|
||||||
|
{ poolEntryId: pool.id, fresh: true },
|
||||||
|
{
|
||||||
|
trigger: "auto",
|
||||||
|
createdBy: "system:organize",
|
||||||
|
...(opts.signal ? { signal: opts.signal } : {}),
|
||||||
|
seedPages,
|
||||||
|
seedCandidates: facts?.candidates ?? [],
|
||||||
|
kind,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (out.status === "failed" || !out.suggestion)
|
||||||
|
return result(null, null, out.error ?? "metadata_no_evidence", out.enrichmentId);
|
||||||
|
const fields = out.suggestion.fields;
|
||||||
|
for (const key of ["aliases", "genre", "cast"] as const) fields[key] = fields[key].filter((v) => v.verbatim);
|
||||||
|
await ctx.db
|
||||||
|
.updateTable("metadata_enrichment")
|
||||||
|
.set({ suggestion: jsonb(out.suggestion) })
|
||||||
|
.where("id", "=", out.enrichmentId)
|
||||||
|
.execute();
|
||||||
|
const names = [
|
||||||
|
...new Set(
|
||||||
|
[
|
||||||
|
fields.title?.value,
|
||||||
|
...fields.aliases.filter((v) => v.verbatim).map((v) => v.value),
|
||||||
|
...(facts?.names ?? []),
|
||||||
|
].filter((s): s is string => !!s),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const tmdb = await rt.tmdb();
|
||||||
|
if (tmdb && names.length) {
|
||||||
|
const year = fields.year?.verbatim ? fields.year.value : (facts?.year ?? parsed.year);
|
||||||
|
const candidates = await recallTmdbCandidates(tmdb, {
|
||||||
|
names,
|
||||||
|
type: kind === "movie" ? "movie" : "tv",
|
||||||
|
...(year ? { year } : {}),
|
||||||
|
maxNames: 4,
|
||||||
|
});
|
||||||
|
const matched = new Map<string, NonNullable<(typeof candidates)[number]>>();
|
||||||
|
let ambiguous = false;
|
||||||
|
for (const name of names) {
|
||||||
|
const query = { ...parsed, title: name, titleCn: undefined, titleEn: undefined, ...(year ? { year } : {}) };
|
||||||
|
const r = resolveIdentity(query, candidates.map(tmdbToCandidate));
|
||||||
|
if (r.status === "ambiguous") ambiguous = true;
|
||||||
|
if (r.status === "auto" && r.best) {
|
||||||
|
const c = candidates.find(
|
||||||
|
(c) => c.externalId === r.best!.candidate.externalId && c.entityType === r.best!.candidate.entityType,
|
||||||
|
);
|
||||||
|
if (c) matched.set(`${c.entityType}:${c.externalId}`, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matched.size === 1 && !ambiguous) {
|
||||||
|
const c = [...matched.values()][0]!;
|
||||||
|
const assigned = await assignReleaseIdentity(
|
||||||
|
rt,
|
||||||
|
sourceReleaseId,
|
||||||
|
{
|
||||||
|
external: { provider: "tmdb", entityType: c.entityType, externalId: c.externalId },
|
||||||
|
lock: false,
|
||||||
|
reason: `整理 Agent 别名补全 ${out.enrichmentId}`,
|
||||||
|
},
|
||||||
|
opts.actor,
|
||||||
|
);
|
||||||
|
return result(assigned.mediaEntityId, "tmdb", null, out.enrichmentId);
|
||||||
|
}
|
||||||
|
if (matched.size > 1 || ambiguous) return result(null, null, "metadata_tmdb_ambiguous", out.enrichmentId);
|
||||||
|
}
|
||||||
|
if (normal.outcome === "ambiguous" || normal.outcome === "proposed")
|
||||||
|
return result(null, null, "metadata_tmdb_ambiguous", out.enrichmentId);
|
||||||
|
// No invented placeholder entity: require a supported title and at least one additional factual field.
|
||||||
|
if (
|
||||||
|
!fields.title?.verbatim ||
|
||||||
|
!(
|
||||||
|
fields.year?.verbatim ||
|
||||||
|
fields.synopsis?.verbatim ||
|
||||||
|
fields.cast.some((v) => v.verbatim) ||
|
||||||
|
fields.genre.some((v) => v.verbatim)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result(null, null, "metadata_insufficient_evidence", out.enrichmentId);
|
||||||
|
const allowedFields = Object.entries(fields)
|
||||||
|
.filter(([, v]) => (Array.isArray(v) ? v.some((x) => x.verbatim) : v?.verbatim))
|
||||||
|
.map(([k]) => k) as NonNullable<Parameters<typeof applyEnrichment>[2]["fields"]>;
|
||||||
|
const applied = await applyEnrichment(
|
||||||
|
rt,
|
||||||
|
pool.id,
|
||||||
|
{ enrichmentId: out.enrichmentId, kind, fields: allowedFields, lock: false },
|
||||||
|
opts.actor,
|
||||||
|
);
|
||||||
|
if (facts?.extras.length) {
|
||||||
|
const writes: EnrichmentFieldWrite[] = facts.extras.map((f) => ({
|
||||||
|
...f,
|
||||||
|
source: "web",
|
||||||
|
evidence: {
|
||||||
|
sourceUrl: seedUrl,
|
||||||
|
enrichmentId: out.enrichmentId,
|
||||||
|
evidenceLevel: "rule_confirmed",
|
||||||
|
extractor: "rule",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
await ctx.db.transaction().execute(async (trx) => {
|
||||||
|
await writeEnrichmentFields(trx, applied.mediaEntityId, writes, `enrich:${out.enrichmentId}:pt`, rt.now());
|
||||||
|
await project(trx, applied.mediaEntityId, rt.now());
|
||||||
|
await audit(
|
||||||
|
trx,
|
||||||
|
opts.actor,
|
||||||
|
"metadata.organize_local",
|
||||||
|
{ type: "media_entity", id: applied.mediaEntityId },
|
||||||
|
{ sourceReleaseId, enrichmentId: out.enrichmentId },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Automatic fallback must remain revisitable by future identity review.
|
||||||
|
await ctx.db
|
||||||
|
.updateTable("source_release")
|
||||||
|
.set({ resolution_locked: false })
|
||||||
|
.where("id", "=", sourceReleaseId)
|
||||||
|
.execute();
|
||||||
|
return result(applied.mediaEntityId, await modeOf(applied.mediaEntityId), null, out.enrichmentId);
|
||||||
|
}
|
||||||
@@ -261,6 +261,11 @@ function escalationFetchPolicy(
|
|||||||
return "no";
|
return "no";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RESOLUTION_RANK: Record<IdentityResolution["status"], number> = { no_match: 0, ambiguous: 1, auto: 2 };
|
||||||
|
/** 升级链的结果只在更确定时采用,原有歧义候选不被更差的结果冲掉 */
|
||||||
|
const betterResolution = (next: IdentityResolution, cur: IdentityResolution) =>
|
||||||
|
RESOLUTION_RANK[next.status] > RESOLUTION_RANK[cur.status];
|
||||||
|
|
||||||
interface EscalationOutcome {
|
interface EscalationOutcome {
|
||||||
evidence: EscalationEvidence;
|
evidence: EscalationEvidence;
|
||||||
resolution?: IdentityResolution;
|
resolution?: IdentityResolution;
|
||||||
@@ -305,11 +310,13 @@ async function escalateIdentity(
|
|||||||
const remote = await tmdbByExplicitIds(tmdb, after);
|
const remote = await tmdbByExplicitIds(tmdb, after);
|
||||||
if (remote.length) {
|
if (remote.length) {
|
||||||
const res = resolveIdentity(p, [...local, ...remote.map(tmdbToCandidate)], trustedOf(after));
|
const res = resolveIdentity(p, [...local, ...remote.map(tmdbToCandidate)], trustedOf(after));
|
||||||
|
if (res.status !== "no_match") {
|
||||||
best = { evidence: ev, resolution: res, remote, parsed: p };
|
best = { evidence: ev, resolution: res, remote, parsed: p };
|
||||||
ev.via = "site_id";
|
ev.via = "site_id";
|
||||||
if (res.status === "auto") return best;
|
if (res.status === "auto") return best;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// 2) 豆瓣 ID → 缓存的豆瓣条目(标题 + 年份)→ TMDB
|
// 2) 豆瓣 ID → 缓存的豆瓣条目(标题 + 年份)→ TMDB
|
||||||
const douban = after.find((x) => x.provider === "douban");
|
const douban = after.find((x) => x.provider === "douban");
|
||||||
if (douban) {
|
if (douban) {
|
||||||
@@ -317,7 +324,8 @@ async function escalateIdentity(
|
|||||||
if (d) {
|
if (d) {
|
||||||
ev.douban = { id: d.subject.id, title: d.subject.title, year: d.subject.year, source: d.subject.source };
|
ev.douban = { id: d.subject.id, title: d.subject.title, year: d.subject.year, source: d.subject.source };
|
||||||
const res = resolveIdentity(d.parsed, [...local, ...d.remote.map(tmdbToCandidate)]);
|
const res = resolveIdentity(d.parsed, [...local, ...d.remote.map(tmdbToCandidate)]);
|
||||||
if (res.status === "auto" || !best || (best.resolution?.status === "no_match" && res.status !== "no_match")) {
|
// 与 MP 识别兜底一致:无匹配的结果不顶替任何已有结果
|
||||||
|
if (res.status === "auto" || (!best && res.status !== "no_match")) {
|
||||||
ev.via = "douban";
|
ev.via = "douban";
|
||||||
best = { evidence: ev, resolution: res, remote: d.remote, parsed: d.parsed };
|
best = { evidence: ev, resolution: res, remote: d.remote, parsed: d.parsed };
|
||||||
}
|
}
|
||||||
@@ -445,7 +453,7 @@ export async function resolveRelease(rt: SourcesRuntime, input: ResolveInput): P
|
|||||||
try {
|
try {
|
||||||
const up = await escalateIdentity(rt, release, parsed, local, siteIds, policy);
|
const up = await escalateIdentity(rt, release, parsed, local, siteIds, policy);
|
||||||
escalation = up.evidence;
|
escalation = up.evidence;
|
||||||
if (up.resolution && up.remote && up.parsed) {
|
if (up.resolution && up.remote && up.parsed && betterResolution(up.resolution, resolution)) {
|
||||||
resolution = up.resolution;
|
resolution = up.resolution;
|
||||||
remote = up.remote;
|
remote = up.remote;
|
||||||
scored = up.parsed;
|
scored = up.parsed;
|
||||||
|
|||||||
@@ -37,6 +37,24 @@ describe.skipIf(!hasPg)("POST /engine/runs/{id}/retry", () => {
|
|||||||
await h?.close();
|
await h?.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("工作流多值筛选保留排队核验与独立刮削,单值筛选兼容", async () => {
|
||||||
|
const table = sql.raw(`"${h.engineSchema}".workflow_runs`);
|
||||||
|
await sql`insert into ${table} (namespace_id, id, workflow_name, version, status, config, context, input, attempts, created_at, updated_at)
|
||||||
|
values ('default', 'scrape-queue', 'scrapeMedia', 'v2', 'pending', '{}'::jsonb, null, '{"input":{}}'::jsonb, 0, now(), now())`.execute(
|
||||||
|
h.db,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const both = await json(
|
||||||
|
await admin("GET", "/engine/runs?workflowName=verifyAndImport,scrapeMedia&status=pending"),
|
||||||
|
);
|
||||||
|
expect(both.data.items.map((run: { id: string }) => run.id).sort()).toEqual(["p1", "scrape-queue"]);
|
||||||
|
const single = await json(await admin("GET", "/engine/runs?workflowName=verifyAndImport&status=pending"));
|
||||||
|
expect(single.data.items.map((run: { id: string }) => run.id)).toEqual(["p1"]);
|
||||||
|
} finally {
|
||||||
|
await sql`delete from ${table} where id = 'scrape-queue'`.execute(h.db);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("失败的 run:按原任务名与原始输入重新入队,序号递增,写审计", async () => {
|
it("失败的 run:按原任务名与原始输入重新入队,序号递增,写审计", async () => {
|
||||||
const r1 = await json(await admin("POST", "/engine/runs/f1/retry"));
|
const r1 = await json(await admin("POST", "/engine/runs/f1/retry"));
|
||||||
expect(r1.data).toEqual({ runId: "new-1", retryOf: "f1", attempt: 1 });
|
expect(r1.data).toEqual({ runId: "new-1", retryOf: "f1", attempt: 1 });
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
waitFor,
|
waitFor,
|
||||||
} from "./harness.ts";
|
} from "./harness.ts";
|
||||||
|
|
||||||
// E10(原编号与 e6-auto-recover 重复,W6-B 改名) W4-A 前后端集成端点:手动提交下载(与自动同流程)、候选评估去重、库存叠加补字段、
|
// E10(原编号与 e6-auto-recover 重复,W6-B 改名) W4-A 前后端集成端点:独立手动下载与自动订阅下载分别验证、候选评估去重、库存叠加补字段、
|
||||||
// 整理记录 downloadTaskId、只读资产/产物端点、偏好标签修订历史、列表带作品标题、事件 JSON 补读。
|
// 整理记录 downloadTaskId、只读资产/产物端点、偏好标签修订历史、列表带作品标题、事件 JSON 补读。
|
||||||
|
|
||||||
const hasPg = await pgAvailable();
|
const hasPg = await pgAvailable();
|
||||||
@@ -29,13 +29,17 @@ describe.skipIf(!hasPg)("E10 W4-A 集成端点", () => {
|
|||||||
await e?.close();
|
await e?.close();
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
it("POST /acquisition-plans:硬条件不满足 → 不提交并给出原因;重复评估只留一条候选记录", async () => {
|
it("download.plan:自动订阅硬条件不满足 → 无计划并给出原因;重复评估只留一条候选记录", async () => {
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
const r = await e.api("POST", "/acquisition-plans", { sourceReleaseId: target.r720, slotId: target.slotId });
|
const r = await e.api("POST", "/capabilities/download.plan/invoke", {
|
||||||
|
input: { sourceReleaseId: target.r720, slotId: target.slotId },
|
||||||
|
});
|
||||||
expect(r.status).toBe(200);
|
expect(r.status).toBe(200);
|
||||||
expect(r.body.data).toMatchObject({ submitted: false, plan: null, taskId: null });
|
const out = r.body.data.output ?? r.body.data;
|
||||||
expect(r.body.data.rejected.length).toBeGreaterThan(0);
|
expect(out.plan).toBeNull();
|
||||||
expect(r.body.data.rejected[0].slotId).toBe(target.slotId);
|
expect(out.slot.id).toBe(target.slotId);
|
||||||
|
expect(out.rejected.length).toBeGreaterThan(0);
|
||||||
|
expect(await e.db.selectFrom("download_task").select("id").execute()).toHaveLength(0);
|
||||||
}
|
}
|
||||||
const n = await sql<{ n: number }>`select count(*)::int as n from artifact
|
const n = await sql<{ n: number }>`select count(*)::int as n from artifact
|
||||||
where title = 'candidate_evaluation' and subject_id = ${target.slotId}
|
where title = 'candidate_evaluation' and subject_id = ${target.slotId}
|
||||||
@@ -53,23 +57,30 @@ describe.skipIf(!hasPg)("E10 W4-A 集成端点", () => {
|
|||||||
expect(c.reasons.length).toBeGreaterThan(0);
|
expect(c.reasons.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("POST /acquisition-plans:按作品选 Slot,走 download.plan → download.submit,qB 收到种子;整理记录带 downloadTaskId", async () => {
|
it("自动订阅 download.plan → download.submit 推进 Slot,qB 收到种子;整理记录带 downloadTaskId", async () => {
|
||||||
const r = await e.api("POST", "/acquisition-plans", {
|
const planned = await e.api("POST", "/capabilities/download.plan/invoke", {
|
||||||
sourceReleaseId: target.r1080,
|
input: { sourceReleaseId: target.r1080, slotId: target.slotId },
|
||||||
entityId: target.entityId,
|
|
||||||
note: "工作台手动",
|
|
||||||
});
|
});
|
||||||
expect(r.status).toBe(200);
|
expect(planned.status).toBe(200);
|
||||||
expect(r.body.data).toMatchObject({ submitted: true, slotId: target.slotId });
|
const out = planned.body.data.output ?? planned.body.data;
|
||||||
expect(r.body.data.plan.status).toMatch(/reserved|submitted/);
|
expect(out.plan).toMatchObject({ slotId: target.slotId, sourceReleaseId: target.r1080 });
|
||||||
expect(r.body.data.taskId).toBeTruthy();
|
const grant = await e.api("POST", "/automation-grants", {
|
||||||
|
action: "download.submit",
|
||||||
|
scope: { mediaEntityIds: [target.entityId] },
|
||||||
|
reason: "e10 自动订阅收录验证",
|
||||||
|
});
|
||||||
|
expect(grant.status).toBe(201);
|
||||||
|
const submitted = await e.api("POST", "/capabilities/download.submit/invoke", { input: { planId: out.plan.id } });
|
||||||
|
expect(submitted.status).toBe(200);
|
||||||
|
const accepted = submitted.body.data.output ?? submitted.body.data;
|
||||||
|
expect(accepted.planId).toBe(out.plan.id);
|
||||||
|
expect(accepted.taskId).toBeTruthy();
|
||||||
const audits = await e.db
|
const audits = await e.db
|
||||||
.selectFrom("audit_event")
|
.selectFrom("audit_event")
|
||||||
.select(["action", "target_id"])
|
.select(["action", "target_id"])
|
||||||
.where("action", "in", ["capability.invoke", "acquisition_plan.manual_submit"])
|
.where("action", "=", "capability.invoke")
|
||||||
.execute();
|
.execute();
|
||||||
expect(audits.filter((a) => a.target_id === "download.submit")).toHaveLength(1);
|
expect(audits.filter((a) => a.target_id === "download.submit")).toHaveLength(1);
|
||||||
expect(audits.some((a) => a.action === "acquisition_plan.manual_submit")).toBe(true);
|
|
||||||
|
|
||||||
await completeDownload(e, "Dune.Part.Two.2024.1080p.BluRay.x264-GRP");
|
await completeDownload(e, "Dune.Part.Two.2024.1080p.BluRay.x264-GRP");
|
||||||
const rec = await waitFor(
|
const rec = await waitFor(
|
||||||
@@ -104,6 +115,80 @@ describe.skipIf(!hasPg)("E10 W4-A 集成端点", () => {
|
|||||||
expect(slots.body.data.items[0].currentAsset).toMatchObject({ resolution: "1080p" });
|
expect(slots.body.data.items[0].currentAsset).toMatchObject({ resolution: "1080p" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("POST /acquisition-plans:没有订阅和 Slot 的作品也能独立手动下载", async () => {
|
||||||
|
const standalone = await e.db
|
||||||
|
.insertInto("media_entity")
|
||||||
|
.values({ kind: "movie", title: "独立手动电影", year: 2025, status: "active" })
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
const site = await e.db
|
||||||
|
.selectFrom("source_release")
|
||||||
|
.select("site_id")
|
||||||
|
.where("id", "=", target.r720)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
const release = await e.db
|
||||||
|
.insertInto("source_release")
|
||||||
|
.values({
|
||||||
|
site_id: site.site_id,
|
||||||
|
site_release_id: "880003",
|
||||||
|
title: "Manual.Standalone.2025.720p.WEB-DL.x264-GRP",
|
||||||
|
size_bytes: 300000,
|
||||||
|
media_entity_id: standalone.id,
|
||||||
|
resolution_status: "resolved",
|
||||||
|
raw: JSON.stringify({ _site: { hr: false } }),
|
||||||
|
})
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
const before = await e.db.selectFrom("subscription").select("id").execute();
|
||||||
|
const slotBefore = await e.db
|
||||||
|
.selectFrom("acquisition_slot")
|
||||||
|
.select(["current_asset_id", "revision"])
|
||||||
|
.where("id", "=", target.slotId)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
const r = await e.api("POST", "/acquisition-plans", {
|
||||||
|
sourceReleaseId: release.id,
|
||||||
|
entityId: standalone.id,
|
||||||
|
note: "工作台独立手动",
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.body.data).toMatchObject({
|
||||||
|
submitted: true,
|
||||||
|
slotId: null,
|
||||||
|
rejected: [],
|
||||||
|
plan: { slotId: null, evaluation: { manual: true, mediaEntityId: standalone.id } },
|
||||||
|
});
|
||||||
|
expect(r.body.data.taskId).toBeTruthy();
|
||||||
|
await waitFor(
|
||||||
|
async () =>
|
||||||
|
e.db
|
||||||
|
.selectFrom("download_task")
|
||||||
|
.selectAll()
|
||||||
|
.where("import_plan_id", "=", r.body.data.plan.id)
|
||||||
|
.executeTakeFirst(),
|
||||||
|
60000,
|
||||||
|
"独立手动下载已提交",
|
||||||
|
);
|
||||||
|
expect(await e.db.selectFrom("subscription").select("id").execute()).toEqual(before);
|
||||||
|
expect(
|
||||||
|
await e.db.selectFrom("acquisition_slot").select("id").where("media_entity_id", "=", standalone.id).execute(),
|
||||||
|
).toHaveLength(0);
|
||||||
|
expect(
|
||||||
|
await e.db
|
||||||
|
.selectFrom("acquisition_slot")
|
||||||
|
.select(["current_asset_id", "revision"])
|
||||||
|
.where("id", "=", target.slotId)
|
||||||
|
.executeTakeFirstOrThrow(),
|
||||||
|
).toEqual(slotBefore);
|
||||||
|
expect(
|
||||||
|
await e.db
|
||||||
|
.selectFrom("audit_event")
|
||||||
|
.select("id")
|
||||||
|
.where("action", "=", "acquisition_plan.manual_submit")
|
||||||
|
.where("target_id", "=", r.body.data.plan.id)
|
||||||
|
.executeTakeFirst(),
|
||||||
|
).toBeTruthy();
|
||||||
|
}, 90000);
|
||||||
|
|
||||||
it("GET /artifacts:列表与详情", async () => {
|
it("GET /artifacts:列表与详情", async () => {
|
||||||
const list = await e.api("GET", "/artifacts?kind=evidence");
|
const list = await e.api("GET", "/artifacts?kind=evidence");
|
||||||
expect(list.status).toBe(200);
|
expect(list.status).toBe(200);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CAPABILITY_IDS } from "@umt/contracts";
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import { delegateScript } from "./fixtures/scripts.ts";
|
import { delegateScript } from "./fixtures/scripts.ts";
|
||||||
import {
|
import {
|
||||||
@@ -45,8 +46,16 @@ describe.skipIf(!hasPg)("E2 Agent 委托闭环(fake 模型 + 真实能力/授权
|
|||||||
return r.body.data.id as string;
|
return r.body.data.id as string;
|
||||||
};
|
};
|
||||||
|
|
||||||
it("启动断言:41 个能力全部注册,并在能力目录里可见", async () => {
|
it("启动断言:合同中的能力全部注册,整理兜底不可重放,并在能力目录里可见", async () => {
|
||||||
expect(e.booted.ctx.capabilities.list()).toHaveLength(41);
|
expect(
|
||||||
|
e.booted.ctx.capabilities
|
||||||
|
.list()
|
||||||
|
.map((c) => c.descriptor.id)
|
||||||
|
.sort(),
|
||||||
|
).toEqual([...CAPABILITY_IDS].sort());
|
||||||
|
expect(
|
||||||
|
e.booted.ctx.capabilities.list().find((c) => c.descriptor.id === "metadata.resolve_organize")?.descriptor,
|
||||||
|
).toMatchObject({ kind: "execute", replay: "never" });
|
||||||
const r = await e.api("GET", "/capabilities");
|
const r = await e.api("GET", "/capabilities");
|
||||||
expect(r.body.data.items.map((c: { id: string }) => c.id).sort()).toEqual(
|
expect(r.body.data.items.map((c: { id: string }) => c.id).sort()).toEqual(
|
||||||
e.booted.ctx.capabilities
|
e.booted.ctx.capabilities
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createQbClient } from "@umt/adapters";
|
import { createQbClient } from "@umt/adapters";
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import type { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
|
import type { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
|
||||||
|
import { evaluateAcquisition } from "../../src/modules/acquisition-organize/services/acquisition.ts";
|
||||||
import { syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
|
import { syncDownloads } from "../../src/modules/acquisition-organize/services/downloads.ts";
|
||||||
import { torrentFor } from "./fixtures/modules.ts";
|
import { torrentFor } from "./fixtures/modules.ts";
|
||||||
import { createE2E, type E2E, pgAvailable, seedBase, waitFor } from "./harness.ts";
|
import { createE2E, type E2E, pgAvailable, seedBase, waitFor } from "./harness.ts";
|
||||||
@@ -81,9 +82,11 @@ describe.skipIf(!hasPg)("E8 AT24 标签移除 / 任务消失", () => {
|
|||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
const submit = async (k: K) => {
|
const submit = async (k: K) => {
|
||||||
const r = await e.api("POST", "/acquisition-plans", { sourceReleaseId: ids[k].releaseId });
|
const planned = await evaluateAcquisition(deps(), {
|
||||||
expect(r.status, JSON.stringify(r.body)).toBe(200);
|
sourceReleaseId: ids[k].releaseId,
|
||||||
expect(r.body.data.submitted).toBe(true);
|
acquisitionSlotId: ids[k].slotId,
|
||||||
|
});
|
||||||
|
expect(planned.plans, JSON.stringify(planned)).toHaveLength(1);
|
||||||
await waitFor(async () => e.qb.get(ids[k].hash), 60_000, `qB 收到 ${k}`);
|
await waitFor(async () => e.qb.get(ids[k].hash), 60_000, `qB 收到 ${k}`);
|
||||||
const row = await waitFor(
|
const row = await waitFor(
|
||||||
async () =>
|
async () =>
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ describe.skipIf(!hasPg)("E9 连续更新剧集:Slot 忙时到达的集全部入
|
|||||||
30_000,
|
30_000,
|
||||||
"订阅完成",
|
"订阅完成",
|
||||||
);
|
);
|
||||||
expect(sub.status_reason).toBe("slot_satisfied");
|
expect(sub.status_reason).toBe("download_accepted");
|
||||||
const slot = await e.db
|
const slot = await e.db
|
||||||
.selectFrom("acquisition_slot")
|
.selectFrom("acquisition_slot")
|
||||||
.selectAll()
|
.selectAll()
|
||||||
@@ -263,7 +263,7 @@ describe.skipIf(!hasPg)("E9 连续更新剧集:Slot 忙时到达的集全部入
|
|||||||
expect(slot.status).toBe("satisfied");
|
expect(slot.status).toBe("satisfied");
|
||||||
const hist = await e.api("GET", `/subscription-history?subscriptionId=${subId}`);
|
const hist = await e.api("GET", `/subscription-history?subscriptionId=${subId}`);
|
||||||
expect(hist.body.data.items).toHaveLength(1);
|
expect(hist.body.data.items).toHaveLength(1);
|
||||||
expect(hist.body.data.items[0]).toMatchObject({ reason: "slot_satisfied", mediaEntityId: entityId });
|
expect(hist.body.data.items[0]).toMatchObject({ reason: "download_accepted", mediaEntityId: entityId });
|
||||||
// 进度 API:三集已覆盖,总集数已知
|
// 进度 API:三集已覆盖,总集数已知
|
||||||
const prog = await e.api("GET", `/subscriptions/${subId}/progress`);
|
const prog = await e.api("GET", `/subscriptions/${subId}/progress`);
|
||||||
expect(prog.body.data.seasons[0]).toMatchObject({ episodeTotal: 3, totalKnown: true, complete: true });
|
expect(prog.body.data.seasons[0]).toMatchObject({ episodeTotal: 3, totalKnown: true, complete: true });
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||||
|
import { createFmHarness, type FmHarness, key, pgAvailable, seedEntity } from "./harness.ts";
|
||||||
|
|
||||||
|
const skip = !(await pgAvailable());
|
||||||
|
describe.skipIf(skip)("file manager in-place import", () => {
|
||||||
|
let h: FmHarness;
|
||||||
|
beforeAll(async () => {
|
||||||
|
h = await createFmHarness("in_place", {}, { runTransfers: false });
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await h?.close();
|
||||||
|
});
|
||||||
|
async function source(label: string, kind: "movie" | "series" = "movie") {
|
||||||
|
const title = `${label}-${randomUUID().slice(0, 8)}`;
|
||||||
|
const entity = await seedEntity(h.db, { title, kind, year: 2024 });
|
||||||
|
await mkdir(join(h.lib, title), { recursive: true });
|
||||||
|
const path = `${title}/${kind === "movie" ? "original.name.mkv" : "original.name.S03E04.mkv"}`;
|
||||||
|
await writeFile(join(h.lib, path), "untouched video");
|
||||||
|
return { entity, path, items: [{ root: h.libRoot, path, mediaEntityId: entity }] };
|
||||||
|
}
|
||||||
|
async function preview(items: unknown[], extra: Record<string, unknown> = {}) {
|
||||||
|
const payload = { items, action: "in_place", minSizeBytes: 0, scrapeAfterImport: false, ...extra };
|
||||||
|
const r = await h.api("POST", "/files/organize", { ...payload, idempotencyKey: key(), dryRun: true });
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
return { payload, data: r.body.data };
|
||||||
|
}
|
||||||
|
async function queue(items: unknown[], extra: Record<string, unknown> = {}) {
|
||||||
|
const r = await preview(items, extra);
|
||||||
|
expect(r.data.groups[0].error).toBeNull();
|
||||||
|
const accepted = await h.api("POST", "/files/organize", {
|
||||||
|
...r.payload,
|
||||||
|
idempotencyKey: key(),
|
||||||
|
dryRun: false,
|
||||||
|
planIds: r.data.groups.map((g: { planId: string }) => g.planId),
|
||||||
|
});
|
||||||
|
expect(accepted.status).toBe(200);
|
||||||
|
expect(accepted.body.data.groups[0].error).toBeNull();
|
||||||
|
return { ...r, recordId: accepted.body.data.recordIds[0] as string };
|
||||||
|
}
|
||||||
|
it("preserves movie and matched subtitle bytes, paths and mtimes; registers once and never transfers", async () => {
|
||||||
|
const s = await source("Movie");
|
||||||
|
const subtitle = s.path.replace(/\.mkv$/, ".chs.srt");
|
||||||
|
await writeFile(join(h.lib, subtitle), "untouched subtitle");
|
||||||
|
const before = await Promise.all([s.path, subtitle].map((p) => stat(join(h.lib, p))));
|
||||||
|
const r = await queue([{ root: h.libRoot, path: s.path.split("/")[0]!, mediaEntityId: s.entity }], {
|
||||||
|
naming: "template",
|
||||||
|
onExisting: "replace_with_verify",
|
||||||
|
});
|
||||||
|
expect(r.data.groups[0].files).toHaveLength(2);
|
||||||
|
expect(r.data.groups[0].files.every((f: { source: string; target: string }) => f.source === f.target)).toBe(true);
|
||||||
|
expect((await executeRecord(h.module.deps().ao, r.recordId)).status).toBe("succeeded");
|
||||||
|
expect((await executeRecord(h.module.deps().ao, r.recordId)).status).toBe("succeeded");
|
||||||
|
expect(await readFile(join(h.lib, s.path), "utf8")).toBe("untouched video");
|
||||||
|
expect(await readFile(join(h.lib, subtitle), "utf8")).toBe("untouched subtitle");
|
||||||
|
const after = await Promise.all([s.path, subtitle].map((p) => stat(join(h.lib, p))));
|
||||||
|
expect(after.map((f) => [f.size, f.mtimeMs, f.ino])).toEqual(before.map((f) => [f.size, f.mtimeMs, f.ino]));
|
||||||
|
expect(await readdir(join(h.lib, s.path.split("/")[0]!))).toEqual(["original.name.chs.srt", "original.name.mkv"]);
|
||||||
|
const assets = await h.db.selectFrom("media_asset").selectAll().where("media_entity_id", "=", s.entity).execute();
|
||||||
|
expect(assets).toHaveLength(2);
|
||||||
|
expect(assets.find((a) => a.kind === "subtitle")?.parent_asset_id).toBe(assets.find((a) => a.kind === "video")?.id);
|
||||||
|
const ops = await h.db
|
||||||
|
.selectFrom("asset_operation")
|
||||||
|
.select(["kind", "phase"])
|
||||||
|
.where("organization_record_id", "=", r.recordId)
|
||||||
|
.orderBy("ordinal")
|
||||||
|
.execute();
|
||||||
|
expect(ops).toEqual([
|
||||||
|
{ kind: "register", phase: "verify" },
|
||||||
|
{ kind: "register", phase: "verify" },
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
await h.db.selectFrom("path_binding").select("id").where("media_entity_id", "=", s.entity).execute(),
|
||||||
|
).toEqual([]);
|
||||||
|
const again = await queue(s.items);
|
||||||
|
await executeRecord(h.module.deps().ao, again.recordId);
|
||||||
|
expect(
|
||||||
|
await h.db.selectFrom("media_asset").select("id").where("media_entity_id", "=", s.entity).execute(),
|
||||||
|
).toHaveLength(2);
|
||||||
|
// 文件管理装配不挂整理记录路由:直接查记录表
|
||||||
|
const history = await h.db
|
||||||
|
.selectFrom("organization_record")
|
||||||
|
.select("action")
|
||||||
|
.where("media_entity_id", "=", s.entity)
|
||||||
|
.execute();
|
||||||
|
expect(history.some((item) => item.action === "in_place")).toBe(true);
|
||||||
|
});
|
||||||
|
it("registers TV episodes in their existing directory and honors manual season/episode", async () => {
|
||||||
|
const s = await source("TV", "series");
|
||||||
|
const r = await queue(s.items.map((i) => ({ ...i, season: 2, episodes: [7] })));
|
||||||
|
expect(r.data.groups[0].files[0].target).toBe(join(h.lib, s.path));
|
||||||
|
expect((await executeRecord(h.module.deps().ao, r.recordId)).status).toBe("succeeded");
|
||||||
|
const units = await h.db
|
||||||
|
.selectFrom("content_unit")
|
||||||
|
.select(["season_number", "episode_number"])
|
||||||
|
.where("media_entity_id", "=", s.entity)
|
||||||
|
.execute();
|
||||||
|
expect(units.some((u) => u.season_number === 2 && u.episode_number === 7)).toBe(true);
|
||||||
|
});
|
||||||
|
it("scrapes only the newly registered file, retaining its organization association on resume", async () => {
|
||||||
|
const s = await source("Scrape");
|
||||||
|
await h.db
|
||||||
|
.insertInto("external_identity")
|
||||||
|
.values({
|
||||||
|
media_entity_id: s.entity,
|
||||||
|
provider: "tmdb",
|
||||||
|
entity_type: "movie",
|
||||||
|
external_id: "123456",
|
||||||
|
status: "accepted",
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
await writeFile(join(h.lib, s.path.split("/")[0]!, "Unrelated.mkv"), "unrelated video");
|
||||||
|
const r = await queue(s.items, { scrapeAfterImport: true });
|
||||||
|
await executeRecord(h.module.deps().ao, r.recordId);
|
||||||
|
await executeRecord(h.module.deps().ao, r.recordId);
|
||||||
|
const scrapes = await h.db
|
||||||
|
.selectFrom("scrape_record")
|
||||||
|
.select(["path", "organization_record_id"])
|
||||||
|
.where("organization_record_id", "=", r.recordId)
|
||||||
|
.execute();
|
||||||
|
expect(scrapes).toEqual([{ path: s.path, organization_record_id: r.recordId }]);
|
||||||
|
expect(await readFile(join(h.lib, s.path.split("/")[0]!, "Unrelated.mkv"), "utf8")).toBe("unrelated video");
|
||||||
|
});
|
||||||
|
it("rejects an unregistered download directory without submitting a task", async () => {
|
||||||
|
const entity = await seedEntity(h.db, { kind: "movie", title: "Outside", year: 2024 });
|
||||||
|
await writeFile(join(h.dl, "outside.mkv"), "video");
|
||||||
|
const r = await h.api("POST", "/files/organize", {
|
||||||
|
items: [{ root: h.dlRoot, path: "outside.mkv", mediaEntityId: entity }],
|
||||||
|
action: "in_place",
|
||||||
|
minSizeBytes: 0,
|
||||||
|
idempotencyKey: key(),
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(400);
|
||||||
|
expect(await h.db.selectFrom("media_asset").select("id").where("media_entity_id", "=", entity).execute()).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("does not bind a file to another work or import changed files after queue acceptance", async () => {
|
||||||
|
const s = await source("Changed");
|
||||||
|
const r = await queue(s.items);
|
||||||
|
await writeFile(join(h.lib, s.path), "changed video longer");
|
||||||
|
expect((await executeRecord(h.module.deps().ao, r.recordId)).status).toBe("failed");
|
||||||
|
expect(await h.db.selectFrom("media_asset").select("id").where("media_entity_id", "=", s.entity).execute()).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const original = await source("Owned");
|
||||||
|
const a = await queue(original.items);
|
||||||
|
await executeRecord(h.module.deps().ao, a.recordId);
|
||||||
|
const other = await seedEntity(h.db, { kind: "movie", title: "Other", year: 2024 });
|
||||||
|
const conflict = await preview([{ root: h.libRoot, path: original.path, mediaEntityId: other }]);
|
||||||
|
expect(conflict.data.groups[0].error).not.toBeNull();
|
||||||
|
expect(await h.db.selectFrom("media_asset").select("id").where("media_entity_id", "=", other).execute()).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("registers read-only library roots without writing metadata", async () => {
|
||||||
|
const s = await source("ReadOnly");
|
||||||
|
const rootId = h.libRoot.slice(4);
|
||||||
|
await h.db.updateTable("library_root").set({ management_mode: "register_only" }).where("id", "=", rootId).execute();
|
||||||
|
try {
|
||||||
|
const r = await queue(s.items, { scrapeAfterImport: true });
|
||||||
|
expect((await executeRecord(h.module.deps().ao, r.recordId)).status).toBe("succeeded");
|
||||||
|
expect(
|
||||||
|
await h.db.selectFrom("scrape_record").select("id").where("organization_record_id", "=", r.recordId).execute(),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(await readdir(join(h.lib, s.path.split("/")[0]!))).toEqual(["original.name.mkv"]);
|
||||||
|
} finally {
|
||||||
|
await h.db.updateTable("library_root").set({ management_mode: "managed" }).where("id", "=", rootId).execute();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { access, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||||
|
import { createFmHarness, type FmHarness, key, pgAvailable, seedEntity } from "./harness.ts";
|
||||||
|
|
||||||
|
const skip = !(await pgAvailable());
|
||||||
|
describe.skipIf(skip)("manual organize history and per-import scraping", () => {
|
||||||
|
let h: FmHarness;
|
||||||
|
beforeAll(async () => {
|
||||||
|
h = await createFmHarness("manual_options", {}, { runTransfers: false });
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await h?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function source(label: string, local = false) {
|
||||||
|
const title = `${label} ${randomUUID().slice(0, 8)}`;
|
||||||
|
const entity = await seedEntity(h.db, { kind: "movie", title, year: 2024 });
|
||||||
|
if (local) {
|
||||||
|
await h.db
|
||||||
|
.updateTable("media_entity")
|
||||||
|
.set({
|
||||||
|
metadata: JSON.stringify({
|
||||||
|
overview: { value: "Local synopsis", source: "web", sources: [], locked: false },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.where("id", "=", entity)
|
||||||
|
.execute();
|
||||||
|
} else {
|
||||||
|
await h.db
|
||||||
|
.insertInto("external_identity")
|
||||||
|
.values({
|
||||||
|
media_entity_id: entity,
|
||||||
|
provider: "tmdb",
|
||||||
|
entity_type: "movie",
|
||||||
|
external_id: String(Math.floor(Math.random() * 1_000_000)),
|
||||||
|
status: "accepted",
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
const path = `${title.replaceAll(" ", ".")}.2024.1080p.mkv`;
|
||||||
|
await writeFile(join(h.dl, path), `video-${title}`);
|
||||||
|
return { entity, path, items: [{ root: h.dlRoot, path, mediaEntityId: entity }] };
|
||||||
|
}
|
||||||
|
async function organize(items: unknown[], extra: Record<string, unknown> = {}) {
|
||||||
|
const payload = { action: "copy", items, minSizeBytes: 0, ...extra };
|
||||||
|
const dry = await h.api("POST", "/files/organize", { ...payload, idempotencyKey: key(), dryRun: true });
|
||||||
|
expect(dry.status).toBe(200);
|
||||||
|
expect(dry.body.data.groups[0].error).toBeNull();
|
||||||
|
const exec = await h.api("POST", "/files/organize", {
|
||||||
|
...payload,
|
||||||
|
idempotencyKey: key(),
|
||||||
|
dryRun: false,
|
||||||
|
planIds: dry.body.data.groups.map((g: { planId: string }) => g.planId),
|
||||||
|
});
|
||||||
|
expect(exec.status).toBe(200);
|
||||||
|
expect(exec.body.data.groups[0].error).toBeNull();
|
||||||
|
return { dry: dry.body.data, exec: exec.body.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("successful move reorganizes the target; failed move and copy retain their source", async () => {
|
||||||
|
const s = await source("Moved");
|
||||||
|
const r = await organize(s.items, { action: "move", scrapeAfterImport: false });
|
||||||
|
const rid = r.exec.recordIds[0];
|
||||||
|
expect((await executeRecord(h.module.deps().ao, rid)).status).toBe("succeeded");
|
||||||
|
await expect(access(join(h.dl, s.path))).rejects.toThrow();
|
||||||
|
const refs = await h.api("GET", `/files/record-sources?recordId=${rid}`);
|
||||||
|
expect(refs.status).toBe(200);
|
||||||
|
expect(refs.body.data.unresolved).toEqual([]);
|
||||||
|
expect(refs.body.data.items).toEqual([
|
||||||
|
{ root: h.libRoot, path: r.dry.groups[0].files[0].target.slice(h.lib.length + 1) },
|
||||||
|
]);
|
||||||
|
expect(await readFile(r.dry.groups[0].files[0].target, "utf8")).toContain("video-Moved");
|
||||||
|
const c = await source("Copied");
|
||||||
|
const copy = await organize(c.items, { scrapeAfterImport: false });
|
||||||
|
const crid = copy.exec.recordIds[0];
|
||||||
|
await executeRecord(h.module.deps().ao, crid);
|
||||||
|
expect((await h.api("GET", `/files/record-sources?recordId=${crid}`)).body.data.items).toEqual([
|
||||||
|
{ root: h.dlRoot, path: c.path },
|
||||||
|
]);
|
||||||
|
await h.db
|
||||||
|
.updateTable("organization_record")
|
||||||
|
.set({ action: "move", status: "failed" })
|
||||||
|
.where("id", "=", crid)
|
||||||
|
.execute();
|
||||||
|
expect((await h.api("GET", `/files/record-sources?recordId=${crid}`)).body.data.items).toEqual([
|
||||||
|
{ root: h.dlRoot, path: c.path },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("successful-history skip includes matched sidecars and does not enqueue work; latest failure does not hide success", async () => {
|
||||||
|
const s = await source("History");
|
||||||
|
const sidecar = s.path.replace(/\.mkv$/, ".srt");
|
||||||
|
await writeFile(join(h.dl, sidecar), "subtitle");
|
||||||
|
const first = await organize(s.items, { scrapeAfterImport: false });
|
||||||
|
await executeRecord(h.module.deps().ao, first.exec.recordIds[0]);
|
||||||
|
const old = await h.db
|
||||||
|
.selectFrom("organization_record")
|
||||||
|
.selectAll()
|
||||||
|
.where("id", "=", first.exec.recordIds[0])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
const laterId = randomUUID();
|
||||||
|
await h.db
|
||||||
|
.insertInto("organization_record")
|
||||||
|
.values({
|
||||||
|
...old,
|
||||||
|
id: laterId,
|
||||||
|
change_seq: undefined,
|
||||||
|
status: "failed",
|
||||||
|
reason: "later_failure",
|
||||||
|
created_at: new Date(Date.now() + 1000),
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
const main = await h.db
|
||||||
|
.selectFrom("organization_file_result")
|
||||||
|
.selectAll()
|
||||||
|
.where("organization_record_id", "=", old.id)
|
||||||
|
.where("role", "=", "main")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
await h.db
|
||||||
|
.insertInto("organization_file_result")
|
||||||
|
.values({ ...main, id: randomUUID(), organization_record_id: laterId, status: "failed" })
|
||||||
|
.execute();
|
||||||
|
const identify = await h.api("POST", "/files/identify", {
|
||||||
|
items: [{ root: h.dlRoot, path: s.path }],
|
||||||
|
minSizeBytes: 0,
|
||||||
|
});
|
||||||
|
expect(identify.body.data.items[0].records).toMatchObject({ successfulCount: 1, lastStatus: "failed" });
|
||||||
|
const before = h.engine.enqueued.filter((x) => x.task === "verifyAndImport").length;
|
||||||
|
const second = await organize(s.items, { skipSuccessfulRecords: true, scrapeAfterImport: false });
|
||||||
|
expect(second.dry.groups[0].files).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
second.dry.groups[0].files.every(
|
||||||
|
(f: { action: string; skipReason: string }) => f.action === "skip" && f.skipReason === "successful_record",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(second.exec.recordIds).toEqual([]);
|
||||||
|
expect(h.engine.enqueued.filter((x) => x.task === "verifyAndImport")).toHaveLength(before);
|
||||||
|
const normal = await h.api("POST", "/files/organize", {
|
||||||
|
idempotencyKey: key(),
|
||||||
|
action: "copy",
|
||||||
|
items: s.items,
|
||||||
|
minSizeBytes: 0,
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
normal.body.data.groups[0].files.some((f: { skipReason: string }) => f.skipReason === "successful_record"),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["failed", "partial"])("%s records do not count as successful histories", async (status) => {
|
||||||
|
const s = await source(status);
|
||||||
|
const first = await organize(s.items, { scrapeAfterImport: false });
|
||||||
|
await executeRecord(h.module.deps().ao, first.exec.recordIds[0]);
|
||||||
|
await h.db
|
||||||
|
.updateTable("organization_record")
|
||||||
|
.set({ status: status as "failed" | "partial" })
|
||||||
|
.where("id", "=", first.exec.recordIds[0])
|
||||||
|
.execute();
|
||||||
|
const dry = await h.api("POST", "/files/organize", {
|
||||||
|
idempotencyKey: key(),
|
||||||
|
action: "copy",
|
||||||
|
items: s.items,
|
||||||
|
minSizeBytes: 0,
|
||||||
|
dryRun: true,
|
||||||
|
skipSuccessfulRecords: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
dry.body.data.groups[0].files.some((f: { skipReason: string }) => f.skipReason === "successful_record"),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mixed selection skips successful files while organizing the remaining video", async () => {
|
||||||
|
const s = await source("Mixed");
|
||||||
|
const first = await organize(s.items, { scrapeAfterImport: false });
|
||||||
|
await executeRecord(h.module.deps().ao, first.exec.recordIds[0]);
|
||||||
|
const newPath = s.path.replace("1080p", "2160p");
|
||||||
|
await writeFile(join(h.dl, newPath), "different new video version");
|
||||||
|
const result = await organize([...s.items, { root: h.dlRoot, path: newPath, mediaEntityId: s.entity }], {
|
||||||
|
skipSuccessfulRecords: true,
|
||||||
|
scrapeAfterImport: false,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
result.dry.groups[0].files.filter((f: { skipReason: string }) => f.skipReason === "successful_record"),
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(result.exec.recordIds).toHaveLength(1);
|
||||||
|
expect((await executeRecord(h.module.deps().ao, result.exec.recordIds[0])).status).toBe("succeeded");
|
||||||
|
const files = await h.db
|
||||||
|
.selectFrom("organization_file_result")
|
||||||
|
.select("source_path")
|
||||||
|
.where("organization_record_id", "=", result.exec.recordIds[0])
|
||||||
|
.execute();
|
||||||
|
expect(files).toEqual([{ source_path: join(h.dl, newPath) }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a succeeded record with an unsuccessful main file does not count as successful", async () => {
|
||||||
|
const s = await source("MainFailed");
|
||||||
|
const first = await organize(s.items, { scrapeAfterImport: false });
|
||||||
|
await executeRecord(h.module.deps().ao, first.exec.recordIds[0]);
|
||||||
|
await h.db
|
||||||
|
.updateTable("organization_file_result")
|
||||||
|
.set({ status: "failed" })
|
||||||
|
.where("organization_record_id", "=", first.exec.recordIds[0])
|
||||||
|
.where("role", "=", "main")
|
||||||
|
.execute();
|
||||||
|
const dry = await h.api("POST", "/files/organize", {
|
||||||
|
idempotencyKey: key(),
|
||||||
|
action: "copy",
|
||||||
|
items: s.items,
|
||||||
|
minSizeBytes: 0,
|
||||||
|
dryRun: true,
|
||||||
|
skipSuccessfulRecords: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
dry.body.data.groups[0].files.some((f: { skipReason: string }) => f.skipReason === "successful_record"),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([false, true])(
|
||||||
|
"TMDB per-import scrape=%s overrides the opposite global setting and survives replay",
|
||||||
|
async (enabled) => {
|
||||||
|
await h.db
|
||||||
|
.insertInto("settings")
|
||||||
|
.values({ key: "organize", value: JSON.stringify({ scrapeAfterImport: !enabled }) })
|
||||||
|
.onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify({ scrapeAfterImport: !enabled }) }))
|
||||||
|
.execute();
|
||||||
|
const s = await source("TMDB");
|
||||||
|
const r = await organize(s.items, { scrapeAfterImport: enabled });
|
||||||
|
const rid = r.exec.recordIds[0];
|
||||||
|
expect((await executeRecord(h.module.deps().ao, rid)).status).toBe("succeeded");
|
||||||
|
const records = () =>
|
||||||
|
h.db.selectFrom("scrape_record").selectAll().where("media_entity_id", "=", s.entity).execute();
|
||||||
|
const scraped = await records();
|
||||||
|
expect(scraped).toHaveLength(enabled ? 1 : 0);
|
||||||
|
const byJob = await h.api("GET", `/files/scrape-records?organizationRecordId=${rid}`);
|
||||||
|
expect(byJob.status).toBe(200);
|
||||||
|
expect(byJob.body.data.records.map((r: { id: string }) => r.id)).toEqual(scraped.map((r) => r.id));
|
||||||
|
if (enabled) {
|
||||||
|
const id = scraped[0]!.id;
|
||||||
|
const oldQuery = await h.api("GET", `/files/scrape-records?ids=${id}`);
|
||||||
|
expect(oldQuery.body.data.records[0].id).toBe(id);
|
||||||
|
const both = await h.api("GET", `/files/scrape-records?ids=${id}&organizationRecordId=${rid}`);
|
||||||
|
expect(both.body.data.records[0].id).toBe(id);
|
||||||
|
const differentJob = await h.api("GET", `/files/scrape-records?ids=${id}&organizationRecordId=${randomUUID()}`);
|
||||||
|
expect(differentJob.body.data.records).toEqual([]);
|
||||||
|
}
|
||||||
|
await executeRecord(h.module.deps().ao, rid);
|
||||||
|
expect(await records()).toHaveLength(enabled ? 1 : 0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([false, true])("Local per-import scrape=%s is respected", async (enabled) => {
|
||||||
|
await h.db
|
||||||
|
.insertInto("settings")
|
||||||
|
.values({ key: "organize", value: JSON.stringify({ scrapeAfterImport: false }) })
|
||||||
|
.onConflict((oc) => oc.column("key").doUpdateSet({ value: JSON.stringify({ scrapeAfterImport: false }) }))
|
||||||
|
.execute();
|
||||||
|
const s = await source("Local", true);
|
||||||
|
const r = await organize(s.items, { scrapeAfterImport: enabled });
|
||||||
|
const rid = r.exec.recordIds[0];
|
||||||
|
const record = await h.db
|
||||||
|
.selectFrom("organization_record")
|
||||||
|
.select("plan")
|
||||||
|
.where("id", "=", rid)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
expect(record.plan).toMatchObject({ metadataMode: "local", scrapeAfterImport: enabled });
|
||||||
|
await executeRecord(h.module.deps().ao, rid);
|
||||||
|
expect(
|
||||||
|
await h.db.selectFrom("scrape_record").select("id").where("media_entity_id", "=", s.entity).execute(),
|
||||||
|
).toHaveLength(enabled ? 1 : 0);
|
||||||
|
});
|
||||||
|
it("scrape-records requires an ID list or an organization record", async () => {
|
||||||
|
expect((await h.api("GET", "/files/scrape-records")).status).toBe(422);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { executeRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
|
||||||
|
import { createFmHarness, type FmHarness, key, pgAvailable, seedEntity } from "./harness.ts";
|
||||||
|
|
||||||
|
const skip = !(await pgAvailable());
|
||||||
|
|
||||||
|
describe.skipIf(skip)("files 整理人工集号校验", () => {
|
||||||
|
let h: FmHarness;
|
||||||
|
let show: string;
|
||||||
|
let film: string;
|
||||||
|
const path = "Some.Show.S01E02.mkv";
|
||||||
|
const bytes = "x".repeat(64);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
h = await createFmHarness("overrides");
|
||||||
|
show = await seedEntity(h.db, { kind: "series", title: "Some Show", year: 2022 });
|
||||||
|
film = await seedEntity(h.db, { kind: "movie", title: "Film", year: 2021 });
|
||||||
|
await writeFile(join(h.dl, path), bytes);
|
||||||
|
await mkdir(join(h.dl, "Season"));
|
||||||
|
await writeFile(join(h.dl, "Season", path), bytes);
|
||||||
|
});
|
||||||
|
afterAll(async () => h?.close());
|
||||||
|
|
||||||
|
const facts = async () => ({
|
||||||
|
entities: await h.db.selectFrom("media_entity").select("id").orderBy("id").execute(),
|
||||||
|
identities: await h.db.selectFrom("external_identity").select("id").orderBy("id").execute(),
|
||||||
|
plans: await h.db.selectFrom("artifact").select("id").where("title", "=", "files_plan").orderBy("id").execute(),
|
||||||
|
records: await h.db.selectFrom("organization_record").select("id").orderBy("id").execute(),
|
||||||
|
tasks: await h.db.selectFrom("task").select("id").orderBy("id").execute(),
|
||||||
|
jobs: h.engine.enqueued.length,
|
||||||
|
library: await readdir(h.lib, { recursive: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([true, false])("非连续集号在 dryRun=%s 时整批拒绝,不创建作品、计划、记录或文件", async (dryRun) => {
|
||||||
|
const before = await facts();
|
||||||
|
const r = await h.api("POST", "/files/organize", {
|
||||||
|
idempotencyKey: key(),
|
||||||
|
action: "copy",
|
||||||
|
dryRun,
|
||||||
|
items: [
|
||||||
|
// 排在坏输入前的远端候选也不能创建作品或发起 TMDB 请求。
|
||||||
|
{ root: h.dlRoot, path, external: { provider: "tmdb", externalId: "136315", kind: "tv" } },
|
||||||
|
{ root: h.dlRoot, path, mediaEntityId: show, season: 2, episodes: [5, 7] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(400);
|
||||||
|
expect(r.body.error.message).toMatch(/bad_request:.*集号 5,7 不连续/);
|
||||||
|
expect(await facts()).toEqual(before);
|
||||||
|
expect(await readFile(join(h.dl, path), "utf8")).toBe(bytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("远端剧集候选的不连续集号也在建档前拒绝", async () => {
|
||||||
|
const before = await facts();
|
||||||
|
const r = await h.api("POST", "/files/organize", {
|
||||||
|
idempotencyKey: key(),
|
||||||
|
action: "copy",
|
||||||
|
dryRun: true,
|
||||||
|
items: [
|
||||||
|
{ root: h.dlRoot, path, external: { provider: "tmdb", externalId: "136315", kind: "tv" }, episodes: [2, 4] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(400);
|
||||||
|
expect(r.body.error.message).toContain("不连续");
|
||||||
|
expect(await facts()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["local", "external"])("%s 剧集目录指定集号时明确拒绝,不保存计划", async (source) => {
|
||||||
|
const before = await facts();
|
||||||
|
const entity =
|
||||||
|
source === "local"
|
||||||
|
? { mediaEntityId: show }
|
||||||
|
: { external: { provider: "tmdb", externalId: "136315", kind: "tv" } };
|
||||||
|
const r = await h.api("POST", "/files/organize", {
|
||||||
|
idempotencyKey: key(),
|
||||||
|
action: "copy",
|
||||||
|
dryRun: true,
|
||||||
|
items: [{ root: h.dlRoot, path: "Season", ...entity, season: 1, episodes: [2] }],
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(400);
|
||||||
|
expect(r.body.error.message).toContain("目录项不能指定集号");
|
||||||
|
expect(await facts()).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("连续人工季集覆盖原解析,预览与执行路径一致", async () => {
|
||||||
|
const items = [{ root: h.dlRoot, path, mediaEntityId: show, season: 2, episodes: [4, 3, 4] }];
|
||||||
|
const dry = await h.api("POST", "/files/organize", { idempotencyKey: key(), action: "copy", dryRun: true, items });
|
||||||
|
expect(dry.status).toBe(200);
|
||||||
|
const group = dry.body.data.groups[0];
|
||||||
|
expect(group.error).toBeNull();
|
||||||
|
expect(group.warnings).toEqual([]);
|
||||||
|
expect(group.files).toHaveLength(1);
|
||||||
|
expect(group.files[0].target).toMatch(/Season 2\/Some Show - S02E03-E04 - 第 3-4 集\.mkv$/);
|
||||||
|
const exec = await h.api("POST", "/files/organize", {
|
||||||
|
idempotencyKey: key(),
|
||||||
|
action: "copy",
|
||||||
|
dryRun: false,
|
||||||
|
items,
|
||||||
|
planIds: [group.planId],
|
||||||
|
});
|
||||||
|
expect(exec.status).toBe(200);
|
||||||
|
expect(exec.body.data.groups[0].error).toBeNull();
|
||||||
|
expect(exec.body.data.groups[0].files).toEqual(group.files);
|
||||||
|
expect(exec.body.data.recordIds).toHaveLength(1);
|
||||||
|
const result = await executeRecord(h.module.deps().ao, exec.body.data.recordIds[0]);
|
||||||
|
expect(result.status).toBe("succeeded");
|
||||||
|
expect(await readFile(group.files[0].target, "utf8")).toBe(bytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("电影仍忽略季集覆写并提示", async () => {
|
||||||
|
const r = await h.api("POST", "/files/organize", {
|
||||||
|
idempotencyKey: key(),
|
||||||
|
action: "copy",
|
||||||
|
dryRun: true,
|
||||||
|
items: [{ root: h.dlRoot, path, mediaEntityId: film, season: 1, episodes: [2, 4] }],
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(200);
|
||||||
|
expect(r.body.data.groups[0].warnings).toEqual([expect.stringMatching(/电影不适用季\/集覆写/)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdir, rename, stat, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createQbClient } from "@umt/adapters";
|
||||||
|
import type { OrganizeSourceConfig } from "@umt/contracts";
|
||||||
|
import { createMockQb, type MockQb } from "@umt/testkit";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
deleteOrganizeSource,
|
||||||
|
listOrganizeSources,
|
||||||
|
saveOrganizeSource,
|
||||||
|
} from "../../src/modules/file-manager/organize-sources.ts";
|
||||||
|
import { advanceSnapshot, scanOrganizeSource, scanStock } from "../../src/modules/file-manager/source-monitor.ts";
|
||||||
|
import { createFmHarness, type FmHarness, pgAvailable, seedDownload, seedEntity } from "./harness.ts";
|
||||||
|
|
||||||
|
const fact = { size: 100, mtimeMs: 1000 };
|
||||||
|
describe("source snapshot observations", () => {
|
||||||
|
it("baselines historical files and requires two stable observations for new and changed files", () => {
|
||||||
|
const baseline = advanceSnapshot(null, { "old.mkv": fact });
|
||||||
|
expect(baseline.eligible).toEqual([]);
|
||||||
|
const added = advanceSnapshot(baseline.snapshot, { "old.mkv": fact, "new.mkv": fact });
|
||||||
|
expect(added.eligible).toEqual([]);
|
||||||
|
const stable = advanceSnapshot(added.snapshot, { "old.mkv": fact, "new.mkv": fact });
|
||||||
|
expect(stable.eligible).toEqual(["new.mkv"]);
|
||||||
|
const changed = advanceSnapshot(stable.snapshot, { "old.mkv": { ...fact, size: 200 }, "new.mkv": fact });
|
||||||
|
expect(changed.eligible).not.toContain("old.mkv");
|
||||||
|
expect(advanceSnapshot(changed.snapshot, { "old.mkv": { ...fact, size: 200 } }).eligible).toEqual(["old.mkv"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const skip = !(await pgAvailable());
|
||||||
|
describe.skipIf(skip)("persistent source monitoring", () => {
|
||||||
|
let h: FmHarness | undefined;
|
||||||
|
let qb: MockQb | undefined;
|
||||||
|
afterEach(async () => {
|
||||||
|
await qb?.close();
|
||||||
|
qb = undefined;
|
||||||
|
await h?.close();
|
||||||
|
h = undefined;
|
||||||
|
});
|
||||||
|
const setup = async () => {
|
||||||
|
qb = await createMockQb();
|
||||||
|
h = await createFmHarness("source_monitor", {
|
||||||
|
ao: {
|
||||||
|
downloader: async (inst) =>
|
||||||
|
createQbClient({ instanceId: inst.id, baseUrl: qb!.url, username: "admin", password: "adminadmin" }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const config: Omit<OrganizeSourceConfig, "id"> = {
|
||||||
|
name: "来源",
|
||||||
|
enabled: true,
|
||||||
|
source: { root: h.dlRoot, path: "" },
|
||||||
|
target: { root: h.libRoot, path: "" },
|
||||||
|
mediaType: "all",
|
||||||
|
category: null,
|
||||||
|
action: "copy",
|
||||||
|
trigger: "snapshot",
|
||||||
|
downloaderInstanceId: null,
|
||||||
|
tag: null,
|
||||||
|
intervalSec: 60,
|
||||||
|
};
|
||||||
|
const id = randomUUID();
|
||||||
|
await saveOrganizeSource(h.module.deps(), id, config);
|
||||||
|
return { h, id, config, qb };
|
||||||
|
};
|
||||||
|
it("first scan persists stock baseline without records; stock never changes baseline", async () => {
|
||||||
|
const { h, id } = await setup();
|
||||||
|
await writeFile(join(h.dl, "old.mkv"), "old video content");
|
||||||
|
const result = await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||||
|
expect(result).toMatchObject({ baseline: true, queued: 0 });
|
||||||
|
const before = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
|
||||||
|
expect(before.baseline_at).not.toBeNull();
|
||||||
|
await writeFile(join(h.dl, "new.mkv"), "new video content");
|
||||||
|
const stock = await scanStock(h.module.deps(), id);
|
||||||
|
expect(stock.items.map((f) => f.path).sort()).toEqual(["new.mkv", "old.mkv"]);
|
||||||
|
const after = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
|
||||||
|
expect(after).toEqual(before);
|
||||||
|
expect(await h.db.selectFrom("organization_record").select("id").execute()).toEqual([]);
|
||||||
|
expect((await listOrganizeSources(h.module.deps())).items[0]?.fileCount).toBe(1);
|
||||||
|
});
|
||||||
|
it("unavailable source preserves baseline and retry does not import historical files", async () => {
|
||||||
|
const { h, id } = await setup();
|
||||||
|
await writeFile(join(h.dl, "old.mkv"), "old video content");
|
||||||
|
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||||
|
const before = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
|
||||||
|
await rename(h.dl, `${h.dl}-offline`);
|
||||||
|
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||||
|
const failed = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
|
||||||
|
expect(failed.snapshot).toEqual(before.snapshot);
|
||||||
|
expect(failed.baseline_at).toEqual(before.baseline_at);
|
||||||
|
expect(failed.last_error).not.toBeNull();
|
||||||
|
await rename(`${h.dl}-offline`, h.dl);
|
||||||
|
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({
|
||||||
|
baseline: false,
|
||||||
|
eligible: 0,
|
||||||
|
queued: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("an identified stable new file plans and queues import once", async () => {
|
||||||
|
const { h, id, config } = await setup();
|
||||||
|
await saveOrganizeSource(h.module.deps(), id, { ...config, category: "欧美电影" });
|
||||||
|
const entityId = await seedEntity(h.db, { kind: "movie", title: "Snapshot Film", year: 2024 });
|
||||||
|
await h.db
|
||||||
|
.insertInto("external_identity")
|
||||||
|
.values({
|
||||||
|
media_entity_id: entityId,
|
||||||
|
provider: "tmdb",
|
||||||
|
external_id: "123456",
|
||||||
|
status: "accepted",
|
||||||
|
entity_type: "movie",
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||||
|
await writeFile(join(h.dl, "Snapshot.Film.2024.tmdb-123456.1080p.BluRay.mkv"), "video content for snapshot import");
|
||||||
|
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 0, queued: 0 });
|
||||||
|
const stableResult = await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||||
|
expect(stableResult).toMatchObject({ eligible: 1, queued: 1 });
|
||||||
|
const imports = h.engine.enqueued.filter((e) => e.task === "verifyAndImport");
|
||||||
|
expect(imports).toHaveLength(1);
|
||||||
|
expect(imports[0]?.input).toMatchObject({ recordId: expect.any(String) });
|
||||||
|
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||||
|
expect(h.engine.enqueued.filter((e) => e.task === "verifyAndImport")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
it("new stable files create one scoped decision and never reprocess baseline stock", async () => {
|
||||||
|
const { h, id } = await setup();
|
||||||
|
await writeFile(join(h.dl, "historical.mkv"), "historical video content");
|
||||||
|
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||||
|
await writeFile(join(h.dl, "unidentified.mkv"), "unidentified video content");
|
||||||
|
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 0, queued: 0 });
|
||||||
|
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 1, queued: 0 });
|
||||||
|
const decisions = await h.db.selectFrom("decision").selectAll().execute();
|
||||||
|
expect(decisions).toHaveLength(1);
|
||||||
|
expect(decisions[0]?.evidence).toMatchObject({ sourceConfigId: id, root: h.dlRoot, path: "unidentified.mkv" });
|
||||||
|
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||||
|
expect(await h.db.selectFrom("decision").select("id").execute()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
it("active and completed QB tasks remain under the downloader tag policy", async () => {
|
||||||
|
const { h, id } = await setup();
|
||||||
|
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||||
|
await writeFile(join(h.dl, "active.mkv"), "active video content");
|
||||||
|
await writeFile(join(h.dl, "completed.mkv"), "completed video content");
|
||||||
|
await seedDownload(h.db, h, { name: "active.mkv", status: "downloading" });
|
||||||
|
await seedDownload(h.db, h, { name: "completed.mkv", status: "completed" });
|
||||||
|
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||||
|
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 2, queued: 0 });
|
||||||
|
expect(await h.db.selectFrom("organization_record").select("id").execute()).toEqual([]);
|
||||||
|
expect(await scanStock(h.module.deps(), id)).toMatchObject({
|
||||||
|
unavailable: 1,
|
||||||
|
items: [{ root: h.dlRoot, path: "completed.mkv" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("untagged paused QB downloads without DB rows wait for completion before snapshot import", async () => {
|
||||||
|
const { h, id, qb, config } = await setup();
|
||||||
|
await saveOrganizeSource(h.module.deps(), id, { ...config, category: "欧美电影" });
|
||||||
|
const entityId = await seedEntity(h.db, { kind: "movie", title: "Snapshot Film", year: 2024 });
|
||||||
|
await h.db
|
||||||
|
.insertInto("external_identity")
|
||||||
|
.values({
|
||||||
|
media_entity_id: entityId,
|
||||||
|
provider: "tmdb",
|
||||||
|
external_id: "123456",
|
||||||
|
status: "accepted",
|
||||||
|
entity_type: "movie",
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||||
|
const name = "Snapshot.Film.2024.tmdb-123456.1080p.BluRay.mkv";
|
||||||
|
await writeFile(join(h.dl, name), "stable incomplete video content");
|
||||||
|
qb.seed({
|
||||||
|
hash: "a".repeat(40),
|
||||||
|
name,
|
||||||
|
tags: [],
|
||||||
|
state: "pausedDL",
|
||||||
|
progress: 0.5,
|
||||||
|
amount_left: 50,
|
||||||
|
save_path: h.dl,
|
||||||
|
content_path: join(h.dl, name),
|
||||||
|
});
|
||||||
|
expect(await h.db.selectFrom("download_task").select("id").execute()).toEqual([]);
|
||||||
|
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||||
|
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 1, queued: 0 });
|
||||||
|
expect(await scanStock(h.module.deps(), id)).toMatchObject({ items: [], unavailable: 1 });
|
||||||
|
expect(await h.db.selectFrom("decision").select("id").execute()).toEqual([]);
|
||||||
|
expect(h.engine.enqueued.filter((e) => e.task === "verifyAndImport")).toEqual([]);
|
||||||
|
await qb.complete("a".repeat(40));
|
||||||
|
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ eligible: 1, queued: 1 });
|
||||||
|
expect(await h.db.selectFrom("download_task").select("id").execute()).toEqual([]);
|
||||||
|
});
|
||||||
|
it("unreachable QB preserves the previous baseline and rejects stock", async () => {
|
||||||
|
const { h, id, qb } = await setup();
|
||||||
|
await writeFile(join(h.dl, "baseline.mkv"), "baseline video content");
|
||||||
|
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||||
|
const before = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
|
||||||
|
qb.fault("torrents/info", { status: 503, times: 10 });
|
||||||
|
await writeFile(join(h.dl, "later.mkv"), "later video content");
|
||||||
|
expect(await scanOrganizeSource(h.module.deps(), { sourceId: id })).toMatchObject({ queued: 0 });
|
||||||
|
const after = await h.db.selectFrom("organize_source_snapshot").selectAll().executeTakeFirstOrThrow();
|
||||||
|
expect(after.snapshot).toEqual(before.snapshot);
|
||||||
|
expect(after.baseline_at).toEqual(before.baseline_at);
|
||||||
|
expect(after.last_error).toContain("无法查询来源下载器");
|
||||||
|
await expect(scanStock(h.module.deps(), id)).rejects.toThrow("无法查询来源下载器");
|
||||||
|
});
|
||||||
|
it("partial records exclude successful files while leaving failed files available", async () => {
|
||||||
|
const { h, id } = await setup();
|
||||||
|
await writeFile(join(h.dl, "success.mkv"), "successful video content");
|
||||||
|
await writeFile(join(h.dl, "failure.mkv"), "failed video content");
|
||||||
|
const record = await h.db
|
||||||
|
.insertInto("organization_record")
|
||||||
|
.values({ trigger: "manual", actor_type: "system", action: "copy", status: "partial" })
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
await h.db
|
||||||
|
.insertInto("organization_file_result")
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
organization_record_id: record.id,
|
||||||
|
ordinal: 0,
|
||||||
|
mode: "copy",
|
||||||
|
source_path: join(h.dl, "success.mkv"),
|
||||||
|
status: "succeeded",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
organization_record_id: record.id,
|
||||||
|
ordinal: 1,
|
||||||
|
mode: "copy",
|
||||||
|
source_path: join(h.dl, "failure.mkv"),
|
||||||
|
status: "failed",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.execute();
|
||||||
|
expect(await scanStock(h.module.deps(), id)).toMatchObject({
|
||||||
|
items: [{ root: h.dlRoot, path: "failure.mkv" }],
|
||||||
|
skipped: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("historical success excludes only matching source facts, so replacement files remain candidates", async () => {
|
||||||
|
const { h, id } = await setup();
|
||||||
|
const names = ["same.mkv", "size-changed.mkv", "mtime-changed.mkv"];
|
||||||
|
await Promise.all(names.map((n) => writeFile(join(h.dl, n), "video content")));
|
||||||
|
const facts = Object.fromEntries(
|
||||||
|
await Promise.all(
|
||||||
|
names.map(async (n) => {
|
||||||
|
const st = await stat(join(h.dl, n));
|
||||||
|
return [join(h.dl, n), { size: st.size, mtimeMs: st.mtimeMs }];
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
facts[join(h.dl, "size-changed.mkv")]!.size -= 1;
|
||||||
|
facts[join(h.dl, "mtime-changed.mkv")]!.mtimeMs -= 1;
|
||||||
|
const record = await h.db
|
||||||
|
.insertInto("organization_record")
|
||||||
|
.values({
|
||||||
|
trigger: "manual",
|
||||||
|
actor_type: "system",
|
||||||
|
action: "copy",
|
||||||
|
status: "succeeded",
|
||||||
|
plan: JSON.stringify({ sourceFacts: facts }),
|
||||||
|
})
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
await h.db
|
||||||
|
.insertInto("organization_file_result")
|
||||||
|
.values(
|
||||||
|
names.map((n, ordinal) => ({
|
||||||
|
organization_record_id: record.id,
|
||||||
|
ordinal,
|
||||||
|
mode: "copy" as const,
|
||||||
|
source_path: join(h.dl, n),
|
||||||
|
source_size_bytes: facts[join(h.dl, n)]!.size,
|
||||||
|
status: "succeeded" as const,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
const stock = await scanStock(h.module.deps(), id);
|
||||||
|
expect(stock.skipped).toBe(1);
|
||||||
|
expect(stock.items.map((f) => f.path).sort()).toEqual(["mtime-changed.mkv", "size-changed.mkv"]);
|
||||||
|
});
|
||||||
|
it("stock batches stay within the identify wizard limit", async () => {
|
||||||
|
const { h, id } = await setup();
|
||||||
|
await Promise.all(Array.from({ length: 201 }, (_, n) => writeFile(join(h.dl, `stock-${n}.mkv`), "video content")));
|
||||||
|
const stock = await scanStock(h.module.deps(), id);
|
||||||
|
expect(stock.items).toHaveLength(200);
|
||||||
|
expect(stock.truncated).toBe(true);
|
||||||
|
expect(await h.db.selectFrom("organize_source_snapshot").selectAll().execute()).toEqual([]);
|
||||||
|
});
|
||||||
|
it("stock omits successfully organized files", async () => {
|
||||||
|
const { h, id } = await setup();
|
||||||
|
const path = join(h.dl, "organized.mkv");
|
||||||
|
await writeFile(path, "organized video content");
|
||||||
|
const record = await h.db
|
||||||
|
.insertInto("organization_record")
|
||||||
|
.values({ trigger: "manual", actor_type: "system", action: "copy", status: "succeeded" })
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
await h.db
|
||||||
|
.insertInto("organization_file_result")
|
||||||
|
.values({ organization_record_id: record.id, mode: "copy", source_path: path, status: "succeeded" })
|
||||||
|
.execute();
|
||||||
|
expect(await scanStock(h.module.deps(), id)).toMatchObject({ items: [], skipped: 1 });
|
||||||
|
});
|
||||||
|
it("source edits reset baseline; identical categories coexist; deleting removes state", async () => {
|
||||||
|
const { h, id, config } = await setup();
|
||||||
|
await mkdir(join(h.dl, "other"));
|
||||||
|
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||||
|
await saveOrganizeSource(h.module.deps(), randomUUID(), { ...config, category: "电影" });
|
||||||
|
await saveOrganizeSource(h.module.deps(), id, {
|
||||||
|
...config,
|
||||||
|
category: "电影",
|
||||||
|
source: { ...config.source, path: "other" },
|
||||||
|
});
|
||||||
|
expect((await listOrganizeSources(h.module.deps())).items).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
await h.db.selectFrom("organize_source_snapshot").selectAll().where("source_id", "=", id).executeTakeFirst(),
|
||||||
|
).toBeUndefined();
|
||||||
|
await scanOrganizeSource(h.module.deps(), { sourceId: id });
|
||||||
|
await deleteOrganizeSource(h.module.deps(), id);
|
||||||
|
expect(
|
||||||
|
await h.db.selectFrom("organize_source_snapshot").selectAll().where("source_id", "=", id).executeTakeFirst(),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -224,7 +224,7 @@ describe.skipIf(skip)("files W5-A2", () => {
|
|||||||
dryRun: true,
|
dryRun: true,
|
||||||
items: [
|
items: [
|
||||||
{ root: h.dlRoot, path: "weird-a.mkv", mediaEntityId: series, season: 2, episodes: [3, 4] },
|
{ root: h.dlRoot, path: "weird-a.mkv", mediaEntityId: series, season: 2, episodes: [3, 4] },
|
||||||
{ root: h.dlRoot, path: "weird-b.mkv", mediaEntityId: series, season: 2, episodes: [5, 7] },
|
{ root: h.dlRoot, path: "weird-b.mkv", mediaEntityId: series, season: 2, episodes: [5, 6] },
|
||||||
{
|
{
|
||||||
root: h.dlRoot,
|
root: h.dlRoot,
|
||||||
path: "Film.2019.1080p.mkv",
|
path: "Film.2019.1080p.mkv",
|
||||||
@@ -243,8 +243,8 @@ describe.skipIf(skip)("files W5-A2", () => {
|
|||||||
const mv = groups.find((g: { mediaEntityId: string }) => g.mediaEntityId === film);
|
const mv = groups.find((g: { mediaEntityId: string }) => g.mediaEntityId === film);
|
||||||
const tvTargets = tv.files.map((f: { target: string | null }) => f.target);
|
const tvTargets = tv.files.map((f: { target: string | null }) => f.target);
|
||||||
expect(tvTargets.some((t: string | null) => t?.endsWith("Season 2/剧 - S02E03-E04 - 第 3-4 集.mkv"))).toBe(true);
|
expect(tvTargets.some((t: string | null) => t?.endsWith("Season 2/剧 - S02E03-E04 - 第 3-4 集.mkv"))).toBe(true);
|
||||||
// 非连续集号不应用(只按季覆写,文件名无集号 → 计划排除),其余覆写不进 warnings
|
expect(tvTargets.some((t: string | null) => t?.endsWith("Season 2/剧 - S02E05-E06 - 第 5-6 集.mkv"))).toBe(true);
|
||||||
expect(tv.warnings).toEqual([expect.stringMatching(/weird-b\.mkv:集号 5,7 不连续/)]);
|
expect(tv.warnings).toEqual([]);
|
||||||
expect(mv.warnings).toEqual([expect.stringMatching(/电影不适用季\/集覆写/)]);
|
expect(mv.warnings).toEqual([expect.stringMatching(/电影不适用季\/集覆写/)]);
|
||||||
expect(mv).toMatchObject({ action: "copy", slotId: slot.id, targetDir: join(h.lib, "picked") });
|
expect(mv).toMatchObject({ action: "copy", slotId: slot.id, targetDir: join(h.lib, "picked") });
|
||||||
expect(mv.files[0].target).toBe(join(h.lib, "picked", "Film (2019) - 1080p.mkv"));
|
expect(mv.files[0].target).toBe(join(h.lib, "picked", "Film (2019) - 1080p.mkv"));
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// 不挂载 HTTP 路由;隔离 Deno 静态文件适配器,以便本单元测试在 Node 下执行。
|
||||||
|
vi.mock("hono/deno", () => ({ serveStatic: vi.fn() }));
|
||||||
|
|
||||||
|
import { verifyInPlace } from "../src/modules/acquisition-organize/services/organize.ts";
|
||||||
|
|
||||||
|
const path = "/media/unchanged.mkv";
|
||||||
|
const frozen = {
|
||||||
|
rootPath: "/media",
|
||||||
|
sentinel: ".uhd-root",
|
||||||
|
libraryRootId: "root",
|
||||||
|
entityId: "work",
|
||||||
|
sourceFacts: { [path]: { size: 100, mtimeMs: 200 } },
|
||||||
|
} as unknown as Parameters<typeof verifyInPlace>[2];
|
||||||
|
async function verify(observed: unknown, owner: string | null = null) {
|
||||||
|
const executeTakeFirst = vi.fn().mockResolvedValue(owner ? { media_entity_id: owner } : undefined);
|
||||||
|
const query = { innerJoin: () => query, select: () => query, where: () => query, executeTakeFirst };
|
||||||
|
const d = { ctx: { db: { selectFrom: () => query } } } as unknown as Parameters<typeof verifyInPlace>[0];
|
||||||
|
const statSafe = vi.fn().mockResolvedValue(observed);
|
||||||
|
// 仅提供 statSafe;若意外调用复制、移动、清理或写入,本测试立即失败。
|
||||||
|
const fsr = { proxy: { statSafe } } as unknown as Parameters<typeof verifyInPlace>[1];
|
||||||
|
return { result: await verifyInPlace(d, fsr, frozen, path, 100), statSafe, executeTakeFirst };
|
||||||
|
}
|
||||||
|
const observed = (extra = {}) => ({
|
||||||
|
kind: "ok",
|
||||||
|
facts: { isFile: true, isSymbolicLink: false, size: 100, mtimeMs: 200, ...extra },
|
||||||
|
});
|
||||||
|
describe("原地登记执行核验", () => {
|
||||||
|
it("只读核验,不复制或移动文件;同作品重复登记可继续", async () => {
|
||||||
|
const r = await verify(observed(), "work");
|
||||||
|
expect(r.result).toEqual({ status: "succeeded", bytes: 0 });
|
||||||
|
expect(r.statSafe).toHaveBeenCalledWith(path, { root: "/media", sentinel: ".uhd-root" });
|
||||||
|
});
|
||||||
|
it("文件消失、链接、变为目录或计划后的大小/时间变化均不登记", async () => {
|
||||||
|
for (const value of [
|
||||||
|
{ kind: "missing" },
|
||||||
|
observed({ isSymbolicLink: true }),
|
||||||
|
observed({ isFile: false }),
|
||||||
|
observed({ size: 101 }),
|
||||||
|
observed({ mtimeMs: 201 }),
|
||||||
|
]) {
|
||||||
|
const r = await verify(value);
|
||||||
|
expect(r.result.status).toBe("failed");
|
||||||
|
expect(r.executeTakeFirst).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("不会把已有视频悄悄登记为另一个作品", async () => {
|
||||||
|
expect((await verify(observed(), "other")).result).toMatchObject({
|
||||||
|
status: "failed",
|
||||||
|
error: { code: "ASSET_ENTITY_CONFLICT" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -102,7 +102,7 @@ describe.skipIf(!hasPg)("Pi 上游(真实 PG)", () => {
|
|||||||
.plaintextKey as string;
|
.plaintextKey as string;
|
||||||
expect((await bearer(h.app, readKey)("GET", "/pi/upstreams")).status).toBe(403);
|
expect((await bearer(h.app, readKey)("GET", "/pi/upstreams")).status).toBe(403);
|
||||||
const cat = await json(await key("GET", "/pi/providers?models=false"));
|
const cat = await json(await key("GET", "/pi/providers?models=false"));
|
||||||
expect(cat.data.providers).toHaveLength(43);
|
expect(cat.data.providers).toHaveLength(44);
|
||||||
const anthropic = cat.data.providers.find((p: { id: string }) => p.id === "anthropic");
|
const anthropic = cat.data.providers.find((p: { id: string }) => p.id === "anthropic");
|
||||||
expect(anthropic.termsNote).toMatch(/封禁/);
|
expect(anthropic.termsNote).toMatch(/封禁/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { createOutboxPort } from "../../src/context.ts";
|
|||||||
import { createApp } from "../../src/http/app.ts";
|
import { createApp } from "../../src/http/app.ts";
|
||||||
import { EventBus } from "../../src/http/sse.ts";
|
import { EventBus } from "../../src/http/sse.ts";
|
||||||
import { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
|
import { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
|
||||||
|
import { createCapabilityRegistry } from "../../src/modules/agent/capability-registry.ts";
|
||||||
import { createAgentModule } from "../../src/modules/agent/index.ts";
|
import { createAgentModule } from "../../src/modules/agent/index.ts";
|
||||||
import { createSearchModule } from "../../src/modules/search/index.ts";
|
import { createSearchModule } from "../../src/modules/search/index.ts";
|
||||||
import { type BaseRelease, filterReleases, quickFilter } from "../../src/modules/search/releases.ts";
|
import { type BaseRelease, filterReleases, quickFilter } from "../../src/modules/search/releases.ts";
|
||||||
@@ -149,7 +150,7 @@ describe.skipIf(!up)("全局搜索", () => {
|
|||||||
outbox: createOutboxPort(),
|
outbox: createOutboxPort(),
|
||||||
secrets,
|
secrets,
|
||||||
settings: plainSettings(() => db),
|
settings: plainSettings(() => db),
|
||||||
capabilities: {} as never,
|
capabilities: createCapabilityRegistry({ logger }),
|
||||||
} as ServerContext;
|
} as ServerContext;
|
||||||
// 挂起的站点:请求一直不返回,直到被中止(单站超时)
|
// 挂起的站点:请求一直不返回,直到被中止(单站超时)
|
||||||
const fetchImpl: typeof fetch = (input, init) => {
|
const fetchImpl: typeof fetch = (input, init) => {
|
||||||
@@ -208,6 +209,28 @@ describe.skipIf(!up)("全局搜索", () => {
|
|||||||
await addAccount(sites.mteam, "api_key", mteam.apiKey);
|
await addAccount(sites.mteam, "api_key", mteam.apiKey);
|
||||||
sites.slow = await addSite("hdsky", "天空", "nexusphp", `http://${SLOW_HOST}/`);
|
sites.slow = await addSite("hdsky", "天空", "nexusphp", `http://${SLOW_HOST}/`);
|
||||||
await addAccount(sites.slow, "cookie", "uid=1; pass=x");
|
await addAccount(sites.slow, "cookie", "uid=1; pass=x");
|
||||||
|
const downloader = await db
|
||||||
|
.insertInto("downloader_instance")
|
||||||
|
.values({
|
||||||
|
name: "search-download",
|
||||||
|
base_url: "http://downloader.invalid",
|
||||||
|
cache_volume_key: "search-cache",
|
||||||
|
settings: JSON.stringify({ savePath: "/downloads" }),
|
||||||
|
})
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
await db
|
||||||
|
.insertInto("path_mapping")
|
||||||
|
.values({
|
||||||
|
downloader_instance_id: downloader.id,
|
||||||
|
view_kind: "downloader",
|
||||||
|
remote_prefix: "/downloads",
|
||||||
|
local_prefix: "/tmp/umt-search-cache",
|
||||||
|
media_server_instance_id: null,
|
||||||
|
library_root_id: null,
|
||||||
|
verified_at: null,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
// 已关闭的站点不参与搜索
|
// 已关闭的站点不参与搜索
|
||||||
await db
|
await db
|
||||||
.insertInto("site")
|
.insertInto("site")
|
||||||
@@ -561,12 +584,16 @@ describe.skipIf(!up)("全局搜索", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("从搜索结果下载:转调 /acquisition-plans(同一鉴权、同一拒绝原因)", async () => {
|
it("从搜索结果下载:转调 /acquisition-plans(同一鉴权、同一拒绝原因)", async () => {
|
||||||
// 未识别的资源 → 与 POST /acquisition-plans 相同的 412
|
// 未识别资源先返回一次结构化确认,不阻断后续手动下载
|
||||||
const unresolved = await req(`/search/releases/${sites.nexus}/900001/plan`, { body: {} });
|
const unresolved = await req(`/search/releases/${sites.nexus}/900001/plan`, { body: {} });
|
||||||
expect(unresolved.status).toBe(412);
|
expect(unresolved.status, JSON.stringify(unresolved.body)).toBe(200);
|
||||||
expect(unresolved.body.error.code).toBe("precondition_failed");
|
expect(unresolved.body.data).toMatchObject({
|
||||||
|
submitted: false,
|
||||||
|
requiresConfirmation: true,
|
||||||
|
rejected: [{ code: "unrecognized" }],
|
||||||
|
});
|
||||||
|
|
||||||
// 已识别但作品没有收录版本 → submitted=false + no_slot
|
// 已识别资源无需订阅或收录版本,独立计划提交
|
||||||
const other = (
|
const other = (
|
||||||
await db
|
await db
|
||||||
.insertInto("media_entity")
|
.insertInto("media_entity")
|
||||||
@@ -582,7 +609,13 @@ describe.skipIf(!up)("全局搜索", () => {
|
|||||||
.execute();
|
.execute();
|
||||||
const ok = await req(`/search/releases/${sites.mteam}/912234/plan`, { body: { note: "from search" } });
|
const ok = await req(`/search/releases/${sites.mteam}/912234/plan`, { body: { note: "from search" } });
|
||||||
expect(ok.status).toBe(200);
|
expect(ok.status).toBe(200);
|
||||||
expect(ok.body.data).toMatchObject({ submitted: false, plan: null, rejected: [{ code: "no_slot" }] });
|
expect(ok.body.data).toMatchObject({ submitted: true, slotId: null, plan: { slotId: null }, rejected: [] });
|
||||||
|
expect(await db.selectFrom("subscription").selectAll().where("media_entity_id", "=", other).execute()).toHaveLength(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await db.selectFrom("acquisition_slot").selectAll().where("media_entity_id", "=", other).execute(),
|
||||||
|
).toHaveLength(0);
|
||||||
|
|
||||||
// 不在搜索结果里
|
// 不在搜索结果里
|
||||||
const missing = await req(`/search/releases/${sites.mteam}/nope/plan`, { body: {} });
|
const missing = await req(`/search/releases/${sites.mteam}/nope/plan`, { body: {} });
|
||||||
|
|||||||
@@ -316,6 +316,28 @@ describe.skipIf(!pg)("ID-3 识别升级链:详情页 ID", () => {
|
|||||||
expect(siteRequests.some((x) => x.includes("douban"))).toBe(false);
|
expect(siteRequests.some((x) => x.includes("douban"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("升级链没认出(豆瓣召回无匹配)时保留原有歧义:候选不丢,照常开有歧义的事项", async () => {
|
||||||
|
await saveDoubanSubject(h.ctx, {
|
||||||
|
id: "36000097",
|
||||||
|
title: "查无此片",
|
||||||
|
aliases: [],
|
||||||
|
// 年份对不上:按豆瓣条目召回 TMDB 拿不到候选
|
||||||
|
year: 1990,
|
||||||
|
source: "web_snippet",
|
||||||
|
ref: "x",
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
const r = await release(audiences, "725859", "Rose 2026 2160p WEB-DL H265-ADWeb", {
|
||||||
|
_detail: { doubanId: "36000097" },
|
||||||
|
});
|
||||||
|
await h.db.updateTable("source_release").set({ detail_fetched_at: new Date() }).where("id", "=", r.id).execute();
|
||||||
|
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual" });
|
||||||
|
expect(res.outcome).toBe("ambiguous");
|
||||||
|
expect(res.decisionId).not.toBeNull();
|
||||||
|
const d = await h.db.selectFrom("decision").select("kind").where("id", "=", res.decisionId!).executeTakeFirst();
|
||||||
|
expect(d?.kind).toBe("identity_ambiguous");
|
||||||
|
});
|
||||||
|
|
||||||
it("池条目补全前先走升级链:详情页给了 ID 就直接认出,返回 409,不做网页补全", async () => {
|
it("池条目补全前先走升级链:详情页给了 ID 就直接认出,返回 409,不做网页补全", async () => {
|
||||||
const r = await release(hhan, "91007", "Example Show S01 2026 2160p YK WEB-DL H.265 DV HQ AAC2.0-HHWEB");
|
const r = await release(hhan, "91007", "Example Show S01 2026 2160p YK WEB-DL H.265 DV HQ AAC2.0-HHWEB");
|
||||||
const first = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual", escalate: false });
|
const first = await resolveRelease(h.rt, { sourceReleaseId: r.id, trigger: "manual", escalate: false });
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { workNfo } from "@umt/domain";
|
||||||
|
import { createMockTmdb } from "@umt/testkit";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { imagePathsOf, nfoWork } from "../../src/modules/acquisition-organize/services/scrape-input.ts";
|
||||||
|
import { saveDetailPage } from "../../src/modules/sources-identity/service/detail-page.ts";
|
||||||
|
import {
|
||||||
|
ptDetailFacts,
|
||||||
|
resolveOrganizeMetadata,
|
||||||
|
} from "../../src/modules/sources-identity/service/organize-metadata.ts";
|
||||||
|
import { createHarness, type Harness, pgAvailable, seedSite } from "./harness.ts";
|
||||||
|
|
||||||
|
const ptText =
|
||||||
|
"◎译 名 未收录的故事\n◎原 名 Unlisted Story\n◎年 代 2025\n◎产 地 中国大陆\n◎语 言 汉语普通话\n◎类 别 剧情\n◎导 演 张三\n◎主 演 李四 / 王五\n◎简 介 一位主人公在旅途中发现了一段不为人知的故事,并开始寻找失踪的朋友。";
|
||||||
|
const up = await pgAvailable();
|
||||||
|
describe("PT detail extraction", () => {
|
||||||
|
it("reads evidenced labels and leaves missing fields empty", () => {
|
||||||
|
const facts = ptDetailFacts(ptText, "https://pt.example/details.php?id=1");
|
||||||
|
expect(facts.names).toEqual(["未收录的故事", "Unlisted Story"]);
|
||||||
|
expect(facts.year).toBe(2025);
|
||||||
|
expect(facts.extras).toContainEqual({ field: "originCountry", value: ["CN"] });
|
||||||
|
expect(facts.extras).toContainEqual({ field: "originalLanguage", value: "zh" });
|
||||||
|
expect(ptDetailFacts("Unknown.2025.1080p", "https://pt.example/").candidates).toEqual([]);
|
||||||
|
});
|
||||||
|
it("local final fields reuse NFO input and external poster", () => {
|
||||||
|
const metadata = {
|
||||||
|
overview: { value: "简介" },
|
||||||
|
posterUrl: { value: "https://img.example/cover.jpg" },
|
||||||
|
originCountry: { value: ["CN"] },
|
||||||
|
cast: { value: [{ id: -1, name: "本地主演", role: null, profilePath: null }] },
|
||||||
|
crew: { value: [{ id: -2, name: "本地导演", role: "Director", profilePath: null }] },
|
||||||
|
};
|
||||||
|
expect(imagePathsOf(metadata).posterPath).toBe("https://img.example/cover.jpg");
|
||||||
|
const nfo = nfoWork({ kind: "movie", title: "本地作品", year: 2025, original_title: null, metadata }, {});
|
||||||
|
expect(nfo.ids.tmdb).toBeNull();
|
||||||
|
expect(nfo.overview).toBe("简介");
|
||||||
|
expect(nfo.countries).toEqual(["中国"]);
|
||||||
|
expect(nfo.actors?.[0]?.tmdbId).toBeNull();
|
||||||
|
expect(nfo.directors?.[0]?.tmdbId).toBeNull();
|
||||||
|
const xml = workNfo(nfo);
|
||||||
|
expect(xml).toContain("本地主演");
|
||||||
|
expect(xml).toContain("本地导演");
|
||||||
|
expect(xml).not.toContain("-1");
|
||||||
|
expect(xml).not.toContain("-2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe.skipIf(!up)("organize metadata local recovery (isolated PG)", () => {
|
||||||
|
let h: Harness;
|
||||||
|
let siteId: string;
|
||||||
|
beforeAll(async () => {
|
||||||
|
h = await createHarness("local_metadata", { tmdb: () => null });
|
||||||
|
siteId = (await seedSite(h, { baseUrl: "https://pt.example/" })).site.id;
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await h?.close();
|
||||||
|
});
|
||||||
|
const add = async (n: string, text: string, title?: string) => {
|
||||||
|
const release = await h.db
|
||||||
|
.insertInto("source_release")
|
||||||
|
.values({
|
||||||
|
site_id: siteId,
|
||||||
|
site_release_id: n,
|
||||||
|
title: title ?? `Unlisted Story ${n} 2025 1080p`,
|
||||||
|
detail_fetched_at: new Date(),
|
||||||
|
})
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
await saveDetailPage(
|
||||||
|
h.rt,
|
||||||
|
h.db,
|
||||||
|
release.id,
|
||||||
|
{
|
||||||
|
descriptionText: text,
|
||||||
|
mediaInfo: null,
|
||||||
|
screenshots: ["https://img.example/cover.jpg"],
|
||||||
|
imdbId: null,
|
||||||
|
doubanId: null,
|
||||||
|
tmdb: null,
|
||||||
|
files: [],
|
||||||
|
tags: [],
|
||||||
|
uploader: null,
|
||||||
|
probe: { labels: [], hasDescription: true, imageCount: 1 },
|
||||||
|
},
|
||||||
|
3600,
|
||||||
|
);
|
||||||
|
return release.id;
|
||||||
|
};
|
||||||
|
const deps = {
|
||||||
|
search: async () => [],
|
||||||
|
fetch: async () => {
|
||||||
|
throw new Error("unexpected fetch");
|
||||||
|
},
|
||||||
|
complete: async () => null,
|
||||||
|
};
|
||||||
|
it("creates Local with sourced fields, remains revisitable and reuses identity", async () => {
|
||||||
|
const id = await add("1", ptText);
|
||||||
|
const out = await resolveOrganizeMetadata(h.rt, id, deps, { actor: { type: "system", id: "system:organize" } });
|
||||||
|
expect(out.mode).toBe("local");
|
||||||
|
expect(out.mediaEntityId).toBeTruthy();
|
||||||
|
const entity = await h.db
|
||||||
|
.selectFrom("media_entity")
|
||||||
|
.selectAll()
|
||||||
|
.where("id", "=", out.mediaEntityId!)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
expect(entity.status).toBe("provisional");
|
||||||
|
expect(entity.title).toBe("未收录的故事");
|
||||||
|
expect((entity.metadata as Record<string, { value: unknown }>).originCountry?.value).toEqual(["CN"]);
|
||||||
|
expect((entity.metadata as Record<string, { value: unknown }>).posterUrl?.value).toBe(
|
||||||
|
"https://img.example/cover.jpg",
|
||||||
|
);
|
||||||
|
const release = await h.db.selectFrom("source_release").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||||
|
expect(release.resolution_locked).toBe(false);
|
||||||
|
const again = await resolveOrganizeMetadata(h.rt, id, deps, { actor: { type: "system", id: "system:organize" } });
|
||||||
|
expect(again.mediaEntityId).toBe(out.mediaEntityId);
|
||||||
|
});
|
||||||
|
it("series fallback preserves actual season and episode instead of fabricating season one", async () => {
|
||||||
|
const id = await add(
|
||||||
|
"season",
|
||||||
|
"◎译 名 独立剧集\n◎年 代 2025\n◎集 数 12\n◎类 别 剧情",
|
||||||
|
"Distinct Local Series S02E03 2025 1080p",
|
||||||
|
);
|
||||||
|
const out = await resolveOrganizeMetadata(h.rt, id, deps, { actor: { type: "system", id: "system:organize" } });
|
||||||
|
expect(out.mode).toBe("local");
|
||||||
|
const units = await h.db
|
||||||
|
.selectFrom("content_unit")
|
||||||
|
.select(["season_number", "episode_number"])
|
||||||
|
.where("media_entity_id", "=", out.mediaEntityId!)
|
||||||
|
.execute();
|
||||||
|
expect(units.some((u) => u.season_number === 1)).toBe(false);
|
||||||
|
expect(units.some((u) => u.season_number === 2 && u.episode_number === 3)).toBe(true);
|
||||||
|
});
|
||||||
|
it("does not create a filename placeholder when facts are insufficient", async () => {
|
||||||
|
const id = await add("2", "no metadata available");
|
||||||
|
const out = await resolveOrganizeMetadata(h.rt, id, deps, { actor: { type: "system", id: "system:organize" } });
|
||||||
|
expect(out.mediaEntityId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!up)("organize metadata translated name recovery", () => {
|
||||||
|
it("retries TMDB using evidenced PT aliases after normal title lookup fails", async () => {
|
||||||
|
const tmdb = await createMockTmdb({
|
||||||
|
fixtures: [
|
||||||
|
{
|
||||||
|
type: "movie",
|
||||||
|
id: 990087,
|
||||||
|
original_title: "True Original",
|
||||||
|
original_language: "en",
|
||||||
|
origin_country: ["US"],
|
||||||
|
date: "2025-01-01",
|
||||||
|
titles: { "zh-CN": "正确译名", "en-US": "True Original" },
|
||||||
|
popularity: 10,
|
||||||
|
alternative_titles: [],
|
||||||
|
external_ids: { imdb_id: null },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const h = await createHarness("local_alias", { tmdbUrl: tmdb.url });
|
||||||
|
try {
|
||||||
|
const siteId = (await seedSite(h, { baseUrl: "https://pt.example/" })).site.id;
|
||||||
|
const release = await h.db
|
||||||
|
.insertInto("source_release")
|
||||||
|
.values({
|
||||||
|
site_id: siteId,
|
||||||
|
site_release_id: "alias",
|
||||||
|
title: "Unknown Translation 2025 1080p",
|
||||||
|
detail_fetched_at: new Date(),
|
||||||
|
})
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
await saveDetailPage(
|
||||||
|
h.rt,
|
||||||
|
h.db,
|
||||||
|
release.id,
|
||||||
|
{
|
||||||
|
descriptionText: "◎译 名 另类译名\n◎原 名 True Original\n◎年 代 2025\n◎类 别 剧情",
|
||||||
|
mediaInfo: null,
|
||||||
|
screenshots: [],
|
||||||
|
imdbId: null,
|
||||||
|
doubanId: null,
|
||||||
|
tmdb: null,
|
||||||
|
files: [],
|
||||||
|
tags: [],
|
||||||
|
uploader: null,
|
||||||
|
probe: { labels: [], hasDescription: true, imageCount: 0 },
|
||||||
|
},
|
||||||
|
3600,
|
||||||
|
);
|
||||||
|
let completions = 0;
|
||||||
|
const out = await resolveOrganizeMetadata(
|
||||||
|
h.rt,
|
||||||
|
release.id,
|
||||||
|
{
|
||||||
|
search: async () => [],
|
||||||
|
fetch: async () => {
|
||||||
|
throw new Error("unexpected fetch");
|
||||||
|
},
|
||||||
|
complete: async () => {
|
||||||
|
completions++;
|
||||||
|
return { text: "{}", model: "fixture-agent", inputTokens: 100, outputTokens: 5 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ actor: { type: "system", id: "system:organize" } },
|
||||||
|
);
|
||||||
|
expect(completions).toBe(1);
|
||||||
|
expect(out.mode).toBe("tmdb");
|
||||||
|
const identity = await h.db
|
||||||
|
.selectFrom("external_identity")
|
||||||
|
.selectAll()
|
||||||
|
.where("media_entity_id", "=", out.mediaEntityId!)
|
||||||
|
.where("provider", "=", "tmdb")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
expect(identity.external_id).toBe("990087");
|
||||||
|
const enrichment = await h.db
|
||||||
|
.selectFrom("metadata_enrichment")
|
||||||
|
.selectAll()
|
||||||
|
.where("id", "=", out.enrichmentId!)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
expect((enrichment.usage as { inputTokens: number }).inputTokens).toBe(100);
|
||||||
|
} finally {
|
||||||
|
await h.close();
|
||||||
|
await tmdb.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,8 +15,9 @@ const TiB = 1024 ** 4;
|
|||||||
const SITE_IDS = ["site.list", "site.health_check", "site.refresh_login", "site.user_data"] as const;
|
const SITE_IDS = ["site.list", "site.health_check", "site.refresh_login", "site.user_data"] as const;
|
||||||
|
|
||||||
describe("站点能力规格", () => {
|
describe("站点能力规格", () => {
|
||||||
it("进入首批能力目录(17 + 5 只读摘要 + 4 站点 + 10 浏览器 + 2 会话交互 + 2 网页搜索 + 1 元数据补全 = 41);类别 / 风险 / 重放按约定", () => {
|
it("进入能力目录(41 个已有能力 + 整理元数据补全);类别 / 风险 / 重放按约定", () => {
|
||||||
expect(CAPABILITY_IDS).toHaveLength(41);
|
expect(CAPABILITY_IDS).toHaveLength(42);
|
||||||
|
expect(capabilitySpec("metadata.resolve_organize")).toMatchObject({ kind: "execute", replay: "never" });
|
||||||
for (const id of SITE_IDS) expect(CAPABILITY_IDS).toContain(id);
|
for (const id of SITE_IDS) expect(CAPABILITY_IDS).toContain(id);
|
||||||
expect(capabilitySpec("site.list")).toMatchObject({ kind: "read", riskLevel: "low", replay: "safe" });
|
expect(capabilitySpec("site.list")).toMatchObject({ kind: "read", riskLevel: "low", replay: "safe" });
|
||||||
expect(capabilitySpec("site.user_data")).toMatchObject({ kind: "read", riskLevel: "low", replay: "safe" });
|
expect(capabilitySpec("site.user_data")).toMatchObject({ kind: "read", riskLevel: "low", replay: "safe" });
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { defineConfig } from "vitest/config";
|
|||||||
// 新测试文件缺省归入 pg(串行,安全);确认不碰 PG 的才加进 UNIT。
|
// 新测试文件缺省归入 pg(串行,安全);确认不碰 PG 的才加进 UNIT。
|
||||||
const UNIT = [
|
const UNIT = [
|
||||||
"test/api-registry.test.ts",
|
"test/api-registry.test.ts",
|
||||||
|
"test/organize-in-place-verify.test.ts",
|
||||||
"test/file-manager/preview-parse.test.ts",
|
"test/file-manager/preview-parse.test.ts",
|
||||||
"test/auth.test.ts",
|
"test/auth.test.ts",
|
||||||
"test/health.test.ts",
|
"test/health.test.ts",
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import type { ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeyUpdate, CursorPage } from "@umt/contracts";
|
import type { ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeyUpdate, CursorPage } from "@umt/contracts";
|
||||||
import { apiData } from "~/lib/api.ts";
|
import { apiData } from "~/lib/api.ts";
|
||||||
import { qk } from "~/lib/query-keys.ts";
|
import { qk } from "~/lib/query-keys.ts";
|
||||||
|
import { fetchAllCursorPages } from "./parse.ts";
|
||||||
|
|
||||||
export function useApiKeys() {
|
export function useApiKeys() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: qk.list("api-keys"),
|
queryKey: qk.list("api-keys"),
|
||||||
queryFn: ({ signal }) => apiData<CursorPage<ApiKey>>("/api-keys", { params: { limit: 200 }, signal }),
|
queryFn: ({ signal }) =>
|
||||||
|
fetchAllCursorPages((cursor) =>
|
||||||
|
apiData<CursorPage<ApiKey>>("/api-keys", { params: { limit: 200, cursor }, signal }),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { cursorPage, type Dashboard, SiteListItem } from "@umt/contracts";
|
import { cursorPage, type Dashboard, SiteListItem } from "@umt/contracts";
|
||||||
import { apiData } from "~/lib/api.ts";
|
import { apiData } from "~/lib/api.ts";
|
||||||
import { qk } from "~/lib/query-keys.ts";
|
import { qk } from "~/lib/query-keys.ts";
|
||||||
import { apiParsed } from "./parse.ts";
|
import { apiParsed, fetchAllCursorPages } from "./parse.ts";
|
||||||
|
|
||||||
/** 首页聚合;SSE 事件触发 invalidate,另有 60 秒兜底轮询 */
|
/** 首页聚合;SSE 事件触发 invalidate,另有 60 秒兜底轮询 */
|
||||||
export function useDashboard() {
|
export function useDashboard() {
|
||||||
@@ -20,7 +20,9 @@ export function useSitesWithAccounts() {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: [...qk.dashboard, "sites-accounts"],
|
queryKey: [...qk.dashboard, "sites-accounts"],
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200, include: "accounts" }, signal }),
|
fetchAllCursorPages((cursor) =>
|
||||||
|
apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200, cursor, include: "accounts" }, signal }),
|
||||||
|
),
|
||||||
refetchInterval: 60_000,
|
refetchInterval: 60_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TTL_SECOND } from "~/lib/api.ts";
|
import { TTL_SECOND } from "~/lib/api.ts";
|
||||||
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
|
import { apiMutate, apiParsed, cleanParams, fetchAllCursorPages } from "./parse.ts";
|
||||||
|
|
||||||
// 下载任务、计划(界面统称"下载")、缓存卷(W2-C:/download-tasks、/acquisition-plans、/download-cache/cleanup、/dashboard)
|
// 下载任务、计划(界面统称"下载")、缓存卷(W2-C:/download-tasks、/acquisition-plans、/download-cache/cleanup、/dashboard)
|
||||||
|
|
||||||
@@ -181,7 +181,12 @@ export function useDownloaderNames() {
|
|||||||
queryKey: ["downloads", "instances"],
|
queryKey: ["downloads", "instances"],
|
||||||
staleTime: 5 * 60 * TTL_SECOND,
|
staleTime: 5 * 60 * TTL_SECOND,
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
apiParsed(cursorPage(DownloaderInstance), "/api/v1/downloader-instances", { params: { limit: 200 }, signal }),
|
fetchAllCursorPages((cursor) =>
|
||||||
|
apiParsed(cursorPage(DownloaderInstance), "/api/v1/downloader-instances", {
|
||||||
|
params: { limit: 200, cursor },
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
),
|
||||||
select: (d) => new Map(d.items.map((i) => [i.id, i.name])),
|
select: (d) => new Map(d.items.map((i) => [i.id, i.name])),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type EngineRunFilters = {
|
|||||||
status?: string;
|
status?: string;
|
||||||
/** 触发来源,单值或逗号分隔多值(user,api_key) */
|
/** 触发来源,单值或逗号分隔多值(user,api_key) */
|
||||||
triggeredBy?: string;
|
triggeredBy?: string;
|
||||||
|
/** 单值或逗号分隔多个工作流名称 */
|
||||||
workflowName?: string;
|
workflowName?: string;
|
||||||
businessKind?: EngineBusinessKind;
|
businessKind?: EngineBusinessKind;
|
||||||
businessRefId?: string;
|
businessRefId?: string;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TTL_SECOND } from "~/lib/api.ts";
|
import { TTL_SECOND } from "~/lib/api.ts";
|
||||||
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
|
import { apiMutate, apiParsed, cleanParams, fetchAllCursorPages } from "./parse.ts";
|
||||||
|
|
||||||
// Feed 来源(W2-B:/feed-sources)
|
// Feed 来源(W2-B:/feed-sources)
|
||||||
|
|
||||||
@@ -25,7 +25,9 @@ export function useFeeds(f: { siteId?: string; health?: FeedHealth } = {}) {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: feedKeys.list(f),
|
queryKey: feedKeys.list(f),
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
apiParsed(FeedPage, "/api/v1/feed-sources", { params: cleanParams({ ...f, limit: 200 }), signal }),
|
fetchAllCursorPages((cursor) =>
|
||||||
|
apiParsed(FeedPage, "/api/v1/feed-sources", { params: cleanParams({ ...f, limit: 200, cursor }), signal }),
|
||||||
|
),
|
||||||
refetchInterval: 30 * TTL_SECOND,
|
refetchInterval: 30 * TTL_SECOND,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,14 +24,16 @@ import type {
|
|||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import { apiData } from "~/lib/api.ts";
|
import { apiData } from "~/lib/api.ts";
|
||||||
import { type QueryArea, qk } from "~/lib/query-keys.ts";
|
import { type QueryArea, qk } from "~/lib/query-keys.ts";
|
||||||
|
import { fetchAllCursorPages } from "./parse.ts";
|
||||||
|
|
||||||
/** 实例类资源的标准 CRUD 钩子(列表取前 200 条,首版实例数量很少) */
|
/** 实例类资源的标准 CRUD 钩子(列表按游标取齐配置) */
|
||||||
function crudHooks<Row extends { id: string }, Create, Update>(area: QueryArea, base: string) {
|
function crudHooks<Row extends { id: string }, Create, Update>(area: QueryArea, base: string) {
|
||||||
return {
|
return {
|
||||||
useList: () =>
|
useList: () =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: qk.list(area),
|
queryKey: qk.list(area),
|
||||||
queryFn: ({ signal }) => apiData<CursorPage<Row>>(base, { params: { limit: 200 }, signal }),
|
queryFn: ({ signal }) =>
|
||||||
|
fetchAllCursorPages((cursor) => apiData<CursorPage<Row>>(base, { params: { limit: 200, cursor }, signal })),
|
||||||
}),
|
}),
|
||||||
useCreate: () => {
|
useCreate: () => {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -100,14 +102,18 @@ export const pathMappings = {
|
|||||||
export function useSiteOptions() {
|
export function useSiteOptions() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: qk.list("sites", { limit: 200 }),
|
queryKey: qk.list("sites", { limit: 200 }),
|
||||||
queryFn: ({ signal }) => apiData<CursorPage<Site>>("/sites", { params: { limit: 200 }, signal }),
|
queryFn: ({ signal }) =>
|
||||||
|
fetchAllCursorPages((cursor) => apiData<CursorPage<Site>>("/sites", { params: { limit: 200, cursor }, signal })),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSiteAccounts() {
|
export function useSiteAccounts() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: qk.list("site-accounts"),
|
queryKey: qk.list("site-accounts"),
|
||||||
queryFn: ({ signal }) => apiData<CursorPage<SiteAccount>>("/site-accounts", { params: { limit: 200 }, signal }),
|
queryFn: ({ signal }) =>
|
||||||
|
fetchAllCursorPages((cursor) =>
|
||||||
|
apiData<CursorPage<SiteAccount>>("/site-accounts", { params: { limit: 200, cursor }, signal }),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { ApiRequestError, api, type QueryParams } from "~/lib/api.ts";
|
import { ApiRequestError, api, type QueryParams } from "~/lib/api.ts";
|
||||||
|
import { fetchAllCursorPages } from "./parse.ts";
|
||||||
|
|
||||||
// 媒体库:列表 / 详情 / 作品详情(内容单元、别名、字段来源)与作品级操作。
|
// 媒体库:列表 / 详情 / 作品详情(内容单元、别名、字段来源)与作品级操作。
|
||||||
// 所有响应都按 contracts 的 zod schema 校验,结构不符按错误处理(不静默渲染半截数据)。
|
// 所有响应都按 contracts 的 zod schema 校验,结构不符按错误处理(不静默渲染半截数据)。
|
||||||
@@ -106,7 +107,8 @@ const RootPage = cursorPage(LibraryRoot);
|
|||||||
export function useLibraryRoots() {
|
export function useLibraryRoots() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: libraryKeys.roots,
|
queryKey: libraryKeys.roots,
|
||||||
queryFn: ({ signal }) => getParsed(RootPage, "/library-roots", { limit: 200 }, signal),
|
queryFn: ({ signal }) =>
|
||||||
|
fetchAllCursorPages((cursor) => getParsed(RootPage, "/library-roots", { limit: 200, cursor }, signal)),
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
ClassificationConfigView,
|
||||||
|
type OrganizeSourceInput,
|
||||||
|
OrganizeSourceList,
|
||||||
|
OrganizeSourceView,
|
||||||
|
OrganizeStockResult,
|
||||||
|
} from "@umt/contracts";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { apiParsed } from "./parse.ts";
|
||||||
|
|
||||||
|
export function useOrganizeSources() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["organize-sources"],
|
||||||
|
queryFn: ({ signal }) => apiParsed(OrganizeSourceList, "/organize-sources", { signal }),
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function useSaveOrganizeSource() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: string; body: OrganizeSourceInput }) =>
|
||||||
|
apiParsed(OrganizeSourceView, `/organize-sources/${id}`, { method: "PUT", body }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["organize-sources"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function useRemoveOrganizeSource() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiParsed(z.object({ deleted: z.boolean() }), `/organize-sources/${id}`, { method: "DELETE" }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["organize-sources"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function useOrganizeStock() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, path }: { id: string; path?: string }) =>
|
||||||
|
apiParsed(OrganizeStockResult, `/organize-sources/${id}/stock`, { method: "POST", body: { path } }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function useClassification() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["classification"],
|
||||||
|
queryFn: ({ signal }) => apiParsed(ClassificationConfigView, "/classification", { signal }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function useSaveClassification() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (yaml: string) =>
|
||||||
|
apiParsed(ClassificationConfigView, "/classification", { method: "PUT", body: { yaml } }),
|
||||||
|
onSuccess: (data) => qc.setQueryData(["classification"], data),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { fetchAllCursorPages } from "./parse.ts";
|
||||||
|
|
||||||
|
describe("fetchAllCursorPages", () => {
|
||||||
|
it("keeps configuration rows beyond the first cursor page", async () => {
|
||||||
|
const fetchPage = vi.fn(async (cursor?: string) =>
|
||||||
|
cursor
|
||||||
|
? { items: [201, 202], nextCursor: null, total: 202 }
|
||||||
|
: { items: Array.from({ length: 200 }, (_, i) => i + 1), nextCursor: "page-2", total: 202 },
|
||||||
|
);
|
||||||
|
const result = await fetchAllCursorPages(fetchPage);
|
||||||
|
expect(result.items).toHaveLength(202);
|
||||||
|
expect(result.items.slice(-2)).toEqual([201, 202]);
|
||||||
|
expect(result.total).toBe(202);
|
||||||
|
expect(result.nextCursor).toBeNull();
|
||||||
|
expect(fetchPage.mock.calls).toEqual([[], ["page-2"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a repeated cursor instead of looping indefinitely", async () => {
|
||||||
|
const fetchPage = vi.fn(async () => ({ items: [1], nextCursor: "same" }));
|
||||||
|
await expect(fetchAllCursorPages(fetchPage)).rejects.toThrow("分页游标重复");
|
||||||
|
expect(fetchPage).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates a later page failure instead of returning a partial configuration", async () => {
|
||||||
|
const failure = new Error("aborted");
|
||||||
|
await expect(
|
||||||
|
fetchAllCursorPages(async (cursor) => {
|
||||||
|
if (cursor) throw failure;
|
||||||
|
return { items: [1], nextCursor: "next" };
|
||||||
|
}),
|
||||||
|
).rejects.toBe(failure);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,3 +37,21 @@ export function cleanParams(p: Record<string, string | number | boolean | undefi
|
|||||||
for (const [k, v] of Object.entries(p)) if (v !== undefined && v !== null && v !== "") out[k] = v;
|
for (const [k, v] of Object.entries(p)) if (v !== undefined && v !== null && v !== "") out[k] = v;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 小规模配置集合:跟随游标取齐,保留列表返回形状供选择器与本地分页共用。 */
|
||||||
|
export async function fetchAllCursorPages<P extends { items: unknown[]; nextCursor?: string | null }>(
|
||||||
|
fetchPage: (cursor?: string) => Promise<P>,
|
||||||
|
): Promise<P> {
|
||||||
|
const first = await fetchPage();
|
||||||
|
const items = [...first.items];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let cursor = first.nextCursor;
|
||||||
|
while (cursor) {
|
||||||
|
if (seen.has(cursor)) throw new ApiRequestError("internal", "分页游标重复,请重试");
|
||||||
|
seen.add(cursor);
|
||||||
|
const page = await fetchPage(cursor);
|
||||||
|
items.push(...page.items);
|
||||||
|
cursor = page.nextCursor;
|
||||||
|
}
|
||||||
|
return { ...first, items, nextCursor: null };
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} 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";
|
||||||
|
import { fetchAllCursorPages } from "./parse.ts";
|
||||||
|
|
||||||
// 策略版本:草案 → 预览 → 授权发布 → 版本化 / 回滚(REQ §6.4)
|
// 策略版本:草案 → 预览 → 授权发布 → 版本化 / 回滚(REQ §6.4)
|
||||||
|
|
||||||
@@ -24,7 +25,8 @@ const Page = cursorPage(PolicyRevision);
|
|||||||
export function usePolicyRevisions(filters: PolicyFilters = {}) {
|
export function usePolicyRevisions(filters: PolicyFilters = {}) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: policyKeys.list(filters),
|
queryKey: policyKeys.list(filters),
|
||||||
queryFn: ({ signal }) => getParsed(Page, "/policy-revisions", { ...filters, limit: 200 }, signal),
|
queryFn: ({ signal }) =>
|
||||||
|
fetchAllCursorPages((cursor) => getParsed(Page, "/policy-revisions", { ...filters, limit: 200, cursor }, signal)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} 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";
|
||||||
|
import { fetchAllCursorPages } from "./parse.ts";
|
||||||
|
|
||||||
// 偏好标签与抑制记录(REQ §6.7):停用/删除/语义变更都会留抑制记录,Agent 不得据旧证据自动加回。
|
// 偏好标签与抑制记录(REQ §6.7):停用/删除/语义变更都会留抑制记录,Agent 不得据旧证据自动加回。
|
||||||
|
|
||||||
@@ -29,7 +30,10 @@ const SupPage = cursorPage(PreferenceSuppression);
|
|||||||
export function usePreferenceTags(filters: TagFilters = {}) {
|
export function usePreferenceTags(filters: TagFilters = {}) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: preferenceKeys.tags(filters),
|
queryKey: preferenceKeys.tags(filters),
|
||||||
queryFn: ({ signal }) => getParsed(TagPage, "/preference-tags", { ...filters, limit: 200 }, signal),
|
queryFn: ({ signal }) =>
|
||||||
|
fetchAllCursorPages((cursor) =>
|
||||||
|
getParsed(TagPage, "/preference-tags", { ...filters, limit: 200, cursor }, signal),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,12 +41,14 @@ export function useSuppressions(active?: boolean) {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: preferenceKeys.suppressions(active),
|
queryKey: preferenceKeys.suppressions(active),
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
|
fetchAllCursorPages((cursor) =>
|
||||||
getParsed(
|
getParsed(
|
||||||
SupPage,
|
SupPage,
|
||||||
"/preference-suppressions",
|
"/preference-suppressions",
|
||||||
{ active: active === undefined ? undefined : String(active), limit: 200 },
|
{ active: active === undefined ? undefined : String(active), limit: 200, cursor },
|
||||||
signal,
|
signal,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
PublicOrganizationRecord,
|
PublicOrganizationRecord,
|
||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import { TTL_SECOND } from "~/lib/api.ts";
|
import { TTL_SECOND } from "~/lib/api.ts";
|
||||||
import { apiMutate, apiParsed, cleanParams } from "./parse.ts";
|
import { apiMutate, apiParsed, cleanParams, fetchAllCursorPages } from "./parse.ts";
|
||||||
|
|
||||||
// 整理记录(W2-C:/organization-records;读 organize.read,发起 organize.execute)
|
// 整理记录(W2-C:/organization-records;读 organize.read,发起 organize.execute)
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ const RecordPage = cursorPage(PublicOrganizationRecord);
|
|||||||
|
|
||||||
export type RecordFilters = {
|
export type RecordFilters = {
|
||||||
status?: OrganizationStatus;
|
status?: OrganizationStatus;
|
||||||
|
phase?: "active" | "history";
|
||||||
trigger?: OrganizationTrigger;
|
trigger?: OrganizationTrigger;
|
||||||
mediaEntityId?: string;
|
mediaEntityId?: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
@@ -145,6 +146,7 @@ export const TRIGGER_LABEL: Record<OrganizationTrigger, string> = { auto: "自
|
|||||||
/** 整理方式 / 文件操作(记录的 action) */
|
/** 整理方式 / 文件操作(记录的 action) */
|
||||||
export const ACTION_LABEL = {
|
export const ACTION_LABEL = {
|
||||||
copy: "复制",
|
copy: "复制",
|
||||||
|
in_place: "原地入库",
|
||||||
move: "移动",
|
move: "移动",
|
||||||
rename: "重命名",
|
rename: "重命名",
|
||||||
relocate: "目录调整",
|
relocate: "目录调整",
|
||||||
@@ -174,7 +176,9 @@ export function useLibraryRoots() {
|
|||||||
queryKey: ["records", "library-roots"],
|
queryKey: ["records", "library-roots"],
|
||||||
staleTime: 5 * 60 * TTL_SECOND,
|
staleTime: 5 * 60 * TTL_SECOND,
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
apiParsed(cursorPage(LibraryRoot), "/api/v1/library-roots", { params: { limit: 200 }, signal }),
|
fetchAllCursorPages((cursor) =>
|
||||||
|
apiParsed(cursorPage(LibraryRoot), "/api/v1/library-roots", { params: { limit: 200, cursor }, signal }),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
type ManualAcquisitionRequest,
|
||||||
ManualAcquisitionResult,
|
ManualAcquisitionResult,
|
||||||
type ResolutionStatus,
|
type ResolutionStatus,
|
||||||
WorkbenchReleaseDetail,
|
WorkbenchReleaseDetail,
|
||||||
@@ -81,7 +82,7 @@ export function useRefreshReleaseDetail() {
|
|||||||
export function useSubmitRelease() {
|
export function useSubmitRelease() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body: { sourceReleaseId: string; slotId?: string; entityId?: string; note?: string }) =>
|
mutationFn: (body: ManualAcquisitionRequest) =>
|
||||||
apiMutate("/api/v1/acquisition-plans", { body }).then((d) => ManualAcquisitionResult.parse(d)),
|
apiMutate("/api/v1/acquisition-plans", { body }).then((d) => ManualAcquisitionResult.parse(d)),
|
||||||
onSuccess: (r) => {
|
onSuccess: (r) => {
|
||||||
if (r.submitted) void qc.invalidateQueries({ queryKey: ["downloads"] });
|
if (r.submitted) void qc.invalidateQueries({ queryKey: ["downloads"] });
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} 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";
|
||||||
|
import { fetchAllCursorPages } from "./parse.ts";
|
||||||
|
|
||||||
// 规则(W9-D,docs/runbook/rules.md):规则组 = 收录过滤条件 + 偏好标签合一;自定义条件;三套默认链;抑制记录。
|
// 规则(W9-D,docs/runbook/rules.md):规则组 = 收录过滤条件 + 偏好标签合一;自定义条件;三套默认链;抑制记录。
|
||||||
// 写入给 ruleString(优先)或 layers;PATCH 带 expectedRevision,不匹配 409。
|
// 写入给 ruleString(优先)或 layers;PATCH 带 expectedRevision,不匹配 409。
|
||||||
@@ -53,7 +54,8 @@ const SupPage = cursorPage(PreferenceSuppression);
|
|||||||
export function useRuleGroups(filters: RuleGroupFilters = {}) {
|
export function useRuleGroups(filters: RuleGroupFilters = {}) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ruleKeys.groups(filters),
|
queryKey: ruleKeys.groups(filters),
|
||||||
queryFn: ({ signal }) => getParsed(GroupPage, "/rules", { ...filters, limit: 200 }, signal),
|
queryFn: ({ signal }) =>
|
||||||
|
fetchAllCursorPages((cursor) => getParsed(GroupPage, "/rules", { ...filters, limit: 200, cursor }, signal)),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -78,7 +80,8 @@ export function useRuleKeywords() {
|
|||||||
export function useRuleAtoms() {
|
export function useRuleAtoms() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ruleKeys.atoms,
|
queryKey: ruleKeys.atoms,
|
||||||
queryFn: ({ signal }) => getParsed(AtomPage, "/rules/atoms", { limit: 200 }, signal),
|
queryFn: ({ signal }) =>
|
||||||
|
fetchAllCursorPages((cursor) => getParsed(AtomPage, "/rules/atoms", { limit: 200, cursor }, signal)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +95,8 @@ export function useRuleDefaults() {
|
|||||||
export function useRuleSuppressions() {
|
export function useRuleSuppressions() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ruleKeys.suppressions,
|
queryKey: ruleKeys.suppressions,
|
||||||
queryFn: ({ signal }) => getParsed(SupPage, "/rules/suppressions", { limit: 200 }, signal),
|
queryFn: ({ signal }) =>
|
||||||
|
fetchAllCursorPages((cursor) => getParsed(SupPage, "/rules/suppressions", { limit: 200, cursor }, signal)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
} from "@umt/contracts";
|
} from "@umt/contracts";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TTL_SECOND } from "~/lib/api.ts";
|
import { TTL_SECOND } from "~/lib/api.ts";
|
||||||
import { apiMutate, apiParsed } from "./parse.ts";
|
import { apiMutate, apiParsed, fetchAllCursorPages } from "./parse.ts";
|
||||||
|
|
||||||
// 站点、站点账号、站点快照(W2-B:/sites、/site-accounts、/sites/{id}/snapshot(s))
|
// 站点、站点账号、站点快照(W2-B:/sites、/site-accounts、/sites/{id}/snapshot(s))
|
||||||
// 站点中心(W9-A):include=accounts、凭证 PUT、健康检测、刷新登录、用户数据、删除
|
// 站点中心(W9-A):include=accounts、凭证 PUT、健康检测、刷新登录、用户数据、删除
|
||||||
@@ -41,12 +41,14 @@ export const siteKeys = {
|
|||||||
loginHistory: (accountId: string) => ["sites", "login-history", accountId] as const,
|
loginHistory: (accountId: string) => ["sites", "login-history", accountId] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 站点列表(附账号与整体账号状态,站点页一次请求) */
|
/** 站点列表(附账号与整体账号状态,站点页按游标取齐配置) */
|
||||||
export function useSites() {
|
export function useSites() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: siteKeys.list(),
|
queryKey: siteKeys.list(),
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200, include: "accounts" }, signal }),
|
fetchAllCursorPages((cursor) =>
|
||||||
|
apiParsed(SitePage, "/api/v1/sites", { params: { limit: 200, include: "accounts", cursor }, signal }),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +64,9 @@ export function useSiteAccounts(siteId?: string) {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: siteKeys.accounts(siteId),
|
queryKey: siteKeys.accounts(siteId),
|
||||||
queryFn: ({ signal }) =>
|
queryFn: ({ signal }) =>
|
||||||
apiParsed(AccountPage, "/api/v1/site-accounts", { params: { limit: 200, siteId }, signal }),
|
fetchAllCursorPages((cursor) =>
|
||||||
|
apiParsed(AccountPage, "/api/v1/site-accounts", { params: { limit: 200, siteId, cursor }, signal }),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} 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";
|
||||||
|
import { fetchAllCursorPages } from "./parse.ts";
|
||||||
|
|
||||||
// 收录目标 Slot:硬条件 / 软偏好 / 回退 / 升级阈值 / 完成条件(REQ §6.1)
|
// 收录目标 Slot:硬条件 / 软偏好 / 回退 / 升级阈值 / 完成条件(REQ §6.1)
|
||||||
|
|
||||||
@@ -24,7 +25,10 @@ export function useSlots(filters: SlotFilters, enabled = true) {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: slotKeys.list(filters),
|
queryKey: slotKeys.list(filters),
|
||||||
enabled,
|
enabled,
|
||||||
queryFn: ({ signal }) => getParsed(Page, "/acquisition-slots", { ...filters, limit: 200 }, signal),
|
queryFn: ({ signal }) =>
|
||||||
|
fetchAllCursorPages((cursor) =>
|
||||||
|
getParsed(Page, "/acquisition-slots", { ...filters, limit: 200, cursor }, signal),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Bot, ChevronRight } from "lucide-react";
|
import { Bot, ChevronRight } from "lucide-react";
|
||||||
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { type AgentRunView, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts";
|
import { type AgentRunView, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts";
|
||||||
import { CHEVRON_CLASS } from "~/components/agentui/disclosure.tsx";
|
import { CHEVRON_CLASS } from "~/components/agentui/disclosure.tsx";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { useListPagination } from "~/components/console/use-list-pagination.ts";
|
||||||
import { Input } from "~/components/ui/input.tsx";
|
import { Input } from "~/components/ui/input.tsx";
|
||||||
|
import { Pagination } from "~/components/ui/pagination.tsx";
|
||||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||||
import { cn } from "~/lib/utils.ts";
|
import { cn } from "~/lib/utils.ts";
|
||||||
import { contextLabel, RunStatusBadge } from "./agent-conversation.tsx";
|
import { contextLabel, RunStatusBadge } from "./agent-conversation.tsx";
|
||||||
@@ -176,10 +177,10 @@ function Row({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 会话列表:搜索 + 全部 / 等你处理 / 失败 + 游标加载(滚到底自动加载)。
|
* 会话列表:搜索 + 全部 / 等你处理 / 失败 + 按需加载的统一分页。
|
||||||
* - hrefFor:会话页左栏,点一条跳到该会话页。
|
* - hrefFor:会话页左栏,点一条跳到该会话页。
|
||||||
* - onOpen:抽屉历史弹层,点一条在抽屉内打开;remember 时筛选与滚动位置下次打开时恢复。
|
* - onOpen:抽屉历史弹层,点一条在抽屉内打开;remember 时筛选与滚动位置下次打开时恢复。
|
||||||
* - limit:历史弹层只列最近 N 条(置顶的算在内),不自动翻页。
|
* - limit:历史弹层的默认每页条数,可调节并翻页查看更早的会话。
|
||||||
*/
|
*/
|
||||||
export function AgentSessionList({
|
export function AgentSessionList({
|
||||||
activeId,
|
activeId,
|
||||||
@@ -208,32 +209,29 @@ export function AgentSessionList({
|
|||||||
const waiting = useAgentRuns("waiting");
|
const waiting = useAgentRuns("waiting");
|
||||||
const loaded = runs.data?.pages.flatMap((p) => p.items) ?? [];
|
const loaded = runs.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
const pending = waiting.data?.pages.flatMap((p) => p.items) ?? [];
|
const pending = waiting.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
const { pinned, rows } = arrangeHistory([...pending.filter(awaitingUser), ...loaded], f, limit);
|
const arranged = arrangeHistory([...pending.filter(awaitingUser), ...loaded], f);
|
||||||
const canLoadMore = !limit && !!runs.hasNextPage;
|
const loadWaiting = f.view !== "failed" && !!waiting.hasNextPage;
|
||||||
|
const canLoadMore = loadWaiting || !!runs.hasNextPage;
|
||||||
|
const paged = useListPagination([...arranged.pinned, ...arranged.rows], {
|
||||||
|
hasMore: canLoadMore,
|
||||||
|
onLoadMore: () => (loadWaiting ? waiting.fetchNextPage() : runs.fetchNextPage()),
|
||||||
|
loadingMore: waiting.isFetchingNextPage || runs.isFetchingNextPage,
|
||||||
|
error: runs.error ?? (loadWaiting ? waiting.error : undefined),
|
||||||
|
resetKey: JSON.stringify(f),
|
||||||
|
defaultPageSize: limit,
|
||||||
|
});
|
||||||
|
const visible = paged.items ?? [];
|
||||||
|
const pinned = f.view === "all" ? visible.filter((row) => awaitingUser(row.run)) : [];
|
||||||
|
const rows = f.view === "all" ? visible.filter((row) => !awaitingUser(row.run)) : visible;
|
||||||
const filtered = !!f.q.trim() || f.view !== "all";
|
const filtered = !!f.q.trim() || f.view !== "all";
|
||||||
|
|
||||||
const scroller = useRef<HTMLDivElement>(null);
|
const scroller = useRef<HTMLDivElement>(null);
|
||||||
const sentinel = useRef<HTMLDivElement>(null);
|
|
||||||
const restored = useRef(!remember);
|
const restored = useRef(!remember);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (restored.current || !rows.length || !scroller.current) return;
|
if (restored.current || !rows.length || !scroller.current) return;
|
||||||
restored.current = true;
|
restored.current = true;
|
||||||
scroller.current.scrollTop = drawerMemory.scroll;
|
scroller.current.scrollTop = drawerMemory.scroll;
|
||||||
});
|
});
|
||||||
const { fetchNextPage, isFetchingNextPage } = runs;
|
|
||||||
useEffect(() => {
|
|
||||||
const el = sentinel.current;
|
|
||||||
if (!el || !canLoadMore || typeof IntersectionObserver === "undefined") return;
|
|
||||||
const io = new IntersectionObserver(
|
|
||||||
(es) => {
|
|
||||||
if (es.some((e) => e.isIntersecting) && !isFetchingNextPage) void fetchNextPage();
|
|
||||||
},
|
|
||||||
{ root: scroller.current, rootMargin: "120px" },
|
|
||||||
);
|
|
||||||
io.observe(el);
|
|
||||||
return () => io.disconnect();
|
|
||||||
}, [canLoadMore, isFetchingNextPage, fetchNextPage]);
|
|
||||||
|
|
||||||
const rowProps = { activeId, hrefFor, onOpen };
|
const rowProps = { activeId, hrefFor, onOpen };
|
||||||
return (
|
return (
|
||||||
<nav aria-label="智能体会话列表" className={cn("flex min-h-0 flex-col", className)}>
|
<nav aria-label="智能体会话列表" className={cn("flex min-h-0 flex-col", className)}>
|
||||||
@@ -305,20 +303,8 @@ export function AgentSessionList({
|
|||||||
</ul>
|
</ul>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{canLoadMore ? (
|
|
||||||
<div ref={sentinel} className="pt-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="w-full"
|
|
||||||
disabled={isFetchingNextPage}
|
|
||||||
onClick={() => void fetchNextPage()}
|
|
||||||
>
|
|
||||||
{isFetchingNextPage ? "加载中…" : "加载更早的会话"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
{visible.length > 0 || canLoadMore ? <Pagination {...paged.pagination} /> : null}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@ import { apiData, errorMessage } from "~/lib/api.ts";
|
|||||||
import { qk } from "~/lib/query-keys.ts";
|
import { qk } from "~/lib/query-keys.ts";
|
||||||
import { agentActive, controllerLabel, credentialBadge, holderName, waitingText } from "./coords.ts";
|
import { agentActive, controllerLabel, credentialBadge, holderName, waitingText } from "./coords.ts";
|
||||||
import { ReconnectPanel, ReloadNotice, useAutoReconnect } from "./reconnect.tsx";
|
import { ReconnectPanel, ReloadNotice, useAutoReconnect } from "./reconnect.tsx";
|
||||||
|
import { ResourceDownload } from "./resource-download.tsx";
|
||||||
import { SiteOriginView } from "./site-origin-view.tsx";
|
import { SiteOriginView } from "./site-origin-view.tsx";
|
||||||
|
|
||||||
// 原站视图桌面模式(docs/design/webtop-remote-browser.md §5.7):iframe 嵌浏览器桌面(Selkies),
|
// 原站视图桌面模式(docs/design/webtop-remote-browser.md §5.7):iframe 嵌浏览器桌面(Selkies),
|
||||||
@@ -208,6 +209,7 @@ function DesktopView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<ResourceDownload session={s} />
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<StatusBadge tone={conn.tone}>{conn.text}</StatusBadge>
|
<StatusBadge tone={conn.tone}>{conn.text}</StatusBadge>
|
||||||
<StatusBadge tone={CONTROLLER_TONE[s.controller] ?? "neutral"}>
|
<StatusBadge tone={CONTROLLER_TONE[s.controller] ?? "neutral"}>
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { BrowserResourceResult, BrowserSessionView } from "@umt/contracts";
|
||||||
|
import { Download, Settings2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useSubmitRelease } from "~/api/releases.ts";
|
||||||
|
import { ManualDownloadDialog } from "~/components/downloads/manual-download-dialog.tsx";
|
||||||
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
|
import { apiData, errorMessage } from "~/lib/api.ts";
|
||||||
|
|
||||||
|
/** 控件位于 iframe/截图外;直接解析当前详情页并交给共同手动下载入口。 */
|
||||||
|
export function ResourceDownload({ session }: { session: BrowserSessionView }) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [resource, setResource] = useState<BrowserResourceResult | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [unrecognized, setUnrecognized] = useState(false);
|
||||||
|
const [planId, setPlanId] = useState<string | null>(null);
|
||||||
|
const submit = useSubmitRelease();
|
||||||
|
const prepare = async (options: boolean) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const r = await apiData<BrowserResourceResult>("/browser/sessions/{id}/resource", {
|
||||||
|
method: "POST",
|
||||||
|
pathParams: { id: session.id },
|
||||||
|
body: {},
|
||||||
|
});
|
||||||
|
setResource(r);
|
||||||
|
setUnrecognized(false);
|
||||||
|
if (options) setOpen(true);
|
||||||
|
else {
|
||||||
|
const result = await submit.mutateAsync({
|
||||||
|
sourceReleaseId: r.release.id,
|
||||||
|
importMode: "normal",
|
||||||
|
replacementAssetIds: [],
|
||||||
|
});
|
||||||
|
if (result.requiresConfirmation) {
|
||||||
|
setUnrecognized(true);
|
||||||
|
setOpen(true);
|
||||||
|
} else if (result.submitted) {
|
||||||
|
setPlanId(result.plan?.id ?? null);
|
||||||
|
toast.success("已提交下载");
|
||||||
|
} else toast.warning(result.rejected.map((v) => v.message).join(";") || "没有提交下载");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(errorMessage(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button size="sm" disabled={busy || session.controller === "closed"} onClick={() => void prepare(false)}>
|
||||||
|
<Download />
|
||||||
|
{busy ? "正在处理…" : "下载当前资源"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy || session.controller === "closed"}
|
||||||
|
onClick={() => void prepare(true)}
|
||||||
|
>
|
||||||
|
<Settings2 />
|
||||||
|
下载选项
|
||||||
|
</Button>
|
||||||
|
{planId ? (
|
||||||
|
<Link className="text-caption underline" to={`/console/downloads?plan=${planId}`}>
|
||||||
|
查看下载任务
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{resource ? (
|
||||||
|
<ManualDownloadDialog
|
||||||
|
key={resource.release.id}
|
||||||
|
resource={{
|
||||||
|
title: resource.release.title,
|
||||||
|
sizeBytes: resource.release.sizeBytes,
|
||||||
|
entityId: resource.release.entity?.id,
|
||||||
|
}}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
initialUnrecognized={unrecognized}
|
||||||
|
submit={(options) => submit.mutateAsync({ ...options, sourceReleaseId: resource.release.id })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ import { Input } from "~/components/ui/input.tsx";
|
|||||||
import { errorMessage } from "~/lib/api.ts";
|
import { errorMessage } from "~/lib/api.ts";
|
||||||
import { controllerLabel, credentialBadge, holderName, toViewport, waitingText } from "./coords.ts";
|
import { controllerLabel, credentialBadge, holderName, toViewport, waitingText } from "./coords.ts";
|
||||||
import { isBrowserGone, ReconnectPanel, ReloadNotice, useAutoReconnect } from "./reconnect.tsx";
|
import { isBrowserGone, ReconnectPanel, ReloadNotice, useAutoReconnect } from "./reconnect.tsx";
|
||||||
|
import { ResourceDownload } from "./resource-download.tsx";
|
||||||
|
|
||||||
// 原站视图(REQ §4.7.1、§4.7.3):同一个 camofox 会话的定时截图 + 人工输入映射。
|
// 原站视图(REQ §4.7.1、§4.7.3):同一个 camofox 会话的定时截图 + 人工输入映射。
|
||||||
// 人工接管前服务端先停掉该会话的自动化输入;交还后 Agent 必须重新读取页面。
|
// 人工接管前服务端先停掉该会话的自动化输入;交还后 Agent 必须重新读取页面。
|
||||||
@@ -153,6 +154,8 @@ export function SiteOriginView({ site }: { site: SiteListItem }) {
|
|||||||
<p className="text-caption text-muted-foreground">验证码等需本人操作在此完成。</p>
|
<p className="text-caption text-muted-foreground">验证码等需本人操作在此完成。</p>
|
||||||
<ReloadNotice session={s} />
|
<ReloadNotice session={s} />
|
||||||
|
|
||||||
|
<ResourceDownload session={s} />
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<StatusBadge tone={conn.tone}>{conn.text}</StatusBadge>
|
<StatusBadge tone={conn.tone}>{conn.text}</StatusBadge>
|
||||||
<StatusBadge tone={CONTROLLER_TONE[s.controller] ?? "neutral"}>
|
<StatusBadge tone={CONTROLLER_TONE[s.controller] ?? "neutral"}>
|
||||||
|
|||||||
@@ -85,6 +85,6 @@ describe("DataTable groupBy:渲染", () => {
|
|||||||
rowKey: (r: Row) => r.id,
|
rowKey: (r: Row) => r.id,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(html).not.toContain("aria-expanded");
|
expect(html).not.toContain('aria-expanded="true"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { ChevronRight, type LucideIcon } from "lucide-react";
|
import { ChevronRight, type LucideIcon } from "lucide-react";
|
||||||
import { Fragment, type ReactNode, useEffect, useMemo, useState } from "react";
|
import { Fragment, type ReactNode, useEffect, useMemo, useState } from "react";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Pagination, type PaginationProps } from "~/components/ui/pagination.tsx";
|
||||||
import { cn } from "~/lib/utils.ts";
|
import { cn } from "~/lib/utils.ts";
|
||||||
import { groupRows, readCollapsed, toggleCollapsed, writeCollapsed } from "./data-table-groups.ts";
|
import { groupRows, readCollapsed, toggleCollapsed, writeCollapsed } from "./data-table-groups.ts";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "./empty-state.tsx";
|
import { EmptyState, ErrorState, LoadingState } from "./empty-state.tsx";
|
||||||
|
import { useListPagination } from "./use-list-pagination.ts";
|
||||||
|
|
||||||
export type DataColumn<T> = {
|
export type DataColumn<T> = {
|
||||||
/** 列 key(React key 用) */
|
/** 列 key(React key 用) */
|
||||||
@@ -33,7 +34,7 @@ const HIDE: Record<NonNullable<DataColumn<unknown>["hideBelow"]>, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 数据表:内置 loading / error / empty 三态与"加载更多"游标翻页。
|
* 数据表:内置 loading / error / empty 三态与统一分页。
|
||||||
* 外层自带 pg-slab 卡片(plain 时不带);表格在卡片内横向滚动,页面本身不溢出。
|
* 外层自带 pg-slab 卡片(plain 时不带);表格在卡片内横向滚动,页面本身不溢出。
|
||||||
*
|
*
|
||||||
* <DataTable columns={cols} rows={q.data?.items} rowKey={(r) => r.id}
|
* <DataTable columns={cols} rows={q.data?.items} rowKey={(r) => r.id}
|
||||||
@@ -43,7 +44,7 @@ const HIDE: Record<NonNullable<DataColumn<unknown>["hideBelow"]>, string> = {
|
|||||||
*/
|
*/
|
||||||
export function DataTable<T>({
|
export function DataTable<T>({
|
||||||
columns,
|
columns,
|
||||||
rows,
|
rows: allRows,
|
||||||
rowKey,
|
rowKey,
|
||||||
loading = false,
|
loading = false,
|
||||||
error,
|
error,
|
||||||
@@ -57,6 +58,8 @@ export function DataTable<T>({
|
|||||||
plain = false,
|
plain = false,
|
||||||
groupBy,
|
groupBy,
|
||||||
className,
|
className,
|
||||||
|
pagination = true,
|
||||||
|
paginationKey,
|
||||||
}: {
|
}: {
|
||||||
columns: DataColumn<T>[];
|
columns: DataColumn<T>[];
|
||||||
rows: T[] | undefined;
|
rows: T[] | undefined;
|
||||||
@@ -73,7 +76,17 @@ export function DataTable<T>({
|
|||||||
plain?: boolean;
|
plain?: boolean;
|
||||||
groupBy?: DataGroupBy<T>;
|
groupBy?: DataGroupBy<T>;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
pagination?: boolean | PaginationProps;
|
||||||
|
paginationKey?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const paged = useListPagination(allRows, {
|
||||||
|
hasMore: pagination === true && hasMore,
|
||||||
|
onLoadMore,
|
||||||
|
loadingMore,
|
||||||
|
error,
|
||||||
|
resetKey: paginationKey ?? (allRows?.[0] ? rowKey(allRows[0]) : ""),
|
||||||
|
});
|
||||||
|
const rows = pagination === true ? paged.items : allRows;
|
||||||
const storageKey = groupBy?.storageKey;
|
const storageKey = groupBy?.storageKey;
|
||||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
||||||
// 折叠态在挂载后读(服务端渲染时没有 localStorage,首屏一律展开)
|
// 折叠态在挂载后读(服务端渲染时没有 localStorage,首屏一律展开)
|
||||||
@@ -217,17 +230,11 @@ export function DataTable<T>({
|
|||||||
return (
|
return (
|
||||||
<div className={cn("min-w-0 overflow-hidden rounded-xl", !plain && "pg-slab", className)}>
|
<div className={cn("min-w-0 overflow-hidden rounded-xl", !plain && "pg-slab", className)}>
|
||||||
{body}
|
{body}
|
||||||
{rows && rows.length > 0 && (hasMore || error) ? (
|
{error && allRows?.length ? (
|
||||||
<div className="flex items-center justify-center gap-3 border-t border-border/60 px-4 py-3">
|
<div className="px-4 py-2 text-meta text-destructive">{String((error as Error).message ?? error)}</div>
|
||||||
{error ? (
|
|
||||||
<span className="text-meta text-destructive">{String((error as Error).message ?? error)}</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
{hasMore && onLoadMore ? (
|
{pagination && allRows ? (
|
||||||
<Button variant="outline" size="sm" onClick={onLoadMore} disabled={loadingMore}>
|
<Pagination {...(typeof pagination === "object" ? pagination : paged.pagination)} />
|
||||||
{loadingMore ? "加载中…" : "加载更多"}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ArrowRight, Bell, CheckCheck } from "lucide-react";
|
|||||||
import { useMemo, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
import { decisionKeys, useDecisionSummary, useDecisions } from "~/api/decisions.ts";
|
import { decisionKeys, useDecisionSummary, useDecisions } from "~/api/decisions.ts";
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||||
import { SEVERITY } from "~/components/library/labels.ts";
|
import { SEVERITY } from "~/components/library/labels.ts";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
import { Button } from "~/components/ui/button.tsx";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
|
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
|
||||||
@@ -59,55 +60,54 @@ export function InboxBell() {
|
|||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="icon"
|
size="icon-sm"
|
||||||
className="relative h-8 w-8"
|
className="relative"
|
||||||
aria-label={total ? `待处理 ${total} 项` : "待处理"}
|
aria-label={total ? `待处理 ${total} 项` : "待处理"}
|
||||||
title={total ? `待处理 ${total} 项` : "待处理"}
|
title={total ? `待处理 ${total} 项` : "待处理"}
|
||||||
>
|
>
|
||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
{total ? (
|
{total ? (
|
||||||
<span className="absolute -top-0.5 -right-0.5 min-w-4 rounded-full bg-warning px-1 text-center text-micro leading-4 font-semibold text-background tabular-nums">
|
<span className="absolute -top-0.5 -right-0.5 min-w-4 rounded-full bg-primary px-1 text-center text-micro leading-4 font-semibold text-primary-foreground tabular-nums">
|
||||||
{total > 99 ? "99+" : total}
|
{total > 99 ? "99+" : total}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent align="end" className="w-[min(22rem,calc(100vw-2rem))] p-0">
|
<PopoverContent
|
||||||
<div className="flex items-center justify-between border-b border-border/60 px-4 py-3">
|
align="end"
|
||||||
|
sideOffset={8}
|
||||||
|
collisionPadding={16}
|
||||||
|
aria-label="待处理事项"
|
||||||
|
className="pg-float-solid flex max-h-[var(--radix-popover-content-available-height)] w-[min(22rem,calc(100vw-2rem))] flex-col overflow-hidden p-2"
|
||||||
|
>
|
||||||
|
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-2 pt-1 pb-2">
|
||||||
<p className="text-sm font-semibold text-foreground">
|
<p className="text-sm font-semibold text-foreground">
|
||||||
待处理
|
待处理
|
||||||
{total ? <span className="ml-1.5 text-warning tabular-nums">{total}</span> : null}
|
{total ? <span className="ml-1.5 font-normal text-muted-foreground tabular-nums">{total}</span> : null}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button variant="ghost" size="xs" onClick={() => go(inboxPath())}>
|
||||||
type="button"
|
|
||||||
onClick={() => go(inboxPath())}
|
|
||||||
className="inline-flex cursor-pointer items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
查看全部
|
查看全部
|
||||||
<ArrowRight className="h-3.5 w-3.5" />
|
<ArrowRight />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{list.isPending ? (
|
{list.isPending ? (
|
||||||
<p className="px-4 py-6 text-center text-caption text-muted-foreground">加载中…</p>
|
<LoadingState className="px-4 py-6" />
|
||||||
) : list.isError ? (
|
) : list.isError ? (
|
||||||
<p className="px-4 py-6 text-center text-caption text-destructive">加载失败,稍后再试</p>
|
<ErrorState plain error="稍后再试" className="py-8" />
|
||||||
) : groups.length === 0 ? (
|
) : groups.length === 0 ? (
|
||||||
<div className="flex flex-col items-center gap-2 px-4 py-8 text-caption text-muted-foreground">
|
<EmptyState plain icon={CheckCheck} title="没有需要处理的事项" className="py-8" />
|
||||||
<CheckCheck className="h-5 w-5" />
|
|
||||||
没有需要处理的事项
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-[60vh] overflow-y-auto py-1">
|
<div className="min-h-0 overflow-y-auto overscroll-contain pt-1">
|
||||||
{groups.map((g) => (
|
{groups.map((g) => (
|
||||||
<section key={g.kind} className="py-1">
|
<section key={g.kind} className="pt-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => go(inboxPath(g.kind))}
|
onClick={() => go(inboxPath(g.kind))}
|
||||||
className="flex w-full cursor-pointer items-center justify-between px-4 py-1 text-meta font-medium text-muted-foreground hover:text-foreground"
|
className="flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
>
|
>
|
||||||
<span>{decisionKindLabel(g.kind)}</span>
|
<span>{decisionKindLabel(g.kind)}</span>
|
||||||
<span className="tabular-nums">{g.count}</span>
|
<span className="tracking-normal tabular-nums">{g.count}</span>
|
||||||
</button>
|
</button>
|
||||||
<ul>
|
<ul>
|
||||||
{g.items.map((d) => (
|
{g.items.map((d) => (
|
||||||
@@ -115,15 +115,13 @@ export function InboxBell() {
|
|||||||
<Link
|
<Link
|
||||||
to={inboxPath(d.kind)}
|
to={inboxPath(d.kind)}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
className="flex items-start gap-2.5 px-4 py-2 hover:bg-foreground/[0.04]"
|
className="block rounded-lg px-2 py-2 outline-none transition-colors hover:bg-foreground/[0.06] focus-visible:bg-foreground/[0.06] focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||||
>
|
>
|
||||||
<span aria-hidden className={cn("mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full", DOT[d.severity])} />
|
<span className="line-clamp-2 text-body break-words text-foreground">{d.title}</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="mt-0.5 flex items-center gap-1.5 text-meta text-muted-foreground">
|
||||||
<span className="line-clamp-2 text-caption text-foreground">{d.title}</span>
|
<span aria-hidden className={cn("h-1.5 w-1.5 shrink-0 rounded-full", DOT[d.severity])} />
|
||||||
<span className="text-meta text-muted-foreground">
|
|
||||||
{SEVERITY[d.severity].label} · {formatRelative(d.createdAt)}
|
{SEVERITY[d.severity].label} · {formatRelative(d.createdAt)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Loaded 100 of 265 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user