feat: implement RSS subscriptions, downloader routing and local media checks
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# Copy values into your shell, or source a private .env file before cargo run.
|
||||
# This application does not automatically load .env files.
|
||||
APP_PORT=8080
|
||||
DATABASE_URL=sqlite://data/ani.db
|
||||
POLL_INTERVAL_SECONDS=300
|
||||
RUST_LOG=info
|
||||
|
||||
# Website requests (Mikan pages/covers, RSS and torrent metadata).
|
||||
# Unset means direct; HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY are NOT inherited.
|
||||
MIKAN_BASE_URL=https://mikanani.me
|
||||
# WEBSITE_PROXY_URL=http://127.0.0.1:7897
|
||||
# WEBSITE_PROXY_URL=socks5h://127.0.0.1:7897
|
||||
# Optional URL-encoded credentials: http://username:password@host:port
|
||||
# Comma-separated explicit direct exceptions; empty by default.
|
||||
# WEBSITE_DIRECT_HOSTS=localhost,127.0.0.1,::1
|
||||
# qBittorrent control connections always remain direct.
|
||||
|
||||
# Initial import only, when the database has no downloader instances.
|
||||
# Existing instances and the global selection are managed through /api/downloaders.
|
||||
# When upgrading, these MUST identify the original downloader of existing jobs.
|
||||
# Unset on a fresh installation leaves jobs unassigned until first selection.
|
||||
# QBIT_URL=http://127.0.0.1:8081
|
||||
# QBIT_USERNAME=admin
|
||||
# QBIT_PASSWORD=replace-me
|
||||
# Path as seen by qBittorrent, not by this Rust process.
|
||||
# QBIT_SAVE_PATH=/downloads
|
||||
@@ -52,3 +52,10 @@ desktop.ini
|
||||
|
||||
# For Cargo documentation
|
||||
/doc/
|
||||
|
||||
# Local state and credentials
|
||||
/data/
|
||||
.env
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
Generated
+3841
File diff suppressed because it is too large
Load Diff
+21
-1
@@ -1,6 +1,26 @@
|
||||
[package]
|
||||
name = "rust_project"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
edition = "2021"
|
||||
license = "GPL-2.0-only"
|
||||
description = "A Rust RSS subscription and download automation service"
|
||||
|
||||
[dependencies]
|
||||
actix-web = "4.9"
|
||||
actix-files = "0.6"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1", features = ["sync", "time", "signal", "rt-multi-thread", "macros"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "cookies", "multipart", "socks"] }
|
||||
scraper = "0.24"
|
||||
cap-std = "4.0"
|
||||
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "derive", "migrate", "macros"] }
|
||||
feed-rs = "2.3"
|
||||
regex = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
thiserror = "2"
|
||||
sha1 = "0.10"
|
||||
bendy = "0.3"
|
||||
data-encoding = "2"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1989
|
||||
Ty Coon, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License.
|
||||
@@ -0,0 +1,259 @@
|
||||
# Rust RSS 追番服务
|
||||
|
||||
使用 Rust 独立设计实现的 RSS 订阅与下载自动化项目,采用 **GPL-2.0-only**。
|
||||
功能方向参考 [ani-rss](https://github.com/wushuo894/ani-rss),没有移植其 Java 业务代码或前端资源;本项目不是上游的官方 Rust 版本。
|
||||
|
||||
当前为后端 MVP:蜜柑搜索 / 手动 RSS → 订阅管理 → RSS/Atom 预览 → 持久化任务 → qBittorrent 提交与状态同步。
|
||||
现有 Actix 示例接口继续保留。
|
||||
|
||||
## 运行
|
||||
|
||||
```sh
|
||||
cargo run --locked
|
||||
```
|
||||
|
||||
默认监听 `127.0.0.1:8080`,自动创建 `data/ani.db` 并运行数据库迁移。
|
||||
当前 API 供本机使用,没有用户认证;监听地址固定为回环地址。
|
||||
首次运行需要下载 Cargo 依赖。已在 Rust 1.93.1 上进行编译验证。
|
||||
|
||||
按需配置环境变量,参见 [.env.example](.env.example)。程序不自动读取 `.env`:
|
||||
|
||||
```sh
|
||||
export QBIT_URL='http://127.0.0.1:8081'
|
||||
export QBIT_USERNAME='admin'
|
||||
export QBIT_PASSWORD='你的密码'
|
||||
export QBIT_SAVE_PATH='/downloads'
|
||||
cargo run --locked
|
||||
```
|
||||
|
||||
qBittorrent 需要启用 Web UI。`QBIT_SAVE_PATH` 是下载器看到的路径。
|
||||
`QBIT_*` 只在数据库尚无下载器实例时导入一次,并选为全局下载器。已有实例时,重启或修改这些环境变量不会覆盖保存的实例或选择。
|
||||
全新安装不设置 `QBIT_URL` 时,仍可管理订阅、预览和生成未分配任务;首次通过接口选中下载器后绑定这些任务。清空环境变量不会停用数据库中已选中的下载器。
|
||||
程序启动后立即执行一轮检查,之后默认每 300 秒检查一次;`POLL_INTERVAL_SECONDS` 最小为 10。
|
||||
|
||||
## 使用流程
|
||||
|
||||
### 从蜜柑发现订阅
|
||||
|
||||
```sh
|
||||
curl -G 'http://127.0.0.1:8080/api/sources/mikan/search' --data-urlencode 'q=葬送'
|
||||
curl 'http://127.0.0.1:8080/api/sources/mikan/series/3141'
|
||||
curl 'http://127.0.0.1:8080/api/sources/mikan/resolve' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"url":"https://mikanani.me/Home/Bangumi/3141"}'
|
||||
```
|
||||
|
||||
搜索返回各季度的独立候选及关键词 RSS。详情返回整部番剧 RSS、字幕组 RSS、站点信息和可用封面地址。
|
||||
将详情中的字幕组 `rss_url` 传给 `resolve`,可以获得对应字幕组候选。
|
||||
`resolve` 返回的 `subscription` 对象可直接作为下面预览、保存接口的请求体;默认禁用,`season=1` 只是初始值,需核对季度、偏移和筛选规则。
|
||||
候选中的来源 ID、字幕组 ID 和范围用于发现流程;当前订阅保存标题、RSS 与规则,尚不单独持久化这些来源元数据。
|
||||
|
||||
关键词 RSS 可能跨多部作品,`resolve` 返回 `scope=keyword`、提示和 RSS 地址,`subscription=null`,不会生成单番剧草稿。
|
||||
建议选择具体番剧;若手工使用关键词 RSS,需先用规则将内容限定为一部作品并预览。
|
||||
搜索、查看详情、解析链接和预览均不创建下载任务。发布条目通过 RSS 预览获取,不从详情 HTML 重复解析。
|
||||
这阶段提供后端 API,尚无搜索管理页面或蜜柑账号同步。
|
||||
|
||||
### 网站代理
|
||||
|
||||
```sh
|
||||
export WEBSITE_PROXY_URL='http://127.0.0.1:7897'
|
||||
# 或 socks5h://127.0.0.1:7897,让代理解析目标域名
|
||||
export MIKAN_BASE_URL='https://mikanani.me'
|
||||
cargo run --locked
|
||||
```
|
||||
|
||||
网站客户端支持 HTTP、HTTPS、SOCKS5 和 SOCKS5H 代理 URL,可在 URL 内携带编码后的用户名和密码。
|
||||
`socks5` 本地解析目标域名,`socks5h` 由代理解析。配置在启动时读取,修改后重启。
|
||||
**不继承** `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY` 或 `NO_PROXY`;未设置 `WEBSITE_PROXY_URL` 时直连。
|
||||
可用 `WEBSITE_DIRECT_HOSTS` 设置逗号分隔的显式直连域名/IP/CIDR,例如 `localhost,127.0.0.1,::1`,默认没有例外。
|
||||
每次重定向重新应用代理及例外;代理失败返回错误,不自动直连。
|
||||
|
||||
蜜柑搜索、详情、封面、RSS 和种子元数据请求共用网站策略。封面返回本应用地址,由后端获取,浏览器无需直连蜜柑。
|
||||
当前仅识别蜜柑同源 `/images/` 下的封面,支持 PNG/JPEG/WebP/GIF;其他图片布局会显示无封面。
|
||||
qBittorrent 控制连接独立且始终直连;BT 传输由下载器控制。容器中的 `127.0.0.1` 指容器自身。
|
||||
网站基址必须是无路径、查询参数或凭据的 HTTP(S) origin;粘贴解析仅接受该站点的公开搜索/番剧页面或 RSS。
|
||||
|
||||
### 预览、保存和启用
|
||||
|
||||
1. 预览真实的 RSS 地址,查看匹配结果。预览不会保存订阅或提交下载。
|
||||
|
||||
```sh
|
||||
curl -s http://127.0.0.1:8080/api/preview \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"title": "示例番剧",
|
||||
"rss_url": "https://example.com/anime.xml",
|
||||
"season": 1,
|
||||
"episode_offset": 0,
|
||||
"include": ["1080p"],
|
||||
"exclude": ["合集"],
|
||||
"enabled": false
|
||||
}'
|
||||
```
|
||||
|
||||
2. 将相同 JSON 提交到 `POST /api/subscriptions` 保存订阅。订阅默认禁用。
|
||||
3. 确认预览结果后,向 `PUT /api/subscriptions/{id}` 提交完整 JSON,将 `enabled` 改为 `true`。
|
||||
4. 等待自动检查,或使用 `POST /api/run` 立即执行一轮 RSS 检查和下载同步。
|
||||
5. 使用 `GET /api/jobs` 查看状态、失败原因和重试时间。
|
||||
|
||||
首次启用会考虑 RSS 当前返回的全部匹配条目,尚未实现“仅从当前最新集开始”。
|
||||
`include` 为不区分大小写的关键词“全部满足”,`exclude` 为“任意命中则排除”,不是正则表达式。
|
||||
修改订阅规则只影响后续扫描;已入队任务保留创建时的季度、集数和资源地址。禁用订阅会暂停未提交任务。
|
||||
|
||||
## API
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/status` | 版本及下载器是否配置,不返回凭据 |
|
||||
| GET / POST | `/api/downloaders` | 列出 / 创建下载器实例,响应不返回密码 |
|
||||
| PUT | `/api/downloaders/active` | `{"downloader_id":"实例 ID"}` 选择接收新任务的全局下载器 |
|
||||
| POST | `/api/downloaders/{id}/claim-legacy-jobs` | 将升级后尚无归属的旧任务明确关联到恢复的原实例 |
|
||||
| GET / POST | `/api/destinations` | 列出 / 创建本地媒体目的地 |
|
||||
| PUT | `/api/destinations/active` | `{"destination_id":"目的地 ID"}` 设置全局目的地 |
|
||||
| GET / POST | `/api/destinations/{id}/mappings` | 查看历史映射 / 新增映射版本 |
|
||||
| GET | `/api/destinations/{id}/entries?path=相对目录` | 只读浏览目录,省略 path 为根目录 |
|
||||
| GET | `/api/sources/mikan/search?q=关键词` | 番剧搜索和关键词 RSS |
|
||||
| GET | `/api/sources/mikan/series/{id}` | 番剧、字幕组及对应 RSS |
|
||||
| GET | `/api/sources/mikan/series/{id}/cover` | 经网站网络策略获取封面 |
|
||||
| POST | `/api/sources/mikan/resolve` | `{"url":"蜜柑地址"}` → 订阅候选 |
|
||||
| POST | `/api/preview` | 临时预览,不写库、不下载 |
|
||||
| GET / POST | `/api/subscriptions` | 列出 / 创建订阅 |
|
||||
| GET / PUT / DELETE | `/api/subscriptions/{id}` | 查询 / 完整替换 / 删除订阅 |
|
||||
| GET | `/api/subscriptions/{id}/preview` | 预览已保存订阅,包含去重判断 |
|
||||
| POST | `/api/subscriptions/{id}/scan` | 扫描并入队,要求订阅已启用 |
|
||||
| GET | `/api/subscriptions/{id}/releases` | 最近最多 500 条扫描记录 |
|
||||
| GET | `/api/jobs` | 最近最多 500 个任务 |
|
||||
| GET | `/api/jobs/{id}/files` | 读取已保存的文件清单与核对状态 |
|
||||
| POST | `/api/jobs/{id}/files/check` | 发现文件清单(首次)并核对本地文件是否存在 |
|
||||
| POST | `/api/jobs/{id}/retry` | 手动重试 failed / missing 任务 |
|
||||
| POST | `/api/run` | 执行完整扫描及下载同步 |
|
||||
|
||||
后台处理与手动执行互斥;处理期间修改订阅或再次执行会返回 `409`,稍后重试即可。
|
||||
删除订阅为软删除,保留去重与任务记录,不删除下载器任务或媒体文件。已提交任务仍会同步状态。
|
||||
|
||||
## 下载器实例与切换
|
||||
|
||||
```sh
|
||||
curl 'http://127.0.0.1:8080/api/downloaders' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"NAS qBittorrent","kind":"qbittorrent","connection":{"url":"http://nas:8081","username":"admin","password":"你的密码","save_path":"/downloads"}}'
|
||||
|
||||
# 将返回的 id 填入下面的 downloader_id。
|
||||
curl -X PUT 'http://127.0.0.1:8080/api/downloaders/active' \
|
||||
-H 'Content-Type: application/json' -d '{"downloader_id":"返回的实例 ID"}'
|
||||
```
|
||||
|
||||
创建实例不会自动切换全局选择。任务在扫描入队的同一个事务中保存 `downloader_id`;已有归属的排队任务、重试和重启恢复都使用原实例。
|
||||
切换后继续同步原实例;一个实例连接失败会报告该实例 ID,并继续处理其他实例,不把连接失败标成任务丢失。
|
||||
原实例的地址与写入路径不随全局选择改变。本阶段只实现多个 qBittorrent 实例,不提供实例编辑/删除;第二种工具与凭据维护接口另行扩展。
|
||||
实例凭据保存在 SQLite 数据库中,尚未加密;数据库及其备份应按含凭据的配置文件管理。列表和创建响应只返回 `password_configured`。
|
||||
|
||||
### 从旧版本升级
|
||||
|
||||
升级迁移为旧任务增加实例归属字段。首次导入时,应提供**旧任务实际使用的原 `QBIT_*` 配置**,应用会将它与旧任务关联;不能在升级时把这些值替换成另一台下载器。
|
||||
若升级时没有提供原配置,历史任务保留 `legacy_unassigned=true`,不会自动绑定以后新选中的实例;`/api/status` 的 `legacy_unassigned_jobs` 及运行报告会提示数量。
|
||||
恢复原实例后,显式调用 `POST /api/downloaders/{原实例ID}/claim-legacy-jobs` 才建立关联。该操作仅认领没有归属的旧任务,不迁移已有归属,也不立即提交下载;后续同步先核对原实例的任务。
|
||||
|
||||
## 全局媒体目的地与本地文件核对
|
||||
|
||||
先配置并选中下载器,再创建一个本地目的地,配置该下载器的写入路径映射,最后激活目的地:
|
||||
|
||||
```sh
|
||||
curl 'http://127.0.0.1:8080/api/destinations' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"番剧目录","kind":"local","root_path":"/mnt/nas/anime"}'
|
||||
|
||||
curl 'http://127.0.0.1:8080/api/destinations/目的地ID/mappings' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"downloader_id":"下载器ID","download_root":"/downloads/anime"}'
|
||||
|
||||
curl -X PUT 'http://127.0.0.1:8080/api/destinations/active' \
|
||||
-H 'Content-Type: application/json' -d '{"destination_id":"目的地ID"}'
|
||||
```
|
||||
|
||||
`root_path` 是 Rust 应用所在环境可访问的绝对目录;`download_root` 是同一个位置在下载器环境中的绝对路径。
|
||||
配置映射不会挂载、复制或上传文件,也不会创建这些目录。两个路径必须实际指向同一批文件。
|
||||
同一时刻所有新任务统一使用全局目的地,订阅不能覆盖。切换目的地或下载器前,必须存在所需映射,否则返回 `400`,不会静默使用默认目录。
|
||||
|
||||
每次 `POST mappings` 都追加一个版本;新任务使用最新版本,旧任务保留 `destination_id`、`mapping_id` 和 `download_save_path`。
|
||||
目的地读取根目录和已有映射版本保持不变;更改读取根需创建新目的地。即使任务尚未提交、后来重试或应用重启,也不重新解释其存储位置。
|
||||
同一下载器里已存在相同 hash,但保存路径与任务快照不一致时,任务报告冲突,不复用错误位置的下载、不自动移动种子。
|
||||
|
||||
未启用目的地时继续使用原下载器 `save_path`,保持旧配置可用。旧任务不会自动绑定后来创建的目的地;没有可靠位置的任务核对为 `unchecked`。本阶段没有旧任务目的地补录或文件迁移接口。
|
||||
|
||||
```sh
|
||||
curl -G 'http://127.0.0.1:8080/api/destinations/目的地ID/entries' --data-urlencode 'path=Series'
|
||||
curl -X POST 'http://127.0.0.1:8080/api/jobs/任务ID/files/check'
|
||||
curl 'http://127.0.0.1:8080/api/jobs/任务ID/files'
|
||||
```
|
||||
|
||||
首次核对向原下载器读取文件清单,并核实其保存路径与快照一致。清单使用 [qBittorrent 文件接口](https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-%28qBittorrent-5.0%29#get-torrent-contents) 提供的相对文件名和大小,排除优先级为 0 的文件;不从发布标题猜文件名。
|
||||
清单成功保存后,后续核对直接访问记录的本地路径,下载器离线也能执行。清单不会随下载器外部改名或移动而自动改写;本阶段不提供清单刷新或路径迁移。
|
||||
核对由上述 POST 接口显式触发,后台下载同步暂不自动扫描文件系统。
|
||||
|
||||
| 状态 | 含义 |
|
||||
| --- | --- |
|
||||
| `unchecked` | 尚未核对,或未记录目的地、种子元数据/已选文件尚不可用 |
|
||||
| `present` | 成功访问并找到所有记录的普通文件 |
|
||||
| `absent` | 成功访问根目录,至少一个记录的文件不存在 |
|
||||
| `unavailable` | 根目录不可访问、权限不足、路径越界、映射不匹配或首次读取下载器清单失败 |
|
||||
|
||||
汇总时 `unavailable` 优先于 `absent`;每个文件分别记录结果。响应含本次尝试时间 `checked_at` 和最后成功核对时间 `last_success_at`,失败不会抹去后者。
|
||||
存在状态独立于下载状态;文件缺失不会触发重下载或删除。返回预期/观察大小供查看,`present` 不保证大小一致或内容完整,也不将下载标记为完成。
|
||||
目录浏览最多返回 1000 项,达到上限时 `truncated=true`;单个任务清单上限 5000 项。不读取文件内容、不提供播放。
|
||||
|
||||
本地访问通过 [cap-std 的目录能力接口](https://docs.rs/cap-std/4.0.3/cap_std/fs/struct.Dir.html) 限定在配置根目录内。相对路径拒绝绝对地址、`..`、盘符和反斜杠;符号链接不能越出根目录。根目录若离线而仍保留可访问的空挂载点,本阶段不具备挂载状态识别能力。
|
||||
P2 仅支持进程可访问的本地目录。SMB 应用直连属于 P3,尚未实现。
|
||||
|
||||
## 当前规则与恢复行为
|
||||
|
||||
- 支持常见 `[03]`、`- 03`、`EP03`、`第03话`、`S01E03` 以及 `03v2` 标题。
|
||||
- 合集、分数集数、模糊或冲突的集数标记为 `needs_review`;目前可通过调整源或规则解决,尚无人工集数映射接口。
|
||||
- RSS enclosure、Atom enclosure、磁力链接及 `.torrent` 链接可作为资源入口;尚未覆盖所有站点私有 XML 扩展。
|
||||
- 支持 BitTorrent v1 磁力链接(十六进制或 Base32)及 v1/hybrid 种子;纯 v2 尚不支持。
|
||||
- 同一订阅内按资源地址及“季度 + 集数”去重。相同集数有多个版本时采用 RSS 中先出现的条目,尚不自动洗版。
|
||||
- 保存原始资源标题、地址、识别结果、过滤原因;种子文件按原始 info 字节计算 hash,并保存实际提交的种子数据。
|
||||
- 下载前先持久化 hash 和提交意图,再调用下载器。超时或重启后先查询 qBittorrent,按 hash 核对,不盲目重复提交。
|
||||
- 网络失败按 30 秒起、最多一小时的退避间隔重试;实际执行时间还受扫描周期影响。无效种子进入 `failed`,需手动重试。
|
||||
- 已提交任务若从下载器消失,标记为 `missing`,不会自动重新下载。完成任务记录保留,不因再次扫描而重建。
|
||||
- RSS/种子响应限制为 4 MiB,请求超时 30 秒。下载器 Cookie 与 RSS 请求使用不同客户端。
|
||||
- 蜜柑 HTML 上限 8 MiB,封面上限 4 MiB;成功搜索/详情缓存 60 秒、最多 64 项,最多同时执行 4 个蜜柑请求,超限返回 `429`。HTTP 重定向最多 5 次。
|
||||
- 无搜索结果返回空列表;页面无法识别、访问受限及上游失败返回 `502`,不会伪装成空列表。公开页面结构变化可能需要更新蜜柑适配器。
|
||||
|
||||
本地事务不能与下载器 API 组成原子事务。恢复依赖原下载器实例中的稳定 info hash 和下载器去重;同一数据库仅运行一个服务实例。使用全局选择接口切换不会改变恢复目标。
|
||||
|
||||
## 结构与验证
|
||||
|
||||
```text
|
||||
src/api.rs HTTP 接口
|
||||
src/domain.rs 订阅、资源、任务模型与输入校验
|
||||
src/parser.rs 标题与集数识别
|
||||
src/feeds.rs RSS/Atom 获取、解析、过滤
|
||||
src/mikan.rs 蜜柑搜索、详情、订阅候选与封面
|
||||
src/media.rs 本地文件访问、路径校验与媒体数据结构
|
||||
src/media_store.rs 目的地、版本化映射、文件清单与核对记录持久化
|
||||
src/storage.rs SQLite 持久化与去重
|
||||
src/downloader.rs 种子标识与 qBittorrent 适配
|
||||
src/service.rs 扫描、任务恢复与后台调度
|
||||
migrations/ 数据库迁移
|
||||
tests/fixtures/ 人工构造的 RSS 测试样本
|
||||
```
|
||||
|
||||
```sh
|
||||
cargo fmt --check
|
||||
cargo test --locked
|
||||
cargo clippy --locked --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
集成测试使用回环地址上的模拟蜜柑 / RSS / 代理 / qBittorrent 服务和临时 SQLite 数据库,不连接真实下载器、不下载媒体。
|
||||
覆盖过滤、保守集数解析、重复扫描、数据库重开、提交成功但响应失败后的恢复、下载器不可用、禁用订阅、任务移除与 API CRUD。
|
||||
新增覆盖中文搜索编码、不同季度、字幕组 ID、RSS 候选到保存、访问限制页面、封面与缓存、代理认证及跨域重定向、直连例外和代理失败处理。
|
||||
下载器归属测试覆盖切换时已排队任务、同 hash 不同实例、单实例离线、重试、重启,以及真实旧版 SQLite 数据库升级后的显式归属恢复。
|
||||
P2 使用临时本地文件与模拟下载器,覆盖目的地/映射版本快照、实际提交路径、同 hash 不同路径冲突、文件四态、成功核对时间保留、下载器离线核对、目录浏览与路径/符号链接限制。没有连接真实 NAS 或下载器。
|
||||
|
||||
已实现全局媒体目的地与本地文件存在核对。后续将支持 SMB 应用直连、第二种下载工具、MySQL、管理页面和部署。
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目按 GNU General Public License version 2 only 发布,完整条款见 [LICENSE](LICENSE)。
|
||||
新增 Rust 源文件使用 `SPDX-License-Identifier: GPL-2.0-only` 标识。第三方依赖遵循各自许可证。
|
||||
@@ -0,0 +1,34 @@
|
||||
CREATE TABLE subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
spec TEXT NOT NULL,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
last_checked_at INTEGER,
|
||||
last_error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE releases (
|
||||
subscription_id TEXT NOT NULL REFERENCES subscriptions(id),
|
||||
resource_key TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
PRIMARY KEY (subscription_id, resource_key)
|
||||
);
|
||||
|
||||
CREATE TABLE download_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
subscription_id TEXT NOT NULL REFERENCES subscriptions(id),
|
||||
resource_key TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
season INTEGER NOT NULL,
|
||||
episode INTEGER NOT NULL,
|
||||
download_url TEXT NOT NULL,
|
||||
torrent_bytes BLOB,
|
||||
info_hash TEXT,
|
||||
state TEXT NOT NULL DEFAULT 'queued'
|
||||
CHECK (state IN ('queued', 'submitting', 'submitted', 'downloading', 'completed', 'missing', 'failed')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
next_attempt_at INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(subscription_id, resource_key),
|
||||
UNIQUE(subscription_id, season, episode)
|
||||
);
|
||||
CREATE INDEX jobs_state ON download_jobs(state, next_attempt_at);
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE downloader_instances (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind = 'qbittorrent'),
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
config TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE downloader_selection (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
downloader_id TEXT REFERENCES downloader_instances(id)
|
||||
);
|
||||
INSERT INTO downloader_selection(singleton) VALUES (1);
|
||||
|
||||
ALTER TABLE download_jobs ADD COLUMN downloader_id TEXT REFERENCES downloader_instances(id);
|
||||
ALTER TABLE download_jobs ADD COLUMN legacy_unassigned INTEGER NOT NULL DEFAULT 0;
|
||||
-- Existing jobs have no recorded owner. Do not assign them to an arbitrary new instance.
|
||||
UPDATE download_jobs SET legacy_unassigned = 1;
|
||||
CREATE INDEX jobs_downloader ON download_jobs(downloader_id, state);
|
||||
@@ -0,0 +1,37 @@
|
||||
CREATE TABLE media_destinations (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind = 'local'),
|
||||
root_path TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE destination_selection (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
destination_id TEXT REFERENCES media_destinations(id)
|
||||
);
|
||||
INSERT INTO destination_selection(singleton) VALUES (1);
|
||||
CREATE TABLE destination_mappings (
|
||||
id TEXT PRIMARY KEY,
|
||||
destination_id TEXT NOT NULL REFERENCES media_destinations(id),
|
||||
downloader_id TEXT NOT NULL REFERENCES downloader_instances(id),
|
||||
version INTEGER NOT NULL,
|
||||
download_root TEXT NOT NULL,
|
||||
UNIQUE(destination_id, downloader_id, version)
|
||||
);
|
||||
ALTER TABLE download_jobs ADD COLUMN destination_id TEXT REFERENCES media_destinations(id);
|
||||
ALTER TABLE download_jobs ADD COLUMN mapping_id TEXT REFERENCES destination_mappings(id);
|
||||
ALTER TABLE download_jobs ADD COLUMN download_save_path TEXT;
|
||||
ALTER TABLE download_jobs ADD COLUMN file_status TEXT NOT NULL DEFAULT 'unchecked'
|
||||
CHECK (file_status IN ('unchecked', 'present', 'absent', 'unavailable'));
|
||||
ALTER TABLE download_jobs ADD COLUMN file_checked_at INTEGER;
|
||||
ALTER TABLE download_jobs ADD COLUMN file_last_success_at INTEGER;
|
||||
ALTER TABLE download_jobs ADD COLUMN file_error TEXT;
|
||||
CREATE TABLE media_files (
|
||||
job_id TEXT NOT NULL REFERENCES download_jobs(id),
|
||||
relative_path TEXT NOT NULL,
|
||||
expected_size INTEGER NOT NULL,
|
||||
observed_size INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'unchecked'
|
||||
CHECK (status IN ('unchecked', 'present', 'absent', 'unavailable')),
|
||||
error TEXT,
|
||||
PRIMARY KEY(job_id, relative_path)
|
||||
);
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
// 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?))
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use crate::error::{Error, Result};
|
||||
use std::{env, time::Duration};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Config {
|
||||
pub database_url: String,
|
||||
pub port: u16,
|
||||
pub poll_interval: Duration,
|
||||
pub qbit: Option<QbitConfig>,
|
||||
pub mikan_base_url: String,
|
||||
pub website: WebsiteConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct WebsiteConfig {
|
||||
pub proxy_url: Option<String>,
|
||||
pub direct_hosts: String,
|
||||
}
|
||||
|
||||
// Deliberately omit Debug: these fields can contain credentials.
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct QbitConfig {
|
||||
pub url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub save_path: String,
|
||||
}
|
||||
|
||||
impl QbitConfig {
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
self.url = self.url.trim().trim_end_matches('/').to_owned();
|
||||
crate::domain::validate_http_url(&self.url)?;
|
||||
let url = reqwest::Url::parse(&self.url).expect("validated URL");
|
||||
if url.query().is_some() || url.fragment().is_some() {
|
||||
return Err(Error::Invalid(
|
||||
"downloader URL cannot contain query parameters or a fragment".into(),
|
||||
));
|
||||
}
|
||||
self.url = url.to_string().trim_end_matches('/').to_owned();
|
||||
if self.username.len() > 1024
|
||||
|| self.password.len() > 4096
|
||||
|| self.save_path.trim().is_empty()
|
||||
|| self.save_path.len() > 4096
|
||||
|| self.save_path.chars().any(char::is_control)
|
||||
{
|
||||
return Err(Error::Invalid(
|
||||
"invalid downloader credentials or save path".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self> {
|
||||
let port = env::var("APP_PORT")
|
||||
.unwrap_or_else(|_| "8080".into())
|
||||
.parse()
|
||||
.map_err(|_| Error::Invalid("APP_PORT must be a valid port".into()))?;
|
||||
let seconds: u64 = env::var("POLL_INTERVAL_SECONDS")
|
||||
.unwrap_or_else(|_| "300".into())
|
||||
.parse()
|
||||
.map_err(|_| Error::Invalid("POLL_INTERVAL_SECONDS must be an integer".into()))?;
|
||||
if seconds < 10 {
|
||||
return Err(Error::Invalid(
|
||||
"POLL_INTERVAL_SECONDS must be at least 10".into(),
|
||||
));
|
||||
}
|
||||
let qbit = match env::var("QBIT_URL") {
|
||||
Ok(url) if !url.is_empty() => {
|
||||
crate::domain::validate_http_url(&url)?;
|
||||
let base = reqwest::Url::parse(&url).expect("validated URL");
|
||||
if base.query().is_some() || base.fragment().is_some() {
|
||||
return Err(Error::Invalid(
|
||||
"QBIT_URL cannot contain query parameters or a fragment".into(),
|
||||
));
|
||||
}
|
||||
Some(QbitConfig {
|
||||
url: url.trim_end_matches('/').into(),
|
||||
username: env::var("QBIT_USERNAME").unwrap_or_default(),
|
||||
password: env::var("QBIT_PASSWORD").unwrap_or_default(),
|
||||
save_path: env::var("QBIT_SAVE_PATH").unwrap_or_else(|_| "/downloads".into()),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Ok(Self {
|
||||
database_url: env::var("DATABASE_URL")
|
||||
.unwrap_or_else(|_| "sqlite://data/ani.db".into()),
|
||||
port,
|
||||
poll_interval: Duration::from_secs(seconds),
|
||||
qbit,
|
||||
mikan_base_url: env::var("MIKAN_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://mikanani.me".into()),
|
||||
website: WebsiteConfig {
|
||||
proxy_url: env::var("WEBSITE_PROXY_URL")
|
||||
.ok()
|
||||
.map(|s| s.trim().to_owned())
|
||||
.filter(|s| !s.is_empty()),
|
||||
direct_hosts: env::var("WEBSITE_DIRECT_HOSTS").unwrap_or_default(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use crate::error::{Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubscriptionInput {
|
||||
pub title: String,
|
||||
pub rss_url: String,
|
||||
#[serde(default = "one")]
|
||||
pub season: i32,
|
||||
#[serde(default)]
|
||||
pub episode_offset: i32,
|
||||
#[serde(default)]
|
||||
pub include: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub exclude: Vec<String>,
|
||||
// Opt in after previewing: the first scan considers all entries in the feed.
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
fn one() -> i32 {
|
||||
1
|
||||
}
|
||||
|
||||
impl SubscriptionInput {
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
self.title = self.title.trim().to_owned();
|
||||
self.rss_url = self.rss_url.trim().to_owned();
|
||||
if self.title.is_empty() || self.title.chars().count() > 200 {
|
||||
return Err(Error::Invalid("title must contain 1–200 characters".into()));
|
||||
}
|
||||
validate_http_url(&self.rss_url)?;
|
||||
if !(1..=999).contains(&self.season) || self.episode_offset.abs_diff(0) > 10000 {
|
||||
return Err(Error::Invalid(
|
||||
"season or episode_offset is out of range".into(),
|
||||
));
|
||||
}
|
||||
for rules in [&mut self.include, &mut self.exclude] {
|
||||
if rules.len() > 32 {
|
||||
return Err(Error::Invalid("at most 32 rules per list".into()));
|
||||
}
|
||||
for rule in rules {
|
||||
*rule = rule.trim().to_owned();
|
||||
if rule.is_empty() || rule.len() > 200 {
|
||||
return Err(Error::Invalid(
|
||||
"filter keywords must contain 1–200 bytes".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_http_url(value: &str) -> Result<()> {
|
||||
let url = reqwest::Url::parse(value).map_err(|_| Error::Invalid("invalid URL".into()))?;
|
||||
if !matches!(url.scheme(), "http" | "https")
|
||||
|| url.host_str().is_none()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| value.len() > 8192
|
||||
{
|
||||
return Err(Error::Invalid(
|
||||
"expected an HTTP(S) URL without embedded credentials".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Subscription {
|
||||
pub id: String,
|
||||
#[serde(flatten)]
|
||||
pub input: SubscriptionInput,
|
||||
pub last_checked_at: Option<i64>,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Decision {
|
||||
Ready,
|
||||
Filtered,
|
||||
NeedsReview,
|
||||
AlreadyQueued,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Release {
|
||||
pub key: String,
|
||||
pub title: String,
|
||||
pub download_url: Option<String>,
|
||||
pub episode: Option<i32>,
|
||||
pub decision: Decision,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct DownloadJob {
|
||||
pub id: String,
|
||||
pub subscription_id: String,
|
||||
pub title: String,
|
||||
pub season: i32,
|
||||
pub episode: i32,
|
||||
pub download_url: String,
|
||||
pub info_hash: Option<String>,
|
||||
pub state: String,
|
||||
pub attempts: i32,
|
||||
pub last_error: Option<String>,
|
||||
pub next_attempt_at: i64,
|
||||
pub downloader_id: Option<String>,
|
||||
pub legacy_unassigned: bool,
|
||||
pub destination_id: Option<String>,
|
||||
pub mapping_id: Option<String>,
|
||||
pub download_save_path: Option<String>,
|
||||
pub file_status: String,
|
||||
pub file_checked_at: Option<i64>,
|
||||
pub file_last_success_at: Option<i64>,
|
||||
pub file_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DownloaderInput {
|
||||
pub name: String,
|
||||
pub kind: DownloaderKind,
|
||||
pub connection: crate::config::QbitConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Deserialize, serde::Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DownloaderKind {
|
||||
Qbittorrent,
|
||||
}
|
||||
|
||||
impl DownloaderInput {
|
||||
pub fn validate(&mut self) -> Result<()> {
|
||||
self.name = self.name.trim().to_owned();
|
||||
if self.name.is_empty() || self.name.chars().count() > 100 {
|
||||
return Err(Error::Invalid(
|
||||
"downloader name must contain 1–100 characters".into(),
|
||||
));
|
||||
}
|
||||
self.connection.validate()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct DownloaderView {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: DownloaderKind,
|
||||
pub url: String,
|
||||
pub username: String,
|
||||
pub save_path: String,
|
||||
pub password_configured: bool,
|
||||
pub is_active: bool,
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use crate::{
|
||||
config::QbitConfig,
|
||||
domain::validate_http_url,
|
||||
error::{Error, Result},
|
||||
feeds::bounded_body,
|
||||
};
|
||||
use bendy::decoding::{Decoder, Object};
|
||||
use reqwest::{multipart, Client, Url};
|
||||
use serde::Deserialize;
|
||||
use sha1::{Digest, Sha1};
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct PreparedTorrent {
|
||||
pub hash: String,
|
||||
pub bytes: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
pub fn magnet_hash(value: &str) -> Result<String> {
|
||||
let url = Url::parse(value).map_err(|_| Error::Invalid("invalid magnet URI".into()))?;
|
||||
if url.scheme() != "magnet" {
|
||||
return Err(Error::Invalid("expected magnet URI".into()));
|
||||
}
|
||||
for (key, value) in url.query_pairs() {
|
||||
if key != "xt" {
|
||||
continue;
|
||||
}
|
||||
if let Some(hash) = value.strip_prefix("urn:btih:") {
|
||||
if hash.len() == 40 && hash.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return Ok(hash.to_ascii_lowercase());
|
||||
}
|
||||
if hash.len() == 32 {
|
||||
if let Ok(bytes) =
|
||||
data_encoding::BASE32.decode(hash.to_ascii_uppercase().as_bytes())
|
||||
{
|
||||
return Ok(data_encoding::HEXLOWER.encode(&bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(Error::Invalid(
|
||||
"magnet must contain a valid BitTorrent v1 info hash".into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn torrent_hash(bytes: &[u8]) -> Result<String> {
|
||||
let invalid = |_| Error::Invalid("invalid or unsupported torrent metadata".into());
|
||||
let mut decoder = Decoder::new(bytes).with_max_depth(32);
|
||||
let mut root = decoder
|
||||
.next_object()
|
||||
.map_err(invalid)?
|
||||
.ok_or_else(|| Error::Invalid("empty torrent metadata".into()))?
|
||||
.try_into_dictionary()
|
||||
.map_err(invalid)?;
|
||||
let mut raw_info = None;
|
||||
while let Some((key, object)) = root.next_pair().map_err(invalid)? {
|
||||
if key == b"info" {
|
||||
raw_info = Some(
|
||||
object
|
||||
.try_into_dictionary()
|
||||
.map_err(invalid)?
|
||||
.into_raw()
|
||||
.map_err(invalid)?,
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(root);
|
||||
if decoder.next_object().map_err(invalid)?.is_some() {
|
||||
return Err(Error::Invalid("trailing torrent data".into()));
|
||||
}
|
||||
let raw =
|
||||
raw_info.ok_or_else(|| Error::Invalid("torrent is missing info dictionary".into()))?;
|
||||
let mut info_decoder = Decoder::new(raw).with_max_depth(32);
|
||||
let mut info = info_decoder
|
||||
.next_object()
|
||||
.map_err(invalid)?
|
||||
.unwrap()
|
||||
.try_into_dictionary()
|
||||
.map_err(invalid)?;
|
||||
let mut v1 = false;
|
||||
while let Some((key, object)) = info.next_pair().map_err(invalid)? {
|
||||
if let (b"pieces", Object::Bytes(pieces)) = (key, object) {
|
||||
v1 = !pieces.is_empty() && pieces.len() % 20 == 0;
|
||||
}
|
||||
}
|
||||
if !v1 {
|
||||
return Err(Error::Invalid(
|
||||
"only BitTorrent v1/hybrid torrents are supported".into(),
|
||||
));
|
||||
}
|
||||
// Hash the exact encoded bytes, never a decoded/re-encoded dictionary.
|
||||
Ok(format!("{:x}", Sha1::digest(raw)))
|
||||
}
|
||||
|
||||
pub async fn prepare(client: &Client, url: &str) -> Result<PreparedTorrent> {
|
||||
if url.starts_with("magnet:") {
|
||||
return Ok(PreparedTorrent {
|
||||
hash: magnet_hash(url)?,
|
||||
bytes: None,
|
||||
});
|
||||
}
|
||||
validate_http_url(url)?;
|
||||
let response =
|
||||
crate::feeds::website_get(client, Url::parse(url).expect("validated URL")).await?;
|
||||
let bytes = bounded_body(response, 4 * 1024 * 1024).await?;
|
||||
Ok(PreparedTorrent {
|
||||
hash: torrent_hash(&bytes)?,
|
||||
bytes: Some(bytes),
|
||||
})
|
||||
}
|
||||
|
||||
#[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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn handles_hex_and_base32_magnets() {
|
||||
assert_eq!(
|
||||
magnet_hash("magnet:?xt=urn:btih:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").unwrap(),
|
||||
"a".repeat(40)
|
||||
);
|
||||
assert_eq!(
|
||||
magnet_hash("magnet:?xt=urn:btih:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").unwrap(),
|
||||
"0".repeat(40)
|
||||
);
|
||||
assert!(magnet_hash("magnet:?xt=urn:btih:bad").is_err());
|
||||
}
|
||||
#[test]
|
||||
fn hashes_original_info_bytes_and_rejects_bad_metadata() {
|
||||
let info = b"d6:lengthi1e4:name1:a12:piece lengthi16384e6:pieces20:aaaaaaaaaaaaaaaaaaaae";
|
||||
let torrent = [b"d4:info".as_slice(), info, b"e"].concat();
|
||||
assert_eq!(
|
||||
torrent_hash(&torrent).unwrap(),
|
||||
format!("{:x}", Sha1::digest(info))
|
||||
);
|
||||
for bad in [
|
||||
b"<html>error</html>".as_slice(),
|
||||
b"d4:infodee",
|
||||
b"d4:info",
|
||||
&[torrent.as_slice(), b"junk"].concat(),
|
||||
] {
|
||||
assert!(torrent_hash(bad).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use actix_web::{http::StatusCode, HttpResponse, ResponseError};
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
#[error("resource not found")]
|
||||
NotFound,
|
||||
#[error("a scan or download synchronization is already running")]
|
||||
Busy,
|
||||
#[error("too many Mikan requests; retry shortly")]
|
||||
SourceBusy,
|
||||
#[error("{0}")]
|
||||
Upstream(String),
|
||||
#[error("database operation failed")]
|
||||
Database(#[from] sqlx::Error),
|
||||
#[error("stored data could not be decoded")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("database migration failed")]
|
||||
Migration(#[from] sqlx::migrate::MigrateError),
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for Error {
|
||||
fn from(value: reqwest::Error) -> Self {
|
||||
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()}))
|
||||
}
|
||||
}
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use crate::{
|
||||
domain::*,
|
||||
error::{Error, Result},
|
||||
parser,
|
||||
};
|
||||
use reqwest::{Client, Url};
|
||||
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>> {
|
||||
let response = website_get(
|
||||
client,
|
||||
Url::parse(&sub.rss_url).map_err(|_| Error::Invalid("invalid RSS URL".into()))?,
|
||||
)
|
||||
.await?;
|
||||
let base = response.url().clone();
|
||||
let bytes = bounded_body(response, 4 * 1024 * 1024).await?;
|
||||
parse(&bytes, &base, sub)
|
||||
}
|
||||
|
||||
pub fn parse(bytes: &[u8], base: &Url, sub: &SubscriptionInput) -> Result<Vec<Release>> {
|
||||
let feed = feed_rs::parser::Builder::new()
|
||||
.base_uri(Some(base.as_str()))
|
||||
.build()
|
||||
.parse(bytes)
|
||||
.map_err(|_| Error::Upstream("invalid RSS/Atom feed".into()))?;
|
||||
if feed.entries.len() > 2000 {
|
||||
return Err(Error::Upstream("feed exceeds 2000 entries".into()));
|
||||
}
|
||||
Ok(feed
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
let title = entry.title.map(|t| t.content).unwrap_or_default();
|
||||
let media = entry
|
||||
.media
|
||||
.iter()
|
||||
.flat_map(|m| m.content.iter())
|
||||
.filter_map(|c| c.url.as_ref().map(|u| u.as_str()));
|
||||
let links = entry
|
||||
.links
|
||||
.iter()
|
||||
.filter(|l| {
|
||||
l.rel.as_deref() == Some("enclosure")
|
||||
|| l.media_type.as_deref() == Some("application/x-bittorrent")
|
||||
|| l.href.starts_with("magnet:")
|
||||
|| Url::parse(&l.href)
|
||||
.ok()
|
||||
.is_some_and(|u| u.path().ends_with(".torrent"))
|
||||
})
|
||||
.map(|l| l.href.as_str());
|
||||
let download_url = media.chain(links).find_map(|link| {
|
||||
let url = base.join(link).ok()?;
|
||||
match url.scheme() {
|
||||
"magnet" => Some(url.to_string()),
|
||||
"http" | "https" if validate_http_url(url.as_str()).is_ok() => {
|
||||
Some(url.to_string())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
let key = format!(
|
||||
"{:x}",
|
||||
Sha1::digest(download_url.as_deref().unwrap_or(&entry.id).as_bytes())
|
||||
);
|
||||
let mut release = Release {
|
||||
key,
|
||||
title,
|
||||
download_url,
|
||||
episode: None,
|
||||
decision: Decision::Ready,
|
||||
reason: "matched subscription rules".into(),
|
||||
};
|
||||
let lower = release.title.to_lowercase();
|
||||
if sub
|
||||
.exclude
|
||||
.iter()
|
||||
.any(|v| lower.contains(&v.to_lowercase()))
|
||||
{
|
||||
release.decision = Decision::Filtered;
|
||||
release.reason = "matched an exclusion keyword".into();
|
||||
} else if !sub
|
||||
.include
|
||||
.iter()
|
||||
.all(|v| lower.contains(&v.to_lowercase()))
|
||||
{
|
||||
release.decision = Decision::Filtered;
|
||||
release.reason = "missing a required inclusion keyword".into();
|
||||
} else if release.download_url.is_none() {
|
||||
release.decision = Decision::NeedsReview;
|
||||
release.reason = "no supported torrent enclosure or magnet link".into();
|
||||
} else {
|
||||
match parser::episode(&release.title, sub.season, sub.episode_offset) {
|
||||
Ok(ep) => release.episode = Some(ep),
|
||||
Err(reason) => {
|
||||
release.decision = Decision::NeedsReview;
|
||||
release.reason = reason.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
release
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
pub mod api;
|
||||
pub mod config;
|
||||
pub mod domain;
|
||||
pub mod downloader;
|
||||
pub mod error;
|
||||
pub mod feeds;
|
||||
pub mod media;
|
||||
mod media_store;
|
||||
pub mod mikan;
|
||||
pub mod parser;
|
||||
pub mod service;
|
||||
pub mod storage;
|
||||
+142
-35
@@ -1,45 +1,152 @@
|
||||
use std::io;
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use actix_web::{web, App, HttpResponse, HttpServer, Responder};
|
||||
use rust_project::{
|
||||
api,
|
||||
config::Config,
|
||||
service::{worker, Service},
|
||||
storage::Store,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn main() {
|
||||
println!("欢迎来到增强版 Rust 程序!");
|
||||
|
||||
// 演示变量声明和类型d
|
||||
let name = "Rustacean"; // Rustacean 是 Rust 开发者的昵称
|
||||
let age: u32 = 10;
|
||||
|
||||
println!("你好,{}!你使用 Rust 已经 {} 年了。", name, age);
|
||||
|
||||
// 演示数组和循环
|
||||
let numbers = [1, 2, 3, 4, 5];
|
||||
println!("\n数字列表:");
|
||||
for (index, &value) in numbers.iter().enumerate() {
|
||||
println!("索引 {}: 值 {}", index, value);
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Greeting {
|
||||
message: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
// 计算数组元素总和
|
||||
let sum: i32 = numbers.iter().sum();
|
||||
println!("\n数组元素总和: {}", sum);
|
||||
|
||||
// 条件判断
|
||||
if sum > 10 {
|
||||
println!("总和大于 10");
|
||||
} else {
|
||||
println!("总和小于或等于 10");
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Health {
|
||||
status: String,
|
||||
version: String,
|
||||
}
|
||||
|
||||
// 与用户交互
|
||||
println!("\n请输入您的名字:");
|
||||
let mut input = String::new();
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CalcRequest {
|
||||
a: f64,
|
||||
b: f64,
|
||||
operation: String,
|
||||
}
|
||||
|
||||
match io::stdin().read_line(&mut input) {
|
||||
Ok(_) => {
|
||||
let input = input.trim(); // 移除换行符
|
||||
if !input.is_empty() {
|
||||
println!("很高兴认识您,{}!", input);
|
||||
} else {
|
||||
println!("您没有输入任何内容。");
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CalcResponse {
|
||||
result: f64,
|
||||
operation: String,
|
||||
}
|
||||
|
||||
async fn hello() -> impl Responder {
|
||||
HttpResponse::Ok().body("Hello World! 欢迎使用 Rust Web 服务器")
|
||||
}
|
||||
Err(error) => println!("读取输入时发生错误: {}", error),
|
||||
|
||||
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]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
let config = Config::from_env().map_err(std::io::Error::other)?;
|
||||
std::fs::create_dir_all("data")?;
|
||||
let store = Store::open(&config.database_url)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
let service = Arc::new(
|
||||
Service::new(store, &config)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?,
|
||||
);
|
||||
let app_service = service.clone();
|
||||
let server = HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(web::Data::new(app_service.clone()))
|
||||
.app_data(web::JsonConfig::default().limit(32 * 1024))
|
||||
.route("/", web::get().to(hello))
|
||||
.route("/health", web::get().to(health_check))
|
||||
.route("/greet/{name}", web::get().to(greet))
|
||||
.route("/api/echo", web::post().to(echo))
|
||||
.route("/api/calculate", web::post().to(calculate))
|
||||
.configure(api::configure)
|
||||
.route(
|
||||
"/info",
|
||||
web::get().to(|| async {
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"project": env!("CARGO_PKG_NAME"),
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"endpoints": [
|
||||
{ "method": "GET", "path": "/", "description": "欢迎页面" },
|
||||
{ "method": "GET", "path": "/health", "description": "健康检查" },
|
||||
{ "method": "GET", "path": "/greet/{name}", "description": "问候接口" },
|
||||
{ "method": "POST", "path": "/api/echo", "description": "回显数据" },
|
||||
{ "method": "POST", "path": "/api/calculate", "description": "计算接口" }
|
||||
]
|
||||
}))
|
||||
}),
|
||||
)
|
||||
})
|
||||
.bind(("127.0.0.1", config.port))?;
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
|
||||
let background = tokio::spawn(worker(service, config.poll_interval, shutdown_rx));
|
||||
|
||||
println!("\n==================================================");
|
||||
println!("🚀 Rust Web 服务器已启动");
|
||||
println!("📡 监听地址: http://127.0.0.1:{}", config.port);
|
||||
println!(" GET/POST /api/subscriptions - 订阅管理");
|
||||
println!(" POST /api/preview - RSS 解析预览");
|
||||
println!(" POST /api/run - 执行订阅与下载同步");
|
||||
println!(" GET /api/jobs - 下载任务");
|
||||
println!("📋 可用端点:");
|
||||
println!(" GET / - 欢迎页面");
|
||||
println!(" GET /health - 健康检查");
|
||||
println!(" GET /info - API 信息");
|
||||
println!(" GET /greet/{{name}} - 问候接口");
|
||||
println!(" POST /api/echo - 回显数据");
|
||||
println!(" POST /api/calculate - 计算接口");
|
||||
println!("==================================================\n");
|
||||
|
||||
let result = server.run().await;
|
||||
let _ = shutdown_tx.send(true);
|
||||
let _ = background.await;
|
||||
result
|
||||
}
|
||||
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
// 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,153 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use crate::{
|
||||
domain::DownloadJob,
|
||||
error::{Error, Result},
|
||||
media::*,
|
||||
storage::{now, Store},
|
||||
};
|
||||
|
||||
impl Store {
|
||||
pub async fn destinations(&self) -> Result<Vec<Destination>> {
|
||||
Ok(sqlx::query_as("SELECT d.*, COALESCE(s.destination_id = d.id, 0) AS is_active FROM media_destinations d CROSS JOIN destination_selection s ORDER BY d.rowid")
|
||||
.fetch_all(&self.pool).await?)
|
||||
}
|
||||
|
||||
pub async fn destination(&self, id: &str) -> Result<Destination> {
|
||||
self.destinations()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|d| d.id == id)
|
||||
.ok_or(Error::NotFound)
|
||||
}
|
||||
|
||||
pub async fn create_destination(&self, mut input: DestinationInput) -> Result<Destination> {
|
||||
input.validate()?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO media_destinations(id, name, kind, root_path) VALUES (?, ?, 'local', ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(input.name)
|
||||
.bind(input.root_path)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
self.destination(&id).await
|
||||
}
|
||||
|
||||
pub async fn active_destination_id(&self) -> Result<Option<String>> {
|
||||
Ok(sqlx::query_scalar(
|
||||
"SELECT destination_id FROM destination_selection WHERE singleton = 1",
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn activate_destination(&self, id: &str) -> Result<()> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let exists: bool =
|
||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM media_destinations WHERE id = ?)")
|
||||
.bind(id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if !exists {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
let mapped: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM destination_mappings m JOIN downloader_selection s ON s.downloader_id = m.downloader_id WHERE m.destination_id = ?)")
|
||||
.bind(id).fetch_one(&mut *tx).await?;
|
||||
if !mapped {
|
||||
return Err(Error::Invalid(
|
||||
"select a downloader and configure its mapping before activating this destination"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
sqlx::query("UPDATE destination_selection SET destination_id = ? WHERE singleton = 1")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn mappings(&self, id: &str) -> Result<Vec<Mapping>> {
|
||||
self.destination(id).await?;
|
||||
Ok(sqlx::query_as("SELECT * FROM destination_mappings WHERE destination_id = ? ORDER BY version DESC, downloader_id")
|
||||
.bind(id).fetch_all(&self.pool).await?)
|
||||
}
|
||||
|
||||
pub async fn create_mapping(
|
||||
&self,
|
||||
destination_id: &str,
|
||||
input: MappingInput,
|
||||
) -> Result<Mapping> {
|
||||
self.destination(destination_id).await?;
|
||||
self.downloader_config(&input.downloader_id).await?;
|
||||
let root = normalize_download_root(&input.download_root)?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let version: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) + 1 FROM destination_mappings WHERE destination_id = ? AND downloader_id = ?")
|
||||
.bind(destination_id).bind(&input.downloader_id).fetch_one(&mut *tx).await?;
|
||||
sqlx::query("INSERT INTO destination_mappings(id, destination_id, downloader_id, version, download_root) VALUES (?, ?, ?, ?, ?)")
|
||||
.bind(&id).bind(destination_id).bind(&input.downloader_id).bind(version).bind(&root).execute(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
Ok(Mapping {
|
||||
id,
|
||||
destination_id: destination_id.into(),
|
||||
downloader_id: input.downloader_id,
|
||||
version,
|
||||
download_root: root,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn job(&self, id: &str) -> Result<DownloadJob> {
|
||||
sqlx::query_as("SELECT * FROM download_jobs WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)
|
||||
}
|
||||
|
||||
pub async fn file_report(&self, id: &str) -> Result<FileReport> {
|
||||
let job = self.job(id).await?;
|
||||
let files = sqlx::query_as("SELECT relative_path, expected_size, observed_size, status, error FROM media_files WHERE job_id = ? ORDER BY relative_path")
|
||||
.bind(id).fetch_all(&self.pool).await?;
|
||||
Ok(FileReport {
|
||||
job_id: id.into(),
|
||||
destination_id: job.destination_id,
|
||||
status: job.file_status,
|
||||
checked_at: job.file_checked_at,
|
||||
last_success_at: job.file_last_success_at,
|
||||
error: job.file_error,
|
||||
files,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn save_manifest(&self, id: &str, files: &[MediaFile]) -> Result<()> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
for file in files {
|
||||
validate_relative(&file.relative_path, false)?;
|
||||
sqlx::query("INSERT INTO media_files(job_id, relative_path, expected_size) VALUES (?, ?, ?) ON CONFLICT(job_id, relative_path) DO NOTHING")
|
||||
.bind(id).bind(&file.relative_path).bind(file.expected_size).execute(&mut *tx).await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn save_file_check(
|
||||
&self,
|
||||
id: &str,
|
||||
status: &str,
|
||||
error: Option<&str>,
|
||||
files: &[MediaFile],
|
||||
) -> Result<()> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let checked_at = now();
|
||||
for file in files {
|
||||
sqlx::query("UPDATE media_files SET observed_size = ?, status = ?, error = ? WHERE job_id = ? AND relative_path = ?")
|
||||
.bind(file.observed_size).bind(&file.status).bind(&file.error).bind(id).bind(&file.relative_path).execute(&mut *tx).await?;
|
||||
}
|
||||
sqlx::query("UPDATE download_jobs SET file_status = ?, file_error = ?, file_checked_at = ?, file_last_success_at = CASE WHEN ? IN ('present', 'absent') THEN ? ELSE file_last_success_at END WHERE id = ?")
|
||||
.bind(status).bind(error).bind(checked_at).bind(status).bind(checked_at).bind(id).execute(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+460
@@ -0,0 +1,460 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
//! Public Mikan discovery; HTML details stay inside this adapter.
|
||||
use crate::{
|
||||
domain::{validate_http_url, SubscriptionInput},
|
||||
error::{Error, Result},
|
||||
feeds::{bounded_body, website_get},
|
||||
};
|
||||
use reqwest::{Client, Url};
|
||||
use scraper::{Html, Selector};
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::OnceLock,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::sync::{Mutex, Semaphore};
|
||||
|
||||
pub struct Mikan {
|
||||
client: Client,
|
||||
base: Url,
|
||||
cache: Mutex<HashMap<String, (Instant, Cached)>>,
|
||||
requests: Semaphore,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Cached {
|
||||
Search(SearchResult),
|
||||
Series(Box<SeriesDetail>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct SeriesSummary {
|
||||
pub series_id: String,
|
||||
pub title: String,
|
||||
pub page_url: String,
|
||||
pub cover_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct SearchResult {
|
||||
pub source_id: &'static str,
|
||||
pub query: String,
|
||||
pub series: Vec<SeriesSummary>,
|
||||
pub keyword_rss_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct SubtitleGroup {
|
||||
pub group_id: String,
|
||||
pub name: String,
|
||||
pub rss_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct SeriesDetail {
|
||||
pub source_id: &'static str,
|
||||
pub series_id: String,
|
||||
pub title: String,
|
||||
pub page_url: String,
|
||||
pub rss_url: String,
|
||||
pub groups: Vec<SubtitleGroup>,
|
||||
pub information: Vec<String>,
|
||||
pub bangumi_id: Option<String>,
|
||||
pub cover_url: Option<String>,
|
||||
#[serde(skip)]
|
||||
poster: Option<Url>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Candidate {
|
||||
pub source_id: &'static str,
|
||||
pub scope: &'static str,
|
||||
pub series_id: Option<String>,
|
||||
pub group_id: Option<String>,
|
||||
pub page_url: String,
|
||||
pub rss_url: String,
|
||||
pub subscription: Option<SubscriptionInput>,
|
||||
pub warnings: Vec<&'static str>,
|
||||
}
|
||||
|
||||
fn text(element: scraper::ElementRef<'_>) -> String {
|
||||
element
|
||||
.text()
|
||||
.collect::<String>()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn parameters(url: &Url) -> Result<HashMap<String, String>> {
|
||||
let mut params = HashMap::new();
|
||||
for (key, value) in url.query_pairs() {
|
||||
if params
|
||||
.insert(key.to_ascii_lowercase(), value.into_owned())
|
||||
.is_some()
|
||||
{
|
||||
return Err(Error::Invalid("duplicate URL parameter".into()));
|
||||
}
|
||||
}
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
fn selector(value: &str) -> Selector {
|
||||
Selector::parse(value).expect("static selector")
|
||||
}
|
||||
|
||||
fn valid_id(value: &str) -> bool {
|
||||
!value.is_empty() && value.len() <= 20 && value.bytes().all(|b| b.is_ascii_digit())
|
||||
}
|
||||
|
||||
impl Mikan {
|
||||
pub fn new(client: Client, base: &str) -> Result<Self> {
|
||||
validate_http_url(base)?;
|
||||
let base = Url::parse(base).expect("validated URL");
|
||||
if base.path() != "/" || base.query().is_some() || base.fragment().is_some() {
|
||||
return Err(Error::Invalid(
|
||||
"MIKAN_BASE_URL must be an HTTP(S) origin without a path or query".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
client,
|
||||
base,
|
||||
cache: Mutex::new(HashMap::new()),
|
||||
requests: Semaphore::new(4),
|
||||
})
|
||||
}
|
||||
|
||||
async fn cached(&self, key: &str) -> Option<Cached> {
|
||||
self.cache
|
||||
.lock()
|
||||
.await
|
||||
.get(key)
|
||||
.filter(|(created, _)| created.elapsed() < Duration::from_secs(60))
|
||||
.map(|(_, value)| value.clone())
|
||||
}
|
||||
|
||||
async fn remember(&self, key: String, value: Cached) {
|
||||
let mut cache = self.cache.lock().await;
|
||||
cache.retain(|_, (created, _)| created.elapsed() < Duration::from_secs(60));
|
||||
if cache.len() >= 64 {
|
||||
if let Some(oldest) = cache
|
||||
.iter()
|
||||
.min_by_key(|(_, (time, _))| *time)
|
||||
.map(|(key, _)| key.clone())
|
||||
{
|
||||
cache.remove(&oldest);
|
||||
}
|
||||
}
|
||||
cache.insert(key, (Instant::now(), value));
|
||||
}
|
||||
|
||||
async fn html(&self, url: Url) -> Result<Html> {
|
||||
let response = website_get(&self.client, url).await?;
|
||||
let bytes = bounded_body(response, 8 * 1024 * 1024).await?;
|
||||
let text = std::str::from_utf8(&bytes)
|
||||
.map_err(|_| Error::Upstream("Mikan returned invalid UTF-8".into()))?;
|
||||
Ok(Html::parse_document(text))
|
||||
}
|
||||
|
||||
fn same_origin_url(&self, value: &str) -> Result<Url> {
|
||||
let url = self
|
||||
.base
|
||||
.join(value)
|
||||
.map_err(|_| Error::Invalid("invalid Mikan URL".into()))?;
|
||||
validate_http_url(url.as_str())?;
|
||||
if url.origin() != self.base.origin() {
|
||||
return Err(Error::Invalid(
|
||||
"URL must belong to the configured Mikan origin".into(),
|
||||
));
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn rss_link(
|
||||
&self,
|
||||
element: scraper::ElementRef<'_>,
|
||||
series_id: &str,
|
||||
) -> Option<(Url, Option<String>)> {
|
||||
let url = self.same_origin_url(element.value().attr("href")?).ok()?;
|
||||
if url.path() != "/RSS/Bangumi" {
|
||||
return None;
|
||||
}
|
||||
let params = parameters(&url).ok()?;
|
||||
if params.get("bangumiid").map(String::as_str) != Some(series_id) {
|
||||
return None;
|
||||
}
|
||||
let group = params.get("subgroupid").cloned();
|
||||
if group.as_ref().is_some_and(|id| !valid_id(id)) {
|
||||
return None;
|
||||
}
|
||||
Some((url, group))
|
||||
}
|
||||
|
||||
pub async fn series(&self, id: &str) -> Result<SeriesDetail> {
|
||||
if !valid_id(id) {
|
||||
return Err(Error::Invalid("invalid Mikan series ID".into()));
|
||||
}
|
||||
let key = format!("series:{id}");
|
||||
if let Some(Cached::Series(detail)) = self.cached(&key).await {
|
||||
return Ok(*detail);
|
||||
}
|
||||
let _permit = self.requests.try_acquire().map_err(|_| Error::SourceBusy)?;
|
||||
let page = self
|
||||
.base
|
||||
.join(&format!("/Home/Bangumi/{id}"))
|
||||
.expect("numeric ID");
|
||||
let html = self.html(page.clone()).await?;
|
||||
let title = html
|
||||
.select(&selector(".bangumi-title"))
|
||||
.next()
|
||||
.map(text)
|
||||
.filter(|name| !name.is_empty())
|
||||
.ok_or_else(|| {
|
||||
Error::Upstream("Mikan detail page is unrecognized or access is restricted".into())
|
||||
})?;
|
||||
let rss_url = html
|
||||
.select(&selector(".bangumi-title a.mikan-rss[href]"))
|
||||
.filter_map(|link| self.rss_link(link, id))
|
||||
.find(|(_, group)| group.is_none())
|
||||
.map(|(url, _)| url.to_string())
|
||||
.ok_or_else(|| Error::Upstream("Mikan series RSS link is missing or invalid".into()))?;
|
||||
let mut groups = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for block in html.select(&selector(".subgroup-text")) {
|
||||
let Some((url, Some(group_id))) = block
|
||||
.select(&selector("a.mikan-rss[href]"))
|
||||
.find_map(|link| self.rss_link(link, id))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(name) = block
|
||||
.select(&selector("a[href*='/Home/PublishGroup/']"))
|
||||
.next()
|
||||
.map(text)
|
||||
.filter(|name| !name.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if seen.insert(group_id.clone()) {
|
||||
groups.push(SubtitleGroup {
|
||||
group_id,
|
||||
name,
|
||||
rss_url: url.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let information = html.select(&selector(".bangumi-info")).map(text).collect();
|
||||
let bangumi_id = html
|
||||
.select(&selector(".bangumi-info a[href]"))
|
||||
.find_map(|link| {
|
||||
let url = Url::parse(link.value().attr("href")?).ok()?;
|
||||
if !matches!(url.host_str()?, "bgm.tv" | "bangumi.tv" | "chii.in") {
|
||||
return None;
|
||||
}
|
||||
url.path()
|
||||
.strip_prefix("/subject/")
|
||||
.filter(|id| valid_id(id))
|
||||
.map(str::to_owned)
|
||||
});
|
||||
static CSS_URL: OnceLock<regex::Regex> = OnceLock::new();
|
||||
let pattern = CSS_URL.get_or_init(|| {
|
||||
regex::Regex::new(r#"url\(\s*['"]?([^'"\s)]+)['"]?\s*\)"#).expect("static regex")
|
||||
});
|
||||
let poster = html
|
||||
.select(&selector(".bangumi-poster[style]"))
|
||||
.find_map(|element| {
|
||||
let captures = pattern.captures(element.value().attr("style")?)?;
|
||||
let url = self.same_origin_url(captures.get(1)?.as_str()).ok()?;
|
||||
url.path().starts_with("/images/").then_some(url)
|
||||
});
|
||||
let detail = SeriesDetail {
|
||||
source_id: "mikan",
|
||||
series_id: id.into(),
|
||||
title,
|
||||
page_url: page.into(),
|
||||
rss_url,
|
||||
groups,
|
||||
information,
|
||||
bangumi_id,
|
||||
cover_url: poster
|
||||
.as_ref()
|
||||
.map(|_| format!("/api/sources/mikan/series/{id}/cover")),
|
||||
poster,
|
||||
};
|
||||
self.remember(key, Cached::Series(Box::new(detail.clone())))
|
||||
.await;
|
||||
Ok(detail)
|
||||
}
|
||||
|
||||
pub async fn cover(&self, id: &str) -> Result<(String, Vec<u8>)> {
|
||||
let url = self.series(id).await?.poster.ok_or(Error::NotFound)?;
|
||||
let _permit = self.requests.try_acquire().map_err(|_| Error::SourceBusy)?;
|
||||
let response = website_get(&self.client, url).await?;
|
||||
let mime = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
if !matches!(
|
||||
mime.as_str(),
|
||||
"image/png" | "image/jpeg" | "image/webp" | "image/gif"
|
||||
) {
|
||||
return Err(Error::Upstream(
|
||||
"Mikan cover is not a supported raster image".into(),
|
||||
));
|
||||
}
|
||||
Ok((mime, bounded_body(response, 4 * 1024 * 1024).await?))
|
||||
}
|
||||
|
||||
pub async fn resolve(&self, value: &str) -> Result<Candidate> {
|
||||
if value.len() > 8192 {
|
||||
return Err(Error::Invalid("Mikan URL is too long".into()));
|
||||
}
|
||||
let url = self.same_origin_url(value.trim())?;
|
||||
let params = parameters(&url)?;
|
||||
if matches!(url.path(), "/RSS/Search" | "/Home/Search") {
|
||||
let query = params
|
||||
.get("searchstr")
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty() && s.chars().count() <= 100)
|
||||
.ok_or_else(|| {
|
||||
Error::Invalid("keyword RSS requires a query of 1–100 characters".into())
|
||||
})?;
|
||||
if params.len() != 1 {
|
||||
return Err(Error::Invalid("unsupported keyword RSS parameters".into()));
|
||||
}
|
||||
let mut rss = self.base.join("/RSS/Search").expect("static path");
|
||||
rss.query_pairs_mut().append_pair("searchstr", query);
|
||||
let mut page = rss.clone();
|
||||
page.set_path("/Home/Search");
|
||||
return Ok(Candidate { source_id: "mikan", scope: "keyword", series_id: None, group_id: None,
|
||||
page_url: page.into(), rss_url: rss.into(), subscription: None,
|
||||
warnings: vec!["Keyword RSS can contain multiple series. Select a specific series, or manually preview with filters that restrict the feed to one series before creating a subscription."] });
|
||||
}
|
||||
let (id, group) = if let Some(id) = url.path().strip_prefix("/Home/Bangumi/") {
|
||||
(id.to_owned(), None)
|
||||
} else if url.path() == "/RSS/Bangumi" {
|
||||
(
|
||||
params
|
||||
.get("bangumiid")
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::Invalid("missing bangumiId".into()))?,
|
||||
params.get("subgroupid").cloned(),
|
||||
)
|
||||
} else {
|
||||
return Err(Error::Invalid(
|
||||
"expected a Mikan series page or series RSS URL".into(),
|
||||
));
|
||||
};
|
||||
if group.as_ref().is_some_and(|id| !valid_id(id)) {
|
||||
return Err(Error::Invalid("invalid subtitle group ID".into()));
|
||||
}
|
||||
let detail = self.series(&id).await?;
|
||||
let rss_url = match &group {
|
||||
Some(group) => detail
|
||||
.groups
|
||||
.iter()
|
||||
.find(|g| &g.group_id == group)
|
||||
.map(|g| g.rss_url.clone())
|
||||
.ok_or_else(|| {
|
||||
Error::Invalid("subtitle group is not available for this series".into())
|
||||
})?,
|
||||
None => detail.rss_url,
|
||||
};
|
||||
Ok(Candidate {
|
||||
source_id: "mikan", scope: if group.is_some() { "group" } else { "series" },
|
||||
series_id: Some(id), group_id: group, page_url: detail.page_url, rss_url: rss_url.clone(),
|
||||
subscription: Some(SubscriptionInput { title: detail.title, rss_url, season: 1, episode_offset: 0,
|
||||
include: Vec::new(), exclude: Vec::new(), enabled: false }),
|
||||
warnings: vec!["Confirm season, episode offset and filters in preview before enabling; season 1 is a default, not inferred metadata."],
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn search(&self, query: &str) -> Result<SearchResult> {
|
||||
let query = query.trim();
|
||||
if query.is_empty() || query.chars().count() > 100 {
|
||||
return Err(Error::Invalid(
|
||||
"search query must contain 1–100 characters".into(),
|
||||
));
|
||||
}
|
||||
let key = format!("search:{query}");
|
||||
if let Some(Cached::Search(result)) = self.cached(&key).await {
|
||||
return Ok(result);
|
||||
}
|
||||
let _permit = self.requests.try_acquire().map_err(|_| Error::SourceBusy)?;
|
||||
let mut url = self.base.join("/Home/Search").expect("static path");
|
||||
url.query_pairs_mut().append_pair("searchstr", query);
|
||||
let html = self.html(url.clone()).await?;
|
||||
let unrecognized =
|
||||
|| Error::Upstream("Mikan search page is unrecognized or access is restricted".into());
|
||||
let search_form = html
|
||||
.select(&selector(
|
||||
"form[action='/Home/Search'] input[name='searchstr']",
|
||||
))
|
||||
.next()
|
||||
.is_some();
|
||||
let results = html.select(&selector(".an-ul")).next().is_some()
|
||||
|| html
|
||||
.select(&selector(".header2-text"))
|
||||
.any(|element| text(element).contains("搜索结果"));
|
||||
if !search_form || !results {
|
||||
return Err(unrecognized());
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
let mut series = Vec::new();
|
||||
for link in html.select(&selector(".an-ul a[href]")) {
|
||||
let Some(url) = link
|
||||
.value()
|
||||
.attr("href")
|
||||
.and_then(|s| self.base.join(s).ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if url.origin() != self.base.origin() {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = url
|
||||
.path()
|
||||
.strip_prefix("/Home/Bangumi/")
|
||||
.filter(|id| valid_id(id))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(name) = link.select(&selector(".an-text")).next() else {
|
||||
return Err(unrecognized());
|
||||
};
|
||||
let text = name.text().collect::<String>();
|
||||
let title = name.value().attr("title").unwrap_or(&text).trim();
|
||||
if title.is_empty() {
|
||||
return Err(unrecognized());
|
||||
}
|
||||
if !seen.insert(id.to_owned()) {
|
||||
continue;
|
||||
}
|
||||
series.push(SeriesSummary {
|
||||
series_id: id.into(),
|
||||
title: title.into(),
|
||||
page_url: url.to_string(),
|
||||
cover_url: link
|
||||
.select(&selector("[data-src], img[src]"))
|
||||
.next()
|
||||
.map(|_| format!("/api/sources/mikan/series/{id}/cover")),
|
||||
});
|
||||
}
|
||||
url.set_path("/RSS/Search");
|
||||
let result = SearchResult {
|
||||
source_id: "mikan",
|
||||
query: query.into(),
|
||||
series,
|
||||
keyword_rss_url: url.into(),
|
||||
};
|
||||
self.remember(key, Cached::Search(result.clone())).await;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use regex::Regex;
|
||||
use std::{collections::BTreeSet, sync::LazyLock};
|
||||
|
||||
static UNSUPPORTED: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(
|
||||
r"(?i)(合集|全集|\bbatch\b|\bcomplete\b|\b(?:OVA|SP|NCOP|NCED)(?:[0-9]+\b|\b)|(?:\s-\s*|\[|第|\bEP?\s*)[0-9]+\.[0-9]+|[0-9]+\s*[-~~]\s*[0-9]+)"
|
||||
).unwrap()
|
||||
});
|
||||
static SEASON_EPISODE: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?i)\bS([0-9]{1,3})E([0-9]{1,4})(?:v[0-9]+)?(?:\b|_)").unwrap());
|
||||
static EPISODE: LazyLock<Vec<Regex>> = LazyLock::new(|| {
|
||||
[
|
||||
r"(?i)\[([0-9]{1,3})(?:v[0-9]+)?(?:\s*END)?\]",
|
||||
r"第\s*([0-9]{1,4})\s*[话話集]",
|
||||
r"(?i)\bEP?\s*([0-9]{1,4})(?:v[0-9]+)?\b",
|
||||
r"(?i)\s-\s*([0-9]{1,4})(?:v[0-9]+)?(?:\s|\[|$)",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|r| Regex::new(r).unwrap())
|
||||
.collect()
|
||||
});
|
||||
|
||||
/// Conservative single-episode parsing. Ambiguity is visible, never guessed.
|
||||
pub fn episode(title: &str, season: i32, offset: i32) -> std::result::Result<i32, &'static str> {
|
||||
if UNSUPPORTED.is_match(title) {
|
||||
return Err("batch, special episode or fractional episode requires manual review");
|
||||
}
|
||||
let mut candidates = BTreeSet::new();
|
||||
for captures in SEASON_EPISODE.captures_iter(title) {
|
||||
if captures[1].parse::<i32>().ok() != Some(season) {
|
||||
return Err("title season differs from subscription season");
|
||||
}
|
||||
candidates.insert(captures[2].parse::<i32>().unwrap());
|
||||
}
|
||||
for pattern in EPISODE.iter() {
|
||||
for captures in pattern.captures_iter(title) {
|
||||
candidates.insert(captures[1].parse::<i32>().unwrap());
|
||||
}
|
||||
}
|
||||
if candidates.len() != 1 {
|
||||
return Err("episode is missing or ambiguous");
|
||||
}
|
||||
let value = candidates
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap()
|
||||
.checked_add(offset)
|
||||
.ok_or("episode offset overflow")?;
|
||||
if !(1..=10000).contains(&value) {
|
||||
return Err("episode is outside supported range after offset");
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn single_episode_formats() {
|
||||
for title in [
|
||||
"[Group] Example [03][1080p]",
|
||||
"Example - 03v2 [1080p]",
|
||||
"Example 第03话",
|
||||
"Example S01E03",
|
||||
"Example EP03",
|
||||
"Example - 03 [1080p HEVC AAC2.0]",
|
||||
] {
|
||||
assert_eq!(episode(title, 1, 0), Ok(3), "{title}");
|
||||
}
|
||||
assert_eq!(episode("Example - 13 [1080p]", 2, -12), Ok(1));
|
||||
}
|
||||
#[test]
|
||||
fn uncertain_titles_are_not_downloaded() {
|
||||
for title in [
|
||||
"Example [1080p]",
|
||||
"Example [01-12]",
|
||||
"Example - 12.5",
|
||||
"Example SP01",
|
||||
"Example SP01 [03]",
|
||||
"Example [03] [04]",
|
||||
"Example S02E03",
|
||||
"Example S01E03E04",
|
||||
] {
|
||||
assert!(episode(title, 1, 0).is_err(), "{title}");
|
||||
}
|
||||
assert!(episode("Example [01]", 1, -1).is_err());
|
||||
}
|
||||
}
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use crate::{
|
||||
config::Config,
|
||||
domain::*,
|
||||
downloader::{self, Qbit},
|
||||
error::{Error, Result},
|
||||
feeds,
|
||||
media::{self, FileReport, MediaFile},
|
||||
storage::Store,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
use tokio::sync::{watch, Mutex};
|
||||
|
||||
pub struct Service {
|
||||
pub store: Store,
|
||||
pub gate: Mutex<()>,
|
||||
pub mikan: crate::mikan::Mikan,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
pub struct CycleReport {
|
||||
pub scanned: usize,
|
||||
pub queued: usize,
|
||||
pub errors: Vec<String>,
|
||||
pub downloader_configured: bool,
|
||||
}
|
||||
|
||||
impl Service {
|
||||
pub async fn new(store: Store, config: &Config) -> Result<Self> {
|
||||
let client = feeds::website_client(&config.website)?;
|
||||
let mikan = crate::mikan::Mikan::new(client.clone(), &config.mikan_base_url)?;
|
||||
store.bootstrap_downloader(config.qbit.as_ref()).await?;
|
||||
Ok(Self {
|
||||
store,
|
||||
gate: Mutex::new(()),
|
||||
mikan,
|
||||
client,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn downloader_configured(&self) -> Result<bool> {
|
||||
Ok(self.store.active_downloader_id().await?.is_some())
|
||||
}
|
||||
|
||||
pub async fn preview(&self, mut input: SubscriptionInput) -> Result<Vec<Release>> {
|
||||
input.validate()?;
|
||||
feeds::fetch(&self.client, &input).await
|
||||
}
|
||||
|
||||
pub async fn preview_subscription(&self, id: &str) -> Result<Vec<Release>> {
|
||||
let sub = self.store.subscription(id).await?;
|
||||
let mut releases = self.preview(sub.input.clone()).await?;
|
||||
let mut episodes = HashSet::new();
|
||||
// Feed order determines the candidate selected in v0.1; priority rules come later.
|
||||
for release in &mut releases {
|
||||
if release.decision == Decision::Ready {
|
||||
let duplicate = self
|
||||
.store
|
||||
.already_queued(id, sub.input.season, release)
|
||||
.await?;
|
||||
if duplicate || !episodes.insert(release.episode) {
|
||||
release.decision = Decision::AlreadyQueued;
|
||||
release.reason = if duplicate {
|
||||
"resource or episode already has a task"
|
||||
} else {
|
||||
"an earlier feed entry already represents this episode"
|
||||
}
|
||||
.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(releases)
|
||||
}
|
||||
|
||||
async fn scan(&self, sub: &Subscription) -> Result<usize> {
|
||||
let result = async {
|
||||
let releases = self.preview_subscription(&sub.id).await?;
|
||||
self.store.record_scan(sub, &releases).await
|
||||
}
|
||||
.await;
|
||||
self.store
|
||||
.checked(
|
||||
&sub.id,
|
||||
result.as_ref().err().map(ToString::to_string).as_deref(),
|
||||
)
|
||||
.await?;
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn scan_subscription(&self, id: &str) -> Result<usize> {
|
||||
let _guard = self.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
let sub = self.store.subscription(id).await?;
|
||||
if !sub.input.enabled {
|
||||
return Err(Error::Invalid(
|
||||
"enable the subscription before scanning; preview is available while disabled"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
self.scan(&sub).await
|
||||
}
|
||||
|
||||
pub async fn cycle(&self) -> Result<CycleReport> {
|
||||
let _guard = self.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
let mut report = CycleReport {
|
||||
downloader_configured: self.downloader_configured().await?,
|
||||
..Default::default()
|
||||
};
|
||||
let legacy = self.store.legacy_unassigned_count().await?;
|
||||
if legacy > 0 {
|
||||
report.errors.push(format!("{legacy} legacy jobs have no recorded downloader; restore their original instance and explicitly claim them"));
|
||||
}
|
||||
for sub in self.store.subscriptions().await? {
|
||||
if !sub.input.enabled {
|
||||
continue;
|
||||
}
|
||||
match self.scan(&sub).await {
|
||||
Ok(count) => {
|
||||
report.scanned += 1;
|
||||
report.queued += count;
|
||||
}
|
||||
Err(error) => report
|
||||
.errors
|
||||
.push(format!("subscription {}: {error}", sub.id)),
|
||||
}
|
||||
}
|
||||
match self.sync_downloads().await {
|
||||
Ok(errors) => report.errors.extend(errors),
|
||||
Err(error) => report.errors.push(error.to_string()),
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
async fn sync_downloads(&self) -> Result<Vec<String>> {
|
||||
let jobs = self.store.active_jobs().await?;
|
||||
let mut groups: std::collections::BTreeMap<String, Vec<DownloadJob>> = Default::default();
|
||||
for job in jobs {
|
||||
if let Some(id) = &job.downloader_id {
|
||||
groups.entry(id.clone()).or_default().push(job);
|
||||
}
|
||||
}
|
||||
let mut errors = Vec::new();
|
||||
for (id, jobs) in groups {
|
||||
let result = async {
|
||||
let qbit = Qbit::new(self.store.downloader_config(&id).await?)?;
|
||||
self.sync_instance(&qbit, jobs).await
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
if matches!(error, Error::Database(_)) {
|
||||
return Err(error);
|
||||
}
|
||||
errors.push(format!("downloader {id}: {error}"));
|
||||
}
|
||||
}
|
||||
Ok(errors)
|
||||
}
|
||||
|
||||
async fn sync_instance(&self, qbit: &Qbit, jobs: Vec<DownloadJob>) -> Result<()> {
|
||||
qbit.login().await?;
|
||||
// Never submit when querying the remote state fails.
|
||||
let remote: HashMap<_, _> = qbit
|
||||
.torrents()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|torrent| (torrent.hash.to_ascii_lowercase(), torrent))
|
||||
.collect();
|
||||
let mut added: HashMap<String, Option<String>> = HashMap::new();
|
||||
for mut job in jobs {
|
||||
let sub = self.store.subscription(&job.subscription_id).await;
|
||||
let enabled = match sub {
|
||||
Ok(sub) => sub.input.enabled,
|
||||
Err(Error::NotFound) => false,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let result: Result<()> = async {
|
||||
if job.info_hash.is_none() {
|
||||
if !enabled { return Ok(()); }
|
||||
let prepared = downloader::prepare(&self.client, &job.download_url).await?;
|
||||
self.store.prepare(&job.id, &prepared.hash, prepared.bytes.as_deref()).await?;
|
||||
job.info_hash = Some(prepared.hash);
|
||||
}
|
||||
let hash = job.info_hash.as_ref().expect("prepared hash");
|
||||
if let Some(torrent) = remote.get(hash) {
|
||||
if job.destination_id.is_some() && !job.download_save_path.as_deref().zip(torrent.save_path.as_deref())
|
||||
.is_some_and(|(expected, actual)| media::same_download_root(expected, actual)) {
|
||||
return Err(Error::Invalid("remote save path differs from the job snapshot; refusing to move or reuse this torrent".into()));
|
||||
}
|
||||
if matches!(torrent.state.as_str(), "error" | "missingFiles") {
|
||||
return Err(Error::Invalid("qBittorrent reports an error or missing files; inspect the downloader before retrying".into()));
|
||||
}
|
||||
let state = if torrent.progress >= 1.0 { "completed" }
|
||||
else if torrent.progress > 0.0 { "downloading" } else { "submitted" };
|
||||
self.store.state(&job.id, state).await?;
|
||||
} else if let Some(path) = added.get(hash) {
|
||||
if job.destination_id.is_some() && path != &job.download_save_path {
|
||||
return Err(Error::Invalid("this torrent was submitted to a different path; refusing to reuse it".into()));
|
||||
}
|
||||
self.store.state(&job.id, "submitted").await?;
|
||||
} else if matches!(job.state.as_str(), "submitted" | "downloading") {
|
||||
// Respect removal in qBittorrent; recreating a task needs an explicit retry.
|
||||
self.store.state(&job.id, "missing").await?;
|
||||
} else if enabled {
|
||||
let bytes = self.store.torrent_bytes(&job.id).await?;
|
||||
// Commit the intent before the external side effect.
|
||||
self.store.state(&job.id, "submitting").await?;
|
||||
qbit.add(&job.download_url, bytes, job.download_save_path.as_deref()).await?;
|
||||
added.insert(hash.clone(), job.download_save_path.clone());
|
||||
self.store.state(&job.id, "submitted").await?;
|
||||
}
|
||||
Ok(())
|
||||
}.await;
|
||||
if let Err(error) = result {
|
||||
if matches!(error, Error::Database(_)) {
|
||||
return Err(error);
|
||||
}
|
||||
if matches!(error, Error::Invalid(_)) {
|
||||
self.store.state(&job.id, "failed").await?;
|
||||
}
|
||||
self.store.failed(&job, &error.to_string()).await?;
|
||||
tracing::warn!(job_id = %job.id, error = %error, "download job failed");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn discover_files(&self, job: &DownloadJob) -> Result<Vec<MediaFile>> {
|
||||
let hash = job
|
||||
.info_hash
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::Invalid("torrent metadata is not available yet".into()))?;
|
||||
let owner = job
|
||||
.downloader_id
|
||||
.as_ref()
|
||||
.ok_or_else(|| Error::Invalid("downloader ownership is unknown".into()))?;
|
||||
let qbit = Qbit::new(self.store.downloader_config(owner).await?)?;
|
||||
qbit.login().await?;
|
||||
let torrent = qbit
|
||||
.torrents()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|torrent| torrent.hash.eq_ignore_ascii_case(hash))
|
||||
.ok_or_else(|| {
|
||||
Error::Upstream("torrent is not available in its original downloader".into())
|
||||
})?;
|
||||
if !job
|
||||
.download_save_path
|
||||
.as_deref()
|
||||
.zip(torrent.save_path.as_deref())
|
||||
.is_some_and(|(expected, actual)| media::same_download_root(expected, actual))
|
||||
{
|
||||
return Err(Error::Upstream(
|
||||
"remote save path does not match the frozen destination mapping".into(),
|
||||
));
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
let mut paths = HashSet::new();
|
||||
for file in qbit
|
||||
.files(hash)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|file| file.priority != 0)
|
||||
{
|
||||
media::validate_relative(&file.name, false).map_err(|_| {
|
||||
Error::Upstream("downloader returned an unsafe relative file path".into())
|
||||
})?;
|
||||
if !paths.insert(file.name.clone()) {
|
||||
return Err(Error::Upstream(
|
||||
"duplicate path in downloader file list".into(),
|
||||
));
|
||||
}
|
||||
files.push(MediaFile {
|
||||
relative_path: file.name,
|
||||
expected_size: file.size,
|
||||
observed_size: None,
|
||||
status: "unchecked".into(),
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
pub async fn check_job_files(&self, id: &str) -> Result<FileReport> {
|
||||
// Serialize manifest discovery/checks with task synchronization and configuration changes.
|
||||
let _guard = self.gate.try_lock().map_err(|_| Error::Busy)?;
|
||||
let job = self.store.job(id).await?;
|
||||
let Some(destination) = &job.destination_id else {
|
||||
self.store
|
||||
.save_file_check(
|
||||
id,
|
||||
"unchecked",
|
||||
Some("job has no recorded media destination"),
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
return self.store.file_report(id).await;
|
||||
};
|
||||
let root = self.store.destination(destination).await?.root_path;
|
||||
let mut files = self.store.file_report(id).await?.files;
|
||||
if files.is_empty() {
|
||||
if job.info_hash.is_none() {
|
||||
self.store
|
||||
.save_file_check(
|
||||
id,
|
||||
"unchecked",
|
||||
Some("torrent metadata is not available yet"),
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
return self.store.file_report(id).await;
|
||||
}
|
||||
match self.discover_files(&job).await {
|
||||
Ok(manifest) => files = manifest,
|
||||
Err(error @ Error::Database(_)) => return Err(error),
|
||||
Err(error) => {
|
||||
self.store
|
||||
.save_file_check(id, "unavailable", Some(&error.to_string()), &[])
|
||||
.await?;
|
||||
return self.store.file_report(id).await;
|
||||
}
|
||||
}
|
||||
if files.is_empty() {
|
||||
self.store
|
||||
.save_file_check(
|
||||
id,
|
||||
"unchecked",
|
||||
Some("no selected files or torrent metadata is still pending"),
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
return self.store.file_report(id).await;
|
||||
}
|
||||
self.store.save_manifest(id, &files).await?;
|
||||
}
|
||||
let files = tokio::task::spawn_blocking(move || media::check_local(&root, files))
|
||||
.await
|
||||
.map_err(|_| Error::Upstream("local file check failed".into()))?;
|
||||
let status = if files.iter().any(|f| f.status == "unavailable") {
|
||||
"unavailable"
|
||||
} else if files.iter().any(|f| f.status == "absent") {
|
||||
"absent"
|
||||
} else {
|
||||
"present"
|
||||
};
|
||||
let error = files.iter().find_map(|file| file.error.as_deref());
|
||||
self.store
|
||||
.save_file_check(id, status, error, &files)
|
||||
.await?;
|
||||
self.store.file_report(id).await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn worker(
|
||||
service: Arc<Service>,
|
||||
interval: std::time::Duration,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
) {
|
||||
let mut timer = tokio::time::interval(interval);
|
||||
timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown.changed() => break,
|
||||
_ = timer.tick() => {
|
||||
// Cancellation can interrupt network I/O; persisted submission intent allows recovery.
|
||||
tokio::select! {
|
||||
_ = shutdown.changed() => break,
|
||||
result = service.cycle() => match result {
|
||||
Ok(report) => for error in report.errors { tracing::warn!(%error, "background cycle issue"); },
|
||||
Err(Error::Busy) => {},
|
||||
Err(error) => tracing::error!(%error, "background cycle failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use crate::{
|
||||
domain::*,
|
||||
error::{Error, Result},
|
||||
};
|
||||
use sqlx::{
|
||||
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
|
||||
SqlitePool,
|
||||
};
|
||||
use std::{
|
||||
str::FromStr,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Store {
|
||||
pub pool: SqlitePool,
|
||||
}
|
||||
|
||||
pub fn now() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub async fn active_downloader_id(&self) -> Result<Option<String>> {
|
||||
Ok(
|
||||
sqlx::query_scalar(
|
||||
"SELECT downloader_id FROM downloader_selection WHERE singleton = 1",
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn downloaders(&self) -> Result<Vec<DownloaderView>> {
|
||||
let rows: Vec<(String, String, String, bool)> = sqlx::query_as(
|
||||
"SELECT d.id, d.name, d.config, COALESCE(s.downloader_id = d.id, 0) FROM downloader_instances d CROSS JOIN downloader_selection s ORDER BY d.rowid")
|
||||
.fetch_all(&self.pool).await?;
|
||||
rows.into_iter()
|
||||
.map(|(id, name, json, is_active)| {
|
||||
let config: crate::config::QbitConfig = serde_json::from_str(&json)?;
|
||||
Ok(DownloaderView {
|
||||
id,
|
||||
name,
|
||||
kind: DownloaderKind::Qbittorrent,
|
||||
url: config.url,
|
||||
username: config.username,
|
||||
save_path: config.save_path,
|
||||
password_configured: !config.password.is_empty(),
|
||||
is_active,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn create_downloader(&self, mut input: DownloaderInput) -> Result<DownloaderView> {
|
||||
input.validate()?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let result = sqlx::query("INSERT INTO downloader_instances(id, name, kind, endpoint, config) VALUES (?, ?, 'qbittorrent', ?, ?) ON CONFLICT(endpoint) DO NOTHING")
|
||||
.bind(&id).bind(&input.name).bind(&input.connection.url)
|
||||
.bind(serde_json::to_string(&input.connection)?).execute(&self.pool).await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(Error::Invalid(
|
||||
"this downloader endpoint is already registered".into(),
|
||||
));
|
||||
}
|
||||
self.downloaders()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|d| d.id == id)
|
||||
.ok_or(Error::NotFound)
|
||||
}
|
||||
|
||||
pub(crate) async fn downloader_config(&self, id: &str) -> Result<crate::config::QbitConfig> {
|
||||
let json: String =
|
||||
sqlx::query_scalar("SELECT config FROM downloader_instances WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)?;
|
||||
Ok(serde_json::from_str(&json)?)
|
||||
}
|
||||
|
||||
pub async fn activate_downloader(&self, id: &str) -> Result<()> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let exists: bool =
|
||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM downloader_instances WHERE id = ?)")
|
||||
.bind(id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if !exists {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
let destination: Option<String> = sqlx::query_scalar(
|
||||
"SELECT destination_id FROM destination_selection WHERE singleton = 1",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if let Some(destination) = destination {
|
||||
let mapped: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM destination_mappings WHERE destination_id = ? AND downloader_id = ?)")
|
||||
.bind(destination).bind(id).fetch_one(&mut *tx).await?;
|
||||
if !mapped {
|
||||
return Err(Error::Invalid("configure a mapping to the active destination before selecting this downloader".into()));
|
||||
}
|
||||
}
|
||||
sqlx::query("UPDATE downloader_selection SET downloader_id = ? WHERE singleton = 1")
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// Bind only new, never-owned work. A switch never rewrites an existing owner.
|
||||
sqlx::query("UPDATE download_jobs SET downloader_id = ? WHERE downloader_id IS NULL AND legacy_unassigned = 0")
|
||||
.bind(id).execute(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn claim_legacy_jobs(&self, id: &str) -> Result<u64> {
|
||||
self.downloader_config(id).await?;
|
||||
Ok(sqlx::query("UPDATE download_jobs SET downloader_id = ?, legacy_unassigned = 0 WHERE downloader_id IS NULL AND legacy_unassigned = 1")
|
||||
.bind(id).execute(&self.pool).await?.rows_affected())
|
||||
}
|
||||
|
||||
pub async fn legacy_unassigned_count(&self) -> Result<i64> {
|
||||
Ok(sqlx::query_scalar("SELECT COUNT(*) FROM download_jobs WHERE legacy_unassigned = 1 AND downloader_id IS NULL")
|
||||
.fetch_one(&self.pool).await?)
|
||||
}
|
||||
|
||||
pub(crate) async fn bootstrap_downloader(
|
||||
&self,
|
||||
config: Option<&crate::config::QbitConfig>,
|
||||
) -> Result<()> {
|
||||
let Some(config) = config else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut config = config.clone();
|
||||
config.validate()?;
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM downloader_instances")
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if count == 0 {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
sqlx::query("INSERT INTO downloader_instances(id, name, kind, endpoint, config) VALUES (?, 'Initial qBittorrent', 'qbittorrent', ?, ?)")
|
||||
.bind(&id).bind(&config.url).bind(serde_json::to_string(&config)?).execute(&mut *tx).await?;
|
||||
sqlx::query("UPDATE downloader_selection SET downloader_id = ? WHERE singleton = 1")
|
||||
.bind(&id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// Initial import uses the operator-supplied original QBIT_* connection.
|
||||
sqlx::query("UPDATE download_jobs SET downloader_id = ?, legacy_unassigned = 0 WHERE downloader_id IS NULL")
|
||||
.bind(&id).execute(&mut *tx).await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn open(url: &str) -> Result<Self> {
|
||||
let options = SqliteConnectOptions::from_str(url)?
|
||||
.create_if_missing(true)
|
||||
.foreign_keys(true)
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.busy_timeout(Duration::from_secs(5));
|
||||
// A single connection also makes sqlite::memory: deterministic in tests.
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await?;
|
||||
sqlx::migrate!().run(&pool).await?;
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
pub async fn subscriptions(&self) -> Result<Vec<Subscription>> {
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<i64>, Option<String>)>(
|
||||
"SELECT id, spec, last_checked_at, last_error FROM subscriptions WHERE deleted = 0 ORDER BY rowid"
|
||||
).fetch_all(&self.pool).await?;
|
||||
rows.into_iter()
|
||||
.map(|(id, spec, last_checked_at, last_error)| {
|
||||
Ok(Subscription {
|
||||
id,
|
||||
input: serde_json::from_str(&spec)?,
|
||||
last_checked_at,
|
||||
last_error,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn subscription(&self, id: &str) -> Result<Subscription> {
|
||||
self.subscriptions()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|s| s.id == id)
|
||||
.ok_or(Error::NotFound)
|
||||
}
|
||||
|
||||
pub async fn create(&self, mut input: SubscriptionInput) -> Result<Subscription> {
|
||||
input.validate()?;
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
sqlx::query("INSERT INTO subscriptions(id, spec) VALUES (?, ?)")
|
||||
.bind(&id)
|
||||
.bind(serde_json::to_string(&input)?)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
self.subscription(&id).await
|
||||
}
|
||||
|
||||
pub async fn update(&self, id: &str, mut input: SubscriptionInput) -> Result<Subscription> {
|
||||
input.validate()?;
|
||||
let result = sqlx::query("UPDATE subscriptions SET spec = ? WHERE id = ? AND deleted = 0")
|
||||
.bind(serde_json::to_string(&input)?)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
self.subscription(id).await
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: &str) -> Result<()> {
|
||||
let result =
|
||||
sqlx::query("UPDATE subscriptions SET deleted = 1 WHERE id = ? AND deleted = 0")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn checked(&self, id: &str, error: Option<&str>) -> Result<()> {
|
||||
sqlx::query("UPDATE subscriptions SET last_checked_at = ?, last_error = ? WHERE id = ?")
|
||||
.bind(now())
|
||||
.bind(error)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn already_queued(&self, id: &str, season: i32, release: &Release) -> Result<bool> {
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM download_jobs WHERE subscription_id = ? AND (resource_key = ? OR (season = ? AND episode = ?))")
|
||||
.bind(id).bind(&release.key).bind(season).bind(release.episode).fetch_one(&self.pool).await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
pub async fn record_scan(&self, sub: &Subscription, releases: &[Release]) -> Result<usize> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let downloader_id: Option<String> = sqlx::query_scalar(
|
||||
"SELECT downloader_id FROM downloader_selection WHERE singleton = 1",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
let destination_id: Option<String> = sqlx::query_scalar(
|
||||
"SELECT destination_id FROM destination_selection WHERE singleton = 1",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
let mut mapping_id = None;
|
||||
let mut download_save_path = None;
|
||||
if let Some(destination) = &destination_id {
|
||||
let mapping: crate::media::Mapping = sqlx::query_as("SELECT * FROM destination_mappings WHERE destination_id = ? AND downloader_id = ? ORDER BY version DESC LIMIT 1")
|
||||
.bind(destination).bind(&downloader_id).fetch_optional(&mut *tx).await?
|
||||
.ok_or_else(|| Error::Invalid("active destination has no mapping for the selected downloader".into()))?;
|
||||
mapping_id = Some(mapping.id);
|
||||
download_save_path = Some(mapping.download_root);
|
||||
} else if let Some(downloader) = &downloader_id {
|
||||
let config: String =
|
||||
sqlx::query_scalar("SELECT config FROM downloader_instances WHERE id = ?")
|
||||
.bind(downloader)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
download_save_path =
|
||||
Some(serde_json::from_str::<crate::config::QbitConfig>(&config)?.save_path);
|
||||
}
|
||||
let mut queued = 0;
|
||||
for release in releases {
|
||||
sqlx::query("INSERT INTO releases(subscription_id, resource_key, payload) VALUES (?, ?, ?) ON CONFLICT(subscription_id, resource_key) DO UPDATE SET payload = excluded.payload")
|
||||
.bind(&sub.id).bind(&release.key).bind(serde_json::to_string(release)?).execute(&mut *tx).await?;
|
||||
if release.decision == Decision::Ready {
|
||||
if let (Some(episode), Some(url)) = (release.episode, &release.download_url) {
|
||||
let result = sqlx::query("INSERT OR IGNORE INTO download_jobs(id, subscription_id, resource_key, title, season, episode, download_url, downloader_id, destination_id, mapping_id, download_save_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.bind(uuid::Uuid::new_v4().to_string()).bind(&sub.id).bind(&release.key)
|
||||
.bind(&release.title).bind(sub.input.season).bind(episode).bind(url).bind(&downloader_id)
|
||||
.bind(&destination_id).bind(&mapping_id).bind(&download_save_path).execute(&mut *tx).await?;
|
||||
queued += result.rows_affected() as usize;
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(queued)
|
||||
}
|
||||
|
||||
pub async fn releases(&self, id: &str) -> Result<Vec<Release>> {
|
||||
self.subscription(id).await?;
|
||||
let rows: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT payload FROM releases WHERE subscription_id = ? ORDER BY rowid DESC LIMIT 500",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter()
|
||||
.map(|s| serde_json::from_str(&s).map_err(Error::from))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn jobs(&self) -> Result<Vec<DownloadJob>> {
|
||||
Ok(
|
||||
sqlx::query_as("SELECT * FROM download_jobs ORDER BY rowid DESC LIMIT 500")
|
||||
.fetch_all(&self.pool)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn active_jobs(&self) -> Result<Vec<DownloadJob>> {
|
||||
Ok(sqlx::query_as("SELECT * FROM download_jobs WHERE state NOT IN ('completed', 'missing', 'failed') AND next_attempt_at <= ? ORDER BY rowid")
|
||||
.bind(now()).fetch_all(&self.pool).await?)
|
||||
}
|
||||
|
||||
pub async fn prepare(&self, id: &str, hash: &str, bytes: Option<&[u8]>) -> Result<()> {
|
||||
sqlx::query("UPDATE download_jobs SET info_hash = ?, torrent_bytes = ? WHERE id = ?")
|
||||
.bind(hash)
|
||||
.bind(bytes)
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn torrent_bytes(&self, id: &str) -> Result<Option<Vec<u8>>> {
|
||||
Ok(
|
||||
sqlx::query_scalar("SELECT torrent_bytes FROM download_jobs WHERE id = ?")
|
||||
.bind(id)
|
||||
.fetch_one(&self.pool)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn state(&self, id: &str, state: &str) -> Result<()> {
|
||||
sqlx::query("UPDATE download_jobs SET state = ?, last_error = NULL, next_attempt_at = 0 WHERE id = ?")
|
||||
.bind(state).bind(id).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn failed(&self, job: &DownloadJob, error: &str) -> Result<()> {
|
||||
// Keep 'submitting' on an ambiguous network failure, so reconciliation comes first.
|
||||
let delay = 30 * (1_i64 << job.attempts.clamp(0, 7));
|
||||
sqlx::query("UPDATE download_jobs SET attempts = attempts + 1, last_error = ?, next_attempt_at = ? WHERE id = ?")
|
||||
.bind(error).bind(now() + delay.min(3600)).bind(&job.id).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn retry(&self, id: &str) -> Result<()> {
|
||||
let result = sqlx::query("UPDATE download_jobs SET state = 'queued', attempts = 0, next_attempt_at = 0, last_error = NULL WHERE id = ? AND state IN ('failed', 'missing')")
|
||||
.bind(id).execute(&self.pool).await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(Error::Invalid(
|
||||
"only failed or missing jobs can be retried".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Local test releases</title>
|
||||
<link>https://example.invalid/</link>
|
||||
<description>Synthetic fixtures; no real media</description>
|
||||
<item>
|
||||
<guid>release-3</guid>
|
||||
<title>[Group] Example - 03 [1080p]</title>
|
||||
<enclosure url="magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" type="application/x-bittorrent" length="1"/>
|
||||
</item>
|
||||
<item>
|
||||
<guid>release-3-other</guid>
|
||||
<title>[Group] Example - 03v2 [1080p]</title>
|
||||
<enclosure url="magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" type="application/x-bittorrent" length="1"/>
|
||||
</item>
|
||||
<item>
|
||||
<guid>batch</guid>
|
||||
<title>[Group] Example [01-12] [1080p]</title>
|
||||
<enclosure url="magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc" type="application/x-bittorrent" length="1"/>
|
||||
</item>
|
||||
<item>
|
||||
<guid>low-resolution</guid>
|
||||
<title>[Group] Example - 04 [720p]</title>
|
||||
<enclosure url="magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd" type="application/x-bittorrent" length="1"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -0,0 +1,154 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use actix_web::{test, web, App};
|
||||
use rust_project::{api, config::Config, service::Service, storage::Store};
|
||||
use serde_json::{json, Value};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
#[actix_web::test]
|
||||
async fn directory_api_rejects_traversal_and_does_not_follow_links_outside_root() {
|
||||
let directory = std::env::temp_dir().join(format!("media-browse-{}", uuid::Uuid::new_v4()));
|
||||
let root = directory.join("root");
|
||||
std::fs::create_dir_all(root.join("Series")).unwrap();
|
||||
std::fs::create_dir(directory.join("outside")).unwrap();
|
||||
std::fs::write(root.join("Series/ep01.mkv"), b"abc").unwrap();
|
||||
std::fs::write(directory.join("outside/secret"), b"outside").unwrap();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(directory.join("outside"), root.join("escape")).unwrap();
|
||||
let config = Config {
|
||||
database_url: "sqlite::memory:".into(),
|
||||
port: 8080,
|
||||
poll_interval: Duration::from_secs(300),
|
||||
qbit: None,
|
||||
mikan_base_url: "https://mikanani.me".into(),
|
||||
website: Default::default(),
|
||||
};
|
||||
let service = Arc::new(
|
||||
Service::new(Store::open("sqlite::memory:").await.unwrap(), &config)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(service))
|
||||
.configure(api::configure),
|
||||
)
|
||||
.await;
|
||||
for body in [
|
||||
json!({"name":"bad", "kind":"local", "root_path":"relative"}),
|
||||
json!({"name":"bad", "kind":"smb", "root_path":"/share"}),
|
||||
] {
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::post()
|
||||
.uri("/api/destinations")
|
||||
.set_json(body)
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 400);
|
||||
}
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::post()
|
||||
.uri("/api/destinations")
|
||||
.set_json(json!({"name":"Local", "kind":"local", "root_path":root.to_str().unwrap()}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 201);
|
||||
let destination: Value = test::read_body_json(response).await;
|
||||
let id = destination["id"].as_str().unwrap();
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::put()
|
||||
.uri("/api/destinations/active")
|
||||
.set_json(json!({"destination_id":id}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 400); // No downloader mapping: never silently use a fallback.
|
||||
let downloader: Value = test::call_and_read_body_json(&app, test::TestRequest::post().uri("/api/downloaders")
|
||||
.set_json(json!({"name":"Local qBit", "kind":"qbittorrent", "connection":{
|
||||
"url":"http://127.0.0.1:1", "username":"", "password":"", "save_path":"/downloads"
|
||||
}})).to_request()).await;
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::put()
|
||||
.uri("/api/downloaders/active")
|
||||
.set_json(json!({"downloader_id":downloader["id"]}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 204);
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::post()
|
||||
.uri(&format!("/api/destinations/{id}/mappings"))
|
||||
.set_json(json!({"downloader_id":downloader["id"],"download_root":"/downloads/local"}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 201);
|
||||
let mapping: Value = test::read_body_json(response).await;
|
||||
assert_eq!(mapping["version"], 1);
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::put()
|
||||
.uri("/api/destinations/active")
|
||||
.set_json(json!({"destination_id":id}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 204);
|
||||
let status: Value = test::call_and_read_body_json(
|
||||
&app,
|
||||
test::TestRequest::get().uri("/api/status").to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status["active_destination_id"], id);
|
||||
let base = format!("/api/destinations/{id}/entries");
|
||||
let listing: Value = test::call_and_read_body_json(
|
||||
&app,
|
||||
test::TestRequest::get()
|
||||
.uri(&format!("{base}?path=Series"))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(listing["status"], "available");
|
||||
assert_eq!(listing["entries"][0]["name"], "ep01.mkv");
|
||||
assert_eq!(listing["entries"][0]["size"], 3);
|
||||
for path in [
|
||||
"../outside",
|
||||
"%2Fetc",
|
||||
"C%3A%2FWindows",
|
||||
"Series%5C..%5Coutside",
|
||||
"Series/../outside",
|
||||
"Series//bad",
|
||||
] {
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::get()
|
||||
.uri(&format!("{base}?path={path}"))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 400, "{path}");
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let listing: Value = test::call_and_read_body_json(
|
||||
&app,
|
||||
test::TestRequest::get()
|
||||
.uri(&format!("{base}?path=escape"))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(listing["status"], "unavailable");
|
||||
assert_eq!(listing["entries"], json!([]));
|
||||
}
|
||||
std::fs::rename(&root, directory.join("offline")).unwrap();
|
||||
let listing: Value =
|
||||
test::call_and_read_body_json(&app, test::TestRequest::get().uri(&base).to_request()).await;
|
||||
assert_eq!(listing["status"], "unavailable");
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use actix_web::{dev::ServerHandle, test, web, App, HttpRequest, HttpResponse, HttpServer};
|
||||
use rust_project::{api, config::Config, service::Service, storage::Store};
|
||||
use serde_json::Value;
|
||||
use std::{net::TcpListener, sync::Arc, time::Duration};
|
||||
|
||||
const SEARCH: &str = r#"<form action="/Home/Search"><input name="searchstr"></form>
|
||||
<div class="central-container"><ul class="list-inline an-ul">
|
||||
<li><a href="/Home/Bangumi/101"><div class="an-text" title="测试番剧">测试番剧</div></a></li>
|
||||
<li><a href="/Home/Bangumi/202"><div class="an-text" title="测试番剧 第二季">测试番剧 第二季</div></a></li>
|
||||
</ul></div>"#;
|
||||
|
||||
// Independently authored fixtures; publish-group ID deliberately differs from RSS group ID.
|
||||
const DETAIL: &str = r#"<p class="bangumi-title">测试番剧
|
||||
<a class="mikan-rss" href="/RSS/Bangumi?bangumiId=101"></a></p>
|
||||
<p class="bangumi-info">放送日期:星期五</p>
|
||||
<p class="bangumi-info">Bangumi番组计划链接:<a href="https://bgm.tv/subject/400602">详情</a></p>
|
||||
<div class="subgroup-text" id="12"><a href="/Home/PublishGroup/99">字幕组 & A</a>
|
||||
<a class="mikan-rss" href="/RSS/Bangumi?bangumiId=101&subgroupid=12"></a></div>"#;
|
||||
|
||||
async fn mock() -> (String, ServerHandle) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = HttpServer::new(|| {
|
||||
App::new().default_service(web::to(|req: HttpRequest| async move {
|
||||
if req.path() == "/Home/Bangumi/202" {
|
||||
return HttpResponse::Ok().content_type("text/html").body(format!(
|
||||
"{}<div class=\"bangumi-poster\" style=\"background-image: url('/images/Bangumi/cover.png');\"></div>",
|
||||
DETAIL.replace("101", "202")));
|
||||
}
|
||||
if req.path() == "/images/Bangumi/cover.png" {
|
||||
return HttpResponse::Ok().content_type("image/png").body(b"\x89PNG\r\n\x1a\nfixture".as_slice());
|
||||
}
|
||||
if req.path() == "/Home/Bangumi/101" {
|
||||
return HttpResponse::Ok().content_type("text/html").body(DETAIL);
|
||||
}
|
||||
if req.path() == "/RSS/Bangumi" {
|
||||
assert_eq!(req.query_string(), "bangumiId=101&subgroupid=12");
|
||||
return HttpResponse::Ok()
|
||||
.content_type("application/rss+xml")
|
||||
.body(include_str!("fixtures/releases.xml"));
|
||||
}
|
||||
assert_eq!(req.path(), "/Home/Search");
|
||||
let query = web::Query::<std::collections::HashMap<String, String>>::from_query(
|
||||
req.query_string(),
|
||||
)
|
||||
.unwrap();
|
||||
match query.get("searchstr").map(String::as_str) {
|
||||
Some("empty") => return HttpResponse::Ok().content_type("text/html")
|
||||
.body(r#"<form action="/Home/Search"><input name="searchstr"></form><div class="central-container"><ul class="an-ul"></ul></div>"#),
|
||||
Some("challenge") => return HttpResponse::Ok().content_type("text/html").body("<h1>Verify your browser</h1>"),
|
||||
Some("changed") => return HttpResponse::Ok().content_type("text/html")
|
||||
.body(r#"<form action="/Home/Search"><input name="searchstr"></form><ul class="an-ul"><a href="/Home/Bangumi/101"><span class="new-title">番剧</span></a></ul>"#),
|
||||
_ => {}
|
||||
}
|
||||
assert_eq!(query.get("searchstr").unwrap(), "测试 & S2");
|
||||
HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.body(SEARCH)
|
||||
}))
|
||||
})
|
||||
.workers(1)
|
||||
.listen(listener)
|
||||
.unwrap()
|
||||
.run();
|
||||
let handle = server.handle();
|
||||
actix_web::rt::spawn(server);
|
||||
(url, handle)
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn covers_use_an_application_url_and_details_remain_available_in_bounded_cache() {
|
||||
let (base, server) = mock().await;
|
||||
let service = Service::new(
|
||||
Store::open("sqlite::memory:").await.unwrap(),
|
||||
&config(&base),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(Arc::new(service)))
|
||||
.configure(api::configure),
|
||||
)
|
||||
.await;
|
||||
let detail: Value = test::call_and_read_body_json(
|
||||
&app,
|
||||
test::TestRequest::get()
|
||||
.uri("/api/sources/mikan/series/202")
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(detail["cover_url"], "/api/sources/mikan/series/202/cover");
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::get()
|
||||
.uri(detail["cover_url"].as_str().unwrap())
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 200);
|
||||
assert_eq!(response.headers().get("Content-Type").unwrap(), "image/png");
|
||||
assert_eq!(
|
||||
test::read_body(response).await.as_ref(),
|
||||
b"\x89PNG\r\n\x1a\nfixture"
|
||||
);
|
||||
server.stop(false).await;
|
||||
let cached: Value = test::call_and_read_body_json(
|
||||
&app,
|
||||
test::TestRequest::get()
|
||||
.uri("/api/sources/mikan/series/202")
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(cached, detail);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn empty_results_are_distinct_from_challenges_or_changed_markup() {
|
||||
let (base, server) = mock().await;
|
||||
let service = Service::new(
|
||||
Store::open("sqlite::memory:").await.unwrap(),
|
||||
&config(&base),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(Arc::new(service)))
|
||||
.configure(api::configure),
|
||||
)
|
||||
.await;
|
||||
for (query, status) in [
|
||||
("empty", 200),
|
||||
("challenge", 502),
|
||||
("changed", 502),
|
||||
("%20", 400),
|
||||
] {
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::get()
|
||||
.uri(&format!("/api/sources/mikan/search?q={query}"))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), status, "query {query}");
|
||||
if status == 200 {
|
||||
let value: Value = test::read_body_json(response).await;
|
||||
assert_eq!(value["series"], serde_json::json!([]));
|
||||
}
|
||||
}
|
||||
server.stop(false).await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn group_candidate_can_be_previewed_and_saved_disabled_without_downloading() {
|
||||
let (base, server) = mock().await;
|
||||
let service = Service::new(
|
||||
Store::open("sqlite::memory:").await.unwrap(),
|
||||
&config(&base),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(Arc::new(service)))
|
||||
.configure(api::configure),
|
||||
)
|
||||
.await;
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::get()
|
||||
.uri("/api/sources/mikan/series/101")
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 200);
|
||||
let detail: Value = test::read_body_json(response).await;
|
||||
assert_eq!(detail["title"], "测试番剧");
|
||||
assert_eq!(detail["bangumi_id"], "400602");
|
||||
assert_eq!(detail["groups"][0]["group_id"], "12");
|
||||
assert_eq!(detail["groups"][0]["name"], "字幕组 & A");
|
||||
assert!(detail["cover_url"].is_null());
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::post()
|
||||
.uri("/api/sources/mikan/resolve")
|
||||
.set_json(serde_json::json!({"url": detail["groups"][0]["rss_url"]}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 200);
|
||||
let candidate: Value = test::read_body_json(response).await;
|
||||
assert_eq!(candidate["scope"], "group");
|
||||
assert_eq!(candidate["series_id"], "101");
|
||||
assert_eq!(candidate["group_id"], "12");
|
||||
assert_eq!(candidate["subscription"]["enabled"], false);
|
||||
let preview: Value = test::call_and_read_body_json(
|
||||
&app,
|
||||
test::TestRequest::post()
|
||||
.uri("/api/preview")
|
||||
.set_json(&candidate["subscription"])
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(preview[0]["episode"], 3);
|
||||
let saved = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::post()
|
||||
.uri("/api/subscriptions")
|
||||
.set_json(&candidate["subscription"])
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(saved.status(), 201);
|
||||
let jobs: Value =
|
||||
test::call_and_read_body_json(&app, test::TestRequest::get().uri("/api/jobs").to_request())
|
||||
.await;
|
||||
assert_eq!(jobs, serde_json::json!([]));
|
||||
server.stop(true).await;
|
||||
}
|
||||
|
||||
fn config(base: &str) -> Config {
|
||||
Config {
|
||||
database_url: "sqlite::memory:".into(),
|
||||
port: 8080,
|
||||
poll_interval: Duration::from_secs(300),
|
||||
qbit: None,
|
||||
mikan_base_url: base.into(),
|
||||
website: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn resolver_marks_keyword_scope_and_rejects_foreign_or_ambiguous_urls() {
|
||||
let (base, server) = mock().await;
|
||||
let service = Service::new(
|
||||
Store::open("sqlite::memory:").await.unwrap(),
|
||||
&config(&base),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(Arc::new(service)))
|
||||
.configure(api::configure),
|
||||
)
|
||||
.await;
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::post()
|
||||
.uri("/api/sources/mikan/resolve")
|
||||
.set_json(serde_json::json!({"url": format!("{base}/RSS/Search?searchstr=test")}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 200);
|
||||
let candidate: Value = test::read_body_json(response).await;
|
||||
assert_eq!(candidate["scope"], "keyword");
|
||||
assert!(candidate["series_id"].is_null());
|
||||
assert!(candidate["subscription"].is_null());
|
||||
assert!(!candidate["warnings"].as_array().unwrap().is_empty());
|
||||
for url in [
|
||||
"https://other.invalid/Home/Bangumi/101".into(),
|
||||
format!("{base}/RSS/Bangumi?bangumiId=101&bangumiId=202"),
|
||||
format!("{base}/RSS/Bangumi?bangumiId=101&subgroupid=999"),
|
||||
format!("{base}/Home/Bangumi/not-an-id"),
|
||||
] {
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::post()
|
||||
.uri("/api/sources/mikan/resolve")
|
||||
.set_json(serde_json::json!({"url": url}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 400);
|
||||
}
|
||||
server.stop(false).await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn search_discovers_distinct_series_and_encoded_keyword_rss_without_creating_jobs() {
|
||||
let (base, server) = mock().await;
|
||||
let service = Service::new(
|
||||
Store::open("sqlite::memory:").await.unwrap(),
|
||||
&config(&base),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(Arc::new(service)))
|
||||
.configure(api::configure),
|
||||
)
|
||||
.await;
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::get()
|
||||
.uri("/api/sources/mikan/search?q=%E6%B5%8B%E8%AF%95%20%26%20S2")
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 200);
|
||||
let body: Value = test::read_body_json(response).await;
|
||||
assert_eq!(body["series"][0]["series_id"], "101");
|
||||
assert_eq!(body["series"][1]["title"], "测试番剧 第二季");
|
||||
let rss = reqwest::Url::parse(body["keyword_rss_url"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(rss.path(), "/RSS/Search");
|
||||
assert_eq!(
|
||||
rss.query_pairs().collect::<Vec<_>>(),
|
||||
vec![("searchstr".into(), "测试 & S2".into())]
|
||||
);
|
||||
let jobs: Value =
|
||||
test::call_and_read_body_json(&app, test::TestRequest::get().uri("/api/jobs").to_request())
|
||||
.await;
|
||||
assert_eq!(jobs, serde_json::json!([]));
|
||||
server.stop(true).await;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use actix_web::{dev::ServerHandle, test, web, App, HttpRequest, HttpResponse, HttpServer};
|
||||
use rust_project::{
|
||||
api,
|
||||
config::{Config, QbitConfig},
|
||||
service::Service,
|
||||
storage::Store,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::{net::TcpListener, sync::Arc, time::Duration};
|
||||
|
||||
async fn server(proxy: bool) -> (String, ServerHandle) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = HttpServer::new(move || App::new().default_service(web::to(move |req: HttpRequest| async move {
|
||||
if proxy {
|
||||
assert_eq!(req.headers().get("Proxy-Authorization").and_then(|h| h.to_str().ok()), Some("Basic dXNlcjpwYXNz"), "request {}", req.uri());
|
||||
if req.uri().host() == Some("127.0.0.1") { return HttpResponse::BadGateway().finish(); }
|
||||
match req.path() {
|
||||
"/to-direct" => {
|
||||
let query = web::Query::<std::collections::HashMap<String, String>>::from_query(req.query_string()).unwrap();
|
||||
HttpResponse::Found().insert_header(("Location", query.get("target").unwrap().as_str())).finish()
|
||||
},
|
||||
"/Home/Search" => HttpResponse::Found().insert_header(("Location", "http://assets.invalid/results")).finish(),
|
||||
"/results" => {
|
||||
assert_eq!(req.uri().host(), Some("assets.invalid"));
|
||||
HttpResponse::Ok().content_type("text/html").body(r#"<form action="/Home/Search"><input name="searchstr"></form><ul class="an-ul"><a href="/Home/Bangumi/101"><div class="an-text">经代理搜索</div></a></ul>"#)
|
||||
},
|
||||
"/feed" => HttpResponse::Ok().body(include_str!("fixtures/releases.xml")),
|
||||
_ => HttpResponse::BadGateway().finish(),
|
||||
}
|
||||
} else {
|
||||
assert!(req.headers().get("Proxy-Authorization").is_none());
|
||||
match req.path() {
|
||||
"/api/v2/auth/login" => HttpResponse::Ok().insert_header(("Set-Cookie", "SID=test; Path=/")).body("Ok."),
|
||||
"/api/v2/torrents/info" => HttpResponse::Ok().json(json!([])),
|
||||
"/feed" => HttpResponse::Ok().body(include_str!("fixtures/releases.xml")),
|
||||
_ => HttpResponse::NotFound().finish(),
|
||||
}
|
||||
}
|
||||
}))).workers(1).listen(listener).unwrap().run();
|
||||
let handle = server.handle();
|
||||
actix_web::rt::spawn(server);
|
||||
(base, handle)
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn direct_exceptions_are_explicit_and_proxy_failure_never_falls_back() {
|
||||
let (proxy, proxy_server) = server(true).await;
|
||||
let (direct, direct_server) = server(false).await;
|
||||
let proxy = proxy.replacen("http://", "http://user:pass@", 1);
|
||||
for (direct_hosts, status) in [("127.0.0.1", 200), ("", 502)] {
|
||||
let config = Config {
|
||||
database_url: "sqlite::memory:".into(),
|
||||
port: 8080,
|
||||
poll_interval: Duration::from_secs(300),
|
||||
qbit: None,
|
||||
mikan_base_url: "http://mikan.invalid".into(),
|
||||
website: rust_project::config::WebsiteConfig {
|
||||
proxy_url: Some(proxy.clone()),
|
||||
direct_hosts: direct_hosts.into(),
|
||||
},
|
||||
};
|
||||
let service = Service::new(Store::open("sqlite::memory:").await.unwrap(), &config)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(Arc::new(service)))
|
||||
.configure(api::configure),
|
||||
)
|
||||
.await;
|
||||
let mut redirect = reqwest::Url::parse("http://unresolvable.invalid/to-direct").unwrap();
|
||||
redirect
|
||||
.query_pairs_mut()
|
||||
.append_pair("target", &format!("{direct}/feed"));
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::post()
|
||||
.uri("/api/preview")
|
||||
.set_json(json!({"title":"Test", "rss_url": redirect.as_str()}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), status);
|
||||
if status == 200 {
|
||||
let preview: Value = test::read_body_json(response).await;
|
||||
assert_eq!(preview[0]["episode"], 3);
|
||||
} else {
|
||||
let body = String::from_utf8(test::read_body(response).await.to_vec()).unwrap();
|
||||
assert!(!body.contains("user:pass"));
|
||||
}
|
||||
}
|
||||
proxy_server.stop(false).await;
|
||||
direct_server.stop(false).await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn website_proxy_handles_auth_redirects_and_rss_while_downloader_stays_direct() {
|
||||
let (proxy, proxy_server) = server(true).await;
|
||||
let (direct, direct_server) = server(false).await;
|
||||
let mut proxy_url = reqwest::Url::parse(&proxy).unwrap();
|
||||
proxy_url.set_username("user").unwrap();
|
||||
proxy_url.set_password(Some("pass")).unwrap();
|
||||
let config = Config {
|
||||
database_url: "sqlite::memory:".into(),
|
||||
port: 8080,
|
||||
poll_interval: Duration::from_secs(300),
|
||||
mikan_base_url: "http://mikan.invalid".into(),
|
||||
website: rust_project::config::WebsiteConfig {
|
||||
proxy_url: Some(proxy_url.into()),
|
||||
direct_hosts: String::new(),
|
||||
},
|
||||
qbit: Some(QbitConfig {
|
||||
url: direct.clone(),
|
||||
username: "test".into(),
|
||||
password: "test".into(),
|
||||
save_path: "/downloads".into(),
|
||||
}),
|
||||
};
|
||||
let service = Service::new(Store::open("sqlite::memory:").await.unwrap(), &config)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(Arc::new(service)))
|
||||
.configure(api::configure),
|
||||
)
|
||||
.await;
|
||||
let search: Value = test::call_and_read_body_json(
|
||||
&app,
|
||||
test::TestRequest::get()
|
||||
.uri("/api/sources/mikan/search?q=test")
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(search["series"][0]["title"], "经代理搜索");
|
||||
let preview: Value = test::call_and_read_body_json(
|
||||
&app,
|
||||
test::TestRequest::post()
|
||||
.uri("/api/preview")
|
||||
.set_json(json!({"title":"Test", "rss_url":"http://unresolvable.invalid/feed"}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(preview[0]["episode"], 3);
|
||||
let cycle: Value =
|
||||
test::call_and_read_body_json(&app, test::TestRequest::post().uri("/api/run").to_request())
|
||||
.await;
|
||||
assert_eq!(cycle["errors"], json!([]));
|
||||
assert_eq!(cycle["downloader_configured"], true);
|
||||
proxy_server.stop(false).await;
|
||||
direct_server.stop(false).await;
|
||||
}
|
||||
+1100
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user