phase 2: type-specific search, title parser, TMDB enrichment

- tvsearch/movie/music/book endpoints with ID filters (tvdbid, imdbid,
  tmdbid), season/ep, and type-specific default category restrictions
- Title parser (enrich/parser.rs) extracts season, episode, quality,
  source from scene-format names; fills DB fields not supplied by tags
- TMDB enricher (enrich/tmdb.rs) runs as background task after insert
  when no TMDB ID in event; uses reqwest against TMDB v3 API directly
- Adult content exclusion at ingest via curation.exclude_categories
- Simplified server.rs routing: t= param now part of SearchQuery
- Remove unused tmdb-api alpha crate; use reqwest directly
This commit is contained in:
2026-05-17 02:38:28 -07:00
parent 57cda1281b
commit c8005d514d
12 changed files with 451 additions and 99 deletions
+63 -4
View File
@@ -142,14 +142,24 @@ pub struct SearchParams {
pub cats: Vec<i32>,
pub limit: i64,
pub offset: i64,
pub tvdb_id: Option<String>,
pub imdb_id: Option<String>,
pub tmdb_id: Option<String>,
pub season: Option<i32>,
pub episode: Option<i32>,
}
pub async fn search(pool: &SqlitePool, p: &SearchParams) -> anyhow::Result<Vec<TorrentRow>> {
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<TorrentRow> = if !p.query.is_empty() {
let rows: Vec<TorrentRow> = 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<T
}
q.bind(limit).bind(p.offset).fetch_all(pool).await?
} else {
// Direct table scan, with optional FTS subquery and ID/season filters
let mut conditions: Vec<String> = 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);
+2
View File
@@ -0,0 +1,2 @@
pub mod parser;
pub mod tmdb;
+133
View File
@@ -0,0 +1,133 @@
use std::sync::OnceLock;
use regex::Regex;
pub struct ParsedTitle {
pub clean: String,
pub season: Option<i32>,
pub episode: Option<i32>,
pub year: Option<i32>,
pub quality: Option<String>,
pub source: Option<String>,
}
static SE_RE: OnceLock<Regex> = OnceLock::new();
static ALT_SE_RE: OnceLock<Regex> = OnceLock::new();
static YEAR_RE: OnceLock<Regex> = OnceLock::new();
static QUALITY_RE: OnceLock<Regex> = OnceLock::new();
static SOURCE_RE: OnceLock<Regex> = 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::<Vec<_>>().join(" ");
ParsedTitle { clean, season, episode, year, quality, source }
}
fn extract_season_ep(s: &str) -> (Option<i32>, Option<i32>) {
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<i32> {
year_re()
.captures_iter(s)
.filter_map(|c| c.get(1)?.as_str().parse::<i32>().ok())
.find(|&y| y >= 1900 && y <= 2099)
}
fn find_cut(s: &str, season: Option<i32>, 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);
}
}
+82
View File
@@ -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<TmdbHit>,
}
#[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<i32>, newznab_cat: Option<i32>) {
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<i32>, year_param: &str) -> Result<Option<i64>> {
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))
}
}
+1
View File
@@ -1,5 +1,6 @@
pub mod config;
pub mod db;
pub mod enrich;
pub mod nostr;
pub mod torznab;
+3 -2
View File
@@ -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()
+20
View File
@@ -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<regex::Regex> = std::sync::OnceLock::new();
@@ -115,6 +116,25 @@ pub fn parse(event: &Event) -> anyhow::Result<TorrentRecord> {
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);
+48 -20
View File
@@ -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<Config>,
pool: SqlitePool,
connected: Arc<AtomicI32>,
enricher: Arc<Enricher>,
}
impl Reader {
pub fn new(cfg: Arc<Config>, pool: SqlitePool, connected: Arc<AtomicI32>) -> Self {
Reader { cfg, pool, connected }
pub fn new(cfg: Arc<Config>, pool: SqlitePool, connected: Arc<AtomicI32>, enricher: Arc<Enricher>) -> 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<Config>, pool: SqlitePool, connected: Arc<AtomicI32>) {
async fn run_reader(cfg: Arc<Config>, pool: SqlitePool, connected: Arc<AtomicI32>, enricher: Arc<Enricher>) {
if cfg.relays.is_empty() {
warn!("no relays configured; reader idle");
return;
@@ -44,7 +46,6 @@ async fn run_reader(cfg: Arc<Config>, pool: SqlitePool, connected: Arc<AtomicI32
for relay_url in &cfg.relays {
match client.add_relay(relay_url.as_str()).await {
Ok(_) => {
// 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<Config>, pool: SqlitePool, connected: Arc<AtomicI32
connected.store(cfg.relays.len() as i32, Ordering::Relaxed);
info!("relay pool connected ({} relays)", cfg.relays.len());
// Determine subscription since timestamp.
let backfill_since = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
@@ -63,10 +63,7 @@ async fn run_reader(cfg: Arc<Config>, pool: SqlitePool, connected: Arc<AtomicI32
.saturating_sub(cfg.backfill_days as u64 * 86400);
let since = Timestamp::from(backfill_since);
let filter = Filter::new()
.kind(Kind::Custom(2003))
.since(since);
let filter = Filter::new().kind(Kind::Custom(2003)).since(since);
if let Err(e) = client.subscribe(filter, None).await {
error!("subscribe failed: {e}");
@@ -76,22 +73,23 @@ async fn run_reader(cfg: Arc<Config>, pool: SqlitePool, connected: Arc<AtomicI32
info!("subscribed to kind 2003 events since unix={backfill_since}");
// Handle incoming events.
let pool_clone = pool.clone();
if let Err(e) = client
.handle_notifications(|notification| {
let pool = pool_clone.clone();
let cfg = cfg.clone();
let enricher = enricher.clone();
async move {
match notification {
RelayPoolNotification::Event { event, .. } => {
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<Config>, pool: SqlitePool, connected: Arc<AtomicI32
connected.store(0, Ordering::Relaxed);
}
async fn handle_event(pool: &SqlitePool, event: &nostr_sdk::Event) {
async fn handle_event(pool: &SqlitePool, cfg: &Config, enricher: &Enricher, event: &nostr_sdk::Event) {
match super::parser::parse(event) {
Ok(rec) => {
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) => {
+91 -15
View File
@@ -15,22 +15,53 @@ use super::{server::xml_response, xml::*};
#[derive(Deserialize)]
pub struct SearchQuery {
pub t: Option<String>,
pub q: Option<String>,
pub cat: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
// tvsearch / movie ID filters
pub tvdbid: Option<String>,
pub imdbid: Option<String>,
pub tmdbid: Option<String>,
pub season: Option<i32>,
pub ep: Option<i32>,
// music / audio
pub artist: Option<String>,
pub album: Option<String>,
pub year: Option<i32>,
// book
pub author: Option<String>,
}
pub async fn search_handler(
State(state): State<AppState>,
Query(params): Query<SearchQuery>,
) -> 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(&params, 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<String> = 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<i32> {
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::<Utc>::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::<String>(),
);
s.push_str(&form_urlencoded::byte_serialize(title.as_bytes()).collect::<String>());
}
for tr in trackers {
s.push_str("&tr=");
@@ -149,7 +227,5 @@ fn parse_cats(s: &str) -> Vec<i32> {
if s.is_empty() {
return vec![];
}
s.split(',')
.filter_map(|p| p.trim().parse::<i32>().ok())
.collect()
s.split(',').filter_map(|p| p.trim().parse::<i32>().ok()).collect()
}
+7 -31
View File
@@ -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<AppState> {
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<String>,
}
async fn api_handler(
State(state): State<AppState>,
Query(params): Query<ApiQuery>,
req: Request,
Query(params): Query<SearchQuery>,
) -> 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::<super::search::SearchQuery>::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"),
}