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 = 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 { // 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 = None; let mut newznab_cat: Option = 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::().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, newznab_cat: &mut Option, tcat_str: &mut String, ) { if let Some(rest) = value.strip_prefix("newznab:") { if let Ok(cat) = rest.parse::() { *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(); } }