Skip to content

Commit 91cbb01

Browse files
authored
feat: keep a concealed copy of damaged sources when asked (0.6.2) (#44)
## Problem On the real library, 0.6.1's strict decode refuses 9 FLACs that have no clean copy on Soulseek today: Beatsteaks - Hail To The Freaks, Diana Ross & The Supremes - Baby Love, Die Toten Hosen - Tage wie diese and - Wannsee, Julieta Venegas - Limón y sal, Radiohead - High and Dry, Scott McKenzie - San Francisco, Simple Plan Feat. Sean Paul - Summer Paradise, and Steve Harley & Cockney Rebel - Make Me Smile. Refusing them means the phone has no copy of those songs at all. That is worse than the one-frame glitch the old release let through. ## Changes - **The setting.** `corrupt_source: skip | encode_anyway`, either under `settings` (for every output) or on one output (overrides the global value). The default is `skip`, which is exactly the 0.6.1 behaviour. - **What `encode_anyway` does.** The strict run fails and logs the same ERROR lines as today. Then one more encode runs without `-xerror` and `-err_detect crccheck+explode`, so ffmpeg conceals the damaged frames. If it works, the copy exists, one WARNING line says it was made from a damaged source, and its manifest row gets kind `tolerant`. A source that fails even that run stays refused. Timeouts and other non-decode failures get no tolerant run. - **Failure memory.** The source is still remembered as failed, so the strict attempt is not repeated on every scan. - **Clean replacement (from the CodeRabbit review).** A clean replacement has a new size or modification time. That clears the failure memory, and a plain scan now rebuilds the tolerant copy with the strict encode. Only `tolerant` rows are rebuilt this way. - **How the caller tells failures apart.** `atomic_ffmpeg_encode` now returns `DECODE_ERROR_RC` for any failure that printed a decode error, so it can be told from a timeout or a disk error. It takes `strict=False` for the tolerant run, where only the exit code counts. - **Release files.** Version 0.6.2, CHANGELOG entry, and docs for the setting in `docs/configuration.md` (a "Damaged sources" section), `docs/how-it-works.md` and `config.example.yaml`. ## Testing - CI ran in `python:3.14-slim` with FFmpeg 7.1.5: 435 passed, 0 skipped, 97% coverage. Codecov patch is 96.15% and project 96.32%. - **Both modes:** - `skip` runs one strict encode, writes no output, and remembers the source. - `encode_anyway`, set globally or per output, runs one strict encode and exactly one tolerant encode. The tolerant command has no strict flags. The copy is written with manifest kind `tolerant`, the ERROR is still logged, and exactly one WARNING appears. The source is remembered, and the next scan makes no encode at all. - A source that also fails the tolerant run stays refused, with no WARNING. A timeout gets no tolerant run. - **Real file:** a FLAC with 400 flipped bytes, on the local ffmpeg 9 and on 7.1 in CI, gets a tolerant AAC and is remembered as failed. - **Red controls:** every new test failed against a deliberate break, 21 mutations in all, run with bytecode caching off. - **Ruff:** `ruff format --check` is clean. `ruff check` still reports the 23 known `BLE001`/`S110` findings and nothing new.
1 parent 6157b6f commit 91cbb01

16 files changed

Lines changed: 499 additions & 27 deletions

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
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/).
44

5+
## [0.6.2] - 2026-10-05
6+
7+
### Added
8+
9+
- `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.
10+
511
## [0.6.1] - 2026-10-04
612

713
### Added
@@ -53,5 +59,6 @@ All notable changes to this project are documented here. The format follows [Kee
5359
- Lossy copies are written through a temp file and renamed, like encodes, so a half-written copy is never visible.
5460
- `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.
5561

62+
[0.6.2]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.2
5663
[0.6.1]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.1
5764
[0.6.0]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.0

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Keep one library in several formats: lossless for the archive, lossy for phones
3535
curl -fsSL -o config.yaml https://raw.githubusercontent.com/GeiserX/audio-transcode-watcher/main/config.example.yaml
3636
docker run -d --name audio_transcoder -e CONFIG_FILE=/app/config.yaml \
3737
-v ./config.yaml:/app/config.yaml:ro -v /path/to/flac:/music/flac:ro -v /path/to/mp3:/music/mp3 \
38-
drumsergio/audio-transcoder:0.6.1
38+
drumsergio/audio-transcoder:0.6.2
3939
```
4040

4141
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`.

‎config.example.yaml‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Audio Transcode Watcher Configuration (0.6.1)
1+
# Audio Transcode Watcher Configuration (0.6.2)
22
# ======================================
33
#
44
# This configuration file defines the source folder and output destinations
@@ -62,6 +62,13 @@ settings:
6262
# Seconds between periodic full syncs (default: 300)
6363
sync_interval_seconds: 300
6464

65+
# A source that does not decode cleanly (a damaged FLAC):
66+
# skip write nothing for it (default)
67+
# encode_anyway log the error, then encode it with FFmpeg's error
68+
# concealment, so a one-frame glitch is better than no copy
69+
# An output can set its own corrupt_source to override this.
70+
corrupt_source: skip
71+
6572
# whisper_fallback and whisper_model were removed in 0.6.0 and are ignored
6673

6774

‎docs/ROADMAP.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Roadmap
22

3-
Current version: **0.6.1**
3+
Current version: **0.6.2**
44

55
## Completed
66

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

1718
## v0.7.0 -- Quality of Life
1819

‎docs/configuration.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,30 @@ settings:
6060

6161
# Seconds between periodic full syncs (default: 300)
6262
sync_interval_seconds: 300
63+
64+
# What to do with a source that does not decode cleanly: skip | encode_anyway
65+
# (default: skip). An output can set its own corrupt_source.
66+
corrupt_source: skip
67+
```
68+
69+
### Damaged sources
70+
71+
A source that fails the strict decode (see [Corrupt sources](how-it-works.md#corrupt-sources)) is handled by `corrupt_source`:
72+
73+
- `skip` (the default) writes nothing for it.
74+
- `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.
75+
76+
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:
77+
78+
```yaml
79+
- name: aac-256
80+
codec: aac
81+
path: /music/aac
82+
corrupt_source: encode_anyway
6383
```
6484

85+
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.
86+
6587
`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.
6688

6789
## Source formats

‎docs/getting-started.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ outputs:
2929
```yaml
3030
services:
3131
audio-transcoder:
32-
image: drumsergio/audio-transcoder:0.6.1
32+
image: drumsergio/audio-transcoder:0.6.2
3333
container_name: audio_transcoder
3434
environment:
3535
- TZ=Europe/Madrid
@@ -60,6 +60,6 @@ docker run -d \
6060
-v /path/to/flac:/music/flac:ro \
6161
-v /path/to/mp3:/music/mp3 \
6262
--restart unless-stopped \
63-
drumsergio/audio-transcoder:0.6.1
63+
drumsergio/audio-transcoder:0.6.2
6464
```
6565

‎docs/how-it-works.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Each output folder holds `.atw-manifest.json`, a map from each output file to th
1717

1818
## Corrupt sources
1919

20-
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.
20+
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.
2121

2222
## Recursive Directory Support
2323

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
55
[project]
66
name = "audio-transcode-watcher"
77

8-
version = "0.6.1"
8+
version = "0.6.2"
99

1010
description = "Watch a source folder and automatically transcode audio files to multiple formats"
1111
readme = "README.md"
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Audio Transcode Watcher - Automatic audio file transcoding."""
22

3-
__version__ = "0.6.1"
3+
__version__ = "0.6.2"

‎src/audio_transcode_watcher/config.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,19 @@
4545
# Portable-playback limits applied when an output does not set them.
4646
# AAC goes to phones, AirPods and CarPlay: stereo, at most 48 kHz.
4747
# Any codec can opt in by setting channels / max_sample_rate; 0 = no limit.
48+
# What to do with a source that does not decode cleanly: "skip" writes
49+
# nothing; "encode_anyway" retries with ffmpeg's error concealment.
50+
CORRUPT_SOURCE_MODES = ("skip", "encode_anyway")
51+
4852
DEFAULT_CHANNELS = {"aac": 2}
4953
DEFAULT_MAX_SAMPLE_RATE = {"aac": 48000}
5054

5155

56+
def _check_corrupt_source(value: Any, label: str) -> None:
57+
if value not in CORRUPT_SOURCE_MODES:
58+
raise ValueError(f"{label} must be one of {', '.join(CORRUPT_SOURCE_MODES)}")
59+
60+
5261
@dataclass
5362
class OutputConfig:
5463
"""Configuration for a single output destination."""
@@ -60,6 +69,7 @@ class OutputConfig:
6069
include_artwork: bool = True
6170
channels: int | None = None # Downmix above this many channels; 0 = keep
6271
max_sample_rate: int | None = None # Resample above this rate (Hz); 0 = keep
72+
corrupt_source: str | None = None # "skip" | "encode_anyway"; None = global
6373

6474
def __post_init__(self) -> None:
6575
"""Validate and set defaults after initialization."""
@@ -83,6 +93,10 @@ def __post_init__(self) -> None:
8393
self.channels = DEFAULT_CHANNELS.get(self.codec, 0)
8494
if self.max_sample_rate is None:
8595
self.max_sample_rate = DEFAULT_MAX_SAMPLE_RATE.get(self.codec, 0)
96+
if self.corrupt_source is not None:
97+
_check_corrupt_source(
98+
self.corrupt_source, f"Output '{self.name}': corrupt_source"
99+
)
86100
for key in ("channels", "max_sample_rate"):
87101
value = getattr(self, key)
88102
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
@@ -111,6 +125,7 @@ def from_dict(cls, data: dict[str, Any]) -> OutputConfig:
111125
include_artwork=data.get("include_artwork", True),
112126
channels=data.get("channels"),
113127
max_sample_rate=data.get("max_sample_rate"),
128+
corrupt_source=data.get("corrupt_source"),
114129
)
115130

116131

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

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

152+
_check_corrupt_source(self.corrupt_source, "settings.corrupt_source")
153+
136154
interval = self.sync_interval_seconds
137155
if (
138156
isinstance(interval, bool)
@@ -159,6 +177,10 @@ def output_paths(self) -> list[str]:
159177
"""Get list of all output directory paths."""
160178
return [o.path for o in self.outputs]
161179

180+
def corrupt_source_for(self, output: OutputConfig) -> str:
181+
"""The corrupt_source mode for *output*: its own, else the global one."""
182+
return output.corrupt_source or self.corrupt_source
183+
162184
def get_output_by_name(self, name: str) -> OutputConfig | None:
163185
"""Get an output configuration by name."""
164186
for output in self.outputs:
@@ -183,6 +205,7 @@ def from_dict(cls, data: dict[str, Any]) -> Config:
183205
min_stable_seconds=settings.get("min_stable_seconds", 1.0),
184206
fetch_lyrics=settings.get("fetch_lyrics", True),
185207
sync_interval_seconds=settings.get("sync_interval_seconds", 300),
208+
corrupt_source=settings.get("corrupt_source", "skip"),
186209
)
187210

188211
@classmethod

0 commit comments

Comments
 (0)