audio-transcode-watcher 0.6.1__tar.gz → 0.6.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/.beads/config.yaml +2 -2
  2. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/AGENTS.md +1 -1
  3. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/CHANGELOG.md +7 -0
  4. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/CLAUDE.md +1 -1
  5. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/PKG-INFO +2 -2
  6. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/README.md +1 -1
  7. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/config.example.yaml +8 -1
  8. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/docs/ROADMAP.md +2 -1
  9. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/docs/configuration.md +22 -0
  10. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/docs/getting-started.md +2 -2
  11. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/docs/how-it-works.md +1 -1
  12. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/pyproject.toml +1 -1
  13. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/src/audio_transcode_watcher/__init__.py +1 -1
  14. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/src/audio_transcode_watcher/config.py +23 -0
  15. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/src/audio_transcode_watcher/encoder.py +33 -6
  16. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/src/audio_transcode_watcher/sync.py +55 -12
  17. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/tests/test_config.py +36 -0
  18. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/tests/test_encoder.py +41 -0
  19. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/tests/test_sync.py +265 -0
  20. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/uv.lock +1 -1
  21. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/.beads/.gitignore +0 -0
  22. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/.beads/README.md +0 -0
  23. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/.beads/hooks/commit-msg +0 -0
  24. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/.beads/hooks/post-checkout +0 -0
  25. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/.beads/hooks/post-merge +0 -0
  26. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/.beads/hooks/pre-commit +0 -0
  27. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/.beads/hooks/pre-push +0 -0
  28. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/.beads/hooks/prepare-commit-msg +0 -0
  29. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/.beads/metadata.json +0 -0
  30. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/.claude/settings.json +0 -0
  31. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/.coderabbit.yaml +0 -0
  32. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/.github/FUNDING.yml +0 -0
  33. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/.github/dependabot.yml +0 -0
  34. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/.github/workflows/dockerhub-description.yml +0 -0
  35. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/.github/workflows/release.yml +0 -0
  36. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/.github/workflows/stale.yml +0 -0
  37. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/.github/workflows/tests.yml +0 -0
  38. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/.gitignore +0 -0
  39. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/Dockerfile +0 -0
  40. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/LICENSE +0 -0
  41. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/SECURITY.md +0 -0
  42. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/codecov.yml +0 -0
  43. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/docs/development.md +0 -0
  44. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/docs/images/banner.svg +0 -0
  45. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/docs/images/icon.png +0 -0
  46. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/docs/images/icon.svg +0 -0
  47. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/docs/images/social-preview.png +0 -0
  48. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/docs/usage.md +0 -0
  49. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/src/audio_transcode_watcher/lyrics.py +0 -0
  50. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/src/audio_transcode_watcher/main.py +0 -0
  51. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/src/audio_transcode_watcher/manifest.py +0 -0
  52. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/src/audio_transcode_watcher/utils.py +0 -0
  53. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/src/audio_transcode_watcher/watcher.py +0 -0
  54. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/tests/__init__.py +0 -0
  55. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/tests/conftest.py +0 -0
  56. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/tests/test_lyrics.py +0 -0
  57. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/tests/test_main.py +0 -0
  58. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/tests/test_manifest.py +0 -0
  59. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/tests/test_utils.py +0 -0
  60. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/tests/test_watcher.py +0 -0
  61. {audio_transcode_watcher-0.6.1 → audio_transcode_watcher-0.6.2}/tools/verify_sync.py +0 -0
@@ -65,7 +65,7 @@
65
65
  # - linear.api_key → use LINEAR_API_KEY env var instead
66
66
  # - github.token → use GITHUB_TOKEN env var instead
67
67
 
68
- # Public repo: the tracker syncs only to this private Gitea repo, never to GitHub.
69
- sync.remote: "git+ssh://git@gitea.geiser.cloud:2222/giteaer/audio-transcode-watcher-beads.git"
68
+ # Public repo: the tracker syncs only to this private remote, never to GitHub.
69
+ sync.remote: "git+ssh://git@beads-tracker/giteaer/audio-transcode-watcher-beads.git"
70
70
  # Every bd write is a Dolt commit, so a push always carries it.
71
71
  dolt.auto-commit: "on"
@@ -104,4 +104,4 @@ This protocol applies when ending a Beads implementation workflow. It is subordi
104
104
 
105
105
  ## Where the tracker syncs
106
106
 
107
- This repo is public, so its tracker syncs only to the private Dolt remote named by `sync.remote` in `.beads/config.yaml` (`giteaer/audio-transcode-watcher-beads` on Gitea). The block above says sync uses "your git remote". Here that never means this GitHub repo. Don't add it as a Dolt remote and don't push `refs/dolt/*` to it.
107
+ This repo is public, so its tracker syncs only to the private remote named by `sync.remote` in `.beads/config.yaml`. The block above says sync uses "your git remote". Here that never means this GitHub repo. Don't add it as a Dolt remote and don't push `refs/dolt/*` to it.
@@ -2,6 +2,12 @@
2
2
 
3
3
  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/).
4
4
 
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
+
5
11
  ## [0.6.1] - 2026-10-04
6
12
 
