From e5768d9c5c10735ba080034767fc2391160ffac1 Mon Sep 17 00:00:00 2001 From: deucebucket Date: Fri, 17 Apr 2026 18:02:30 -0500 Subject: [PATCH] Fix #209: Hard link failure no longer copies+deletes originals When 'Use hard links' was enabled and the watch folder and library were on different filesystems, os.link() raised EXDEV and the code silently fell back to shutil.copy2() then deleted the originals. That broke torrent seeding, doubled disk use, and silently violated the user's explicit preference. - Add filesystem-compatibility pre-check in move_to_output_folder when use_hard_links is True. Returns a clear, actionable error if source and library are on different st_dev, without touching any source files. - Remove the EXDEV copy+delete fallback from both the single-file and directory-loop branches. Remaining OSErrors (permission, ENOSPC, etc.) propagate to the outer handler with source intact; the watch worker records the failure as watch_folder_error visible in the UI. - Bump APP_VERSION to 0.9.0-beta.147 and update README badge + CHANGELOG. Reported by @kyleviloria. --- CHANGELOG.md | 26 ++++++++++++++++++++++ README.md | 6 ++++- app.py | 62 +++++++++++++++++++--------------------------------- 3 files changed, 54 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fee571d..066d4ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,32 @@ All notable changes to Library Manager will be documented in this file. +## [0.9.0-beta.147] - 2026-04-17 + +### Fixed + +- **Issue #209: Hard link failure silently copies and deletes originals** — When + `Use hard links` was enabled and the watch folder and library lived on different + filesystems, `os.link()` raised `EXDEV` and the code silently fell back to + `shutil.copy2()` followed by deleting each original file. That destroyed the + source data (breaking torrent seeds, doubling disk use, violating the user's + explicit "hard link" preference). Fix: + - Added a filesystem-compatibility pre-check at the start of + `move_to_output_folder`. When hard links are requested but source and library + are on different `st_dev`s, the function returns a clear, actionable error + ("Move your library to the same volume as the watch folder, or disable 'Use + hard links' in Settings") and does not touch source files. + - Removed the EXDEV copy+delete fallback from both the single-file and + directory-loop branches. Remaining `OSError`s (permission, `ENOSPC`, etc.) + propagate to the outer handler with source files intact, and the watch + worker records the failure as `watch_folder_error` with the error message + visible in the UI. + - Reported by `@kyleviloria` — files weren't lost because copies still existed + at the library destination, but the deletion of originals broke their + download workflow and burned disk. + +--- + ## [0.9.0-beta.146] - 2026-04-07 ### Added diff --git a/README.md b/README.md index 95ecbcb..0871d1d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **Smart Audiobook Library Organizer with Multi-Source Metadata & AI Verification** -[![Version](https://img.shields.io/badge/version-0.9.0--beta.146-blue.svg)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-0.9.0--beta.147-blue.svg)](CHANGELOG.md) [![Docker](https://img.shields.io/badge/docker-ghcr.io-blue.svg)](https://ghcr.io/deucebucket/library-manager) [![License](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](LICENSE) @@ -16,6 +16,10 @@ ## Recent Changes (stable) +> **beta.147** - **Critical Fix: Hard Link Safety** (Issue #209) +> - **Stop silent copy+delete** - When "Use hard links" was enabled and the watch folder / library sat on different filesystems, LM used to copy every file and delete the originals. That broke torrent seeding and doubled disk use. Now LM fails fast with a clear error and leaves source files untouched. +> - **Pre-check filesystem compatibility** - Verifies `st_dev` match before any file operations when hard links are enabled. + > **beta.140** - **Feature: Custom Layer Builder** (Issue #186) > - **Plugins tab** - New settings tab with 4-step wizard to add custom HTTP API metadata sources > - **No-code API integration** - Configure URL templates, authentication, JSONPath response mapping, and confidence weights diff --git a/app.py b/app.py index 76dd44c..8663f0e 100644 --- a/app.py +++ b/app.py @@ -11,7 +11,7 @@ - Multi-provider AI (Gemini, OpenRouter, Ollama) """ -APP_VERSION = "0.9.0-beta.146" +APP_VERSION = "0.9.0-beta.147" GITHUB_REPO = "deucebucket/library-manager" # Your GitHub repo # Versioning Guide: @@ -6520,6 +6520,22 @@ def move_to_output_folder(source_path: str, output_folder: str, author: str, tit except Exception as e: return False, None, f"Cannot create output folder: {e}" + # Issue #209: Fail fast if hard links requested across filesystems. + # Without this, a later os.link EXDEV would silently fall back to copy+delete, + # destroying the user's originals (e.g. breaking torrent seeds, doubling disk). + if use_hard_links: + try: + if source.stat().st_dev != output.stat().st_dev: + return False, None, ( + "Hard link failed: watch folder and library are on different " + "filesystems. Hard links require both paths on the same volume. " + "Move your library to the same volume as the watch folder, or " + "disable 'Use hard links' in Settings. Source files were not " + "modified." + ) + except OSError as e: + return False, None, f"Cannot verify filesystem compatibility: {e}" + # Sanitize author and title for filesystem safe_author = sanitize_path_component(author) if author else "Unknown" safe_title = sanitize_path_component(title) if title else source.name @@ -6598,10 +6614,6 @@ def move_to_output_folder(source_path: str, output_folder: str, author: str, tit if not atomic_move_done: dest_folder.mkdir(parents=True, exist_ok=True) - # Track if we fell back to copy (need to delete originals afterward) - used_copy_fallback = False - files_to_delete = [] - if atomic_move_done: # Atomic move succeeded - nothing more to do for the files pass @@ -6609,17 +6621,9 @@ def move_to_output_folder(source_path: str, output_folder: str, author: str, tit # Single file - move/link to destination folder dest_file = dest_folder / source.name if use_hard_links: - try: - os.link(source, dest_file) - except OSError as e: - if "Invalid cross-device link" in str(e) or e.errno == 18: - # Cross-filesystem - fall back to copy, then delete original - logger.warning(f"Hard link failed (cross-filesystem), falling back to copy+delete: {source.name}") - shutil.copy2(source, dest_file) - used_copy_fallback = True - files_to_delete.append(source) - else: - raise + # Pre-check guarantees same filesystem; other OSErrors (perm, ENOSPC) + # propagate to the outer handler with source intact. + os.link(source, dest_file) else: shutil.move(str(source), str(dest_file)) else: @@ -6637,23 +6641,13 @@ def move_to_output_folder(source_path: str, output_folder: str, author: str, tit dest_file.parent.mkdir(parents=True, exist_ok=True) if use_hard_links: - try: - os.link(src_file, dest_file) - except OSError as e: - if "Invalid cross-device link" in str(e) or e.errno == 18: - logger.warning(f"Hard link failed, copy+delete: {src_file.name}") - shutil.copy2(src_file, dest_file) - used_copy_fallback = True - files_to_delete.append(src_file) - else: - raise + os.link(src_file, dest_file) else: shutil.move(str(src_file), str(dest_file)) - # Clean up empty source folder if not using hard links OR if we used copy fallback - if (not use_hard_links or used_copy_fallback) and delete_empty: + # Clean up empty source folder when we moved files out (not for hardlinks — originals stay) + if not use_hard_links and delete_empty: try: - # Remove empty directories bottom-up for dirpath, dirnames, filenames in os.walk(str(source), topdown=False): if not filenames and not dirnames: os.rmdir(dirpath) @@ -6662,16 +6656,6 @@ def move_to_output_folder(source_path: str, output_folder: str, author: str, tit except Exception as e: logger.debug(f"Could not clean up empty folder {source}: {e}") - # Delete originals if we used copy fallback (handles both single files and directories) - if used_copy_fallback and delete_empty: - for f in files_to_delete: - try: - if f.exists(): - f.unlink() - logger.debug(f"Deleted source after copy fallback: {f}") - except Exception as e: - logger.warning(f"Could not delete source {f}: {e}") - return True, str(dest_folder), None except Exception as e: