diff --git a/Cargo.lock b/Cargo.lock index b974d60..c2ef971 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,29 +19,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "actix-files" -version = "0.6.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8c4f30e3272d7c345f88ae0aac3848507ef5ba871f9cc2a41c8085a0f0523b" -dependencies = [ - "actix-http", - "actix-service", - "actix-utils", - "actix-web", - "bitflags", - "bytes", - "derive_more", - "futures-core", - "http-range", - "log", - "mime", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "v_htmlescape", -] - [[package]] name = "actix-http" version = "3.11.2" @@ -289,6 +266,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "autocomic-rss" +version = "0.1.0" +dependencies = [ + "actix-web", + "bendy", + "cap-std", + "data-encoding", + "feed-rs", + "regex", + "reqwest", + "scraper", + "serde", + "serde_json", + "sha1", + "sqlx", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -1181,12 +1181,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "http-range" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" - [[package]] name = "httparse" version = "1.10.1" @@ -2239,30 +2233,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rust_project" -version = "0.1.0" -dependencies = [ - "actix-files", - "actix-web", - "bendy", - "cap-std", - "data-encoding", - "feed-rs", - "regex", - "reqwest", - "scraper", - "serde", - "serde_json", - "sha1", - "sqlx", - "thiserror", - "tokio", - "tracing", - "tracing-subscriber", - "uuid", -] - [[package]] name = "rustc-demangle" version = "0.1.28" @@ -3246,12 +3216,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "v_htmlescape" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" - [[package]] name = "valuable" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 861eb59..ce2c2c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "rust_project" +name = "autocomic-rss" version = "0.1.0" edition = "2021" license = "GPL-2.0-only" @@ -7,7 +7,6 @@ description = "A Rust RSS subscription and download automation service" [dependencies] actix-web = "4.9" -actix-files = "0.6" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" tokio = { version = "1", features = ["sync", "time", "signal", "rt-multi-thread", "macros"] } diff --git a/README.md b/README.md index 545e311..563ff35 100644 --- a/README.md +++ b/README.md @@ -1,259 +1,61 @@ -# Rust RSS 追番服务 +# AutoComicRss -使用 Rust 独立设计实现的 RSS 订阅与下载自动化项目,采用 **GPL-2.0-only**。 -功能方向参考 [ani-rss](https://github.com/wushuo894/ani-rss),没有移植其 Java 业务代码或前端资源;本项目不是上游的官方 Rust 版本。 +使用 Rust 独立实现的 RSS 自动追番服务,功能方向参考 [ani-rss](https://github.com/wushuo894/ani-rss)。 -当前为后端 MVP:蜜柑搜索 / 手动 RSS → 订阅管理 → RSS/Atom 预览 → 持久化任务 → qBittorrent 提交与状态同步。 -现有 Actix 示例接口继续保留。 +## 功能 -## 运行 +- 搜索蜜柑计划番剧、字幕组和 RSS,也支持手动添加 RSS/Atom 订阅。 +- 预览订阅、筛选资源、识别集数和去重,定时提交下载并同步任务状态。 +- 接入 qBittorrent,支持多个实例和全局切换,旧任务继续由原实例管理。 +- 设置全局存储目的地与路径映射,浏览本地目录、手动核对文件是否存在。 +- 访问蜜柑、RSS 和种子资源时可使用 HTTP / SOCKS5 代理。 + +目前提供后端 API,使用 SQLite 存储数据,尚无管理页面。SMB 直连、MySQL 和其他下载工具尚未实现;不提供视频播放。 + +## 部署 + +准备 Rust 工具链(已在 Rust 1.93.1 验证)和开启 Web UI 的 qBittorrent。 ```sh -cargo run --locked +git clone https://github.com/sentryjun/AutoComicRss.git +cd AutoComicRss +cargo build --release --locked +cp .env.example .env ``` -默认监听 `127.0.0.1:8080`,自动创建 `data/ani.db` 并运行数据库迁移。 -当前 API 供本机使用,没有用户认证;监听地址固定为回环地址。 -首次运行需要下载 Cargo 依赖。已在 Rust 1.93.1 上进行编译验证。 - -按需配置环境变量,参见 [.env.example](.env.example)。程序不自动读取 `.env`: +编辑 `.env`,取消注释并填写下载器配置: ```sh -export QBIT_URL='http://127.0.0.1:8081' -export QBIT_USERNAME='admin' -export QBIT_PASSWORD='你的密码' -export QBIT_SAVE_PATH='/downloads' -cargo run --locked +QBIT_URL='http://127.0.0.1:8081' +QBIT_USERNAME='admin' +QBIT_PASSWORD='你的密码' +QBIT_SAVE_PATH='/downloads' + +# 按需启用网站代理,也可使用 socks5h://127.0.0.1:7897 +# WEBSITE_PROXY_URL='http://127.0.0.1:7897' ``` -qBittorrent 需要启用 Web UI。`QBIT_SAVE_PATH` 是下载器看到的路径。 -`QBIT_*` 只在数据库尚无下载器实例时导入一次,并选为全局下载器。已有实例时,重启或修改这些环境变量不会覆盖保存的实例或选择。 -全新安装不设置 `QBIT_URL` 时,仍可管理订阅、预览和生成未分配任务;首次通过接口选中下载器后绑定这些任务。清空环境变量不会停用数据库中已选中的下载器。 -程序启动后立即执行一轮检查,之后默认每 300 秒检查一次;`POLL_INTERVAL_SECONDS` 最小为 10。 +`QBIT_SAVE_PATH` 是下载器看到的保存路径。`QBIT_*` 仅在首次配置下载器时导入,后续通过 API 管理。网站代理需显式配置,不继承系统代理,也不影响下载器连接。 -## 使用流程 - -### 从蜜柑发现订阅 +程序不会自动读取 `.env`,在项目目录加载配置后启动: ```sh -curl -G 'http://127.0.0.1:8080/api/sources/mikan/search' --data-urlencode 'q=葬送' -curl 'http://127.0.0.1:8080/api/sources/mikan/series/3141' -curl 'http://127.0.0.1:8080/api/sources/mikan/resolve' \ - -H 'Content-Type: application/json' \ - -d '{"url":"https://mikanani.me/Home/Bangumi/3141"}' +set -a +. ./.env +set +a +./target/release/autocomic-rss ``` -搜索返回各季度的独立候选及关键词 RSS。详情返回整部番剧 RSS、字幕组 RSS、站点信息和可用封面地址。 -将详情中的字幕组 `rss_url` 传给 `resolve`,可以获得对应字幕组候选。 -`resolve` 返回的 `subscription` 对象可直接作为下面预览、保存接口的请求体;默认禁用,`season=1` 只是初始值,需核对季度、偏移和筛选规则。 -候选中的来源 ID、字幕组 ID 和范围用于发现流程;当前订阅保存标题、RSS 与规则,尚不单独持久化这些来源元数据。 +默认监听 `127.0.0.1:8080`,每 300 秒检查订阅,数据保存在 `data/ani.db`,启动时自动建库和迁移。更多配置见 [.env.example](.env.example)。同一数据库只运行一个服务实例;备份时停止服务并保存 `data/`,其中包含下载器凭据。 -关键词 RSS 可能跨多部作品,`resolve` 返回 `scope=keyword`、提示和 RSS 地址,`subscription=null`,不会生成单番剧草稿。 -建议选择具体番剧;若手工使用关键词 RSS,需先用规则将内容限定为一部作品并预览。 -搜索、查看详情、解析链接和预览均不创建下载任务。发布条目通过 RSS 预览获取,不从详情 HTML 重复解析。 -这阶段提供后端 API,尚无搜索管理页面或蜜柑账号同步。 - -### 网站代理 +在另一终端检查服务是否正常: ```sh -export WEBSITE_PROXY_URL='http://127.0.0.1:7897' -# 或 socks5h://127.0.0.1:7897,让代理解析目标域名 -export MIKAN_BASE_URL='https://mikanani.me' -cargo run --locked +curl http://127.0.0.1:8080/api/status ``` -网站客户端支持 HTTP、HTTPS、SOCKS5 和 SOCKS5H 代理 URL,可在 URL 内携带编码后的用户名和密码。 -`socks5` 本地解析目标域名,`socks5h` 由代理解析。配置在启动时读取,修改后重启。 -**不继承** `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY` 或 `NO_PROXY`;未设置 `WEBSITE_PROXY_URL` 时直连。 -可用 `WEBSITE_DIRECT_HOSTS` 设置逗号分隔的显式直连域名/IP/CIDR,例如 `localhost,127.0.0.1,::1`,默认没有例外。 -每次重定向重新应用代理及例外;代理失败返回错误,不自动直连。 - -蜜柑搜索、详情、封面、RSS 和种子元数据请求共用网站策略。封面返回本应用地址,由后端获取,浏览器无需直连蜜柑。 -当前仅识别蜜柑同源 `/images/` 下的封面,支持 PNG/JPEG/WebP/GIF;其他图片布局会显示无封面。 -qBittorrent 控制连接独立且始终直连;BT 传输由下载器控制。容器中的 `127.0.0.1` 指容器自身。 -网站基址必须是无路径、查询参数或凭据的 HTTP(S) origin;粘贴解析仅接受该站点的公开搜索/番剧页面或 RSS。 - -### 预览、保存和启用 - -1. 预览真实的 RSS 地址,查看匹配结果。预览不会保存订阅或提交下载。 - -```sh -curl -s http://127.0.0.1:8080/api/preview \ - -H 'Content-Type: application/json' \ - -d '{ - "title": "示例番剧", - "rss_url": "https://example.com/anime.xml", - "season": 1, - "episode_offset": 0, - "include": ["1080p"], - "exclude": ["合集"], - "enabled": false - }' -``` - -2. 将相同 JSON 提交到 `POST /api/subscriptions` 保存订阅。订阅默认禁用。 -3. 确认预览结果后,向 `PUT /api/subscriptions/{id}` 提交完整 JSON,将 `enabled` 改为 `true`。 -4. 等待自动检查,或使用 `POST /api/run` 立即执行一轮 RSS 检查和下载同步。 -5. 使用 `GET /api/jobs` 查看状态、失败原因和重试时间。 - -首次启用会考虑 RSS 当前返回的全部匹配条目,尚未实现“仅从当前最新集开始”。 -`include` 为不区分大小写的关键词“全部满足”,`exclude` 为“任意命中则排除”,不是正则表达式。 -修改订阅规则只影响后续扫描;已入队任务保留创建时的季度、集数和资源地址。禁用订阅会暂停未提交任务。 - -## API - -| 方法 | 路径 | 用途 | -| --- | --- | --- | -| GET | `/api/status` | 版本及下载器是否配置,不返回凭据 | -| GET / POST | `/api/downloaders` | 列出 / 创建下载器实例,响应不返回密码 | -| PUT | `/api/downloaders/active` | `{"downloader_id":"实例 ID"}` 选择接收新任务的全局下载器 | -| POST | `/api/downloaders/{id}/claim-legacy-jobs` | 将升级后尚无归属的旧任务明确关联到恢复的原实例 | -| GET / POST | `/api/destinations` | 列出 / 创建本地媒体目的地 | -| PUT | `/api/destinations/active` | `{"destination_id":"目的地 ID"}` 设置全局目的地 | -| GET / POST | `/api/destinations/{id}/mappings` | 查看历史映射 / 新增映射版本 | -| GET | `/api/destinations/{id}/entries?path=相对目录` | 只读浏览目录,省略 path 为根目录 | -| GET | `/api/sources/mikan/search?q=关键词` | 番剧搜索和关键词 RSS | -| GET | `/api/sources/mikan/series/{id}` | 番剧、字幕组及对应 RSS | -| GET | `/api/sources/mikan/series/{id}/cover` | 经网站网络策略获取封面 | -| POST | `/api/sources/mikan/resolve` | `{"url":"蜜柑地址"}` → 订阅候选 | -| POST | `/api/preview` | 临时预览,不写库、不下载 | -| GET / POST | `/api/subscriptions` | 列出 / 创建订阅 | -| GET / PUT / DELETE | `/api/subscriptions/{id}` | 查询 / 完整替换 / 删除订阅 | -| GET | `/api/subscriptions/{id}/preview` | 预览已保存订阅,包含去重判断 | -| POST | `/api/subscriptions/{id}/scan` | 扫描并入队,要求订阅已启用 | -| GET | `/api/subscriptions/{id}/releases` | 最近最多 500 条扫描记录 | -| GET | `/api/jobs` | 最近最多 500 个任务 | -| GET | `/api/jobs/{id}/files` | 读取已保存的文件清单与核对状态 | -| POST | `/api/jobs/{id}/files/check` | 发现文件清单(首次)并核对本地文件是否存在 | -| POST | `/api/jobs/{id}/retry` | 手动重试 failed / missing 任务 | -| POST | `/api/run` | 执行完整扫描及下载同步 | - -后台处理与手动执行互斥;处理期间修改订阅或再次执行会返回 `409`,稍后重试即可。 -删除订阅为软删除,保留去重与任务记录,不删除下载器任务或媒体文件。已提交任务仍会同步状态。 - -## 下载器实例与切换 - -```sh -curl 'http://127.0.0.1:8080/api/downloaders' \ - -H 'Content-Type: application/json' \ - -d '{"name":"NAS qBittorrent","kind":"qbittorrent","connection":{"url":"http://nas:8081","username":"admin","password":"你的密码","save_path":"/downloads"}}' - -# 将返回的 id 填入下面的 downloader_id。 -curl -X PUT 'http://127.0.0.1:8080/api/downloaders/active' \ - -H 'Content-Type: application/json' -d '{"downloader_id":"返回的实例 ID"}' -``` - -创建实例不会自动切换全局选择。任务在扫描入队的同一个事务中保存 `downloader_id`;已有归属的排队任务、重试和重启恢复都使用原实例。 -切换后继续同步原实例;一个实例连接失败会报告该实例 ID,并继续处理其他实例,不把连接失败标成任务丢失。 -原实例的地址与写入路径不随全局选择改变。本阶段只实现多个 qBittorrent 实例,不提供实例编辑/删除;第二种工具与凭据维护接口另行扩展。 -实例凭据保存在 SQLite 数据库中,尚未加密;数据库及其备份应按含凭据的配置文件管理。列表和创建响应只返回 `password_configured`。 - -### 从旧版本升级 - -升级迁移为旧任务增加实例归属字段。首次导入时,应提供**旧任务实际使用的原 `QBIT_*` 配置**,应用会将它与旧任务关联;不能在升级时把这些值替换成另一台下载器。 -若升级时没有提供原配置,历史任务保留 `legacy_unassigned=true`,不会自动绑定以后新选中的实例;`/api/status` 的 `legacy_unassigned_jobs` 及运行报告会提示数量。 -恢复原实例后,显式调用 `POST /api/downloaders/{原实例ID}/claim-legacy-jobs` 才建立关联。该操作仅认领没有归属的旧任务,不迁移已有归属,也不立即提交下载;后续同步先核对原实例的任务。 - -## 全局媒体目的地与本地文件核对 - -先配置并选中下载器,再创建一个本地目的地,配置该下载器的写入路径映射,最后激活目的地: - -```sh -curl 'http://127.0.0.1:8080/api/destinations' \ - -H 'Content-Type: application/json' \ - -d '{"name":"番剧目录","kind":"local","root_path":"/mnt/nas/anime"}' - -curl 'http://127.0.0.1:8080/api/destinations/目的地ID/mappings' \ - -H 'Content-Type: application/json' \ - -d '{"downloader_id":"下载器ID","download_root":"/downloads/anime"}' - -curl -X PUT 'http://127.0.0.1:8080/api/destinations/active' \ - -H 'Content-Type: application/json' -d '{"destination_id":"目的地ID"}' -``` - -`root_path` 是 Rust 应用所在环境可访问的绝对目录;`download_root` 是同一个位置在下载器环境中的绝对路径。 -配置映射不会挂载、复制或上传文件,也不会创建这些目录。两个路径必须实际指向同一批文件。 -同一时刻所有新任务统一使用全局目的地,订阅不能覆盖。切换目的地或下载器前,必须存在所需映射,否则返回 `400`,不会静默使用默认目录。 - -每次 `POST mappings` 都追加一个版本;新任务使用最新版本,旧任务保留 `destination_id`、`mapping_id` 和 `download_save_path`。 -目的地读取根目录和已有映射版本保持不变;更改读取根需创建新目的地。即使任务尚未提交、后来重试或应用重启,也不重新解释其存储位置。 -同一下载器里已存在相同 hash,但保存路径与任务快照不一致时,任务报告冲突,不复用错误位置的下载、不自动移动种子。 - -未启用目的地时继续使用原下载器 `save_path`,保持旧配置可用。旧任务不会自动绑定后来创建的目的地;没有可靠位置的任务核对为 `unchecked`。本阶段没有旧任务目的地补录或文件迁移接口。 - -```sh -curl -G 'http://127.0.0.1:8080/api/destinations/目的地ID/entries' --data-urlencode 'path=Series' -curl -X POST 'http://127.0.0.1:8080/api/jobs/任务ID/files/check' -curl 'http://127.0.0.1:8080/api/jobs/任务ID/files' -``` - -首次核对向原下载器读取文件清单,并核实其保存路径与快照一致。清单使用 [qBittorrent 文件接口](https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-%28qBittorrent-5.0%29#get-torrent-contents) 提供的相对文件名和大小,排除优先级为 0 的文件;不从发布标题猜文件名。 -清单成功保存后,后续核对直接访问记录的本地路径,下载器离线也能执行。清单不会随下载器外部改名或移动而自动改写;本阶段不提供清单刷新或路径迁移。 -核对由上述 POST 接口显式触发,后台下载同步暂不自动扫描文件系统。 - -| 状态 | 含义 | -| --- | --- | -| `unchecked` | 尚未核对,或未记录目的地、种子元数据/已选文件尚不可用 | -| `present` | 成功访问并找到所有记录的普通文件 | -| `absent` | 成功访问根目录,至少一个记录的文件不存在 | -| `unavailable` | 根目录不可访问、权限不足、路径越界、映射不匹配或首次读取下载器清单失败 | - -汇总时 `unavailable` 优先于 `absent`;每个文件分别记录结果。响应含本次尝试时间 `checked_at` 和最后成功核对时间 `last_success_at`,失败不会抹去后者。 -存在状态独立于下载状态;文件缺失不会触发重下载或删除。返回预期/观察大小供查看,`present` 不保证大小一致或内容完整,也不将下载标记为完成。 -目录浏览最多返回 1000 项,达到上限时 `truncated=true`;单个任务清单上限 5000 项。不读取文件内容、不提供播放。 - -本地访问通过 [cap-std 的目录能力接口](https://docs.rs/cap-std/4.0.3/cap_std/fs/struct.Dir.html) 限定在配置根目录内。相对路径拒绝绝对地址、`..`、盘符和反斜杠;符号链接不能越出根目录。根目录若离线而仍保留可访问的空挂载点,本阶段不具备挂载状态识别能力。 -P2 仅支持进程可访问的本地目录。SMB 应用直连属于 P3,尚未实现。 - -## 当前规则与恢复行为 - -- 支持常见 `[03]`、`- 03`、`EP03`、`第03话`、`S01E03` 以及 `03v2` 标题。 -- 合集、分数集数、模糊或冲突的集数标记为 `needs_review`;目前可通过调整源或规则解决,尚无人工集数映射接口。 -- RSS enclosure、Atom enclosure、磁力链接及 `.torrent` 链接可作为资源入口;尚未覆盖所有站点私有 XML 扩展。 -- 支持 BitTorrent v1 磁力链接(十六进制或 Base32)及 v1/hybrid 种子;纯 v2 尚不支持。 -- 同一订阅内按资源地址及“季度 + 集数”去重。相同集数有多个版本时采用 RSS 中先出现的条目,尚不自动洗版。 -- 保存原始资源标题、地址、识别结果、过滤原因;种子文件按原始 info 字节计算 hash,并保存实际提交的种子数据。 -- 下载前先持久化 hash 和提交意图,再调用下载器。超时或重启后先查询 qBittorrent,按 hash 核对,不盲目重复提交。 -- 网络失败按 30 秒起、最多一小时的退避间隔重试;实际执行时间还受扫描周期影响。无效种子进入 `failed`,需手动重试。 -- 已提交任务若从下载器消失,标记为 `missing`,不会自动重新下载。完成任务记录保留,不因再次扫描而重建。 -- RSS/种子响应限制为 4 MiB,请求超时 30 秒。下载器 Cookie 与 RSS 请求使用不同客户端。 -- 蜜柑 HTML 上限 8 MiB,封面上限 4 MiB;成功搜索/详情缓存 60 秒、最多 64 项,最多同时执行 4 个蜜柑请求,超限返回 `429`。HTTP 重定向最多 5 次。 -- 无搜索结果返回空列表;页面无法识别、访问受限及上游失败返回 `502`,不会伪装成空列表。公开页面结构变化可能需要更新蜜柑适配器。 - -本地事务不能与下载器 API 组成原子事务。恢复依赖原下载器实例中的稳定 info hash 和下载器去重;同一数据库仅运行一个服务实例。使用全局选择接口切换不会改变恢复目标。 - -## 结构与验证 - -```text -src/api.rs HTTP 接口 -src/domain.rs 订阅、资源、任务模型与输入校验 -src/parser.rs 标题与集数识别 -src/feeds.rs RSS/Atom 获取、解析、过滤 -src/mikan.rs 蜜柑搜索、详情、订阅候选与封面 -src/media.rs 本地文件访问、路径校验与媒体数据结构 -src/media_store.rs 目的地、版本化映射、文件清单与核对记录持久化 -src/storage.rs SQLite 持久化与去重 -src/downloader.rs 种子标识与 qBittorrent 适配 -src/service.rs 扫描、任务恢复与后台调度 -migrations/ 数据库迁移 -tests/fixtures/ 人工构造的 RSS 测试样本 -``` - -```sh -cargo fmt --check -cargo test --locked -cargo clippy --locked --all-targets -- -D warnings -``` - -集成测试使用回环地址上的模拟蜜柑 / RSS / 代理 / qBittorrent 服务和临时 SQLite 数据库,不连接真实下载器、不下载媒体。 -覆盖过滤、保守集数解析、重复扫描、数据库重开、提交成功但响应失败后的恢复、下载器不可用、禁用订阅、任务移除与 API CRUD。 -新增覆盖中文搜索编码、不同季度、字幕组 ID、RSS 候选到保存、访问限制页面、封面与缓存、代理认证及跨域重定向、直连例外和代理失败处理。 -下载器归属测试覆盖切换时已排队任务、同 hash 不同实例、单实例离线、重试、重启,以及真实旧版 SQLite 数据库升级后的显式归属恢复。 -P2 使用临时本地文件与模拟下载器,覆盖目的地/映射版本快照、实际提交路径、同 hash 不同路径冲突、文件四态、成功核对时间保留、下载器离线核对、目录浏览与路径/符号链接限制。没有连接真实 NAS 或下载器。 - -已实现全局媒体目的地与本地文件存在核对。后续将支持 SMB 应用直连、第二种下载工具、MySQL、管理页面和部署。 +当前服务没有用户认证,仅监听本机。需要远程访问时,请使用带认证的反向代理或受控隧道。 ## 许可证 -本项目按 GNU General Public License version 2 only 发布,完整条款见 [LICENSE](LICENSE)。 -新增 Rust 源文件使用 `SPDX-License-Identifier: GPL-2.0-only` 标识。第三方依赖遵循各自许可证。 +[GPL-2.0-only](LICENSE)。本项目独立设计实现,并非 ani-rss 的官方 Rust 版本。 diff --git a/src/api.rs b/src/api.rs deleted file mode 100644 index 6429952..0000000 --- a/src/api.rs +++ /dev/null @@ -1,248 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -use crate::{ - domain::{DownloaderInput, SubscriptionInput}, - error::{Error, Result}, - media::{self, DestinationInput, MappingInput}, - service::Service, -}; -use actix_web::{web, HttpResponse}; -use std::sync::Arc; - -type State = web::Data>; - -pub fn configure(cfg: &mut web::ServiceConfig) { - cfg.service( - web::scope("/api") - .route("/sources/mikan/search", web::get().to(mikan_search)) - .route("/sources/mikan/series/{id}", web::get().to(mikan_series)) - .route( - "/sources/mikan/series/{id}/cover", - web::get().to(mikan_cover), - ) - .route("/sources/mikan/resolve", web::post().to(mikan_resolve)) - .route("/status", web::get().to(status)) - .route("/destinations", web::get().to(destinations)) - .route("/destinations", web::post().to(create_destination)) - .route("/destinations/active", web::put().to(activate_destination)) - .route("/destinations/{id}/mappings", web::get().to(mappings)) - .route( - "/destinations/{id}/mappings", - web::post().to(create_mapping), - ) - .route( - "/destinations/{id}/entries", - web::get().to(directory_entries), - ) - .route("/downloaders", web::get().to(downloaders)) - .route("/downloaders", web::post().to(create_downloader)) - .route("/downloaders/active", web::put().to(activate_downloader)) - .route( - "/downloaders/{id}/claim-legacy-jobs", - web::post().to(claim_legacy_jobs), - ) - .route("/preview", web::post().to(preview)) - .route("/subscriptions", web::get().to(list)) - .route("/subscriptions", web::post().to(create)) - .route("/subscriptions/{id}", web::get().to(get)) - .route("/subscriptions/{id}", web::put().to(update)) - .route("/subscriptions/{id}", web::delete().to(delete)) - .route("/subscriptions/{id}/preview", web::get().to(preview_saved)) - .route("/subscriptions/{id}/scan", web::post().to(scan)) - .route("/subscriptions/{id}/releases", web::get().to(releases)) - .route("/jobs", web::get().to(jobs)) - .route("/jobs/{id}/files", web::get().to(job_files)) - .route("/jobs/{id}/files/check", web::post().to(check_job_files)) - .route("/jobs/{id}/retry", web::post().to(retry)) - .route("/run", web::post().to(run)), - ); -} - -#[derive(serde::Deserialize)] -struct SearchQuery { - q: String, -} - -async fn mikan_search(state: State, query: web::Query) -> Result { - Ok(HttpResponse::Ok().json(state.mikan.search(&query.q).await?)) -} - -async fn mikan_series(state: State, id: web::Path) -> Result { - Ok(HttpResponse::Ok().json(state.mikan.series(&id).await?)) -} - -async fn mikan_cover(state: State, id: web::Path) -> Result { - let (mime, bytes) = state.mikan.cover(&id).await?; - Ok(HttpResponse::Ok() - .content_type(mime) - .insert_header(("X-Content-Type-Options", "nosniff")) - .insert_header(("Cache-Control", "private, max-age=60")) - .body(bytes)) -} - -#[derive(serde::Deserialize)] -#[serde(deny_unknown_fields)] -struct ResolveInput { - url: String, -} - -async fn mikan_resolve(state: State, body: web::Json) -> Result { - Ok(HttpResponse::Ok().json(state.mikan.resolve(&body.url).await?)) -} - -async fn status(state: State) -> Result { - let active = state.store.active_downloader_id().await?; - Ok(HttpResponse::Ok().json(serde_json::json!({ - "downloader_configured": active.is_some(), - "active_downloader_id": active, - "active_destination_id": state.store.active_destination_id().await?, - "legacy_unassigned_jobs": state.store.legacy_unassigned_count().await?, - "version": env!("CARGO_PKG_VERSION") - }))) -} - -async fn destinations(state: State) -> Result { - Ok(HttpResponse::Ok().json(state.store.destinations().await?)) -} - -async fn create_destination( - state: State, - body: web::Json, -) -> Result { - let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; - Ok(HttpResponse::Created().json(state.store.create_destination(body.into_inner()).await?)) -} - -#[derive(serde::Deserialize)] -#[serde(deny_unknown_fields)] -struct DestinationSelection { - destination_id: String, -} - -async fn activate_destination( - state: State, - body: web::Json, -) -> Result { - let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; - state - .store - .activate_destination(&body.destination_id) - .await?; - Ok(HttpResponse::NoContent().finish()) -} - -async fn mappings(state: State, id: web::Path) -> Result { - Ok(HttpResponse::Ok().json(state.store.mappings(&id).await?)) -} - -async fn create_mapping( - state: State, - id: web::Path, - body: web::Json, -) -> Result { - let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; - Ok(HttpResponse::Created().json(state.store.create_mapping(&id, body.into_inner()).await?)) -} - -#[derive(serde::Deserialize)] -struct DirectoryQuery { - #[serde(default)] - path: String, -} - -async fn directory_entries( - state: State, - id: web::Path, - query: web::Query, -) -> Result { - media::validate_relative(&query.path, true)?; - let root = state.store.destination(&id).await?.root_path; - let path = query.into_inner().path; - let entries = tokio::task::spawn_blocking(move || media::list_local(&root, &path)) - .await - .map_err(|_| Error::Upstream("local directory query failed".into()))?; - Ok(HttpResponse::Ok().json(entries)) -} - -async fn job_files(state: State, id: web::Path) -> Result { - Ok(HttpResponse::Ok().json(state.store.file_report(&id).await?)) -} - -async fn check_job_files(state: State, id: web::Path) -> Result { - Ok(HttpResponse::Ok().json(state.check_job_files(&id).await?)) -} - -async fn downloaders(state: State) -> Result { - Ok(HttpResponse::Ok().json(state.store.downloaders().await?)) -} - -async fn create_downloader(state: State, body: web::Json) -> Result { - let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; - Ok(HttpResponse::Created().json(state.store.create_downloader(body.into_inner()).await?)) -} - -#[derive(serde::Deserialize)] -#[serde(deny_unknown_fields)] -struct DownloaderSelection { - downloader_id: String, -} - -async fn activate_downloader( - state: State, - body: web::Json, -) -> Result { - let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; - state.store.activate_downloader(&body.downloader_id).await?; - Ok(HttpResponse::NoContent().finish()) -} - -async fn claim_legacy_jobs(state: State, id: web::Path) -> Result { - let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; - Ok(HttpResponse::Ok() - .json(serde_json::json!({"claimed": state.store.claim_legacy_jobs(&id).await?}))) -} -async fn list(state: State) -> Result { - Ok(HttpResponse::Ok().json(state.store.subscriptions().await?)) -} -async fn get(state: State, id: web::Path) -> Result { - Ok(HttpResponse::Ok().json(state.store.subscription(&id).await?)) -} -async fn create(state: State, body: web::Json) -> Result { - let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; - Ok(HttpResponse::Created().json(state.store.create(body.into_inner()).await?)) -} -async fn update( - state: State, - id: web::Path, - body: web::Json, -) -> Result { - let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; - Ok(HttpResponse::Ok().json(state.store.update(&id, body.into_inner()).await?)) -} -async fn delete(state: State, id: web::Path) -> Result { - let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; - state.store.delete(&id).await?; - Ok(HttpResponse::NoContent().finish()) -} -async fn preview(state: State, body: web::Json) -> Result { - Ok(HttpResponse::Ok().json(state.preview(body.into_inner()).await?)) -} -async fn preview_saved(state: State, id: web::Path) -> Result { - Ok(HttpResponse::Ok().json(state.preview_subscription(&id).await?)) -} -async fn scan(state: State, id: web::Path) -> Result { - Ok(HttpResponse::Ok().json(serde_json::json!({"queued": state.scan_subscription(&id).await?}))) -} -async fn releases(state: State, id: web::Path) -> Result { - Ok(HttpResponse::Ok().json(state.store.releases(&id).await?)) -} -async fn jobs(state: State) -> Result { - Ok(HttpResponse::Ok().json(state.store.jobs().await?)) -} -async fn retry(state: State, id: web::Path) -> Result { - let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; - state.store.retry(&id).await?; - Ok(HttpResponse::NoContent().finish()) -} -async fn run(state: State) -> Result { - Ok(HttpResponse::Ok().json(state.cycle().await?)) -} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..d796544 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-2.0-only +//! Application startup and graceful shutdown. +use crate::{ + config::Config, + http, + service::{worker, Service}, + storage::Store, +}; +use actix_web::{web, App, HttpServer}; +use std::sync::Arc; + +pub async fn run(config: Config) -> std::io::Result<()> { + std::fs::create_dir_all("data")?; + let store = Store::open(&config.database_url) + .await + .map_err(std::io::Error::other)?; + let service = Arc::new( + Service::new(store, &config) + .await + .map_err(std::io::Error::other)?, + ); + let app_service = service.clone(); + let server = HttpServer::new(move || { + App::new() + .app_data(web::Data::new(app_service.clone())) + .app_data(web::JsonConfig::default().limit(32 * 1024)) + .configure(http::configure) + }) + .bind(("127.0.0.1", config.port))?; + + let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); + let background = tokio::spawn(worker(service, config.poll_interval, shutdown_rx)); + + tracing::info!(address = %format!("127.0.0.1:{}", config.port), "AutoComicRss started"); + + let result = server.run().await; + let _ = shutdown_tx.send(true); + let _ = background.await; + result +} diff --git a/src/parser.rs b/src/domain/episode.rs similarity index 100% rename from src/parser.rs rename to src/domain/episode.rs diff --git a/src/domain.rs b/src/domain/mod.rs similarity index 98% rename from src/domain.rs rename to src/domain/mod.rs index 9785202..6584792 100644 --- a/src/domain.rs +++ b/src/domain/mod.rs @@ -1,4 +1,7 @@ // SPDX-License-Identifier: GPL-2.0-only +//! Subscription, release and download job models. +pub mod episode; + use crate::error::{Error, Result}; use serde::{Deserialize, Serialize}; diff --git a/src/downloaders/mod.rs b/src/downloaders/mod.rs new file mode 100644 index 0000000..55b70df --- /dev/null +++ b/src/downloaders/mod.rs @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: GPL-2.0-only +//! Downloader protocols and torrent metadata preparation. +pub mod qbittorrent; +pub mod torrent; diff --git a/src/downloaders/qbittorrent.rs b/src/downloaders/qbittorrent.rs new file mode 100644 index 0000000..6257624 --- /dev/null +++ b/src/downloaders/qbittorrent.rs @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: GPL-2.0-only +use crate::{ + config::QbitConfig, + error::{Error, Result}, + network::bounded_body, +}; +use reqwest::{multipart, Client}; +use serde::Deserialize; +use std::time::Duration; + +#[derive(Clone)] +pub struct Qbit { + client: Client, + config: QbitConfig, +} + +#[derive(Deserialize)] +pub struct TorrentStatus { + pub hash: String, + pub progress: f64, + pub state: String, + #[serde(default)] + pub save_path: Option, +} + +#[derive(Deserialize)] +pub struct TorrentFile { + pub name: String, + pub size: i64, + pub priority: i32, +} + +impl Qbit { + pub fn new(config: QbitConfig) -> Result { + // Keep downloader cookies in a separate client from untrusted RSS hosts. + let client = Client::builder() + .no_proxy() + .cookie_store(true) + .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build()?; + Ok(Self { client, config }) + } + + pub async fn login(&self) -> Result<()> { + let response = self + .client + .post(format!("{}/api/v2/auth/login", self.config.url)) + .header("Referer", format!("{}/", self.config.url)) + .form(&[ + ("username", &self.config.username), + ("password", &self.config.password), + ]) + .send() + .await?; + let bytes = bounded_body(response, 1024).await?; + if bytes != b"Ok." { + return Err(Error::Upstream("qBittorrent login failed".into())); + } + Ok(()) + } + + pub async fn torrents(&self) -> Result> { + let response = self + .client + .get(format!("{}/api/v2/torrents/info", self.config.url)) + .header("Referer", format!("{}/", self.config.url)) + .send() + .await?; + let bytes = bounded_body(response, 8 * 1024 * 1024).await?; + serde_json::from_slice(&bytes) + .map_err(|_| Error::Upstream("invalid qBittorrent task response".into())) + } + + pub async fn files(&self, hash: &str) -> Result> { + let response = self + .client + .get(format!("{}/api/v2/torrents/files", self.config.url)) + .header("Referer", format!("{}/", self.config.url)) + .query(&[("hash", hash)]) + .send() + .await?; + let bytes = bounded_body(response, 8 * 1024 * 1024).await?; + let files: Vec = serde_json::from_slice(&bytes) + .map_err(|_| Error::Upstream("invalid qBittorrent file list".into()))?; + if files.len() > 5000 || files.iter().any(|file| file.size < 0) { + return Err(Error::Upstream( + "qBittorrent file list exceeds limits or contains invalid sizes".into(), + )); + } + Ok(files) + } + + pub async fn add( + &self, + url: &str, + bytes: Option>, + save_path: Option<&str>, + ) -> Result<()> { + let mut form = multipart::Form::new() + .text( + "savepath", + save_path.unwrap_or(&self.config.save_path).to_owned(), + ) + .text("tags", "rust-project") + .text("autoTMM", "false"); + form = if let Some(bytes) = bytes { + form.part( + "torrents", + multipart::Part::bytes(bytes) + .file_name("release.torrent") + .mime_str("application/x-bittorrent")?, + ) + } else { + form.text("urls", url.to_owned()) + }; + let response = self + .client + .post(format!("{}/api/v2/torrents/add", self.config.url)) + .header("Referer", format!("{}/", self.config.url)) + .multipart(form) + .send() + .await?; + let bytes = bounded_body(response, 1024).await?; + if bytes != b"Ok." { + return Err(Error::Upstream("qBittorrent rejected torrent".into())); + } + Ok(()) + } +} diff --git a/src/downloader.rs b/src/downloaders/torrent.rs similarity index 52% rename from src/downloader.rs rename to src/downloaders/torrent.rs index 04c1b1e..9657af6 100644 --- a/src/downloader.rs +++ b/src/downloaders/torrent.rs @@ -1,15 +1,12 @@ // SPDX-License-Identifier: GPL-2.0-only use crate::{ - config::QbitConfig, domain::validate_http_url, error::{Error, Result}, - feeds::bounded_body, + network::{bounded_body, website_get}, }; use bendy::decoding::{Decoder, Object}; -use reqwest::{multipart, Client, Url}; -use serde::Deserialize; +use reqwest::{Client, Url}; use sha1::{Digest, Sha1}; -use std::time::Duration; pub struct PreparedTorrent { pub hash: String, @@ -100,8 +97,7 @@ pub async fn prepare(client: &Client, url: &str) -> Result { }); } validate_http_url(url)?; - let response = - crate::feeds::website_get(client, Url::parse(url).expect("validated URL")).await?; + let response = website_get(client, Url::parse(url).expect("validated URL")).await?; let bytes = bounded_body(response, 4 * 1024 * 1024).await?; Ok(PreparedTorrent { hash: torrent_hash(&bytes)?, @@ -109,127 +105,6 @@ pub async fn prepare(client: &Client, url: &str) -> Result { }) } -#[derive(Clone)] -pub struct Qbit { - client: Client, - config: QbitConfig, -} - -#[derive(Deserialize)] -pub struct TorrentStatus { - pub hash: String, - pub progress: f64, - pub state: String, - #[serde(default)] - pub save_path: Option, -} - -#[derive(Deserialize)] -pub struct TorrentFile { - pub name: String, - pub size: i64, - pub priority: i32, -} - -impl Qbit { - pub fn new(config: QbitConfig) -> Result { - // Keep downloader cookies in a separate client from untrusted RSS hosts. - let client = Client::builder() - .no_proxy() - .cookie_store(true) - .timeout(Duration::from_secs(30)) - .redirect(reqwest::redirect::Policy::none()) - .build()?; - Ok(Self { client, config }) - } - - pub async fn login(&self) -> Result<()> { - let response = self - .client - .post(format!("{}/api/v2/auth/login", self.config.url)) - .header("Referer", format!("{}/", self.config.url)) - .form(&[ - ("username", &self.config.username), - ("password", &self.config.password), - ]) - .send() - .await?; - let bytes = bounded_body(response, 1024).await?; - if bytes != b"Ok." { - return Err(Error::Upstream("qBittorrent login failed".into())); - } - Ok(()) - } - - pub async fn torrents(&self) -> Result> { - let response = self - .client - .get(format!("{}/api/v2/torrents/info", self.config.url)) - .header("Referer", format!("{}/", self.config.url)) - .send() - .await?; - let bytes = bounded_body(response, 8 * 1024 * 1024).await?; - serde_json::from_slice(&bytes) - .map_err(|_| Error::Upstream("invalid qBittorrent task response".into())) - } - - pub async fn files(&self, hash: &str) -> Result> { - let response = self - .client - .get(format!("{}/api/v2/torrents/files", self.config.url)) - .header("Referer", format!("{}/", self.config.url)) - .query(&[("hash", hash)]) - .send() - .await?; - let bytes = bounded_body(response, 8 * 1024 * 1024).await?; - let files: Vec = serde_json::from_slice(&bytes) - .map_err(|_| Error::Upstream("invalid qBittorrent file list".into()))?; - if files.len() > 5000 || files.iter().any(|file| file.size < 0) { - return Err(Error::Upstream( - "qBittorrent file list exceeds limits or contains invalid sizes".into(), - )); - } - Ok(files) - } - - pub async fn add( - &self, - url: &str, - bytes: Option>, - save_path: Option<&str>, - ) -> Result<()> { - let mut form = multipart::Form::new() - .text( - "savepath", - save_path.unwrap_or(&self.config.save_path).to_owned(), - ) - .text("tags", "rust-project") - .text("autoTMM", "false"); - form = if let Some(bytes) = bytes { - form.part( - "torrents", - multipart::Part::bytes(bytes) - .file_name("release.torrent") - .mime_str("application/x-bittorrent")?, - ) - } else { - form.text("urls", url.to_owned()) - }; - let response = self - .client - .post(format!("{}/api/v2/torrents/add", self.config.url)) - .header("Referer", format!("{}/", self.config.url)) - .multipart(form) - .send() - .await?; - let bytes = bounded_body(response, 1024).await?; - if bytes != b"Ok." { - return Err(Error::Upstream("qBittorrent rejected torrent".into())); - } - Ok(()) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/error.rs b/src/error.rs index 16fb18b..86541a4 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,4 @@ // SPDX-License-Identifier: GPL-2.0-only -use actix_web::{http::StatusCode, HttpResponse, ResponseError}; pub type Result = std::result::Result; @@ -28,20 +27,3 @@ impl From for Error { Self::Upstream(value.without_url().to_string()) } } - -impl ResponseError for Error { - fn status_code(&self) -> StatusCode { - match self { - Self::Invalid(_) => StatusCode::BAD_REQUEST, - Self::NotFound => StatusCode::NOT_FOUND, - Self::Busy => StatusCode::CONFLICT, - Self::SourceBusy => StatusCode::TOO_MANY_REQUESTS, - Self::Upstream(_) => StatusCode::BAD_GATEWAY, - _ => StatusCode::INTERNAL_SERVER_ERROR, - } - } - - fn error_response(&self) -> HttpResponse { - HttpResponse::build(self.status_code()).json(serde_json::json!({"error": self.to_string()})) - } -} diff --git a/src/http/destinations.rs b/src/http/destinations.rs new file mode 100644 index 0000000..943004a --- /dev/null +++ b/src/http/destinations.rs @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-2.0-only +use super::State; +use crate::{ + error::{Error, Result}, + media::{self, DestinationInput, MappingInput}, +}; +use actix_web::{web, HttpResponse}; + +pub(super) async fn destinations(state: State) -> Result { + Ok(HttpResponse::Ok().json(state.store.destinations().await?)) +} + +pub(super) async fn create_destination( + state: State, + body: web::Json, +) -> Result { + let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; + Ok(HttpResponse::Created().json(state.store.create_destination(body.into_inner()).await?)) +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct DestinationSelection { + destination_id: String, +} + +pub(super) async fn activate_destination( + state: State, + body: web::Json, +) -> Result { + let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; + state + .store + .activate_destination(&body.destination_id) + .await?; + Ok(HttpResponse::NoContent().finish()) +} + +pub(super) async fn mappings(state: State, id: web::Path) -> Result { + Ok(HttpResponse::Ok().json(state.store.mappings(&id).await?)) +} + +pub(super) async fn create_mapping( + state: State, + id: web::Path, + body: web::Json, +) -> Result { + let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; + Ok(HttpResponse::Created().json(state.store.create_mapping(&id, body.into_inner()).await?)) +} + +#[derive(serde::Deserialize)] +pub(super) struct DirectoryQuery { + #[serde(default)] + path: String, +} + +pub(super) async fn directory_entries( + state: State, + id: web::Path, + query: web::Query, +) -> Result { + media::validate_relative(&query.path, true)?; + let root = state.store.destination(&id).await?.root_path; + let path = query.into_inner().path; + let entries = tokio::task::spawn_blocking(move || media::list_local(&root, &path)) + .await + .map_err(|_| Error::Upstream("local directory query failed".into()))?; + Ok(HttpResponse::Ok().json(entries)) +} diff --git a/src/http/downloaders.rs b/src/http/downloaders.rs new file mode 100644 index 0000000..a9029b8 --- /dev/null +++ b/src/http/downloaders.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-2.0-only +use super::State; +use crate::{ + domain::DownloaderInput, + error::{Error, Result}, +}; +use actix_web::{web, HttpResponse}; + +pub(super) async fn downloaders(state: State) -> Result { + Ok(HttpResponse::Ok().json(state.store.downloaders().await?)) +} + +pub(super) async fn create_downloader( + state: State, + body: web::Json, +) -> Result { + let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; + Ok(HttpResponse::Created().json(state.store.create_downloader(body.into_inner()).await?)) +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct DownloaderSelection { + downloader_id: String, +} + +pub(super) async fn activate_downloader( + state: State, + body: web::Json, +) -> Result { + let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; + state.store.activate_downloader(&body.downloader_id).await?; + Ok(HttpResponse::NoContent().finish()) +} + +pub(super) async fn claim_legacy_jobs(state: State, id: web::Path) -> Result { + let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; + Ok(HttpResponse::Ok() + .json(serde_json::json!({"claimed": state.store.claim_legacy_jobs(&id).await?}))) +} diff --git a/src/http/error.rs b/src/http/error.rs new file mode 100644 index 0000000..5a02be9 --- /dev/null +++ b/src/http/error.rs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-2.0-only +use crate::error::Error; +use actix_web::{http::StatusCode, HttpResponse, ResponseError}; + +impl ResponseError for Error { + fn status_code(&self) -> StatusCode { + match self { + Self::Invalid(_) => StatusCode::BAD_REQUEST, + Self::NotFound => StatusCode::NOT_FOUND, + Self::Busy => StatusCode::CONFLICT, + Self::SourceBusy => StatusCode::TOO_MANY_REQUESTS, + Self::Upstream(_) => StatusCode::BAD_GATEWAY, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()).json(serde_json::json!({"error": self.to_string()})) + } +} diff --git a/src/http/jobs.rs b/src/http/jobs.rs new file mode 100644 index 0000000..364570c --- /dev/null +++ b/src/http/jobs.rs @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-2.0-only +use super::State; +use crate::error::{Error, Result}; +use actix_web::{web, HttpResponse}; + +pub(super) async fn job_files(state: State, id: web::Path) -> Result { + Ok(HttpResponse::Ok().json(state.store.file_report(&id).await?)) +} + +pub(super) async fn check_job_files(state: State, id: web::Path) -> Result { + Ok(HttpResponse::Ok().json(state.check_job_files(&id).await?)) +} + +pub(super) async fn jobs(state: State) -> Result { + Ok(HttpResponse::Ok().json(state.store.jobs().await?)) +} +pub(super) async fn retry(state: State, id: web::Path) -> Result { + let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; + state.store.retry(&id).await?; + Ok(HttpResponse::NoContent().finish()) +} diff --git a/src/http/mikan.rs b/src/http/mikan.rs new file mode 100644 index 0000000..e1948a8 --- /dev/null +++ b/src/http/mikan.rs @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-2.0-only +use super::State; +use crate::error::Result; +use actix_web::{web, HttpResponse}; + +#[derive(serde::Deserialize)] +pub(super) struct SearchQuery { + q: String, +} + +pub(super) async fn mikan_search( + state: State, + query: web::Query, +) -> Result { + Ok(HttpResponse::Ok().json(state.mikan.search(&query.q).await?)) +} + +pub(super) async fn mikan_series(state: State, id: web::Path) -> Result { + Ok(HttpResponse::Ok().json(state.mikan.series(&id).await?)) +} + +pub(super) async fn mikan_cover(state: State, id: web::Path) -> Result { + let (mime, bytes) = state.mikan.cover(&id).await?; + Ok(HttpResponse::Ok() + .content_type(mime) + .insert_header(("X-Content-Type-Options", "nosniff")) + .insert_header(("Cache-Control", "private, max-age=60")) + .body(bytes)) +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ResolveInput { + url: String, +} + +pub(super) async fn mikan_resolve( + state: State, + body: web::Json, +) -> Result { + Ok(HttpResponse::Ok().json(state.mikan.resolve(&body.url).await?)) +} diff --git a/src/http/mod.rs b/src/http/mod.rs new file mode 100644 index 0000000..19e9626 --- /dev/null +++ b/src/http/mod.rs @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: GPL-2.0-only +//! HTTP routes and request/response translation. +mod destinations; +mod downloaders; +mod error; +mod jobs; +mod mikan; +mod subscriptions; +mod system; + +use crate::service::Service; +use actix_web::web; +use std::sync::Arc; + +type State = web::Data>; + +pub fn configure(cfg: &mut web::ServiceConfig) { + cfg.route("/health", web::get().to(system::health)); + cfg.service( + web::scope("/api") + .route("/sources/mikan/search", web::get().to(mikan::mikan_search)) + .route( + "/sources/mikan/series/{id}", + web::get().to(mikan::mikan_series), + ) + .route( + "/sources/mikan/series/{id}/cover", + web::get().to(mikan::mikan_cover), + ) + .route( + "/sources/mikan/resolve", + web::post().to(mikan::mikan_resolve), + ) + .route("/status", web::get().to(system::status)) + .route("/destinations", web::get().to(destinations::destinations)) + .route( + "/destinations", + web::post().to(destinations::create_destination), + ) + .route( + "/destinations/active", + web::put().to(destinations::activate_destination), + ) + .route( + "/destinations/{id}/mappings", + web::get().to(destinations::mappings), + ) + .route( + "/destinations/{id}/mappings", + web::post().to(destinations::create_mapping), + ) + .route( + "/destinations/{id}/entries", + web::get().to(destinations::directory_entries), + ) + .route("/downloaders", web::get().to(downloaders::downloaders)) + .route( + "/downloaders", + web::post().to(downloaders::create_downloader), + ) + .route( + "/downloaders/active", + web::put().to(downloaders::activate_downloader), + ) + .route( + "/downloaders/{id}/claim-legacy-jobs", + web::post().to(downloaders::claim_legacy_jobs), + ) + .route("/preview", web::post().to(subscriptions::preview)) + .route("/subscriptions", web::get().to(subscriptions::list)) + .route("/subscriptions", web::post().to(subscriptions::create)) + .route("/subscriptions/{id}", web::get().to(subscriptions::get)) + .route("/subscriptions/{id}", web::put().to(subscriptions::update)) + .route( + "/subscriptions/{id}", + web::delete().to(subscriptions::delete), + ) + .route( + "/subscriptions/{id}/preview", + web::get().to(subscriptions::preview_saved), + ) + .route( + "/subscriptions/{id}/scan", + web::post().to(subscriptions::scan), + ) + .route( + "/subscriptions/{id}/releases", + web::get().to(subscriptions::releases), + ) + .route("/jobs", web::get().to(jobs::jobs)) + .route("/jobs/{id}/files", web::get().to(jobs::job_files)) + .route( + "/jobs/{id}/files/check", + web::post().to(jobs::check_job_files), + ) + .route("/jobs/{id}/retry", web::post().to(jobs::retry)) + .route("/run", web::post().to(system::run)), + ); +} diff --git a/src/http/subscriptions.rs b/src/http/subscriptions.rs new file mode 100644 index 0000000..6bac8fe --- /dev/null +++ b/src/http/subscriptions.rs @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-2.0-only +use super::State; +use crate::{ + domain::SubscriptionInput, + error::{Error, Result}, +}; +use actix_web::{web, HttpResponse}; + +pub(super) async fn list(state: State) -> Result { + Ok(HttpResponse::Ok().json(state.store.subscriptions().await?)) +} +pub(super) async fn get(state: State, id: web::Path) -> Result { + Ok(HttpResponse::Ok().json(state.store.subscription(&id).await?)) +} +pub(super) async fn create( + state: State, + body: web::Json, +) -> Result { + let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; + Ok(HttpResponse::Created().json(state.store.create(body.into_inner()).await?)) +} +pub(super) async fn update( + state: State, + id: web::Path, + body: web::Json, +) -> Result { + let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; + Ok(HttpResponse::Ok().json(state.store.update(&id, body.into_inner()).await?)) +} +pub(super) async fn delete(state: State, id: web::Path) -> Result { + let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; + state.store.delete(&id).await?; + Ok(HttpResponse::NoContent().finish()) +} +pub(super) async fn preview( + state: State, + body: web::Json, +) -> Result { + Ok(HttpResponse::Ok().json(state.preview(body.into_inner()).await?)) +} +pub(super) async fn preview_saved(state: State, id: web::Path) -> Result { + Ok(HttpResponse::Ok().json(state.preview_subscription(&id).await?)) +} +pub(super) async fn scan(state: State, id: web::Path) -> Result { + Ok(HttpResponse::Ok().json(serde_json::json!({"queued": state.scan_subscription(&id).await?}))) +} +pub(super) async fn releases(state: State, id: web::Path) -> Result { + Ok(HttpResponse::Ok().json(state.store.releases(&id).await?)) +} diff --git a/src/http/system.rs b/src/http/system.rs new file mode 100644 index 0000000..65fb1a8 --- /dev/null +++ b/src/http/system.rs @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-2.0-only +use super::State; +use crate::error::Result; +use actix_web::HttpResponse; + +pub(super) async fn status(state: State) -> Result { + let active = state.store.active_downloader_id().await?; + Ok(HttpResponse::Ok().json(serde_json::json!({ + "downloader_configured": active.is_some(), + "active_downloader_id": active, + "active_destination_id": state.store.active_destination_id().await?, + "legacy_unassigned_jobs": state.store.legacy_unassigned_count().await?, + "version": env!("CARGO_PKG_VERSION") + }))) +} + +pub(super) async fn run(state: State) -> Result { + Ok(HttpResponse::Ok().json(state.cycle().await?)) +} + +pub(super) async fn health() -> HttpResponse { + HttpResponse::Ok() + .json(serde_json::json!({"status": "ok", "version": env!("CARGO_PKG_VERSION")})) +} diff --git a/src/lib.rs b/src/lib.rs index bdaf1a4..ce06413 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,13 +1,13 @@ // SPDX-License-Identifier: GPL-2.0-only -pub mod api; +//! AutoComicRss: RSS subscriptions, download automation and media presence checks. +pub mod app; pub mod config; pub mod domain; -pub mod downloader; +pub mod downloaders; pub mod error; -pub mod feeds; +pub mod http; pub mod media; -mod media_store; -pub mod mikan; -pub mod parser; +mod network; pub mod service; +pub mod sources; pub mod storage; diff --git a/src/main.rs b/src/main.rs index b0ae52d..57602a3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,83 +1,5 @@ // SPDX-License-Identifier: GPL-2.0-only -use actix_web::{web, App, HttpResponse, HttpServer, Responder}; -use rust_project::{ - api, - config::Config, - service::{worker, Service}, - storage::Store, -}; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -#[derive(Debug, Serialize, Deserialize)] -struct Greeting { - message: String, - name: String, -} - -#[derive(Debug, Serialize, Deserialize)] -struct Health { - status: String, - version: String, -} - -#[derive(Debug, Serialize, Deserialize)] -struct CalcRequest { - a: f64, - b: f64, - operation: String, -} - -#[derive(Debug, Serialize, Deserialize)] -struct CalcResponse { - result: f64, - operation: String, -} - -async fn hello() -> impl Responder { - HttpResponse::Ok().body("Hello World! 欢迎使用 Rust Web 服务器") -} - -async fn health_check() -> impl Responder { - let health = Health { - status: "ok".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - }; - HttpResponse::Ok().json(health) -} - -async fn greet(name: web::Path) -> impl Responder { - let greeting = Greeting { - message: format!("你好,{}!", name), - name: name.into_inner(), - }; - HttpResponse::Ok().json(greeting) -} - -async fn calculate(web::Json(req): web::Json) -> impl Responder { - let result = match req.operation.as_str() { - "add" => req.a + req.b, - "subtract" => req.a - req.b, - "multiply" => req.a * req.b, - "divide" => { - if req.b == 0.0 { - return HttpResponse::BadRequest().json("除数不能为 0"); - } - req.a / req.b - } - _ => return HttpResponse::BadRequest().json("不支持的操作"), - }; - - let response = CalcResponse { - result, - operation: req.operation, - }; - HttpResponse::Ok().json(response) -} - -async fn echo(data: web::Json) -> impl Responder { - HttpResponse::Ok().json(data) -} +use autocomic_rss::{app, config::Config}; #[actix_web::main] async fn main() -> std::io::Result<()> { @@ -87,66 +9,5 @@ async fn main() -> std::io::Result<()> { ) .init(); let config = Config::from_env().map_err(std::io::Error::other)?; - std::fs::create_dir_all("data")?; - let store = Store::open(&config.database_url) - .await - .map_err(std::io::Error::other)?; - let service = Arc::new( - Service::new(store, &config) - .await - .map_err(std::io::Error::other)?, - ); - let app_service = service.clone(); - let server = HttpServer::new(move || { - App::new() - .app_data(web::Data::new(app_service.clone())) - .app_data(web::JsonConfig::default().limit(32 * 1024)) - .route("/", web::get().to(hello)) - .route("/health", web::get().to(health_check)) - .route("/greet/{name}", web::get().to(greet)) - .route("/api/echo", web::post().to(echo)) - .route("/api/calculate", web::post().to(calculate)) - .configure(api::configure) - .route( - "/info", - web::get().to(|| async { - HttpResponse::Ok().json(serde_json::json!({ - "project": env!("CARGO_PKG_NAME"), - "version": env!("CARGO_PKG_VERSION"), - "endpoints": [ - { "method": "GET", "path": "/", "description": "欢迎页面" }, - { "method": "GET", "path": "/health", "description": "健康检查" }, - { "method": "GET", "path": "/greet/{name}", "description": "问候接口" }, - { "method": "POST", "path": "/api/echo", "description": "回显数据" }, - { "method": "POST", "path": "/api/calculate", "description": "计算接口" } - ] - })) - }), - ) - }) - .bind(("127.0.0.1", config.port))?; - - let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); - let background = tokio::spawn(worker(service, config.poll_interval, shutdown_rx)); - - println!("\n=================================================="); - println!("🚀 Rust Web 服务器已启动"); - println!("📡 监听地址: http://127.0.0.1:{}", config.port); - println!(" GET/POST /api/subscriptions - 订阅管理"); - println!(" POST /api/preview - RSS 解析预览"); - println!(" POST /api/run - 执行订阅与下载同步"); - println!(" GET /api/jobs - 下载任务"); - println!("📋 可用端点:"); - println!(" GET / - 欢迎页面"); - println!(" GET /health - 健康检查"); - println!(" GET /info - API 信息"); - println!(" GET /greet/{{name}} - 问候接口"); - println!(" POST /api/echo - 回显数据"); - println!(" POST /api/calculate - 计算接口"); - println!("==================================================\n"); - - let result = server.run().await; - let _ = shutdown_tx.send(true); - let _ = background.await; - result + app::run(config).await } diff --git a/src/media.rs b/src/media.rs deleted file mode 100644 index 1bb1e0a..0000000 --- a/src/media.rs +++ /dev/null @@ -1,269 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-only -use crate::error::{Error, Result}; -use cap_std::{ambient_authority, fs::Dir}; -use serde::{Deserialize, Serialize}; -use std::{ - io, - path::{Component, Path}, -}; - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DestinationInput { - pub name: String, - pub kind: LocalKind, - pub root_path: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum LocalKind { - Local, -} - -impl DestinationInput { - pub fn validate(&mut self) -> Result<()> { - self.name = self.name.trim().to_owned(); - if self.name.is_empty() - || self.name.chars().count() > 100 - || self.root_path.len() > 4096 - || self.root_path.chars().any(char::is_control) - || !Path::new(&self.root_path).is_absolute() - || Path::new(&self.root_path) - .components() - .any(|c| matches!(c, Component::ParentDir)) - { - return Err(Error::Invalid( - "expected a name and an absolute local root without parent traversal".into(), - )); - } - Ok(()) - } -} - -#[derive(Serialize, sqlx::FromRow)] -pub struct Destination { - pub id: String, - pub name: String, - pub kind: String, - pub root_path: String, - pub is_active: bool, -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -pub struct MappingInput { - pub downloader_id: String, - pub download_root: String, -} - -#[derive(Clone, Serialize, sqlx::FromRow)] -pub struct Mapping { - pub id: String, - pub destination_id: String, - pub downloader_id: String, - pub version: i64, - pub download_root: String, -} - -#[derive(Clone, Serialize, sqlx::FromRow)] -pub struct MediaFile { - pub relative_path: String, - pub expected_size: i64, - pub observed_size: Option, - pub status: String, - pub error: Option, -} - -#[derive(Serialize)] -pub struct FileReport { - pub job_id: String, - pub destination_id: Option, - pub status: String, - pub checked_at: Option, - pub last_success_at: Option, - pub error: Option, - pub files: Vec, -} - -// Wire paths always use '/' and must be relative on both Unix and Windows. -pub fn validate_relative(path: &str, allow_empty: bool) -> Result<()> { - if (path.is_empty() && !allow_empty) - || path.len() > 4096 - || path.contains(['\\', ':']) - || path.chars().any(char::is_control) - || (!path.is_empty() - && path - .split('/') - .any(|part| part.is_empty() || matches!(part, "." | ".."))) - { - return Err(Error::Invalid( - "expected a relative path without empty, dot, parent, drive or backslash components" - .into(), - )); - } - Ok(()) -} - -// qBittorrent may run on a different OS. Do not interpret its paths using the host OS. -pub fn normalize_download_root(value: &str) -> Result { - let path = value.replace('\\', "/"); - let drive = path.len() >= 3 - && path.as_bytes()[0].is_ascii_alphabetic() - && path.as_bytes()[1] == b':' - && path.as_bytes()[2] == b'/'; - if path.len() > 4096 || path.chars().any(char::is_control) || !(path.starts_with('/') || drive) - { - return Err(Error::Invalid( - "downloader root must be an absolute Unix or Windows path".into(), - )); - } - let rest = if drive { - &path[3..] - } else { - path.trim_start_matches('/') - }; - if rest.contains(':') || rest.split('/').any(|s| matches!(s, "." | "..")) { - return Err(Error::Invalid("invalid downloader root components".into())); - } - let path = path.trim_end_matches('/'); - Ok(if path.is_empty() { - "/".into() - } else if drive && path.len() == 2 { - format!("{path}/") - } else { - path.into() - }) -} - -pub fn same_download_root(expected: &str, actual: &str) -> bool { - match ( - normalize_download_root(expected), - normalize_download_root(actual), - ) { - (Ok(a), Ok(b)) => a == b, - _ => false, - } -} - -fn access_error(error: &io::Error) -> String { - match error.kind() { - io::ErrorKind::PermissionDenied => "access denied or path escapes the configured root", - io::ErrorKind::NotFound => "configured root or directory is unavailable", - _ => "filesystem access failed", - } - .into() -} - -pub fn check_local(root: &str, mut files: Vec) -> Vec { - let dir = Dir::open_ambient_dir(root, ambient_authority()); - for file in &mut files { - file.observed_size = None; - file.error = None; - let result = match &dir { - Ok(dir) => { - if validate_relative(&file.relative_path, false).is_err() { - file.status = "unavailable".into(); - file.error = Some("invalid stored relative path".into()); - continue; - } - dir.metadata(&file.relative_path) - } - Err(error) => { - file.status = "unavailable".into(); - file.error = Some(access_error(error)); - continue; - } - }; - match result { - Ok(metadata) if metadata.is_file() => { - file.status = "present".into(); - file.observed_size = i64::try_from(metadata.len()).ok(); - } - Ok(_) => { - file.status = "unavailable".into(); - file.error = Some("expected a regular file".into()); - } - Err(error) if error.kind() == io::ErrorKind::NotFound => file.status = "absent".into(), - Err(error) => { - file.status = "unavailable".into(); - file.error = Some(access_error(&error)); - } - } - } - files -} - -#[derive(Serialize)] -pub struct DirectoryEntry { - pub name: String, - pub kind: &'static str, - pub size: Option, -} -#[derive(Serialize)] -pub struct DirectoryListing { - pub status: &'static str, - pub error: Option, - pub entries: Vec, - pub truncated: bool, -} - -pub fn list_local(root: &str, path: &str) -> DirectoryListing { - if let Err(error) = validate_relative(path, true) { - return DirectoryListing { - status: "unavailable", - error: Some(error.to_string()), - entries: vec![], - truncated: false, - }; - } - let result = (|| -> io::Result<_> { - let dir = Dir::open_ambient_dir(root, ambient_authority())?; - let mut entries = Vec::new(); - let mut truncated = false; - for (index, item) in dir - .read_dir(if path.is_empty() { "." } else { path })? - .enumerate() - { - if index >= 1000 { - truncated = true; - break; - } - let item = item?; - let kind = item.file_type()?; - entries.push(DirectoryEntry { - name: item.file_name().to_string_lossy().into_owned(), - size: if kind.is_file() { - Some(item.metadata()?.len()) - } else { - None - }, - kind: if kind.is_symlink() { - "symlink" - } else if kind.is_dir() { - "directory" - } else if kind.is_file() { - "file" - } else { - "other" - }, - }); - } - entries.sort_by(|a, b| a.name.cmp(&b.name)); - Ok((entries, truncated)) - })(); - match result { - Ok((entries, truncated)) => DirectoryListing { - status: "available", - error: None, - entries, - truncated, - }, - Err(error) => DirectoryListing { - status: "unavailable", - error: Some(access_error(&error)), - entries: vec![], - truncated: false, - }, - } -} diff --git a/src/media/local.rs b/src/media/local.rs new file mode 100644 index 0000000..5608437 --- /dev/null +++ b/src/media/local.rs @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: GPL-2.0-only +use super::{validate_relative, DirectoryEntry, DirectoryListing, MediaFile}; +use cap_std::{ambient_authority, fs::Dir}; +use std::io; + +fn access_error(error: &io::Error) -> String { + match error.kind() { + io::ErrorKind::PermissionDenied => "access denied or path escapes the configured root", + io::ErrorKind::NotFound => "configured root or directory is unavailable", + _ => "filesystem access failed", + } + .into() +} + +pub fn check_local(root: &str, mut files: Vec) -> Vec { + let dir = Dir::open_ambient_dir(root, ambient_authority()); + for file in &mut files { + file.observed_size = None; + file.error = None; + let result = match &dir { + Ok(dir) => { + if validate_relative(&file.relative_path, false).is_err() { + file.status = "unavailable".into(); + file.error = Some("invalid stored relative path".into()); + continue; + } + dir.metadata(&file.relative_path) + } + Err(error) => { + file.status = "unavailable".into(); + file.error = Some(access_error(error)); + continue; + } + }; + match result { + Ok(metadata) if metadata.is_file() => { + file.status = "present".into(); + file.observed_size = i64::try_from(metadata.len()).ok(); + } + Ok(_) => { + file.status = "unavailable".into(); + file.error = Some("expected a regular file".into()); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => file.status = "absent".into(), + Err(error) => { + file.status = "unavailable".into(); + file.error = Some(access_error(&error)); + } + } + } + files +} + +pub fn list_local(root: &str, path: &str) -> DirectoryListing { + if let Err(error) = validate_relative(path, true) { + return DirectoryListing { + status: "unavailable", + error: Some(error.to_string()), + entries: vec![], + truncated: false, + }; + } + let result = (|| -> io::Result<_> { + let dir = Dir::open_ambient_dir(root, ambient_authority())?; + let mut entries = Vec::new(); + let mut truncated = false; + for (index, item) in dir + .read_dir(if path.is_empty() { "." } else { path })? + .enumerate() + { + if index >= 1000 { + truncated = true; + break; + } + let item = item?; + let kind = item.file_type()?; + entries.push(DirectoryEntry { + name: item.file_name().to_string_lossy().into_owned(), + size: if kind.is_file() { + Some(item.metadata()?.len()) + } else { + None + }, + kind: if kind.is_symlink() { + "symlink" + } else if kind.is_dir() { + "directory" + } else if kind.is_file() { + "file" + } else { + "other" + }, + }); + } + entries.sort_by(|a, b| a.name.cmp(&b.name)); + Ok((entries, truncated)) + })(); + match result { + Ok((entries, truncated)) => DirectoryListing { + status: "available", + error: None, + entries, + truncated, + }, + Err(error) => DirectoryListing { + status: "unavailable", + error: Some(access_error(&error)), + entries: vec![], + truncated: false, + }, + } +} diff --git a/src/media/mod.rs b/src/media/mod.rs new file mode 100644 index 0000000..99fa9bf --- /dev/null +++ b/src/media/mod.rs @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: GPL-2.0-only +//! Media destinations, path mappings and file presence reports. +mod local; +mod path; + +pub use local::{check_local, list_local}; +pub use path::{normalize_download_root, same_download_root, validate_relative}; + +use crate::error::{Error, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Component, Path}; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DestinationInput { + pub name: String, + pub kind: LocalKind, + pub root_path: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LocalKind { + Local, +} + +impl DestinationInput { + pub fn validate(&mut self) -> Result<()> { + self.name = self.name.trim().to_owned(); + if self.name.is_empty() + || self.name.chars().count() > 100 + || self.root_path.len() > 4096 + || self.root_path.chars().any(char::is_control) + || !Path::new(&self.root_path).is_absolute() + || Path::new(&self.root_path) + .components() + .any(|c| matches!(c, Component::ParentDir)) + { + return Err(Error::Invalid( + "expected a name and an absolute local root without parent traversal".into(), + )); + } + Ok(()) + } +} + +#[derive(Serialize, sqlx::FromRow)] +pub struct Destination { + pub id: String, + pub name: String, + pub kind: String, + pub root_path: String, + pub is_active: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MappingInput { + pub downloader_id: String, + pub download_root: String, +} + +#[derive(Clone, Serialize, sqlx::FromRow)] +pub struct Mapping { + pub id: String, + pub destination_id: String, + pub downloader_id: String, + pub version: i64, + pub download_root: String, +} + +#[derive(Clone, Serialize, sqlx::FromRow)] +pub struct MediaFile { + pub relative_path: String, + pub expected_size: i64, + pub observed_size: Option, + pub status: String, + pub error: Option, +} + +#[derive(Serialize)] +pub struct FileReport { + pub job_id: String, + pub destination_id: Option, + pub status: String, + pub checked_at: Option, + pub last_success_at: Option, + pub error: Option, + pub files: Vec, +} + +#[derive(Serialize)] +pub struct DirectoryEntry { + pub name: String, + pub kind: &'static str, + pub size: Option, +} +#[derive(Serialize)] +pub struct DirectoryListing { + pub status: &'static str, + pub error: Option, + pub entries: Vec, + pub truncated: bool, +} diff --git a/src/media/path.rs b/src/media/path.rs new file mode 100644 index 0000000..d92e616 --- /dev/null +++ b/src/media/path.rs @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-2.0-only +use crate::error::{Error, Result}; + +// Wire paths always use '/' and must be relative on both Unix and Windows. +pub fn validate_relative(path: &str, allow_empty: bool) -> Result<()> { + if (path.is_empty() && !allow_empty) + || path.len() > 4096 + || path.contains(['\\', ':']) + || path.chars().any(char::is_control) + || (!path.is_empty() + && path + .split('/') + .any(|part| part.is_empty() || matches!(part, "." | ".."))) + { + return Err(Error::Invalid( + "expected a relative path without empty, dot, parent, drive or backslash components" + .into(), + )); + } + Ok(()) +} + +// qBittorrent may run on a different OS. Do not interpret its paths using the host OS. +pub fn normalize_download_root(value: &str) -> Result { + let path = value.replace('\\', "/"); + let drive = path.len() >= 3 + && path.as_bytes()[0].is_ascii_alphabetic() + && path.as_bytes()[1] == b':' + && path.as_bytes()[2] == b'/'; + if path.len() > 4096 || path.chars().any(char::is_control) || !(path.starts_with('/') || drive) + { + return Err(Error::Invalid( + "downloader root must be an absolute Unix or Windows path".into(), + )); + } + let rest = if drive { + &path[3..] + } else { + path.trim_start_matches('/') + }; + if rest.contains(':') || rest.split('/').any(|s| matches!(s, "." | "..")) { + return Err(Error::Invalid("invalid downloader root components".into())); + } + let path = path.trim_end_matches('/'); + Ok(if path.is_empty() { + "/".into() + } else if drive && path.len() == 2 { + format!("{path}/") + } else { + path.into() + }) +} + +pub fn same_download_root(expected: &str, actual: &str) -> bool { + match ( + normalize_download_root(expected), + normalize_download_root(actual), + ) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } +} diff --git a/src/network.rs b/src/network.rs new file mode 100644 index 0000000..d222558 --- /dev/null +++ b/src/network.rs @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-2.0-only +//! HTTP request policy and bounded responses shared by external adapters. +use crate::{ + domain::validate_http_url, + error::{Error, Result}, +}; +use reqwest::{Client, Url}; +use std::time::Duration; + +pub fn website_client(config: &crate::config::WebsiteConfig) -> Result { + let mut builder = Client::builder() + .no_proxy() + .timeout(Duration::from_secs(30)) + .connect_timeout(Duration::from_secs(10)) + .user_agent(concat!( + env!("CARGO_PKG_NAME"), + "/", + env!("CARGO_PKG_VERSION") + )) + // Rebuild each redirected request so proxy auth and direct-host rules are reapplied. + .redirect(reqwest::redirect::Policy::none()); + if let Some(value) = &config.proxy_url { + let invalid = || { + Error::Invalid( + "WEBSITE_PROXY_URL must be an HTTP(S), SOCKS5 or SOCKS5H proxy URL".into(), + ) + }; + let url = Url::parse(value).map_err(|_| invalid())?; + if !matches!(url.scheme(), "http" | "https" | "socks5" | "socks5h") + || url.host_str().is_none() + || url.query().is_some() + || url.fragment().is_some() + || !matches!(url.path(), "" | "/") + { + return Err(invalid()); + } + let proxy = reqwest::Proxy::all(url) + .map_err(|_| invalid())? + .no_proxy(reqwest::NoProxy::from_string(&config.direct_hosts)); + builder = builder.proxy(proxy); + } + Ok(builder.build()?) +} + +pub async fn website_get(client: &Client, mut url: Url) -> Result { + tokio::time::timeout(Duration::from_secs(30), async { + for hop in 0..=5 { + validate_http_url(url.as_str())?; + let response = client.get(url.clone()).send().await?; + if !matches!(response.status().as_u16(), 301 | 302 | 303 | 307 | 308) { + return Ok(response); + } + if hop == 5 { + return Err(Error::Upstream("website redirect limit exceeded".into())); + } + let location = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| Error::Upstream("invalid website redirect".into()))?; + url = url + .join(location) + .map_err(|_| Error::Upstream("invalid website redirect URL".into()))?; + validate_http_url(url.as_str()) + .map_err(|_| Error::Upstream("unsupported website redirect URL".into()))?; + } + unreachable!("redirect count is bounded") + }) + .await + .map_err(|_| Error::Upstream("website request timed out".into()))? +} + +pub async fn bounded_body(mut response: reqwest::Response, limit: usize) -> Result> { + response = response.error_for_status()?; + if response.content_length().is_some_and(|n| n > limit as u64) { + return Err(Error::Upstream("response exceeds size limit".into())); + } + let mut data = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if data.len() + chunk.len() > limit { + return Err(Error::Upstream("response exceeds size limit".into())); + } + data.extend_from_slice(&chunk); + } + Ok(data) +} diff --git a/src/service.rs b/src/service.rs index 71e22f9..a6db2f2 100644 --- a/src/service.rs +++ b/src/service.rs @@ -2,10 +2,10 @@ use crate::{ config::Config, domain::*, - downloader::{self, Qbit}, + downloaders::{qbittorrent::Qbit, torrent}, error::{Error, Result}, - feeds, media::{self, FileReport, MediaFile}, + sources::feeds, storage::Store, }; use serde::Serialize; @@ -18,7 +18,7 @@ use tokio::sync::{watch, Mutex}; pub struct Service { pub store: Store, pub gate: Mutex<()>, - pub mikan: crate::mikan::Mikan, + pub mikan: crate::sources::mikan::Mikan, client: reqwest::Client, } @@ -32,8 +32,8 @@ pub struct CycleReport { impl Service { pub async fn new(store: Store, config: &Config) -> Result { - let client = feeds::website_client(&config.website)?; - let mikan = crate::mikan::Mikan::new(client.clone(), &config.mikan_base_url)?; + let client = crate::network::website_client(&config.website)?; + let mikan = crate::sources::mikan::Mikan::new(client.clone(), &config.mikan_base_url)?; store.bootstrap_downloader(config.qbit.as_ref()).await?; Ok(Self { store, @@ -180,7 +180,7 @@ impl Service { let result: Result<()> = async { if job.info_hash.is_none() { if !enabled { return Ok(()); } - let prepared = downloader::prepare(&self.client, &job.download_url).await?; + let prepared = torrent::prepare(&self.client, &job.download_url).await?; self.store.prepare(&job.id, &prepared.hash, prepared.bytes.as_deref()).await?; job.info_hash = Some(prepared.hash); } diff --git a/src/feeds.rs b/src/sources/feeds.rs similarity index 54% rename from src/feeds.rs rename to src/sources/feeds.rs index 6b31912..d59fe5b 100644 --- a/src/feeds.rs +++ b/src/sources/feeds.rs @@ -1,90 +1,11 @@ // SPDX-License-Identifier: GPL-2.0-only use crate::{ - domain::*, + domain::{episode as parser, *}, error::{Error, Result}, - parser, + network::{bounded_body, website_get}, }; use reqwest::{Client, Url}; use sha1::{Digest, Sha1}; -use std::time::Duration; - -pub fn http_client() -> Result { - website_client(&crate::config::WebsiteConfig::default()) -} - -pub fn website_client(config: &crate::config::WebsiteConfig) -> Result { - let mut builder = Client::builder() - .no_proxy() - .timeout(Duration::from_secs(30)) - .connect_timeout(Duration::from_secs(10)) - .user_agent(concat!("rust_project/", env!("CARGO_PKG_VERSION"))) - // Rebuild each redirected request so proxy auth and direct-host rules are reapplied. - .redirect(reqwest::redirect::Policy::none()); - if let Some(value) = &config.proxy_url { - let invalid = || { - Error::Invalid( - "WEBSITE_PROXY_URL must be an HTTP(S), SOCKS5 or SOCKS5H proxy URL".into(), - ) - }; - let url = Url::parse(value).map_err(|_| invalid())?; - if !matches!(url.scheme(), "http" | "https" | "socks5" | "socks5h") - || url.host_str().is_none() - || url.query().is_some() - || url.fragment().is_some() - || !matches!(url.path(), "" | "/") - { - return Err(invalid()); - } - let proxy = reqwest::Proxy::all(url) - .map_err(|_| invalid())? - .no_proxy(reqwest::NoProxy::from_string(&config.direct_hosts)); - builder = builder.proxy(proxy); - } - Ok(builder.build()?) -} - -pub async fn website_get(client: &Client, mut url: Url) -> Result { - tokio::time::timeout(Duration::from_secs(30), async { - for hop in 0..=5 { - validate_http_url(url.as_str())?; - let response = client.get(url.clone()).send().await?; - if !matches!(response.status().as_u16(), 301 | 302 | 303 | 307 | 308) { - return Ok(response); - } - if hop == 5 { - return Err(Error::Upstream("website redirect limit exceeded".into())); - } - let location = response - .headers() - .get(reqwest::header::LOCATION) - .and_then(|v| v.to_str().ok()) - .ok_or_else(|| Error::Upstream("invalid website redirect".into()))?; - url = url - .join(location) - .map_err(|_| Error::Upstream("invalid website redirect URL".into()))?; - validate_http_url(url.as_str()) - .map_err(|_| Error::Upstream("unsupported website redirect URL".into()))?; - } - unreachable!("redirect count is bounded") - }) - .await - .map_err(|_| Error::Upstream("website request timed out".into()))? -} - -pub async fn bounded_body(mut response: reqwest::Response, limit: usize) -> Result> { - response = response.error_for_status()?; - if response.content_length().is_some_and(|n| n > limit as u64) { - return Err(Error::Upstream("response exceeds size limit".into())); - } - let mut data = Vec::new(); - while let Some(chunk) = response.chunk().await? { - if data.len() + chunk.len() > limit { - return Err(Error::Upstream("response exceeds size limit".into())); - } - data.extend_from_slice(&chunk); - } - Ok(data) -} pub async fn fetch(client: &Client, sub: &SubscriptionInput) -> Result> { let response = website_get( diff --git a/src/mikan.rs b/src/sources/mikan.rs similarity index 99% rename from src/mikan.rs rename to src/sources/mikan.rs index b65627d..ec582c5 100644 --- a/src/mikan.rs +++ b/src/sources/mikan.rs @@ -3,7 +3,7 @@ use crate::{ domain::{validate_http_url, SubscriptionInput}, error::{Error, Result}, - feeds::{bounded_body, website_get}, + network::{bounded_body, website_get}, }; use reqwest::{Client, Url}; use scraper::{Html, Selector}; diff --git a/src/sources/mod.rs b/src/sources/mod.rs new file mode 100644 index 0000000..8cf73f0 --- /dev/null +++ b/src/sources/mod.rs @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: GPL-2.0-only +//! Subscription discovery and RSS/Atom parsing. +pub mod feeds; +pub mod mikan; diff --git a/src/storage/mod.rs b/src/storage/mod.rs new file mode 100644 index 0000000..4f97336 --- /dev/null +++ b/src/storage/mod.rs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: GPL-2.0-only +//! Transactional persistence; SQLite owns connections, SQL and migrations. +mod sqlite; + +pub use sqlite::Store; diff --git a/src/media_store.rs b/src/storage/sqlite/media.rs similarity index 99% rename from src/media_store.rs rename to src/storage/sqlite/media.rs index b5e8b92..c8f8a97 100644 --- a/src/media_store.rs +++ b/src/storage/sqlite/media.rs @@ -1,9 +1,9 @@ // SPDX-License-Identifier: GPL-2.0-only +use super::{now, Store}; use crate::{ domain::DownloadJob, error::{Error, Result}, media::*, - storage::{now, Store}, }; impl Store { diff --git a/src/storage.rs b/src/storage/sqlite/mod.rs similarity index 99% rename from src/storage.rs rename to src/storage/sqlite/mod.rs index 2983c96..6d5730c 100644 --- a/src/storage.rs +++ b/src/storage/sqlite/mod.rs @@ -1,4 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-only +mod media; + use crate::{ domain::*, error::{Error, Result}, @@ -17,7 +19,7 @@ pub struct Store { pub pool: SqlitePool, } -pub fn now() -> i64 { +pub(crate) fn now() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() diff --git a/tests/local_media.rs b/tests/local_media.rs index 044cd2a..f8d48cc 100644 --- a/tests/local_media.rs +++ b/tests/local_media.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-only use actix_web::{test, web, App}; -use rust_project::{api, config::Config, service::Service, storage::Store}; +use autocomic_rss::{config::Config, http, service::Service, storage::Store}; use serde_json::{json, Value}; use std::{sync::Arc, time::Duration}; @@ -30,7 +30,7 @@ async fn directory_api_rejects_traversal_and_does_not_follow_links_outside_root( let app = test::init_service( App::new() .app_data(web::Data::new(service)) - .configure(api::configure), + .configure(http::configure), ) .await; for body in [ diff --git a/tests/mikan.rs b/tests/mikan.rs index 0a4796f..bfe61b8 100644 --- a/tests/mikan.rs +++ b/tests/mikan.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-only use actix_web::{dev::ServerHandle, test, web, App, HttpRequest, HttpResponse, HttpServer}; -use rust_project::{api, config::Config, service::Service, storage::Store}; +use autocomic_rss::{config::Config, http, service::Service, storage::Store}; use serde_json::Value; use std::{net::TcpListener, sync::Arc, time::Duration}; @@ -80,7 +80,7 @@ async fn covers_use_an_application_url_and_details_remain_available_in_bounded_c let app = test::init_service( App::new() .app_data(web::Data::new(Arc::new(service))) - .configure(api::configure), + .configure(http::configure), ) .await; let detail: Value = test::call_and_read_body_json( @@ -127,7 +127,7 @@ async fn empty_results_are_distinct_from_challenges_or_changed_markup() { let app = test::init_service( App::new() .app_data(web::Data::new(Arc::new(service))) - .configure(api::configure), + .configure(http::configure), ) .await; for (query, status) in [ @@ -164,7 +164,7 @@ async fn group_candidate_can_be_previewed_and_saved_disabled_without_downloading let app = test::init_service( App::new() .app_data(web::Data::new(Arc::new(service))) - .configure(api::configure), + .configure(http::configure), ) .await; let response = test::call_service( @@ -243,7 +243,7 @@ async fn resolver_marks_keyword_scope_and_rejects_foreign_or_ambiguous_urls() { let app = test::init_service( App::new() .app_data(web::Data::new(Arc::new(service))) - .configure(api::configure), + .configure(http::configure), ) .await; let response = test::call_service( @@ -291,7 +291,7 @@ async fn search_discovers_distinct_series_and_encoded_keyword_rss_without_creati let app = test::init_service( App::new() .app_data(web::Data::new(Arc::new(service))) - .configure(api::configure), + .configure(http::configure), ) .await; let response = test::call_service( diff --git a/tests/network.rs b/tests/network.rs index 8b1b546..af52c36 100644 --- a/tests/network.rs +++ b/tests/network.rs @@ -1,8 +1,8 @@ // SPDX-License-Identifier: GPL-2.0-only use actix_web::{dev::ServerHandle, test, web, App, HttpRequest, HttpResponse, HttpServer}; -use rust_project::{ - api, +use autocomic_rss::{ config::{Config, QbitConfig}, + http, service::Service, storage::Store, }; @@ -56,7 +56,7 @@ async fn direct_exceptions_are_explicit_and_proxy_failure_never_falls_back() { poll_interval: Duration::from_secs(300), qbit: None, mikan_base_url: "http://mikan.invalid".into(), - website: rust_project::config::WebsiteConfig { + website: autocomic_rss::config::WebsiteConfig { proxy_url: Some(proxy.clone()), direct_hosts: direct_hosts.into(), }, @@ -67,7 +67,7 @@ async fn direct_exceptions_are_explicit_and_proxy_failure_never_falls_back() { let app = test::init_service( App::new() .app_data(web::Data::new(Arc::new(service))) - .configure(api::configure), + .configure(http::configure), ) .await; let mut redirect = reqwest::Url::parse("http://unresolvable.invalid/to-direct").unwrap(); @@ -107,7 +107,7 @@ async fn website_proxy_handles_auth_redirects_and_rss_while_downloader_stays_dir port: 8080, poll_interval: Duration::from_secs(300), mikan_base_url: "http://mikan.invalid".into(), - website: rust_project::config::WebsiteConfig { + website: autocomic_rss::config::WebsiteConfig { proxy_url: Some(proxy_url.into()), direct_hosts: String::new(), }, @@ -124,7 +124,7 @@ async fn website_proxy_handles_auth_redirects_and_rss_while_downloader_stays_dir let app = test::init_service( App::new() .app_data(web::Data::new(Arc::new(service))) - .configure(api::configure), + .configure(http::configure), ) .await; let search: Value = test::call_and_read_body_json( diff --git a/tests/pipeline.rs b/tests/pipeline.rs index c6c21d9..c181954 100644 --- a/tests/pipeline.rs +++ b/tests/pipeline.rs @@ -1,11 +1,11 @@ // SPDX-License-Identifier: GPL-2.0-only use actix_web::{dev::ServerHandle, web, App, HttpRequest, HttpResponse, HttpServer}; -use rust_project::{ - api, +use autocomic_rss::{ config::{Config, QbitConfig}, domain::{Decision, SubscriptionInput}, - feeds, + http, service::Service, + sources::feeds, storage::Store, }; use serde_json::{json, Value}; @@ -74,7 +74,7 @@ async fn add( let text = String::from_utf8_lossy(&body); let hash = if text.contains("name=\"torrents\"") { assert!(body.windows(TORRENT.len()).any(|w| w == TORRENT)); - rust_project::downloader::torrent_hash(TORRENT).unwrap() + autocomic_rss::downloaders::torrent::torrent_hash(TORRENT).unwrap() } else { assert!(text.contains("name=\"urls\"")); regex::Regex::new("urn:btih:([a-f0-9]{40})") @@ -300,7 +300,7 @@ async fn api_validates_input_and_persists_crud() { let app = test::init_service( App::new() .app_data(web::Data::new(service.clone())) - .configure(api::configure), + .configure(http::configure), ) .await; let mut invalid = input("http://example.invalid", false); @@ -369,7 +369,7 @@ async fn downloads_relative_torrent_enclosure_and_submits_original_bytes() { assert_eq!( jobs[0].info_hash.as_deref(), Some( - rust_project::downloader::torrent_hash(TORRENT) + autocomic_rss::downloaders::torrent::torrent_hash(TORRENT) .unwrap() .as_str() ) @@ -404,7 +404,7 @@ async fn switching_keeps_queued_jobs_and_retries_with_their_original_downloader_ let app = test::init_service( App::new() .app_data(web::Data::new(service.clone())) - .configure(api::configure), + .configure(http::configure), ) .await; let instances: Value = test::call_and_read_body_json( @@ -535,7 +535,7 @@ async fn switching_keeps_queued_jobs_and_retries_with_their_original_downloader_ let app = test::init_service( App::new() .app_data(web::Data::new(recovered.clone())) - .configure(api::configure), + .configure(http::configure), ) .await; let status: Value = test::call_and_read_body_json( @@ -614,7 +614,7 @@ async fn upgrading_without_original_config_requires_explicit_legacy_claim() { let app = test::init_service( App::new() .app_data(web::Data::new(service.clone())) - .configure(api::configure), + .configure(http::configure), ) .await; let status: Value = test::call_and_read_body_json( @@ -721,7 +721,7 @@ async fn upgrading_without_original_config_requires_explicit_legacy_claim() { #[actix_web::test] async fn destination_and_mapping_changes_preserve_queued_paths_and_restart_snapshots() { - use rust_project::media::{DestinationInput, LocalKind, MappingInput}; + use autocomic_rss::media::{DestinationInput, LocalKind, MappingInput}; let (url, remote, server) = mock().await; let path = std::env::temp_dir().join(format!("destination-snapshots-{}.db", uuid::Uuid::new_v4())); @@ -851,9 +851,9 @@ async fn destination_and_mapping_changes_preserve_queued_paths_and_restart_snaps assert_eq!(remote.lock().unwrap().adds, 3); let other = recovered .store - .create_downloader(rust_project::domain::DownloaderInput { + .create_downloader(autocomic_rss::domain::DownloaderInput { name: "unmapped".into(), - kind: rust_project::domain::DownloaderKind::Qbittorrent, + kind: autocomic_rss::domain::DownloaderKind::Qbittorrent, connection: config("http://127.0.0.1:1").qbit.unwrap(), }) .await @@ -881,7 +881,7 @@ async fn destination_and_mapping_changes_preserve_queued_paths_and_restart_snaps #[actix_web::test] async fn file_checks_keep_download_completion_and_last_success_when_storage_is_unavailable() { use actix_web::test; - use rust_project::media::{DestinationInput, LocalKind, MappingInput}; + use autocomic_rss::media::{DestinationInput, LocalKind, MappingInput}; let (url, remote, server) = mock().await; let directory = std::env::temp_dir().join(format!("local-media-{}", uuid::Uuid::new_v4())); let root = directory.join("media"); @@ -929,7 +929,7 @@ async fn file_checks_keep_download_completion_and_last_success_when_storage_is_u let app = test::init_service( App::new() .app_data(web::Data::new(service.clone())) - .configure(api::configure), + .configure(http::configure), ) .await; let report_url = format!("/api/jobs/{}/files", job.id); @@ -993,7 +993,7 @@ async fn file_checks_keep_download_completion_and_last_success_when_storage_is_u #[actix_web::test] async fn unsafe_downloader_paths_and_unreadable_files_are_unavailable_not_missing() { - use rust_project::media::{DestinationInput, LocalKind, MappingInput}; + use autocomic_rss::media::{DestinationInput, LocalKind, MappingInput}; let (url, remote, server) = mock().await; let directory = std::env::temp_dir().join(format!("media-confinement-{}", uuid::Uuid::new_v4()));