use std::path::PathBuf;
use ureq::http::Uri;
use crate::error::{Context, Result};
use crate::graphics::Image;
use crate::model::Book;
use crate::net::{Http, join_uri};
use crate::{bail, jpeg, libgen, net};
const MAX_COVER_BYTES: u64 = 4 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cover {
pub encoded: Vec<u8>,
pub kind: Kind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Jpeg,
Png,
Other,
}
impl Cover {
pub fn pixels(&self) -> Option<Image> {
match self.kind {
Kind::Jpeg => jpeg::decode(&self.encoded).ok(),
_ => None,
}
}
pub fn from_bytes(bytes: Vec<u8>) -> Option<Cover> {
Self::classify(bytes)
}
fn classify(bytes: Vec<u8>) -> Option<Cover> {
let kind = if jpeg::is_jpeg(&bytes) {
Kind::Jpeg
} else if jpeg::is_png(&bytes) {
Kind::Png
} else if bytes.starts_with(b"GIF8")
|| (bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP"))
{
Kind::Other
} else {
return None;
};
Some(Cover {
encoded: bytes,
kind,
})
}
fn extension(&self) -> &'static str {
match self.kind {
Kind::Jpeg => "jpg",
Kind::Png => "png",
Kind::Other => "img",
}
}
}
pub fn fetch(http: &Http, base: &Uri, book: &Book) -> Result<Option<Cover>> {
if !crate::model::is_md5(&book.md5) {
bail!("a cover needs a valid MD5");
}
if let Some(cached) = from_cache(&book.md5) {
return Ok(cached);
}
let cover = match find_url(http, base, book)? {
Some((url, from)) if same_host(base, &url) => download(http, &url, &from)?,
Some(_) | None => None,
};
store(&book.md5, cover.as_ref());
Ok(cover)
}
fn find_url(http: &Http, base: &Uri, book: &Book) -> Result<Option<(Uri, Uri)>> {
let page_url = match &book.file_id {
Some(id) if id.chars().all(|c| c.is_ascii_digit()) && !id.is_empty() => {
join_uri(base, &format!("file.php?id={id}"))?
}
_ => libgen::ads_url(base, &book.md5)?,
};
let page = http.get_text(&page_url)?;
let Some(src) = pick_cover_src(&page) else {
return Ok(None);
};
Ok(Some((join_uri(base, &src)?, page_url)))
}
fn pick_cover_src(page: &str) -> Option<String> {
let images = crate::html::img_srcs(page);
let looks_like_cover = |src: &&String| {
let lower = src.to_lowercase();
lower.contains("/covers/") || lower.contains("cover")
};
let is_image_file = |src: &&String| {
let lower = src.to_lowercase();
[".jpg", ".jpeg", ".png", ".gif", ".webp"]
.iter()
.any(|ext| lower.contains(ext))
};
images
.iter()
.find(|src| looks_like_cover(src) && is_image_file(src))
.or_else(|| images.iter().find(looks_like_cover))
.cloned()
}
fn download(http: &Http, url: &Uri, from: &Uri) -> Result<Option<Cover>> {
let response = http.get_referred(url, from)?;
if !(200..300).contains(&response.status) {
return Ok(None);
}
if let Some(length) = response.content_length
&& length > MAX_COVER_BYTES
{
return Ok(None);
}
if response
.content_type
.as_deref()
.is_some_and(|ct| ct.to_ascii_lowercase().contains("text/html"))
{
return Ok(None);
}
let bytes = response
.body
.into_with_config()
.limit(MAX_COVER_BYTES)
.read_to_vec()
.with_context(|| format!("could not read cover response body from {url}"))?;
Ok(Cover::classify(bytes))
}
const MISSING: &str = "none";
fn cache_path(md5: &str, extension: &str) -> PathBuf {
crate::config::cover_cache_dir().join(format!("{md5}.{extension}"))
}
pub fn cached(md5: &str) -> Option<Option<Cover>> {
from_cache(md5)
}
fn from_cache(md5: &str) -> Option<Option<Cover>> {
if cache_path(md5, MISSING).exists() {
return Some(None);
}
for extension in ["jpg", "png", "img"] {
if let Ok(bytes) = std::fs::read(cache_path(md5, extension))
&& !bytes.is_empty()
&& let Some(cover) = Cover::classify(bytes)
{
return Some(Some(cover));
}
}
None
}
fn store(md5: &str, cover: Option<&Cover>) {
let dir = crate::config::cover_cache_dir();
if crate::config::ensure_dir(&dir).is_err() {
return;
}
match cover {
Some(cover) => {
let _ = std::fs::write(cache_path(md5, cover.extension()), &cover.encoded);
}
None => {
let _ = std::fs::write(cache_path(md5, MISSING), b"");
}
}
}
pub fn clear_cache() -> Result<()> {
let dir = crate::config::cover_cache_dir();
match std::fs::remove_dir_all(&dir) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(crate::err!("could not clear {}: {e}", dir.display())),
}
}
pub fn cache_size() -> (usize, u64) {
let Ok(entries) = std::fs::read_dir(crate::config::cover_cache_dir()) else {
return (0, 0);
};
let mut count = 0;
let mut bytes = 0;
for entry in entries.flatten() {
if let Ok(meta) = entry.metadata()
&& meta.is_file()
{
count += 1;
bytes += meta.len();
}
}
(count, bytes)
}
pub fn same_host(base: &Uri, image: &Uri) -> bool {
match (net::host_of(base), net::host_of(image)) {
(Ok(a), Ok(b)) => a.eq_ignore_ascii_case(&b),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
const PAGE: &str = r#"
<html><body>
<img src="/img/logo.png" alt="logo">
<img src="/templates/flags/en.gif">
<table><tr><td>
<img src="/covers/2649000/1b9159991f7fb1b3910c0be9ebf7e595.jpg" alt="cover">
</td></tr></table>
</body></html>
"#;
#[test]
fn the_cover_is_picked_out_of_the_page_furniture() {
assert_eq!(
pick_cover_src(PAGE).as_deref(),
Some("/covers/2649000/1b9159991f7fb1b3910c0be9ebf7e595.jpg")
);
}
#[test]
fn a_page_with_no_cover_says_so() {
assert_eq!(
pick_cover_src("<html><img src='/img/logo.png'></html>"),
None
);
assert_eq!(pick_cover_src("<html>nothing at all</html>"), None);
}
#[test]
fn a_cover_named_without_a_covers_directory_is_still_found() {
let page = "<img src='/thumbs/book-cover-123.jpeg'>";
assert_eq!(
pick_cover_src(page).as_deref(),
Some("/thumbs/book-cover-123.jpeg")
);
}
#[test]
fn image_signatures_decide_the_kind() {
assert_eq!(
Cover::classify(b"\xff\xd8\xff\xe0stuff".to_vec())
.unwrap()
.kind,
Kind::Jpeg
);
assert_eq!(
Cover::classify(b"\x89PNG\r\n\x1a\nstuff".to_vec())
.unwrap()
.kind,
Kind::Png
);
assert_eq!(
Cover::classify(b"GIF89a...".to_vec()).unwrap().kind,
Kind::Other
);
}
#[test]
fn a_web_page_is_not_an_image() {
assert!(Cover::classify(b"<html><body>captcha</body></html>".to_vec()).is_none());
assert!(Cover::classify(b"".to_vec()).is_none());
assert!(Cover::classify(b"\x7fELF".to_vec()).is_none());
}
#[test]
fn a_real_jpeg_cover_decodes_to_pixels() {
let cover =
Cover::classify(include_bytes!("../tests/fixtures/cover.jpg").to_vec()).unwrap();
assert_eq!(cover.kind, Kind::Jpeg);
let image = cover.pixels().expect("the fixture decodes");
assert_eq!((image.width, image.height), (24, 24));
}
#[test]
fn a_png_cover_is_kept_but_not_decoded_here() {
let cover = Cover::classify(b"\x89PNG\r\n\x1a\nnot really".to_vec()).unwrap();
assert_eq!(cover.extension(), "png");
assert!(cover.pixels().is_none());
}
#[test]
fn an_interrupted_cover_body_is_an_error_not_a_missing_cover() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut line = String::new();
while reader.read_line(&mut line).unwrap_or(0) > 0 {
if line == "\r\n" || line == "\n" {
break;
}
line.clear();
}
stream
.write_all(
b"HTTP/1.1 200 OK\r\nContent-Type: image/jpeg\r\n\
Content-Length: 100\r\nConnection: close\r\n\r\n\xff\xd8\xff",
)
.unwrap();
});
let http = Http::new(crate::net::NetPolicy {
allow_http: true,
allow_private_hosts: true,
..Default::default()
})
.unwrap();
let url: Uri = format!("http://127.0.0.1:{port}/cover.jpg")
.parse()
.unwrap();
let from: Uri = format!("http://127.0.0.1:{port}/record").parse().unwrap();
assert!(download(&http, &url, &from).is_err());
}
#[test]
#[ignore = "needs the network and a working mirror"]
fn covers_can_be_fetched_from_a_real_mirror() {
let policy = crate::net::NetPolicy::default();
let http = Http::new(policy).unwrap();
let pool = crate::mirror::resolve(
&policy,
crate::mirror::ResolveOptions {
explicit: &[],
refresh: false,
progress: &|_| {},
},
)
.expect("a mirror should be reachable");
let base = pool.mirrors().first().cloned().expect("a mirror");
let mut query = crate::model::SearchQuery::new("dune");
query.limit = 5;
let books = crate::libgen::search(&http, &base, &query).expect("a search");
assert!(!books.is_empty(), "the search found nothing to look up");
let mut found = 0;
for book in books.iter().take(3) {
match fetch(&http, &base, book).expect("the lookup should not error") {
Some(cover) => {
found += 1;
assert!(!cover.encoded.is_empty());
if cover.kind == Kind::Jpeg {
let image = cover.pixels().expect("a JPEG cover should decode");
assert!(image.width > 10 && image.height > 10, "{image:?}");
}
}
None => continue,
}
}
eprintln!("{found} of 3 records had a cover");
}
#[test]
fn hosts_are_compared_for_the_image_url() {
let base: Uri = "https://libgen.li/index.php".parse().unwrap();
assert!(same_host(
&base,
&"https://libgen.li/covers/x.jpg".parse().unwrap()
));
assert!(!same_host(
&base,
&"https://elsewhere.example/covers/x.jpg".parse().unwrap()
));
}
}