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.
- audio_transcode_watcher/__init__.py +3 -0
- audio_transcode_watcher/config.py +190 -0
- audio_transcode_watcher/encoder.py +208 -0
- audio_transcode_watcher/lyrics.py +193 -0
- audio_transcode_watcher/main.py +97 -0
- audio_transcode_watcher/sync.py +471 -0
- audio_transcode_watcher/utils.py +150 -0
- audio_transcode_watcher/watcher.py +109 -0
- audio_transcode_watcher-0.4.2.dist-info/METADATA +305 -0
- audio_transcode_watcher-0.4.2.dist-info/RECORD +13 -0
- audio_transcode_watcher-0.4.2.dist-info/WHEEL +4 -0
- audio_transcode_watcher-0.4.2.dist-info/entry_points.txt +2 -0
- audio_transcode_watcher-0.4.2.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""Synchronization logic for audio-transcode-watcher."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .config import Config, OutputConfig
|
|
14
|
+
from .encoder import atomic_ffmpeg_encode, build_ffmpeg_command
|
|
15
|
+
from .lyrics import fetch_lyrics_for_file
|
|
16
|
+
from .utils import (
|
|
17
|
+
LOSSLESS_EXTENSIONS,
|
|
18
|
+
SIDECAR_EXTENSIONS,
|
|
19
|
+
appears_empty_dir,
|
|
20
|
+
get_output_filename,
|
|
21
|
+
is_audio_file,
|
|
22
|
+
is_mp3,
|
|
23
|
+
nfc,
|
|
24
|
+
nfc_path,
|
|
25
|
+
wait_for_stable,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Default number of parallel encoding workers (can be overridden in config)
|
|
29
|
+
DEFAULT_PARALLEL_WORKERS = 4
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# Global state for tracking in-progress files
|
|
34
|
+
_in_progress: set[str] = set()
|
|
35
|
+
_in_progress_lock = threading.Lock()
|
|
36
|
+
|
|
37
|
+
# Safety guard logging throttle
|
|
38
|
+
_last_safety_log_ts = 0.0
|
|
39
|
+
_SAFETY_LOG_INTERVAL = 10.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def safety_guard_active(config: Config) -> bool:
|
|
43
|
+
"""
|
|
44
|
+
Check if safety guard should prevent operations.
|
|
45
|
+
|
|
46
|
+
Safety guard activates if source appears empty.
|
|
47
|
+
Empty destinations are allowed if allow_initial_bulk_encode is True.
|
|
48
|
+
"""
|
|
49
|
+
global _last_safety_log_ts
|
|
50
|
+
|
|
51
|
+
src_empty = appears_empty_dir(config.source_path)
|
|
52
|
+
|
|
53
|
+
# Source being empty is always a problem
|
|
54
|
+
if src_empty:
|
|
55
|
+
now = time.time()
|
|
56
|
+
if now - _last_safety_log_ts >= _SAFETY_LOG_INTERVAL:
|
|
57
|
+
logger.warning("Safety guard active: source directory appears empty")
|
|
58
|
+
_last_safety_log_ts = now
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
# Empty destinations are OK if allow_initial_bulk_encode is True
|
|
62
|
+
if config.allow_initial_bulk_encode:
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
# Otherwise, check destinations
|
|
66
|
+
dest_empty = {o.name: appears_empty_dir(o.path) for o in config.outputs}
|
|
67
|
+
|
|
68
|
+
if any(dest_empty.values()):
|
|
69
|
+
now = time.time()
|
|
70
|
+
if now - _last_safety_log_ts >= _SAFETY_LOG_INTERVAL:
|
|
71
|
+
empty_dests = [name for name, empty in dest_empty.items() if empty]
|
|
72
|
+
logger.warning(
|
|
73
|
+
"Safety guard active: empty_outputs=%s (set allow_initial_bulk_encode: true to allow)",
|
|
74
|
+
empty_dests,
|
|
75
|
+
)
|
|
76
|
+
_last_safety_log_ts = now
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def process_source_file(
|
|
83
|
+
source_path: str,
|
|
84
|
+
config: Config,
|
|
85
|
+
force: bool = False,
|
|
86
|
+
check_stable: bool = True,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""
|
|
89
|
+
Process a source file and create all configured outputs.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
source_path: Path to source audio file
|
|
93
|
+
config: Configuration
|
|
94
|
+
force: If True, re-encode even if output exists
|
|
95
|
+
check_stable: If True, wait for file to be stable first
|
|
96
|
+
"""
|
|
97
|
+
source_path = nfc_path(source_path)
|
|
98
|
+
|
|
99
|
+
if not is_audio_file(source_path):
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
if safety_guard_active(config):
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
# Wait for file to stabilize if needed
|
|
106
|
+
if check_stable and not wait_for_stable(
|
|
107
|
+
source_path,
|
|
108
|
+
min_stable_secs=config.min_stable_seconds,
|
|
109
|
+
timeout=config.stability_timeout,
|
|
110
|
+
):
|
|
111
|
+
logger.warning("Source not stable or disappeared: %s", source_path)
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
# Prevent duplicate concurrent processing
|
|
115
|
+
with _in_progress_lock:
|
|
116
|
+
if source_path in _in_progress:
|
|
117
|
+
return
|
|
118
|
+
_in_progress.add(source_path)
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
_process_outputs(source_path, config, force)
|
|
122
|
+
# Auto-fetch lyrics if enabled and no .lrc sidecar exists
|
|
123
|
+
if config.fetch_lyrics:
|
|
124
|
+
try:
|
|
125
|
+
fetch_lyrics_for_file(
|
|
126
|
+
source_path,
|
|
127
|
+
whisper_fallback=config.whisper_fallback,
|
|
128
|
+
whisper_model=config.whisper_model,
|
|
129
|
+
)
|
|
130
|
+
except Exception:
|
|
131
|
+
logger.debug("Lyrics fetch failed for %s", source_path, exc_info=True)
|
|
132
|
+
sync_sidecars(source_path, config)
|
|
133
|
+
finally:
|
|
134
|
+
with _in_progress_lock:
|
|
135
|
+
_in_progress.discard(source_path)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _has_lossless_source(source_path: str, config: Config) -> bool:
|
|
139
|
+
"""Check if a lossless source with the same stem exists."""
|
|
140
|
+
stem = Path(source_path).stem
|
|
141
|
+
source_dir = os.path.dirname(source_path)
|
|
142
|
+
|
|
143
|
+
for ext in LOSSLESS_EXTENSIONS:
|
|
144
|
+
lossless_path = os.path.join(source_dir, f"{stem}{ext}")
|
|
145
|
+
if os.path.exists(lossless_path) and lossless_path != source_path:
|
|
146
|
+
return True
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _process_outputs(source_path: str, config: Config, force: bool) -> None:
|
|
151
|
+
"""Process all outputs for a source file."""
|
|
152
|
+
stem = nfc(Path(source_path).stem)
|
|
153
|
+
source_is_mp3 = is_mp3(source_path)
|
|
154
|
+
|
|
155
|
+
for output in config.outputs:
|
|
156
|
+
if safety_guard_active(config):
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
# Determine output filename
|
|
160
|
+
if source_is_mp3 and output.codec == "alac":
|
|
161
|
+
# Skip MP3 if a lossless source with the same stem exists
|
|
162
|
+
if _has_lossless_source(source_path, config):
|
|
163
|
+
logger.debug("Skipping MP3 %s - lossless source exists", source_path)
|
|
164
|
+
continue
|
|
165
|
+
|
|
166
|
+
# Special case: copy MP3 to ALAC folder unchanged
|
|
167
|
+
out_filename = os.path.basename(source_path)
|
|
168
|
+
out_path = nfc_path(os.path.join(output.path, out_filename))
|
|
169
|
+
|
|
170
|
+
if force or not os.path.exists(out_path):
|
|
171
|
+
logger.info("► copy %s → %s", source_path, out_path)
|
|
172
|
+
os.makedirs(output.path, exist_ok=True)
|
|
173
|
+
try:
|
|
174
|
+
shutil.copy2(source_path, out_path)
|
|
175
|
+
except Exception as e:
|
|
176
|
+
logger.error("Copy failed %s → %s: %s", source_path, out_path, e)
|
|
177
|
+
else:
|
|
178
|
+
# Transcode
|
|
179
|
+
out_filename = get_output_filename(source_path, output.extension)
|
|
180
|
+
out_path = nfc_path(os.path.join(output.path, out_filename))
|
|
181
|
+
|
|
182
|
+
if force or not os.path.exists(out_path):
|
|
183
|
+
cmd = build_ffmpeg_command(source_path, out_path, output)
|
|
184
|
+
rc = atomic_ffmpeg_encode(cmd, out_path)
|
|
185
|
+
if rc != 0:
|
|
186
|
+
logger.error(
|
|
187
|
+
"%s encode failed for %s",
|
|
188
|
+
output.name.upper(),
|
|
189
|
+
source_path,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def delete_outputs(source_path: str, config: Config) -> None:
|
|
194
|
+
"""
|
|
195
|
+
Delete all output files corresponding to a source file.
|
|
196
|
+
|
|
197
|
+
Called when source file is deleted or renamed.
|
|
198
|
+
"""
|
|
199
|
+
source_path = nfc_path(source_path)
|
|
200
|
+
|
|
201
|
+
if safety_guard_active(config):
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
stem = nfc(Path(source_path).stem)
|
|
205
|
+
source_basename = os.path.basename(source_path)
|
|
206
|
+
source_is_mp3 = is_mp3(source_path)
|
|
207
|
+
|
|
208
|
+
for output in config.outputs:
|
|
209
|
+
filenames_to_check = []
|
|
210
|
+
|
|
211
|
+
if source_is_mp3:
|
|
212
|
+
# For MP3 source: only delete the copied MP3 in ALAC folder
|
|
213
|
+
# Don't delete transcoded files (they may come from a lossless source)
|
|
214
|
+
if output.codec == "alac":
|
|
215
|
+
filenames_to_check = [source_basename]
|
|
216
|
+
else:
|
|
217
|
+
# For other outputs, only delete if no lossless source exists
|
|
218
|
+
if not _has_lossless_source(source_path, config):
|
|
219
|
+
filenames_to_check = [f"{stem}{output.extension}"]
|
|
220
|
+
else:
|
|
221
|
+
# For lossless source: delete the transcoded output
|
|
222
|
+
filenames_to_check = [f"{stem}{output.extension}"]
|
|
223
|
+
|
|
224
|
+
for filename in filenames_to_check:
|
|
225
|
+
filepath = nfc_path(os.path.join(output.path, filename))
|
|
226
|
+
if os.path.exists(filepath):
|
|
227
|
+
try:
|
|
228
|
+
logger.info("✘ remove %s", filepath)
|
|
229
|
+
os.remove(filepath)
|
|
230
|
+
except Exception as e:
|
|
231
|
+
logger.error("Failed to remove %s: %s", filepath, e)
|
|
232
|
+
|
|
233
|
+
# Also remove sidecar files from all outputs
|
|
234
|
+
delete_sidecars(source_path, config)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def sync_sidecars(source_path: str, config: Config) -> None:
|
|
238
|
+
"""
|
|
239
|
+
Copy sidecar files (e.g. .lrc lyrics) from source to all output directories.
|
|
240
|
+
|
|
241
|
+
Copies if the destination is missing or older than the source.
|
|
242
|
+
"""
|
|
243
|
+
source_path = nfc_path(source_path)
|
|
244
|
+
stem = nfc(Path(source_path).stem)
|
|
245
|
+
source_dir = os.path.dirname(source_path)
|
|
246
|
+
|
|
247
|
+
for ext in SIDECAR_EXTENSIONS:
|
|
248
|
+
sidecar_src = nfc_path(os.path.join(source_dir, f"{stem}{ext}"))
|
|
249
|
+
if not os.path.isfile(sidecar_src):
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
for output in config.outputs:
|
|
253
|
+
sidecar_dst = nfc_path(os.path.join(output.path, f"{stem}{ext}"))
|
|
254
|
+
try:
|
|
255
|
+
needs_copy = not os.path.exists(sidecar_dst)
|
|
256
|
+
if not needs_copy:
|
|
257
|
+
needs_copy = os.path.getmtime(sidecar_src) > os.path.getmtime(sidecar_dst)
|
|
258
|
+
if needs_copy:
|
|
259
|
+
shutil.copy2(sidecar_src, sidecar_dst)
|
|
260
|
+
logger.info("► copy sidecar %s → %s", sidecar_src, sidecar_dst)
|
|
261
|
+
except Exception as e:
|
|
262
|
+
logger.error("Failed to copy sidecar %s → %s: %s", sidecar_src, sidecar_dst, e)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def delete_sidecars(source_path: str, config: Config) -> None:
|
|
266
|
+
"""Delete sidecar files from all output directories for a given source."""
|
|
267
|
+
source_path = nfc_path(source_path)
|
|
268
|
+
stem = nfc(Path(source_path).stem)
|
|
269
|
+
|
|
270
|
+
for ext in SIDECAR_EXTENSIONS:
|
|
271
|
+
for output in config.outputs:
|
|
272
|
+
sidecar_path = nfc_path(os.path.join(output.path, f"{stem}{ext}"))
|
|
273
|
+
if os.path.exists(sidecar_path):
|
|
274
|
+
try:
|
|
275
|
+
logger.info("✘ remove sidecar %s", sidecar_path)
|
|
276
|
+
os.remove(sidecar_path)
|
|
277
|
+
except Exception as e:
|
|
278
|
+
logger.error("Failed to remove sidecar %s: %s", sidecar_path, e)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def cleanup_stale_temp_files(config: Config) -> int:
|
|
282
|
+
"""
|
|
283
|
+
Remove stale temporary files from interrupted encodes.
|
|
284
|
+
|
|
285
|
+
These files have the .tmp__ff suffix and are left behind when
|
|
286
|
+
ffmpeg is interrupted (e.g., container restart, crash).
|
|
287
|
+
|
|
288
|
+
Returns the number of files cleaned up.
|
|
289
|
+
"""
|
|
290
|
+
cleaned = 0
|
|
291
|
+
for output in config.outputs:
|
|
292
|
+
try:
|
|
293
|
+
if not os.path.exists(output.path):
|
|
294
|
+
continue
|
|
295
|
+
with os.scandir(output.path) as entries:
|
|
296
|
+
for entry in entries:
|
|
297
|
+
if entry.is_file() and entry.name.endswith(".tmp__ff"):
|
|
298
|
+
try:
|
|
299
|
+
logger.info("✘ cleanup stale temp %s", entry.path)
|
|
300
|
+
os.remove(entry.path)
|
|
301
|
+
cleaned += 1
|
|
302
|
+
except Exception as e:
|
|
303
|
+
logger.error("Failed to remove temp file %s: %s", entry.path, e)
|
|
304
|
+
except Exception as e:
|
|
305
|
+
logger.error("Failed to scan %s for temp files: %s", output.path, e)
|
|
306
|
+
return cleaned
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def purge_all_outputs(config: Config) -> None:
|
|
310
|
+
"""
|
|
311
|
+
Delete all files in output directories.
|
|
312
|
+
|
|
313
|
+
Used when FORCE_REENCODE is enabled.
|
|
314
|
+
"""
|
|
315
|
+
if safety_guard_active(config):
|
|
316
|
+
logger.warning("Force re-encode requested but safety guard is active.")
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
for output in config.outputs:
|
|
320
|
+
try:
|
|
321
|
+
os.makedirs(output.path, exist_ok=True)
|
|
322
|
+
with os.scandir(output.path) as entries:
|
|
323
|
+
for entry in entries:
|
|
324
|
+
if entry.is_file():
|
|
325
|
+
try:
|
|
326
|
+
logger.info("✘ purge %s", entry.path)
|
|
327
|
+
os.remove(entry.path)
|
|
328
|
+
except Exception as e:
|
|
329
|
+
logger.error("Failed to purge %s: %s", entry.path, e)
|
|
330
|
+
except Exception as e:
|
|
331
|
+
logger.error("Failed to purge folder %s: %s", output.path, e)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def initial_sync(config: Config) -> None:
|
|
335
|
+
"""
|
|
336
|
+
Perform initial synchronization of source to all outputs.
|
|
337
|
+
|
|
338
|
+
- Cleans up stale temp files from interrupted encodes
|
|
339
|
+
- Creates output directories
|
|
340
|
+
- Encodes missing files
|
|
341
|
+
- Removes orphaned files from outputs
|
|
342
|
+
"""
|
|
343
|
+
# Create output directories
|
|
344
|
+
for output in config.outputs:
|
|
345
|
+
os.makedirs(output.path, exist_ok=True)
|
|
346
|
+
|
|
347
|
+
# Clean up stale temp files from previous interrupted runs
|
|
348
|
+
cleaned = cleanup_stale_temp_files(config)
|
|
349
|
+
if cleaned > 0:
|
|
350
|
+
logger.info("Cleaned up %d stale temp files from interrupted encodes.", cleaned)
|
|
351
|
+
|
|
352
|
+
# Purge if force_reencode is enabled
|
|
353
|
+
if config.force_reencode:
|
|
354
|
+
logger.info("FORCE_REENCODE enabled. Purging all outputs.")
|
|
355
|
+
purge_all_outputs(config)
|
|
356
|
+
|
|
357
|
+
if safety_guard_active(config):
|
|
358
|
+
logger.info("Initial sync skipped due to safety guard.")
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
logger.info("Initial sync …")
|
|
362
|
+
|
|
363
|
+
# Collect source files
|
|
364
|
+
try:
|
|
365
|
+
source_files = []
|
|
366
|
+
with os.scandir(config.source_path) as entries:
|
|
367
|
+
for entry in entries:
|
|
368
|
+
if entry.is_file() and is_audio_file(entry.path):
|
|
369
|
+
source_files.append(entry.path)
|
|
370
|
+
except Exception as e:
|
|
371
|
+
logger.error("Failed to scan source: %s", e)
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
# Get normalized stems for orphan detection
|
|
375
|
+
source_stems = {nfc(Path(f).stem) for f in source_files}
|
|
376
|
+
|
|
377
|
+
# Process all source files in parallel (skip stability check - files are on disk)
|
|
378
|
+
workers = getattr(config, 'parallel_workers', DEFAULT_PARALLEL_WORKERS)
|
|
379
|
+
logger.info("Processing %d source files with %d workers…", len(source_files), workers)
|
|
380
|
+
|
|
381
|
+
def process_one(src_file: str) -> None:
|
|
382
|
+
try:
|
|
383
|
+
process_source_file(src_file, config, force=False, check_stable=False)
|
|
384
|
+
except Exception as e:
|
|
385
|
+
logger.error("Error processing %s: %s", src_file, e)
|
|
386
|
+
|
|
387
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
388
|
+
futures = [executor.submit(process_one, f) for f in source_files]
|
|
389
|
+
for future in as_completed(futures):
|
|
390
|
+
pass
|
|
391
|
+
del futures
|
|
392
|
+
|
|
393
|
+
# Remove orphans
|
|
394
|
+
if safety_guard_active(config):
|
|
395
|
+
logger.info("Skipping orphan cleanup due to safety guard.")
|
|
396
|
+
logger.info("Initial sync complete (partial).")
|
|
397
|
+
return
|
|
398
|
+
|
|
399
|
+
_cleanup_orphans(config, source_stems)
|
|
400
|
+
logger.info("Initial sync complete.")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _cleanup_orphans(config: Config, source_stems: set[str]) -> None:
|
|
404
|
+
"""Remove output files that no longer have a source."""
|
|
405
|
+
# Build a set of stems that have lossless sources
|
|
406
|
+
lossless_stems = set()
|
|
407
|
+
try:
|
|
408
|
+
with os.scandir(config.source_path) as entries:
|
|
409
|
+
for entry in entries:
|
|
410
|
+
if entry.is_file():
|
|
411
|
+
ext = Path(entry.name).suffix.lower()
|
|
412
|
+
if ext in LOSSLESS_EXTENSIONS:
|
|
413
|
+
lossless_stems.add(nfc(Path(entry.name).stem))
|
|
414
|
+
except Exception:
|
|
415
|
+
pass
|
|
416
|
+
|
|
417
|
+
for output in config.outputs:
|
|
418
|
+
# Valid extensions for this output
|
|
419
|
+
# ALAC folder may contain both .m4a and .mp3 (copied MP3s)
|
|
420
|
+
if output.codec == "alac":
|
|
421
|
+
valid_extensions = (".m4a", ".mp3")
|
|
422
|
+
else:
|
|
423
|
+
valid_extensions = (output.extension,)
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
with os.scandir(output.path) as entries:
|
|
427
|
+
for entry in entries:
|
|
428
|
+
if not entry.is_file():
|
|
429
|
+
continue
|
|
430
|
+
|
|
431
|
+
if not entry.name.endswith(valid_extensions):
|
|
432
|
+
continue
|
|
433
|
+
|
|
434
|
+
stem = nfc(Path(entry.name).stem)
|
|
435
|
+
is_orphan = stem not in source_stems
|
|
436
|
+
|
|
437
|
+
# Special case: MP3 in ALAC folder is orphan if lossless source exists
|
|
438
|
+
if output.codec == "alac" and entry.name.endswith(".mp3"):
|
|
439
|
+
if stem in lossless_stems:
|
|
440
|
+
is_orphan = True
|
|
441
|
+
|
|
442
|
+
if is_orphan:
|
|
443
|
+
filepath = nfc_path(entry.path)
|
|
444
|
+
try:
|
|
445
|
+
logger.info("✘ remove orphan %s", filepath)
|
|
446
|
+
os.remove(filepath)
|
|
447
|
+
except Exception as e:
|
|
448
|
+
logger.error("Failed to remove %s: %s", filepath, e)
|
|
449
|
+
except Exception as e:
|
|
450
|
+
logger.error("Failed to scan %s for orphans: %s", output.path, e)
|
|
451
|
+
|
|
452
|
+
# Clean up orphaned sidecar files in output directories
|
|
453
|
+
sidecar_exts = tuple(SIDECAR_EXTENSIONS)
|
|
454
|
+
for output in config.outputs:
|
|
455
|
+
try:
|
|
456
|
+
with os.scandir(output.path) as entries:
|
|
457
|
+
for entry in entries:
|
|
458
|
+
if not entry.is_file():
|
|
459
|
+
continue
|
|
460
|
+
if not entry.name.lower().endswith(sidecar_exts):
|
|
461
|
+
continue
|
|
462
|
+
stem = nfc(Path(entry.name).stem)
|
|
463
|
+
if stem not in source_stems:
|
|
464
|
+
filepath = nfc_path(entry.path)
|
|
465
|
+
try:
|
|
466
|
+
logger.info("✘ remove orphan sidecar %s", filepath)
|
|
467
|
+
os.remove(filepath)
|
|
468
|
+
except Exception as e:
|
|
469
|
+
logger.error("Failed to remove sidecar %s: %s", filepath, e)
|
|
470
|
+
except Exception as e:
|
|
471
|
+
logger.error("Failed to scan %s for orphan sidecars: %s", output.path, e)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Utility functions for audio-transcode-watcher."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
import unicodedata
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
# Audio file extensions considered as source files
|
|
11
|
+
LOSSLESS_EXTENSIONS = {
|
|
12
|
+
".flac", ".alac", ".wav", ".ape", ".aiff",
|
|
13
|
+
".wv", ".tta", ".ogg", ".opus"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
LOSSY_EXTENSIONS = {".mp3", ".aac", ".m4a"}
|
|
17
|
+
|
|
18
|
+
AUDIO_EXTENSIONS = LOSSLESS_EXTENSIONS | LOSSY_EXTENSIONS | {".mp3"}
|
|
19
|
+
|
|
20
|
+
# Sidecar file extensions to copy alongside transcoded audio
|
|
21
|
+
SIDECAR_EXTENSIONS = {".lrc"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def nfc(s: str) -> str:
|
|
25
|
+
"""Normalize string to NFC Unicode form."""
|
|
26
|
+
return unicodedata.normalize("NFC", s)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def nfc_path(p: str) -> str:
|
|
30
|
+
"""Normalize every path component to NFC Unicode form."""
|
|
31
|
+
parts = list(Path(p).parts)
|
|
32
|
+
if not parts:
|
|
33
|
+
return p
|
|
34
|
+
|
|
35
|
+
if parts[0] == os.sep:
|
|
36
|
+
# Absolute path handling on POSIX
|
|
37
|
+
normalized = os.sep + os.path.join(*[nfc(x) for x in parts[1:]])
|
|
38
|
+
else:
|
|
39
|
+
normalized = os.path.join(*[nfc(x) for x in parts])
|
|
40
|
+
|
|
41
|
+
return normalized
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def has_audio_extension(path: str) -> bool:
|
|
45
|
+
"""Check if a path has a recognized audio file extension (no existence check).
|
|
46
|
+
|
|
47
|
+
Use this instead of is_audio_file() when the file may no longer exist
|
|
48
|
+
on disk (e.g. in watchdog on_deleted / on_moved handlers).
|
|
49
|
+
"""
|
|
50
|
+
return Path(nfc_path(path)).suffix.lower() in AUDIO_EXTENSIONS
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_audio_file(path: str) -> bool:
|
|
54
|
+
"""Check if a file is a recognized audio file (must exist on disk)."""
|
|
55
|
+
normalized = nfc_path(path)
|
|
56
|
+
if not os.path.isfile(normalized):
|
|
57
|
+
return False
|
|
58
|
+
return Path(normalized).suffix.lower() in AUDIO_EXTENSIONS
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def has_sidecar_extension(path: str) -> bool:
|
|
62
|
+
"""Check if a path has a recognized sidecar file extension (e.g. .lrc)."""
|
|
63
|
+
return Path(nfc_path(path)).suffix.lower() in SIDECAR_EXTENSIONS
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_lossless(path: str) -> bool:
|
|
67
|
+
"""Check if a file is a lossless audio file."""
|
|
68
|
+
return Path(path).suffix.lower() in LOSSLESS_EXTENSIONS
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_mp3(path: str) -> bool:
|
|
72
|
+
"""Check if a file is an MP3."""
|
|
73
|
+
return Path(path).suffix.lower() == ".mp3"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def appears_empty_dir(path: str) -> bool:
|
|
77
|
+
"""
|
|
78
|
+
Check if a directory appears empty (no non-hidden files).
|
|
79
|
+
|
|
80
|
+
Returns True if:
|
|
81
|
+
- Directory doesn't exist
|
|
82
|
+
- Directory is not accessible
|
|
83
|
+
- Directory contains only hidden files (starting with .)
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
if not os.path.isdir(path):
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
with os.scandir(path) as entries:
|
|
90
|
+
for entry in entries:
|
|
91
|
+
if not entry.name.startswith("."):
|
|
92
|
+
return False
|
|
93
|
+
return True
|
|
94
|
+
except Exception:
|
|
95
|
+
# On any error, treat as empty to be safe
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def wait_for_stable(
|
|
100
|
+
path: str,
|
|
101
|
+
min_stable_secs: float = 1.0,
|
|
102
|
+
timeout: float = 60.0
|
|
103
|
+
) -> bool:
|
|
104
|
+
"""
|
|
105
|
+
Wait until file size stays unchanged for min_stable_secs.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
path: Path to the file
|
|
109
|
+
min_stable_secs: Minimum seconds the file must be stable
|
|
110
|
+
timeout: Maximum time to wait
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
True if file is stable, False if timed out or file doesn't exist
|
|
114
|
+
"""
|
|
115
|
+
path = nfc_path(path)
|
|
116
|
+
start = time.time()
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
last_size = os.path.getsize(path)
|
|
120
|
+
except OSError:
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
last_change = time.time()
|
|
124
|
+
|
|
125
|
+
while time.time() - start < timeout:
|
|
126
|
+
try:
|
|
127
|
+
cur_size = os.path.getsize(path)
|
|
128
|
+
except OSError:
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
if cur_size != last_size:
|
|
132
|
+
last_size = cur_size
|
|
133
|
+
last_change = time.time()
|
|
134
|
+
elif time.time() - last_change >= min_stable_secs:
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
time.sleep(0.2)
|
|
138
|
+
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_output_filename(source_path: str, extension: str) -> str:
|
|
143
|
+
"""
|
|
144
|
+
Get the output filename for a source file.
|
|
145
|
+
|
|
146
|
+
Uses the stem of the source file and adds the new extension.
|
|
147
|
+
All components are NFC-normalized.
|
|
148
|
+
"""
|
|
149
|
+
stem = nfc(Path(source_path).stem)
|
|
150
|
+
return f"{stem}{extension}"
|