feat: change volume from string to integer type

Parser:
- Change volume type from Option<String> to Option<i32>
- Parse volume as integer to remove leading zeros
- Keep original title with volume info

Indexer:
- Update SQL queries to insert volume as integer
- Add volume column to INSERT and UPDATE statements

API:
- Change BookItem.volume and BookDetails.volume to Option<i32>
- Add natural sorting for books

Backoffice:
- Update volume type to number
- Update book detail page
- Add CSS styles
This commit is contained in:
2026-03-05 23:32:01 +01:00
parent 262c5c9f12
commit 82294a1bee
9 changed files with 190 additions and 18 deletions

1
Cargo.lock generated
View File

@@ -1447,6 +1447,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"lopdf", "lopdf",
"regex",
"zip 2.4.2", "zip 2.4.2",
] ]

View File

@@ -31,7 +31,7 @@ pub struct BookItem {
pub title: String, pub title: String,
pub author: Option<String>, pub author: Option<String>,
pub series: Option<String>, pub series: Option<String>,
pub volume: Option<String>, pub volume: Option<i32>,
pub language: Option<String>, pub language: Option<String>,
pub page_count: Option<i32>, pub page_count: Option<i32>,
#[schema(value_type = String)] #[schema(value_type = String)]
@@ -55,7 +55,7 @@ pub struct BookDetails {
pub title: String, pub title: String,
pub author: Option<String>, pub author: Option<String>,
pub series: Option<String>, pub series: Option<String>,
pub volume: Option<String>, pub volume: Option<i32>,
pub language: Option<String>, pub language: Option<String>,
pub page_count: Option<i32>, pub page_count: Option<i32>,
pub file_path: Option<String>, pub file_path: Option<String>,
@@ -102,7 +102,16 @@ pub async fn list_books(
AND ($2::text IS NULL OR kind = $2) AND ($2::text IS NULL OR kind = $2)
AND ($3::uuid IS NULL OR id > $3) AND ($3::uuid IS NULL OR id > $3)
{} {}
ORDER BY id ASC ORDER BY
-- Extract text part before numbers (case insensitive)
REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'),
-- Extract first number group and convert to integer for numeric sort
COALESCE(
NULLIF(REGEXP_REPLACE(LOWER(title), '^[^0-9]*', '', 'g'), '')::int,
0
),
-- Then by full title as fallback
title ASC
LIMIT $4 LIMIT $4
"#, "#,
series_condition series_condition
@@ -235,11 +244,18 @@ pub async fn list_series(
) -> Result<Json<Vec<SeriesItem>>, ApiError> { ) -> Result<Json<Vec<SeriesItem>>, ApiError> {
let rows = sqlx::query( let rows = sqlx::query(
r#" r#"
WITH series_books AS ( WITH sorted_books AS (
SELECT SELECT
COALESCE(NULLIF(series, ''), 'unclassified') as name, COALESCE(NULLIF(series, ''), 'unclassified') as name,
id, id,
ROW_NUMBER() OVER (PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified') ORDER BY id) as rn -- Natural sort order for books within series
ROW_NUMBER() OVER (
PARTITION BY COALESCE(NULLIF(series, ''), 'unclassified')
ORDER BY
REGEXP_REPLACE(LOWER(title), '[0-9]+', '', 'g'),
COALESCE(NULLIF(REGEXP_REPLACE(LOWER(title), '^[^0-9]*', '', 'g'), '')::int, 0),
title ASC
) as rn
FROM books FROM books
WHERE library_id = $1 WHERE library_id = $1
), ),
@@ -247,7 +263,7 @@ pub async fn list_series(
SELECT SELECT
name, name,
COUNT(*) as book_count COUNT(*) as book_count
FROM series_books FROM sorted_books
GROUP BY name GROUP BY name
) )
SELECT SELECT
@@ -255,8 +271,16 @@ pub async fn list_series(
sc.book_count, sc.book_count,
sb.id as first_book_id sb.id as first_book_id
FROM series_counts sc FROM series_counts sc
JOIN series_books sb ON sb.name = sc.name AND sb.rn = 1 JOIN sorted_books sb ON sb.name = sc.name AND sb.rn = 1
ORDER BY sc.name ASC ORDER BY
-- Natural sort: extract text part before numbers
REGEXP_REPLACE(LOWER(sc.name), '[0-9]+', '', 'g'),
-- Extract first number group and convert to integer
COALESCE(
NULLIF(REGEXP_REPLACE(LOWER(sc.name), '^[^0-9]*', '', 'g'), '')::int,
0
),
sc.name ASC
"#, "#,
) )
.bind(library_id) .bind(library_id)

View File

@@ -68,6 +68,13 @@ export default async function BookDetailPage({
<span className={`book-kind ${book.kind}`}>{book.kind.toUpperCase()}</span> <span className={`book-kind ${book.kind}`}>{book.kind.toUpperCase()}</span>
</div> </div>
{book.volume && (
<div className="meta-row">
<span className="meta-label">Volume:</span>
<span>{book.volume}</span>
</div>
)}
{book.language && ( {book.language && (
<div className="meta-row"> <div className="meta-row">
<span className="meta-label">Language:</span> <span className="meta-label">Language:</span>
@@ -87,10 +94,50 @@ export default async function BookDetailPage({
<span>{library?.name || book.library_id}</span> <span>{library?.name || book.library_id}</span>
</div> </div>
{book.series && (
<div className="meta-row"> <div className="meta-row">
<span className="meta-label">ID:</span> <span className="meta-label">Series:</span>
<span>{book.series}</span>
</div>
)}
{book.file_format && (
<div className="meta-row">
<span className="meta-label">File Format:</span>
<span>{book.file_format.toUpperCase()}</span>
</div>
)}
{book.file_parse_status && (
<div className="meta-row">
<span className="meta-label">Parse Status:</span>
<span className={`status-${book.file_parse_status}`}>{book.file_parse_status}</span>
</div>
)}
{book.file_path && (
<div className="meta-row">
<span className="meta-label">File Path:</span>
<code className="file-path">{book.file_path}</code>
</div>
)}
<div className="meta-row">
<span className="meta-label">Book ID:</span>
<code className="book-id">{book.id}</code> <code className="book-id">{book.id}</code>
</div> </div>
<div className="meta-row">
<span className="meta-label">Library ID:</span>
<code className="book-id">{book.library_id}</code>
</div>
{book.updated_at && (
<div className="meta-row">
<span className="meta-label">Updated:</span>
<span>{new Date(book.updated_at).toLocaleString()}</span>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -36,6 +36,9 @@ export default async function BooksPage({
volume: hit.volume, volume: hit.volume,
language: hit.language, language: hit.language,
page_count: null, page_count: null,
file_path: null,
file_format: null,
file_parse_status: null,
updated_at: "" updated_at: ""
})); }));
totalHits = searchResponse.estimated_total_hits; totalHits = searchResponse.estimated_total_hits;

View File

@@ -706,3 +706,26 @@ button:hover {
.dark .series-cover { .dark .series-cover {
background: linear-gradient(135deg, hsl(221 24% 20%), hsl(221 24% 15%)); background: linear-gradient(135deg, hsl(221 24% 20%), hsl(221 24% 15%));
} }
/* Status badges */
.status-ok {
color: hsl(142 60% 45%);
font-weight: 700;
}
.status-error {
color: hsl(2 72% 48%);
font-weight: 700;
}
.status-pending {
color: hsl(45 93% 47%);
font-weight: 700;
}
.file-path {
font-size: 0.8rem;
word-break: break-all;
max-width: 400px;
display: inline-block;
}

View File

@@ -37,9 +37,12 @@ export type BookDto = {
title: string; title: string;
author: string | null; author: string | null;
series: string | null; series: string | null;
volume: string | null; volume: number | null;
language: string | null; language: string | null;
page_count: number | null; page_count: number | null;
file_path: string | null;
file_format: string | null;
file_parse_status: string | null;
updated_at: string; updated_at: string;
}; };
@@ -54,7 +57,7 @@ export type SearchHitDto = {
title: string; title: string;
author: string | null; author: string | null;
series: string | null; series: string | null;
volume: string | null; volume: number | null;
kind: string; kind: string;
language: string | null; language: string | null;
}; };

View File

@@ -231,12 +231,13 @@ async fn scan_library(
match parse_metadata(path, format, root) { match parse_metadata(path, format, root) {
Ok(parsed) => { Ok(parsed) => {
sqlx::query( sqlx::query(
"UPDATE books SET title = $2, kind = $3, series = $4, page_count = $5, updated_at = NOW() WHERE id = $1", "UPDATE books SET title = $2, kind = $3, series = $4, volume = $5, page_count = $6, updated_at = NOW() WHERE id = $1",
) )
.bind(book_id) .bind(book_id)
.bind(parsed.title) .bind(&parsed.title)
.bind(kind_from_format(format)) .bind(kind_from_format(format))
.bind(parsed.series) .bind(&parsed.series)
.bind(&parsed.volume)
.bind(parsed.page_count) .bind(parsed.page_count)
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
@@ -274,13 +275,14 @@ async fn scan_library(
let book_id = Uuid::new_v4(); let book_id = Uuid::new_v4();
let file_id = Uuid::new_v4(); let file_id = Uuid::new_v4();
sqlx::query( sqlx::query(
"INSERT INTO books (id, library_id, kind, title, series, page_count) VALUES ($1, $2, $3, $4, $5, $6)", "INSERT INTO books (id, library_id, kind, title, series, volume, page_count) VALUES ($1, $2, $3, $4, $5, $6, $7)",
) )
.bind(book_id) .bind(book_id)
.bind(library_id) .bind(library_id)
.bind(kind_from_format(format)) .bind(kind_from_format(format))
.bind(parsed.title) .bind(&parsed.title)
.bind(parsed.series) .bind(&parsed.series)
.bind(&parsed.volume)
.bind(parsed.page_count) .bind(parsed.page_count)
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;

View File

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

View File

@@ -22,6 +22,7 @@ impl BookFormat {
pub struct ParsedMetadata { pub struct ParsedMetadata {
pub title: String, pub title: String,
pub series: Option<String>, pub series: Option<String>,
pub volume: Option<i32>,
pub page_count: Option<i32>, pub page_count: Option<i32>,
} }
@@ -40,11 +41,17 @@ pub fn parse_metadata(
format: BookFormat, format: BookFormat,
library_root: &Path, library_root: &Path,
) -> Result<ParsedMetadata> { ) -> Result<ParsedMetadata> {
let title = path let filename = path
.file_stem() .file_stem()
.map(|s| s.to_string_lossy().to_string()) .map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Untitled".to_string()); .unwrap_or_else(|| "Untitled".to_string());
// Extract volume from filename (patterns: T01, T02, Vol 1, Volume 1, #1, - 01, etc.)
let volume = extract_volume(&filename);
// Keep original filename as title (don't clean it)
let title = filename;
// Determine series from parent folder relative to library root // Determine series from parent folder relative to library root
let series = path.parent().and_then(|parent| { let series = path.parent().and_then(|parent| {
// Get the relative path from library root to parent // Get the relative path from library root to parent
@@ -69,10 +76,71 @@ pub fn parse_metadata(
Ok(ParsedMetadata { Ok(ParsedMetadata {
title, title,
series, series,
volume,
page_count, page_count,
}) })
} }
fn extract_volume(filename: &str) -> Option<i32> {
// Common volume patterns: T01, T02, T1, T2, Vol 1, Vol. 1, Volume 1, #1, #01, - 1, - 01
let patterns = [
// T01, T02 pattern (most common for manga/comics)
(r"(?i)T(\d+)", 1),
// Vol 1, Vol. 1, Volume 1
(r"(?i)Vol\.?\s*(\d+)", 1),
(r"(?i)Volume\s*(\d+)", 1),
// #1, #01
(r"#(\d+)", 1),
// - 1, - 01 at the end
(r"-\s*(\d+)\s*$", 1),
];
for (pattern, group) in &patterns {
if let Ok(re) = regex::Regex::new(pattern) {
if let Some(caps) = re.captures(filename) {
if let Some(mat) = caps.get(*group) {
// Parse as integer to remove leading zeros
return mat.as_str().parse::<i32>().ok();
}
}
}
}
None
}
#[allow(dead_code)]
fn clean_title(filename: &str) -> String {
// Remove volume patterns from title to clean it up
let cleaned = regex::Regex::new(r"(?i)\s*T\d+\s*")
.ok()
.and_then(|re| Some(re.replace_all(filename, " ").to_string()))
.unwrap_or_else(|| filename.to_string());
let cleaned = regex::Regex::new(r"(?i)\s*Vol\.?\s*\d+\s*")
.ok()
.and_then(|re| Some(re.replace_all(&cleaned, " ").to_string()))
.unwrap_or_else(|| cleaned);
let cleaned = regex::Regex::new(r"(?i)\s*Volume\s*\d+\s*")
.ok()
.and_then(|re| Some(re.replace_all(&cleaned, " ").to_string()))
.unwrap_or_else(|| cleaned);
let cleaned = regex::Regex::new(r"#\d+")
.ok()
.and_then(|re| Some(re.replace_all(&cleaned, " ").to_string()))
.unwrap_or_else(|| cleaned);
let cleaned = regex::Regex::new(r"-\s*\d+\s*$")
.ok()
.and_then(|re| Some(re.replace_all(&cleaned, " ").to_string()))
.unwrap_or_else(|| cleaned);
// Clean up extra spaces
cleaned.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn parse_cbz_page_count(path: &Path) -> Result<i32> { fn parse_cbz_page_count(path: &Path) -> Result<i32> {
let file = std::fs::File::open(path) let file = std::fs::File::open(path)
.with_context(|| format!("cannot open cbz: {}", path.display()))?; .with_context(|| format!("cannot open cbz: {}", path.display()))?;