audio-transcode-watcher 0.6.0__tar.gz → 0.6.1__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.1}/CHANGELOG.md +19 -0
  2. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/PKG-INFO +3 -2
  3. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/README.md +1 -1
  4. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/config.example.yaml +8 -2
  5. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/docs/ROADMAP.md +2 -1
  6. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/docs/configuration.md +24 -1
  7. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/docs/getting-started.md +2 -2
  8. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/docs/how-it-works.md +4 -0
  9. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/pyproject.toml +4 -1
  10. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/src/audio_transcode_watcher/__init__.py +1 -1
  11. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/src/audio_transcode_watcher/config.py +49 -24
  12. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/src/audio_transcode_watcher/encoder.py +99 -37
  13. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/src/audio_transcode_watcher/lyrics.py +5 -3
  14. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/src/audio_transcode_watcher/main.py +11 -11
  15. audio_transcode_watcher-0.6.1/src/audio_transcode_watcher/manifest.py +161 -0
  16. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/src/audio_transcode_watcher/sync.py +112 -37
  17. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/src/audio_transcode_watcher/utils.py +22 -17
  18. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/src/audio_transcode_watcher/watcher.py +7 -5
  19. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/tests/conftest.py +2 -3
  20. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/tests/test_config.py +96 -41
  21. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/tests/test_encoder.py +314 -63
  22. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/tests/test_lyrics.py +56 -18
  23. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/tests/test_main.py +25 -7
  24. audio_transcode_watcher-0.6.1/tests/test_manifest.py +122 -0
  25. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/tests/test_sync.py +483 -113
  26. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/tests/test_utils.py +47 -39
  27. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/tests/test_watcher.py +56 -34
  28. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/uv.lock +6 -4
  29. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.beads/.gitignore +0 -0
  30. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.beads/README.md +0 -0
  31. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.beads/config.yaml +0 -0
  32. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.beads/hooks/commit-msg +0 -0
  33. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.beads/hooks/post-checkout +0 -0
  34. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.beads/hooks/post-merge +0 -0
  35. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.beads/hooks/pre-commit +0 -0
  36. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.beads/hooks/pre-push +0 -0
  37. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.beads/hooks/prepare-commit-msg +0 -0
  38. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.beads/metadata.json +0 -0
  39. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.claude/settings.json +0 -0
  40. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.coderabbit.yaml +0 -0
  41. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.github/FUNDING.yml +0 -0
  42. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.github/dependabot.yml +0 -0
  43. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.github/workflows/dockerhub-description.yml +0 -0
  44. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.github/workflows/release.yml +0 -0
  45. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.github/workflows/stale.yml +0 -0
  46. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.github/workflows/tests.yml +0 -0
  47. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.gitignore +0 -0
  48. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/AGENTS.md +0 -0
  49. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/CLAUDE.md +0 -0
  50. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/Dockerfile +0 -0
  51. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/LICENSE +0 -0
  52. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/SECURITY.md +0 -0
  53. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/codecov.yml +0 -0
  54. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/docs/development.md +0 -0
  55. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/docs/images/banner.svg +0 -0
  56. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/docs/images/icon.png +0 -0
  57. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/docs/images/icon.svg +0 -0
  58. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/docs/images/social-preview.png +0 -0
  59. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/docs/usage.md +0 -0
  60. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/tests/__init__.py +0 -0
  61. {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/tools/verify_sync.py +0 -0
@@ -2,6 +2,24 @@
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.1] - 2026-10-04
6
+
7
+ ### Added
8
+
9
+ - 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.
10
+
11
+ ### Fixed
12
+
13
+ - 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.
14
+
15
+ ### Upgrading
16
+
17
+ - 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"' _ {} \;`
18
+
19
+ ### Security
20
+
21
+ - `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.
22
+
5
23
  ## [0.6.0] - 2026-10-04
6
24
 
7
25
  ### Changed
@@ -35,4 +53,5 @@ All notable changes to this project are documented here. The format follows [Kee
35
53
  - Lossy copies are written through a temp file and renamed, like encodes, so a half-written copy is never visible.
36
54
  - `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
55
 
56
+ [0.6.1]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.1
38
57
  [0.6.0]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.0
@@ -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.1
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.1
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.1
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.1)
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:
@@ -1,6 +1,6 @@
1
1
  # Roadmap
2
2
 
3
- Current version: **0.6.0**
3
+ Current version: **0.6.1**
4
4
 
