Compare commits
5 Commits
e18bbba4ce
...
b14accbbe0
| Author | SHA1 | Date | |
|---|---|---|---|
| b14accbbe0 | |||
| 330239d2c3 | |||
| bf5a20882b | |||
| 44c6dd626a | |||
| 9153b0c750 |
@@ -5,6 +5,7 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -14,7 +15,14 @@ pub async fn request_counter(
|
|||||||
next: Next,
|
next: Next,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
state.metrics.requests_total.fetch_add(1, Ordering::Relaxed);
|
state.metrics.requests_total.fetch_add(1, Ordering::Relaxed);
|
||||||
next.run(req).await
|
let method = req.method().clone();
|
||||||
|
let uri = req.uri().clone();
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let response = next.run(req).await;
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let elapsed = start.elapsed();
|
||||||
|
info!("{} {} {} {}ms", method, uri.path(), status, elapsed.as_millis());
|
||||||
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn read_rate_limit(
|
pub async fn read_rate_limit(
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ pub async fn list_books(
|
|||||||
let order_clause = if query.sort.as_deref() == Some("latest") {
|
let order_clause = if query.sort.as_deref() == Some("latest") {
|
||||||
"b.updated_at DESC".to_string()
|
"b.updated_at DESC".to_string()
|
||||||
} else {
|
} else {
|
||||||
"REGEXP_REPLACE(LOWER(b.title), '[0-9]+', '', 'g'), COALESCE((REGEXP_MATCH(LOWER(b.title), '\\d+'))[1]::int, 0), b.title ASC".to_string()
|
"b.volume NULLS LAST, REGEXP_REPLACE(LOWER(b.title), '[0-9]+', '', 'g'), COALESCE((REGEXP_MATCH(LOWER(b.title), '\\d+'))[1]::int, 0), b.title ASC".to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
// DATA: mêmes params filtre, puis $N+1=limit $N+2=offset
|
// DATA: mêmes params filtre, puis $N+1=limit $N+2=offset
|
||||||
@@ -400,6 +400,7 @@ pub async fn list_series(
|
|||||||
ROW_NUMBER() OVER (
|
ROW_NUMBER() OVER (
|
||||||
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
|
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
|
||||||
ORDER BY
|
ORDER BY
|
||||||
|
volume NULLS LAST,
|
||||||
REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'),
|
REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'),
|
||||||
COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0),
|
COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0),
|
||||||
title ASC
|
title ASC
|
||||||
@@ -586,6 +587,7 @@ pub async fn list_all_series(
|
|||||||
ROW_NUMBER() OVER (
|
ROW_NUMBER() OVER (
|
||||||
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
|
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
|
||||||
ORDER BY
|
ORDER BY
|
||||||
|
volume NULLS LAST,
|
||||||
REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'),
|
REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'),
|
||||||
COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0),
|
COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0),
|
||||||
title ASC
|
title ASC
|
||||||
@@ -714,6 +716,7 @@ pub async fn ongoing_series(
|
|||||||
ROW_NUMBER() OVER (
|
ROW_NUMBER() OVER (
|
||||||
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
|
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
|
||||||
ORDER BY
|
ORDER BY
|
||||||
|
volume NULLS LAST,
|
||||||
REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'),
|
REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'),
|
||||||
COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0),
|
COALESCE((REGEXP_MATCH(LOWER(title), '\d+'))[1]::int, 0),
|
||||||
title ASC
|
title ASC
|
||||||
|
|||||||
@@ -120,9 +120,12 @@ export default async function BookDetailPage({
|
|||||||
<div className="flex items-center justify-between py-2 border-b border-border">
|
<div className="flex items-center justify-between py-2 border-b border-border">
|
||||||
<span className="text-sm text-muted-foreground">Format:</span>
|
<span className="text-sm text-muted-foreground">Format:</span>
|
||||||
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${
|
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-semibold ${
|
||||||
book.kind === 'epub' ? 'bg-primary/10 text-primary' : 'bg-muted/50 text-muted-foreground'
|
(book.format ?? book.kind) === 'cbz' ? 'bg-success/10 text-success' :
|
||||||
|
(book.format ?? book.kind) === 'cbr' ? 'bg-warning/10 text-warning' :
|
||||||
|
(book.format ?? book.kind) === 'pdf' ? 'bg-destructive/10 text-destructive' :
|
||||||
|
'bg-muted/50 text-muted-foreground'
|
||||||
}`}>
|
}`}>
|
||||||
{book.kind.toUpperCase()}
|
{(book.format ?? book.kind).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export default async function BooksPage({
|
|||||||
volume: hit.volume,
|
volume: hit.volume,
|
||||||
language: hit.language,
|
language: hit.language,
|
||||||
page_count: null,
|
page_count: null,
|
||||||
|
format: null,
|
||||||
file_path: null,
|
file_path: null,
|
||||||
file_format: null,
|
file_format: null,
|
||||||
file_parse_status: null,
|
file_parse_status: null,
|
||||||
|
|||||||
@@ -102,14 +102,16 @@ export function BookCard({ book, readingStatus }: BookCardProps) {
|
|||||||
|
|
||||||
{/* Meta Tags */}
|
{/* Meta Tags */}
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<span className={`
|
{(book.format ?? book.kind) && (
|
||||||
px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded-full
|
<span className={`
|
||||||
${book.kind === 'cbz' ? 'bg-success/10 text-success' : ''}
|
px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider rounded-full
|
||||||
${book.kind === 'cbr' ? 'bg-warning/10 text-warning' : ''}
|
${(book.format ?? book.kind) === 'cbz' ? 'bg-success/10 text-success' : ''}
|
||||||
${book.kind === 'pdf' ? 'bg-destructive/10 text-destructive' : ''}
|
${(book.format ?? book.kind) === 'cbr' ? 'bg-warning/10 text-warning' : ''}
|
||||||
`}>
|
${(book.format ?? book.kind) === 'pdf' ? 'bg-destructive/10 text-destructive' : ''}
|
||||||
{book.kind}
|
`}>
|
||||||
</span>
|
{book.format ?? book.kind}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{book.language && (
|
{book.language && (
|
||||||
<span className="px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider rounded-full bg-primary/10 text-primary">
|
<span className="px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider rounded-full bg-primary/10 text-primary">
|
||||||
{book.language}
|
{book.language}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export type BookDto = {
|
|||||||
id: string;
|
id: string;
|
||||||
library_id: string;
|
library_id: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
|
format: string | null;
|
||||||
title: string;
|
title: string;
|
||||||
author: string | null;
|
author: string | null;
|
||||||
series: string | null;
|
series: string | null;
|
||||||
|
|||||||
@@ -507,7 +507,7 @@ fn parse_pdf_page_count(path: &Path) -> Result<i32> {
|
|||||||
Ok(doc.get_pages().len() as i32)
|
Ok(doc.get_pages().len() as i32)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_image_name(name: &str) -> bool {
|
pub fn is_image_name(name: &str) -> bool {
|
||||||
// Skip macOS metadata entries (__MACOSX/ prefix or AppleDouble ._* files)
|
// Skip macOS metadata entries (__MACOSX/ prefix or AppleDouble ._* files)
|
||||||
if name.starts_with("__macosx/") || name.contains("/._") || name.starts_with("._") {
|
if name.starts_with("__macosx/") || name.contains("/._") || name.starts_with("._") {
|
||||||
return false;
|
return false;
|
||||||
@@ -517,6 +517,191 @@ fn is_image_name(name: &str) -> bool {
|
|||||||
|| name.ends_with(".png")
|
|| name.ends_with(".png")
|
||||||
|| name.ends_with(".webp")
|
|| name.ends_with(".webp")
|
||||||
|| name.ends_with(".avif")
|
|| name.ends_with(".avif")
|
||||||
|
|| name.ends_with(".gif")
|
||||||
|
|| name.ends_with(".bmp")
|
||||||
|
|| name.ends_with(".tif")
|
||||||
|
|| name.ends_with(".tiff")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the sorted list of image entry names in a CBZ or CBR archive.
|
||||||
|
/// Intended to be cached by the caller; pass the result to `extract_image_by_name`.
|
||||||
|
pub fn list_archive_images(path: &Path, format: BookFormat) -> Result<Vec<String>> {
|
||||||
|
match format {
|
||||||
|
BookFormat::Cbz => list_cbz_images(path),
|
||||||
|
BookFormat::Cbr => list_cbr_images(path),
|
||||||
|
BookFormat::Pdf => Err(anyhow::anyhow!("list_archive_images not applicable for PDF")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_cbz_images(path: &Path) -> Result<Vec<String>> {
|
||||||
|
let file = std::fs::File::open(path)
|
||||||
|
.with_context(|| format!("cannot open cbz: {}", path.display()))?;
|
||||||
|
let mut archive = match zip::ZipArchive::new(file) {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(zip_err) => {
|
||||||
|
// Try RAR fallback
|
||||||
|
if let Ok(names) = list_cbr_images(path) {
|
||||||
|
return Ok(names);
|
||||||
|
}
|
||||||
|
// Try streaming fallback
|
||||||
|
return list_cbz_images_streaming(path).map_err(|_| {
|
||||||
|
anyhow::anyhow!("invalid cbz for {}: {}", path.display(), zip_err)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut names: Vec<String> = Vec::new();
|
||||||
|
for i in 0..archive.len() {
|
||||||
|
let entry = match archive.by_index(i) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let lower = entry.name().to_ascii_lowercase();
|
||||||
|
if is_image_name(&lower) {
|
||||||
|
names.push(entry.name().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names.sort_by(|a, b| natord::compare(a, b));
|
||||||
|
Ok(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_cbz_images_streaming(path: &Path) -> Result<Vec<String>> {
|
||||||
|
let file = std::fs::File::open(path)
|
||||||
|
.with_context(|| format!("cannot open cbz for streaming: {}", path.display()))?;
|
||||||
|
let mut reader = std::io::BufReader::new(file);
|
||||||
|
let mut names: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match zip::read::read_zipfile_from_stream(&mut reader) {
|
||||||
|
Ok(Some(mut entry)) => {
|
||||||
|
let name = entry.name().to_string();
|
||||||
|
if is_image_name(&name.to_ascii_lowercase()) {
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
std::io::copy(&mut entry, &mut std::io::sink())?;
|
||||||
|
}
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(_) => {
|
||||||
|
if !names.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"streaming ZIP listing failed for {}",
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names.sort_by(|a, b| natord::compare(a, b));
|
||||||
|
Ok(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_cbr_images(path: &Path) -> Result<Vec<String>> {
|
||||||
|
let archive = unrar::Archive::new(path)
|
||||||
|
.open_for_listing()
|
||||||
|
.map_err(|e| anyhow::anyhow!("unrar listing failed for {}: {}", path.display(), e));
|
||||||
|
let archive = match archive {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(e) => {
|
||||||
|
let e_str = e.to_string();
|
||||||
|
if e_str.contains("Not a RAR archive") || e_str.contains("bad archive") {
|
||||||
|
return list_cbz_images(path);
|
||||||
|
}
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut names: Vec<String> = Vec::new();
|
||||||
|
for entry in archive {
|
||||||
|
let entry = entry.map_err(|e| anyhow::anyhow!("unrar entry error: {}", e))?;
|
||||||
|
let name = entry.filename.to_string_lossy().to_string();
|
||||||
|
if is_image_name(&name.to_ascii_lowercase()) {
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names.sort_by(|a, b| natord::compare(a, b));
|
||||||
|
Ok(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a specific image entry by name from a CBZ or CBR archive.
|
||||||
|
/// Use in combination with `list_archive_images` to avoid re-enumerating entries.
|
||||||
|
pub fn extract_image_by_name(path: &Path, format: BookFormat, image_name: &str) -> Result<Vec<u8>> {
|
||||||
|
match format {
|
||||||
|
BookFormat::Cbz => extract_cbz_by_name(path, image_name),
|
||||||
|
BookFormat::Cbr => extract_cbr_by_name(path, image_name),
|
||||||
|
BookFormat::Pdf => Err(anyhow::anyhow!("use extract_page for PDF")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_cbz_by_name(path: &Path, image_name: &str) -> Result<Vec<u8>> {
|
||||||
|
let file = std::fs::File::open(path)
|
||||||
|
.with_context(|| format!("cannot open cbz: {}", path.display()))?;
|
||||||
|
let mut archive = match zip::ZipArchive::new(file) {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(_) => return extract_cbz_by_name_streaming(path, image_name),
|
||||||
|
};
|
||||||
|
let mut entry = archive
|
||||||
|
.by_name(image_name)
|
||||||
|
.with_context(|| format!("entry '{}' not found in {}", image_name, path.display()))?;
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
entry.read_to_end(&mut buf)?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_cbz_by_name_streaming(path: &Path, image_name: &str) -> Result<Vec<u8>> {
|
||||||
|
let file = std::fs::File::open(path)
|
||||||
|
.with_context(|| format!("cannot open cbz for streaming: {}", path.display()))?;
|
||||||
|
let mut reader = std::io::BufReader::new(file);
|
||||||
|
loop {
|
||||||
|
match zip::read::read_zipfile_from_stream(&mut reader) {
|
||||||
|
Ok(Some(mut entry)) => {
|
||||||
|
if entry.name() == image_name {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
entry.read_to_end(&mut buf)?;
|
||||||
|
return Ok(buf);
|
||||||
|
}
|
||||||
|
std::io::copy(&mut entry, &mut std::io::sink())?;
|
||||||
|
}
|
||||||
|
Ok(None) => break,
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"entry '{}' not found in streaming cbz: {}",
|
||||||
|
image_name,
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_cbr_by_name(path: &Path, image_name: &str) -> Result<Vec<u8>> {
|
||||||
|
let mut archive = unrar::Archive::new(path)
|
||||||
|
.open_for_processing()
|
||||||
|
.map_err(|e| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"unrar open for processing failed for {}: {}",
|
||||||
|
path.display(),
|
||||||
|
e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
while let Some(header) = archive
|
||||||
|
.read_header()
|
||||||
|
.map_err(|e| anyhow::anyhow!("unrar read header: {}", e))?
|
||||||
|
{
|
||||||
|
let entry_name = header.entry().filename.to_string_lossy().to_string();
|
||||||
|
if entry_name == image_name {
|
||||||
|
let (data, _) = header
|
||||||
|
.read()
|
||||||
|
.map_err(|e| anyhow::anyhow!("unrar read data: {}", e))?;
|
||||||
|
return Ok(data);
|
||||||
|
}
|
||||||
|
archive = header
|
||||||
|
.skip()
|
||||||
|
.map_err(|e| anyhow::anyhow!("unrar skip: {}", e))?;
|
||||||
|
}
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"entry '{}' not found in cbr: {}",
|
||||||
|
image_name,
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn extract_first_page(path: &Path, format: BookFormat) -> Result<Vec<u8>> {
|
pub fn extract_first_page(path: &Path, format: BookFormat) -> Result<Vec<u8>> {
|
||||||
|
|||||||
Reference in New Issue
Block a user