audio-transcode-watcher 0.4.2__py3-none-any.whl

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.
@@ -0,0 +1,109 @@
1
+ """File system watcher for audio-transcode-watcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ from watchdog.events import FileSystemEventHandler
8
+ from watchdog.observers import Observer
9
+
10
+ from .config import Config
11
+ from .sync import (
12
+ delete_outputs,
13
+ delete_sidecars,
14
+ process_source_file,
15
+ safety_guard_active,
16
+ sync_sidecars,
17
+ )
18
+ from .utils import has_audio_extension, has_sidecar_extension, is_audio_file
19
+
20
+
21
+ class AudioSyncHandler(FileSystemEventHandler):
22
+ """
23
+ Watchdog event handler for audio file changes.
24
+
25
+ Handles file creation, modification, deletion, and rename events.
26
+ """
27
+
28
+ def __init__(self, config: Config) -> None:
29
+ super().__init__()
30
+ self.config = config
31
+
32
+ def _process_later(self, path: str, force: bool = False) -> None:
33
+ """Process a file after a brief delay to coalesce rapid events."""
34
+ if not is_audio_file(path):
35
+ return
36
+
37
+ # Small delay to coalesce burst events
38
+ time.sleep(0.2)
39
+
40
+ if safety_guard_active(self.config):
41
+ return
42
+
43
+ process_source_file(path, self.config, force=force, check_stable=True)
44
+
45
+ def on_created(self, event) -> None:
46
+ """Handle file creation (audio or sidecar)."""
47
+ if event.is_directory:
48
+ return
49
+
50
+ if has_sidecar_extension(event.src_path):
51
+ sync_sidecars(event.src_path, self.config)
52
+ else:
53
+ self._process_later(event.src_path, force=False)
54
+
55
+ def on_modified(self, event) -> None:
56
+ """Handle file modification (audio or sidecar)."""
57
+ if event.is_directory:
58
+ return
59
+
60
+ if safety_guard_active(self.config):
61
+ return
62
+
63
+ if has_sidecar_extension(event.src_path):
64
+ sync_sidecars(event.src_path, self.config)
65
+ else:
66
+ # Delete old outputs first, then re-encode
67
+ delete_outputs(event.src_path, self.config)
68
+ self._process_later(event.src_path, force=True)
69
+
70
+ def on_moved(self, event) -> None:
71
+ """Handle file rename/move (audio or sidecar)."""
72
+ if event.is_directory:
73
+ return
74
+
75
+ if has_sidecar_extension(event.src_path) or has_sidecar_extension(event.dest_path):
76
+ # Delete old sidecar copies, sync new ones
77
+ if has_sidecar_extension(event.src_path):
78
+ delete_sidecars(event.src_path, self.config)
79
+ if has_sidecar_extension(event.dest_path):
80
+ sync_sidecars(event.dest_path, self.config)
81
+ else:
82
+ # Audio file move
83
+ if has_audio_extension(event.src_path):
84
+ delete_outputs(event.src_path, self.config)
85
+ if is_audio_file(event.dest_path):
86
+ self._process_later(event.dest_path, force=True)
87
+
88
+ def on_deleted(self, event) -> None:
89
+ """Handle file deletion (audio or sidecar)."""
90
+ if event.is_directory:
91
+ return
92
+
93
+ if has_sidecar_extension(event.src_path):
94
+ delete_sidecars(event.src_path, self.config)
95
+ elif has_audio_extension(event.src_path):
96
+ delete_outputs(event.src_path, self.config)
97
+
98
+
99
+ def start_watcher(config: Config) -> Observer:
100
+ """
101
+ Start the file system watcher.
102
+
103
+ Returns the observer instance (call observer.stop() to stop).
104
+ """
105
+ observer = Observer()
106
+ handler = AudioSyncHandler(config)
107
+ observer.schedule(handler, config.source_path, recursive=False)
108
+ observer.start()
109
+ return observer
@@ -0,0 +1,305 @@
1
+ Metadata-Version: 2.4
2
+ Name: audio-transcode-watcher
3
+ Version: 0.4.2
4
+ Summary: Watch a source folder and automatically transcode audio files to multiple formats
5
+ Project-URL: Homepage, https://github.com/GeiserX/audio-transcode-watcher
6
+ Project-URL: Repository, https://github.com/GeiserX/audio-transcode-watcher
7
+ Project-URL: Issues, https://github.com/GeiserX/audio-transcode-watcher/issues
8
+ Author: GeiserX
9
+ License-Expression: GPL-3.0-or-later
10
+ License-File: LICENSE
11
+ Keywords: aac,alac,audio,ffmpeg,flac,mp3,transcode,watchdog
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Conversion
20
+ Requires-Python: >=3.14
21
+ Requires-Dist: mutagen>=1.47.0
22
+ Requires-Dist: openai-whisper>=20240930
23
+ Requires-Dist: pyyaml>=6.0
24
+ Requires-Dist: syncedlyrics>=1.0.0
25
+ Requires-Dist: watchdog>=4.0.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
28
+ Requires-Dist: pytest-mock>=3.12.0; extra == 'dev'
29
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ <p align="center">
33
+ <img src="https://raw.githubusercontent.com/GeiserX/audio-transcode-watcher/main/docs/images/banner.svg" alt="audio-transcode-watcher banner" width="900" />
34
+ </p>
35
+
36
+ <p align="center">
37
+ <strong>A containerized service that watches a source folder and automatically transcodes audio files to multiple formats simultaneously.</strong>
38
+ </p>
39
+
40
+ <p align="center">
41
+ <a href="https://pypi.org/project/audio-transcode-watcher/"><img src="https://img.shields.io/pypi/v/audio-transcode-watcher?style=flat-square" alt="PyPI" /></a>
42
+ <a href="https://github.com/GeiserX/audio-transcode-watcher/actions/workflows/tests.yml"><img src="https://github.com/GeiserX/audio-transcode-watcher/actions/workflows/tests.yml/badge.svg" alt="Tests" /></a>
43
+ <a href="https://hub.docker.com/r/drumsergio/audio-transcoder"><img src="https://img.shields.io/docker/pulls/drumsergio/audio-transcoder" alt="Docker Pulls" /></a>
44
+ <a href="https://hub.docker.com/r/drumsergio/audio-transcoder"><img src="https://img.shields.io/docker/image-size/drumsergio/audio-transcoder/latest" alt="Docker Image Size" /></a>
45
+ <a href="https://www.gnu.org/licenses/gpl-3.0"><img src="https://img.shields.io/badge/License-GPLv3-blue.svg" alt="License: GPL v3" /></a>
46
+ <a href="https://github.com/GeiserX/audio-transcode-watcher/releases"><img src="https://img.shields.io/github/v/release/GeiserX/audio-transcode-watcher" alt="GitHub Release" /></a>
47
+ </p>
48
+
49
+ ---
50
+
51
+ Perfect for maintaining a music library in multiple formats -- lossless for archival, lossy for portable devices -- without lifting a finger. Drop a FLAC into your source folder and get ALAC, MP3, AAC, and Opus copies instantly.
52
+
53
+ ## Table of Contents
54
+
55
+ - [Features](#features)
56
+ - [Quick Start](#quick-start)
57
+ - [Configuration](#configuration)
58
+ - [How It Works](#how-it-works)
59
+ - [Performance](#performance)
60
+ - [Verification Tool](#verification-tool)
61
+ - [Development](#development)
62
+ - [License](#license)
63
+ - [Contributing](#contributing)
64
+
65
+ ## Features
66
+
67
+ - **Real-time file watching** -- Detects new, modified, renamed, or deleted files via watchdog
68
+ - **Multiple simultaneous outputs** -- Transcode to any number of formats in a single pass
69
+ - **Six codecs** -- ALAC, AAC, MP3, Opus, FLAC, WAV with configurable bitrates
70
+ - **Synced lyrics** -- Auto-fetches `.lrc` lyrics with Whisper speech-to-text fallback
71
+ - **Artwork preservation** -- Optionally embeds cover art in output files
72
+ - **Atomic writes** -- No partial or corrupted files on failure
73
+ - **Orphan cleanup** -- Automatically removes outputs that no longer have a source
74
+ - **Safety guards** -- Prevents accidental mass deletion if folders appear empty
75
+ - **Unicode safe** -- Properly handles special characters in filenames
76
+ - **Docker-first** -- Ships as a lightweight container built on Python 3.14-slim + FFmpeg
77
+
78
+ ## Quick Start
79
+
80
+ ### Docker Compose (Recommended)
81
+
82
+ **1.** Create a `config.yaml` file:
83
+
84
+ ```yaml
85
+ source:
86
+ path: /music/flac
87
+
88
+ outputs:
89
+ - name: alac
90
+ codec: alac
91
+ path: /music/alac
92
+
93
+ - name: mp3-256
94
+ codec: mp3
95
+ bitrate: 256k
96
+ path: /music/mp3
97
+
98
+ - name: aac-256
99
+ codec: aac
100
+ bitrate: 256k
101
+ path: /music/aac
102
+ ```
103
+
104
+ **2.** Create a `docker-compose.yml`:
105
+
106
+ ```yaml
107
+ services:
108
+ audio-transcoder:
109
+ image: drumsergio/audio-transcoder:0.4.1
110
+ container_name: audio_transcoder
111
+ environment:
112
+ - TZ=Europe/Madrid
113
+ - CONFIG_FILE=/app/config.yaml
114
+ volumes:
115
+ - ./config.yaml:/app/config.yaml:ro
116
+ - /path/to/flac:/music/flac:ro
117
+ - /path/to/alac:/music/alac
118
+ - /path/to/mp3:/music/mp3
119
+ - /path/to/aac:/music/aac
120
+ restart: unless-stopped
121
+ ```
122
+
123
+ **3.** Start the service:
124
+
125
+ ```bash
126
+ docker compose up -d
127
+ ```
128
+
129
+ ### Docker CLI
130
+
131
+ ```bash
132
+ docker run -d \
133
+ --name audio_transcoder \
134
+ -e TZ=Europe/Madrid \
135
+ -e CONFIG_FILE=/app/config.yaml \
136
+ -v ./config.yaml:/app/config.yaml:ro \
137
+ -v /path/to/flac:/music/flac:ro \
138
+ -v /path/to/mp3:/music/mp3 \
139
+ --restart unless-stopped \
140
+ drumsergio/audio-transcoder:0.4.1
141
+ ```
142
+
143
+ ## Configuration
144
+
145
+ Configuration is provided via a YAML file. Set the `CONFIG_FILE` environment variable to its path inside the container.
146
+
147
+ ### Full Configuration Example
148
+
149
+ ```yaml
150
+ # Source folder containing original audio files
151
+ source:
152
+ path: /music/flac
153
+
154
+ # Output destinations -- define as many as you need
155
+ outputs:
156
+ # Lossless ALAC for Apple devices
157
+ - name: alac
158
+ codec: alac
159
+ path: /music/alac
160
+ include_artwork: true
161
+
162
+ # High-quality MP3 for broad compatibility
163
+ - name: mp3-320
164
+ codec: mp3
165
+ bitrate: 320k
166
+ path: /music/mp3-320
167
+ include_artwork: true
168
+
169
+ # Balanced MP3 for portable devices
170
+ - name: mp3-192
171
+ codec: mp3
172
+ bitrate: 192k
173
+ path: /music/mp3-192
174
+ include_artwork: true
175
+
176
+ # AAC for modern devices
177
+ - name: aac-256
178
+ codec: aac
179
+ bitrate: 256k
180
+ path: /music/aac
181
+ include_artwork: true
182
+
183
+ # Opus for streaming (best quality-to-size ratio)
184
+ - name: opus-128
185
+ codec: opus
186
+ bitrate: 128k
187
+ path: /music/opus
188
+
189
+ # Optional settings
190
+ settings:
191
+ # Delete all outputs and re-encode on startup
192
+ force_reencode: false
193
+
194
+ # Maximum time to wait for a file to become stable (seconds)
195
+ stability_timeout: 60
196
+
197
+ # Minimum time a file must be unchanged before processing (seconds)
198
+ min_stable_seconds: 1.0
199
+ ```
200
+
201
+ ### Supported Codecs
202
+
203
+ | Codec | Extension | Bitrate | Artwork | Description |
204
+ |--------|-----------|-----------|---------|----------------------------|
205
+ | `alac` | `.m4a` | N/A | Yes | Lossless, Apple compatible |
206
+ | `aac` | `.m4a` | 64k--320k | Yes | Lossy, excellent quality |
207
+ | `mp3` | `.mp3` | 64k--320k | Yes | Lossy, universal support |
208
+ | `opus` | `.opus` | 32k--256k | No | Lossy, best quality/size |
209
+ | `flac` | `.flac` | N/A | Yes | Lossless, open format |
210
+ | `wav` | `.wav` | N/A | No | Lossless, uncompressed |
211
+
212
+ ### JSON Configuration
213
+
214
+ You can alternatively provide configuration as a JSON string via the `CONFIG_JSON` environment variable:
215
+
216
+ ```yaml
217
+ environment:
218
+ - CONFIG_JSON={"source":{"path":"/music/flac"},"outputs":[{"name":"mp3","codec":"mp3","bitrate":"256k","path":"/music/mp3"}]}
219
+ ```
220
+
221
+ ## How It Works
222
+
223
+ 1. **Initial sync** -- On startup, scans the source folder and encodes any missing files to all configured outputs.
224
+ 2. **Watch mode** -- Continuously monitors the source folder for changes:
225
+ - **New files** are encoded to all configured outputs
226
+ - **Modified files** are re-encoded to all outputs
227
+ - **Renamed files** trigger deletion of old outputs and creation of new ones
228
+ - **Deleted files** have their corresponding outputs removed
229
+ 3. **Orphan cleanup** -- Removes output files that no longer have a matching source.
230
+ 4. **Lyrics sync** -- Fetches synced `.lrc` lyrics from online databases; falls back to Whisper transcription when no lyrics are found.
231
+
232
+ ### Safety Guards
233
+
234
+ The service includes multiple guards to prevent data loss:
235
+
236
+ - If the source folder appears empty, no deletions are performed
237
+ - If any output folder appears empty, no deletions are performed
238
+ - All writes are atomic -- encoding happens to a temporary file that is moved into place only on success
239
+
240
+ ## Performance
241
+
242
+ - **Parallel processing** -- Multiple output formats are encoded concurrently
243
+ - **Incremental sync** -- Only missing or changed files are processed; unchanged files are skipped
244
+ - **Stability detection** -- Files are not processed until they have been stable on disk for a configurable period, avoiding partial reads during large copies or network transfers
245
+ - **Low idle footprint** -- Uses inotify/FSEvents-based watching with minimal CPU usage when idle
246
+
247
+ ## Verification Tool
248
+
249
+ A built-in verification tool checks that all outputs are in sync with the source:
250
+
251
+ ```bash
252
+ # Basic sync check
253
+ docker exec audio_transcoder python /app/tools/verify_sync.py --config /app/config.yaml
254
+
255
+ # Thorough check including duration comparison
256
+ docker exec audio_transcoder python /app/tools/verify_sync.py --config /app/config.yaml --check-duration -v
257
+ ```
258
+
259
+ ## Development
260
+
261
+ ### Requirements
262
+
263
+ - Docker (recommended), or Python 3.14+ with FFmpeg installed
264
+ - [Hatch](https://hatch.pypa.io/) build system
265
+
266
+ ### Running Tests
267
+
268
+ ```bash
269
+ python -m venv .venv
270
+ source .venv/bin/activate
271
+ pip install -e ".[dev]"
272
+
273
+ # Run tests with coverage
274
+ pytest
275
+ ```
276
+
277
+ ### Building the Docker Image
278
+
279
+ ```bash
280
+ docker build -t audio-transcoder:dev .
281
+ ```
282
+
283
+ ## Related Music Tools
284
+
285
+ | Project | Description |
286
+ |---------|-------------|
287
+ | [slskd-transform](https://github.com/GeiserX/slskd-transform) | Bulk upgrade your music library from lossy to lossless via Soulseek |
288
+ | [telegram-slskd-local-bot](https://github.com/GeiserX/telegram-slskd-local-bot) | Automated music discovery and download via Telegram |
289
+ | [jellyfin-encoder](https://github.com/GeiserX/jellyfin-encoder) | Automatic 720p HEVC/AV1 transcoding for Jellyfin |
290
+
291
+
292
+ ## License
293
+
294
+ This project is licensed under the **GNU General Public License v3.0** -- see the [LICENSE](LICENSE) file for details.
295
+
296
+ ## Contributing
297
+
298
+ Contributions are welcome. Please open an issue to discuss significant changes before submitting a pull request.
299
+
300
+ 1. Fork the repository
301
+ 2. Create a feature branch (`git checkout -b feat/amazing-feature`)
302
+ 3. Run tests (`pytest`)
303
+ 4. Commit your changes (`git commit -m 'feat: add amazing feature'`)
304
+ 5. Push to the branch (`git push origin feat/amazing-feature`)
305
+ 6. Open a Pull Request
@@ -0,0 +1,13 @@
1
+ audio_transcode_watcher/__init__.py,sha256=0CMXt0pg_rLju7to1KdACZCgI4iF2bjfs9J0NuBlmBQ,89
2
+ audio_transcode_watcher/config.py,sha256=0_Dcg3GM0ad9YVjv1DwgO6AeGd-Y8lI7ZSfhvUdVwt8,6319
3
+ audio_transcode_watcher/encoder.py,sha256=rezEqMFn0DrkIn1YuVMUxF6CqTV56R5A1SqyBHRw9II,5839
4
+ audio_transcode_watcher/lyrics.py,sha256=mJS3YCXsKEVXw-MHDHbuRRcvnJcjBYMhSRGOoYlVdGk,6173
5
+ audio_transcode_watcher/main.py,sha256=Uui0u_CwGcjDLoMXerKQefMndx0XS_uKioQDfqCN41g,2515
6
+ audio_transcode_watcher/sync.py,sha256=5jnXXaHDA60odnLv5TTGiUBiOykVv3ckFOP4RRTm4V8,17302
7
+ audio_transcode_watcher/utils.py,sha256=DoRJZx15wBYpYYwswtxcpFArAo-w0Tkr1AB6S2XF3cM,4067
8
+ audio_transcode_watcher/watcher.py,sha256=C4ipvFhOVqJnbs9VjXtyAb5SUJkP9YlJW8NG1s5QyA4,3564
9
+ audio_transcode_watcher-0.4.2.dist-info/METADATA,sha256=lDTx5i1QXkDIPblpmB56LZ6CUjjE-Dl_A5E-YhaUwEw,10683
10
+ audio_transcode_watcher-0.4.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ audio_transcode_watcher-0.4.2.dist-info/entry_points.txt,sha256=A9zP7O7DJdX-ThutrcqgbII8UX_GHr2_UoD87GBswe8,78
12
+ audio_transcode_watcher-0.4.2.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
13
+ audio_transcode_watcher-0.4.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ audio-transcode-watcher = audio_transcode_watcher.main:main