fix: prevent scanner from recreating renamed series
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 47s
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:
@@ -1015,10 +1015,29 @@ pub async fn update_series(
|
|||||||
.filter(|a| !a.is_empty())
|
.filter(|a| !a.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
let locked_fields = body.locked_fields.clone().unwrap_or(serde_json::json!({}));
|
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(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO series_metadata (library_id, name, authors, description, publishers, start_year, total_volumes, status, locked_fields, updated_at)
|
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, NOW())
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
|
||||||
ON CONFLICT (library_id, name) DO UPDATE
|
ON CONFLICT (library_id, name) DO UPDATE
|
||||||
SET authors = EXCLUDED.authors,
|
SET authors = EXCLUDED.authors,
|
||||||
description = EXCLUDED.description,
|
description = EXCLUDED.description,
|
||||||
@@ -1027,6 +1046,7 @@ pub async fn update_series(
|
|||||||
total_volumes = EXCLUDED.total_volumes,
|
total_volumes = EXCLUDED.total_volumes,
|
||||||
status = EXCLUDED.status,
|
status = EXCLUDED.status,
|
||||||
locked_fields = EXCLUDED.locked_fields,
|
locked_fields = EXCLUDED.locked_fields,
|
||||||
|
original_name = COALESCE(EXCLUDED.original_name, series_metadata.original_name),
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
@@ -1039,11 +1059,12 @@ pub async fn update_series(
|
|||||||
.bind(body.total_volumes)
|
.bind(body.total_volumes)
|
||||||
.bind(&body.status)
|
.bind(&body.status)
|
||||||
.bind(&locked_fields)
|
.bind(&locked_fields)
|
||||||
|
.bind(&original_name)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 3. If renamed, move series_metadata from old name to new name
|
// 3. If renamed, delete the old series_metadata entry
|
||||||
if name != "unclassified" && new_name != name {
|
if is_rename {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"DELETE FROM series_metadata WHERE library_id = $1 AND name = $2"
|
"DELETE FROM series_metadata WHERE library_id = $1 AND name = $2"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -120,6 +120,31 @@ pub async fn scan_library_discovery(
|
|||||||
.collect();
|
.collect();
|
||||||
let mut seen_new_series: HashSet<String> = HashSet::new();
|
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 seen: HashMap<String, bool> = HashMap::new();
|
||||||
let mut library_processed_count = 0i32;
|
let mut library_processed_count = 0i32;
|
||||||
let mut last_progress_update = std::time::Instant::now();
|
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);
|
seen.insert(lookup_path.clone(), true);
|
||||||
|
|
||||||
// Fast metadata extraction — no archive I/O
|
// 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)) =
|
if let Some((file_id, book_id, old_fingerprint)) =
|
||||||
existing.get(&lookup_path).cloned()
|
existing.get(&lookup_path).cloned()
|
||||||
|
|||||||
32
infra/migrations/0062_add_series_original_name.sql
Normal file
32
infra/migrations/0062_add_series_original_name.sql
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
-- Track the filesystem-derived series name so the scanner can map
|
||||||
|
-- renamed series back to their current DB name.
|
||||||
|
-- When a user renames series "A" → "B", original_name stores "A" (the directory name).
|
||||||
|
ALTER TABLE series_metadata ADD COLUMN original_name TEXT;
|
||||||
|
|
||||||
|
-- Back-fill original_name for series that were already renamed:
|
||||||
|
-- compare the DB series name with the actual directory name from book_files.abs_path.
|
||||||
|
-- If they differ, the series was renamed and we record the filesystem name.
|
||||||
|
UPDATE series_metadata sm
|
||||||
|
SET original_name = fs.fs_series
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (b.library_id, b.series)
|
||||||
|
b.library_id,
|
||||||
|
b.series,
|
||||||
|
-- First path component after the library root = filesystem series name
|
||||||
|
split_part(
|
||||||
|
ltrim(
|
||||||
|
replace(bf.abs_path, l.root_path, ''),
|
||||||
|
'/'
|
||||||
|
),
|
||||||
|
'/',
|
||||||
|
1
|
||||||
|
) AS fs_series
|
||||||
|
FROM books b
|
||||||
|
JOIN book_files bf ON bf.book_id = b.id
|
||||||
|
JOIN libraries l ON l.id = b.library_id
|
||||||
|
WHERE b.series IS NOT NULL AND b.series != ''
|
||||||
|
) fs
|
||||||
|
WHERE sm.library_id = fs.library_id
|
||||||
|
AND sm.name = fs.series
|
||||||
|
AND fs.fs_series != ''
|
||||||
|
AND fs.fs_series != fs.series;
|
||||||
Reference in New Issue
Block a user