diff --git a/apps/api/src/series.rs b/apps/api/src/series.rs index 86f93e5..5a928be 100644 --- a/apps/api/src/series.rs +++ b/apps/api/src/series.rs @@ -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 = if is_rename { + // Check if the old metadata already has an original_name (chained renames: A→B→C) + let existing_original: Option> = 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" ) diff --git a/apps/indexer/src/scanner.rs b/apps/indexer/src/scanner.rs index 7a7af25..8932e80 100644 --- a/apps/indexer/src/scanner.rs +++ b/apps/indexer/src/scanner.rs @@ -120,6 +120,31 @@ pub async fn scan_library_discovery( .collect(); let mut seen_new_series: HashSet = 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 = 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 = 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() diff --git a/infra/migrations/0062_add_series_original_name.sql b/infra/migrations/0062_add_series_original_name.sql new file mode 100644 index 0000000..e8c056c --- /dev/null +++ b/infra/migrations/0062_add_series_original_name.sql @@ -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;