#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Book {
pub md5: String,
pub title: String,
pub authors: Option<String>,
pub publisher: Option<String>,
pub year: Option<String>,
pub language: Option<String>,
pub pages: Option<String>,
pub size_bytes: Option<u64>,
pub extension: Option<String>,
pub file_id: Option<String>,
}
impl Book {
pub fn ext(&self) -> &str {
match self.extension.as_deref() {
Some(e) if !e.is_empty() => e,
_ => "bin",
}
}
pub fn authors_or_unknown(&self) -> &str {
match self.authors.as_deref() {
Some(a) if !a.trim().is_empty() => a,
_ => "Unknown author",
}
}
pub fn size_human(&self) -> String {
match self.size_bytes {
Some(b) => human_bytes(b),
None => String::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Topic {
Libgen,
Fiction,
Comics,
Magazines,
Standards,
RussianFiction,
}
impl Topic {
pub fn code(self) -> &'static str {
match self {
Topic::Libgen => "l",
Topic::Fiction => "f",
Topic::Comics => "c",
Topic::Magazines => "m",
Topic::Standards => "s",
Topic::RussianFiction => "r",
}
}
pub fn parse(s: &str) -> Option<Self> {
Some(match s.trim().to_ascii_lowercase().as_str() {
"libgen" | "scitech" | "sci-tech" | "nonfiction" | "l" => Topic::Libgen,
"fiction" | "f" => Topic::Fiction,
"comics" | "c" => Topic::Comics,
"magazines" | "magazine" | "m" => Topic::Magazines,
"standards" | "s" => Topic::Standards,
"russian" | "russian-fiction" | "r" => Topic::RussianFiction,
_ => return None,
})
}
pub fn name(self) -> &'static str {
match self {
Topic::Libgen => "libgen",
Topic::Fiction => "fiction",
Topic::Comics => "comics",
Topic::Magazines => "magazines",
Topic::Standards => "standards",
Topic::RussianFiction => "russian",
}
}
pub const ALL_NAMES: &'static str = "libgen, fiction, comics, magazines, standards, russian";
pub const ALL: [Topic; 6] = [
Topic::Libgen,
Topic::Fiction,
Topic::Comics,
Topic::Magazines,
Topic::Standards,
Topic::RussianFiction,
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Field {
Title,
Author,
Series,
Publisher,
Year,
Isbn,
}
impl Field {
pub fn code(self) -> &'static str {
match self {
Field::Title => "t",
Field::Author => "a",
Field::Series => "s",
Field::Publisher => "p",
Field::Year => "y",
Field::Isbn => "i",
}
}
}
#[derive(Debug, Clone)]
pub struct SearchQuery {
pub terms: String,
pub fields: Vec<Field>,
pub topics: Vec<Topic>,
pub limit: usize,
pub extension: Option<String>,
pub language: Option<String>,
}
impl SearchQuery {
pub fn new(terms: impl Into<String>) -> Self {
Self {
terms: terms.into(),
fields: Vec::new(),
topics: vec![Topic::Libgen, Topic::Fiction],
limit: 25,
extension: None,
language: None,
}
}
pub fn page_size(&self) -> usize {
let wanted = if self.extension.is_some() || self.language.is_some() {
self.limit.saturating_mul(4).max(50)
} else {
self.limit
};
match wanted {
0..=25 => 25,
26..=50 => 50,
_ => 100,
}
}
pub fn matches(&self, book: &Book) -> bool {
if let Some(want) = &self.extension {
let got = book.extension.as_deref().unwrap_or_default();
if !got.eq_ignore_ascii_case(want) {
return false;
}
}
if let Some(want) = &self.language {
let got = book.language.as_deref().unwrap_or_default();
if !got.to_lowercase().contains(&want.to_lowercase()) {
return false;
}
}
true
}
}
pub fn human_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
if bytes < 1024 {
return format!("{bytes} B");
}
let mut value = bytes as f64;
let mut unit = 0usize;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if value >= 100.0 {
format!("{value:.0} {}", UNITS[unit])
} else if value >= 10.0 {
format!("{value:.1} {}", UNITS[unit])
} else {
format!("{value:.2} {}", UNITS[unit])
}
}
pub fn parse_size(text: &str) -> Option<u64> {
let text = text.trim();
if text.is_empty() {
return None;
}
let digits_end = text
.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == ','))
.unwrap_or(text.len());
let (number, suffix) = text.split_at(digits_end);
let number: f64 = number.replace(',', ".").parse().ok()?;
let multiplier = match suffix.trim().to_ascii_lowercase().as_str() {
"" | "b" => 1.0,
"k" | "kb" | "kib" => 1024.0,
"m" | "mb" | "mib" => 1024.0 * 1024.0,
"g" | "gb" | "gib" => 1024.0 * 1024.0 * 1024.0,
"t" | "tb" | "tib" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
_ => return None,
};
let bytes = number * multiplier;
if bytes.is_finite() && bytes >= 0.0 {
Some(bytes as u64)
} else {
None
}
}
pub fn is_md5(s: &str) -> bool {
s.len() == 32 && s.bytes().all(|b| b.is_ascii_hexdigit())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_libgen_size_strings() {
assert_eq!(parse_size("19 kB"), Some(19 * 1024));
assert_eq!(parse_size("700 B"), Some(700));
assert_eq!(parse_size("2 GB"), Some(2 * 1024 * 1024 * 1024));
assert_eq!(parse_size(""), None);
assert_eq!(parse_size("unknown"), None);
assert_eq!(parse_size("1,5 MB"), Some(1_572_864));
}
#[test]
fn formats_bytes_readably() {
assert_eq!(human_bytes(512), "512 B");
assert_eq!(human_bytes(19 * 1024), "19.0 KB");
assert_eq!(human_bytes(1024 * 1024), "1.00 MB");
}
#[test]
fn validates_md5_keys() {
assert!(is_md5("1b9159991f7fb1b3910c0be9ebf7e595"));
assert!(!is_md5("1b9159991f7fb1b3910c0be9ebf7e59"));
assert!(!is_md5("zzzz59991f7fb1b3910c0be9ebf7e595"));
assert!(!is_md5(""));
}
#[test]
fn client_side_filters_apply() {
let book = Book {
md5: "1b9159991f7fb1b3910c0be9ebf7e595".into(),
title: "T".into(),
language: Some("English".into()),
extension: Some("epub".into()),
..Default::default()
};
let mut q = SearchQuery::new("x");
q.extension = Some("epub".into());
assert!(q.matches(&book));
q.extension = Some("pdf".into());
assert!(!q.matches(&book));
q.extension = None;
q.language = Some("eng".into());
assert!(q.matches(&book));
q.language = Some("french".into());
assert!(!q.matches(&book));
}
#[test]
fn over_fetches_when_filtering_client_side() {
let mut q = SearchQuery::new("x");
q.limit = 25;
assert_eq!(q.page_size(), 25);
q.extension = Some("epub".into());
assert_eq!(q.page_size(), 100);
}
#[test]
fn topic_names_round_trip() {
assert_eq!(Topic::parse("fiction"), Some(Topic::Fiction));
assert_eq!(Topic::parse("SCI-TECH"), Some(Topic::Libgen));
assert_eq!(Topic::parse("nope"), None);
}
}