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
+248
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}
+265
View File
@@ -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());
}
}
}
+47
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+148 -41
View File
@@ -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);
}
// 计算数组元素总和
let sum: i32 = numbers.iter().sum();
println!("\n数组元素总和: {}", sum);
// 条件判断
if sum > 10 {
println!("总和大于 10");
} else {
println!("总和小于或等于 10");
}
// 与用户交互
println!("\n请输入您的名字:");
let mut input = String::new();
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 Greeting {
message: String,
name: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct Health {
status: String,
version: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct CalcRequest {
a: f64,
b: f64,
operation: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct CalcResponse {
result: f64,
operation: String,
}
async fn hello() -> impl Responder {
HttpResponse::Ok().body("Hello World! 欢迎使用 Rust Web 服务器")
}
async fn health_check() -> impl Responder {
let health = Health {
status: "ok".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
};
HttpResponse::Ok().json(health)
}
async fn greet(name: web::Path<String>) -> impl Responder {
let greeting = Greeting {
message: format!("你好,{}!", name),
name: name.into_inner(),
};
HttpResponse::Ok().json(greeting)
}
async fn calculate(web::Json(req): web::Json<CalcRequest>) -> impl Responder {
let result = match req.operation.as_str() {
"add" => req.a + req.b,
"subtract" => req.a - req.b,
"multiply" => req.a * req.b,
"divide" => {
if req.b == 0.0 {
return HttpResponse::BadRequest().json("除数不能为 0");
}
req.a / req.b
}
Err(error) => println!("读取输入时发生错误: {}", error),
}
_ => 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
View File
@@ -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,
},
}
}
+153
View File
@@ -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
View File
@@ -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)
}
}
+89
View File
@@ -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
View File
@@ -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
View File
@@ -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(())
}
}