Files
kindexr/src/torznab/search.rs
T
enki c8005d514d 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
2026-05-17 02:38:28 -07:00

232 lines
6.6 KiB
Rust

use axum::{
extract::{Query, State},
response::Response,
};
use chrono::{DateTime, Utc};
use serde::Deserialize;
use url::form_urlencoded;
use crate::{
db::{self, TorrentRow},
AppState,
};
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 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: 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,
Err(e) => {
tracing::error!("search error: {e}");
return super::server::xml_error(500, 200, "search error");
}
};
let mut items = Vec::with_capacity(rows.len());
for row in rows {
let trackers = db::get_trackers(&state.pool, &row.event_id)
.await
.unwrap_or_default();
items.push(build_item(&row, &trackers));
}
let feed = Rss {
version: "2.0".into(),
xmlns_atom: "http://www.w3.org/2005/Atom".into(),
xmlns_torznab: "http://torznab.com/schemas/2015/feed".into(),
channel: RssChannel {
atom_link: AtomLink {
rel: "self".into(),
type_: "application/rss+xml".into(),
},
title: "kindexr".into(),
description: "Nostr NIP-35 torrent index".into(),
link: state.cfg.server.base_url.clone(),
language: "en-us".into(),
category: "search".into(),
items,
},
};
xml_response(200, &feed)
}
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)
.unwrap_or_default()
.format("%a, %d %b %Y %H:%M:%S +0000")
.to_string();
let size_bytes = row.size_bytes.unwrap_or(0);
let mut attrs = vec![
TorznabAttr { name: "infohash".into(), value: row.info_hash.clone() },
TorznabAttr { name: "magneturl".into(), value: magnet.clone() },
TorznabAttr { name: "downloadvolumefactor".into(), value: "0".into() },
TorznabAttr { name: "uploadvolumefactor".into(), value: "1".into() },
];
if let Some(sz) = row.size_bytes {
attrs.push(TorznabAttr { name: "size".into(), value: sz.to_string() });
}
if let Some(cat) = row.newznab_cat {
let parent = (cat / 1000) * 1000;
attrs.push(TorznabAttr { name: "category".into(), value: cat.to_string() });
if parent != cat {
attrs.push(TorznabAttr { name: "category".into(), value: parent.to_string() });
}
}
if let Some(ref imdb) = row.imdb_id {
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 {
if let Some(id) = tmdb.splitn(2, ':').nth(1) {
attrs.push(TorznabAttr { name: "tmdbid".into(), value: id.to_owned() });
}
}
if let Some(ref tvdb) = row.tvdb_id {
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 },
link: magnet.clone(),
pub_date,
size: size_bytes,
enclosure: RssEnclosure {
url: magnet,
length: size_bytes,
type_: "application/x-bittorrent".into(),
},
attrs,
}
}
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>());
}
for tr in trackers {
s.push_str("&tr=");
s.push_str(&form_urlencoded::byte_serialize(tr.as_bytes()).collect::<String>());
}
s
}
fn parse_cats(s: &str) -> Vec<i32> {
if s.is_empty() {
return vec![];
}
s.split(',').filter_map(|p| p.trim().parse::<i32>().ok()).collect()
}