c8005d514d
- 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
232 lines
6.6 KiB
Rust
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(¶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: 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()
|
|
}
|