Files
kindexr/src/nostr/parser.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

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();
}
}