feat: implement RSS subscriptions, downloader routing and local media checks

This commit is contained in:
junior
2026-10-17 22:49:11 +08:00
parent 645ea6fe2d
commit 68c972dadd
28 changed files with 9226 additions and 43 deletions
+28
View File
@@ -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>
+154
View File
@@ -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
View File
@@ -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">字幕组 &amp; A</a>
<a class="mikan-rss" href="/RSS/Bangumi?bangumiId=101&amp;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;
}
+154
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff