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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

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.6.2] - 2026-10-05

### Added

- `corrupt_source: skip | encode_anyway`, under `settings` or on one output (default `skip`, the 0.6.1 behaviour). Some damaged FLACs have no clean copy to replace them, and refusing them leaves the phone with no copy of the song at all. With `encode_anyway`, a source that fails the strict decode still gets the same ERROR line, then one more encode without `-xerror` and `-err_detect`, so FFmpeg conceals the damaged frames. That copy is logged with one WARNING saying it was made from a damaged source, and its manifest row has kind `tolerant`. The source is still remembered as failed, so the strict attempt is not repeated on every scan, and a source that fails even the tolerant encode stays refused. When the damaged file is later replaced by a clean one (a different size or modification time), the next scan rebuilds the tolerant copy with the strict encode.

## [0.6.1] - 2026-10-04

### Added
Expand Down Expand Up @@ -53,5 +59,6 @@ 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.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
[0.6.0]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.0
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,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.6.1
drumsergio/audio-transcoder:0.6.2
```

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
9 changes: 8 additions & 1 deletion config.example.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Audio Transcode Watcher Configuration (0.6.1)
# Audio Transcode Watcher Configuration (0.6.2)
# ======================================
#
# This configuration file defines the source folder and output destinations
Expand Down Expand Up @@ -62,6 +62,13 @@ settings:
# Seconds between periodic full syncs (default: 300)
sync_interval_seconds: 300

# A source that does not decode cleanly (a damaged FLAC):
# skip write nothing for it (default)
# encode_anyway log the error, then encode it with FFmpeg's error
# concealment, so a one-frame glitch is better than no copy
# An output can set its own corrupt_source to override this.
corrupt_source: skip

# whisper_fallback and whisper_model were removed in 0.6.0 and are ignored


Expand Down
3 changes: 2 additions & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Roadmap

Current version: **0.6.1**
Current version: **0.6.2**

## Completed

Expand All @@ -13,6 +13,7 @@ Current version: **0.6.1**
- **Recursive directory support** -- mirror source folder hierarchy in outputs (v0.5.0)
- **Lossy sources copied, not inflated**, corrupt sources fail loudly, ReplayGain and MusicBrainz tags kept in M4A outputs, configurable periodic sync (v0.6.0)
- **Portable AAC**: stereo downmix and a 48 kHz cap for AAC outputs, per-output `channels` and `max_sample_rate` (v0.6.1)
- **Damaged sources**: `corrupt_source: encode_anyway` makes a concealed copy instead of none (v0.6.2)

## v0.7.0 -- Quality of Life

Expand Down
22 changes: 22 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,30 @@ settings:

# Seconds between periodic full syncs (default: 300)
sync_interval_seconds: 300

# What to do with a source that does not decode cleanly: skip | encode_anyway
# (default: skip). An output can set its own corrupt_source.
corrupt_source: skip
```

### Damaged sources

