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