5
5
  ## Completed
6
6
 
@@ -12,6 +12,7 @@ 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)
15
16
 
16
17
  ## v0.7.0 -- Quality of Life
17
18
 
@@ -75,7 +75,30 @@ A lossless source is encoded to every output.
75
75
 
76
76
  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
77
 
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.
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 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.
79
+
80
+ ## Portable limits (channels and sample rate)
81
+
82
+ Each output can cap the channel count and the sample rate:
83
+
84
+ ```yaml
85
+ - name: aac-256
86
+ codec: aac
87
+ bitrate: 256k
88
+ path: /music/aac
89
+ channels: 2 # downmix anything with more channels to stereo
90
+ max_sample_rate: 48000 # resample anything above 48 kHz
91
+ ```
92
+
93
+ `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`).
94
+
95
+ 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.
96
+
97
+ 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:
98
+
99
+ ```bash
100
+ 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"' _ {} \;
101
+ ```
79
102
 
80
103
  ## Tags in ALAC and AAC outputs
81
104
 
@@ -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.1
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.1
64
64
  ```
65
65
 
@@ -11,6 +11,10 @@
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
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.
@@ -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.1"
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.1"
@@ -42,45 +42,64 @@ 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
+ DEFAULT_CHANNELS = {"aac": 2}
49
+ DEFAULT_MAX_SAMPLE_RATE = {"aac": 48000}
50
+
45
51
 
46
52
  @dataclass
47
53
  class OutputConfig:
48
54
  """Configuration for a single output destination."""
49
-
55
+
50
56
  name: str
51
57
  codec: str
52
58
  path: str
53
59
  bitrate: str = ""
54
60
  include_artwork: bool = True
55
-
61
+ channels: int | None = None # Downmix above this many channels; 0 = keep
62
+ max_sample_rate: int | None = None # Resample above this rate (Hz); 0 = keep
63
+
56
64
  def __post_init__(self) -> None:
57
65
  """Validate and set defaults after initialization."""
58
66
  self.codec = self.codec.lower()
59
-
67
+
60
68
  if self.codec not in CODEC_EXTENSIONS:
61
69
  raise ValueError(
62
70
  f"Unknown codec '{self.codec}'. "
63
71
  f"Supported: {', '.join(CODEC_EXTENSIONS.keys())}"
64
72
  )
65
-
73
+
66
74
  # Set default bitrate for lossy codecs
67
75
  if not self.bitrate and self.codec in DEFAULT_BITRATES:
68
76
  self.bitrate = DEFAULT_BITRATES[self.codec]
69
-
77
+
70
78
  # Artwork not supported for some codecs
71
79
  if self.include_artwork and self.codec not in ARTWORK_SUPPORTED_CODECS:
72
80
  self.include_artwork = False
73
-
81
+
82
+ if self.channels is None:
83
+ self.channels = DEFAULT_CHANNELS.get(self.codec, 0)
84
+ if self.max_sample_rate is None:
85
+ self.max_sample_rate = DEFAULT_MAX_SAMPLE_RATE.get(self.codec, 0)
86
+ for key in ("channels", "max_sample_rate"):
87
+ value = getattr(self, key)
88
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
89
+ raise ValueError(
90
+ f"Output '{self.name}': {key} must be a whole number, 0 for no limit"
91
+ )
92
+
74
93
  @property
75
94
  def extension(self) -> str:
76
95
  """Get the file extension for this codec."""
77
96
  return CODEC_EXTENSIONS[self.codec]
78
-
97
+
79
98
  @property
80
99
  def is_lossless(self) -> bool:
81
100
  """Check if this codec is lossless."""
82
101
  return self.codec in {"alac", "flac", "wav"}
83
-
102
+
84
103
  @classmethod
85
104
  def from_dict(cls, data: dict[str, Any]) -> OutputConfig:
86
105
  """Create OutputConfig from a dictionary."""
@@ -90,13 +109,15 @@ class OutputConfig:
90
109
  path=data["path"],
91
110
  bitrate=data.get("bitrate", ""),
92
111
  include_artwork=data.get("include_artwork", True),
112
+ channels=data.get("channels"),
113
+ max_sample_rate=data.get("max_sample_rate"),
93
114
  )
94
115
 
95
116
 
96
117
  @dataclass
97
118
  class Config:
98
119
  """Main configuration for audio-transcode-watcher."""
99
-
120
+
100
121
  source_path: str
101
122
  outputs: list[OutputConfig] = field(default_factory=list)
102
123
  force_reencode: bool = False
@@ -106,48 +127,52 @@ class Config:
106
127
  min_stable_seconds: float = 1.0
107
128
  fetch_lyrics: bool = True # Auto-fetch .lrc lyrics via syncedlyrics
108
129
  sync_interval_seconds: int = 300 # Seconds between periodic full syncs
109
-
130
+
110
131
  def __post_init__(self) -> None:
111
132
  """Validate configuration after initialization."""
112
133
  if not self.source_path:
113
134
  raise ValueError("source_path is required")
114
135
 
115
136
  interval = self.sync_interval_seconds
116
- if isinstance(interval, bool) or not isinstance(interval, (int, float)) or interval <= 0:
137
+ if (
138
+ isinstance(interval, bool)
139
+ or not isinstance(interval, (int, float))
140
+ or interval <= 0
141
+ ):
117
142
  raise ValueError("sync_interval_seconds must be a number greater than 0")
118
-
143
+
119
144
  if not self.outputs:
120
145
  raise ValueError("At least one output is required")
121
-
146
+
122
147
  # Check for duplicate output names
123
148
  names = [o.name for o in self.outputs]
124
149
  if len(names) != len(set(names)):
125
150
  raise ValueError("Duplicate output names detected")
126
-
151
+
127
152
  # Check for duplicate output paths
128
153
  paths = [o.path for o in self.outputs]
129
154
  if len(paths) != len(set(paths)):
130
155
  raise ValueError("Duplicate output paths detected")
131
-
156
+
132
157
  @property
133
158
  def output_paths(self) -> list[str]:
134
159
  """Get list of all output directory paths."""
135
160
  return [o.path for o in self.outputs]
136
-
161
+
137
162
  def get_output_by_name(self, name: str) -> OutputConfig | None:
138
163
  """Get an output configuration by name."""
139
164
  for output in self.outputs:
140
165
  if output.name == name:
141
166
  return output
142
167
  return None
143
-
168
+
144
169
  @classmethod
145
170
  def from_dict(cls, data: dict[str, Any]) -> Config:
146
171
  """Create Config from a dictionary."""
147
172
  outputs = [OutputConfig.from_dict(o) for o in data.get("outputs", [])]
148
173
  settings = data.get("settings", {})
149
174
  _warn_deprecated_settings(settings)
150
-
175
+
151
176
  return cls(
152
177
  source_path=data.get("source", {}).get("path", ""),
153
178
  outputs=outputs,
@@ -159,14 +184,14 @@ class Config:
159
184
  fetch_lyrics=settings.get("fetch_lyrics", True),
160
185
  sync_interval_seconds=settings.get("sync_interval_seconds", 300),
161
186
  )
162
-
187
+
163
188
  @classmethod
164
189
  def from_yaml_file(cls, path: str) -> Config:
165
190
  """Load configuration from a YAML file."""
166
191
  with open(path, "r", encoding="utf-8") as f:
167
192
  data = yaml.safe_load(f)
168
193
  return cls.from_dict(data)
169
-
194
+
170
195
  @classmethod
171
196
  def from_json_string(cls, json_str: str) -> Config:
172
197
  """Load configuration from a JSON string."""
@@ -191,11 +216,11 @@ def _warn_deprecated_settings(settings: dict[str, Any]) -> None:
191
216
  def load_config() -> Config:
192
217
  """
193
218
  Load configuration from environment variables.
194
-
219
+
195
220
  Configuration is loaded from one of these sources (in priority order):
196
221
  1. CONFIG_FILE env var - path to a YAML config file
197
222
  2. CONFIG_JSON env var - JSON string with full configuration
198
-
223
+
199
224
  Raises:
200
225
  ValueError: If no valid configuration is found
201
226
  """
@@ -205,12 +230,12 @@ def load_config() -> Config:
205
230
  if not Path(config_file).exists():
206
231
  raise ValueError(f"CONFIG_FILE not found: {config_file}")
207
232
  return Config.from_yaml_file(config_file)
208
-
233
+
209
234
  # Try CONFIG_JSON
210
235
  config_json = os.getenv("CONFIG_JSON")
211
236
  if config_json:
212
237
  return Config.from_json_string(config_json)
213
-
238
+
214
239
  # No configuration provided
215
240
  raise ValueError(
216
241
  "No configuration found. Set CONFIG_FILE (path to YAML config) "