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
172 lines
5.1 KiB
Rust
172 lines
5.1 KiB
Rust
use anyhow::bail;
|
|
use nostr::JsonUtil;
|
|
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();
|
|
|
|
fn info_hash_re() -> &'static regex::Regex {
|
|
INFO_HASH_RE.get_or_init(|| regex::Regex::new(r"^[0-9a-fA-F]{40}$").unwrap())
|
|
}
|
|
|
|
/// Parse a kind 2003 Nostr event into a TorrentRecord ready for storage.
|
|
pub fn parse(event: &Event) -> anyhow::Result<TorrentRecord> {
|
|
// nostr-sdk validates signatures before we receive events; no need to re-validate here.
|
|
if event.kind.as_u16() != 2003 {
|
|
bail!("wrong kind: {}", event.kind.as_u16());
|
|
}
|
|
|
|
let now = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs() as i64;
|
|
|
|
let mut rec = TorrentRecord {
|
|
event_id: event.id.to_hex(),
|
|
info_hash: String::new(),
|
|
pubkey: event.pubkey.to_hex(),
|
|
created_at: event.created_at.as_secs() as i64,
|
|
ingested_at: now,
|
|
title: String::new(),
|
|
description: String::new(),
|
|
size_bytes: None,
|
|
category: String::new(),
|
|
newznab_cat: None,
|
|
imdb_id: String::new(),
|
|
tmdb_id: String::new(),
|
|
tvdb_id: String::new(),
|
|
season: None,
|
|
episode: None,
|
|
quality: String::new(),
|
|
source: String::new(),
|
|
raw_event: event.as_json(),
|
|
trackers: vec![],
|
|
tags: vec![],
|
|
files: vec![],
|
|
};
|
|
|
|
let mut total_size: i64 = 0;
|
|
let mut has_size = false;
|
|
let mut tcat_cat: Option<i32> = None;
|
|
let mut newznab_cat: Option<i32> = None;
|
|
let mut tcat_str = String::new();
|
|
|
|
for tag in event.tags.iter() {
|
|
let tag_vec = tag.as_slice();
|
|
if tag_vec.len() < 2 {
|
|
continue;
|
|
}
|
|
match tag_vec[0].as_str() {
|
|
"x" => {
|
|
let hash = tag_vec[1].to_lowercase();
|
|
if !info_hash_re().is_match(&hash) {
|
|
bail!("invalid info_hash: {:?}", tag_vec[1]);
|
|
}
|
|
rec.info_hash = hash;
|
|
}
|
|
"title" => {
|
|
rec.title = tag_vec[1].clone();
|
|
}
|
|
"file" => {
|
|
let path = tag_vec[1].clone();
|
|
let sz = tag_vec.get(2).and_then(|s| s.parse::<i64>().ok());
|
|
if let Some(s) = sz {
|
|
total_size += s;
|
|
has_size = true;
|
|
}
|
|
rec.files.push(FileRecord { path, size_bytes: sz });
|
|
}
|
|
"tracker" => {
|
|
rec.trackers.push(tag_vec[1].clone());
|
|
}
|
|
"i" => {
|
|
parse_i_tag(&tag_vec[1], &mut rec, &mut tcat_cat, &mut newznab_cat, &mut tcat_str);
|
|
}
|
|
"t" => {
|
|
rec.tags.push(tag_vec[1].clone());
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
if rec.info_hash.is_empty() {
|
|
bail!("missing x (info_hash) tag");
|
|
}
|
|
|
|
// newznab: overrides tcat:
|
|
rec.newznab_cat = newznab_cat.or(tcat_cat);
|
|
if !tcat_str.is_empty() {
|
|
rec.category = tcat_str;
|
|
}
|
|
|
|
// Title fallback: first line of content.
|
|
if rec.title.is_empty() {
|
|
rec.title = event.content
|
|
.split('\n')
|
|
.next()
|
|
.unwrap_or("")
|
|
.trim()
|
|
.to_owned();
|
|
}
|
|
if rec.title.is_empty() {
|
|
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);
|
|
}
|
|
|
|
Ok(rec)
|
|
}
|
|
|
|
fn parse_i_tag(
|
|
value: &str,
|
|
rec: &mut TorrentRecord,
|
|
tcat_cat: &mut Option<i32>,
|
|
newznab_cat: &mut Option<i32>,
|
|
tcat_str: &mut String,
|
|
) {
|
|
if let Some(rest) = value.strip_prefix("newznab:") {
|
|
if let Ok(cat) = rest.parse::<i32>() {
|
|
*newznab_cat = Some(cat);
|
|
}
|
|
} else if let Some(rest) = value.strip_prefix("tcat:") {
|
|
*tcat_str = rest.to_owned();
|
|
if tcat_cat.is_none() {
|
|
*tcat_cat = Some(tcat_to_newznab(rest));
|
|
}
|
|
} else if let Some(rest) = value.strip_prefix("imdb:") {
|
|
rec.imdb_id = rest.to_owned();
|
|
} else if let Some(rest) = value.strip_prefix("tmdb:movie:") {
|
|
rec.tmdb_id = format!("movie:{rest}");
|
|
} else if let Some(rest) = value.strip_prefix("tmdb:tv:") {
|
|
rec.tmdb_id = format!("tv:{rest}");
|
|
} else if let Some(rest) = value.strip_prefix("tvdb:") {
|
|
rec.tvdb_id = rest.to_owned();
|
|
}
|
|
}
|