diff --git a/Cargo.lock b/Cargo.lock index b18981d..c53e248 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1242,7 +1242,6 @@ dependencies = [ "serde_json", "sqlx", "thiserror 2.0.18", - "tmdb-api", "tokio", "tower", "tower-http", @@ -2167,17 +2166,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2630,19 +2618,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "tmdb-api" -version = "1.0.0-alpha.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54ddb0556d53c900a3eafb0b070679e31d4fd75f0197d456dbff262314c5fe98" -dependencies = [ - "chrono", - "reqwest", - "serde", - "serde_repr", - "thiserror 2.0.18", -] - [[package]] name = "tokio" version = "1.52.3" diff --git a/Cargo.toml b/Cargo.toml index 6071e12..e5bd14a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,8 +45,7 @@ serde_json = "1" # Torrent file parsing (Phase 4, listed now) lava_torrent = "0.11" -# TMDB enrichment (Phase 2, listed now) -tmdb-api = "1.0.0-alpha.5" +# HTTP client (TMDB enrichment, Phase 2) reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } # Error handling diff --git a/src/db/queries.rs b/src/db/queries.rs index 388a066..ef7a3ce 100644 --- a/src/db/queries.rs +++ b/src/db/queries.rs @@ -142,14 +142,24 @@ pub struct SearchParams { pub cats: Vec, pub limit: i64, pub offset: i64, + pub tvdb_id: Option, + pub imdb_id: Option, + pub tmdb_id: Option, + pub season: Option, + pub episode: Option, } pub async fn search(pool: &SqlitePool, p: &SearchParams) -> anyhow::Result> { let limit = p.limit.clamp(1, 100); + let has_ids = p.tvdb_id.is_some() || p.imdb_id.is_some() || p.tmdb_id.is_some(); + let has_se = p.season.is_some() || p.episode.is_some(); + let use_fts = !p.query.is_empty(); + let cat_where = build_cat_where(&p.cats); - let rows: Vec = if !p.query.is_empty() { + let rows: Vec = if use_fts && !has_ids && !has_se { + // Pure FTS — fastest path for text queries with no ID filters let mut sql = String::from( "SELECT t.event_id, t.info_hash, t.pubkey, t.created_at, t.title, t.description, t.size_bytes, t.newznab_cat, t.imdb_id, @@ -175,19 +185,68 @@ pub async fn search(pool: &SqlitePool, p: &SearchParams) -> anyhow::Result = vec![]; + + if use_fts { + conditions.push( + "rowid IN (SELECT rowid FROM torrents_fts WHERE torrents_fts MATCH ?)".into(), + ); + } + if p.tvdb_id.is_some() { + conditions.push("tvdb_id = ?".into()); + } + if p.imdb_id.is_some() { + conditions.push("(imdb_id = ? OR imdb_id = ?)".into()); + } + if p.tmdb_id.is_some() { + conditions.push("(tmdb_id = ? OR tmdb_id = ?)".into()); + } + if p.season.is_some() { + conditions.push("season = ?".into()); + } + if p.episode.is_some() { + conditions.push("episode = ?".into()); + } + if !cat_where.is_empty() { + conditions.push(format!("({cat_where})")); + } + let mut sql = String::from( "SELECT event_id, info_hash, pubkey, created_at, title, description, size_bytes, newznab_cat, imdb_id, tmdb_id, tvdb_id, season, episode, quality, source FROM torrents", ); - if !cat_where.is_empty() { + if !conditions.is_empty() { sql.push_str(" WHERE "); - sql.push_str(&cat_where); + sql.push_str(&conditions.join(" AND ")); } sql.push_str(" ORDER BY created_at DESC LIMIT ? OFFSET ?"); - let mut q = sqlx::query_as(&sql); + let mut q = sqlx::query_as::<_, TorrentRow>(&sql); + + if use_fts { + q = q.bind(&p.query); + } + if let Some(ref id) = p.tvdb_id { + q = q.bind(id); + } + if let Some(ref id) = p.imdb_id { + // Match both bare digits and tt-prefixed forms + let bare = id.trim_start_matches("tt").to_owned(); + let tt = if id.starts_with("tt") { id.clone() } else { format!("tt{id}") }; + q = q.bind(bare).bind(tt); + } + if let Some(ref id) = p.tmdb_id { + q = q.bind(format!("movie:{id}")).bind(format!("tv:{id}")); + } + if let Some(s) = p.season { + q = q.bind(s); + } + if let Some(e) = p.episode { + q = q.bind(e); + } for cat in &p.cats { if cat % 1000 == 0 { q = q.bind(cat).bind(cat + 999); diff --git a/src/enrich/mod.rs b/src/enrich/mod.rs new file mode 100644 index 0000000..5b8c509 --- /dev/null +++ b/src/enrich/mod.rs @@ -0,0 +1,2 @@ +pub mod parser; +pub mod tmdb; diff --git a/src/enrich/parser.rs b/src/enrich/parser.rs new file mode 100644 index 0000000..c40e21a --- /dev/null +++ b/src/enrich/parser.rs @@ -0,0 +1,133 @@ +use std::sync::OnceLock; + +use regex::Regex; + +pub struct ParsedTitle { + pub clean: String, + pub season: Option, + pub episode: Option, + pub year: Option, + pub quality: Option, + pub source: Option, +} + +static SE_RE: OnceLock = OnceLock::new(); +static ALT_SE_RE: OnceLock = OnceLock::new(); +static YEAR_RE: OnceLock = OnceLock::new(); +static QUALITY_RE: OnceLock = OnceLock::new(); +static SOURCE_RE: OnceLock = OnceLock::new(); + +fn se_re() -> &'static Regex { + SE_RE.get_or_init(|| Regex::new(r"(?i)\bS(\d{1,2})E(\d{1,4})\b").unwrap()) +} + +fn alt_se_re() -> &'static Regex { + ALT_SE_RE.get_or_init(|| Regex::new(r"\b(\d{1,2})x(\d{2,4})\b").unwrap()) +} + +fn year_re() -> &'static Regex { + YEAR_RE.get_or_init(|| Regex::new(r"\b((?:19|20)\d{2})\b").unwrap()) +} + +fn quality_re() -> &'static Regex { + QUALITY_RE.get_or_init(|| Regex::new(r"(?i)\b(2160p|4K|UHD|1080p|720p|576p|480p)\b").unwrap()) +} + +fn source_re() -> &'static Regex { + SOURCE_RE.get_or_init(|| { + Regex::new(r"(?i)\b(WEB-DL|WEBRip|BluRay|BDRip|HDTV|DVDRip|REMUX)\b").unwrap() + }) +} + +pub fn parse(raw: &str) -> ParsedTitle { + let s: String = raw.chars().map(|c| if c == '.' || c == '_' { ' ' } else { c }).collect(); + + let (season, episode) = extract_season_ep(&s); + let year = extract_year(&s); + let quality = quality_re().captures(&s).and_then(|c| c.get(1)).map(|m| m.as_str().to_owned()); + let source = source_re().captures(&s).and_then(|c| c.get(1)).map(|m| m.as_str().to_owned()); + + let cut = find_cut(&s, season, quality.is_some(), source.is_some()); + let clean = s[..cut].split_whitespace().collect::>().join(" "); + + ParsedTitle { clean, season, episode, year, quality, source } +} + +fn extract_season_ep(s: &str) -> (Option, Option) { + if let Some(caps) = se_re().captures(s) { + return ( + caps.get(1).and_then(|m| m.as_str().parse().ok()), + caps.get(2).and_then(|m| m.as_str().parse().ok()), + ); + } + if let Some(caps) = alt_se_re().captures(s) { + return ( + caps.get(1).and_then(|m| m.as_str().parse().ok()), + caps.get(2).and_then(|m| m.as_str().parse().ok()), + ); + } + (None, None) +} + +fn extract_year(s: &str) -> Option { + year_re() + .captures_iter(s) + .filter_map(|c| c.get(1)?.as_str().parse::().ok()) + .find(|&y| y >= 1900 && y <= 2099) +} + +fn find_cut(s: &str, season: Option, has_quality: bool, has_source: bool) -> usize { + let mut cut = s.len(); + if season.is_some() { + if let Some(m) = se_re().find(s) { cut = cut.min(m.start()); } + if let Some(m) = alt_se_re().find(s) { cut = cut.min(m.start()); } + } + if has_quality { + if let Some(m) = quality_re().find(s) { cut = cut.min(m.start()); } + } + if has_source { + if let Some(m) = source_re().find(s) { cut = cut.min(m.start()); } + } + cut +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn season_ep() { + let p = parse("Breaking.Bad.S01E07.720p.BluRay.x264"); + assert_eq!(p.season, Some(1)); + assert_eq!(p.episode, Some(7)); + assert_eq!(p.clean, "Breaking Bad"); + assert_eq!(p.quality.as_deref(), Some("720p")); + assert_eq!(p.source.as_deref(), Some("BluRay")); + } + + #[test] + fn alt_season_ep() { + let p = parse("Show.Name.1x03.HDTV"); + assert_eq!(p.season, Some(1)); + assert_eq!(p.episode, Some(3)); + assert_eq!(p.source.as_deref(), Some("HDTV")); + } + + #[test] + fn movie_with_year() { + let p = parse("The.Dark.Knight.2008.1080p.WEB-DL"); + assert_eq!(p.season, None); + assert_eq!(p.year, Some(2008)); + assert_eq!(p.quality.as_deref(), Some("1080p")); + // Clean title includes year since year alone doesn't cut + assert!(p.clean.contains("Dark Knight")); + } + + #[test] + fn no_markers() { + let p = parse("Just a plain title"); + assert_eq!(p.clean, "Just a plain title"); + assert_eq!(p.season, None); + assert_eq!(p.quality, None); + } +} diff --git a/src/enrich/tmdb.rs b/src/enrich/tmdb.rs new file mode 100644 index 0000000..3db4697 --- /dev/null +++ b/src/enrich/tmdb.rs @@ -0,0 +1,82 @@ +use anyhow::Result; +use reqwest::Client; +use serde::Deserialize; +use sqlx::SqlitePool; +use tracing::{debug, warn}; +use url::form_urlencoded; + +#[derive(Clone)] +pub struct Enricher { + api_key: String, + client: Client, +} + +#[derive(Deserialize)] +struct SearchResponse { + results: Vec, +} + +#[derive(Deserialize)] +struct TmdbHit { + id: i64, +} + +impl Enricher { + pub fn new(api_key: String) -> Self { + Enricher { api_key, client: Client::new() } + } + + pub fn enabled(&self) -> bool { + !self.api_key.is_empty() + } + + pub async fn enrich(&self, pool: &SqlitePool, event_id: &str, title: &str, year: Option, newznab_cat: Option) { + if !self.enabled() { return; } + + let (kind, prefix) = match newznab_cat { + Some(c) if (2000..3000).contains(&c) => ("movie", "movie"), + Some(c) if (5000..6000).contains(&c) => ("tv", "tv"), + _ => return, + }; + + let result = match kind { + "movie" => self.search("search/movie", title, year, "year").await, + _ => self.search("search/tv", title, year, "first_air_date_year").await, + }; + + let tmdb_id = match result { + Ok(Some(id)) => id, + Ok(None) => return, + Err(e) => { + warn!(event_id, "tmdb lookup error: {e}"); + return; + } + }; + + let stored = format!("{prefix}:{tmdb_id}"); + match sqlx::query( + "UPDATE torrents SET tmdb_id = ? WHERE event_id = ? AND (tmdb_id IS NULL OR tmdb_id = '')", + ) + .bind(&stored) + .bind(event_id) + .execute(pool) + .await + { + Ok(_) => debug!(event_id, tmdb_id = %stored, "tmdb enriched"), + Err(e) => warn!(event_id, "tmdb db update failed: {e}"), + } + } + + async fn search(&self, path: &str, title: &str, year: Option, year_param: &str) -> Result> { + let encoded: String = form_urlencoded::byte_serialize(title.as_bytes()).collect(); + let mut url = format!( + "https://api.themoviedb.org/3/{path}?api_key={key}&query={encoded}", + key = self.api_key, + ); + if let Some(y) = year { + url.push_str(&format!("&{year_param}={y}")); + } + let res: SearchResponse = self.client.get(&url).send().await?.json().await?; + Ok(res.results.first().map(|h| h.id)) + } +} diff --git a/src/lib.rs b/src/lib.rs index 0f3e727..643cf71 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod config; pub mod db; +pub mod enrich; pub mod nostr; pub mod torznab; diff --git a/src/main.rs b/src/main.rs index 911cb33..b9ce463 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use anyhow::Context; use axum::{extract::State, routing::get, Json, Router}; use clap::Parser; -use kindexr::{config, db, nostr, torznab, AppState}; +use kindexr::{config, db, enrich::tmdb::Enricher, nostr, torznab, AppState}; use serde_json::{json, Value}; use std::{ sync::{atomic::AtomicI32, Arc}, @@ -58,7 +58,8 @@ async fn main() -> anyhow::Result<()> { relay_count: relay_count.clone(), }; - let reader = nostr::reader::Reader::new(cfg.clone(), pool.clone(), relay_count); + let enricher = Arc::new(Enricher::new(cfg.tmdb.api_key.clone())); + let reader = nostr::reader::Reader::new(cfg.clone(), pool.clone(), relay_count, enricher); reader.start(); let app = Router::new() diff --git a/src/nostr/parser.rs b/src/nostr/parser.rs index 1d75087..71022ac 100644 --- a/src/nostr/parser.rs +++ b/src/nostr/parser.rs @@ -4,6 +4,7 @@ use nostr::Event; use std::time::{SystemTime, UNIX_EPOCH}; use crate::db::{FileRecord, TorrentRecord}; +use crate::enrich::parser as title_parser; use crate::torznab::categories::tcat_to_newznab; static INFO_HASH_RE: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -115,6 +116,25 @@ pub fn parse(event: &Event) -> anyhow::Result { bail!("event has no title"); } + // Apply title parser to fill in fields not supplied by tags. + let parsed = title_parser::parse(&rec.title); + if rec.season.is_none() && parsed.season.is_some() { + rec.season = parsed.season; + } + if rec.episode.is_none() && parsed.episode.is_some() { + rec.episode = parsed.episode; + } + if rec.quality.is_empty() { + if let Some(q) = parsed.quality { + rec.quality = q; + } + } + if rec.source.is_empty() { + if let Some(s) = parsed.source { + rec.source = s; + } + } + rec.description = event.content.clone(); if has_size { rec.size_bytes = Some(total_size); diff --git a/src/nostr/reader.rs b/src/nostr/reader.rs index fc01119..52707ed 100644 --- a/src/nostr/reader.rs +++ b/src/nostr/reader.rs @@ -10,30 +10,32 @@ use nostr_sdk::{ use sqlx::SqlitePool; use tracing::{debug, error, info, warn}; -use crate::{config::Config, db}; +use crate::{config::Config, db, enrich::tmdb::Enricher}; pub struct Reader { cfg: Arc, pool: SqlitePool, connected: Arc, + enricher: Arc, } impl Reader { - pub fn new(cfg: Arc, pool: SqlitePool, connected: Arc) -> Self { - Reader { cfg, pool, connected } + pub fn new(cfg: Arc, pool: SqlitePool, connected: Arc, enricher: Arc) -> Self { + Reader { cfg, pool, connected, enricher } } pub fn start(self) { let cfg = self.cfg.clone(); let pool = self.pool.clone(); let connected = self.connected.clone(); + let enricher = self.enricher.clone(); tokio::spawn(async move { - run_reader(cfg, pool, connected).await; + run_reader(cfg, pool, connected, enricher).await; }); } } -async fn run_reader(cfg: Arc, pool: SqlitePool, connected: Arc) { +async fn run_reader(cfg: Arc, pool: SqlitePool, connected: Arc, enricher: Arc) { if cfg.relays.is_empty() { warn!("no relays configured; reader idle"); return; @@ -44,7 +46,6 @@ async fn run_reader(cfg: Arc, pool: SqlitePool, connected: Arc { - // Seed relay row so sync state is tracked. let _ = db::upsert_relay(&pool, relay_url).await; } Err(e) => warn!(url = relay_url, "failed to add relay: {e}"), @@ -55,7 +56,6 @@ async fn run_reader(cfg: Arc, pool: SqlitePool, connected: Arc, pool: SqlitePool, connected: Arc, pool: SqlitePool, connected: Arc { - handle_event(&pool, &event).await; + handle_event(&pool, &cfg, &enricher, &event).await; } RelayPoolNotification::Message { relay_url, message } => { debug!(url = %relay_url, "relay message: {:?}", message); } RelayPoolNotification::Shutdown => { info!("relay pool shutdown"); - return Ok(true); // exit handler + return Ok(true); } } Ok(false) @@ -105,14 +103,44 @@ async fn run_reader(cfg: Arc, pool: SqlitePool, connected: Arc { - if let Err(e) = db::insert_torrent(pool, &rec).await { - error!(event_id = %event.id, "db insert failed: {e}"); - } else { - debug!(event_id = %event.id, title = %rec.title, "indexed event"); - let _ = db::update_relay_last_event(pool, "", event.created_at.as_secs() as i64).await; + // Drop events in excluded categories (adult content etc.) + if let Some(cat) = rec.newznab_cat { + let excluded = cfg.curation.exclude_categories.iter().any(|&excl| { + if excl % 1000 == 0 { + cat >= excl && cat < excl + 1000 + } else { + cat == excl + } + }); + if excluded { + debug!(event_id = %event.id, cat, "dropped: excluded category"); + return; + } + } + + match db::insert_torrent(pool, &rec).await { + Err(e) => error!(event_id = %event.id, "db insert failed: {e}"), + Ok(()) => { + debug!(event_id = %event.id, title = %rec.title, "indexed event"); + let _ = db::update_relay_last_event(pool, "", event.created_at.as_secs() as i64).await; + + // Spawn TMDB enrichment when no ID was in the event. + if enricher.enabled() && rec.tmdb_id.is_empty() { + let enricher = enricher.clone(); + let pool = pool.clone(); + let event_id = rec.event_id.clone(); + let title = rec.title.clone(); + let newznab_cat = rec.newznab_cat; + tokio::spawn(async move { + // Re-parse title to get year for TMDB lookup. + let parsed = crate::enrich::parser::parse(&title); + enricher.enrich(&pool, &event_id, &parsed.clean, parsed.year, newznab_cat).await; + }); + } + } } } Err(e) => { diff --git a/src/torznab/search.rs b/src/torznab/search.rs index d04b936..d9541c1 100644 --- a/src/torznab/search.rs +++ b/src/torznab/search.rs @@ -15,22 +15,53 @@ use super::{server::xml_response, xml::*}; #[derive(Deserialize)] pub struct SearchQuery { + pub t: Option, pub q: Option, pub cat: Option, pub limit: Option, pub offset: Option, + // tvsearch / movie ID filters + pub tvdbid: Option, + pub imdbid: Option, + pub tmdbid: Option, + pub season: Option, + pub ep: Option, + // music / audio + pub artist: Option, + pub album: Option, + pub year: Option, + // book + pub author: Option, } pub async fn search_handler( State(state): State, Query(params): Query, ) -> Response { - let query = params.q.unwrap_or_default(); - let cats = parse_cats(params.cat.as_deref().unwrap_or("")); + let search_type = params.t.as_deref().unwrap_or("search"); + + let query_text = build_query_text(¶ms, search_type); + let explicit_cats = parse_cats(params.cat.as_deref().unwrap_or("")); + let cats = if explicit_cats.is_empty() { + default_cats(search_type) + } else { + explicit_cats + }; + let limit = params.limit.unwrap_or(50).clamp(1, 100); let offset = params.offset.unwrap_or(0).max(0); - let p = db::SearchParams { query, cats, limit, offset }; + let p = db::SearchParams { + query: query_text, + cats, + limit, + offset, + tvdb_id: params.tvdbid.clone(), + imdb_id: params.imdbid.clone(), + tmdb_id: params.tmdbid.clone(), + season: params.season, + episode: params.ep, + }; let rows = match db::search(&state.pool, &p).await { Ok(r) => r, @@ -45,7 +76,7 @@ pub async fn search_handler( let trackers = db::get_trackers(&state.pool, &row.event_id) .await .unwrap_or_default(); - items.push(build_item(&state.cfg.server.base_url, &row, &trackers)); + items.push(build_item(&row, &trackers)); } let feed = Rss { @@ -69,9 +100,51 @@ pub async fn search_handler( xml_response(200, &feed) } -fn build_item(_base_url: &str, row: &TorrentRow, trackers: &[String]) -> RssItem { - let magnet = build_magnet(&row.info_hash, &row.title, trackers); +fn build_query_text(params: &SearchQuery, search_type: &str) -> String { + let mut parts: Vec = vec![]; + if let Some(ref q) = params.q { + let q = q.trim(); + if !q.is_empty() { + parts.push(q.to_owned()); + } + } + + match search_type { + "music" | "audio" => { + if let Some(ref a) = params.artist { + let a = a.trim(); + if !a.is_empty() { parts.push(a.to_owned()); } + } + if let Some(ref b) = params.album { + let b = b.trim(); + if !b.is_empty() { parts.push(b.to_owned()); } + } + } + "book" => { + if let Some(ref a) = params.author { + let a = a.trim(); + if !a.is_empty() { parts.push(a.to_owned()); } + } + } + _ => {} + } + + parts.join(" ") +} + +fn default_cats(search_type: &str) -> Vec { + match search_type { + "tvsearch" => vec![5000], + "movie" => vec![2000], + "music" | "audio" => vec![3000], + "book" => vec![7000], + _ => vec![], + } +} + +fn build_item(row: &TorrentRow, trackers: &[String]) -> RssItem { + let magnet = build_magnet(&row.info_hash, &row.title, trackers); let guid = format!("kindexr:{}", row.event_id); let pub_date = DateTime::::from_timestamp(row.created_at, 0) @@ -95,17 +168,17 @@ fn build_item(_base_url: &str, row: &TorrentRow, trackers: &[String]) -> RssItem if let Some(cat) = row.newznab_cat { let parent = (cat / 1000) * 1000; attrs.push(TorznabAttr { name: "category".into(), value: cat.to_string() }); - attrs.push(TorznabAttr { name: "category".into(), value: parent.to_string() }); + if parent != cat { + attrs.push(TorznabAttr { name: "category".into(), value: parent.to_string() }); + } } if let Some(ref imdb) = row.imdb_id { - // Strip leading "tt" — *arr expects bare digits. let bare = imdb.trim_start_matches("tt"); attrs.push(TorznabAttr { name: "imdbid".into(), value: bare.to_owned() }); } if let Some(ref tmdb) = row.tmdb_id { - // "movie:12345" or "tv:67890" — extract the ID part. if let Some(id) = tmdb.splitn(2, ':').nth(1) { attrs.push(TorznabAttr { name: "tmdbid".into(), value: id.to_owned() }); } @@ -115,6 +188,13 @@ fn build_item(_base_url: &str, row: &TorrentRow, trackers: &[String]) -> RssItem attrs.push(TorznabAttr { name: "tvdbid".into(), value: tvdb.clone() }); } + if let Some(s) = row.season { + attrs.push(TorznabAttr { name: "season".into(), value: s.to_string() }); + } + if let Some(e) = row.episode { + attrs.push(TorznabAttr { name: "episode".into(), value: e.to_string() }); + } + RssItem { title: row.title.clone(), guid: RssGuid { is_perma_link: "false".into(), value: guid }, @@ -134,9 +214,7 @@ fn build_magnet(info_hash: &str, title: &str, trackers: &[String]) -> String { let mut s = format!("magnet:?xt=urn:btih:{info_hash}"); if !title.is_empty() { s.push_str("&dn="); - s.push_str( - &form_urlencoded::byte_serialize(title.as_bytes()).collect::(), - ); + s.push_str(&form_urlencoded::byte_serialize(title.as_bytes()).collect::()); } for tr in trackers { s.push_str("&tr="); @@ -149,7 +227,5 @@ fn parse_cats(s: &str) -> Vec { if s.is_empty() { return vec![]; } - s.split(',') - .filter_map(|p| p.trim().parse::().ok()) - .collect() + s.split(',').filter_map(|p| p.trim().parse::().ok()).collect() } diff --git a/src/torznab/server.rs b/src/torznab/server.rs index 94fe50e..1b8ce7f 100644 --- a/src/torznab/server.rs +++ b/src/torznab/server.rs @@ -1,58 +1,34 @@ use axum::{ body::Body, - extract::{Query, Request, State}, + extract::{Query, State}, http::header, - middleware::{self, Next}, + middleware::{Next, from_fn_with_state}, response::Response, routing::get, Router, }; -use serde::Deserialize; +use axum::extract::Request; use serde::Serialize; use crate::{db, AppState}; -use super::{caps::caps_handler, search::search_handler, xml::ErrorResponse}; +use super::{caps::caps_handler, search::{search_handler, SearchQuery}, xml::ErrorResponse}; pub fn router(state: AppState) -> Router { Router::new().route( "/api", - get(api_handler).layer(middleware::from_fn_with_state( - state.clone(), - auth_middleware, - )), + get(api_handler).layer(from_fn_with_state(state.clone(), auth_middleware)), ) } -#[derive(Deserialize)] -struct ApiQuery { - t: Option, -} - async fn api_handler( State(state): State, - Query(params): Query, - req: Request, + Query(params): Query, ) -> Response { match params.t.as_deref() { Some("caps") => caps_handler(State(state)).await, Some("search" | "tvsearch" | "movie" | "music" | "audio" | "book") => { - // Extract inner query params for search handler. - let uri = req.uri().clone(); - let new_req = Request::builder() - .uri(uri) - .extension(state.clone()) - .body(Body::empty()) - .unwrap(); - let (parts, _) = new_req.into_parts(); - let _query_str = parts.uri.query().unwrap_or(""); - let search_params = axum::extract::Query::::try_from_uri( - &parts.uri, - ); - match search_params { - Ok(p) => search_handler(State(state), p).await, - Err(_) => xml_error(400, 201, "invalid search params"), - } + search_handler(State(state), Query(params)).await } _ => xml_error(400, 202, "unknown function"), }