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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
```

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`.
Expand Down
2 changes: 1 addition & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Roadmap

Current version: **0.7.0**
Current version: **0.7.1**

## Completed

Expand Down
4 changes: 2 additions & 2 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/audio_transcode_watcher/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Audio Transcode Watcher - Automatic audio file transcoding."""

__version__ = "0.7.0"
__version__ = "0.7.1"
14 changes: 8 additions & 6 deletions src/audio_transcode_watcher/encoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion src/audio_transcode_watcher/lyrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 10 additions & 8 deletions src/audio_transcode_watcher/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
62 changes: 37 additions & 25 deletions src/audio_transcode_watcher/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -231,13 +239,17 @@ 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):
manifest.restat(output.path, out_path, before)
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved


def _is_known_failure(source_path: str) -> bool:
Expand Down Expand Up @@ -367,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):
Expand Down Expand Up @@ -535,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
Expand Down Expand Up @@ -582,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
)
Expand All @@ -606,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)


Expand Down Expand Up @@ -637,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


Expand All @@ -663,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:
Expand Down Expand Up @@ -705,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)
Expand All @@ -718,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]
Expand Down Expand Up @@ -762,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(
Expand Down Expand Up @@ -801,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:
Expand Down Expand Up @@ -831,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
Expand Down
4 changes: 4 additions & 0 deletions src/audio_transcode_watcher/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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


Expand Down
Loading
Loading