7
13
  ### Added
@@ -53,5 +59,6 @@ All notable changes to this project are documented here. The format follows [Kee
53
59
  - Lossy copies are written through a temp file and renamed, like encodes, so a half-written copy is never visible.
54
60
  - `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.
55
61
 
62
+ [0.6.2]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.2
56
63
  [0.6.1]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.1
57
64
  [0.6.0]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.0
@@ -100,4 +100,4 @@ This protocol applies when ending a Beads implementation workflow. It is subordi
100
100
 
101
101
  ## Where the tracker syncs
102
102
 
103
- This repo is public, so its tracker syncs only to the private Dolt remote named by `sync.remote` in `.beads/config.yaml` (`giteaer/audio-transcode-watcher-beads` on Gitea). The block above says sync uses "your git remote". Here that never means this GitHub repo. Don't add it as a Dolt remote and don't push `refs/dolt/*` to it.
103
+ This repo is public, so its tracker syncs only to the private remote named by `sync.remote` in `.beads/config.yaml`. The block above says sync uses "your git remote". Here that never means this GitHub repo. Don't add it as a Dolt remote and don't push `refs/dolt/*` to it.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: audio-transcode-watcher
3
- Version: 0.6.1
3
+ Version: 0.6.2
4
4
  Summary: Watch a source folder and automatically transcode audio files to multiple formats
5
5
  Project-URL: Homepage, https://github.com/GeiserX/audio-transcode-watcher
6
6
  Project-URL: Repository, https://github.com/GeiserX/audio-transcode-watcher
@@ -66,7 +66,7 @@ Keep one library in several formats: lossless for the archive, lossy for phones
66
66
  curl -fsSL -o config.yaml https://raw.githubusercontent.com/GeiserX/audio-transcode-watcher/main/config.example.yaml
67
67
  docker run -d --name audio_transcoder -e CONFIG_FILE=/app/config.yaml \
68
68
  -v ./config.yaml:/app/config.yaml:ro -v /path/to/flac:/music/flac:ro -v /path/to/mp3:/music/mp3 \
69
- drumsergio/audio-transcoder:0.6.1
69
+ drumsergio/audio-transcoder:0.6.2
70
70
  ```
71
71
 
72
72
  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`.
@@ -35,7 +35,7 @@ Keep one library in several formats: lossless for the archive, lossy for phones
35
35
  curl -fsSL -o config.yaml https://raw.githubusercontent.com/GeiserX/audio-transcode-watcher/main/config.example.yaml
36
36
  docker run -d --name audio_transcoder -e CONFIG_FILE=/app/config.yaml \
37
37
  -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
39
39
  ```
40
40
 
41
41
  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`.
@@ -1,4 +1,4 @@
1
- # Audio Transcode Watcher Configuration (0.6.1)
1
+ # Audio Transcode Watcher Configuration (0.6.2)
2
2
  # ======================================
3
3
  #
4
4
  # This configuration file defines the source folder and output destinations
@@ -62,6 +62,13 @@ settings:
62
62
  # Seconds between periodic full syncs (default: 300)
63
63
  sync_interval_seconds: 300
64
64
 
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
+
65
72
  # whisper_fallback and whisper_model were removed in 0.6.0 and are ignored
66
73
 
67
74
 
@@ -1,6 +1,6 @@
1
1
  # Roadmap
2
2
 
3
- Current version: **0.6.1**
3
+ Current version: **0.6.2**
4
4
 
5
5
  ## Completed
6
6
 
@@ -13,6 +13,7 @@ Current version: **0.6.1**
13
13
  - **Recursive directory support** -- mirror source folder hierarchy in outputs (v0.5.0)
14
14
  - **Lossy sources copied, not inflated**, corrupt sources fail loudly, ReplayGain and MusicBrainz tags kept in M4A outputs, configurable periodic sync (v0.6.0)
15
15
  - **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)
16
17
 
17
18
  ## v0.7.0 -- Quality of Life
18
19
 
@@ -60,8 +60,30 @@ settings:
60
60
 
61
61
  # Seconds between periodic full syncs (default: 300)
62
62
  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
63
83
  ```
64
84
 
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
+
65
87
  `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.
66
88
 
67
89
  ## Source formats
@@ -29,7 +29,7 @@ outputs:
29
29
  ```yaml
30
30
  services:
31
31
  audio-transcoder:
32
- image: drumsergio/audio-transcoder:0.6.1
32
+ image: drumsergio/audio-transcoder:0.6.2
33
33
  container_name: audio_transcoder
34
34
  environment:
35
35
  - TZ=Europe/Madrid
@@ -60,6 +60,6 @@ docker run -d \
60
60
  -v /path/to/flac:/music/flac:ro \
61
61
  -v /path/to/mp3:/music/mp3 \
62
62
  --restart unless-stopped \
63
- drumsergio/audio-transcoder:0.6.1
63
+ drumsergio/audio-transcoder:0.6.2
64
64
  ```
65
65
 
@@ -17,7 +17,7 @@ Each output folder holds `.atw-manifest.json`, a map from each output file to th
17
17
 
18
18
  ## Corrupt sources
19
19
 
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.
21
21
 
22
22
  ## Recursive Directory Support
23
23
 
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
  [project]
6
6
  name = "audio-transcode-watcher"
7
7
 
8
- version = "0.6.1"
8
+ version = "0.6.2"
9
9
 
10
10
  description = "Watch a source folder and automatically transcode audio files to multiple formats"
11
11
  readme = "README.md"
@@ -1,3 +1,3 @@
1
1
  """Audio Transcode Watcher - Automatic audio file transcoding."""
2
2
 
3
- __version__ = "0.6.1"
3
+ __version__ = "0.6.2"
@@ -45,10 +45,19 @@ DEFAULT_BITRATES = {
45
45
  # Portable-playback limits applied when an output does not set them.
46
46
  # AAC goes to phones, AirPods and CarPlay: stereo, at most 48 kHz.
47
47
  # 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
+
48
52
  DEFAULT_CHANNELS = {"aac": 2}
49
53
  DEFAULT_MAX_SAMPLE_RATE = {"aac": 48000}
50
54
 
51
55
 
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
+
52
61
  @dataclass
53
62
  class OutputConfig:
54
63
  """Configuration for a single output destination."""
@@ -60,6 +69,7 @@ class OutputConfig:
60
69
  include_artwork: bool = True
61
70
  channels: int | None = None # Downmix above this many channels; 0 = keep
62
71
  max_sample_rate: int | None = None # Resample above this rate (Hz); 0 = keep
72
+ corrupt_source: str | None = None # "skip" | "encode_anyway"; None = global
63
73
 
64
74
  def __post_init__(self) -> None:
65
75
  """Validate and set defaults after initialization."""
@@ -83,6 +93,10 @@ class OutputConfig:
83
93
  self.channels = DEFAULT_CHANNELS.get(self.codec, 0)
84
94
  if self.max_sample_rate is None:
85
95
  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
+ )
86
100
  for key in ("channels", "max_sample_rate"):
87
101
  value = getattr(self, key)
88
102
  if isinstance(value, bool) or not isinstance(value, int) or value < 0:
@@ -111,6 +125,7 @@ class OutputConfig:
111
125
  include_artwork=data.get("include_artwork", True),
112
126
  channels=data.get("channels"),
113
127
  max_sample_rate=data.get("max_sample_rate"),
128
+ corrupt_source=data.get("corrupt_source"),
114
129
  )
115
130
 
116
131
 
@@ -127,12 +142,15 @@ class Config:
127
142
  min_stable_seconds: float = 1.0
128
143
  fetch_lyrics: bool = True # Auto-fetch .lrc lyrics via syncedlyrics
129
144
  sync_interval_seconds: int = 300 # Seconds between periodic full syncs
145
+ corrupt_source: str = "skip" # Default for outputs that don't set it
130
146
 
131
147
  def __post_init__(self) -> None:
132
148
  """Validate configuration after initialization."""
133
149
  if not self.source_path:
134
150
  raise ValueError("source_path is required")
135
151
 
152
+ _check_corrupt_source(self.corrupt_source, "settings.corrupt_source")
153
+
136
154
  interval = self.sync_interval_seconds
137
155
  if (
138
156
  isinstance(interval, bool)
@@ -159,6 +177,10 @@ class Config:
159
177
  """Get list of all output directory paths."""
160
178
  return [o.path for o in self.outputs]
161
179
 
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
+
162
184
  def get_output_by_name(self, name: str) -> OutputConfig | None:
163
185
  """Get an output configuration by name."""
164
186
  for output in self.outputs:
@@ -183,6 +205,7 @@ class Config:
183
205
  min_stable_seconds=settings.get("min_stable_seconds", 1.0),
184
206
  fetch_lyrics=settings.get("fetch_lyrics", True),
185
207
  sync_interval_seconds=settings.get("sync_interval_seconds", 300),
208
+ corrupt_source=settings.get("corrupt_source", "skip"),
186
209
  )
187
210
 
188
211
  @classmethod
@@ -275,8 +275,31 @@ def _is_artwork_error(stderr: str) -> bool:
275
275
  return any(h in low for h in _ARTWORK_ERROR_HINTS)
276
276
 
277
277
 
278
- def _run_ffmpeg(cmd: list[str]) -> tuple[int, str]:
279
- """Run ffmpeg; a decode error on stderr counts as a failure even with rc 0."""
278
+ def tolerant_command(cmd: list[str]) -> list[str]:
279
+ """*cmd* without -xerror and -err_detect, so ffmpeg conceals decode errors."""
280
+ out: list[str] = []
281
+ skip = False
282
+ for i, arg in enumerate(cmd):
283
+ if skip:
284
+ skip = False
285
+ continue
286
+ if arg == "-xerror":
287
+ continue
288
+ if arg == "-err_detect" and i + 1 < len(cmd):
289
+ skip = True
290
+ continue
291
+ out.append(arg)
292
+ return out
293
+
294
+
295
+ def _run_ffmpeg(cmd: list[str], strict: bool = True) -> tuple[int, str]:
296
+ """
297
+ Run ffmpeg and return (rc, stderr).
298
+
299
+ Strict: a decode error on stderr is a failure even with rc 0, and any
300
+ failure that reports a decode error returns DECODE_ERROR_RC. Not strict:
301
+ only the exit code counts (concealed errors still print to stderr).
302
+ """
280
303
  try:
281
304
  proc = subprocess.run(
282
305
  cmd, capture_output=True, timeout=FFMPEG_TIMEOUT, check=False
@@ -285,7 +308,7 @@ def _run_ffmpeg(cmd: list[str]) -> tuple[int, str]:
285
308
  return TIMEOUT_RC, f"ffmpeg timed out after {FFMPEG_TIMEOUT} s"
286
309
  stderr = proc.stderr.decode("utf-8", errors="ignore") if proc.stderr else ""
287
310
  rc = proc.returncode
288
- if rc == 0 and _decode_error(stderr):
311
+ if strict and _decode_error(stderr):
289
312
  rc = DECODE_ERROR_RC
290
313
  return rc, stderr
291
314
 
@@ -295,6 +318,7 @@ def atomic_ffmpeg_encode(
295
318
  final_dest: str,
296
319
  retry_without_artwork: bool = True,
297
320
  finalize: Callable[[str], None] | None = None,
321
+ strict: bool = True,
298
322
  ) -> int:
299
323
  """
300
324
  Run FFmpeg with atomic output (write to temp, then rename).
@@ -306,9 +330,12 @@ def atomic_ffmpeg_encode(
306
330
  failure points at the attached picture stream
307
331
  finalize: Optional callable run on the finished temp file before
308
332
  the rename (used to copy extra tags)
333
+ strict: If True, a decode error reported on stderr fails the encode
334
+ and the result is DECODE_ERROR_RC. False for the tolerant retry.
309
335
 
310
336
  Returns:
311
- Return code (0 for success). No output is written on failure.
337
+ Return code (0 for success, DECODE_ERROR_RC when the source did not
338
+ decode cleanly). No output is written on failure.
312
339
  """
313
340
  final_dest = nfc_path(final_dest)
314
341
  dest_dir = os.path.dirname(final_dest)
@@ -329,7 +356,7 @@ def atomic_ffmpeg_encode(
329
356
  source = _source_from_cmd(cmd)
330
357
 
331
358
  logger.info("► %s", " ".join(cmd))
332
- rc, stderr = _run_ffmpeg(cmd)
359
+ rc, stderr = _run_ffmpeg(cmd, strict)
333
360
 
334
361
  if rc != 0 and retry_without_artwork and _is_artwork_error(stderr):
335
362
  _cleanup_temp(tmp_dest)
@@ -337,7 +364,7 @@ def atomic_ffmpeg_encode(
337
364
  cmd = _remove_artwork_from_command(cmd)
338
365
  cmd[-1] = tmp_dest
339
366
  logger.info("► (retry) %s", " ".join(cmd))
340
- rc, stderr = _run_ffmpeg(cmd)
367
+ rc, stderr = _run_ffmpeg(cmd, strict)
341
368
 
342
369
  if rc != 0:
343
370
  detail = _decode_error(stderr) or (stderr.strip().splitlines() or [""])[-1]
@@ -14,10 +14,12 @@ from pathlib import Path
14
14
  from . import manifest
15
15
  from .config import Config, OutputConfig
16
16
  from .encoder import (
17
+ DECODE_ERROR_RC,
17
18
  atomic_ffmpeg_encode,
18
19
  build_ffmpeg_command,
19
20
  copy_mp4_tags,
20
21
  portable_limit_args,
22
+ tolerant_command,
21
23
  )
22
24
  from .lyrics import fetch_lyrics_for_file
23
25
  from .utils import (
@@ -320,6 +322,23 @@ def _made_from_lossy(output: OutputConfig, out_path: str) -> bool:
320
322
  return True
321
323
 
322
324
 
325
+ def _tolerant_source_changed(
326
+ output: OutputConfig, out_path: str, source_path: str
327
+ ) -> bool:
328
+ """True if *out_path* is a tolerant copy and its source has since changed.
329
+
330
+ A damaged source replaced by a clean copy gets a strict encode again.
331
+ """
332
+ row = manifest.lookup(output.path, out_path)
333
+ if not row or row.get("kind") != "tolerant":
334
+ return False
335
+ try:
336
+ st = os.stat(source_path)
337
+ except OSError:
338
+ return False
339
+ return (st.st_size, st.st_mtime) != (row.get("size"), row.get("mtime"))
340
+
341
+
323
342
  def _process_outputs(source_path: str, config: Config, force: bool) -> bool:
324
343
  """
325
344
  Process all outputs for a source file.
@@ -347,13 +366,19 @@ def _process_outputs(source_path: str, config: Config, force: bool) -> bool:
347
366
  if siblings:
348
367
  _remove_lossy_copies(source_path, siblings, output, config)
349
368
  if not force and os.path.exists(out_path):
350
- if not (is_lossless(source_path) and _made_from_lossy(output, out_path)):
369
+ if _tolerant_source_changed(output, out_path, source_path):
370
+ logger.info(
371
+ "Rebuilding %s: it was made from a damaged source that has changed",
372
+ out_path,
373
+ )
374
+ elif is_lossless(source_path) and _made_from_lossy(output, out_path):
375
+ logger.info(
376
+ "Replacing %s: it was made from a lossy source, %s wins",
377
+ out_path,
378
+ source_path,
379
+ )
380
+ else:
351
381
  continue
352
- logger.info(
353
- "Replacing %s: it was made from a lossy source, %s wins",
354
- out_path,
355
- source_path,
356
- )
357
382
 
358
383
  os.makedirs(os.path.dirname(out_path), exist_ok=True)
359
384
  if action == "copy":
@@ -371,18 +396,36 @@ def _process_outputs(source_path: str, config: Config, force: bool) -> bool:
371
396
 
372
397
  cmd = build_ffmpeg_command(source_path, out_path, output)
373
398
  rc = atomic_ffmpeg_encode(cmd, out_path, finalize=finalize)
374
- if rc == 0:
375
- kind = "encode" if is_lossless(source_path) else "transcode"
376
- manifest.record(
377
- output.path, out_path, config.source_path, source_path, kind
378
- )
379
- else:
399
+ kind = "encode" if is_lossless(source_path) else "transcode"
400
+ if rc != 0:
380
401
  logger.error(
381
402
  "%s encode failed for %s",
382
403
  output.name.upper(),
383
404
  source_path,
384
405
  )
406
+ # Remembered even if the tolerant run below succeeds, so the
407
+ # strict attempt is not repeated on every scan.
385
408
  ok = False
409
+ if (
410
+ rc == DECODE_ERROR_RC
411
+ and config.corrupt_source_for(output) == "encode_anyway"
412
+ ):
413
+ rc = atomic_ffmpeg_encode(
414
+ tolerant_command(cmd), out_path, finalize=finalize, strict=False
415
+ )
416
+ if rc == 0:
417
+ kind = "tolerant"
418
+ logger.warning(
419
+ "%s copy %s was made from a damaged source %s; "
420
+ "it may glitch where the source is corrupt",
421
+ output.name.upper(),
422
+ out_path,
423
+ source_path,
424
+ )
425
+ if rc == 0:
426
+ manifest.record(
427
+ output.path, out_path, config.source_path, source_path, kind
428
+ )
386
429
  return ok
387
430
 
388
431
 
@@ -338,3 +338,39 @@ class TestPortableLimits:
338
338
  OutputConfig.from_dict(
339
339
  {"name": "aac", "codec": "aac", "path": "/a", key: bad}
340
340
  )
341
+
342
+
343
+ class TestCorruptSourceSetting:
344
+ def test_defaults_to_skip(self):
345
+ cfg = Config.from_dict(_settings_config({}))
346
+ assert cfg.corrupt_source == "skip"
347
+ assert cfg.corrupt_source_for(cfg.outputs[0]) == "skip"
348
+
349
+ def test_global_setting(self):
350
+ cfg = Config.from_dict(_settings_config({"corrupt_source": "encode_anyway"}))
351
+ assert cfg.corrupt_source_for(cfg.outputs[0]) == "encode_anyway"
352
+
353
+ def test_output_overrides_global(self):
354
+ data = _settings_config({"corrupt_source": "skip"})
355
+ data["outputs"].append(
356
+ {
357
+ "name": "aac",
358
+ "codec": "aac",
359
+ "path": "/music/aac",
360
+ "corrupt_source": "encode_anyway",
361
+ }
362
+ )
363
+ cfg = Config.from_dict(data)
364
+ alac, aac = cfg.outputs
365
+ assert cfg.corrupt_source_for(alac) == "skip"
366
+ assert cfg.corrupt_source_for(aac) == "encode_anyway"
367
+
368
+ def test_rejects_unknown_global_value(self):
369
+ with pytest.raises(ValueError, match="settings.corrupt_source"):
370
+ Config.from_dict(_settings_config({"corrupt_source": "ignore"}))
371
+
372
+ def test_rejects_unknown_output_value(self):
373
+ with pytest.raises(ValueError, match="corrupt_source"):
374
+ OutputConfig.from_dict(
375
+ {"name": "aac", "codec": "aac", "path": "/a", "corrupt_source": "yes"}
376
+ )
@@ -13,6 +13,7 @@ import pytest
13
13
 
14
14
  from audio_transcode_watcher.config import OutputConfig
15
15
  from audio_transcode_watcher.encoder import (
16
+ DECODE_ERROR_RC,
16
17
  MP4_TAG_ATOMS,
17
18
  _cleanup_temp,
18
19
  _remove_artwork_from_command,
@@ -21,6 +22,7 @@ from audio_transcode_watcher.encoder import (
21
22
  copy_mp4_tags,
22
23
  limit_args,
23
24
  target_sample_rate,
25
+ tolerant_command,
24
26
  )
25
27
 
26
28
 
@@ -996,3 +998,42 @@ class TestPortableAacRealFiles:
996
998
  assert self._encode(
997
999
  src, OutputConfig(name="alac", codec="alac", path="/x"), tmp_path
998
1000
  ) == (96000, 6)
1001
+
1002
+
1003
+ class TestTolerantRun:
1004
+ """The relaxed encode used for corrupt_source: encode_anyway."""
1005
+
1006
+ def test_tolerant_command_drops_only_the_strict_flags(self, tmp_path):
1007
+ cmd, _dest = _alac_cmd(tmp_path)
1008
+ relaxed = tolerant_command(cmd)
1009
+ assert "-xerror" not in relaxed and "-err_detect" not in relaxed
1010
+ assert "crccheck+explode" not in relaxed
1011
+ assert [
1012
+ a for a in cmd if a not in ("-xerror", "-err_detect", "crccheck+explode")
1013
+ ] == relaxed
1014
+
1015
+ def test_strict_failure_with_decode_error_reports_decode_rc(
1016
+ self, fake_ffmpeg, tmp_path, monkeypatch
1017
+ ):
1018
+ monkeypatch.setenv("FAKE_RC", "183")
1019
+ monkeypatch.setenv("FAKE_STDERR", CORRUPT_FLAC_STDERR)
1020
+ cmd, dest = _alac_cmd(tmp_path)
1021
+ assert atomic_ffmpeg_encode(cmd, dest) == DECODE_ERROR_RC
1022
+
1023
+ def test_non_strict_run_ignores_concealed_errors(
1024
+ self, fake_ffmpeg, tmp_path, monkeypatch
1025
+ ):
1026
+ monkeypatch.setenv("FAKE_RC", "0")
1027
+ monkeypatch.setenv("FAKE_STDERR", CORRUPT_FLAC_STDERR)
1028
+ cmd, dest = _alac_cmd(tmp_path)
1029
+ assert atomic_ffmpeg_encode(tolerant_command(cmd), dest, strict=False) == 0
1030
+ assert os.path.isfile(dest)
1031
+
1032
+ def test_non_strict_run_still_fails_on_exit_code(
1033
+ self, fake_ffmpeg, tmp_path, monkeypatch
1034
+ ):
1035
+ monkeypatch.setenv("FAKE_RC", "1")
1036
+ monkeypatch.setenv("FAKE_STDERR", CORRUPT_FLAC_STDERR)
1037
+ cmd, dest = _alac_cmd(tmp_path)
1038
+ assert atomic_ffmpeg_encode(tolerant_command(cmd), dest, strict=False) == 1
1039
+ assert not os.path.exists(dest)
@@ -10,6 +10,7 @@ import pytest
10
10
  import audio_transcode_watcher.sync as sync_mod
11
11
  from audio_transcode_watcher import manifest
12
12
  from audio_transcode_watcher.config import Config, OutputConfig
13
+ from audio_transcode_watcher.encoder import DECODE_ERROR_RC
13
14
  from audio_transcode_watcher.sync import (
14
15
  _cleanup_orphans,
15
16
  _has_lossless_source,
@@ -2075,3 +2076,267 @@ class TestLimitsAndProvenanceEdges:
2075
2076
 
2076
2077
  enc.assert_not_called()
2077
2078
  assert (mp3 / "X.mp3").read_bytes() == b"edited by hand, longer"
2079
+
2080
+
2081
+ @pytest.mark.usefixtures("fresh_manifests")
2082
+ class TestCorruptSourceModes:
2083
+ """corrupt_source: skip refuses a damaged source; encode_anyway conceals."""
2084
+
2085
+ def _config(self, source, out, mode, per_output=None):
2086
+ return Config(
2087
+ source_path=str(source),
2088
+ outputs=[
2089
+ OutputConfig(
2090
+ name="aac", codec="aac", path=str(out), corrupt_source=per_output
2091
+ )
2092
+ ],
2093
+ fetch_lyrics=False,
2094
+ corrupt_source=mode,
2095
+ )
2096
+
2097
+ @staticmethod
2098
+ def _strict_fails_tolerant_works(calls):
2099
+ def encode(cmd, dest, strict=True, **_kwargs):
2100
+ calls.append((list(cmd), strict))
2101
+ if strict:
2102
+ return DECODE_ERROR_RC
2103
+ Path(dest).write_bytes(b"concealed")
2104
+ return 0
2105
+
2106
+ return encode
2107
+
2108
+ @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False)
2109
+ def test_skip_writes_nothing_and_remembers(self, _guard, temp_dir):
2110
+ source, out = _dirs(temp_dir, "source", "aac")
2111
+ flac = source / "Radiohead - High and Dry.flac"
2112
+ flac.write_bytes(b"flac")
2113
+ calls = []
2114
+ with patch(
2115
+ "audio_transcode_watcher.sync.atomic_ffmpeg_encode",
2116
+ side_effect=self._strict_fails_tolerant_works(calls),
2117
+ ):
2118
+ process_source_file(
2119
+ str(flac), self._config(source, out, "skip"), check_stable=False
2120
+ )
2121
+ assert [strict for _cmd, strict in calls] == [True]
2122
+ assert _visible(out) == []
2123
+ assert str(flac) in sync_mod._failed_sources
2124
+
2125
+ @pytest.mark.parametrize(
2126
+ "glob,per_output", [("encode_anyway", None), ("skip", "encode_anyway")]
2127
+ )
2128
+ @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False)
2129
+ def test_encode_anyway_makes_a_tolerant_copy(
2130
+ self, _guard, temp_dir, caplog, glob, per_output
2131
+ ):
2132
+ source, out = _dirs(temp_dir, "source", "aac")
2133
+ flac = source / "Radiohead - High and Dry.flac"
2134
+ flac.write_bytes(b"flac")
2135
+ config = self._config(source, out, glob, per_output)
2136
+ calls = []
2137
+ with patch(
2138
+ "audio_transcode_watcher.sync.atomic_ffmpeg_encode",
2139
+ side_effect=self._strict_fails_tolerant_works(calls),
2140
+ ):
2141
+ process_source_file(str(flac), config, check_stable=False)
2142
+
2143
+ # One strict run, then exactly one tolerant run without the strict flags.
2144
+ assert [strict for _cmd, strict in calls] == [True, False]
2145
+ strict_cmd, tolerant_cmd = calls[0][0], calls[1][0]
2146
+ assert "-xerror" in strict_cmd and "-err_detect" in strict_cmd
2147
+ assert "-xerror" not in tolerant_cmd and "-err_detect" not in tolerant_cmd
2148
+ dest = out / "Radiohead - High and Dry.m4a"
2149
+ assert dest.read_bytes() == b"concealed"
2150
+ assert manifest.lookup(str(out), str(dest))["kind"] == "tolerant"
2151
+
2152
+ errors = [r for r in caplog.records if r.levelname == "ERROR"]
2153
+ warnings = [
2154
+ r
2155
+ for r in caplog.records
2156
+ if r.levelname == "WARNING" and "damaged source" in r.getMessage()
2157
+ ]
2158
+ assert any("encode failed" in r.getMessage() for r in errors)
2159
+ assert len(warnings) == 1
2160
+
2161
+ # The source is remembered: the next scan does not retry it at all.
2162
+ assert str(flac) in sync_mod._failed_sources
2163
+ calls.clear()
2164
+ dest.unlink()
2165
+ with patch(
2166
+ "audio_transcode_watcher.sync.atomic_ffmpeg_encode",
2167
+ side_effect=self._strict_fails_tolerant_works(calls),
2168
+ ):
2169
+ process_source_file(str(flac), config, check_stable=False)
2170
+ assert calls == []
2171
+
2172
+ @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False)
2173
+ def test_source_failing_the_tolerant_run_stays_refused(
2174
+ self, _guard, temp_dir, caplog
2175
+ ):
2176
+ source, out = _dirs(temp_dir, "source", "aac")
2177
+ flac = source / "X.flac"
2178
+ flac.write_bytes(b"flac")
2179
+ calls = []
2180
+
2181
+ def encode(cmd, dest, strict=True, **_kwargs):
2182
+ calls.append(strict)
2183
+ return DECODE_ERROR_RC if strict else 1
2184
+
2185
+ with patch(
2186
+ "audio_transcode_watcher.sync.atomic_ffmpeg_encode", side_effect=encode
2187
+ ):
2188
+ process_source_file(
2189
+ str(flac),
2190
+ self._config(source, out, "encode_anyway"),
2191
+ check_stable=False,
2192
+ )
2193
+ assert calls == [True, False]
2194
+ assert _visible(out) == []
2195
+ assert str(flac) in sync_mod._failed_sources
2196
+ assert "damaged source" not in caplog.text
2197
+
2198
+ @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False)
2199
+ def test_other_failures_get_no_tolerant_run(self, _guard, temp_dir):
2200
+ source, out = _dirs(temp_dir, "source", "aac")
2201
+ flac = source / "X.flac"
2202
+ flac.write_bytes(b"flac")
2203
+ calls = []
2204
+
2205
+ def encode(cmd, dest, strict=True, **_kwargs):
2206
+ calls.append(strict)
2207
+ return 124 # timed out, not a decode error
2208
+
2209
+ with patch(
2210
+ "audio_transcode_watcher.sync.atomic_ffmpeg_encode", side_effect=encode
2211
+ ):
2212
+ process_source_file(
2213
+ str(flac),
2214
+ self._config(source, out, "encode_anyway"),
2215
+ check_stable=False,
2216
+ )
2217
+ assert calls == [True]
2218
+
2219
+
2220
+ @pytest.mark.skipif(
2221
+ __import__("shutil").which("ffmpeg") is None,
2222
+ reason="ffmpeg is not installed; the real-file checks need it",
2223
+ )
2224
+ @pytest.mark.usefixtures("fresh_manifests")
2225
+ class TestCorruptSourceRealFile:
2226
+ def test_damaged_flac_gets_a_tolerant_aac(self, temp_dir):
2227
+ import subprocess
2228
+
2229
+ source, out = _dirs(temp_dir, "source", "aac")
2230
+ good = source / "good.tmp.flac"
2231
+ subprocess.run(
2232
+ [
2233
+ "ffmpeg",
2234
+ "-loglevel",
2235
+ "error",
2236
+ "-y",
2237
+ "-f",
2238
+ "lavfi",
2239
+ "-i",
2240
+ "sine=frequency=440:duration=5",
2241
+ "-c:a",
2242
+ "flac",
2243
+ str(good),
2244
+ ],
2245
+ check=True,
2246
+ )
2247
+ data = bytearray(good.read_bytes())
2248
+ good.unlink()
2249
+ for i in range(len(data) // 3, len(data) // 3 + 400):
2250
+ data[i] ^= 0x5A
2251
+ bad = source / "Scott McKenzie - San Francisco.flac"
2252
+ bad.write_bytes(bytes(data))
2253
+ config = Config(
2254
+ source_path=str(source),
2255
+ outputs=[OutputConfig(name="aac", codec="aac", path=str(out))],
2256
+ fetch_lyrics=False,
2257
+ corrupt_source="encode_anyway",
2258
+ )
2259
+
2260
+ process_source_file(str(bad), config, check_stable=False)
2261
+
2262
+ dest = out / "Scott McKenzie - San Francisco.m4a"
2263
+ assert dest.exists() and dest.stat().st_size > 0
2264
+ assert manifest.lookup(str(out), str(dest))["kind"] == "tolerant"
2265
+ assert str(bad) in sync_mod._failed_sources
2266
+
2267
+
2268
+ @pytest.mark.usefixtures("fresh_manifests")
2269
+ class TestTolerantCopyReplacedByCleanSource:
2270
+ """A clean replacement of a damaged source gets a strict encode on a plain scan."""
2271
+
2272
+ @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False)
2273
+ def test_clean_replacement_rebuilds_the_tolerant_copy(self, _guard, temp_dir):
2274
+ source, out = _dirs(temp_dir, "source", "aac")
2275
+ flac = source / "Radiohead - High and Dry.flac"
2276
+ flac.write_bytes(b"damaged flac")
2277
+ config = Config(
2278
+ source_path=str(source),
2279
+ outputs=[OutputConfig(name="aac", codec="aac", path=str(out))],
2280
+ fetch_lyrics=False,
2281
+ corrupt_source="encode_anyway",
2282
+ )
2283
+ dest = out / "Radiohead - High and Dry.m4a"
2284
+
2285
+ def damaged(cmd, d, strict=True, **_kwargs):
2286
+ if strict:
2287
+ return DECODE_ERROR_RC
2288
+ Path(d).write_bytes(b"concealed")
2289
+ return 0
2290
+
2291
+ with patch(
2292
+ "audio_transcode_watcher.sync.atomic_ffmpeg_encode", side_effect=damaged
2293
+ ):
2294
+ process_source_file(str(flac), config, check_stable=False)
2295
+ assert dest.read_bytes() == b"concealed"
2296
+
2297
+ # Unchanged source: the tolerant copy is left alone on later scans.
2298
+ sync_mod._failed_sources.clear()
2299
+ with patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode") as enc:
2300
+ process_source_file(str(flac), config, check_stable=False)
2301
+ enc.assert_not_called()
2302
+
2303
+ # The clean copy arrives (new size and mtime): strict encode replaces it.
2304
+ flac.write_bytes(b"clean flac, a different size")
2305
+ later = flac.stat().st_mtime + 10
2306
+ os.utime(flac, (later, later))
2307
+ calls = []
2308
+
2309
+ def clean(cmd, d, strict=True, **_kwargs):
2310
+ calls.append(strict)
2311
+ Path(d).write_bytes(b"clean encode")
2312
+ return 0
2313
+
2314
+ with patch(
2315
+ "audio_transcode_watcher.sync.atomic_ffmpeg_encode", side_effect=clean
2316
+ ):
2317
+ process_source_file(str(flac), config, force=False, check_stable=False)
2318
+ assert calls == [True]
2319
+ assert dest.read_bytes() == b"clean encode"
2320
+ assert manifest.lookup(str(out), str(dest))["kind"] == "encode"
2321
+
2322
+ @patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode")
2323
+ @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False)
2324
+ def test_only_tolerant_rows_are_rebuilt_on_a_plain_scan(
2325
+ self, _guard, mock_encode, temp_dir
2326
+ ):
2327
+ source, out = _dirs(temp_dir, "source", "aac")
2328
+ flac = source / "X.flac"
2329
+ flac.write_bytes(b"flac")
2330
+ dest = out / "X.m4a"
2331
+ dest.write_bytes(b"strict encode")
2332
+ manifest.record(str(out), str(dest), str(source), str(flac), "encode")
2333
+ flac.write_bytes(b"flac with edited tags") # source changed after the encode
2334
+ config = Config(
2335
+ source_path=str(source),
2336
+ outputs=[OutputConfig(name="aac", codec="aac", path=str(out))],
2337
+ fetch_lyrics=False,
2338
+ )
2339
+
2340
+ process_source_file(str(flac), config, force=False, check_stable=False)
2341
+
2342
+ mock_encode.assert_not_called()
@@ -4,7 +4,7 @@ requires-python = ">=3.14"
4
4
 
5
5
  [[package]]
6
6
  name = "audio-transcode-watcher"
7
- version = "0.6.1"
7
+ version = "0.6.2"
8
8
  source = { editable = "." }
9
9
  dependencies = [
10
10
  { name = "mutagen" },