audio-transcode-watcher 0.6.0__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.0 → audio_transcode_watcher-0.6.2}/.beads/config.yaml +2 -2
  2. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/AGENTS.md +1 -1
  3. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/CHANGELOG.md +26 -0
  4. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/CLAUDE.md +1 -1
  5. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/PKG-INFO +3 -2
  6. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/README.md +1 -1
  7. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/config.example.yaml +15 -2
  8. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/docs/ROADMAP.md +3 -1
  9. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/docs/configuration.md +46 -1
  10. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/docs/getting-started.md +2 -2
  11. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/docs/how-it-works.md +5 -1
  12. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/pyproject.toml +4 -1
  13. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/src/audio_transcode_watcher/__init__.py +1 -1
  14. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/src/audio_transcode_watcher/config.py +72 -24
  15. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/src/audio_transcode_watcher/encoder.py +132 -43
  16. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/src/audio_transcode_watcher/lyrics.py +5 -3
  17. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/src/audio_transcode_watcher/main.py +11 -11
  18. audio_transcode_watcher-0.6.2/src/audio_transcode_watcher/manifest.py +161 -0
  19. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/src/audio_transcode_watcher/sync.py +154 -36
  20. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/src/audio_transcode_watcher/utils.py +22 -17
  21. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/src/audio_transcode_watcher/watcher.py +7 -5
  22. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/tests/conftest.py +2 -3
  23. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/tests/test_config.py +132 -41
  24. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/tests/test_encoder.py +355 -63
  25. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/tests/test_lyrics.py +56 -18
  26. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/tests/test_main.py +25 -7
  27. audio_transcode_watcher-0.6.2/tests/test_manifest.py +122 -0
  28. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/tests/test_sync.py +748 -113
  29. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/tests/test_utils.py +47 -39
  30. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/tests/test_watcher.py +56 -34
  31. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/uv.lock +6 -4
  32. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/.beads/.gitignore +0 -0
  33. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/.beads/README.md +0 -0
  34. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/.beads/hooks/commit-msg +0 -0
  35. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/.beads/hooks/post-checkout +0 -0
  36. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/.beads/hooks/post-merge +0 -0
  37. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/.beads/hooks/pre-commit +0 -0
  38. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/.beads/hooks/pre-push +0 -0
  39. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/.beads/hooks/prepare-commit-msg +0 -0
  40. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/.beads/metadata.json +0 -0
  41. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/.claude/settings.json +0 -0
  42. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/.coderabbit.yaml +0 -0
  43. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/.github/FUNDING.yml +0 -0
  44. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/.github/dependabot.yml +0 -0
  45. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/.github/workflows/dockerhub-description.yml +0 -0
  46. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/.github/workflows/release.yml +0 -0
  47. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/.github/workflows/stale.yml +0 -0
  48. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/.github/workflows/tests.yml +0 -0
  49. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/.gitignore +0 -0
  50. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/Dockerfile +0 -0
  51. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/LICENSE +0 -0
  52. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/SECURITY.md +0 -0
  53. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/codecov.yml +0 -0
  54. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/docs/development.md +0 -0
  55. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/docs/images/banner.svg +0 -0
  56. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/docs/images/icon.png +0 -0
  57. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/docs/images/icon.svg +0 -0
  58. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/docs/images/social-preview.png +0 -0
  59. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/docs/usage.md +0 -0
  60. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.2}/tests/__init__.py +0 -0
  61. {audio_transcode_watcher-0.6.0 → 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,30 @@
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
+
11
+ ## [0.6.1] - 2026-10-04
12
+
13
+ ### Added
14
+
15
+ - Per-output `channels` and `max_sample_rate`. An `aac` output now defaults to `channels: 2` and `max_sample_rate: 48000`, so the AAC copies play everywhere a phone, AirPods or CarPlay can take them: anything with more than two channels is downmixed to stereo, 88.2 and 176.4 kHz sources become 44.1 kHz, and 96 and 192 kHz sources become 48 kHz. Nothing is ever upsampled or upmixed. A lossy AAC source that exceeds the limits is transcoded rather than copied. Other codecs have no limit unless they set one, and `0` turns a limit off. ALAC is unchanged.
16
+
17
+ ### Fixed
18
+
19
+ - A lossless file that arrives after a lossy one of the same name now replaces the lossy file's transcode too, not only its copy. An Ogg transcoded into an MP3 output as `X.mp3` used to block the later `X.flac` from ever being encoded there. Each output folder now keeps `.atw-manifest.json`, recording which source made each file and how (encode, copy or transcode). A row is trusted only while the output still has the size and modification time it was written with. A missing or unreadable manifest means "unknown", which behaves exactly as before, and the orphan pass drops rows whose file is gone.
20
+
21
+ ### Upgrading
22
+
23
+ - Existing AAC files are not rebuilt by the upgrade. They are re-encoded only when their source changes, or with `force_reencode: true` on startup. To rebuild just the hi-res and multichannel ones, delete the AAC files above 48 kHz or 2 channels and the next periodic sync encodes them again: `docker exec audio_transcoder find /music/aac -name '*.m4a' -exec sh -c 'ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate,channels -of default=nw=1 "$1" | awk -F= "/^sample_rate/{r=\$2} /^channels/{c=\$2} END{exit !(r>48000||c>2)}" && rm -v "$1"' _ {} \;`
24
+
25
+ ### Security
26
+
27
+ - `urllib3` is now required at 2.8.0 or newer, for GHSA-vxq7-64xx-v4gw, GHSA-8988-9cw3-xx77 and GHSA-gh4c-6fx4-qh6g. It comes in through syncedlyrics and requests; the image installs from `pyproject.toml`, so the floor is declared there and `uv.lock` resolves 2.8.0.
28
+
5
29
  ## [0.6.0] - 2026-10-04
6
30
 
7
31
  ### Changed
@@ -35,4 +59,6 @@ All notable changes to this project are documented here. The format follows [Kee
35
59
  - Lossy copies are written through a temp file and renamed, like encodes, so a half-written copy is never visible.
36
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.
37
61
 
62
+ [0.6.2]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.2
63
+ [0.6.1]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.1
38
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.0
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
@@ -21,6 +21,7 @@ Requires-Python: >=3.14
21
21
  Requires-Dist: mutagen>=1.47.0
22
22
  Requires-Dist: pyyaml>=6.0
23
23
  Requires-Dist: syncedlyrics>=1.0.0
24
+ Requires-Dist: urllib3>=2.8.0
24
25
  Requires-Dist: watchdog>=4.0.0
25
26
  Provides-Extra: dev
26
27
  Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
@@ -65,7 +66,7 @@ Keep one library in several formats: lossless for the archive, lossy for phones
65
66
  curl -fsSL -o config.yaml https://raw.githubusercontent.com/GeiserX/audio-transcode-watcher/main/config.example.yaml
66
67
  docker run -d --name audio_transcoder -e CONFIG_FILE=/app/config.yaml \
67
68
  -v ./config.yaml:/app/config.yaml:ro -v /path/to/flac:/music/flac:ro -v /path/to/mp3:/music/mp3 \
68
- drumsergio/audio-transcoder:0.6.0
69
+ drumsergio/audio-transcoder:0.6.2
69
70
  ```
70
71
 
71
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.0
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.0)
1
+ # Audio Transcode Watcher Configuration (0.6.2)
2
2
  # ======================================
3
3
  #
4
4
  # This configuration file defines the source folder and output destinations
@@ -28,12 +28,18 @@ outputs:
28
28
  path: /music/mp3
29
29
  include_artwork: true
30
30
 
31
- # AAC 256kbps for modern devices
31
+ # AAC 256kbps for phones, AirPods and CarPlay
32
32
  - name: aac-256
33
33
  codec: aac
34
34
  bitrate: 256k
35
35
  path: /music/aac
36
36
  include_artwork: true
37
+ # Portable limits. These are the AAC defaults, shown for reference:
38
+ # more than 2 channels are downmixed to stereo, and rates above 48 kHz
39
+ # drop to 44.1 kHz (88.2/176.4 kHz sources) or 48 kHz (96/192 kHz).
40
+ # Lower rates are never upsampled. Any codec can opt in; 0 = no limit.
41
+ channels: 2
42
+ max_sample_rate: 48000
37
43
 
38
44
  # Optional settings
39
45
  settings:
@@ -56,6 +62,13 @@ settings:
56
62
  # Seconds between periodic full syncs (default: 300)
57
63
  sync_interval_seconds: 300
58
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
+
59
72
  # whisper_fallback and whisper_model were removed in 0.6.0 and are ignored
60
73
 
61
74
 
@@ -1,6 +1,6 @@
1
1
  # Roadmap
2
2
 
3
- Current version: **0.6.0**
3
+ Current version: **0.6.2**
4
4
 
5
5
  ## Completed
6
6
 
@@ -12,6 +12,8 @@ Current version: **0.6.0**
12
12
  - **Whisper local transcription fallback** for lyrics (v0.4.0, removed in v0.6.0)
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
+ - **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)
15
17
 
16
18
  ## v0.7.0 -- Quality of Life
17
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
63
67
  ```
64
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
83
+ ```
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
@@ -75,7 +97,30 @@ A lossless source is encoded to every output.
75
97
 
76
98
  A lossy source is never encoded into a lossless output, because that only makes a big file that looks lossless. It is copied there unchanged, with its own extension, so an ALAC folder can hold `.m4a` encodes next to `.mp3` or `.ogg` copies. Into a lossy output it is copied unchanged when it already has that output's codec (`.mp3` into `mp3`, `.m4a` or `.aac` into `aac`, `.opus` into `opus`) and transcoded otherwise. Copies keep their tags and cover as they are.
77
99
 
78
- When a lossless and a lossy file share a name in the source folder, the lossless one is used for every output. If the lossy file came first and was already copied, the copy is removed and the lossless file is encoded as soon as it is processed.
100
+ When a lossless and a lossy file share a name in the source folder, the lossless one is used for every output. If the lossy file came first and was already copied or transcoded, that file is replaced by an encode of the lossless one as soon as it is processed. To tell those files apart, each output folder keeps a hidden `.atw-manifest.json` recording which source made each file and whether it was an encode, a copy or a transcode. Deleting it is safe: files it does not know about are treated as they were before 0.6.1.
101
+
102
+ ## Portable limits (channels and sample rate)
103
+
104
+ Each output can cap the channel count and the sample rate:
105
+
106
+ ```yaml
107
+ - name: aac-256
108
+ codec: aac
109
+ bitrate: 256k
110
+ path: /music/aac
111
+ channels: 2 # downmix anything with more channels to stereo
112
+ max_sample_rate: 48000 # resample anything above 48 kHz
113
+ ```
114
+
115
+ `aac` outputs get `channels: 2` and `max_sample_rate: 48000` when they don't set them, because they are for phones, AirPods and CarPlay. Every other codec has no limit unless you set one, and `0` turns a limit off (also for `aac`).
116
+
117
+ The sample rate never goes up, and a rate above the cap drops within its own family: 88.2 and 176.4 kHz become 44.1 kHz, 96 and 192 kHz become 48 kHz. A rate from neither family goes to the cap. A mono or stereo source is never upmixed. A lossy source that would normally be copied into the output (an `.m4a` into `aac`) is transcoded instead when it exceeds a limit. ALAC and the other lossless outputs keep the source's rate and channels.
118
+
119
+ Changing these settings does not rebuild files that already exist. They are re-encoded when their source changes, or on startup with `force_reencode: true`. To rebuild only the files above the limits, delete them and let the next periodic sync encode them again:
120
+
121
+ ```bash
122
+ docker exec audio_transcoder find /music/aac -name '*.m4a' -exec sh -c 'ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate,channels -of default=nw=1 "$1" | awk -F= "/^sample_rate/{r=\$2} /^channels/{c=\$2} END{exit !(r>48000||c>2)}" && rm -v "$1"' _ {} \;
123
+ ```
79
124
 
80
125
  ## Tags in ALAC and AAC outputs
81
126
 
@@ -29,7 +29,7 @@ outputs:
29
29
  ```yaml
30
30
  services:
31
31
  audio-transcoder:
32
- image: drumsergio/audio-transcoder:0.6.0
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.0
63
+ drumsergio/audio-transcoder:0.6.2
64
64
  ```
65
65
 
@@ -11,9 +11,13 @@
11
11
 
12
12
  Lossy sources are copied rather than encoded where that keeps quality honest; see [Source formats](configuration.md#source-formats).
13
13
 
14
+ ## Provenance manifest
15
+
16
+ Each output folder holds `.atw-manifest.json`, a map from each output file to the source that made it (path, size, modification time) and how: `encode`, `copy` or `transcode`. It is written in batches at most every 5 seconds and at the end of each sync. It is only used to replace a file made from a lossy source once a lossless source of the same name appears. A missing or unreadable manifest just means "unknown".
17
+
14
18
  ## Corrupt sources
15
19
 
16
- 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.
17
21
 
18
22
  ## Recursive Directory Support
19
23
 
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
5
5
  [project]
6
6
  name = "audio-transcode-watcher"
7
7
 
8
- version = "0.6.0"
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"
@@ -30,6 +30,9 @@ dependencies = [
30
30
  "pyyaml>=6.0",
31
31
  "mutagen>=1.47.0",
32
32
  "syncedlyrics>=1.0.0",
33
+ # Transitive via syncedlyrics/requests; pinned up for GHSA-vxq7-64xx-v4gw,
34
+ # GHSA-8988-9cw3-xx77 and GHSA-gh4c-6fx4-qh6g.
35
+ "urllib3>=2.8.0",
33
36
  ]
34
37
 
35
38
  [project.optional-dependencies]
@@ -1,3 +1,3 @@
1
1
  """Audio Transcode Watcher - Automatic audio file transcoding."""
2
2
 
3
- __version__ = "0.6.0"
3
+ __version__ = "0.6.2"
@@ -42,45 +42,78 @@ DEFAULT_BITRATES = {
42
42
  "opus": "128k",
43
43
  }
44
44
 
45
+ # Portable-playback limits applied when an output does not set them.
46
+ # AAC goes to phones, AirPods and CarPlay: stereo, at most 48 kHz.
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
+
52
+ DEFAULT_CHANNELS = {"aac": 2}
53
+ DEFAULT_MAX_SAMPLE_RATE = {"aac": 48000}
54
+
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
+
45
60
 
46
61
  @dataclass
47
62
  class OutputConfig:
48
63
  """Configuration for a single output destination."""
49
-
64
+
50
65
  name: str
51
66
  codec: str
52
67
  path: str
53
68
  bitrate: str = ""
54
69
  include_artwork: bool = True
55
-
70
+ channels: int | None = None # Downmix above this many channels; 0 = keep
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
73
+
56
74
  def __post_init__(self) -> None:
57
75
  """Validate and set defaults after initialization."""
58
76
  self.codec = self.codec.lower()
59
-
77
+
60
78
  if self.codec not in CODEC_EXTENSIONS:
61
79
  raise ValueError(
62
80
  f"Unknown codec '{self.codec}'. "
63
81
  f"Supported: {', '.join(CODEC_EXTENSIONS.keys())}"
64
82
  )
65
-
83
+
66
84
  # Set default bitrate for lossy codecs
67
85
  if not self.bitrate and self.codec in DEFAULT_BITRATES:
68
86
  self.bitrate = DEFAULT_BITRATES[self.codec]
69
-
87
+
70
88
  # Artwork not supported for some codecs
71
89
  if self.include_artwork and self.codec not in ARTWORK_SUPPORTED_CODECS:
72
90
  self.include_artwork = False
73
-
91
+
92
+ if self.channels is None:
93
+ self.channels = DEFAULT_CHANNELS.get(self.codec, 0)
94
+ if self.max_sample_rate is None:
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
+ )
100
+ for key in ("channels", "max_sample_rate"):
101
+ value = getattr(self, key)
102
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
103
+ raise ValueError(
104
+ f"Output '{self.name}': {key} must be a whole number, 0 for no limit"
105
+ )
106
+
74
107
  @property
75
108
  def extension(self) -> str:
76
109
  """Get the file extension for this codec."""
77
110
  return CODEC_EXTENSIONS[self.codec]
78
-
111
+
79
112
  @property
80
113
  def is_lossless(self) -> bool:
81
114
  """Check if this codec is lossless."""
82
115
  return self.codec in {"alac", "flac", "wav"}
83
-
116
+
84
117
  @classmethod
85
118
  def from_dict(cls, data: dict[str, Any]) -> OutputConfig:
86
119
  """Create OutputConfig from a dictionary."""
@@ -90,13 +123,16 @@ class OutputConfig:
90
123
  path=data["path"],
91
124
  bitrate=data.get("bitrate", ""),
92
125
  include_artwork=data.get("include_artwork", True),
126
+ channels=data.get("channels"),
127
+ max_sample_rate=data.get("max_sample_rate"),
128
+ corrupt_source=data.get("corrupt_source"),
93
129
  )
94
130
 
95
131
 
96
132
  @dataclass
97
133
  class Config:
98
134
  """Main configuration for audio-transcode-watcher."""
99
-
135
+
100
136
  source_path: str
101
137
  outputs: list[OutputConfig] = field(default_factory=list)
102
138
  force_reencode: bool = False
@@ -106,48 +142,59 @@ class Config:
106
142
  min_stable_seconds: float = 1.0
107
143
  fetch_lyrics: bool = True # Auto-fetch .lrc lyrics via syncedlyrics
108
144
  sync_interval_seconds: int = 300 # Seconds between periodic full syncs
109
-
145
+ corrupt_source: str = "skip" # Default for outputs that don't set it
146
+
110
147
  def __post_init__(self) -> None:
111
148
  """Validate configuration after initialization."""
112
149
  if not self.source_path:
113
150
  raise ValueError("source_path is required")
114
151
 
152
+ _check_corrupt_source(self.corrupt_source, "settings.corrupt_source")
153
+
115
154
  interval = self.sync_interval_seconds
116
- if isinstance(interval, bool) or not isinstance(interval, (int, float)) or interval <= 0:
155
+ if (
156
+ isinstance(interval, bool)
157
+ or not isinstance(interval, (int, float))
158
+ or interval <= 0
159
+ ):
117
160
  raise ValueError("sync_interval_seconds must be a number greater than 0")
118
-
161
+
119
162
  if not self.outputs:
120
163
  raise ValueError("At least one output is required")
121
-
164
+
122
165
  # Check for duplicate output names
123
166
  names = [o.name for o in self.outputs]
124
167
  if len(names) != len(set(names)):
125
168
  raise ValueError("Duplicate output names detected")
126
-
169
+
127
170
  # Check for duplicate output paths
128
171
  paths = [o.path for o in self.outputs]
129
172
  if len(paths) != len(set(paths)):
130
173
  raise ValueError("Duplicate output paths detected")
131
-
174
+
132
175
  @property
133
176
  def output_paths(self) -> list[str]:
134
177
  """Get list of all output directory paths."""
135
178
  return [o.path for o in self.outputs]
136
-
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
+
137
184
  def get_output_by_name(self, name: str) -> OutputConfig | None:
138
185
  """Get an output configuration by name."""
139
186
  for output in self.outputs:
140
187
  if output.name == name:
141
188
  return output
142
189
  return None
143
-
190
+
144
191
  @classmethod
145
192
  def from_dict(cls, data: dict[str, Any]) -> Config:
146
193
  """Create Config from a dictionary."""
147
194
  outputs = [OutputConfig.from_dict(o) for o in data.get("outputs", [])]
148
195
  settings = data.get("settings", {})
149
196
  _warn_deprecated_settings(settings)
150
-
197
+
151
198
  return cls(
152
199
  source_path=data.get("source", {}).get("path", ""),
153
200
  outputs=outputs,
@@ -158,15 +205,16 @@ class Config:
158
205
  min_stable_seconds=settings.get("min_stable_seconds", 1.0),
159
206
  fetch_lyrics=settings.get("fetch_lyrics", True),
160
207
  sync_interval_seconds=settings.get("sync_interval_seconds", 300),
208
+ corrupt_source=settings.get("corrupt_source", "skip"),
161
209
  )
162
-
210
+
163
211
  @classmethod
164
212
  def from_yaml_file(cls, path: str) -> Config:
165
213
  """Load configuration from a YAML file."""
166
214
  with open(path, "r", encoding="utf-8") as f:
167
215
  data = yaml.safe_load(f)
168
216
  return cls.from_dict(data)
169
-
217
+
170
218
  @classmethod
171
219
  def from_json_string(cls, json_str: str) -> Config:
172
220
  """Load configuration from a JSON string."""
@@ -191,11 +239,11 @@ def _warn_deprecated_settings(settings: dict[str, Any]) -> None:
191
239
  def load_config() -> Config:
192
240
  """
193
241
  Load configuration from environment variables.
194
-
242
+
195
243
  Configuration is loaded from one of these sources (in priority order):
196
244
  1. CONFIG_FILE env var - path to a YAML config file
197
245
  2. CONFIG_JSON env var - JSON string with full configuration
198
-
246
+
199
247
  Raises:
200
248
  ValueError: If no valid configuration is found
201
249
  """
@@ -205,12 +253,12 @@ def load_config() -> Config:
205
253
  if not Path(config_file).exists():
206
254
  raise ValueError(f"CONFIG_FILE not found: {config_file}")
207
255
  return Config.from_yaml_file(config_file)
208
-
256
+
209
257
  # Try CONFIG_JSON
210
258
  config_json = os.getenv("CONFIG_JSON")
211
259
  if config_json:
212
260
  return Config.from_json_string(config_json)
213
-
261
+
214
262
  # No configuration provided
215
263
  raise ValueError(
216
264
  "No configuration found. Set CONFIG_FILE (path to YAML config) "