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,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}"