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