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.
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/CHANGELOG.md +19 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/PKG-INFO +3 -2
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/README.md +1 -1
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/config.example.yaml +8 -2
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/docs/ROADMAP.md +2 -1
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/docs/configuration.md +24 -1
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/docs/getting-started.md +2 -2
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/docs/how-it-works.md +4 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/pyproject.toml +4 -1
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/src/audio_transcode_watcher/__init__.py +1 -1
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/src/audio_transcode_watcher/config.py +49 -24
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/src/audio_transcode_watcher/encoder.py +99 -37
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/src/audio_transcode_watcher/lyrics.py +5 -3
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/src/audio_transcode_watcher/main.py +11 -11
- audio_transcode_watcher-0.6.1/src/audio_transcode_watcher/manifest.py +161 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/src/audio_transcode_watcher/sync.py +112 -37
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/src/audio_transcode_watcher/utils.py +22 -17
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/src/audio_transcode_watcher/watcher.py +7 -5
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/tests/conftest.py +2 -3
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/tests/test_config.py +96 -41
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/tests/test_encoder.py +314 -63
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/tests/test_lyrics.py +56 -18
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/tests/test_main.py +25 -7
- audio_transcode_watcher-0.6.1/tests/test_manifest.py +122 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/tests/test_sync.py +483 -113
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/tests/test_utils.py +47 -39
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/tests/test_watcher.py +56 -34
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/uv.lock +6 -4
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.beads/.gitignore +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.beads/README.md +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.beads/config.yaml +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.beads/hooks/commit-msg +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.beads/hooks/post-checkout +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.beads/hooks/post-merge +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.beads/hooks/pre-commit +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.beads/hooks/pre-push +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.beads/hooks/prepare-commit-msg +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.beads/metadata.json +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.claude/settings.json +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.coderabbit.yaml +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.github/FUNDING.yml +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.github/dependabot.yml +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.github/workflows/dockerhub-description.yml +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.github/workflows/release.yml +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.github/workflows/stale.yml +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.github/workflows/tests.yml +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/.gitignore +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/AGENTS.md +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/CLAUDE.md +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/Dockerfile +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/LICENSE +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/SECURITY.md +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/codecov.yml +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/docs/development.md +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/docs/images/banner.svg +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/docs/images/icon.png +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/docs/images/icon.svg +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/docs/images/social-preview.png +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/docs/usage.md +0 -0
- {audio_transcode_watcher-0.6.0 → audio_transcode_watcher-0.6.1}/tests/__init__.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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]
|
|
@@ -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
|
|
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) "
|