add indexing jobs, parsers, and search APIs

This commit is contained in:
2026-03-05 15:05:34 +01:00
parent 88db9805b5
commit 6eaf2ba5dc
17 changed files with 1548 additions and 46 deletions

View File

@@ -4,6 +4,8 @@ use anyhow::{Context, Result};
pub struct ApiConfig {
pub listen_addr: String,
pub database_url: String,
pub meili_url: String,
pub meili_master_key: String,
pub api_bootstrap_token: String,
}
@@ -12,6 +14,8 @@ impl ApiConfig {
Ok(Self {
listen_addr: std::env::var("API_LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string()),
database_url: std::env::var("DATABASE_URL").context("DATABASE_URL is required")?,
meili_url: std::env::var("MEILI_URL").context("MEILI_URL is required")?,
meili_master_key: std::env::var("MEILI_MASTER_KEY").context("MEILI_MASTER_KEY is required")?,
api_bootstrap_token: std::env::var("API_BOOTSTRAP_TOKEN")
.context("API_BOOTSTRAP_TOKEN is required")?,
})
@@ -21,14 +25,25 @@ impl ApiConfig {
#[derive(Debug, Clone)]
pub struct IndexerConfig {
pub listen_addr: String,
pub database_url: String,
pub meili_url: String,
pub meili_master_key: String,
pub scan_interval_seconds: u64,
}
impl IndexerConfig {
pub fn from_env() -> Self {
Self {
pub fn from_env() -> Result<Self> {
Ok(Self {
listen_addr: std::env::var("INDEXER_LISTEN_ADDR")
.unwrap_or_else(|_| "0.0.0.0:8081".to_string()),
}
database_url: std::env::var("DATABASE_URL").context("DATABASE_URL is required")?,
meili_url: std::env::var("MEILI_URL").context("MEILI_URL is required")?,
meili_master_key: std::env::var("MEILI_MASTER_KEY").context("MEILI_MASTER_KEY is required")?,
scan_interval_seconds: std::env::var("INDEXER_SCAN_INTERVAL_SECONDS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(5),
})
}
}

View File

@@ -6,3 +6,5 @@ license.workspace = true
[dependencies]
anyhow.workspace = true
lopdf = "0.35"
zip = { version = "2.2", default-features = false, features = ["deflate"] }

View File

@@ -1,3 +1,96 @@
pub fn supported_formats() -> &'static [&'static str] {
&["cbz", "cbr", "pdf"]
use anyhow::{Context, Result};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BookFormat {
Cbz,
Cbr,
Pdf,
}
impl BookFormat {
pub fn as_str(self) -> &'static str {
match self {
Self::Cbz => "cbz",
Self::Cbr => "cbr",
Self::Pdf => "pdf",
}
}
}
#[derive(Debug, Clone)]
pub struct ParsedMetadata {
pub title: String,
pub page_count: Option<i32>,
}
pub fn detect_format(path: &Path) -> Option<BookFormat> {
let ext = path.extension()?.to_string_lossy().to_ascii_lowercase();
match ext.as_str() {
"cbz" => Some(BookFormat::Cbz),
"cbr" => Some(BookFormat::Cbr),
"pdf" => Some(BookFormat::Pdf),
_ => None,
}
}
pub fn parse_metadata(path: &Path, format: BookFormat) -> Result<ParsedMetadata> {
let title = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string());
let page_count = match format {
BookFormat::Cbz => parse_cbz_page_count(path).ok(),
BookFormat::Cbr => parse_cbr_page_count(path).ok(),
BookFormat::Pdf => parse_pdf_page_count(path).ok(),
};
Ok(ParsedMetadata { title, page_count })
}
fn parse_cbz_page_count(path: &Path) -> Result<i32> {
let file = std::fs::File::open(path).with_context(|| format!("cannot open cbz: {}", path.display()))?;
let mut archive = zip::ZipArchive::new(file).context("invalid cbz archive")?;
let mut count: i32 = 0;
for i in 0..archive.len() {
let entry = archive.by_index(i).context("cannot read cbz entry")?;
let name = entry.name().to_ascii_lowercase();
if is_image_name(&name) {
count += 1;
}
}
Ok(count)
}
fn parse_cbr_page_count(path: &Path) -> Result<i32> {
let output = std::process::Command::new("unrar")
.arg("lb")
.arg(path)
.output()
.with_context(|| format!("failed to execute unrar for {}", path.display()))?;
if !output.status.success() {
return Err(anyhow::anyhow!("unrar failed for {}", path.display()));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let count = stdout
.lines()
.filter(|line| is_image_name(&line.to_ascii_lowercase()))
.count() as i32;
Ok(count)
}
fn parse_pdf_page_count(path: &Path) -> Result<i32> {
let doc = lopdf::Document::load(path).with_context(|| format!("cannot open pdf: {}", path.display()))?;
Ok(doc.get_pages().len() as i32)
}
fn is_image_name(name: &str) -> bool {
name.ends_with(".jpg")
|| name.ends_with(".jpeg")
|| name.ends_with(".png")
|| name.ends_with(".webp")
|| name.ends_with(".avif")
}