From 75042783d451f2a228be5f36e777e1c8b702ce1f Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:59:02 +0200 Subject: [PATCH 1/4] fix: retry ReplayGain tags on outputs whose write failed A failed tag write into one output (a root-owned ALAC file answering Permission denied) still marked the source as checked, so no later scan retried it until the source changed or the service restarted. The source is now marked checked only when every output got its tags. --- src/audio_transcode_watcher/sync.py | 24 +++++++++++++++++------ tests/test_replaygain.py | 30 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/audio_transcode_watcher/sync.py b/src/audio_transcode_watcher/sync.py index e977fd5..ee2da4d 100644 --- a/src/audio_transcode_watcher/sync.py +++ b/src/audio_transcode_watcher/sync.py @@ -160,8 +160,12 @@ def process_source_file( _tag_source(source_path, config) if not _process_outputs(source_path, config, force): _remember_failure(source_path) - if config.replaygain and not replaygain.is_checked(source_path): - _carry_replaygain(source_path, config) + # An output that could not be tagged is tried again next scan. + if ( + config.replaygain + and not replaygain.is_checked(source_path) + and _carry_replaygain(source_path, config) + ): replaygain.mark_checked(source_path) # Auto-fetch lyrics if enabled and no .lrc sidecar exists if config.fetch_lyrics: @@ -201,21 +205,25 @@ def _tag_source(source_path: str, config: Config) -> None: manifest.restat(output.path, out_path, before, source_path=source_path) -def _carry_replaygain(source_path: str, config: Config) -> None: +def _carry_replaygain(source_path: str, config: Config) -> bool: """Give *source_path*'s existing outputs its ReplayGain track tags. Nothing is re-encoded. A copy is copied again, so it stays identical to its source; any other output gets the two tags written into it. + + Returns False if an output could not be tagged or recopied, so the + caller leaves the source unchecked and the next scan tries again. """ if not is_lossless(source_path) and _has_lossless_source(source_path, config): - return # its outputs belong to the lossless source of the stem + return True # its outputs belong to the lossless source of the stem try: values = replaygain.read_track_tags(source_path) except Exception as e: # noqa: BLE001 - unreadable means nothing to carry logger.debug("ReplayGain: cannot read %s: %s", source_path, e) - return + return True if replaygain.GAIN_TAG not in values: - return + return True + ok = True for output in config.outputs: filename, action = plan_output(source_path, output) out_path = get_output_file_path( @@ -231,6 +239,8 @@ def _carry_replaygain(source_path: str, config: Config) -> None: manifest.record( output.path, out_path, config.source_path, source_path, "copy" ) + else: + ok = False continue before = os.stat(out_path) if replaygain.write_track_tags(out_path, values): @@ -238,6 +248,8 @@ def _carry_replaygain(source_path: str, config: Config) -> None: logger.info("♫ ReplayGain tags → %s", out_path) except Exception as e: # noqa: BLE001 - one bad output never stops the pass logger.warning("ReplayGain: could not tag %s: %s", out_path, e) + ok = False + return ok def _is_known_failure(source_path: str) -> bool: diff --git a/tests/test_replaygain.py b/tests/test_replaygain.py index 28fbf39..6ff820a 100644 --- a/tests/test_replaygain.py +++ b/tests/test_replaygain.py @@ -503,6 +503,36 @@ def flaky(path, values): assert "could not tag" in caplog.text assert abs(_gain(tmp_path / "mp3" / "a.mp3") - EXPECTED_GAIN) < 0.5 + @pytest.mark.skipif( + hasattr(os, "geteuid") and os.geteuid() == 0, + reason="root writes through a read-only mode", + ) + def test_unwritable_output_is_retried_on_the_next_scan(self, tmp_path, caplog): + src = tmp_path / "src" + _tone(src / "a.flac") + config = _config(src, {"alac": "", "mp3": ""}, tmp_path, rg=False) + initial_sync(config) + alac = tmp_path / "alac" / "a.m4a" + alac.chmod(0o444) # an output the service cannot write, as root-owned + config.replaygain = True + try: + initial_sync(config, periodic=True) + assert f"could not tag {alac}" in caplog.text + assert replaygain.read_track_tags(str(alac)) == {} + assert abs(_gain(tmp_path / "mp3" / "a.mp3") - EXPECTED_GAIN) < 0.5 + + caplog.clear() + initial_sync(config, periodic=True) # still read-only: warned again + assert f"could not tag {alac}" in caplog.text + finally: + alac.chmod(0o644) + + caplog.clear() + initial_sync(config, periodic=True) + assert "could not tag" not in caplog.text + assert abs(_gain(alac) - EXPECTED_GAIN) < 0.5 + assert replaygain.is_checked(str(src / "a.flac")) + def test_tagging_error_never_blocks_the_encode(self, tmp_path, caplog): src = tmp_path / "src" _tone(src / "a.flac") From 247e4d602548407b0f17f51b77eabe3e6145efdb Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:59:04 +0200 Subject: [PATCH 2/4] fix: log every caught error instead of swallowing it Filesystem calls now catch OSError only. The broad catches that remain log a traceback, or log at debug level where the failure is expected noise. Clears the 23 BLE001/S110 findings ruff 0.16 reports. --- src/audio_transcode_watcher/encoder.py | 14 ++++++---- src/audio_transcode_watcher/lyrics.py | 2 +- src/audio_transcode_watcher/main.py | 18 ++++++------ src/audio_transcode_watcher/sync.py | 38 +++++++++++++------------- src/audio_transcode_watcher/utils.py | 4 +++ 5 files changed, 42 insertions(+), 34 deletions(-) diff --git a/src/audio_transcode_watcher/encoder.py b/src/audio_transcode_watcher/encoder.py index d08aba3..2dee950 100644 --- a/src/audio_transcode_watcher/encoder.py +++ b/src/audio_transcode_watcher/encoder.py @@ -347,8 +347,8 @@ def atomic_ffmpeg_encode( try: if os.path.exists(tmp_dest): os.remove(tmp_dest) - except Exception: - pass + except OSError as e: + logger.warning("Could not remove stale temp file %s: %s", tmp_dest, e) # Replace destination with temp path cmd = list(cmd) @@ -378,12 +378,14 @@ def atomic_ffmpeg_encode( try: finalize(tmp_dest) except Exception as e: - logger.warning("Post-encode step failed for %s: %s", final_dest, e) + logger.warning( + "Post-encode step failed for %s: %s", final_dest, e, exc_info=True + ) try: os.replace(tmp_dest, final_dest) return 0 - except Exception as e: + except OSError as e: logger.error("Atomic replace failed for %s: %s", final_dest, e) _cleanup_temp(tmp_dest) return 1 @@ -464,5 +466,5 @@ def _cleanup_temp(path: str) -> None: try: if os.path.exists(path): os.remove(path) - except Exception: - pass + except OSError as e: + logger.warning("Could not remove temp file %s: %s", path, e) diff --git a/src/audio_transcode_watcher/lyrics.py b/src/audio_transcode_watcher/lyrics.py index d67fc73..ba06e30 100644 --- a/src/audio_transcode_watcher/lyrics.py +++ b/src/audio_transcode_watcher/lyrics.py @@ -75,7 +75,7 @@ def extract_metadata(filepath: str) -> tuple[str, str] | None: if artist and title: return artist, title except Exception: - logger.debug("Could not read metadata from %s", filepath) + logger.debug("Could not read metadata from %s", filepath, exc_info=True) # Fallback: parse filename "Artist - Title.ext" stem = Path(filepath).stem diff --git a/src/audio_transcode_watcher/main.py b/src/audio_transcode_watcher/main.py index 3044360..24558e5 100644 --- a/src/audio_transcode_watcher/main.py +++ b/src/audio_transcode_watcher/main.py @@ -16,20 +16,22 @@ def setup_logging() -> None: """Configure logging for the application.""" - # Ensure Unicode output works + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s: %(message)s", + datefmt="%H:%M:%S", + ) + + # Ensure Unicode output works (reconfigure changes the stream in place, + # so the handler above writes UTF-8 too) try: if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8") if hasattr(sys.stderr, "reconfigure"): sys.stderr.reconfigure(encoding="utf-8") except Exception: - pass - - logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)s: %(message)s", - datefmt="%H:%M:%S", - ) + logger = logging.getLogger(__name__) + logger.warning("Could not switch output to UTF-8", exc_info=True) def main() -> int: diff --git a/src/audio_transcode_watcher/sync.py b/src/audio_transcode_watcher/sync.py index ee2da4d..4aba856 100644 --- a/src/audio_transcode_watcher/sync.py +++ b/src/audio_transcode_watcher/sync.py @@ -379,7 +379,7 @@ def _atomic_copy(source_path: str, out_path: str) -> bool: shutil.copy2(source_path, tmp) os.replace(tmp, out_path) return True - except Exception as e: + except OSError as e: logger.error("Copy failed %s → %s: %s", source_path, out_path, e) try: if os.path.exists(tmp): @@ -547,7 +547,7 @@ def delete_outputs(source_path: str, config: Config) -> None: try: logger.info("✘ remove %s", filepath) os.remove(filepath) - except Exception as e: + except OSError as e: logger.error("Failed to remove %s: %s", filepath, e) # Sidecars are shared by every source of the stem; remove them only @@ -594,7 +594,7 @@ def sync_sidecars(source_path: str, config: Config) -> None: os.makedirs(os.path.dirname(sidecar_dst), exist_ok=True) shutil.copy2(sidecar_src, sidecar_dst) logger.info("► copy sidecar %s → %s", sidecar_src, sidecar_dst) - except Exception as e: + except OSError as e: logger.error( "Failed to copy sidecar %s → %s: %s", sidecar_src, sidecar_dst, e ) @@ -618,7 +618,7 @@ def delete_sidecars(source_path: str, config: Config) -> None: try: logger.info("✘ remove sidecar %s", sidecar_path) os.remove(sidecar_path) - except Exception as e: + except OSError as e: logger.error("Failed to remove sidecar %s: %s", sidecar_path, e) @@ -649,10 +649,10 @@ def cleanup_stale_temp_files(config: Config) -> int: logger.info("✘ cleanup stale temp %s", full) os.remove(full) cleaned += 1 - except Exception as e: + except OSError as e: logger.error("Failed to remove temp file %s: %s", full, e) - except Exception as e: - logger.error("Failed to scan %s for temp files: %s", output.path, e) + except Exception: + logger.exception("Failed to scan %s for temp files", output.path) return cleaned @@ -675,12 +675,12 @@ def purge_all_outputs(config: Config) -> None: try: logger.info("✘ purge %s", full) os.remove(full) - except Exception as e: + except OSError as e: logger.error("Failed to purge %s: %s", full, e) manifest.forget(output.path) remove_empty_dirs(output.path) - except Exception as e: - logger.error("Failed to purge folder %s: %s", output.path, e) + except Exception: + logger.exception("Failed to purge folder %s", output.path) def initial_sync(config: Config, periodic: bool = False) -> None: @@ -717,8 +717,8 @@ def initial_sync(config: Config, periodic: bool = False) -> None: # Collect source files recursively try: source_files = walk_audio_files(config.source_path) - except Exception as e: - logger.error("Failed to scan source: %s", e) + except Exception: + logger.exception("Failed to scan source") return # Process all source files in parallel (skip stability check - files are on disk) @@ -730,8 +730,8 @@ def initial_sync(config: Config, periodic: bool = False) -> None: def process_one(src_file: str) -> None: try: process_source_file(src_file, config, force=False, check_stable=False) - except Exception as e: - logger.error("Error processing %s: %s", src_file, e) + except Exception: + logger.exception("Error processing %s", src_file) with ThreadPoolExecutor(max_workers=workers) as executor: futures = [executor.submit(process_one, f) for f in source_files] @@ -774,8 +774,8 @@ def _cleanup_orphans(config: Config) -> None: src_root = config.source_path try: source_files = walk_audio_files(src_root) - except Exception as e: - logger.error("Failed to scan source for orphan cleanup: %s", e) + except Exception: + logger.exception("Failed to scan source for orphan cleanup") return if not source_files: logger.warning( @@ -813,7 +813,7 @@ def remove(full: str, kind: str) -> None: try: logger.info("✘ remove %s %s", kind, full) os.remove(full) - except Exception as e: + except OSError as e: logger.error("Failed to remove %s: %s", full, e) for output in config.outputs: @@ -843,8 +843,8 @@ def remove(full: str, kind: str) -> None: logger.debug("Keeping young or in-progress %s", full) continue remove(full, kind) - except Exception as e: - logger.error("Failed to scan %s for orphans: %s", output.path, e) + except Exception: + logger.exception("Failed to scan %s for orphans", output.path) # Remove empty subdirectories left after orphan cleanup, and forget # manifest rows whose output is gone diff --git a/src/audio_transcode_watcher/utils.py b/src/audio_transcode_watcher/utils.py index 3e26120..998edbf 100644 --- a/src/audio_transcode_watcher/utils.py +++ b/src/audio_transcode_watcher/utils.py @@ -2,11 +2,14 @@ from __future__ import annotations +import logging import os import time import unicodedata from pathlib import Path +logger = logging.getLogger(__name__) + # Audio file extensions considered as source files LOSSLESS_EXTENSIONS = { ".flac", @@ -121,6 +124,7 @@ def appears_empty_dir(path: str) -> bool: return True except Exception: # On any error, treat as empty to be safe + logger.debug("Cannot list %s, treating it as empty", path, exc_info=True) return True From 4879fb4f8ad53f37efdc4d522c6da75780cd8fdf Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:59:06 +0200 Subject: [PATCH 3/4] chore(release): 0.7.1 --- CHANGELOG.md | 8 ++++++++ README.md | 2 +- config.example.yaml | 2 +- docs/ROADMAP.md | 2 +- docs/getting-started.md | 4 ++-- pyproject.toml | 2 +- src/audio_transcode_watcher/__init__.py | 2 +- uv.lock | 2 +- 8 files changed, 16 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5280b63..feb82d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/). +## [0.7.1] - 2026-10-07 + +### Fixed + +- An output whose ReplayGain tags could not be written is now tried again on the next periodic sync. Before, one failed write (for example a root-owned ALAC file answering "Permission denied") marked its source as done, so the output stayed untagged until the source changed or the service restarted. Each failed attempt logs the same warning, and the source counts as done only once every output has its tags. +- Errors are no longer swallowed silently. File operations now catch only filesystem errors and log them as before. The broad catches that stay, such as the one around each file of a sync, log the full traceback, and lyric metadata reads and unreadable folders log at debug level. A failed removal of a leftover temp file is now logged too. + ## [0.7.0] - 2026-10-06 ### Added @@ -65,6 +72,7 @@ All notable changes to this project are documented here. The format follows [Kee - Lossy copies are written through a temp file and renamed, like encodes, so a half-written copy is never visible. - `force_reencode: true` purges the outputs once at startup. It used to purge them again on every periodic sync, which re-encoded the whole library every five minutes. +[0.7.1]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.7.1 [0.7.0]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.7.0 [0.6.2]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.2 [0.6.1]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.1 diff --git a/README.md b/README.md index 88b8fe0..20b0fb5 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Keep one library in several formats: lossless for the archive, lossy for phones curl -fsSL -o config.yaml https://raw.githubusercontent.com/GeiserX/audio-transcode-watcher/main/config.example.yaml docker run -d --name audio_transcoder -e CONFIG_FILE=/app/config.yaml \ -v ./config.yaml:/app/config.yaml:ro -v /path/to/flac:/music/flac:ro -v /path/to/mp3:/music/mp3 \ - drumsergio/audio-transcoder:0.7.0 + drumsergio/audio-transcoder:0.7.1 ``` Edit `config.yaml` first so its outputs match the folders you mount; the example writes ALAC, MP3 and AAC. [Getting started](https://github.com/GeiserX/audio-transcode-watcher/blob/main/docs/getting-started.md) has Docker Compose and the full `docker run`. diff --git a/config.example.yaml b/config.example.yaml index 1fcba79..0769052 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,4 +1,4 @@ -# Audio Transcode Watcher Configuration (0.7.0) +# Audio Transcode Watcher Configuration (0.7.1) # ====================================== # # This configuration file defines the source folder and output destinations diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7fd870e..4a587f8 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap -Current version: **0.7.0** +Current version: **0.7.1** ## Completed diff --git a/docs/getting-started.md b/docs/getting-started.md index 8985ddc..7903e1e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -29,7 +29,7 @@ outputs: ```yaml services: audio-transcoder: - image: drumsergio/audio-transcoder:0.7.0 + image: drumsergio/audio-transcoder:0.7.1 container_name: audio_transcoder environment: - TZ=Europe/Madrid @@ -60,6 +60,6 @@ docker run -d \ -v /path/to/flac:/music/flac:ro \ -v /path/to/mp3:/music/mp3 \ --restart unless-stopped \ - drumsergio/audio-transcoder:0.7.0 + drumsergio/audio-transcoder:0.7.1 ``` diff --git a/pyproject.toml b/pyproject.toml index 5721c2f..f0e3625 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "audio-transcode-watcher" -version = "0.7.0" +version = "0.7.1" description = "Watch a source folder and automatically transcode audio files to multiple formats" readme = "README.md" diff --git a/src/audio_transcode_watcher/__init__.py b/src/audio_transcode_watcher/__init__.py index 4ac47b5..f965fc4 100644 --- a/src/audio_transcode_watcher/__init__.py +++ b/src/audio_transcode_watcher/__init__.py @@ -1,3 +1,3 @@ """Audio Transcode Watcher - Automatic audio file transcoding.""" -__version__ = "0.7.0" +__version__ = "0.7.1" diff --git a/uv.lock b/uv.lock index b642867..5e45b37 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.14" [[package]] name = "audio-transcode-watcher" -version = "0.7.0" +version = "0.7.1" source = { editable = "." } dependencies = [ { name = "mutagen" }, From 40ea272c8e8f205ad74a3deef8230f3ae4430954 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Wed, 7 Oct 2026 18:05:37 +0200 Subject: [PATCH 4/4] test: retry a lossy copy whose ReplayGain recopy failed --- tests/test_replaygain.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_replaygain.py b/tests/test_replaygain.py index 6ff820a..b7bab2c 100644 --- a/tests/test_replaygain.py +++ b/tests/test_replaygain.py @@ -533,6 +533,19 @@ def test_unwritable_output_is_retried_on_the_next_scan(self, tmp_path, caplog): assert abs(_gain(alac) - EXPECTED_GAIN) < 0.5 assert replaygain.is_checked(str(src / "a.flac")) + def test_failed_recopy_is_retried_on_the_next_scan(self, tmp_path): + src = tmp_path / "src" + mp3 = _tone(src / "a.mp3", "-c:a", "libmp3lame") + config = _config(src, {"alac": ""}, tmp_path, rg=False) + initial_sync(config) + config.replaygain = True + with patch.object(sync_mod, "_atomic_copy", return_value=False): + initial_sync(config, periodic=True) + assert not replaygain.is_checked(str(mp3)) + initial_sync(config, periodic=True) + assert filecmp.cmp(mp3, tmp_path / "alac" / "a.mp3", shallow=False) + assert replaygain.is_checked(str(mp3)) + def test_tagging_error_never_blocks_the_encode(self, tmp_path, caplog): src = tmp_path / "src" _tone(src / "a.flac")