refactor: organize Rust modules and simplify deployment README
This commit is contained in:
Generated
+23
-59
@@ -19,29 +19,6 @@ dependencies = [
|
|||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "actix-http"
|
name = "actix-http"
|
||||||
version = "3.11.2"
|
version = "3.11.2"
|
||||||
@@ -289,6 +266,29 @@ version = "1.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
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]]
|
[[package]]
|
||||||
name = "backtrace"
|
name = "backtrace"
|
||||||
version = "0.3.76"
|
version = "0.3.76"
|
||||||
@@ -1181,12 +1181,6 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "http-range"
|
|
||||||
version = "0.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
@@ -2239,30 +2233,6 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-demangle"
|
name = "rustc-demangle"
|
||||||
version = "0.1.28"
|
version = "0.1.28"
|
||||||
@@ -3246,12 +3216,6 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "v_htmlescape"
|
|
||||||
version = "0.15.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "valuable"
|
name = "valuable"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
|
|||||||
+1
-2
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rust_project"
|
name = "autocomic-rss"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "GPL-2.0-only"
|
license = "GPL-2.0-only"
|
||||||
@@ -7,7 +7,6 @@ description = "A Rust RSS subscription and download automation service"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
actix-web = "4.9"
|
actix-web = "4.9"
|
||||||
actix-files = "0.6"
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
tokio = { version = "1", features = ["sync", "time", "signal", "rt-multi-thread", "macros"] }
|
tokio = { version = "1", features = ["sync", "time", "signal", "rt-multi-thread", "macros"] }
|
||||||
|
|||||||
@@ -1,259 +1,61 @@
|
|||||||
# Rust RSS 追番服务
|
# AutoComicRss
|
||||||
|
|
||||||
使用 Rust 独立设计实现的 RSS 订阅与下载自动化项目,采用 **GPL-2.0-only**。
|
使用 Rust 独立实现的 RSS 自动追番服务,功能方向参考 [ani-rss](https://github.com/wushuo894/ani-rss)。
|
||||||
功能方向参考 [ani-rss](https://github.com/wushuo894/ani-rss),没有移植其 Java 业务代码或前端资源;本项目不是上游的官方 Rust 版本。
|
|
||||||
|
|
||||||
当前为后端 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
|
```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` 并运行数据库迁移。
|
编辑 `.env`,取消注释并填写下载器配置:
|
||||||
当前 API 供本机使用,没有用户认证;监听地址固定为回环地址。
|
|
||||||
首次运行需要下载 Cargo 依赖。已在 Rust 1.93.1 上进行编译验证。
|
|
||||||
|
|
||||||
按需配置环境变量,参见 [.env.example](.env.example)。程序不自动读取 `.env`:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export QBIT_URL='http://127.0.0.1:8081'
|
QBIT_URL='http://127.0.0.1:8081'
|
||||||
export QBIT_USERNAME='admin'
|
QBIT_USERNAME='admin'
|
||||||
export QBIT_PASSWORD='你的密码'
|
QBIT_PASSWORD='你的密码'
|
||||||
export QBIT_SAVE_PATH='/downloads'
|
QBIT_SAVE_PATH='/downloads'
|
||||||
cargo run --locked
|
|
||||||
|
# 按需启用网站代理,也可使用 socks5h://127.0.0.1:7897
|
||||||
|
# WEBSITE_PROXY_URL='http://127.0.0.1:7897'
|
||||||
```
|
```
|
||||||
|
|
||||||
qBittorrent 需要启用 Web UI。`QBIT_SAVE_PATH` 是下载器看到的路径。
|
`QBIT_SAVE_PATH` 是下载器看到的保存路径。`QBIT_*` 仅在首次配置下载器时导入,后续通过 API 管理。网站代理需显式配置,不继承系统代理,也不影响下载器连接。
|
||||||
`QBIT_*` 只在数据库尚无下载器实例时导入一次,并选为全局下载器。已有实例时,重启或修改这些环境变量不会覆盖保存的实例或选择。
|
|
||||||
全新安装不设置 `QBIT_URL` 时,仍可管理订阅、预览和生成未分配任务;首次通过接口选中下载器后绑定这些任务。清空环境变量不会停用数据库中已选中的下载器。
|
|
||||||
程序启动后立即执行一轮检查,之后默认每 300 秒检查一次;`POLL_INTERVAL_SECONDS` 最小为 10。
|
|
||||||
|
|
||||||
## 使用流程
|
程序不会自动读取 `.env`,在项目目录加载配置后启动:
|
||||||
|
|
||||||
### 从蜜柑发现订阅
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -G 'http://127.0.0.1:8080/api/sources/mikan/search' --data-urlencode 'q=葬送'
|
set -a
|
||||||
curl 'http://127.0.0.1:8080/api/sources/mikan/series/3141'
|
. ./.env
|
||||||
curl 'http://127.0.0.1:8080/api/sources/mikan/resolve' \
|
set +a
|
||||||
-H 'Content-Type: application/json' \
|
./target/release/autocomic-rss
|
||||||
-d '{"url":"https://mikanani.me/Home/Bangumi/3141"}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
搜索返回各季度的独立候选及关键词 RSS。详情返回整部番剧 RSS、字幕组 RSS、站点信息和可用封面地址。
|
默认监听 `127.0.0.1:8080`,每 300 秒检查订阅,数据保存在 `data/ani.db`,启动时自动建库和迁移。更多配置见 [.env.example](.env.example)。同一数据库只运行一个服务实例;备份时停止服务并保存 `data/`,其中包含下载器凭据。
|
||||||
将详情中的字幕组 `rss_url` 传给 `resolve`,可以获得对应字幕组候选。
|
|
||||||
`resolve` 返回的 `subscription` 对象可直接作为下面预览、保存接口的请求体;默认禁用,`season=1` 只是初始值,需核对季度、偏移和筛选规则。
|
|
||||||
候选中的来源 ID、字幕组 ID 和范围用于发现流程;当前订阅保存标题、RSS 与规则,尚不单独持久化这些来源元数据。
|
|
||||||
|
|
||||||
关键词 RSS 可能跨多部作品,`resolve` 返回 `scope=keyword`、提示和 RSS 地址,`subscription=null`,不会生成单番剧草稿。
|
在另一终端检查服务是否正常:
|
||||||
建议选择具体番剧;若手工使用关键词 RSS,需先用规则将内容限定为一部作品并预览。
|
|
||||||
搜索、查看详情、解析链接和预览均不创建下载任务。发布条目通过 RSS 预览获取,不从详情 HTML 重复解析。
|
|
||||||
这阶段提供后端 API,尚无搜索管理页面或蜜柑账号同步。
|
|
||||||
|
|
||||||
### 网站代理
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export WEBSITE_PROXY_URL='http://127.0.0.1:7897'
|
curl http://127.0.0.1:8080/api/status
|
||||||
# 或 socks5h://127.0.0.1:7897,让代理解析目标域名
|
|
||||||
export MIKAN_BASE_URL='https://mikanani.me'
|
|
||||||
cargo run --locked
|
|
||||||
```
|
```
|
||||||
|
|
||||||
网站客户端支持 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)。
|
[GPL-2.0-only](LICENSE)。本项目独立设计实现,并非 ani-rss 的官方 Rust 版本。
|
||||||
新增 Rust 源文件使用 `SPDX-License-Identifier: GPL-2.0-only` 标识。第三方依赖遵循各自许可证。
|
|
||||||
|
|||||||
-248
@@ -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<Arc<Service>>;
|
|
||||||
|
|
||||||
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<SearchQuery>) -> Result<HttpResponse> {
|
|
||||||
Ok(HttpResponse::Ok().json(state.mikan.search(&query.q).await?))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn mikan_series(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
|
||||||
Ok(HttpResponse::Ok().json(state.mikan.series(&id).await?))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn mikan_cover(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
|
||||||
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<ResolveInput>) -> Result<HttpResponse> {
|
|
||||||
Ok(HttpResponse::Ok().json(state.mikan.resolve(&body.url).await?))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn status(state: State) -> Result<HttpResponse> {
|
|
||||||
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<HttpResponse> {
|
|
||||||
Ok(HttpResponse::Ok().json(state.store.destinations().await?))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_destination(
|
|
||||||
state: State,
|
|
||||||
body: web::Json<DestinationInput>,
|
|
||||||
) -> Result<HttpResponse> {
|
|
||||||
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<DestinationSelection>,
|
|
||||||
) -> Result<HttpResponse> {
|
|
||||||
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<String>) -> Result<HttpResponse> {
|
|
||||||
Ok(HttpResponse::Ok().json(state.store.mappings(&id).await?))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_mapping(
|
|
||||||
state: State,
|
|
||||||
id: web::Path<String>,
|
|
||||||
body: web::Json<MappingInput>,
|
|
||||||
) -> Result<HttpResponse> {
|
|
||||||
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<String>,
|
|
||||||
query: web::Query<DirectoryQuery>,
|
|
||||||
) -> Result<HttpResponse> {
|
|
||||||
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<String>) -> Result<HttpResponse> {
|
|
||||||
Ok(HttpResponse::Ok().json(state.store.file_report(&id).await?))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn check_job_files(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
|
||||||
Ok(HttpResponse::Ok().json(state.check_job_files(&id).await?))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn downloaders(state: State) -> Result<HttpResponse> {
|
|
||||||
Ok(HttpResponse::Ok().json(state.store.downloaders().await?))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_downloader(state: State, body: web::Json<DownloaderInput>) -> Result<HttpResponse> {
|
|
||||||
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<DownloaderSelection>,
|
|
||||||
) -> Result<HttpResponse> {
|
|
||||||
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<String>) -> Result<HttpResponse> {
|
|
||||||
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<HttpResponse> {
|
|
||||||
Ok(HttpResponse::Ok().json(state.store.subscriptions().await?))
|
|
||||||
}
|
|
||||||
async fn get(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
|
||||||
Ok(HttpResponse::Ok().json(state.store.subscription(&id).await?))
|
|
||||||
}
|
|
||||||
async fn create(state: State, body: web::Json<SubscriptionInput>) -> Result<HttpResponse> {
|
|
||||||
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<String>,
|
|
||||||
body: web::Json<SubscriptionInput>,
|
|
||||||
) -> Result<HttpResponse> {
|
|
||||||
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<String>) -> Result<HttpResponse> {
|
|
||||||
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<SubscriptionInput>) -> Result<HttpResponse> {
|
|
||||||
Ok(HttpResponse::Ok().json(state.preview(body.into_inner()).await?))
|
|
||||||
}
|
|
||||||
async fn preview_saved(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
|
||||||
Ok(HttpResponse::Ok().json(state.preview_subscription(&id).await?))
|
|
||||||
}
|
|
||||||
async fn scan(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
|
||||||
Ok(HttpResponse::Ok().json(serde_json::json!({"queued": state.scan_subscription(&id).await?})))
|
|
||||||
}
|
|
||||||
async fn releases(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
|
||||||
Ok(HttpResponse::Ok().json(state.store.releases(&id).await?))
|
|
||||||
}
|
|
||||||
async fn jobs(state: State) -> Result<HttpResponse> {
|
|
||||||
Ok(HttpResponse::Ok().json(state.store.jobs().await?))
|
|
||||||
}
|
|
||||||
async fn retry(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
|
||||||
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<HttpResponse> {
|
|
||||||
Ok(HttpResponse::Ok().json(state.cycle().await?))
|
|
||||||
}
|
|
||||||
+40
@@ -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
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
//! Subscription, release and download job models.
|
||||||
|
pub mod episode;
|
||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
//! Downloader protocols and torrent metadata preparation.
|
||||||
|
pub mod qbittorrent;
|
||||||
|
pub mod torrent;
|
||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct TorrentFile {
|
||||||
|
pub name: String,
|
||||||
|
pub size: i64,
|
||||||
|
pub priority: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Qbit {
|
||||||
|
pub fn new(config: QbitConfig) -> Result<Self> {
|
||||||
|
// 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<Vec<TorrentStatus>> {
|
||||||
|
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<Vec<TorrentFile>> {
|
||||||
|
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<TorrentFile> = 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<Vec<u8>>,
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
use crate::{
|
use crate::{
|
||||||
config::QbitConfig,
|
|
||||||
domain::validate_http_url,
|
domain::validate_http_url,
|
||||||
error::{Error, Result},
|
error::{Error, Result},
|
||||||
feeds::bounded_body,
|
network::{bounded_body, website_get},
|
||||||
};
|
};
|
||||||
use bendy::decoding::{Decoder, Object};
|
use bendy::decoding::{Decoder, Object};
|
||||||
use reqwest::{multipart, Client, Url};
|
use reqwest::{Client, Url};
|
||||||
use serde::Deserialize;
|
|
||||||
use sha1::{Digest, Sha1};
|
use sha1::{Digest, Sha1};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
pub struct PreparedTorrent {
|
pub struct PreparedTorrent {
|
||||||
pub hash: String,
|
pub hash: String,
|
||||||
@@ -100,8 +97,7 @@ pub async fn prepare(client: &Client, url: &str) -> Result<PreparedTorrent> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
validate_http_url(url)?;
|
validate_http_url(url)?;
|
||||||
let response =
|
let response = website_get(client, Url::parse(url).expect("validated URL")).await?;
|
||||||
crate::feeds::website_get(client, Url::parse(url).expect("validated URL")).await?;
|
|
||||||
let bytes = bounded_body(response, 4 * 1024 * 1024).await?;
|
let bytes = bounded_body(response, 4 * 1024 * 1024).await?;
|
||||||
Ok(PreparedTorrent {
|
Ok(PreparedTorrent {
|
||||||
hash: torrent_hash(&bytes)?,
|
hash: torrent_hash(&bytes)?,
|
||||||
@@ -109,127 +105,6 @@ pub async fn prepare(client: &Client, url: &str) -> Result<PreparedTorrent> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct TorrentFile {
|
|
||||||
pub name: String,
|
|
||||||
pub size: i64,
|
|
||||||
pub priority: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Qbit {
|
|
||||||
pub fn new(config: QbitConfig) -> Result<Self> {
|
|
||||||
// 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<Vec<TorrentStatus>> {
|
|
||||||
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<Vec<TorrentFile>> {
|
|
||||||
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<TorrentFile> = 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<Vec<u8>>,
|
|
||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
use actix_web::{http::StatusCode, HttpResponse, ResponseError};
|
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, Error>;
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
|
||||||
@@ -28,20 +27,3 @@ impl From<reqwest::Error> for Error {
|
|||||||
Self::Upstream(value.without_url().to_string())
|
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()}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<HttpResponse> {
|
||||||
|
Ok(HttpResponse::Ok().json(state.store.destinations().await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn create_destination(
|
||||||
|
state: State,
|
||||||
|
body: web::Json<DestinationInput>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
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<DestinationSelection>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
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<String>) -> Result<HttpResponse> {
|
||||||
|
Ok(HttpResponse::Ok().json(state.store.mappings(&id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn create_mapping(
|
||||||
|
state: State,
|
||||||
|
id: web::Path<String>,
|
||||||
|
body: web::Json<MappingInput>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
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<String>,
|
||||||
|
query: web::Query<DirectoryQuery>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -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<HttpResponse> {
|
||||||
|
Ok(HttpResponse::Ok().json(state.store.downloaders().await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn create_downloader(
|
||||||
|
state: State,
|
||||||
|
body: web::Json<DownloaderInput>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
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<DownloaderSelection>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
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<String>) -> Result<HttpResponse> {
|
||||||
|
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?})))
|
||||||
|
}
|
||||||
@@ -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()}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>) -> Result<HttpResponse> {
|
||||||
|
Ok(HttpResponse::Ok().json(state.store.file_report(&id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn check_job_files(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||||
|
Ok(HttpResponse::Ok().json(state.check_job_files(&id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn jobs(state: State) -> Result<HttpResponse> {
|
||||||
|
Ok(HttpResponse::Ok().json(state.store.jobs().await?))
|
||||||
|
}
|
||||||
|
pub(super) async fn retry(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||||
|
let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||||
|
state.store.retry(&id).await?;
|
||||||
|
Ok(HttpResponse::NoContent().finish())
|
||||||
|
}
|
||||||
@@ -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<SearchQuery>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
Ok(HttpResponse::Ok().json(state.mikan.search(&query.q).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn mikan_series(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||||
|
Ok(HttpResponse::Ok().json(state.mikan.series(&id).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn mikan_cover(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||||
|
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<ResolveInput>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
Ok(HttpResponse::Ok().json(state.mikan.resolve(&body.url).await?))
|
||||||
|
}
|
||||||
@@ -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<Arc<Service>>;
|
||||||
|
|
||||||
|
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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HttpResponse> {
|
||||||
|
Ok(HttpResponse::Ok().json(state.store.subscriptions().await?))
|
||||||
|
}
|
||||||
|
pub(super) async fn get(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||||
|
Ok(HttpResponse::Ok().json(state.store.subscription(&id).await?))
|
||||||
|
}
|
||||||
|
pub(super) async fn create(
|
||||||
|
state: State,
|
||||||
|
body: web::Json<SubscriptionInput>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
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<String>,
|
||||||
|
body: web::Json<SubscriptionInput>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
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<String>) -> Result<HttpResponse> {
|
||||||
|
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<SubscriptionInput>,
|
||||||
|
) -> Result<HttpResponse> {
|
||||||
|
Ok(HttpResponse::Ok().json(state.preview(body.into_inner()).await?))
|
||||||
|
}
|
||||||
|
pub(super) async fn preview_saved(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||||
|
Ok(HttpResponse::Ok().json(state.preview_subscription(&id).await?))
|
||||||
|
}
|
||||||
|
pub(super) async fn scan(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||||
|
Ok(HttpResponse::Ok().json(serde_json::json!({"queued": state.scan_subscription(&id).await?})))
|
||||||
|
}
|
||||||
|
pub(super) async fn releases(state: State, id: web::Path<String>) -> Result<HttpResponse> {
|
||||||
|
Ok(HttpResponse::Ok().json(state.store.releases(&id).await?))
|
||||||
|
}
|
||||||
@@ -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<HttpResponse> {
|
||||||
|
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<HttpResponse> {
|
||||||
|
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")}))
|
||||||
|
}
|
||||||
+6
-6
@@ -1,13 +1,13 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// 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 config;
|
||||||
pub mod domain;
|
pub mod domain;
|
||||||
pub mod downloader;
|
pub mod downloaders;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod feeds;
|
pub mod http;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
mod media_store;
|
mod network;
|
||||||
pub mod mikan;
|
|
||||||
pub mod parser;
|
|
||||||
pub mod service;
|
pub mod service;
|
||||||
|
pub mod sources;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
|
|||||||
+2
-141
@@ -1,83 +1,5 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
use actix_web::{web, App, HttpResponse, HttpServer, Responder};
|
use autocomic_rss::{app, config::Config};
|
||||||
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<String>) -> impl Responder {
|
|
||||||
let greeting = Greeting {
|
|
||||||
message: format!("你好,{}!", name),
|
|
||||||
name: name.into_inner(),
|
|
||||||
};
|
|
||||||
HttpResponse::Ok().json(greeting)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn calculate(web::Json(req): web::Json<CalcRequest>) -> 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<serde_json::Value>) -> impl Responder {
|
|
||||||
HttpResponse::Ok().json(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[actix_web::main]
|
#[actix_web::main]
|
||||||
async fn main() -> std::io::Result<()> {
|
async fn main() -> std::io::Result<()> {
|
||||||
@@ -87,66 +9,5 @@ async fn main() -> std::io::Result<()> {
|
|||||||
)
|
)
|
||||||
.init();
|
.init();
|
||||||
let config = Config::from_env().map_err(std::io::Error::other)?;
|
let config = Config::from_env().map_err(std::io::Error::other)?;
|
||||||
std::fs::create_dir_all("data")?;
|
app::run(config).await
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
-269
@@ -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<i64>,
|
|
||||||
pub status: String,
|
|
||||||
pub error: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct FileReport {
|
|
||||||
pub job_id: String,
|
|
||||||
pub destination_id: Option<String>,
|
|
||||||
pub status: String,
|
|
||||||
pub checked_at: Option<i64>,
|
|
||||||
pub last_success_at: Option<i64>,
|
|
||||||
pub error: Option<String>,
|
|
||||||
pub files: Vec<MediaFile>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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<String> {
|
|
||||||
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<MediaFile>) -> Vec<MediaFile> {
|
|
||||||
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<u64>,
|
|
||||||
}
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct DirectoryListing {
|
|
||||||
pub status: &'static str,
|
|
||||||
pub error: Option<String>,
|
|
||||||
pub entries: Vec<DirectoryEntry>,
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<MediaFile>) -> Vec<MediaFile> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<i64>,
|
||||||
|
pub status: String,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct FileReport {
|
||||||
|
pub job_id: String,
|
||||||
|
pub destination_id: Option<String>,
|
||||||
|
pub status: String,
|
||||||
|
pub checked_at: Option<i64>,
|
||||||
|
pub last_success_at: Option<i64>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub files: Vec<MediaFile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DirectoryEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub kind: &'static str,
|
||||||
|
pub size: Option<u64>,
|
||||||
|
}
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DirectoryListing {
|
||||||
|
pub status: &'static str,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub entries: Vec<DirectoryEntry>,
|
||||||
|
pub truncated: bool,
|
||||||
|
}
|
||||||
@@ -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<String> {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Client> {
|
||||||
|
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<reqwest::Response> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
+6
-6
@@ -2,10 +2,10 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
config::Config,
|
config::Config,
|
||||||
domain::*,
|
domain::*,
|
||||||
downloader::{self, Qbit},
|
downloaders::{qbittorrent::Qbit, torrent},
|
||||||
error::{Error, Result},
|
error::{Error, Result},
|
||||||
feeds,
|
|
||||||
media::{self, FileReport, MediaFile},
|
media::{self, FileReport, MediaFile},
|
||||||
|
sources::feeds,
|
||||||
storage::Store,
|
storage::Store,
|
||||||
};
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -18,7 +18,7 @@ use tokio::sync::{watch, Mutex};
|
|||||||
pub struct Service {
|
pub struct Service {
|
||||||
pub store: Store,
|
pub store: Store,
|
||||||
pub gate: Mutex<()>,
|
pub gate: Mutex<()>,
|
||||||
pub mikan: crate::mikan::Mikan,
|
pub mikan: crate::sources::mikan::Mikan,
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,8 +32,8 @@ pub struct CycleReport {
|
|||||||
|
|
||||||
impl Service {
|
impl Service {
|
||||||
pub async fn new(store: Store, config: &Config) -> Result<Self> {
|
pub async fn new(store: Store, config: &Config) -> Result<Self> {
|
||||||
let client = feeds::website_client(&config.website)?;
|
let client = crate::network::website_client(&config.website)?;
|
||||||
let mikan = crate::mikan::Mikan::new(client.clone(), &config.mikan_base_url)?;
|
let mikan = crate::sources::mikan::Mikan::new(client.clone(), &config.mikan_base_url)?;
|
||||||
store.bootstrap_downloader(config.qbit.as_ref()).await?;
|
store.bootstrap_downloader(config.qbit.as_ref()).await?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
store,
|
store,
|
||||||
@@ -180,7 +180,7 @@ impl Service {
|
|||||||
let result: Result<()> = async {
|
let result: Result<()> = async {
|
||||||
if job.info_hash.is_none() {
|
if job.info_hash.is_none() {
|
||||||
if !enabled { return Ok(()); }
|
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?;
|
self.store.prepare(&job.id, &prepared.hash, prepared.bytes.as_deref()).await?;
|
||||||
job.info_hash = Some(prepared.hash);
|
job.info_hash = Some(prepared.hash);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +1,11 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
use crate::{
|
use crate::{
|
||||||
domain::*,
|
domain::{episode as parser, *},
|
||||||
error::{Error, Result},
|
error::{Error, Result},
|
||||||
parser,
|
network::{bounded_body, website_get},
|
||||||
};
|
};
|
||||||
use reqwest::{Client, Url};
|
use reqwest::{Client, Url};
|
||||||
use sha1::{Digest, Sha1};
|
use sha1::{Digest, Sha1};
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
pub fn http_client() -> Result<Client> {
|
|
||||||
website_client(&crate::config::WebsiteConfig::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn website_client(config: &crate::config::WebsiteConfig) -> Result<Client> {
|
|
||||||
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<reqwest::Response> {
|
|
||||||
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<Vec<u8>> {
|
|
||||||
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<Vec<Release>> {
|
pub async fn fetch(client: &Client, sub: &SubscriptionInput) -> Result<Vec<Release>> {
|
||||||
let response = website_get(
|
let response = website_get(
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
domain::{validate_http_url, SubscriptionInput},
|
domain::{validate_http_url, SubscriptionInput},
|
||||||
error::{Error, Result},
|
error::{Error, Result},
|
||||||
feeds::{bounded_body, website_get},
|
network::{bounded_body, website_get},
|
||||||
};
|
};
|
||||||
use reqwest::{Client, Url};
|
use reqwest::{Client, Url};
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
//! Subscription discovery and RSS/Atom parsing.
|
||||||
|
pub mod feeds;
|
||||||
|
pub mod mikan;
|
||||||
@@ -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;
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
use super::{now, Store};
|
||||||
use crate::{
|
use crate::{
|
||||||
domain::DownloadJob,
|
domain::DownloadJob,
|
||||||
error::{Error, Result},
|
error::{Error, Result},
|
||||||
media::*,
|
media::*,
|
||||||
storage::{now, Store},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
impl Store {
|
impl Store {
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
mod media;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
domain::*,
|
domain::*,
|
||||||
error::{Error, Result},
|
error::{Error, Result},
|
||||||
@@ -17,7 +19,7 @@ pub struct Store {
|
|||||||
pub pool: SqlitePool,
|
pub pool: SqlitePool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn now() -> i64 {
|
pub(crate) fn now() -> i64 {
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
use actix_web::{test, web, App};
|
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 serde_json::{json, Value};
|
||||||
use std::{sync::Arc, time::Duration};
|
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(
|
let app = test::init_service(
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(web::Data::new(service))
|
.app_data(web::Data::new(service))
|
||||||
.configure(api::configure),
|
.configure(http::configure),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
for body in [
|
for body in [
|
||||||
|
|||||||
+6
-6
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
use actix_web::{dev::ServerHandle, test, web, App, HttpRequest, HttpResponse, HttpServer};
|
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 serde_json::Value;
|
||||||
use std::{net::TcpListener, sync::Arc, time::Duration};
|
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(
|
let app = test::init_service(
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(web::Data::new(Arc::new(service)))
|
.app_data(web::Data::new(Arc::new(service)))
|
||||||
.configure(api::configure),
|
.configure(http::configure),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let detail: Value = test::call_and_read_body_json(
|
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(
|
let app = test::init_service(
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(web::Data::new(Arc::new(service)))
|
.app_data(web::Data::new(Arc::new(service)))
|
||||||
.configure(api::configure),
|
.configure(http::configure),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
for (query, status) in [
|
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(
|
let app = test::init_service(
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(web::Data::new(Arc::new(service)))
|
.app_data(web::Data::new(Arc::new(service)))
|
||||||
.configure(api::configure),
|
.configure(http::configure),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let response = test::call_service(
|
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(
|
let app = test::init_service(
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(web::Data::new(Arc::new(service)))
|
.app_data(web::Data::new(Arc::new(service)))
|
||||||
.configure(api::configure),
|
.configure(http::configure),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let response = test::call_service(
|
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(
|
let app = test::init_service(
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(web::Data::new(Arc::new(service)))
|
.app_data(web::Data::new(Arc::new(service)))
|
||||||
.configure(api::configure),
|
.configure(http::configure),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let response = test::call_service(
|
let response = test::call_service(
|
||||||
|
|||||||
+6
-6
@@ -1,8 +1,8 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
use actix_web::{dev::ServerHandle, test, web, App, HttpRequest, HttpResponse, HttpServer};
|
use actix_web::{dev::ServerHandle, test, web, App, HttpRequest, HttpResponse, HttpServer};
|
||||||
use rust_project::{
|
use autocomic_rss::{
|
||||||
api,
|
|
||||||
config::{Config, QbitConfig},
|
config::{Config, QbitConfig},
|
||||||
|
http,
|
||||||
service::Service,
|
service::Service,
|
||||||
storage::Store,
|
storage::Store,
|
||||||
};
|
};
|
||||||
@@ -56,7 +56,7 @@ async fn direct_exceptions_are_explicit_and_proxy_failure_never_falls_back() {
|
|||||||
poll_interval: Duration::from_secs(300),
|
poll_interval: Duration::from_secs(300),
|
||||||
qbit: None,
|
qbit: None,
|
||||||
mikan_base_url: "http://mikan.invalid".into(),
|
mikan_base_url: "http://mikan.invalid".into(),
|
||||||
website: rust_project::config::WebsiteConfig {
|
website: autocomic_rss::config::WebsiteConfig {
|
||||||
proxy_url: Some(proxy.clone()),
|
proxy_url: Some(proxy.clone()),
|
||||||
direct_hosts: direct_hosts.into(),
|
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(
|
let app = test::init_service(
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(web::Data::new(Arc::new(service)))
|
.app_data(web::Data::new(Arc::new(service)))
|
||||||
.configure(api::configure),
|
.configure(http::configure),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let mut redirect = reqwest::Url::parse("http://unresolvable.invalid/to-direct").unwrap();
|
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,
|
port: 8080,
|
||||||
poll_interval: Duration::from_secs(300),
|
poll_interval: Duration::from_secs(300),
|
||||||
mikan_base_url: "http://mikan.invalid".into(),
|
mikan_base_url: "http://mikan.invalid".into(),
|
||||||
website: rust_project::config::WebsiteConfig {
|
website: autocomic_rss::config::WebsiteConfig {
|
||||||
proxy_url: Some(proxy_url.into()),
|
proxy_url: Some(proxy_url.into()),
|
||||||
direct_hosts: String::new(),
|
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(
|
let app = test::init_service(
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(web::Data::new(Arc::new(service)))
|
.app_data(web::Data::new(Arc::new(service)))
|
||||||
.configure(api::configure),
|
.configure(http::configure),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let search: Value = test::call_and_read_body_json(
|
let search: Value = test::call_and_read_body_json(
|
||||||
|
|||||||
+15
-15
@@ -1,11 +1,11 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-only
|
// SPDX-License-Identifier: GPL-2.0-only
|
||||||
use actix_web::{dev::ServerHandle, web, App, HttpRequest, HttpResponse, HttpServer};
|
use actix_web::{dev::ServerHandle, web, App, HttpRequest, HttpResponse, HttpServer};
|
||||||
use rust_project::{
|
use autocomic_rss::{
|
||||||
api,
|
|
||||||
config::{Config, QbitConfig},
|
config::{Config, QbitConfig},
|
||||||
domain::{Decision, SubscriptionInput},
|
domain::{Decision, SubscriptionInput},
|
||||||
feeds,
|
http,
|
||||||
service::Service,
|
service::Service,
|
||||||
|
sources::feeds,
|
||||||
storage::Store,
|
storage::Store,
|
||||||
};
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -74,7 +74,7 @@ async fn add(
|
|||||||
let text = String::from_utf8_lossy(&body);
|
let text = String::from_utf8_lossy(&body);
|
||||||
let hash = if text.contains("name=\"torrents\"") {
|
let hash = if text.contains("name=\"torrents\"") {
|
||||||
assert!(body.windows(TORRENT.len()).any(|w| w == TORRENT));
|
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 {
|
} else {
|
||||||
assert!(text.contains("name=\"urls\""));
|
assert!(text.contains("name=\"urls\""));
|
||||||
regex::Regex::new("urn:btih:([a-f0-9]{40})")
|
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(
|
let app = test::init_service(
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(web::Data::new(service.clone()))
|
.app_data(web::Data::new(service.clone()))
|
||||||
.configure(api::configure),
|
.configure(http::configure),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let mut invalid = input("http://example.invalid", false);
|
let mut invalid = input("http://example.invalid", false);
|
||||||
@@ -369,7 +369,7 @@ async fn downloads_relative_torrent_enclosure_and_submits_original_bytes() {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
jobs[0].info_hash.as_deref(),
|
jobs[0].info_hash.as_deref(),
|
||||||
Some(
|
Some(
|
||||||
rust_project::downloader::torrent_hash(TORRENT)
|
autocomic_rss::downloaders::torrent::torrent_hash(TORRENT)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_str()
|
.as_str()
|
||||||
)
|
)
|
||||||
@@ -404,7 +404,7 @@ async fn switching_keeps_queued_jobs_and_retries_with_their_original_downloader_
|
|||||||
let app = test::init_service(
|
let app = test::init_service(
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(web::Data::new(service.clone()))
|
.app_data(web::Data::new(service.clone()))
|
||||||
.configure(api::configure),
|
.configure(http::configure),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let instances: Value = test::call_and_read_body_json(
|
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(
|
let app = test::init_service(
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(web::Data::new(recovered.clone()))
|
.app_data(web::Data::new(recovered.clone()))
|
||||||
.configure(api::configure),
|
.configure(http::configure),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let status: Value = test::call_and_read_body_json(
|
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(
|
let app = test::init_service(
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(web::Data::new(service.clone()))
|
.app_data(web::Data::new(service.clone()))
|
||||||
.configure(api::configure),
|
.configure(http::configure),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let status: Value = test::call_and_read_body_json(
|
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]
|
#[actix_web::test]
|
||||||
async fn destination_and_mapping_changes_preserve_queued_paths_and_restart_snapshots() {
|
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 (url, remote, server) = mock().await;
|
||||||
let path =
|
let path =
|
||||||
std::env::temp_dir().join(format!("destination-snapshots-{}.db", uuid::Uuid::new_v4()));
|
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);
|
assert_eq!(remote.lock().unwrap().adds, 3);
|
||||||
let other = recovered
|
let other = recovered
|
||||||
.store
|
.store
|
||||||
.create_downloader(rust_project::domain::DownloaderInput {
|
.create_downloader(autocomic_rss::domain::DownloaderInput {
|
||||||
name: "unmapped".into(),
|
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(),
|
connection: config("http://127.0.0.1:1").qbit.unwrap(),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -881,7 +881,7 @@ async fn destination_and_mapping_changes_preserve_queued_paths_and_restart_snaps
|
|||||||
#[actix_web::test]
|
#[actix_web::test]
|
||||||
async fn file_checks_keep_download_completion_and_last_success_when_storage_is_unavailable() {
|
async fn file_checks_keep_download_completion_and_last_success_when_storage_is_unavailable() {
|
||||||
use actix_web::test;
|
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 (url, remote, server) = mock().await;
|
||||||
let directory = std::env::temp_dir().join(format!("local-media-{}", uuid::Uuid::new_v4()));
|
let directory = std::env::temp_dir().join(format!("local-media-{}", uuid::Uuid::new_v4()));
|
||||||
let root = directory.join("media");
|
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(
|
let app = test::init_service(
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(web::Data::new(service.clone()))
|
.app_data(web::Data::new(service.clone()))
|
||||||
.configure(api::configure),
|
.configure(http::configure),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let report_url = format!("/api/jobs/{}/files", job.id);
|
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]
|
#[actix_web::test]
|
||||||
async fn unsafe_downloader_paths_and_unreadable_files_are_unavailable_not_missing() {
|
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 (url, remote, server) = mock().await;
|
||||||
let directory =
|
let directory =
|
||||||
std::env::temp_dir().join(format!("media-confinement-{}", uuid::Uuid::new_v4()));
|
std::env::temp_dir().join(format!("media-confinement-{}", uuid::Uuid::new_v4()));
|
||||||
|
|||||||
Reference in New Issue
Block a user