fix: prevent scanner from recreating renamed series
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 47s

When a user renames a series via the UI, the scanner was using the
filesystem directory name to overwrite the DB series name, effectively
undoing the rename. This adds an original_name column to series_metadata
that tracks the filesystem-derived name, so the scanner can map it back
to the user-chosen name. The migration also back-fills existing renamed
series by comparing book file paths with DB series names.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 07:00:18 +01:00
parent c3cbf716a7
commit 66d0a9f56d
3 changed files with 95 additions and 5 deletions

View File

@@ -1015,10 +1015,29 @@ pub async fn update_series(
.filter(|a| !a.is_empty())
.collect();
let locked_fields = body.locked_fields.clone().unwrap_or(serde_json::json!({}));
// When renaming, preserve the filesystem-derived original name so the scanner
// can map files back to the renamed series instead of recreating the old one.
let is_rename = name != "unclassified" && new_name != name;
let original_name: Option<String> = if is_rename {
// Check if the old metadata already has an original_name (chained renames: A→B→C)
let existing_original: Option<Option<String>> = sqlx::query_scalar(
"SELECT original_name FROM series_metadata WHERE library_id = $1 AND name = $2"
)
.bind(library_id)
.bind(&name)
.fetch_optional(&state.pool)
.await?;
// Use existing original_name if set, otherwise use the old name itself
Some(existing_original.flatten().unwrap_or_else(|| name.clone()))
} else {
None
};
sqlx::query(
r#"
INSERT INTO series_metadata (library_id, name, authors, description, publishers, start_year, total_volumes, status, locked_fields, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
INSERT INTO series_metadata (library_id, name, authors, description, publishers, start_year, total_volumes, status, locked_fields, original_name, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
ON CONFLICT (library_id, name) DO UPDATE
SET authors = EXCLUDED.authors,
description = EXCLUDED.description,
@@ -1027,6 +1046,7 @@ pub async fn update_series(
total_volumes = EXCLUDED.total_volumes,
status = EXCLUDED.status,
locked_fields = EXCLUDED.locked_fields,
original_name = COALESCE(EXCLUDED.original_name, series_metadata.original_name),
updated_at = NOW()
"#
)
@@ -1039,11 +1059,12 @@ pub async fn update_series(
.bind(body.total_volumes)
.bind(&body.status)
.bind(&locked_fields)
.bind(&original_name)
.execute(&state.pool)
.await?;
// 3. If renamed, move series_metadata from old name to new name
if name != "unclassified" && new_name != name {
// 3. If renamed, delete the old series_metadata entry
if is_rename {
sqlx::query(
"DELETE FROM series_metadata WHERE library_id = $1 AND name = $2"
)

View File

@@ -120,6 +120,31 @@ pub async fn scan_library_discovery(
.collect();
let mut seen_new_series: HashSet<String> = HashSet::new();
// Load series rename mapping: original filesystem name → current DB name.
// This prevents the scanner from recreating old series after a user rename.
let rename_rows = sqlx::query(
"SELECT original_name, name FROM series_metadata WHERE library_id = $1 AND original_name IS NOT NULL",
)
.bind(library_id)
.fetch_all(&state.pool)
.await
.unwrap_or_default();
let series_rename_map: HashMap<String, String> = rename_rows
.into_iter()
.map(|row| {
let original: String = row.get("original_name");
let current: String = row.get("name");
(original, current)
})
.collect();
if !series_rename_map.is_empty() {
info!(
"[SCAN] Loaded {} series rename mapping(s) for library {}",
series_rename_map.len(),
library_id
);
}
let mut seen: HashMap<String, bool> = HashMap::new();
let mut library_processed_count = 0i32;
let mut last_progress_update = std::time::Instant::now();
@@ -324,7 +349,19 @@ pub async fn scan_library_discovery(
seen.insert(lookup_path.clone(), true);
// Fast metadata extraction — no archive I/O
let parsed = parse_metadata_fast(&path, format, root);
let mut parsed = parse_metadata_fast(&path, format, root);
// Apply series rename mapping: if the filesystem-derived series name
// was renamed by the user, use the current DB name instead.
if let Some(ref fs_series) = parsed.series {
if let Some(renamed) = series_rename_map.get(fs_series) {
debug!(
"[SCAN] Mapping renamed series: '{}' → '{}'",
fs_series, renamed
);
parsed.series = Some(renamed.clone());
}
}
if let Some((file_id, book_id, old_fingerprint)) =
existing.get(&lookup_path).cloned()