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:
+63
-4
@@ -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);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod parser;
|
||||
pub mod tmdb;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,5 +1,6 @@
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod enrich;
|
||||
pub mod nostr;
|
||||
pub mod torznab;
|
||||
|
||||
|
||||
+3
-2
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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(¶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<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
@@ -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"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user