Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
Expand Down
62 changes: 23 additions & 39 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -737,7 +737,7 @@
try:
with open(ERROR_REPORTS_PATH, 'r') as f:
reports = json.load(f)
except:

Check failure on line 740 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (E722)

app.py:740:13: E722 Do not use bare `except`
reports = []

# Add new report (keep last 100 reports to avoid file bloat)
Expand All @@ -761,7 +761,7 @@
try:
with open(ERROR_REPORTS_PATH, 'r') as f:
return json.load(f)
except:

Check failure on line 764 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (E722)

app.py:764:9: E722 Do not use bare `except`
return []
return []

Expand Down Expand Up @@ -1716,7 +1716,7 @@
continue
result = call_gemini(prompt, merged_config)
if result:
logger.info(f"[PROVIDER CHAIN] Success with gemini")

Check failure on line 1719 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F541)

app.py:1719:33: F541 f-string without any placeholders help: Remove extraneous `f` prefix
return result

elif provider == 'openrouter':
Expand All @@ -1725,13 +1725,13 @@
continue
result = call_openrouter(prompt, merged_config)
if result:
logger.info(f"[PROVIDER CHAIN] Success with openrouter")

Check failure on line 1728 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F541)

app.py:1728:33: F541 f-string without any placeholders help: Remove extraneous `f` prefix
return result

elif provider == 'ollama':
result = call_ollama(prompt, merged_config)
if result:
logger.info(f"[PROVIDER CHAIN] Success with ollama")

Check failure on line 1734 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F541)

app.py:1734:33: F541 f-string without any placeholders help: Remove extraneous `f` prefix
return result

else:
Expand Down Expand Up @@ -1833,7 +1833,7 @@
return result
elif result and result.get('transcript'):
# Got transcript but no match - still useful, return for potential AI fallback
logger.info(f"[AUDIO CHAIN] BookDB returned transcript only")

Check failure on line 1836 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F541)

app.py:1836:37: F541 f-string without any placeholders help: Remove extraneous `f` prefix
return result
elif result is None and attempt < max_retries - 1:
# Connection might be down, wait and retry
Expand Down Expand Up @@ -2165,11 +2165,11 @@
device = "cuda"
# int8 works on all CUDA devices including GTX 1080 (compute 6.1)
# float16 only works on newer GPUs (compute 7.0+)
logger.info(f"[WHISPER] Using CUDA GPU acceleration (10x faster)")

Check failure on line 2168 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F541)

app.py:2168:29: F541 f-string without any placeholders help: Remove extraneous `f` prefix
else:
logger.info(f"[WHISPER] Using CPU (no CUDA GPU detected)")

Check failure on line 2170 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F541)

app.py:2170:29: F541 f-string without any placeholders help: Remove extraneous `f` prefix
except ImportError:
logger.info(f"[WHISPER] Using CPU (ctranslate2 not available)")

Check failure on line 2172 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (F541)

app.py:2172:25: F541 f-string without any placeholders help: Remove extraneous `f` prefix

_whisper_model = WhisperModel(model_name, device=device, compute_type=compute_type)
_whisper_model_name = model_name
Expand Down Expand Up @@ -2376,7 +2376,7 @@
if sample_path and os.path.exists(sample_path):
try:
os.unlink(sample_path)
except:

Check failure on line 2379 in app.py

View workflow job for this annotation

GitHub Actions / lint

ruff (E722)

app.py:2379:13: E722 Do not use bare `except`
pass

return result
Expand Down Expand Up @@ -6520,6 +6520,22 @@
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
Expand Down Expand Up @@ -6598,28 +6614,16 @@
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
elif source.is_file():
# 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:
Expand All @@ -6637,23 +6641,13 @@
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)
Expand All @@ -6662,16 +6656,6 @@
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:
Expand Down
Loading