A source that fails the strict decode (see [Corrupt sources](how-it-works.md#corrupt-sources)) is handled by `corrupt_source`:

- `skip` (the default) writes nothing for it.
- `encode_anyway` logs the same ERROR, then encodes it once more without `-xerror` and `-err_detect`, letting FFmpeg conceal the damaged frames. The copy exists, one WARNING line says it was made from a damaged source, and its manifest row has kind `tolerant`. A source that fails even that run stays refused.

Set it under `settings` for every output, or on one output to override, for example only on the AAC output that a phone plays from:

```yaml
- name: aac-256
codec: aac
path: /music/aac
corrupt_source: encode_anyway
```

Either way the source is remembered as failed, so the strict attempt is not repeated on every scan. Replacing the file with a clean copy (a new size or modification time) clears that, and the next scan rebuilds the tolerant copy with the strict encode.

`whisper_fallback` and `whisper_model` were removed in 0.6.0 along with the Whisper lyrics fallback. A config that still sets them loads, and the first load logs one warning that they are ignored.

## Source formats
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.6.1
image: drumsergio/audio-transcoder:0.6.2
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.6.1
drumsergio/audio-transcoder:0.6.2
```

2 changes: 1 addition & 1 deletion docs/how-it-works.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Each output folder holds `.atw-manifest.json`, a map from each output file to th

## Corrupt sources

FFmpeg runs with `-xerror` and `-err_detect crccheck+explode`, so a frame whose checksum does not match stops the encode. If a source does not decode cleanly, the encode fails even when FFmpeg exits 0 but printed a decode error. The error is logged with the file name and no output is written. The file is not tried again until its modification time changes, or the service restarts.
FFmpeg runs with `-xerror` and `-err_detect crccheck+explode`, so a frame whose checksum does not match stops the encode. If a source does not decode cleanly, the encode fails even when FFmpeg exits 0 but printed a decode error. The error is logged with the file name and no output is written, unless `corrupt_source: encode_anyway` is set; then the file is encoded once more with FFmpeg's error concealment and marked `tolerant` in the manifest (see [Damaged sources](configuration.md#damaged-sources)). The strict attempt is not repeated until the file's modification time changes, or the service restarts.

## Recursive Directory Support

Expand Down
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.6.1"
version = "0.6.2"

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.6.1"
__version__ = "0.6.2"
23 changes: 23 additions & 0 deletions src/audio_transcode_watcher/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,19 @@
# Portable-playback limits applied when an output does not set them.
# AAC goes to phones, AirPods and CarPlay: stereo, at most 48 kHz.
# Any codec can opt in by setting channels / max_sample_rate; 0 = no limit.
# What to do with a source that does not decode cleanly: "skip" writes
# nothing; "encode_anyway" retries with ffmpeg's error concealment.
CORRUPT_SOURCE_MODES = ("skip", "encode_anyway")

DEFAULT_CHANNELS = {"aac": 2}
DEFAULT_MAX_SAMPLE_RATE = {"aac": 48000}


def _check_corrupt_source(value: Any, label: str) -> None:
if value not in CORRUPT_SOURCE_MODES:
raise ValueError(f"{label} must be one of {', '.join(CORRUPT_SOURCE_MODES)}")


@dataclass
class OutputConfig:
"""Configuration for a single output destination."""
Expand All @@ -60,6 +69,7 @@ class OutputConfig:
include_artwork: bool = True
channels: int | None = None # Downmix above this many channels; 0 = keep
max_sample_rate: int | None = None # Resample above this rate (Hz); 0 = keep
corrupt_source: str | None = None # "skip" | "encode_anyway"; None = global

def __post_init__(self) -> None:
"""Validate and set defaults after initialization."""
Expand All @@ -83,6 +93,10 @@ def __post_init__(self) -> None:
self.channels = DEFAULT_CHANNELS.get(self.codec, 0)
if self.max_sample_rate is None:
self.max_sample_rate = DEFAULT_MAX_SAMPLE_RATE.get(self.codec, 0)
if self.corrupt_source is not None:
_check_corrupt_source(
self.corrupt_source, f"Output '{self.name}': corrupt_source"
)
for key in ("channels", "max_sample_rate"):
value = getattr(self, key)
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
Expand Down Expand Up @@ -111,6 +125,7 @@ def from_dict(cls, data: dict[str, Any]) -> OutputConfig:
include_artwork=data.get("include_artwork", True),
channels=data.get("channels"),
max_sample_rate=data.get("max_sample_rate"),
corrupt_source=data.get("corrupt_source"),
)


Expand All @@ -127,12 +142,15 @@ class Config:
min_stable_seconds: float = 1.0
fetch_lyrics: bool = True # Auto-fetch .lrc lyrics via syncedlyrics
sync_interval_seconds: int = 300 # Seconds between periodic full syncs
corrupt_source: str = "skip" # Default for outputs that don't set it

def __post_init__(self) -> None:
"""Validate configuration after initialization."""
if not self.source_path:
raise ValueError("source_path is required")

_check_corrupt_source(self.corrupt_source, "settings.corrupt_source")

interval = self.sync_interval_seconds
if (
isinstance(interval, bool)
Expand All @@ -159,6 +177,10 @@ def output_paths(self) -> list[str]:
"""Get list of all output directory paths."""
return [o.path for o in self.outputs]

def corrupt_source_for(self, output: OutputConfig) -> str:
"""The corrupt_source mode for *output*: its own, else the global one."""
return output.corrupt_source or self.corrupt_source

def get_output_by_name(self, name: str) -> OutputConfig | None:
"""Get an output configuration by name."""
for output in self.outputs:
Expand All @@ -183,6 +205,7 @@ def from_dict(cls, data: dict[str, Any]) -> Config:
min_stable_seconds=settings.get("min_stable_seconds", 1.0),
fetch_lyrics=settings.get("fetch_lyrics", True),
sync_interval_seconds=settings.get("sync_interval_seconds", 300),
corrupt_source=settings.get("corrupt_source", "skip"),
)

@classmethod
Expand Down
39 changes: 33 additions & 6 deletions src/audio_transcode_watcher/encoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,31 @@ def _is_artwork_error(stderr: str) -> bool:
return any(h in low for h in _ARTWORK_ERROR_HINTS)


def _run_ffmpeg(cmd: list[str]) -> tuple[int, str]:
"""Run ffmpeg; a decode error on stderr counts as a failure even with rc 0."""
def tolerant_command(cmd: list[str]) -> list[str]:
"""*cmd* without -xerror and -err_detect, so ffmpeg conceals decode errors."""
out: list[str] = []
skip = False
for i, arg in enumerate(cmd):
if skip:
skip = False
continue
if arg == "-xerror":
continue
if arg == "-err_detect" and i + 1 < len(cmd):
skip = True
continue
out.append(arg)
return out


def _run_ffmpeg(cmd: list[str], strict: bool = True) -> tuple[int, str]:
"""
Run ffmpeg and return (rc, stderr).

Strict: a decode error on stderr is a failure even with rc 0, and any
failure that reports a decode error returns DECODE_ERROR_RC. Not strict:
only the exit code counts (concealed errors still print to stderr).
"""
try:
proc = subprocess.run(
cmd, capture_output=True, timeout=FFMPEG_TIMEOUT, check=False
Expand All @@ -285,7 +308,7 @@ def _run_ffmpeg(cmd: list[str]) -> tuple[int, str]:
return TIMEOUT_RC, f"ffmpeg timed out after {FFMPEG_TIMEOUT} s"
stderr = proc.stderr.decode("utf-8", errors="ignore") if proc.stderr else ""
rc = proc.returncode
if rc == 0 and _decode_error(stderr):
if strict and _decode_error(stderr):
rc = DECODE_ERROR_RC
return rc, stderr

Expand All @@ -295,6 +318,7 @@ def atomic_ffmpeg_encode(
final_dest: str,
retry_without_artwork: bool = True,
finalize: Callable[[str], None] | None = None,
strict: bool = True,
) -> int:
"""
Run FFmpeg with atomic output (write to temp, then rename).
Expand All @@ -306,9 +330,12 @@ def atomic_ffmpeg_encode(
failure points at the attached picture stream
finalize: Optional callable run on the finished temp file before
the rename (used to copy extra tags)
strict: If True, a decode error reported on stderr fails the encode
and the result is DECODE_ERROR_RC. False for the tolerant retry.

Returns:
Return code (0 for success). No output is written on failure.
Return code (0 for success, DECODE_ERROR_RC when the source did not
decode cleanly). No output is written on failure.
"""
final_dest = nfc_path(final_dest)
dest_dir = os.path.dirname(final_dest)
Expand All @@ -329,15 +356,15 @@ def atomic_ffmpeg_encode(
source = _source_from_cmd(cmd)

logger.info("► %s", " ".join(cmd))
rc, stderr = _run_ffmpeg(cmd)
rc, stderr = _run_ffmpeg(cmd, strict)

if rc != 0 and retry_without_artwork and _is_artwork_error(stderr):
_cleanup_temp(tmp_dest)
logger.warning("Retrying without cover art for %s", final_dest)
cmd = _remove_artwork_from_command(cmd)
cmd[-1] = tmp_dest
logger.info("► (retry) %s", " ".join(cmd))
rc, stderr = _run_ffmpeg(cmd)
rc, stderr = _run_ffmpeg(cmd, strict)

if rc != 0:
detail = _decode_error(stderr) or (stderr.strip().splitlines() or [""])[-1]
Expand Down
Loading
Loading