mod parser;
use crate::parser::{parse_files, parse_metadata, sabnzbd_is_obfuscated, sanitize_xml};
use chrono::{DateTime, Utc};
use itertools::Itertools;
use lazy_regex::regex;
use roxmltree::Document;
use std::path::Path;
use std::str::FromStr;
use thiserror::Error;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Error, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[error("{message}")]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct InvalidNZBError {
message: String,
}
impl InvalidNZBError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Meta {
pub title: Option<String>,
pub passwords: Vec<String>,
pub tags: Vec<String>,
pub category: Option<String>,
}
impl Meta {
pub fn new(
title: Option<impl Into<String>>,
passwords: impl IntoIterator<Item = impl Into<String>>,
tags: impl IntoIterator<Item = impl Into<String>>,
category: Option<impl Into<String>>,
) -> Self {
Self {
title: title.map(Into::into),
passwords: passwords.into_iter().map(Into::into).collect(),
tags: tags.into_iter().map(Into::into).collect(),
category: category.map(Into::into),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Segment {
pub size: u32,
pub number: u32,
pub message_id: String,
}
impl Segment {
pub fn new(size: impl Into<u32>, number: impl Into<u32>, message_id: impl Into<String>) -> Self {
Self {
size: size.into(),
number: number.into(),
message_id: message_id.into(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct File {
pub poster: String,
pub datetime: DateTime<Utc>,
pub subject: String,
pub groups: Vec<String>,
pub segments: Vec<Segment>,
}
impl File {
pub fn new(
poster: impl Into<String>,
datetime: impl Into<DateTime<Utc>>,
subject: impl Into<String>,
groups: impl IntoIterator<Item = impl Into<String>>,
segments: impl IntoIterator<Item = Segment>,
) -> Self {
Self {
poster: poster.into(),
datetime: datetime.into(),
subject: subject.into(),
groups: groups.into_iter().map(Into::into).collect(),
segments: segments.into_iter().collect(),
}
}
pub fn size(&self) -> u64 {
self.segments.iter().map(|x| u64::from(x.size)).sum::<u64>()
}
pub fn name(&self) -> Option<&str> {
let re_subject_filename_quotes = regex!(r#""([^"]*)""#);
let re_subject_basic_filename =
regex!(r"\b([\w\-+()' .,]+(?:\[[\w\-/+()' .,]*][\w\-+()' .,]*)*\.[A-Za-z0-9]{2,4})\b");
if let Some(captured) = re_subject_filename_quotes.captures(&self.subject) {
return captured.get(1).map(|m| m.as_str().trim());
}
if let Some(captured) = re_subject_basic_filename.captures(&self.subject) {
return captured.get(1).map(|m| m.as_str().trim());
}
None
}
pub fn stem(&self) -> Option<&str> {
if let Some(name) = self.name() {
return Path::new(name).file_stem().map(|f| f.to_str())?;
}
None
}
pub fn extension(&self) -> Option<&str> {
if let Some(name) = self.name() {
return Path::new(name).extension().map(|f| f.to_str())?;
}
None
}
pub fn is_par2(&self) -> bool {
let re = regex!(r"\.par2$"i);
self.name().is_some_and(|name| re.is_match(name))
}
pub fn is_rar(&self) -> bool {
let re = regex!(r"(\.rar|\.r\d\d|\.s\d\d|\.t\d\d|\.u\d\d|\.v\d\d)$"i);
self.name().is_some_and(|name| re.is_match(name))
}
pub fn is_obfuscated(&self) -> bool {
if let Some(stem) = self.stem() {
return sabnzbd_is_obfuscated(stem);
}
true
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct NZB {
pub meta: Meta,
pub files: Vec<File>,
}
impl FromStr for NZB {
type Err = InvalidNZBError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let xml = sanitize_xml(s);
let nzb = match Document::parse(xml) {
Ok(doc) => doc,
Err(err) => return Err(InvalidNZBError::new(err.to_string())),
};
let meta = parse_metadata(&nzb);
let files = match parse_files(&nzb) {
Ok(files) => files,
Err(err) => return Err(InvalidNZBError::new(err.to_string())),
};
Ok(NZB { meta, files })
}
}
impl NZB {
pub fn parse(xml: impl AsRef<str>) -> Result<Self, InvalidNZBError> {
Self::from_str(xml.as_ref())
}
pub fn file(&self) -> &File {
self.files.iter().max_by_key(|file| file.size()).unwrap()
}
pub fn size(&self) -> u64 {
self.files.iter().map(|file| file.size()).sum()
}
pub fn filenames(&self) -> Vec<&str> {
self.files.iter().filter_map(|f| f.name()).unique().sorted().collect()
}
pub fn filestems(&self) -> Vec<&str> {
self.files.iter().filter_map(|f| f.stem()).unique().sorted().collect()
}
pub fn extensions(&self) -> Vec<&str> {
self.files
.iter()
.filter_map(|f| f.extension())
.unique()
.sorted()
.collect()
}
pub fn posters(&self) -> Vec<&str> {
self.files.iter().map(|f| f.poster.as_str()).unique().sorted().collect()
}
pub fn groups(&self) -> Vec<&str> {
self.files
.iter()
.flat_map(|f| f.groups.iter().map(|f| f.as_str()))
.unique()
.sorted()
.collect()
}
pub fn par2_size(&self) -> u64 {
self.files.iter().filter(|f| f.is_par2()).map(|file| file.size()).sum()
}
pub fn par2_percentage(&self) -> f64 {
(self.par2_size() as f64 / self.size() as f64) * 100.0
}
pub fn has_par2(&self) -> bool {
self.files.iter().any(|file| file.is_par2())
}
pub fn has_rar(&self) -> bool {
self.files.iter().any(|file| file.is_rar())
}
pub fn is_rar(&self) -> bool {
self.files.iter().all(|file| file.is_rar())
}
pub fn is_obfuscated(&self) -> bool {
self.files.iter().any(|file| file.is_obfuscated())
}
}