From cd9ea6bad2b02c02b4b4e8043130ab2155cb6381 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:43:35 +0200 Subject: [PATCH 1/6] fix: copy lossy sources as-is, fail on corrupt sources, keep M4A tags Lossy files from the bot (mp3, m4a, ogg, opus, wma) are copied unchanged into lossless outputs instead of being inflated into fake lossless copies, and into lossy outputs of their own codec. ogg and opus are no longer treated as lossless; aif and tak are recognised as lossless. A source that does not decode cleanly now fails: ffmpeg runs with -xerror and a decode error on stderr counts even when it exits 0. No output is written and the source is not retried until its mtime changes. The cover-art retry only fires on errors about the picture stream. ALAC and AAC encodes get ReplayGain, MusicBrainz ids, ISRC, label, catalog number and sort tags as iTunes freeform atoms. The Whisper lyrics fallback is gone; junk lyrics are rejected and the .lrc is owned like its source. The orphan pass re-walks the source and skips young or in-progress files, temp files younger than 10 minutes are kept, the periodic pass logs as "Periodic sync", and its interval is settings.sync_interval_seconds. --- src/audio_transcode_watcher/config.py | 35 +- src/audio_transcode_watcher/encoder.py | 233 ++++++++++--- src/audio_transcode_watcher/lyrics.py | 177 +++++----- src/audio_transcode_watcher/main.py | 9 +- src/audio_transcode_watcher/sync.py | 390 +++++++++++++-------- src/audio_transcode_watcher/utils.py | 29 +- tests/test_config.py | 51 +++ tests/test_encoder.py | 247 ++++++++++++++ tests/test_lyrics.py | 323 +++++++----------- tests/test_main.py | 37 ++ tests/test_sync.py | 455 ++++++++++++++++++++++++- tests/test_utils.py | 50 +++ tools/verify_sync.py | 18 +- 13 files changed, 1538 insertions(+), 516 deletions(-) diff --git a/src/audio_transcode_watcher/config.py b/src/audio_transcode_watcher/config.py index d34f87c..22afbeb 100644 --- a/src/audio_transcode_watcher/config.py +++ b/src/audio_transcode_watcher/config.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging import os from dataclasses import dataclass, field from pathlib import Path @@ -10,6 +11,16 @@ import yaml +logger = logging.getLogger(__name__) + +# Settings that older releases read and this one ignores. A config that +# still sets them loads fine; the first load logs one warning. +DEPRECATED_SETTINGS = { + "whisper_fallback": "the Whisper lyrics fallback was removed in 0.6.0", + "whisper_model": "the Whisper lyrics fallback was removed in 0.6.0", +} +_deprecation_warned = False + # Codec to file extension mapping CODEC_EXTENSIONS = { @@ -94,13 +105,15 @@ class Config: stability_timeout: float = 60.0 min_stable_seconds: float = 1.0 fetch_lyrics: bool = True # Auto-fetch .lrc lyrics via syncedlyrics - whisper_fallback: bool = True # Use Whisper local transcription as fallback - whisper_model: str = "base" # Whisper model size: tiny, base, small, medium, large + sync_interval_seconds: int = 300 # Seconds between periodic full syncs def __post_init__(self) -> None: """Validate configuration after initialization.""" if not self.source_path: raise ValueError("source_path is required") + + if self.sync_interval_seconds <= 0: + raise ValueError("sync_interval_seconds must be greater than 0") if not self.outputs: raise ValueError("At least one output is required") @@ -132,6 +145,7 @@ def from_dict(cls, data: dict[str, Any]) -> Config: """Create Config from a dictionary.""" outputs = [OutputConfig.from_dict(o) for o in data.get("outputs", [])] settings = data.get("settings", {}) + _warn_deprecated_settings(settings) return cls( source_path=data.get("source", {}).get("path", ""), @@ -142,8 +156,7 @@ def from_dict(cls, data: dict[str, Any]) -> Config: stability_timeout=settings.get("stability_timeout", 60.0), min_stable_seconds=settings.get("min_stable_seconds", 1.0), fetch_lyrics=settings.get("fetch_lyrics", True), - whisper_fallback=settings.get("whisper_fallback", True), - whisper_model=settings.get("whisper_model", "base"), + sync_interval_seconds=settings.get("sync_interval_seconds", 300), ) @classmethod @@ -160,6 +173,20 @@ def from_json_string(cls, json_str: str) -> Config: return cls.from_dict(data) +def _warn_deprecated_settings(settings: dict[str, Any]) -> None: + """Log one warning per process for settings that are accepted but ignored.""" + global _deprecation_warned + found = sorted(k for k in DEPRECATED_SETTINGS if k in settings) + if not found or _deprecation_warned: + return + _deprecation_warned = True + logger.warning( + "Ignoring deprecated settings %s: %s. Remove them from your config.", + ", ".join(found), + DEPRECATED_SETTINGS[found[0]], + ) + + def load_config() -> Config: """ Load configuration from environment variables. diff --git a/src/audio_transcode_watcher/encoder.py b/src/audio_transcode_watcher/encoder.py index 5b93078..510ba25 100644 --- a/src/audio_transcode_watcher/encoder.py +++ b/src/audio_transcode_watcher/encoder.py @@ -5,12 +5,71 @@ import logging import os import subprocess +from collections.abc import Callable + +import mutagen +from mutagen.apev2 import APETextValue +from mutagen.mp4 import MP4, MP4FreeForm from .config import OutputConfig from .utils import nfc_path logger = logging.getLogger(__name__) +# Exit code returned when ffmpeg exits 0 but reported a decode error. +DECODE_ERROR_RC = 69 + +# stderr text that means the audio did not decode cleanly, even when +# ffmpeg exits 0 (a corrupt FLAC used to come out seconds short). +_DECODE_ERROR_HINTS = ("decode_frame() failed", "invalid", "error while decoding") + +# stderr text that points at the attached picture (cover art) stream. +# Only these trigger the retry without artwork. +_ARTWORK_ERROR_HINTS = ( + "could not find tag for codec", + "attached pic", + "video stream", + "mjpeg", + "png", + "vist#", + "vf#", +) + +# Tags the standard MP4 atoms cannot hold, copied after an ALAC or AAC +# encode as iTunes freeform atoms ``----:com.apple.iTunes:``. +FREEFORM_TAGS = ( + "REPLAYGAIN_TRACK_GAIN", + "REPLAYGAIN_TRACK_PEAK", + "REPLAYGAIN_ALBUM_GAIN", + "REPLAYGAIN_ALBUM_PEAK", + "MUSICBRAINZ_TRACKID", + "MUSICBRAINZ_ALBUMID", + "MUSICBRAINZ_ARTISTID", + "MUSICBRAINZ_ALBUMARTISTID", + "MUSICBRAINZ_RELEASEGROUPID", + "ISRC", + "LABEL", + "CATALOGNUMBER", + "ARTISTSORT", + "ALBUMARTISTSORT", + "ALBUMSORT", +) + +# How the same tags are stored in ID3 (WAV, AIFF and MP3 sources). +_ID3_FRAMES = { + "ISRC": "TSRC", + "LABEL": "TPUB", + "ARTISTSORT": "TSOP", + "ALBUMARTISTSORT": "TSO2", + "ALBUMSORT": "TSOA", +} +_ID3_TXXX = { + "MUSICBRAINZ_ALBUMID": "MusicBrainz Album Id", + "MUSICBRAINZ_ARTISTID": "MusicBrainz Artist Id", + "MUSICBRAINZ_ALBUMARTISTID": "MusicBrainz Album Artist Id", + "MUSICBRAINZ_RELEASEGROUPID": "MusicBrainz Release Group Id", +} + def build_ffmpeg_command( source: str, @@ -33,7 +92,7 @@ def build_ffmpeg_command( # Common arguments cmd = [ - "ffmpeg", "-loglevel", "error", "-y", + "ffmpeg", "-loglevel", "error", "-xerror", "-y", "-i", source, "-map", "0:a:0", # First audio stream ] @@ -121,10 +180,44 @@ def _remove_artwork_from_command(cmd: list[str]) -> list[str]: return filtered +def _source_from_cmd(cmd: list[str]) -> str: + """Return the input file of an ffmpeg command, for log lines.""" + try: + return cmd[cmd.index("-i") + 1] + except (ValueError, IndexError): + return "?" + + +def _decode_error(stderr: str) -> str | None: + """Return the first stderr line that reports a decode error, if any.""" + for line in stderr.splitlines(): + low = line.lower() + if any(h in low for h in _DECODE_ERROR_HINTS): + return line.strip() + return None + + +def _is_artwork_error(stderr: str) -> bool: + """True when stderr points at the attached picture stream.""" + low = stderr.lower() + return any(h in low for h in _ARTWORK_ERROR_HINTS) + + +def _run_ffmpeg(cmd: list[str]) -> tuple[int, str]: + """Run ffmpeg; a decode error on stderr counts as a failure even with rc 0.""" + proc = subprocess.run(cmd, capture_output=True) + stderr = proc.stderr.decode("utf-8", errors="ignore") if proc.stderr else "" + rc = proc.returncode + if rc == 0 and _decode_error(stderr): + rc = DECODE_ERROR_RC + return rc, stderr + + def atomic_ffmpeg_encode( cmd: list[str], final_dest: str, retry_without_artwork: bool = True, + finalize: Callable[[str], None] | None = None, ) -> int: """ Run FFmpeg with atomic output (write to temp, then rename). @@ -132,10 +225,13 @@ def atomic_ffmpeg_encode( Args: cmd: FFmpeg command (last element is destination) final_dest: Final destination path - retry_without_artwork: If True, retry without artwork on failure + retry_without_artwork: If True, retry without artwork when the + failure points at the attached picture stream + finalize: Optional callable run on the finished temp file before + the rename (used to copy extra tags) Returns: - Return code (0 for success) + Return code (0 for success). No output is written on failure. """ final_dest = nfc_path(final_dest) dest_dir = os.path.dirname(final_dest) @@ -153,50 +249,107 @@ def atomic_ffmpeg_encode( # Replace destination with temp path cmd = list(cmd) cmd[-1] = tmp_dest + source = _source_from_cmd(cmd) logger.info("► %s", " ".join(cmd)) - proc = subprocess.run(cmd, capture_output=True) - rc = proc.returncode + rc, stderr = _run_ffmpeg(cmd) + + if rc != 0 and retry_without_artwork and _is_artwork_error(stderr): + _cleanup_temp(tmp_dest) + logger.warning("Retrying without cover art for %s", final_dest) + cmd = _remove_artwork_from_command(cmd) + cmd[-1] = tmp_dest + logger.info("► (retry) %s", " ".join(cmd)) + rc, stderr = _run_ffmpeg(cmd) - if rc == 0: + if rc != 0: + detail = _decode_error(stderr) or (stderr.strip().splitlines() or [""])[-1] + logger.error( + "FFmpeg failed (rc=%s) for %s → %s: %s", rc, source, final_dest, detail + ) + _cleanup_temp(tmp_dest) + return rc + + if finalize is not None: try: - os.replace(tmp_dest, final_dest) - return 0 + finalize(tmp_dest) except Exception as e: - logger.error("Atomic replace failed for %s: %s", final_dest, e) - _cleanup_temp(tmp_dest) - return 1 - - # Handle failure - logger.error("FFmpeg failed (rc=%s) for %s", rc, final_dest) - stderr = proc.stderr.decode("utf-8", errors="ignore") if proc.stderr else "" - _cleanup_temp(tmp_dest) + logger.warning("Post-encode step failed for %s: %s", final_dest, e) - # Retry without artwork if error seems artwork-related - artwork_error_hints = ["vf#", "vist#", "VipsJpeg", "png", "mjpeg", "decode"] - if retry_without_artwork and any(h in stderr.lower() for h in artwork_error_hints): - logger.warning("Retrying without cover art for %s", final_dest) - - filtered_cmd = _remove_artwork_from_command(cmd) - filtered_cmd[-1] = tmp_dest - - logger.info("► (retry) %s", " ".join(filtered_cmd)) - proc2 = subprocess.run(filtered_cmd, capture_output=True) - rc = proc2.returncode - - if rc == 0: - try: - os.replace(tmp_dest, final_dest) - return 0 - except Exception as e: - logger.error("Atomic replace failed for %s: %s", final_dest, e) - _cleanup_temp(tmp_dest) - return 1 - - logger.error("FFmpeg retry also failed (rc=%s) for %s", rc, final_dest) + try: + os.replace(tmp_dest, final_dest) + return 0 + except Exception as e: + logger.error("Atomic replace failed for %s: %s", final_dest, e) _cleanup_temp(tmp_dest) - - return rc + return 1 + + +def _source_tag_values(source: str) -> dict[str, list[str]]: + """ + Read FREEFORM_TAGS from *source*, whatever its tag format. + + Vorbis comments (FLAC, Ogg, Opus) and APEv2 (APE, WavPack, TAK) store + them under the same names; ID3 (WAV, AIFF, MP3) uses TXXX and a few + standard frames. + """ + audio = mutagen.File(source) + if audio is None or audio.tags is None: + return {} + tags = audio.tags + + # Name -> values, with keys upper-cased for case-insensitive lookup. + plain: dict[str, list[str]] = {} + is_id3 = hasattr(tags, "getall") + if is_id3: + for frame in tags.getall("TXXX"): + plain[frame.desc.upper()] = [str(v) for v in frame.text] + for name, frame_id in _ID3_FRAMES.items(): + frame = tags.get(frame_id) + if frame is not None: + plain[name] = [str(v) for v in frame.text] + ufid = tags.get("UFID:http://musicbrainz.org") + if ufid is not None: + plain["MUSICBRAINZ_TRACKID"] = [ufid.data.decode("ascii", "ignore")] + for name, desc in _ID3_TXXX.items(): + if desc.upper() in plain: + plain[name] = plain[desc.upper()] + else: + for key, value in tags.items(): + if isinstance(value, list): + values = value + elif isinstance(value, APETextValue): + values = list(value) + else: + values = [value] + plain[str(key).upper()] = [str(v) for v in values] + + out: dict[str, list[str]] = {} + for name in FREEFORM_TAGS: + values = [v for v in plain.get(name, []) if v.strip()] + if values: + out[name] = values + return out + + +def copy_freeform_tags(source: str, dest: str) -> int: + """ + Copy FREEFORM_TAGS from *source* into the MP4 file *dest*. + + Returns the number of tags written. + """ + values = _source_tag_values(source) + if not values: + return 0 + mp4 = MP4(dest) + if mp4.tags is None: + mp4.add_tags() + for name, vals in values.items(): + mp4.tags[f"----:com.apple.iTunes:{name}"] = [ + MP4FreeForm(v.encode("utf-8")) for v in vals + ] + mp4.save() + return len(values) def _cleanup_temp(path: str) -> None: diff --git a/src/audio_transcode_watcher/lyrics.py b/src/audio_transcode_watcher/lyrics.py index 0a1268e..095c374 100644 --- a/src/audio_transcode_watcher/lyrics.py +++ b/src/audio_transcode_watcher/lyrics.py @@ -1,4 +1,4 @@ -"""Automatic lyrics fetching with syncedlyrics and Whisper fallback.""" +"""Automatic lyrics fetching with syncedlyrics.""" from __future__ import annotations @@ -14,75 +14,40 @@ logger = logging.getLogger(__name__) -# Lazy-loaded Whisper model (heavyweight, only load once when needed) -_whisper_model = None -_whisper_load_failed = False +# A synced line: one or more [mm:ss.xx] stamps, then the text. +_TIMED_LINE = re.compile(r"^\s*(?:\[\d{1,3}:\d{1,2}(?:[.:]\d{1,3})?\])+(.*)$") +_URL = re.compile(r"(https?://|www\.)\S+|\b\S+\.(com|net|org|io|me|ru|cn)\b", re.IGNORECASE) +_TOKEN = re.compile(r"\w+|[^\w\s]+") +# Fewer timed lines than this is not a usable synced lyric. +MIN_TIMED_LINES = 4 -def _get_whisper_model(model_name: str = "base"): - """Lazy-load and cache the Whisper model.""" - global _whisper_model, _whisper_load_failed - if _whisper_load_failed: - return None - if _whisper_model is not None: - return _whisper_model - try: - import whisper - - logger.info("Loading Whisper model '%s' (first use, may take a moment)...", model_name) - _whisper_model = whisper.load_model(model_name) - logger.info("Whisper model '%s' loaded", model_name) - return _whisper_model - except Exception: - logger.warning("Failed to load Whisper model", exc_info=True) - _whisper_load_failed = True - return None - - -def _segments_to_lrc(segments: list[dict]) -> str: - """Convert Whisper transcript segments to LRC format.""" - lines = [] - for seg in segments: - start = seg.get("start", 0.0) - text = seg.get("text", "").strip() - if not text: - continue - mins = int(start // 60) - secs = start % 60 - lines.append(f"[{mins:02d}:{secs:05.2f}] {text}") - return "\n".join(lines) +def lyrics_reject_reason(content: str) -> str | None: + """ + Return why a fetched lyric should not be saved, or None if it is usable. -def _transcribe_with_whisper(filepath: str, model_name: str = "base") -> str | None: + Rejects plain or near-empty results (fewer than MIN_TIMED_LINES timed + lines with text), results made of a single repeated token (``"♪ ♪ ♪"``), + and results whose only text is an advertisement line carrying a URL. """ - Transcribe audio to synced lyrics using Whisper. + texts = [] + for line in content.splitlines(): + m = _TIMED_LINE.match(line) + if m and m.group(1).strip(): + texts.append(m.group(1).strip()) - Args: - filepath: Path to the audio file. - model_name: Whisper model size (tiny, base, small, medium, large). + if texts and all(_URL.search(t) for t in texts): + return "only an advertisement line" - Returns: - LRC-formatted string, or None on failure. - """ - model = _get_whisper_model(model_name) - if model is None: - return None + tokens = {t.lower() for text in texts for t in _TOKEN.findall(text)} + if len(tokens) == 1: + return f"a single repeated token ({next(iter(tokens))!r})" - try: - logger.info("Transcribing with Whisper: %s", Path(filepath).name) - result = model.transcribe(filepath, verbose=False) - segments = result.get("segments", []) - if not segments: - logger.info("Whisper produced no segments for: %s", Path(filepath).name) - return None - lrc = _segments_to_lrc(segments) - logger.info( - "Whisper transcribed %d segments for: %s", len(segments), Path(filepath).name - ) - return lrc - except Exception: - logger.warning("Whisper transcription failed for: %s", filepath, exc_info=True) - return None + if len(texts) < MIN_TIMED_LINES: + return f"{len(texts)} timed lines (need {MIN_TIMED_LINES})" + + return None def extract_metadata(filepath: str) -> tuple[str, str] | None: @@ -123,27 +88,19 @@ def extract_metadata(filepath: str) -> tuple[str, str] | None: return None -def fetch_lyrics_for_file( - filepath: str, - whisper_fallback: bool = True, - whisper_model: str = "base", -) -> str | None: +def fetch_lyrics_for_file(filepath: str) -> str | None: """ Fetch synced lyrics (.lrc) for an audio file if not already present. Strategy: 1. Check if .lrc sidecar already exists -> skip 2. Try syncedlyrics providers (Musixmatch, LRCLIB, NetEase) - 3. If nothing found and whisper_fallback enabled, transcribe locally - - Args: - filepath: Path to the audio file. - whisper_fallback: Use Whisper local transcription as fallback. - whisper_model: Whisper model size (tiny, base, small, medium, large). + 3. Write the result only if it passes lyrics_reject_reason(); when + nothing usable is found, write nothing Returns: - Path to the written .lrc file, or None if lyrics were not found - or already existed. + Path to the written .lrc file, or None if lyrics were not found, + were rejected, or already existed. """ filepath = nfc_path(filepath) lrc_path = nfc_path(str(Path(filepath).with_suffix(".lrc"))) @@ -152,42 +109,62 @@ def fetch_lyrics_for_file( if os.path.isfile(lrc_path): return None - lrc_content: str | None = None - - # Step 1: Try syncedlyrics meta = extract_metadata(filepath) - if meta is not None: - artist, title = meta - query = f"{artist} {title}" - try: - lrc_content = syncedlyrics.search(query) - except Exception: - logger.warning("syncedlyrics search failed for: %s", query, exc_info=True) + if meta is None: + logger.debug("Cannot extract metadata for lyrics: %s", filepath) + return None - if lrc_content: - return _write_lrc(lrc_path, lrc_content, f"{artist} - {title}", "syncedlyrics") + artist, title = meta + query = f"{artist} {title}" + lrc_content: str | None = None + try: + lrc_content = syncedlyrics.search(query) + except Exception: + logger.warning("syncedlyrics search failed for: %s", query, exc_info=True) + if not lrc_content: logger.info("No lyrics found via syncedlyrics for: %s - %s", artist, title) - else: - logger.debug("Cannot extract metadata for lyrics: %s", filepath) + return None - # Step 2: Whisper fallback - if whisper_fallback: - lrc_content = _transcribe_with_whisper(filepath, whisper_model) - if lrc_content: - label = f"{meta[0]} - {meta[1]}" if meta else Path(filepath).stem - return _write_lrc(lrc_path, lrc_content, label, "whisper") + reason = lyrics_reject_reason(lrc_content) + if reason: + logger.info("Rejected lyrics for %s - %s: %s", artist, title, reason) + return None - return None + return _write_lrc( + lrc_path, lrc_content, f"{artist} - {title}", "syncedlyrics", owner_of=filepath + ) -def _write_lrc(lrc_path: str, content: str, label: str, source: str) -> str | None: - """Write LRC content to disk.""" +def _write_lrc( + lrc_path: str, + content: str, + label: str, + source: str, + owner_of: str | None = None, +) -> str | None: + """Write LRC content to disk, owned like *owner_of* with mode 0664.""" try: with open(lrc_path, "w", encoding="utf-8") as f: f.write(content) - logger.info("♫ lyrics saved (%s): %s → %s", source, label, lrc_path) - return lrc_path except Exception: logger.error("Failed to write lyrics file: %s", lrc_path, exc_info=True) return None + + if owner_of: + _match_owner(lrc_path, owner_of) + logger.info("♫ lyrics saved (%s): %s → %s", source, label, lrc_path) + return lrc_path + + +def _match_owner(path: str, reference: str) -> None: + """Give *path* the uid:gid of *reference* and mode 0664 (best effort).""" + try: + st = os.stat(reference) + os.chown(path, st.st_uid, st.st_gid) + except OSError as e: + logger.debug("Could not chown %s like %s: %s", path, reference, e) + try: + os.chmod(path, 0o664) + except OSError as e: + logger.debug("Could not chmod %s: %s", path, e) diff --git a/src/audio_transcode_watcher/main.py b/src/audio_transcode_watcher/main.py index e733839..122fb13 100644 --- a/src/audio_transcode_watcher/main.py +++ b/src/audio_transcode_watcher/main.py @@ -72,18 +72,17 @@ def main() -> int: observer = start_watcher(config) logger.info("Watching %s …", config.source_path) - # Periodic sync interval (check for missing outputs every 5 minutes) - sync_interval = 300 # 5 minutes + # Periodic sync interval (settings.sync_interval_seconds, default 300) + sync_interval = config.sync_interval_seconds last_sync = time.time() try: while True: - time.sleep(10) + time.sleep(min(10, sync_interval)) # Periodic sync to catch deleted outputs if time.time() - last_sync >= sync_interval: - logger.info("Periodic sync check…") - initial_sync(config) + initial_sync(config, periodic=True) gc.collect() last_sync = time.time() except KeyboardInterrupt: diff --git a/src/audio_transcode_watcher/sync.py b/src/audio_transcode_watcher/sync.py index 777e220..536e5d3 100644 --- a/src/audio_transcode_watcher/sync.py +++ b/src/audio_transcode_watcher/sync.py @@ -11,17 +11,20 @@ from pathlib import Path from .config import Config, OutputConfig -from .encoder import atomic_ffmpeg_encode, build_ffmpeg_command +from .encoder import atomic_ffmpeg_encode, build_ffmpeg_command, copy_freeform_tags from .lyrics import fetch_lyrics_for_file from .utils import ( + AUDIO_EXTENSIONS, LOSSLESS_EXTENSIONS, SIDECAR_EXTENSIONS, appears_empty_dir, get_output_file_path, get_output_filename, get_rel_stem, + has_audio_extension, is_audio_file, - is_mp3, + is_lossless, + lossy_source_codec, nfc, nfc_path, remove_empty_dirs, @@ -38,6 +41,22 @@ _in_progress: set[str] = set() _in_progress_lock = threading.Lock() +# Sources whose encode failed, with the mtime they had then. A source in +# here is skipped until its mtime changes (lives for the process lifetime). +_failed_sources: dict[str, float] = {} +_failed_lock = threading.Lock() + +# The orphan pass leaves alone anything younger than this (seconds), so a +# file the watcher just encoded is never removed by a sync that started +# before it arrived. +ORPHAN_MIN_AGE = 120.0 + +# A .tmp__ff file younger than this (seconds) may be an encode in progress. +TEMP_MIN_AGE = 600.0 + +# Output codecs whose files are MP4 and take iTunes freeform tags. +_MP4_CODECS = {"alac", "aac"} + # Safety guard logging throttle _last_safety_log_ts = 0.0 _SAFETY_LOG_INTERVAL = 10.0 @@ -115,6 +134,10 @@ def process_source_file( logger.warning("Source not stable or disappeared: %s", source_path) return + if _is_known_failure(source_path): + logger.debug("Skipping %s: it failed before and has not changed", source_path) + return + # Prevent duplicate concurrent processing with _in_progress_lock: if source_path in _in_progress: @@ -122,15 +145,12 @@ def process_source_file( _in_progress.add(source_path) try: - _process_outputs(source_path, config, force) + if not _process_outputs(source_path, config, force): + _remember_failure(source_path) # Auto-fetch lyrics if enabled and no .lrc sidecar exists if config.fetch_lyrics: try: - fetch_lyrics_for_file( - source_path, - whisper_fallback=config.whisper_fallback, - whisper_model=config.whisper_model, - ) + fetch_lyrics_for_file(source_path) except Exception: logger.debug("Lyrics fetch failed for %s", source_path, exc_info=True) sync_sidecars(source_path, config) @@ -139,6 +159,32 @@ def process_source_file( _in_progress.discard(source_path) +def _is_known_failure(source_path: str) -> bool: + """True if *source_path* failed before and its mtime has not changed since.""" + try: + mtime = os.path.getmtime(source_path) + except OSError: + return False + with _failed_lock: + recorded = _failed_sources.get(source_path) + if recorded is None: + return False + if recorded == mtime: + return True + del _failed_sources[source_path] + return False + + +def _remember_failure(source_path: str) -> None: + """Remember that *source_path* failed, until its mtime changes.""" + try: + mtime = os.path.getmtime(source_path) + except OSError: + return + with _failed_lock: + _failed_sources[source_path] = mtime + + def _has_lossless_source(source_path: str, config: Config) -> bool: """Check if a lossless source with the same stem exists.""" stem = Path(source_path).stem @@ -151,51 +197,95 @@ def _has_lossless_source(source_path: str, config: Config) -> bool: return False -def _process_outputs(source_path: str, config: Config, force: bool) -> None: - """Process all outputs for a source file.""" - source_is_mp3 = is_mp3(source_path) +def _has_other_source(source_path: str) -> bool: + """Check if another audio source with the same stem exists.""" + stem = Path(source_path).stem + source_dir = os.path.dirname(source_path) + for ext in AUDIO_EXTENSIONS: + other = nfc_path(os.path.join(source_dir, f"{stem}{ext}")) + if other != source_path and os.path.exists(other): + return True + return False + + +def plan_output(source_path: str, output: OutputConfig) -> tuple[str, str]: + """ + Decide how *source_path* lands in *output*. + Returns ``(filename, action)`` where action is ``"copy"`` or ``"encode"``: + + - a lossless source is encoded to the output codec; + - a lossy source is copied unchanged (same extension) into a lossless + output, since encoding it would only inflate it; + - a lossy source is copied unchanged into a lossy output of the same + codec, and transcoded into a lossy output of another codec. + """ + if not is_lossless(source_path): + if output.is_lossless or lossy_source_codec(source_path) == output.codec: + return nfc(os.path.basename(source_path)), "copy" + return get_output_filename(source_path, output.extension), "encode" + + +def _atomic_copy(source_path: str, out_path: str) -> bool: + """Copy a file through a temp name, so a partial copy is never visible.""" + tmp = out_path + ".tmp__ff" + logger.info("► copy %s → %s", source_path, out_path) + try: + shutil.copy2(source_path, tmp) + os.replace(tmp, out_path) + return True + except Exception as e: + logger.error("Copy failed %s → %s: %s", source_path, out_path, e) + try: + if os.path.exists(tmp): + os.remove(tmp) + except OSError: + pass + return False + + +def _process_outputs(source_path: str, config: Config, force: bool) -> bool: + """ + Process all outputs for a source file. + + Returns False if an encode failed, True otherwise. + """ + if not is_lossless(source_path) and _has_lossless_source(source_path, config): + logger.debug("Skipping lossy %s - lossless source exists", source_path) + return True + + ok = True for output in config.outputs: if safety_guard_active(config): - return + return ok - # Determine output filename - if source_is_mp3 and output.codec == "alac": - # Skip MP3 if a lossless source with the same stem exists - if _has_lossless_source(source_path, config): - logger.debug("Skipping MP3 %s - lossless source exists", source_path) - continue + out_filename, action = plan_output(source_path, output) + out_path = get_output_file_path( + source_path, config.source_path, output.path, out_filename, + ) + if not force and os.path.exists(out_path): + continue - # Special case: copy MP3 to ALAC folder unchanged - out_filename = os.path.basename(source_path) - out_path = get_output_file_path( - source_path, config.source_path, output.path, out_filename, - ) + os.makedirs(os.path.dirname(out_path), exist_ok=True) + if action == "copy": + _atomic_copy(source_path, out_path) + continue - if force or not os.path.exists(out_path): - logger.info("► copy %s → %s", source_path, out_path) - os.makedirs(os.path.dirname(out_path), exist_ok=True) - try: - shutil.copy2(source_path, out_path) - except Exception as e: - logger.error("Copy failed %s → %s: %s", source_path, out_path, e) - else: - # Transcode - out_filename = get_output_filename(source_path, output.extension) - out_path = get_output_file_path( - source_path, config.source_path, output.path, out_filename, + finalize = None + if output.codec in _MP4_CODECS: + def finalize(tmp: str, src: str = source_path) -> None: + copy_freeform_tags(src, tmp) + + cmd = build_ffmpeg_command(source_path, out_path, output) + rc = atomic_ffmpeg_encode(cmd, out_path, finalize=finalize) + if rc != 0: + logger.error( + "%s encode failed for %s", + output.name.upper(), + source_path, ) - - if force or not os.path.exists(out_path): - os.makedirs(os.path.dirname(out_path), exist_ok=True) - cmd = build_ffmpeg_command(source_path, out_path, output) - rc = atomic_ffmpeg_encode(cmd, out_path) - if rc != 0: - logger.error( - "%s encode failed for %s", - output.name.upper(), - source_path, - ) + ok = False + return ok def delete_outputs(source_path: str, config: Config) -> None: @@ -209,35 +299,33 @@ def delete_outputs(source_path: str, config: Config) -> None: if safety_guard_active(config): return - stem = nfc(Path(source_path).stem) - source_basename = os.path.basename(source_path) - source_is_mp3 = is_mp3(source_path) + lossless_sibling = ( + not is_lossless(source_path) and _has_lossless_source(source_path, config) + ) for output in config.outputs: - filenames_to_check: list[str] = [] - - if source_is_mp3: - if output.codec == "alac": - filenames_to_check = [source_basename] - else: - if not _has_lossless_source(source_path, config): - filenames_to_check = [f"{stem}{output.extension}"] - else: - filenames_to_check = [f"{stem}{output.extension}"] - - for filename in filenames_to_check: - filepath = get_output_file_path( - source_path, config.source_path, output.path, filename, - ) - if os.path.exists(filepath): - try: - logger.info("✘ remove %s", filepath) - os.remove(filepath) - except Exception as e: - logger.error("Failed to remove %s: %s", filepath, e) + filename, _action = plan_output(source_path, output) + # With a lossless source of the same stem, a file of the same name + # in this output belongs to that source; keep it. + if lossless_sibling and filename == get_output_filename( + source_path, output.extension + ): + continue + + filepath = get_output_file_path( + source_path, config.source_path, output.path, filename, + ) + if os.path.exists(filepath): + try: + logger.info("✘ remove %s", filepath) + os.remove(filepath) + except Exception as e: + logger.error("Failed to remove %s: %s", filepath, e) - # Also remove sidecar files from all outputs - delete_sidecars(source_path, config) + # Sidecars are shared by every source of the stem; remove them only + # when no other source of the stem remains. + if not _has_other_source(source_path): + delete_sidecars(source_path, config) # Clean up empty subdirectories left after deletions for output in config.outputs: @@ -301,11 +389,13 @@ def cleanup_stale_temp_files(config: Config) -> int: Remove stale temporary files from interrupted encodes. These files have the .tmp__ff suffix and are left behind when - ffmpeg is interrupted (e.g., container restart, crash). + ffmpeg is interrupted (e.g., container restart, crash). Files younger + than TEMP_MIN_AGE are skipped: they may be an encode still running. Returns the number of files cleaned up. """ cleaned = 0 + now = time.time() for output in config.outputs: try: if not os.path.exists(output.path): @@ -315,6 +405,9 @@ def cleanup_stale_temp_files(config: Config) -> int: if fname.endswith(".tmp__ff"): full = os.path.join(dirpath, fname) try: + if now - os.path.getmtime(full) < TEMP_MIN_AGE: + logger.debug("Keeping young temp file %s", full) + continue logger.info("✘ cleanup stale temp %s", full) os.remove(full) cleaned += 1 @@ -351,15 +444,17 @@ def purge_all_outputs(config: Config) -> None: logger.error("Failed to purge folder %s: %s", output.path, e) -def initial_sync(config: Config) -> None: +def initial_sync(config: Config, periodic: bool = False) -> None: """ - Perform initial synchronization of source to all outputs. + Synchronize the source to all outputs (at startup and periodically). - Cleans up stale temp files from interrupted encodes - Creates output directories - Encodes missing files (recursively) - Removes orphaned files from outputs """ + label = "Periodic sync" if periodic else "Initial sync" + # Create output directories for output in config.outputs: os.makedirs(output.path, exist_ok=True) @@ -369,16 +464,16 @@ def initial_sync(config: Config) -> None: if cleaned > 0: logger.info("Cleaned up %d stale temp files from interrupted encodes.", cleaned) - # Purge if force_reencode is enabled - if config.force_reencode: + # Purge if force_reencode is enabled (startup only) + if config.force_reencode and not periodic: logger.info("FORCE_REENCODE enabled. Purging all outputs.") purge_all_outputs(config) if safety_guard_active(config): - logger.info("Initial sync skipped due to safety guard.") + logger.info("%s skipped due to safety guard.", label) return - logger.info("Initial sync …") + logger.info("%s …", label) # Collect source files recursively try: @@ -387,9 +482,6 @@ def initial_sync(config: Config) -> None: logger.error("Failed to scan source: %s", e) return - # Get relative stems for orphan detection (preserves directory structure) - source_rel_stems = {get_rel_stem(f, config.source_path) for f in source_files} - # Process all source files in parallel (skip stability check - files are on disk) workers = getattr(config, 'parallel_workers', DEFAULT_PARALLEL_WORKERS) logger.info("Processing %d source files with %d workers…", len(source_files), workers) @@ -408,85 +500,105 @@ def process_one(src_file: str) -> None: # Remove orphans if safety_guard_active(config): logger.info("Skipping orphan cleanup due to safety guard.") - logger.info("Initial sync complete (partial).") + logger.info("%s complete (partial).", label) return - if not source_rel_stems: + if not source_files: logger.warning("No source files found; skipping orphan cleanup to avoid wiping outputs.") else: - _cleanup_orphans(config, source_rel_stems) - logger.info("Initial sync complete.") + _cleanup_orphans(config) + logger.info("%s complete.", label) + +def _age_seconds(path: str, now: float) -> float: + """Seconds since *path* was last written or created (mtime or ctime).""" + st = os.stat(path) + return now - max(st.st_mtime, st.st_ctime) -def _cleanup_orphans(config: Config, source_rel_stems: set[str]) -> None: + +def _cleanup_orphans(config: Config) -> None: """Remove output files that no longer have a source. - *source_rel_stems* contains relative stems (e.g. ``"album/song"``) - so that nested directory structures are handled correctly. + The source is walked again here, not reused from the start of the sync, + so a file that arrived during a long sync is known. Anything younger + than ORPHAN_MIN_AGE, or whose stem is being processed, is left alone. + + An output may hold several files for one stem (an ALAC output holds + ``.m4a`` encodes and unchanged lossy copies such as ``.mp3``), so the + expected files are computed per source with plan_output(). """ - # Build a set of rel_stems that have lossless sources - lossless_rel_stems: set[str] = set() + src_root = config.source_path try: - for dirpath, _dirnames, filenames in os.walk(config.source_path): - for fname in filenames: - ext = Path(fname).suffix.lower() - if ext in LOSSLESS_EXTENSIONS: - full = os.path.join(dirpath, fname) - lossless_rel_stems.add(get_rel_stem(full, config.source_path)) - except Exception: - logger.debug("Failed to enumerate lossless sources for orphan cleanup", exc_info=True) - - for output in config.outputs: - # Valid extensions for this output - if output.codec == "alac": - valid_extensions = (".m4a", ".mp3") - else: - valid_extensions = (output.extension,) + source_files = walk_audio_files(src_root) + except Exception as e: + logger.error("Failed to scan source for orphan cleanup: %s", e) + return + if not source_files: + logger.warning("No source files found; skipping orphan cleanup to avoid wiping outputs.") + return + now = time.time() + source_stems: set[str] = set() + lossless_stems: set[str] = set() + young_stems: set[str] = set() + for f in source_files: + stem = get_rel_stem(f, src_root) + source_stems.add(stem) + if is_lossless(f): + lossless_stems.add(stem) try: - for dirpath, _dirnames, filenames in os.walk(output.path): - for fname in filenames: - if not fname.endswith(valid_extensions): - continue - - full = os.path.join(dirpath, fname) - rel_stem = get_rel_stem(full, output.path) - is_orphan = rel_stem not in source_rel_stems + if _age_seconds(f, now) < ORPHAN_MIN_AGE: + young_stems.add(stem) + except OSError: + young_stems.add(stem) + with _in_progress_lock: + in_progress = list(_in_progress) + young_stems.update(get_rel_stem(p, src_root) for p in in_progress) - # Special case: MP3 in ALAC folder is orphan if lossless source exists - if output.codec == "alac" and fname.endswith(".mp3"): - if rel_stem in lossless_rel_stems: - is_orphan = True + def keep_young(full: str, rel_stem: str) -> bool: + if rel_stem in young_stems: + return True + try: + return _age_seconds(full, now) < ORPHAN_MIN_AGE + except OSError: + return True - if is_orphan: - filepath = nfc_path(full) - try: - logger.info("✘ remove orphan %s", filepath) - os.remove(filepath) - except Exception as e: - logger.error("Failed to remove %s: %s", filepath, e) + def remove(full: str, kind: str) -> None: + try: + logger.info("✘ remove %s %s", kind, full) + os.remove(full) except Exception as e: - logger.error("Failed to scan %s for orphans: %s", output.path, e) + logger.error("Failed to remove %s: %s", full, e) - # Clean up orphaned sidecar files in output directories - sidecar_exts = tuple(SIDECAR_EXTENSIONS) for output in config.outputs: + expected: set[str] = set() + for f in source_files: + if not is_lossless(f) and get_rel_stem(f, src_root) in lossless_stems: + continue # the lossless source of this stem wins + filename, _action = plan_output(f, output) + expected.add(get_output_file_path(f, src_root, output.path, filename)) + try: for dirpath, _dirnames, filenames in os.walk(output.path): for fname in filenames: - if not fname.lower().endswith(sidecar_exts): - continue full = os.path.join(dirpath, fname) - rel_stem = get_rel_stem(full, output.path) - if rel_stem not in source_rel_stems: - filepath = nfc_path(full) - try: - logger.info("✘ remove orphan sidecar %s", filepath) - os.remove(filepath) - except Exception as e: - logger.error("Failed to remove sidecar %s: %s", filepath, e) + if has_audio_extension(full): + if nfc_path(full) in expected: + continue + kind = "orphan" + elif fname.lower().endswith(tuple(SIDECAR_EXTENSIONS)): + if get_rel_stem(full, output.path) in source_stems: + continue + kind = "orphan sidecar" + else: + continue + + if keep_young(full, get_rel_stem(full, output.path)): + logger.debug("Keeping young or in-progress %s", full) + continue + remove(full, kind) except Exception as e: - logger.error("Failed to scan %s for orphan sidecars: %s", output.path, e) + logger.error("Failed to scan %s for orphans: %s", output.path, e) # Remove empty subdirectories left after orphan cleanup for output in config.outputs: diff --git a/src/audio_transcode_watcher/utils.py b/src/audio_transcode_watcher/utils.py index a84237e..f5edf26 100644 --- a/src/audio_transcode_watcher/utils.py +++ b/src/audio_transcode_watcher/utils.py @@ -9,13 +9,24 @@ # Audio file extensions considered as source files LOSSLESS_EXTENSIONS = { - ".flac", ".alac", ".wav", ".ape", ".aiff", - ".wv", ".tta", ".ogg", ".opus" + ".flac", ".alac", ".wav", ".aiff", ".aif", + ".ape", ".wv", ".tta", ".tak", } -LOSSY_EXTENSIONS = {".mp3", ".aac", ".m4a"} +LOSSY_EXTENSIONS = {".mp3", ".aac", ".m4a", ".ogg", ".opus", ".wma"} -AUDIO_EXTENSIONS = LOSSLESS_EXTENSIONS | LOSSY_EXTENSIONS | {".mp3"} +AUDIO_EXTENSIONS = LOSSLESS_EXTENSIONS | LOSSY_EXTENSIONS + +# Codec of a lossy source, by extension, in the vocabulary of output codecs. +# A lossy source is copied unchanged into a lossy output of the same codec. +LOSSY_SOURCE_CODECS = { + ".mp3": "mp3", + ".aac": "aac", + ".m4a": "aac", + ".ogg": "vorbis", + ".opus": "opus", + ".wma": "wma", +} # Sidecar file extensions to copy alongside transcoded audio SIDECAR_EXTENSIONS = {".lrc"} @@ -68,6 +79,16 @@ def is_lossless(path: str) -> bool: return Path(path).suffix.lower() in LOSSLESS_EXTENSIONS +def is_lossy(path: str) -> bool: + """Check if a file is a lossy audio file.""" + return Path(path).suffix.lower() in LOSSY_EXTENSIONS + + +def lossy_source_codec(path: str) -> str | None: + """Return the codec of a lossy source (e.g. ``"mp3"``), or None if lossless.""" + return LOSSY_SOURCE_CODECS.get(Path(path).suffix.lower()) + + def is_mp3(path: str) -> bool: """Check if a file is an MP3.""" return Path(path).suffix.lower() == ".mp3" diff --git a/tests/test_config.py b/tests/test_config.py index a0f53f0..41b5843 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,7 @@ """Tests for configuration loading.""" import json +import logging import os from pathlib import Path @@ -222,3 +223,53 @@ def test_nonexistent_config_file_raises_error(self, monkeypatch): monkeypatch.setenv("CONFIG_FILE", "/nonexistent/config.yaml") with pytest.raises(ValueError, match="CONFIG_FILE not found"): load_config() + + +def _settings_config(settings): + return { + "source": {"path": "/music/flac"}, + "outputs": [{"name": "alac", "codec": "alac", "path": "/music/alac"}], + "settings": settings, + } + + +class TestSyncInterval: + """settings.sync_interval_seconds.""" + + def test_defaults_to_300(self): + assert Config.from_dict(_settings_config({})).sync_interval_seconds == 300 + + def test_read_from_settings(self): + cfg = Config.from_dict(_settings_config({"sync_interval_seconds": 60})) + assert cfg.sync_interval_seconds == 60 + + def test_rejects_zero(self): + with pytest.raises(ValueError, match="sync_interval_seconds"): + Config.from_dict(_settings_config({"sync_interval_seconds": 0})) + + +class TestDeprecatedWhisperSettings: + """whisper_fallback / whisper_model still load, ignored, with one warning.""" + + @pytest.fixture(autouse=True) + def _reset_warned(self, monkeypatch): + import audio_transcode_watcher.config as config_mod + + monkeypatch.setattr(config_mod, "_deprecation_warned", False) + + def test_old_config_loads_and_warns_once(self, caplog): + old = _settings_config({"whisper_fallback": True, "whisper_model": "base"}) + with caplog.at_level(logging.WARNING, logger="audio_transcode_watcher.config"): + first = Config.from_dict(old) + Config.from_dict(old) + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert "whisper_fallback" in warnings[0].getMessage() + assert "whisper_model" in warnings[0].getMessage() + assert not hasattr(first, "whisper_fallback") + assert not hasattr(first, "whisper_model") + + def test_no_warning_without_old_keys(self, caplog): + with caplog.at_level(logging.WARNING, logger="audio_transcode_watcher.config"): + Config.from_dict(_settings_config({"fetch_lyrics": True})) + assert not caplog.records diff --git a/tests/test_encoder.py b/tests/test_encoder.py index c71009d..c91fcef 100644 --- a/tests/test_encoder.py +++ b/tests/test_encoder.py @@ -1,16 +1,23 @@ """Tests for FFmpeg encoder module.""" +import logging import os +import shutil +import stat +import subprocess +from pathlib import Path from unittest.mock import MagicMock, patch import pytest from audio_transcode_watcher.config import OutputConfig from audio_transcode_watcher.encoder import ( + FREEFORM_TAGS, _cleanup_temp, _remove_artwork_from_command, atomic_ffmpeg_encode, build_ffmpeg_command, + copy_freeform_tags, ) @@ -414,3 +421,243 @@ def test_no_error_on_permission_failure(self, tmp_path): with patch("os.path.exists", return_value=True), \ patch("os.remove", side_effect=PermissionError("denied")): _cleanup_temp(str(tmp_path / "locked.tmp__ff")) + + +# A stand-in for ffmpeg: logs its arguments, writes the output file, prints +# FAKE_STDERR and exits FAKE_RC. When FAKE_ART_STDERR is set, a command that +# still maps the cover art stream fails with that stderr instead. +FAKE_FFMPEG = """#!/bin/sh +for last; do :; done +echo "$*" >> "$FAKE_LOG" +case " $* " in *" 0:v:0? "*) art=1;; *) art=0;; esac +if [ "$art" = 1 ] && [ -n "$FAKE_ART_STDERR" ]; then + printf '%b\\n' "$FAKE_ART_STDERR" >&2 + exit 1 +fi +printf data > "$last" +if [ -n "$FAKE_STDERR" ]; then printf '%b\\n' "$FAKE_STDERR" >&2; fi +exit ${FAKE_RC:-0} +""" + +CORRUPT_FLAC_STDERR = ( + "[flac @ 0x7f] invalid residual\\n" + "[flac @ 0x7f] decode_frame() failed" +) + + +@pytest.fixture +def fake_ffmpeg(tmp_path, monkeypatch): + """Put a fake ffmpeg first on PATH; return the file it logs calls to.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + script = bin_dir / "ffmpeg" + script.write_text(FAKE_FFMPEG) + script.chmod(script.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + log = tmp_path / "ffmpeg-calls.log" + monkeypatch.setenv("PATH", f"{bin_dir}{os.pathsep}{os.environ['PATH']}") + monkeypatch.setenv("FAKE_LOG", str(log)) + for var in ("FAKE_RC", "FAKE_STDERR", "FAKE_ART_STDERR"): + monkeypatch.delenv(var, raising=False) + return log + + +def _calls(log: Path) -> list[str]: + return log.read_text().splitlines() if log.exists() else [] + + +def _alac_cmd(tmp_path, name="Etta James - I'd Rather Go Blind"): + out = OutputConfig(name="alac", codec="alac", path=str(tmp_path / "alac")) + src = str(tmp_path / f"{name}.flac") + dest = str(tmp_path / "alac" / f"{name}.m4a") + return build_ffmpeg_command(src, dest, out), dest + + +class TestFailLoudly: + """A source that does not decode cleanly fails, even when ffmpeg exits 0.""" + + @pytest.mark.parametrize("codec", ["alac", "aac", "mp3", "opus", "flac", "wav"]) + def test_every_command_uses_xerror(self, codec): + out = OutputConfig(name=codec, codec=codec, path="/out") + assert "-xerror" in build_ffmpeg_command("/in/a.flac", "/out/a.x", out) + + def test_clean_run_succeeds(self, fake_ffmpeg, tmp_path): + cmd, dest = _alac_cmd(tmp_path) + assert atomic_ffmpeg_encode(cmd, dest) == 0 + assert os.path.isfile(dest) + assert len(_calls(fake_ffmpeg)) == 1 + + @pytest.mark.parametrize( + "stderr", + [ + CORRUPT_FLAC_STDERR, + "[aist#0:0/flac @ 0x1] Decoding error: Invalid data found when processing input", + "Error while decoding stream #0:0: Invalid data found when processing input", + ], + ) + def test_decode_error_with_exit_0_fails(self, fake_ffmpeg, tmp_path, monkeypatch, caplog, stderr): + monkeypatch.setenv("FAKE_RC", "0") + monkeypatch.setenv("FAKE_STDERR", stderr) + cmd, dest = _alac_cmd(tmp_path) + + with caplog.at_level(logging.ERROR, logger="audio_transcode_watcher.encoder"): + rc = atomic_ffmpeg_encode(cmd, dest) + + assert rc != 0 + assert not os.path.exists(dest) + assert not os.path.exists(dest + ".tmp__ff") + errors = [r.getMessage() for r in caplog.records if r.levelno == logging.ERROR] + assert any("I'd Rather Go Blind.flac" in m for m in errors) + + def test_nonzero_exit_fails(self, fake_ffmpeg, tmp_path, monkeypatch): + monkeypatch.setenv("FAKE_RC", "183") + cmd, dest = _alac_cmd(tmp_path) + assert atomic_ffmpeg_encode(cmd, dest) == 183 + assert not os.path.exists(dest) + + def test_decode_error_does_not_trigger_artwork_retry(self, fake_ffmpeg, tmp_path, monkeypatch): + monkeypatch.setenv("FAKE_RC", "1") + monkeypatch.setenv("FAKE_STDERR", CORRUPT_FLAC_STDERR) + cmd, dest = _alac_cmd(tmp_path) + assert "0:v:0?" in cmd + + assert atomic_ffmpeg_encode(cmd, dest) != 0 + assert len(_calls(fake_ffmpeg)) == 1 + + @pytest.mark.parametrize( + "art_stderr", + [ + "[mp4 @ 0x1] Could not find tag for codec png in stream #1, codec not currently supported in container", + "[vist#0:1/mjpeg @ 0x1] Error while decoding attached picture", + "Error initializing output stream 0:1 -- video stream", + ], + ) + def test_artwork_error_retries_without_cover(self, fake_ffmpeg, tmp_path, monkeypatch, art_stderr): + monkeypatch.setenv("FAKE_ART_STDERR", art_stderr) + cmd, dest = _alac_cmd(tmp_path) + + assert atomic_ffmpeg_encode(cmd, dest) == 0 + calls = _calls(fake_ffmpeg) + assert len(calls) == 2 + assert "0:v:0?" in calls[0] + assert "0:v:0?" not in calls[1] + assert os.path.isfile(dest) + + +def _make_flac(path: Path, seconds: int = 3) -> None: + subprocess.run( + [ + "ffmpeg", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", f"sine=frequency=440:duration={seconds}", + "-c:a", "flac", str(path), + ], + check=True, + ) + + +REAL_TAG_VALUES = { + "REPLAYGAIN_TRACK_GAIN": "-6.50 dB", + "REPLAYGAIN_TRACK_PEAK": "0.988525", + "REPLAYGAIN_ALBUM_GAIN": "-7.10 dB", + "REPLAYGAIN_ALBUM_PEAK": "1.000000", + "MUSICBRAINZ_TRACKID": "b8b7a3c3-9a4e-4c7b-9a43-5c3b2d0e4f11", + "MUSICBRAINZ_ALBUMID": "1f9a2b3c-4d5e-6f70-8192-a3b4c5d6e7f8", + "MUSICBRAINZ_ARTISTID": "6f2a7c11-0d6e-4b2f-9b8c-1e2d3c4b5a69", + "MUSICBRAINZ_ALBUMARTISTID": "6f2a7c11-0d6e-4b2f-9b8c-1e2d3c4b5a69", + "MUSICBRAINZ_RELEASEGROUPID": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9", + "ISRC": "USMC16046323", + "LABEL": "Argo", + "CATALOGNUMBER": "LP-4003", + "ARTISTSORT": "James, Etta", + "ALBUMARTISTSORT": "James, Etta", + "ALBUMSORT": "At Last!", +} + +needs_ffmpeg = pytest.mark.skipif( + shutil.which("ffmpeg") is None, + reason="ffmpeg is not installed; the real-file tag checks need it", +) + + +@needs_ffmpeg +class TestFreeformTagsRealFiles: + """Tags the MP4 atoms cannot hold survive an ALAC or AAC encode.""" + + def test_list_covers_the_required_tags(self): + assert set(REAL_TAG_VALUES) <= set(FREEFORM_TAGS) + + @pytest.mark.parametrize("codec", ["alac", "aac"]) + def test_flac_tags_land_as_itunes_freeform_atoms(self, tmp_path, codec): + from mutagen.flac import FLAC + from mutagen.mp4 import MP4 + + src = tmp_path / "Etta James - At Last.flac" + _make_flac(src) + flac = FLAC(str(src)) + flac["TITLE"] = "At Last" + flac["ARTIST"] = "Etta James" + for k, v in REAL_TAG_VALUES.items(): + flac[k] = v + flac.save() + + out = OutputConfig(name=codec, codec=codec, path=str(tmp_path / codec)) + dest = str(tmp_path / codec / "Etta James - At Last.m4a") + cmd = build_ffmpeg_command(str(src), dest, out) + rc = atomic_ffmpeg_encode( + cmd, dest, finalize=lambda tmp: copy_freeform_tags(str(src), tmp) + ) + assert rc == 0 + + tags = MP4(dest).tags + for k, v in REAL_TAG_VALUES.items(): + atom = f"----:com.apple.iTunes:{k}" + assert atom in tags, atom + assert bytes(tags[atom][0]).decode() == v + # Standard atoms ffmpeg writes are still there. + assert tags["\xa9nam"] == ["At Last"] + assert tags["\xa9ART"] == ["Etta James"] + + def test_id3_source_tags_are_read(self, tmp_path): + from mutagen.id3 import TPUB, TSOP, TSRC, TXXX, UFID + from mutagen.mp4 import MP4 + from mutagen.wave import WAVE + + src = tmp_path / "song.wav" + subprocess.run( + ["ffmpeg", "-loglevel", "error", "-y", "-f", "lavfi", + "-i", "sine=duration=1", str(src)], + check=True, + ) + wav = WAVE(str(src)) + wav.add_tags() + wav.tags.add(TXXX(encoding=3, desc="REPLAYGAIN_TRACK_GAIN", text=["-3.00 dB"])) + wav.tags.add(TXXX(encoding=3, desc="MusicBrainz Album Id", text=["album-id"])) + wav.tags.add(UFID(owner="http://musicbrainz.org", data=b"track-id")) + wav.tags.add(TSRC(encoding=3, text=["GBAYE0601498"])) + wav.tags.add(TPUB(encoding=3, text=["Parlophone"])) + wav.tags.add(TSOP(encoding=3, text=["Beatles, The"])) + wav.save() + + dest = tmp_path / "song.m4a" + subprocess.run( + ["ffmpeg", "-loglevel", "error", "-y", "-i", str(src), + "-c:a", "alac", "-f", "mp4", str(dest)], + check=True, + ) + assert copy_freeform_tags(str(src), str(dest)) == 6 + + tags = MP4(str(dest)).tags + expect = { + "REPLAYGAIN_TRACK_GAIN": "-3.00 dB", + "MUSICBRAINZ_ALBUMID": "album-id", + "MUSICBRAINZ_TRACKID": "track-id", + "ISRC": "GBAYE0601498", + "LABEL": "Parlophone", + "ARTISTSORT": "Beatles, The", + } + for k, v in expect.items(): + assert bytes(tags[f"----:com.apple.iTunes:{k}"][0]).decode() == v + + def test_source_without_tags_writes_nothing(self, tmp_path): + src = tmp_path / "bare.flac" + _make_flac(src, seconds=1) + assert copy_freeform_tags(str(src), str(tmp_path / "unused.m4a")) == 0 diff --git a/tests/test_lyrics.py b/tests/test_lyrics.py index b6629ad..97f6141 100644 --- a/tests/test_lyrics.py +++ b/tests/test_lyrics.py @@ -2,18 +2,17 @@ from __future__ import annotations +import logging import os from unittest.mock import MagicMock, patch import pytest from audio_transcode_watcher.lyrics import ( - _get_whisper_model, - _segments_to_lrc, - _transcribe_with_whisper, _write_lrc, extract_metadata, fetch_lyrics_for_file, + lyrics_reject_reason, ) @@ -64,28 +63,6 @@ def test_falls_back_to_filename_on_empty_tags(self, mock_mutagen, tmp_path): assert result == ("AC DC", "Thunderstruck") -class TestSegmentsToLrc: - """Tests for _segments_to_lrc().""" - - def test_converts_segments(self): - segments = [ - {"start": 0.0, "text": "Hello world"}, - {"start": 65.5, "text": "Second line"}, - ] - result = _segments_to_lrc(segments) - assert "[00:00.00] Hello world" in result - assert "[01:05.50] Second line" in result - - def test_skips_empty_text(self): - segments = [ - {"start": 0.0, "text": " "}, - {"start": 5.0, "text": "Real line"}, - ] - result = _segments_to_lrc(segments) - assert "Real line" in result - assert result.count("[") == 1 - - class TestFetchLyricsForFile: """Tests for fetch_lyrics_for_file().""" @@ -105,46 +82,20 @@ def test_fetches_and_writes_lyrics(self, mock_meta, mock_syncedlyrics, tmp_path) audio = tmp_path / "Queen - Radio Gaga.flac" audio.touch() mock_meta.return_value = ("Queen", "Radio Gaga") - mock_syncedlyrics.search.return_value = "[00:01.00] All we hear is\n[00:03.00] Radio gaga" + mock_syncedlyrics.search.return_value = ( + "[00:01.00] I'd sit alone and watch your light\n" + "[00:04.00] My only friend through teenage nights\n" + "[00:08.00] All we hear is\n" + "[00:10.00] Radio gaga" + ) - result = fetch_lyrics_for_file(str(audio), whisper_fallback=False) + result = fetch_lyrics_for_file(str(audio)) assert result is not None assert result.endswith(".lrc") assert os.path.isfile(result) content = open(result).read() assert "Radio gaga" in content - @patch("audio_transcode_watcher.lyrics._transcribe_with_whisper") - @patch("audio_transcode_watcher.lyrics.syncedlyrics") - @patch("audio_transcode_watcher.lyrics.extract_metadata") - def test_falls_back_to_whisper(self, mock_meta, mock_syncedlyrics, mock_whisper, tmp_path): - """Fall back to Whisper when syncedlyrics returns nothing.""" - audio = tmp_path / "Niche Band - Rare Song.flac" - audio.touch() - mock_meta.return_value = ("Niche Band", "Rare Song") - mock_syncedlyrics.search.return_value = None - mock_whisper.return_value = "[00:00.00] Transcribed by whisper" - - result = fetch_lyrics_for_file(str(audio), whisper_fallback=True) - assert result is not None - assert os.path.isfile(result) - content = open(result).read() - assert "Transcribed by whisper" in content - mock_whisper.assert_called_once() - - @patch("audio_transcode_watcher.lyrics._transcribe_with_whisper") - @patch("audio_transcode_watcher.lyrics.syncedlyrics") - @patch("audio_transcode_watcher.lyrics.extract_metadata") - def test_whisper_disabled(self, mock_meta, mock_syncedlyrics, mock_whisper, tmp_path): - """Don't use Whisper when disabled.""" - audio = tmp_path / "Artist - Song.flac" - audio.touch() - mock_meta.return_value = ("Artist", "Song") - mock_syncedlyrics.search.return_value = None - - result = fetch_lyrics_for_file(str(audio), whisper_fallback=False) - assert result is None - mock_whisper.assert_not_called() @patch("audio_transcode_watcher.lyrics.syncedlyrics") @patch("audio_transcode_watcher.lyrics.extract_metadata") @@ -155,14 +106,14 @@ def test_returns_none_when_no_lyrics(self, mock_meta, mock_syncedlyrics, tmp_pat mock_meta.return_value = ("Obscure Band", "Niche Song") mock_syncedlyrics.search.return_value = None - result = fetch_lyrics_for_file(str(audio), whisper_fallback=False) + result = fetch_lyrics_for_file(str(audio)) assert result is None def test_returns_none_when_no_metadata(self, tmp_path): """Return None when metadata can't be extracted.""" audio = tmp_path / "noinfo.flac" audio.touch() - result = fetch_lyrics_for_file(str(audio), whisper_fallback=False) + result = fetch_lyrics_for_file(str(audio)) assert result is None @patch("audio_transcode_watcher.lyrics.syncedlyrics") @@ -174,147 +125,7 @@ def test_handles_syncedlyrics_exception(self, mock_meta, mock_syncedlyrics, tmp_ mock_meta.return_value = ("Artist", "Song") mock_syncedlyrics.search.side_effect = Exception("network error") - result = fetch_lyrics_for_file(str(audio), whisper_fallback=False) - assert result is None - - @patch("audio_transcode_watcher.lyrics._transcribe_with_whisper") - @patch("audio_transcode_watcher.lyrics.syncedlyrics") - @patch("audio_transcode_watcher.lyrics.extract_metadata") - def test_whisper_fallback_with_no_metadata(self, mock_meta, mock_syncedlyrics, mock_whisper, tmp_path): - """Use filename stem as label when whisper fallback has no metadata.""" - audio = tmp_path / "instrumental.flac" - audio.touch() - mock_meta.return_value = None - mock_whisper.return_value = "[00:00.00] Instrumental" - - result = fetch_lyrics_for_file(str(audio), whisper_fallback=True) - assert result is not None - assert result.endswith(".lrc") - content = open(result).read() - assert "Instrumental" in content - - -class TestGetWhisperModel: - """Tests for _get_whisper_model function.""" - - def test_returns_none_when_load_previously_failed(self): - """Return None immediately if a prior load attempt failed.""" - import audio_transcode_watcher.lyrics as lmod - - original_failed = lmod._whisper_load_failed - original_model = lmod._whisper_model - try: - lmod._whisper_load_failed = True - lmod._whisper_model = None - result = _get_whisper_model("base") - assert result is None - finally: - lmod._whisper_load_failed = original_failed - lmod._whisper_model = original_model - - def test_returns_cached_model(self): - """Return cached model if already loaded.""" - import audio_transcode_watcher.lyrics as lmod - - original_failed = lmod._whisper_load_failed - original_model = lmod._whisper_model - try: - lmod._whisper_load_failed = False - sentinel = MagicMock() - lmod._whisper_model = sentinel - result = _get_whisper_model("base") - assert result is sentinel - finally: - lmod._whisper_load_failed = original_failed - lmod._whisper_model = original_model - - @patch("audio_transcode_watcher.lyrics.whisper", create=True) - def test_loads_model_on_first_call(self, mock_whisper_module): - """Load whisper model on first call and cache it.""" - import audio_transcode_watcher.lyrics as lmod - - original_failed = lmod._whisper_load_failed - original_model = lmod._whisper_model - try: - lmod._whisper_load_failed = False - lmod._whisper_model = None - - sentinel = MagicMock() - - # We need to patch the import inside _get_whisper_model - with patch.dict("sys.modules", {"whisper": mock_whisper_module}): - mock_whisper_module.load_model.return_value = sentinel - result = _get_whisper_model("tiny") - assert result is sentinel - assert lmod._whisper_model is sentinel - finally: - lmod._whisper_load_failed = original_failed - lmod._whisper_model = original_model - - def test_sets_failed_flag_on_import_error(self): - """Set _whisper_load_failed when whisper import fails.""" - import audio_transcode_watcher.lyrics as lmod - - original_failed = lmod._whisper_load_failed - original_model = lmod._whisper_model - try: - lmod._whisper_load_failed = False - lmod._whisper_model = None - - with patch.dict("sys.modules", {"whisper": None}): - result = _get_whisper_model("base") - assert result is None - assert lmod._whisper_load_failed is True - finally: - lmod._whisper_load_failed = original_failed - lmod._whisper_model = original_model - - -class TestTranscribeWithWhisper: - """Tests for _transcribe_with_whisper function.""" - - @patch("audio_transcode_watcher.lyrics._get_whisper_model") - def test_returns_none_when_model_unavailable(self, mock_get): - """Return None when whisper model cannot be loaded.""" - mock_get.return_value = None - result = _transcribe_with_whisper("/music/song.flac") - assert result is None - - @patch("audio_transcode_watcher.lyrics._get_whisper_model") - def test_returns_lrc_on_successful_transcription(self, mock_get): - """Return LRC content on successful Whisper transcription.""" - mock_model = MagicMock() - mock_model.transcribe.return_value = { - "segments": [ - {"start": 0.0, "text": "Hello world"}, - {"start": 5.5, "text": "Second line"}, - ] - } - mock_get.return_value = mock_model - - result = _transcribe_with_whisper("/music/song.flac", "base") - assert result is not None - assert "Hello world" in result - assert "Second line" in result - - @patch("audio_transcode_watcher.lyrics._get_whisper_model") - def test_returns_none_when_no_segments(self, mock_get): - """Return None when Whisper produces no segments.""" - mock_model = MagicMock() - mock_model.transcribe.return_value = {"segments": []} - mock_get.return_value = mock_model - - result = _transcribe_with_whisper("/music/song.flac") - assert result is None - - @patch("audio_transcode_watcher.lyrics._get_whisper_model") - def test_returns_none_on_transcription_exception(self, mock_get): - """Return None when Whisper transcription raises exception.""" - mock_model = MagicMock() - mock_model.transcribe.side_effect = RuntimeError("CUDA error") - mock_get.return_value = mock_model - - result = _transcribe_with_whisper("/music/song.flac") + result = fetch_lyrics_for_file(str(audio)) assert result is None @@ -333,3 +144,113 @@ def test_returns_none_on_write_error(self, tmp_path): with patch("builtins.open", side_effect=PermissionError("denied")): result = _write_lrc("/impossible/path.lrc", "content", "label", "source") assert result is None + + +GOOD_LRC = ( + "[00:12.10] I've been really tryin', baby\n" + "[00:16.40] Tryin' to hold back this feeling for so long\n" + "[00:21.00] And if you feel like I feel, baby\n" + "[00:25.30] Then come on, oh, come on\n" +) + + +class TestLyricsRejectReason: + """Junk results are rejected; real synced lyrics pass.""" + + @pytest.mark.parametrize( + "content,reason_part", + [ + ("[00:01.00] ♪\n[00:05.00] ♪\n[00:09.00] ♪\n[00:13.00] ♪\n[00:17.00] ♪", "single repeated token"), + ("[00:01.00] la la\n[00:02.00] La\n[00:03.00] la la la\n[00:04.00] la\n", "single repeated token"), + ("[00:01.00] One line\n[00:02.00] Two lines\n[00:03.00] Three lines\n", "3 timed lines"), + ("Plain line one\nPlain line two\nPlain line three\nPlain line four\nFive\n", "0 timed lines"), + ("[00:01.00]\n[00:02.00]\n[00:03.00]\n[00:04.00]\n[00:05.00] Hello there\n", "1 timed lines"), + ("[00:00.00] Lyrics by www.RentAnAdviser.com\n", "advertisement"), + ("[00:00.00] https://example.com/lyrics\n[00:01.00]\n[00:02.00]\n[00:03.00]\n", "advertisement"), + ("", "0 timed lines"), + ], + ) + def test_rejects(self, content, reason_part): + reason = lyrics_reject_reason(content) + assert reason is not None + assert reason_part in reason + + def test_accepts_four_real_lines(self): + assert lyrics_reject_reason(GOOD_LRC) is None + + def test_accepts_real_lyrics_with_one_ad_line(self): + content = GOOD_LRC + "[00:30.00] Lyrics from www.example.com\n" + assert lyrics_reject_reason(content) is None + + def test_accepts_lines_with_several_stamps(self): + content = "[ti:Song]\n" + GOOD_LRC.replace("[00:25.30]", "[00:25.30][01:40.00]") + assert lyrics_reject_reason(content) is None + + +class TestFetchWritesNothingForJunk: + """When nothing usable comes back, no .lrc is written.""" + + @pytest.mark.parametrize("returned", [None, ""]) + @patch("audio_transcode_watcher.lyrics.syncedlyrics") + @patch("audio_transcode_watcher.lyrics.extract_metadata") + def test_nothing_found_writes_nothing(self, mock_meta, mock_sl, returned, tmp_path): + audio = tmp_path / "Obscure - Song.flac" + audio.touch() + mock_meta.return_value = ("Obscure", "Song") + mock_sl.search.return_value = returned + + assert fetch_lyrics_for_file(str(audio)) is None + assert list(tmp_path.glob("*.lrc")) == [] + + @patch("audio_transcode_watcher.lyrics.syncedlyrics") + @patch("audio_transcode_watcher.lyrics.extract_metadata") + def test_rejected_result_writes_nothing_and_logs_why(self, mock_meta, mock_sl, tmp_path, caplog): + audio = tmp_path / "Band - Instrumental.flac" + audio.touch() + mock_meta.return_value = ("Band", "Instrumental") + mock_sl.search.return_value = "[00:00.00] ♪\n[00:10.00] ♪\n[00:20.00] ♪\n[00:30.00] ♪" + + with caplog.at_level(logging.INFO, logger="audio_transcode_watcher.lyrics"): + assert fetch_lyrics_for_file(str(audio)) is None + assert list(tmp_path.glob("*.lrc")) == [] + rejected = [r for r in caplog.records if "Rejected lyrics" in r.getMessage()] + assert rejected and rejected[0].levelno == logging.INFO + assert "single repeated token" in rejected[0].getMessage() + + @patch("audio_transcode_watcher.lyrics.syncedlyrics") + def test_no_metadata_never_searches(self, mock_sl, tmp_path): + audio = tmp_path / "noinfo.flac" + audio.touch() + assert fetch_lyrics_for_file(str(audio)) is None + mock_sl.search.assert_not_called() + + +class TestLrcOwnership: + """The .lrc in the source folder is owned like the source, mode 0664.""" + + @patch("audio_transcode_watcher.lyrics.os.chown") + @patch("audio_transcode_watcher.lyrics.syncedlyrics") + @patch("audio_transcode_watcher.lyrics.extract_metadata") + def test_chown_to_source_owner_and_mode_0664(self, mock_meta, mock_sl, mock_chown, tmp_path): + audio = tmp_path / "Etta James - At Last.flac" + audio.touch() + mock_meta.return_value = ("Etta James", "At Last") + mock_sl.search.return_value = GOOD_LRC + + lrc = fetch_lyrics_for_file(str(audio)) + assert lrc is not None + st = os.stat(audio) + mock_chown.assert_called_once_with(lrc, st.st_uid, st.st_gid) + assert os.stat(lrc).st_mode & 0o777 == 0o664 + + @patch("audio_transcode_watcher.lyrics.os.chown", side_effect=PermissionError("not root")) + def test_chown_failure_is_logged_at_debug_and_file_kept(self, _chown, tmp_path, caplog): + audio = tmp_path / "song.flac" + audio.touch() + lrc_path = str(tmp_path / "song.lrc") + with caplog.at_level(logging.DEBUG, logger="audio_transcode_watcher.lyrics"): + result = _write_lrc(lrc_path, GOOD_LRC, "label", "syncedlyrics", owner_of=str(audio)) + assert result == lrc_path + assert os.path.isfile(lrc_path) + chown_logs = [r for r in caplog.records if "Could not chown" in r.getMessage()] + assert chown_logs and chown_logs[0].levelno == logging.DEBUG diff --git a/tests/test_main.py b/tests/test_main.py index 8a308d1..a745cf2 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -165,3 +165,40 @@ def test_logs_output_with_bitrate(self, _isdir, mock_load, _sync, mock_watcher, # Should not raise -- exercises the bitrate logging branch assert main() == 0 + + +class TestConfigurableInterval: + """settings.sync_interval_seconds drives the periodic pass.""" + + @pytest.mark.parametrize("interval,elapsed,expected_calls", [(60, 61, 2), (300, 61, 1)]) + @patch("gc.collect") + @patch("audio_transcode_watcher.main.start_watcher") + @patch("audio_transcode_watcher.main.initial_sync") + @patch("audio_transcode_watcher.main.load_config") + @patch("os.path.isdir", return_value=True) + def test_interval_from_config( + self, _isdir, mock_load, mock_sync, mock_watcher, _gc, interval, elapsed, expected_calls + ): + from audio_transcode_watcher.config import Config, OutputConfig + + mock_load.return_value = Config( + source_path="/music/flac", + outputs=[OutputConfig(name="alac", codec="alac", path="/tmp/out")], + sync_interval_seconds=interval, + ) + mock_watcher.return_value = MagicMock() + times = iter([0] + [elapsed] * 10) + sleeps = [0] + + def fake_sleep(_secs): + sleeps[0] += 1 + if sleeps[0] >= 2: + raise KeyboardInterrupt + + with patch("time.time", side_effect=lambda: next(times)), \ + patch("time.sleep", side_effect=fake_sleep): + main() + + assert mock_sync.call_count == expected_calls + if expected_calls == 2: + assert mock_sync.call_args_list[1].kwargs == {"periodic": True} diff --git a/tests/test_sync.py b/tests/test_sync.py index c163da6..7f1afdc 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -6,6 +6,7 @@ import pytest +import audio_transcode_watcher.sync as sync_mod from audio_transcode_watcher.config import Config, OutputConfig from audio_transcode_watcher.sync import ( _cleanup_orphans, @@ -19,7 +20,19 @@ safety_guard_active, sync_sidecars, ) -from audio_transcode_watcher.utils import get_rel_stem, nfc + + +@pytest.fixture(autouse=True) +def _no_age_guards(monkeypatch): + """Let orphan and temp cleanup act on files created by the test itself. + + Tests of the age guards set the real thresholds back explicitly. + """ + monkeypatch.setattr(sync_mod, "ORPHAN_MIN_AGE", 0.0) + monkeypatch.setattr(sync_mod, "TEMP_MIN_AGE", 0.0) + sync_mod._failed_sources.clear() + yield + sync_mod._failed_sources.clear() class TestSafetyGuard: @@ -253,6 +266,8 @@ def test_removes_orphan_files(self, temp_dir): """Test that orphan files are removed.""" source = Path(temp_dir) / "source" source.mkdir() + # The orphan pass never runs against an empty source. + (source / "Other - Song.flac").touch() output = Path(temp_dir) / "output" output.mkdir() @@ -264,7 +279,7 @@ def test_removes_orphan_files(self, temp_dir): outputs=[OutputConfig(name="alac", codec="alac", path=str(output))], ) - _cleanup_orphans(config, set()) + _cleanup_orphans(config) assert not orphan.exists() @@ -284,8 +299,7 @@ def test_keeps_non_orphan_files(self, temp_dir): outputs=[OutputConfig(name="alac", codec="alac", path=str(output))], ) - source_rel_stems = {nfc("Valid - Song")} - _cleanup_orphans(config, source_rel_stems) + _cleanup_orphans(config) assert valid_output.exists() @@ -305,8 +319,7 @@ def test_handles_alac_with_mp3(self, temp_dir): outputs=[OutputConfig(name="alac", codec="alac", path=str(output))], ) - source_rel_stems = {nfc("Song")} - _cleanup_orphans(config, source_rel_stems) + _cleanup_orphans(config) # MP3 copy should be kept assert mp3_copy.exists() @@ -315,6 +328,8 @@ def test_recursive_removes_orphan_in_subdir(self, temp_dir): """Test that orphaned files in subdirectories are removed.""" source = Path(temp_dir) / "source" source.mkdir() + # The orphan pass never runs against an empty source. + (source / "Other - Song.flac").touch() output = Path(temp_dir) / "output" (output / "album1").mkdir(parents=True) @@ -326,7 +341,7 @@ def test_recursive_removes_orphan_in_subdir(self, temp_dir): outputs=[OutputConfig(name="alac", codec="alac", path=str(output))], ) - _cleanup_orphans(config, set()) + _cleanup_orphans(config) assert not orphan.exists() # Empty subdir should also be cleaned up @@ -348,10 +363,7 @@ def test_recursive_keeps_non_orphan_in_subdir(self, temp_dir): outputs=[OutputConfig(name="alac", codec="alac", path=str(output))], ) - source_rel_stems = {get_rel_stem( - str(source / "album1" / "Song.flac"), str(source), - )} - _cleanup_orphans(config, source_rel_stems) + _cleanup_orphans(config) assert valid.exists() @@ -487,6 +499,8 @@ def test_removes_orphan_lrc(self, temp_dir): """Test that orphaned .lrc files are removed from outputs.""" source = Path(temp_dir) / "source" source.mkdir() + # The orphan pass never runs against an empty source. + (source / "Other - Song.flac").touch() output = Path(temp_dir) / "output" output.mkdir() @@ -498,7 +512,7 @@ def test_removes_orphan_lrc(self, temp_dir): outputs=[OutputConfig(name="out", codec="aac", path=str(output))], ) - _cleanup_orphans(config, set()) + _cleanup_orphans(config) assert not orphan_lrc.exists() @@ -518,7 +532,7 @@ def test_keeps_valid_lrc(self, temp_dir): outputs=[OutputConfig(name="out", codec="aac", path=str(output))], ) - _cleanup_orphans(config, {nfc("Valid - Song")}) + _cleanup_orphans(config) assert valid_lrc.exists() @@ -526,6 +540,8 @@ def test_recursive_removes_orphan_lrc_in_subdir(self, temp_dir): """Test that orphaned .lrc in subdirectories are removed.""" source = Path(temp_dir) / "source" source.mkdir() + # The orphan pass never runs against an empty source. + (source / "Other - Song.flac").touch() output = Path(temp_dir) / "output" (output / "album").mkdir(parents=True) @@ -537,7 +553,7 @@ def test_recursive_removes_orphan_lrc_in_subdir(self, temp_dir): outputs=[OutputConfig(name="out", codec="aac", path=str(output))], ) - _cleanup_orphans(config, set()) + _cleanup_orphans(config) assert not orphan_lrc.exists() @@ -977,8 +993,7 @@ def test_removes_mp3_from_alac_when_lossless_exists(self, temp_dir): outputs=[OutputConfig(name="alac", codec="alac", path=str(output))], ) - source_rel_stems = {nfc("Song")} - _cleanup_orphans(config, source_rel_stems) + _cleanup_orphans(config) # MP3 should be removed because lossless source exists assert not mp3_in_alac.exists() @@ -1032,3 +1047,411 @@ def test_handles_copy_error(self, temp_dir): with patch("shutil.copy2", side_effect=PermissionError("denied")): # Should not raise sync_sidecars(str(source / "Song.flac"), config) + + +LOSSY = [".mp3", ".aac", ".m4a", ".ogg", ".opus", ".wma"] + + +def _dirs(temp_dir, *names): + paths = [] + for name in names: + p = Path(temp_dir) / name + p.mkdir(parents=True, exist_ok=True) + paths.append(p) + return paths + + +class TestLossySources: + """Lossy sources are copied, never inflated into a lossless output.""" + + @pytest.mark.parametrize("ext", LOSSY) + @patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode") + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_lossy_source_copied_unchanged_into_alac(self, _guard, mock_encode, temp_dir, ext): + source, out = _dirs(temp_dir, "source", "alac") + src = source / f"Artist - Title{ext}" + src.write_bytes(b"lossy bytes with tags " + ext.encode()) + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(out))], + fetch_lyrics=False, + ) + + process_source_file(str(src), config, check_stable=False) + + mock_encode.assert_not_called() + assert [p.name for p in out.iterdir()] == [f"Artist - Title{ext}"] + assert (out / f"Artist - Title{ext}").read_bytes() == src.read_bytes() + + @pytest.mark.parametrize( + "ext,codec", + [(".mp3", "mp3"), (".m4a", "aac"), (".aac", "aac"), (".opus", "opus")], + ) + @patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode") + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_lossy_source_copied_into_lossy_output_of_same_codec( + self, _guard, mock_encode, temp_dir, ext, codec + ): + source, out = _dirs(temp_dir, "source", "lossy") + src = source / f"Song{ext}" + src.write_bytes(b"original") + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="lossy", codec=codec, path=str(out))], + fetch_lyrics=False, + ) + + process_source_file(str(src), config, check_stable=False) + + mock_encode.assert_not_called() + assert (out / f"Song{ext}").read_bytes() == b"original" + + @pytest.mark.parametrize( + "ext,codec", + [(".ogg", "mp3"), (".mp3", "aac"), (".m4a", "mp3"), (".wma", "opus"), (".opus", "aac")], + ) + @patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode", return_value=0) + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_lossy_source_transcoded_into_lossy_output_of_other_codec( + self, _guard, mock_encode, temp_dir, ext, codec + ): + source, out = _dirs(temp_dir, "source", "lossy") + src = source / f"Song{ext}" + src.write_bytes(b"original") + output = OutputConfig(name="lossy", codec=codec, path=str(out)) + config = Config(source_path=str(source), outputs=[output], fetch_lyrics=False) + + process_source_file(str(src), config, check_stable=False) + + mock_encode.assert_called_once() + assert mock_encode.call_args.args[1] == str(out / f"Song{output.extension}") + + @pytest.mark.parametrize("ext", [".tak", ".aif", ".flac"]) + @patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode", return_value=0) + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_lossless_source_encoded_with_tag_copy_for_mp4(self, _guard, mock_encode, temp_dir, ext): + source, alac, mp3 = _dirs(temp_dir, "source", "alac", "mp3") + src = source / f"Song{ext}" + src.touch() + config = Config( + source_path=str(source), + outputs=[ + OutputConfig(name="alac", codec="alac", path=str(alac)), + OutputConfig(name="mp3", codec="mp3", path=str(mp3)), + ], + fetch_lyrics=False, + ) + + process_source_file(str(src), config, check_stable=False) + + assert mock_encode.call_count == 2 + by_dest = {c.args[1]: c.kwargs.get("finalize") for c in mock_encode.call_args_list} + assert by_dest[str(alac / "Song.m4a")] is not None + assert by_dest[str(mp3 / "Song.mp3")] is None + + @patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode") + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_lossy_source_skipped_when_lossless_sibling_exists(self, _guard, mock_encode, temp_dir): + source, alac, mp3 = _dirs(temp_dir, "source", "alac", "mp3") + (source / "Song.flac").touch() + ogg = source / "Song.ogg" + ogg.write_bytes(b"ogg") + config = Config( + source_path=str(source), + outputs=[ + OutputConfig(name="alac", codec="alac", path=str(alac)), + OutputConfig(name="mp3", codec="mp3", path=str(mp3)), + ], + fetch_lyrics=False, + ) + + process_source_file(str(ogg), config, check_stable=False) + + mock_encode.assert_not_called() + assert list(alac.iterdir()) == [] and list(mp3.iterdir()) == [] + + +class TestMixedExtensionOrphans: + """An output can hold several extensions for one stem.""" + + def test_alac_output_keeps_encodes_and_lossy_copies(self, temp_dir): + source, alac = _dirs(temp_dir, "source", "alac") + # A.mp3 sits beside A.flac in the source: the lossless file wins, + # so the copy of A.mp3 made before A.flac arrived is an orphan. + for name in ["A.flac", "A.mp3", "B.mp3", "C.ogg", "D.m4a", "E.wma"]: + (source / name).touch() + keep = ["A.m4a", "B.mp3", "C.ogg", "D.m4a", "E.wma"] + gone = ["A.mp3", "B.m4a", "C.m4a", "Gone.ogg", "Gone.m4a"] + for name in keep + gone: + (alac / name).touch() + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(alac))], + ) + + _cleanup_orphans(config) + + assert sorted(p.name for p in alac.iterdir()) == sorted(keep) + + def test_lossy_output_keeps_copies_and_transcodes(self, temp_dir): + source, mp3 = _dirs(temp_dir, "source", "mp3") + for name in ["A.flac", "B.mp3", "C.ogg"]: + (source / name).touch() + keep = ["A.mp3", "B.mp3", "C.mp3"] + gone = ["C.ogg", "Gone.mp3"] + for name in keep + gone: + (mp3 / name).touch() + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="mp3", codec="mp3", path=str(mp3), bitrate="256k")], + ) + + _cleanup_orphans(config) + + assert sorted(p.name for p in mp3.iterdir()) == sorted(keep) + + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_deleting_lossy_source_keeps_lossless_output_and_lrc(self, _guard, temp_dir): + source, alac = _dirs(temp_dir, "source", "alac") + (source / "Song.flac").touch() + (source / "Song.lrc").write_text("lyrics") + (alac / "Song.m4a").touch() + (alac / "Song.mp3").touch() + (alac / "Song.lrc").write_text("lyrics") + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(alac))], + ) + + # Song.mp3 was in the source and has just been deleted. + delete_outputs(str(source / "Song.mp3"), config) + + assert sorted(p.name for p in alac.iterdir()) == ["Song.lrc", "Song.m4a"] + + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_deleting_last_source_of_stem_removes_lrc(self, _guard, temp_dir): + source, alac = _dirs(temp_dir, "source", "alac") + (alac / "Song.ogg").touch() + (alac / "Song.lrc").write_text("lyrics") + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(alac))], + ) + + delete_outputs(str(source / "Song.ogg"), config) + + assert list(alac.iterdir()) == [] + + +class TestOrphanRaceGuards: + """The periodic sync never removes an output the watcher just made.""" + + def test_young_output_is_kept_until_old(self, temp_dir, monkeypatch): + monkeypatch.setattr(sync_mod, "ORPHAN_MIN_AGE", 120.0) + source, out = _dirs(temp_dir, "source", "alac") + (source / "Other.flac").touch() + fresh = out / "Fresh.m4a" + fresh.touch() + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(out))], + ) + + _cleanup_orphans(config) + assert fresh.exists() + + real_time = sync_mod.time.time + monkeypatch.setattr(sync_mod.time, "time", lambda: real_time() + 121) + _cleanup_orphans(config) + assert not fresh.exists() + + def test_young_source_keeps_its_stem(self, temp_dir, monkeypatch): + monkeypatch.setattr(sync_mod, "ORPHAN_MIN_AGE", 120.0) + source, out = _dirs(temp_dir, "source", "alac") + (source / "Song.flac").touch() + copy = out / "Song.mp3" # orphan: the lossless source of the stem wins + copy.touch() + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(out))], + ) + src_root = str(source) + + def age(path, now): + return 0.0 if path.startswith(src_root) else 1000.0 + + monkeypatch.setattr(sync_mod, "_age_seconds", age) + _cleanup_orphans(config) + assert copy.exists() + + monkeypatch.setattr(sync_mod, "_age_seconds", lambda path, now: 1000.0) + _cleanup_orphans(config) + assert not copy.exists() + + def test_in_progress_stem_is_kept(self, temp_dir, monkeypatch): + monkeypatch.setattr(sync_mod, "_age_seconds", lambda path, now: 1000.0) + source, out = _dirs(temp_dir, "source", "alac") + flac = source / "Song.flac" + flac.touch() + copy = out / "Song.mp3" + copy.touch() + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(out))], + ) + + with sync_mod._in_progress_lock: + sync_mod._in_progress.add(str(flac)) + try: + _cleanup_orphans(config) + assert copy.exists() + finally: + with sync_mod._in_progress_lock: + sync_mod._in_progress.discard(str(flac)) + _cleanup_orphans(config) + assert not copy.exists() + + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_file_arriving_during_sync_keeps_its_output(self, _guard, temp_dir, monkeypatch): + """The 2026-08-06 race: the list was built, a file arrived, the + watcher encoded it, and the orphan pass deleted the fresh output.""" + monkeypatch.setattr(sync_mod, "_age_seconds", lambda path, now: 1000.0) + source, out = _dirs(temp_dir, "source", "alac") + (source / "Old.flac").touch() + (out / "Old.m4a").touch() + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(out))], + parallel_workers=1, + ) + + def watcher_meanwhile(src_file, *_a, **_k): + (source / "New.flac").touch() + (out / "New.m4a").touch() + + with patch("audio_transcode_watcher.sync.process_source_file", side_effect=watcher_meanwhile): + initial_sync(config, periodic=True) + + assert (out / "New.m4a").exists() + assert (out / "Old.m4a").exists() + + +class TestStaleTempAge: + """A temp file younger than 10 minutes may be an encode in progress.""" + + def test_young_temp_kept_old_temp_removed(self, temp_dir, monkeypatch): + monkeypatch.setattr(sync_mod, "TEMP_MIN_AGE", 600.0) + source, out = _dirs(temp_dir, "source", "alac") + young = out / "young.m4a.tmp__ff" + old = out / "old.m4a.tmp__ff" + young.write_text("encoding") + old.write_text("stale") + past = old.stat().st_mtime - 601 + os.utime(old, (past, past)) + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(out))], + ) + + assert cleanup_stale_temp_files(config) == 1 + assert young.exists() + assert not old.exists() + + +class TestFailedSourceMemory: + """A source that failed is not retried on every scan, until it changes.""" + + @patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode", return_value=69) + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_not_retried_until_mtime_changes(self, _guard, mock_encode, temp_dir): + source, out = _dirs(temp_dir, "source", "alac") + flac = source / "Etta James - I'd Rather Go Blind.flac" + flac.touch() + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(out))], + fetch_lyrics=False, + ) + + process_source_file(str(flac), config, check_stable=False) + process_source_file(str(flac), config, check_stable=False) + assert mock_encode.call_count == 1 + + later = flac.stat().st_mtime + 10 + os.utime(flac, (later, later)) + process_source_file(str(flac), config, check_stable=False) + assert mock_encode.call_count == 2 + + @patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode", return_value=0) + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_success_is_not_remembered(self, _guard, mock_encode, temp_dir): + source, out = _dirs(temp_dir, "source", "alac") + flac = source / "Song.flac" + flac.touch() + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(out))], + fetch_lyrics=False, + ) + + process_source_file(str(flac), config, force=True, check_stable=False) + process_source_file(str(flac), config, force=True, check_stable=False) + assert mock_encode.call_count == 2 + + +class TestSyncLogLabel: + """The periodic pass no longer logs as if the service restarted.""" + + @pytest.mark.parametrize("periodic,label,other", [(True, "Periodic sync", "Initial sync"), (False, "Initial sync", "Periodic sync")]) + @patch("audio_transcode_watcher.sync.process_source_file") + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_label(self, _guard, _proc, temp_dir, caplog, periodic, label, other): + source, out = _dirs(temp_dir, "source", "alac") + (source / "Song.flac").touch() + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(out))], + ) + with caplog.at_level("INFO", logger="audio_transcode_watcher.sync"): + initial_sync(config, periodic=periodic) + text = caplog.text + assert f"{label} …" in text and f"{label} complete." in text + assert other not in text + + +needs_ffmpeg = pytest.mark.skipif( + __import__("shutil").which("ffmpeg") is None, + reason="ffmpeg is not installed; the real-file checks need it", +) + + +@needs_ffmpeg +class TestRealCorruptSource: + """A truncated-on-decode FLAC never reaches the output folder.""" + + def test_corrupt_flac_writes_no_output_and_is_remembered(self, temp_dir): + import subprocess + + source, out = _dirs(temp_dir, "source", "alac") + good = source / "good.tmp.flac" + subprocess.run( + ["ffmpeg", "-loglevel", "error", "-y", "-f", "lavfi", + "-i", "sine=frequency=440:duration=5", "-c:a", "flac", str(good)], + check=True, + ) + data = bytearray(good.read_bytes()) + good.unlink() + for i in range(len(data) // 3, len(data) // 3 + 400): + data[i] ^= 0x5A + bad = source / "Etta James - I'd Rather Go Blind.flac" + bad.write_bytes(bytes(data)) + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(out))], + fetch_lyrics=False, + ) + + process_source_file(str(bad), config, check_stable=False) + + assert list(out.iterdir()) == [] + assert str(bad) in sync_mod._failed_sources diff --git a/tests/test_utils.py b/tests/test_utils.py index fce074d..88fe2f1 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -6,6 +6,8 @@ import pytest from audio_transcode_watcher.utils import ( + LOSSLESS_EXTENSIONS, + LOSSY_EXTENSIONS, appears_empty_dir, get_output_file_path, get_output_filename, @@ -14,7 +16,9 @@ has_sidecar_extension, is_audio_file, is_lossless, + is_lossy, is_mp3, + lossy_source_codec, nfc, nfc_path, remove_empty_dirs, @@ -419,3 +423,49 @@ def test_returns_true_on_scandir_error(self): with patch("os.path.isdir", return_value=True), \ patch("os.scandir", side_effect=PermissionError("denied")): assert appears_empty_dir("/some/path") is True + + +LOSSLESS = [".flac", ".alac", ".wav", ".aiff", ".aif", ".ape", ".wv", ".tta", ".tak"] +LOSSY = [".mp3", ".aac", ".m4a", ".ogg", ".opus", ".wma"] + + +class TestExtensionClassification: + """Every source extension is either lossless or lossy, never both.""" + + def test_sets_are_exactly_these(self): + assert LOSSLESS_EXTENSIONS == set(LOSSLESS) + assert LOSSY_EXTENSIONS == set(LOSSY) + + @pytest.mark.parametrize("ext", LOSSLESS) + def test_lossless(self, ext): + assert is_lossless(f"Artist - Title{ext}") + assert not is_lossy(f"Artist - Title{ext}") + assert has_audio_extension(f"Artist - Title{ext.upper()}") + + @pytest.mark.parametrize("ext", LOSSY) + def test_lossy(self, ext): + assert is_lossy(f"Artist - Title{ext}") + assert not is_lossless(f"Artist - Title{ext}") + assert has_audio_extension(f"Artist - Title{ext.upper()}") + + @pytest.mark.parametrize( + "ext,codec", + [ + (".mp3", "mp3"), + (".aac", "aac"), + (".m4a", "aac"), + (".ogg", "vorbis"), + (".opus", "opus"), + (".wma", "wma"), + (".flac", None), + (".tak", None), + ], + ) + def test_lossy_source_codec(self, ext, codec): + assert lossy_source_codec(f"song{ext}") == codec + + def test_walk_finds_formats_the_bot_now_saves(self, temp_dir): + for name in ["a.aif", "b.tak", "c.wma", "d.ogg", "notes.txt"]: + (Path(temp_dir) / name).touch() + found = sorted(Path(f).name for f in walk_audio_files(temp_dir)) + assert found == ["a.aif", "b.tak", "c.wma", "d.ogg"] diff --git a/tools/verify_sync.py b/tools/verify_sync.py index 5ce4675..9251013 100644 --- a/tools/verify_sync.py +++ b/tools/verify_sync.py @@ -44,10 +44,12 @@ # Audio extensions considered as source files +# (same sets as audio_transcode_watcher.utils) +LOSSY_EXTENSIONS = {".mp3", ".aac", ".m4a", ".ogg", ".opus", ".wma"} SOURCE_EXTENSIONS = { - ".flac", ".alac", ".wav", ".ape", ".aiff", - ".wv", ".tta", ".ogg", ".opus", ".mp3" -} + ".flac", ".alac", ".wav", ".aiff", ".aif", + ".ape", ".wv", ".tta", ".tak", +} | LOSSY_EXTENSIONS def nfc(s: str) -> str: @@ -461,18 +463,20 @@ def load_outputs_from_config(config_path: str) -> tuple[str, list[OutputDefiniti codec = output_data.get("codec", "") # Determine extensions based on codec + # Lossless outputs also hold lossy sources copied unchanged; + # lossy outputs hold copies of sources in their own codec. if codec == "alac": - extensions = {".m4a", ".mp3"} # ALAC folder may have copied MP3s + extensions = {".m4a"} | LOSSY_EXTENSIONS elif codec == "mp3": extensions = {".mp3"} elif codec in ("aac",): - extensions = {".m4a"} + extensions = {".m4a", ".aac"} elif codec == "opus": extensions = {".opus"} elif codec == "flac": - extensions = {".flac"} + extensions = {".flac"} | LOSSY_EXTENSIONS elif codec == "wav": - extensions = {".wav"} + extensions = {".wav"} | LOSSY_EXTENSIONS else: extensions = {".m4a", ".mp3", ".opus", ".flac", ".wav"} From a9b9cec0d18180dca83115b4b4ed63ea6aa49b8c Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:47:56 +0200 Subject: [PATCH 2/6] chore(release): 0.6.0 without the Whisper dependency Drop openai-whisper (and PyTorch with it) from the dependencies, bump the version to 0.6.0 everywhere it appears, add CHANGELOG.md, and document the lossy-source rules, the extra M4A tags, the removed Whisper fallback and settings.sync_interval_seconds. --- CHANGELOG.md | 35 ++ README.md | 8 +- config.example.yaml | 13 +- docs/ROADMAP.md | 11 +- docs/configuration.md | 26 +- docs/getting-started.md | 4 +- docs/how-it-works.md | 15 +- pyproject.toml | 3 +- src/audio_transcode_watcher/__init__.py | 2 +- uv.lock | 528 +----------------------- 10 files changed, 96 insertions(+), 549 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c976e52 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# Changelog + +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/). + +## [0.6.0] - 2026-10-04 + +### Changed + +- Lossy sources are copied, not encoded. An MP3, M4A, AAC, Ogg, Opus or WMA file in the source folder lands in a lossless output (ALAC, FLAC, WAV) as an unchanged copy with its own extension. Before, everything except MP3 was encoded to ALAC, which turned a lossy file into a lossless-sized one that only looked lossless. +- Into a lossy output, a lossy source is copied unchanged when it already has that output's codec (an MP3 into an MP3 output, an M4A into an AAC output). It is transcoded only when the codecs differ. That is the one lossy-to-lossy encode left. +- Ogg and Opus are now treated as lossy. AIF and TAK are now recognised as lossless sources, and WMA as a lossy one. Files with these extensions used to be ignored. +- When a lossless and a lossy file share a name in the source folder, the lossless file wins in every output. +- The periodic full sync logs as "Periodic sync" instead of "Initial sync", so the logs no longer read like restarts. Its interval is the new setting `settings.sync_interval_seconds` (default 300). +- A lyrics file written next to a source now gets the source file's owner and group and mode 0664, instead of root and 0644. + +### Added + +- ALAC and AAC outputs keep the tags the standard MP4 atoms have no room for: ReplayGain track and album gain and peak, the MusicBrainz track, album, artist, album artist and release group ids, ISRC, label, catalog number, and the artist, album artist and album sort names. They are written as iTunes freeform atoms (`----:com.apple.iTunes:REPLAYGAIN_TRACK_GAIN` and so on). FLAC, Ogg, APE, WavPack, TAK, WAV and AIFF sources are read. +- `CHANGELOG.md`. + +### Removed + +- The Whisper lyrics fallback, and with it the `openai-whisper` dependency (and PyTorch), which makes the image much smaller. Old configs that still set `whisper_fallback` or `whisper_model` load fine; the first load logs one warning that the keys are ignored. + +### Fixed + +- A source that does not decode cleanly now fails instead of producing a short copy. FFmpeg runs with `-xerror`, and a decode error on its stderr counts as a failure even when it exits 0. A corrupt FLAC used to come out two seconds short with nothing in the logs. Now the error is logged with the file name, no output is written, and the file is not tried again until its modification time changes. +- The retry without cover art only runs when the error is about the picture stream. It used to run on any error that mentioned "decode". +- The periodic sync could delete an output the watcher had just written, because it judged orphans from a source list built before the file arrived. The orphan pass now reads the source folder again, and it leaves alone any file younger than 120 seconds or whose source is being processed. +- Startup and periodic cleanup no longer delete `.tmp__ff` files younger than 10 minutes, which could belong to an encode still running. +- Lyrics: when the providers return nothing, nothing is written. Results made of one repeated token, with fewer than 4 timed lines, or with only an advertisement line are rejected, and the reason is logged. +- Deleting one of two sources that share a name (for example the MP3 next to a FLAC) no longer deletes the `.lrc` copies the other one still needs. +- Lossy copies are written through a temp file and renamed, like encodes, so a half-written copy is never visible. + +[0.6.0]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.0 diff --git a/README.md b/README.md index 0cf0ac1..e32e635 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,10 @@ Keep one library in several formats: lossless for the archive, lossy for phones - Watches the source folder and reacts to new, changed, renamed and deleted files. - Writes any number of outputs in one pass, in ALAC, AAC, MP3, Opus, FLAC or WAV at the bitrate you pick. - Mirrors the Artist/Album folder tree in every output. -- Fetches synced `.lrc` lyrics, with Whisper speech-to-text when none are found online. +- Copies lossy sources (MP3, M4A, Ogg, Opus, WMA) unchanged instead of inflating them into a lossless output. +- Fails loudly on a source that does not decode cleanly, and writes no output for it. +- Keeps ReplayGain, MusicBrainz ids, ISRC, label and sort tags in ALAC and AAC copies. +- Fetches synced `.lrc` lyrics, and writes nothing when no real lyrics are found. - Embeds cover art if you want it. - Writes atomically and removes orphaned outputs, but never deletes anything when a folder looks empty. - Handles Unicode file names. @@ -32,7 +35,7 @@ Keep one library in several formats: lossless for the archive, lossy for phones curl -fsSL -o config.yaml https://raw.githubusercontent.com/GeiserX/audio-transcode-watcher/main/config.example.yaml docker run -d --name audio_transcoder -e CONFIG_FILE=/app/config.yaml \ -v ./config.yaml:/app/config.yaml:ro -v /path/to/flac:/music/flac:ro -v /path/to/mp3:/music/mp3 \ - drumsergio/audio-transcoder:0.5.2 + drumsergio/audio-transcoder:0.6.0 ``` 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`. @@ -45,6 +48,7 @@ Edit `config.yaml` first so its outputs match the folders you mount; the example - [How it works](https://github.com/GeiserX/audio-transcode-watcher/blob/main/docs/how-it-works.md): sync and watch mode, folder mirroring, safety guards, performance - [Development](https://github.com/GeiserX/audio-transcode-watcher/blob/main/docs/development.md): tests, building the image, contributing - [Roadmap](https://github.com/GeiserX/audio-transcode-watcher/blob/main/docs/ROADMAP.md) +- [Changelog](https://github.com/GeiserX/audio-transcode-watcher/blob/main/CHANGELOG.md) ## Related projects diff --git a/config.example.yaml b/config.example.yaml index 5bada46..dd933e0 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,4 +1,4 @@ -# Audio Transcode Watcher Configuration +# Audio Transcode Watcher Configuration (0.6.0) # ====================================== # # This configuration file defines the source folder and output destinations @@ -7,6 +7,9 @@ # Mount this file into the container and set CONFIG_FILE=/app/config.yaml # Source folder containing original audio files (FLAC, WAV, etc.) +# Lossy sources (mp3, m4a, aac, ogg, opus, wma) are copied unchanged into +# lossless outputs, and into lossy outputs of the same codec; they are only +# transcoded into a lossy output of another codec. source: path: /music/flac @@ -47,6 +50,14 @@ settings: # Minimum time a file must be unchanged before processing (seconds) min_stable_seconds: 1.0 + # Fetch synced .lrc lyrics online; nothing is written when none are found + fetch_lyrics: true + + # Seconds between periodic full syncs (default: 300) + sync_interval_seconds: 300 + + # whisper_fallback and whisper_model were removed in 0.6.0 and are ignored + # ============================================================================ # SUPPORTED CODECS diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4d397a3..c791413 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap -Current version: **0.5.0** +Current version: **0.6.0** ## Completed @@ -9,10 +9,11 @@ Current version: **0.5.0** - **Stale temp file cleanup** on startup (v0.1.2) - **Synced lyrics sidecar** (.lrc) copying to outputs (v0.2.0) - **Auto-fetch synced lyrics** via syncedlyrics providers (v0.3.0) -- **Whisper local transcription fallback** for lyrics (v0.4.0) +- **Whisper local transcription fallback** for lyrics (v0.4.0, removed in v0.6.0) - **Recursive directory support** -- mirror source folder hierarchy in outputs (v0.5.0) +- **Lossy sources copied, not inflated**, corrupt sources fail loudly, ReplayGain and MusicBrainz tags kept in M4A outputs, configurable periodic sync (v0.6.0) -## v0.6.0 -- Quality of Life +## v0.7.0 -- Quality of Life - [ ] **Progress reporting during initial sync** - Show file count progress (e.g. 42/500) @@ -26,7 +27,7 @@ Current version: **0.5.0** - Preview what would be encoded without doing it - Useful for testing configuration changes -## v0.7.0 -- Observability +## v0.8.0 -- Observability - [ ] **Health check endpoint** - HTTP endpoint for container health monitoring @@ -36,7 +37,7 @@ Current version: **0.5.0** - Webhook/Shoutrrr support for encoding events - Error alerts for failed encodes -## v0.8.0 -- Smart Encoding +## v0.9.0 -- Smart Encoding - [ ] **FFmpeg multi-output (tee muxer)** - Single decode pass, multiple encodes diff --git a/docs/configuration.md b/docs/configuration.md index 7433d2d..d9f5c97 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -58,13 +58,29 @@ settings: # Auto-fetch synced .lrc lyrics (default: true) fetch_lyrics: true - # Use Whisper local transcription as fallback when no lyrics found online (default: true) - whisper_fallback: true - - # Whisper model size: tiny, base, small, medium, large (default: base) - whisper_model: base + # Seconds between periodic full syncs (default: 300) + sync_interval_seconds: 300 ``` +`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. + +## Source formats + +| Kind | Extensions | +|----------|--------------------------------------------------------------------| +| Lossless | `.flac` `.alac` `.wav` `.aiff` `.aif` `.ape` `.wv` `.tta` `.tak` | +| Lossy | `.mp3` `.aac` `.m4a` `.ogg` `.opus` `.wma` | + +A lossless source is encoded to every output. + +A lossy source is never encoded into a lossless output, because that only makes a big file that looks lossless. It is copied there unchanged, with its own extension, so an ALAC folder can hold `.m4a` encodes next to `.mp3` or `.ogg` copies. Into a lossy output it is copied unchanged when it already has that output's codec (`.mp3` into `mp3`, `.m4a` or `.aac` into `aac`, `.opus` into `opus`) and transcoded otherwise. Copies keep their tags and cover as they are. + +When a lossless and a lossy file share a name in the source folder, the lossless one is used for every output. + +## Tags in ALAC and AAC outputs + +FFmpeg writes the standard MP4 atoms (title, artist, album and so on). After an ALAC or AAC encode, the tags those atoms cannot hold are copied from the source as iTunes freeform atoms named `----:com.apple.iTunes:`: `REPLAYGAIN_TRACK_GAIN`, `REPLAYGAIN_TRACK_PEAK`, `REPLAYGAIN_ALBUM_GAIN`, `REPLAYGAIN_ALBUM_PEAK`, `MUSICBRAINZ_TRACKID`, `MUSICBRAINZ_ALBUMID`, `MUSICBRAINZ_ARTISTID`, `MUSICBRAINZ_ALBUMARTISTID`, `MUSICBRAINZ_RELEASEGROUPID`, `ISRC`, `LABEL`, `CATALOGNUMBER`, `ARTISTSORT`, `ALBUMARTISTSORT` and `ALBUMSORT`. + ## Supported Codecs | Codec | Extension | Bitrate | Artwork | Description | diff --git a/docs/getting-started.md b/docs/getting-started.md index 6075674..a8e6cd0 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -29,7 +29,7 @@ outputs: ```yaml services: audio-transcoder: - image: drumsergio/audio-transcoder:0.5.2 + image: drumsergio/audio-transcoder:0.6.0 container_name: audio_transcoder environment: - TZ=Europe/Madrid @@ -60,6 +60,6 @@ docker run -d \ -v /path/to/flac:/music/flac:ro \ -v /path/to/mp3:/music/mp3 \ --restart unless-stopped \ - drumsergio/audio-transcoder:0.5.2 + drumsergio/audio-transcoder:0.6.0 ``` diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 3344813..a6209da 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -1,13 +1,19 @@ # How it works -1. **Initial sync** -- On startup, scans the source folder and encodes any missing files to all configured outputs. +1. **Initial sync** -- On startup, scans the source folder and encodes any missing files to all configured outputs. The same pass runs again every `sync_interval_seconds` (default 300) and logs as "Periodic sync". 2. **Watch mode** -- Continuously monitors the source folder for changes: - **New files** are encoded to all configured outputs - **Modified files** are re-encoded to all outputs - **Renamed files** trigger deletion of old outputs and creation of new ones - **Deleted files** have their corresponding outputs removed -3. **Orphan cleanup** -- Removes output files that no longer have a matching source. -4. **Lyrics sync** -- Fetches synced `.lrc` lyrics from online databases; falls back to Whisper transcription when no lyrics are found. +3. **Orphan cleanup** -- Removes output files that no longer have a matching source. It reads the source folder again just before, and skips any file younger than 120 seconds or whose source is being processed, so a file that arrives during a long sync keeps its output. +4. **Lyrics sync** -- Fetches synced `.lrc` lyrics from online databases (LRCLIB and the other syncedlyrics providers). When nothing usable comes back, no file is written. A result made of one repeated token, with fewer than 4 timed lines, or with only an advertisement line is rejected and the reason is logged. The `.lrc` gets the source file's owner and mode 0664. + +Lossy sources are copied rather than encoded where that keeps quality honest; see [Source formats](configuration.md#source-formats). + +## Corrupt sources + +FFmpeg runs with `-xerror`. 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 file is not tried again until its modification time changes, or the service restarts. ## Recursive Directory Support @@ -35,7 +41,8 @@ The service includes multiple guards to prevent data loss: - If the source folder appears empty, no deletions are performed - If any output folder appears empty, no deletions are performed -- All writes are atomic -- encoding happens to a temporary file that is moved into place only on success +- All writes are atomic -- encoding and copying happen to a `.tmp__ff` file that is moved into place only on success +- Cleanup leaves `.tmp__ff` files younger than 10 minutes alone, since they may belong to an encode still running ## Performance diff --git a/pyproject.toml b/pyproject.toml index 362a4f5..886a4d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "audio-transcode-watcher" -version = "0.5.2" +version = "0.6.0" description = "Watch a source folder and automatically transcode audio files to multiple formats" readme = "README.md" @@ -30,7 +30,6 @@ dependencies = [ "pyyaml>=6.0", "mutagen>=1.47.0", "syncedlyrics>=1.0.0", - "openai-whisper>=20240930", ] [project.optional-dependencies] diff --git a/src/audio_transcode_watcher/__init__.py b/src/audio_transcode_watcher/__init__.py index b063930..527c09c 100644 --- a/src/audio_transcode_watcher/__init__.py +++ b/src/audio_transcode_watcher/__init__.py @@ -1,3 +1,3 @@ """Audio Transcode Watcher - Automatic audio file transcoding.""" -__version__ = "0.1.2" +__version__ = "0.6.0" diff --git a/uv.lock b/uv.lock index 01c8073..bcae1ff 100644 --- a/uv.lock +++ b/uv.lock @@ -4,11 +4,10 @@ requires-python = ">=3.14" [[package]] name = "audio-transcode-watcher" -version = "0.5.2" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "mutagen" }, - { name = "openai-whisper" }, { name = "pyyaml" }, { name = "syncedlyrics" }, { name = "watchdog" }, @@ -24,7 +23,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "mutagen", specifier = ">=1.47.0" }, - { name = "openai-whisper", specifier = ">=20240930" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.12.0" }, @@ -125,95 +123,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/fb/70af542d2d938c778c9373ce253aa4116dbe7c0a5672f78b2b2ae0e1b94b/coverage-7.13.3-py3-none-any.whl", hash = "sha256:90a8af9dba6429b2573199622d72e0ebf024d6276f16abce394ad4d181bb0910", size = 211237, upload-time = "2026-02-03T14:02:27.986Z" }, ] -[[package]] -name = "cuda-bindings" -version = "13.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cuda-pathfinder" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/81/bff68ce829999c1e4209c761bbf903b1c06ec570416ddb25020864ad5907/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ab2f74ed65bfef4163ba07a8db16f1085e0729291db12a2423aff84ee8278b8", size = 6013639, upload-time = "2026-05-29T23:12:03.509Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e0/c8a1f0c8f9ffdea4f5fe6dbab89b326cef4d85caf489dad39e209da89416/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd4c814d311ec08c981f6dded1dbe7d4b371067ee4f6c14cccec4bde9590f80", size = 6534419, upload-time = "2026-05-29T23:12:05.633Z" }, - { url = "https://files.pythonhosted.org/packages/52/b8/83b1f563925b290f2d11a01a77a84013ba56052fe3653a5bef3ccfbb43d6/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3c772dfff49681541d59630c90f858e173ac926b9c593a2b7123f2a1043cc76", size = 5809771, upload-time = "2026-05-29T23:12:10.422Z" }, - { url = "https://files.pythonhosted.org/packages/12/20/e79b4bfe98f075195afb6343d41c498f9dbd2d161d7021d4d28bceb83581/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36febb7c1079d68a981dbbd8d5a67235b399802b82075c9388624719607e52b9", size = 6358584, upload-time = "2026-05-29T23:12:12.767Z" }, -] - -[[package]] -name = "cuda-pathfinder" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/b4/d088047afe39827556df21118cac9ffd20cc3f968c99a7681494d1eb333c/cuda_pathfinder-1.6.0-py3-none-any.whl", hash = "sha256:1503af579d8379c24bdd65528379bc57039b0455be9f5f9686cf8e473a1fce51", size = 54591, upload-time = "2026-07-21T15:03:56.224Z" }, -] - -[[package]] -name = "cuda-toolkit" -version = "13.0.3.0" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/c7/a79086a62c98befcdb8349656c6f114e2db3b8b2422f6e25c97a7f2a9a3c/cuda_toolkit-13.0.3.0-py2.py3-none-any.whl", hash = "sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f", size = 2512, upload-time = "2026-04-14T00:50:08.173Z" }, -] - -[package.optional-dependencies] -cublas = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, -] -cudart = [ - { name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, -] -cufft = [ - { name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, -] -cufile = [ - { name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, -] -cupti = [ - { name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, -] -curand = [ - { name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, -] -cusolver = [ - { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, -] -cusparse = [ - { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, -] -nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, -] -nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, -] -nvtx = [ - { name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, -] - -[[package]] -name = "filelock" -version = "3.20.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, -] - -[[package]] -name = "fsspec" -version = "2026.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, -] - [[package]] name = "idna" version = "3.15" @@ -232,78 +141,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "jinja2" -version = "3.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markupsafe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, -] - -[[package]] -name = "llvmlite" -version = "0.46.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/74/cd/08ae687ba099c7e3d21fe2ea536500563ef1943c5105bf6ab4ee3829f68e/llvmlite-0.46.0.tar.gz", hash = "sha256:227c9fd6d09dce2783c18b754b7cd9d9b3b3515210c46acc2d3c5badd9870ceb", size = 193456, upload-time = "2025-12-08T18:15:36.295Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/ae/af0ffb724814cc2ea64445acad05f71cff5f799bb7efb22e47ee99340dbc/llvmlite-0.46.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:d252edfb9f4ac1fcf20652258e3f102b26b03eef738dc8a6ffdab7d7d341d547", size = 37232768, upload-time = "2025-12-08T18:15:25.055Z" }, - { url = "https://files.pythonhosted.org/packages/c9/19/5018e5352019be753b7b07f7759cdabb69ca5779fea2494be8839270df4c/llvmlite-0.46.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:379fdd1c59badeff8982cb47e4694a6143bec3bb49aa10a466e095410522064d", size = 56275173, upload-time = "2025-12-08T18:15:28.109Z" }, - { url = "https://files.pythonhosted.org/packages/9f/c9/d57877759d707e84c082163c543853245f91b70c804115a5010532890f18/llvmlite-0.46.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e8cbfff7f6db0fa2c771ad24154e2a7e457c2444d7673e6de06b8b698c3b269", size = 55128628, upload-time = "2025-12-08T18:15:31.098Z" }, - { url = "https://files.pythonhosted.org/packages/30/a8/e61a8c2b3cc7a597073d9cde1fcbb567e9d827f1db30c93cf80422eac70d/llvmlite-0.46.0-cp314-cp314-win_amd64.whl", hash = "sha256:7821eda3ec1f18050f981819756631d60b6d7ab1a6cf806d9efefbe3f4082d61", size = 39153056, upload-time = "2025-12-08T18:15:33.938Z" }, -] - -[[package]] -name = "markupsafe" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, - { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, - { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, - { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, - { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, - { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, - { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, - { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, - { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, - { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, - { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, - { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, - { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, - { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, - { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, - { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, - { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, -] - -[[package]] -name = "more-itertools" -version = "10.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" }, -] - -[[package]] -name = "mpmath" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, -] - [[package]] name = "mutagen" version = "1.47.0" @@ -313,228 +150,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/7a/620f945b96be1f6ee357d211d5bf74ab1b7fe72a9f1525aafbfe3aee6875/mutagen-1.47.0-py3-none-any.whl", hash = "sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719", size = 194391, upload-time = "2023-09-03T16:33:29.955Z" }, ] -[[package]] -name = "networkx" -version = "3.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, -] - -[[package]] -name = "numba" -version = "0.63.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "llvmlite" }, - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dc/60/0145d479b2209bd8fdae5f44201eceb8ce5a23e0ed54c71f57db24618665/numba-0.63.1.tar.gz", hash = "sha256:b320aa675d0e3b17b40364935ea52a7b1c670c9037c39cf92c49502a75902f4b", size = 2761666, upload-time = "2025-12-10T02:57:39.002Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/2f/53be2aa8a55ee2608ebe1231789cbb217f6ece7f5e1c685d2f0752e95a5b/numba-0.63.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f180883e5508940cc83de8a8bea37fc6dd20fbe4e5558d4659b8b9bef5ff4731", size = 2681153, upload-time = "2025-12-10T02:57:32.016Z" }, - { url = "https://files.pythonhosted.org/packages/13/91/53e59c86759a0648282368d42ba732c29524a745fd555ed1fb1df83febbe/numba-0.63.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0938764afa82a47c0e895637a6c55547a42c9e1d35cac42285b1fa60a8b02bb", size = 3778718, upload-time = "2025-12-10T02:57:33.764Z" }, - { url = "https://files.pythonhosted.org/packages/6c/0c/2be19eba50b0b7636f6d1f69dfb2825530537708a234ba1ff34afc640138/numba-0.63.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f90a929fa5094e062d4e0368ede1f4497d5e40f800e80aa5222c4734236a2894", size = 3478712, upload-time = "2025-12-10T02:57:35.518Z" }, - { url = "https://files.pythonhosted.org/packages/0d/5f/4d0c9e756732577a52211f31da13a3d943d185f7fb90723f56d79c696caa/numba-0.63.1-cp314-cp314-win_amd64.whl", hash = "sha256:8d6d5ce85f572ed4e1a135dbb8c0114538f9dd0e3657eeb0bb64ab204cbe2a8f", size = 2752161, upload-time = "2025-12-10T02:57:37.12Z" }, -] - -[[package]] -name = "numpy" -version = "2.3.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/65/21b3bc86aac7b8f2862db1e808f1ea22b028e30a225a34a5ede9bf8678f2/numpy-2.3.5.tar.gz", hash = "sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0", size = 20584950, upload-time = "2025-11-16T22:52:42.067Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/97/1a914559c19e32d6b2e233cf9a6a114e67c856d35b1d6babca571a3e880f/numpy-2.3.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:bf06bc2af43fa8d32d30fae16ad965663e966b1a3202ed407b84c989c3221e82", size = 16735706, upload-time = "2025-11-16T22:51:19.558Z" }, - { url = "https://files.pythonhosted.org/packages/57/d4/51233b1c1b13ecd796311216ae417796b88b0616cfd8a33ae4536330748a/numpy-2.3.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:052e8c42e0c49d2575621c158934920524f6c5da05a1d3b9bab5d8e259e045f0", size = 12264507, upload-time = "2025-11-16T22:51:22.492Z" }, - { url = "https://files.pythonhosted.org/packages/45/98/2fe46c5c2675b8306d0b4a3ec3494273e93e1226a490f766e84298576956/numpy-2.3.5-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:1ed1ec893cff7040a02c8aa1c8611b94d395590d553f6b53629a4461dc7f7b63", size = 5093049, upload-time = "2025-11-16T22:51:25.171Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0e/0698378989bb0ac5f1660c81c78ab1fe5476c1a521ca9ee9d0710ce54099/numpy-2.3.5-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2dcd0808a421a482a080f89859a18beb0b3d1e905b81e617a188bd80422d62e9", size = 6626603, upload-time = "2025-11-16T22:51:27Z" }, - { url = "https://files.pythonhosted.org/packages/5e/a6/9ca0eecc489640615642a6cbc0ca9e10df70df38c4d43f5a928ff18d8827/numpy-2.3.5-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:727fd05b57df37dc0bcf1a27767a3d9a78cbbc92822445f32cc3436ba797337b", size = 14262696, upload-time = "2025-11-16T22:51:29.402Z" }, - { url = "https://files.pythonhosted.org/packages/c8/f6/07ec185b90ec9d7217a00eeeed7383b73d7e709dae2a9a021b051542a708/numpy-2.3.5-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fffe29a1ef00883599d1dc2c51aa2e5d80afe49523c261a74933df395c15c520", size = 16597350, upload-time = "2025-11-16T22:51:32.167Z" }, - { url = "https://files.pythonhosted.org/packages/75/37/164071d1dde6a1a84c9b8e5b414fa127981bad47adf3a6b7e23917e52190/numpy-2.3.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8f7f0e05112916223d3f438f293abf0727e1181b5983f413dfa2fefc4098245c", size = 16040190, upload-time = "2025-11-16T22:51:35.403Z" }, - { url = "https://files.pythonhosted.org/packages/08/3c/f18b82a406b04859eb026d204e4e1773eb41c5be58410f41ffa511d114ae/numpy-2.3.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2e2eb32ddb9ccb817d620ac1d8dae7c3f641c1e5f55f531a33e8ab97960a75b8", size = 18536749, upload-time = "2025-11-16T22:51:39.698Z" }, - { url = "https://files.pythonhosted.org/packages/40/79/f82f572bf44cf0023a2fe8588768e23e1592585020d638999f15158609e1/numpy-2.3.5-cp314-cp314-win32.whl", hash = "sha256:66f85ce62c70b843bab1fb14a05d5737741e74e28c7b8b5a064de10142fad248", size = 6335432, upload-time = "2025-11-16T22:51:42.476Z" }, - { url = "https://files.pythonhosted.org/packages/a3/2e/235b4d96619931192c91660805e5e49242389742a7a82c27665021db690c/numpy-2.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:e6a0bc88393d65807d751a614207b7129a310ca4fe76a74e5c7da5fa5671417e", size = 12919388, upload-time = "2025-11-16T22:51:45.275Z" }, - { url = "https://files.pythonhosted.org/packages/07/2b/29fd75ce45d22a39c61aad74f3d718e7ab67ccf839ca8b60866054eb15f8/numpy-2.3.5-cp314-cp314-win_arm64.whl", hash = "sha256:aeffcab3d4b43712bb7a60b65f6044d444e75e563ff6180af8f98dd4b905dfd2", size = 10476651, upload-time = "2025-11-16T22:51:47.749Z" }, - { url = "https://files.pythonhosted.org/packages/17/e1/f6a721234ebd4d87084cfa68d081bcba2f5cfe1974f7de4e0e8b9b2a2ba1/numpy-2.3.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17531366a2e3a9e30762c000f2c43a9aaa05728712e25c11ce1dbe700c53ad41", size = 16834503, upload-time = "2025-11-16T22:51:50.443Z" }, - { url = "https://files.pythonhosted.org/packages/5c/1c/baf7ffdc3af9c356e1c135e57ab7cf8d247931b9554f55c467efe2c69eff/numpy-2.3.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d21644de1b609825ede2f48be98dfde4656aefc713654eeee280e37cadc4e0ad", size = 12381612, upload-time = "2025-11-16T22:51:53.609Z" }, - { url = "https://files.pythonhosted.org/packages/74/91/f7f0295151407ddc9ba34e699013c32c3c91944f9b35fcf9281163dc1468/numpy-2.3.5-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c804e3a5aba5460c73955c955bdbd5c08c354954e9270a2c1565f62e866bdc39", size = 5210042, upload-time = "2025-11-16T22:51:56.213Z" }, - { url = "https://files.pythonhosted.org/packages/2e/3b/78aebf345104ec50dd50a4d06ddeb46a9ff5261c33bcc58b1c4f12f85ec2/numpy-2.3.5-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:cc0a57f895b96ec78969c34f682c602bf8da1a0270b09bc65673df2e7638ec20", size = 6724502, upload-time = "2025-11-16T22:51:58.584Z" }, - { url = "https://files.pythonhosted.org/packages/02/c6/7c34b528740512e57ef1b7c8337ab0b4f0bddf34c723b8996c675bc2bc91/numpy-2.3.5-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:900218e456384ea676e24ea6a0417f030a3b07306d29d7ad843957b40a9d8d52", size = 14308962, upload-time = "2025-11-16T22:52:01.698Z" }, - { url = "https://files.pythonhosted.org/packages/80/35/09d433c5262bc32d725bafc619e095b6a6651caf94027a03da624146f655/numpy-2.3.5-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09a1bea522b25109bf8e6f3027bd810f7c1085c64a0c7ce050c1676ad0ba010b", size = 16655054, upload-time = "2025-11-16T22:52:04.267Z" }, - { url = "https://files.pythonhosted.org/packages/7a/ab/6a7b259703c09a88804fa2430b43d6457b692378f6b74b356155283566ac/numpy-2.3.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:04822c00b5fd0323c8166d66c701dc31b7fbd252c100acd708c48f763968d6a3", size = 16091613, upload-time = "2025-11-16T22:52:08.651Z" }, - { url = "https://files.pythonhosted.org/packages/c2/88/330da2071e8771e60d1038166ff9d73f29da37b01ec3eb43cb1427464e10/numpy-2.3.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d6889ec4ec662a1a37eb4b4fb26b6100841804dac55bd9df579e326cdc146227", size = 18591147, upload-time = "2025-11-16T22:52:11.453Z" }, - { url = "https://files.pythonhosted.org/packages/51/41/851c4b4082402d9ea860c3626db5d5df47164a712cb23b54be028b184c1c/numpy-2.3.5-cp314-cp314t-win32.whl", hash = "sha256:93eebbcf1aafdf7e2ddd44c2923e2672e1010bddc014138b229e49725b4d6be5", size = 6479806, upload-time = "2025-11-16T22:52:14.641Z" }, - { url = "https://files.pythonhosted.org/packages/90/30/d48bde1dfd93332fa557cff1972fbc039e055a52021fbef4c2c4b1eefd17/numpy-2.3.5-cp314-cp314t-win_amd64.whl", hash = "sha256:c8a9958e88b65c3b27e22ca2a076311636850b612d6bbfb76e8d156aacde2aaf", size = 13105760, upload-time = "2025-11-16T22:52:17.975Z" }, - { url = "https://files.pythonhosted.org/packages/2d/fd/4b5eb0b3e888d86aee4d198c23acec7d214baaf17ea93c1adec94c9518b9/numpy-2.3.5-cp314-cp314t-win_arm64.whl", hash = "sha256:6203fdf9f3dc5bdaed7319ad8698e685c7a3be10819f41d32a0723e611733b42", size = 10545459, upload-time = "2025-11-16T22:52:20.55Z" }, -] - -[[package]] -name = "nvidia-cublas" -version = "13.1.1.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nvidia-cuda-nvrtc" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, - { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" }, -] - -[[package]] -name = "nvidia-cuda-cupti" -version = "13.0.85" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, - { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, -] - -[[package]] -name = "nvidia-cuda-nvrtc" -version = "13.0.88" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, - { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, -] - -[[package]] -name = "nvidia-cuda-runtime" -version = "13.0.96" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, - { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, -] - -[[package]] -name = "nvidia-cudnn-cu13" -version = "9.20.0.48" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nvidia-cublas" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, - { url = "https://files.pythonhosted.org/packages/6e/5e/edb9c0ae051602c3ccaffe424256463636d639e27d7f302dde9975ef9e7a/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0c45dd8eeb50b603f07995b1b300c62ffe6a1980482b82b3bcf94a4ca9d49304", size = 366173588, upload-time = "2026-03-09T19:29:34.474Z" }, -] - -[[package]] -name = "nvidia-cufft" -version = "12.0.0.61" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nvidia-nvjitlink" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, - { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, -] - -[[package]] -name = "nvidia-cufile" -version = "1.15.1.6" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, - { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, -] - -[[package]] -name = "nvidia-curand" -version = "10.4.0.35" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, - { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, -] - -[[package]] -name = "nvidia-cusolver" -version = "12.0.4.66" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nvidia-cublas" }, - { name = "nvidia-cusparse" }, - { name = "nvidia-nvjitlink" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, - { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, -] - -[[package]] -name = "nvidia-cusparse" -version = "12.6.3.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "nvidia-nvjitlink" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, - { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, -] - -[[package]] -name = "nvidia-cusparselt-cu13" -version = "0.8.1" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" }, - { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" }, -] - -[[package]] -name = "nvidia-nccl-cu13" -version = "2.29.7" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/0d/daf50d44177ee0cbc7ff0a0c91eb5ff676c82be42f9a970bc7597f440c3a/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:674a12383e3c38a1bcccae7d4f3633b37852230b6047883cb2f4c2d1b36d9bf5", size = 206014712, upload-time = "2026-03-03T05:34:20.843Z" }, - { url = "https://files.pythonhosted.org/packages/67/f4/58e4e91b6919367c7aafb8e36fce9aad1a3047e536bf7e2fd560927d3a4c/nvidia_nccl_cu13-2.29.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:edd81538446786ec3b73972543e53bb43bcaf0bfc8ef76cb679fcc390ffe136d", size = 205976000, upload-time = "2026-03-03T05:36:24.472Z" }, -] - -[[package]] -name = "nvidia-nvjitlink" -version = "13.3.33" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/ee/580ca6f29dcab0221db8706badca1bbbb084f1975c4d4e83329c3a7e31f0/nvidia_nvjitlink-13.3.33-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:26a6de7fb4c8fdaa7703d3dad720d6d427ddfea5c48a528fd97c11733ad830e5", size = 40742423, upload-time = "2026-05-26T16:54:51.613Z" }, - { url = "https://files.pythonhosted.org/packages/69/30/45414e35ff2eee7db3da037e5707037ccf9d2b5218ffbdb055ea4d5aa98a/nvidia_nvjitlink-13.3.33-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ce48b37dfeb3cb1eae4cf85adacb47d7a6539ea2272870c9a3628ce275c2037e", size = 39168635, upload-time = "2026-05-26T16:54:13.906Z" }, -] - -[[package]] -name = "nvidia-nvshmem-cu13" -version = "3.4.5" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, - { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, -] - -[[package]] -name = "nvidia-nvtx" -version = "13.0.85" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, - { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, -] - -[[package]] -name = "openai-whisper" -version = "20250625" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "more-itertools" }, - { name = "numba" }, - { name = "numpy" }, - { name = "tiktoken" }, - { name = "torch" }, - { name = "tqdm" }, - { name = "triton", marker = "(platform_machine == 'x86_64' and sys_platform == 'linux') or sys_platform == 'linux2'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/35/8e/d36f8880bcf18ec026a55807d02fe4c7357da9f25aebd92f85178000c0dc/openai_whisper-20250625.tar.gz", hash = "sha256:37a91a3921809d9f44748ffc73c0a55c9f366c85a3ef5c2ae0cc09540432eb96", size = 803191, upload-time = "2025-06-26T01:06:13.34Z" } - [[package]] name = "packaging" version = "26.0" @@ -660,46 +275,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/f6/e2176eb94f94892441bce3ddc514c179facb65db245e7ce3356965595b19/rapidfuzz-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e805e52322ae29aa945baf7168b6c898120fbc16d2b8f940b658a5e9e3999253", size = 851487, upload-time = "2025-11-01T11:54:40.176Z" }, ] -[[package]] -name = "regex" -version = "2026.1.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/0a/47fa888ec7cbbc7d62c5f2a6a888878e76169170ead271a35239edd8f0e8/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac", size = 489170, upload-time = "2026-01-14T23:16:19.835Z" }, - { url = "https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6", size = 291146, upload-time = "2026-01-14T23:16:21.541Z" }, - { url = "https://files.pythonhosted.org/packages/f9/b6/921cc61982e538682bdf3bdf5b2c6ab6b34368da1f8e98a6c1ddc503c9cf/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2", size = 288986, upload-time = "2026-01-14T23:16:23.381Z" }, - { url = "https://files.pythonhosted.org/packages/ca/33/eb7383dde0bbc93f4fb9d03453aab97e18ad4024ac7e26cef8d1f0a2cff0/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846", size = 799098, upload-time = "2026-01-14T23:16:25.088Z" }, - { url = "https://files.pythonhosted.org/packages/27/56/b664dccae898fc8d8b4c23accd853f723bde0f026c747b6f6262b688029c/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b", size = 864980, upload-time = "2026-01-14T23:16:27.297Z" }, - { url = "https://files.pythonhosted.org/packages/16/40/0999e064a170eddd237bae9ccfcd8f28b3aa98a38bf727a086425542a4fc/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e", size = 911607, upload-time = "2026-01-14T23:16:29.235Z" }, - { url = "https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde", size = 803358, upload-time = "2026-01-14T23:16:31.369Z" }, - { url = "https://files.pythonhosted.org/packages/27/31/d4292ea8566eaa551fafc07797961c5963cf5235c797cc2ae19b85dfd04d/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5", size = 775833, upload-time = "2026-01-14T23:16:33.141Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b2/cff3bf2fea4133aa6fb0d1e370b37544d18c8350a2fa118c7e11d1db0e14/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34", size = 788045, upload-time = "2026-01-14T23:16:35.005Z" }, - { url = "https://files.pythonhosted.org/packages/8d/99/2cb9b69045372ec877b6f5124bda4eb4253bc58b8fe5848c973f752bc52c/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75", size = 859374, upload-time = "2026-01-14T23:16:36.919Z" }, - { url = "https://files.pythonhosted.org/packages/09/16/710b0a5abe8e077b1729a562d2f297224ad079f3a66dce46844c193416c8/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e", size = 763940, upload-time = "2026-01-14T23:16:38.685Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d1/7585c8e744e40eb3d32f119191969b91de04c073fca98ec14299041f6e7e/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160", size = 850112, upload-time = "2026-01-14T23:16:40.646Z" }, - { url = "https://files.pythonhosted.org/packages/af/d6/43e1dd85df86c49a347aa57c1f69d12c652c7b60e37ec162e3096194a278/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1", size = 789586, upload-time = "2026-01-14T23:16:42.799Z" }, - { url = "https://files.pythonhosted.org/packages/93/38/77142422f631e013f316aaae83234c629555729a9fbc952b8a63ac91462a/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1", size = 271691, upload-time = "2026-01-14T23:16:44.671Z" }, - { url = "https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903", size = 280422, upload-time = "2026-01-14T23:16:46.607Z" }, - { url = "https://files.pythonhosted.org/packages/be/2a/20fd057bf3521cb4791f69f869635f73e0aaf2b9ad2d260f728144f9047c/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705", size = 273467, upload-time = "2026-01-14T23:16:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/ad/77/0b1e81857060b92b9cad239104c46507dd481b3ff1fa79f8e7f865aae38a/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8", size = 492073, upload-time = "2026-01-14T23:16:51.154Z" }, - { url = "https://files.pythonhosted.org/packages/70/f3/f8302b0c208b22c1e4f423147e1913fd475ddd6230565b299925353de644/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf", size = 292757, upload-time = "2026-01-14T23:16:53.08Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f0/ef55de2460f3b4a6da9d9e7daacd0cb79d4ef75c64a2af316e68447f0df0/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d", size = 291122, upload-time = "2026-01-14T23:16:55.383Z" }, - { url = "https://files.pythonhosted.org/packages/cf/55/bb8ccbacabbc3a11d863ee62a9f18b160a83084ea95cdfc5d207bfc3dd75/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84", size = 807761, upload-time = "2026-01-14T23:16:57.251Z" }, - { url = "https://files.pythonhosted.org/packages/8f/84/f75d937f17f81e55679a0509e86176e29caa7298c38bd1db7ce9c0bf6075/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df", size = 873538, upload-time = "2026-01-14T23:16:59.349Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d9/0da86327df70349aa8d86390da91171bd3ca4f0e7c1d1d453a9c10344da3/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434", size = 915066, upload-time = "2026-01-14T23:17:01.607Z" }, - { url = "https://files.pythonhosted.org/packages/2a/5e/f660fb23fc77baa2a61aa1f1fe3a4eea2bbb8a286ddec148030672e18834/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a", size = 812938, upload-time = "2026-01-14T23:17:04.366Z" }, - { url = "https://files.pythonhosted.org/packages/69/33/a47a29bfecebbbfd1e5cd3f26b28020a97e4820f1c5148e66e3b7d4b4992/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10", size = 781314, upload-time = "2026-01-14T23:17:06.378Z" }, - { url = "https://files.pythonhosted.org/packages/65/ec/7ec2bbfd4c3f4e494a24dec4c6943a668e2030426b1b8b949a6462d2c17b/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac", size = 795652, upload-time = "2026-01-14T23:17:08.521Z" }, - { url = "https://files.pythonhosted.org/packages/46/79/a5d8651ae131fe27d7c521ad300aa7f1c7be1dbeee4d446498af5411b8a9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea", size = 868550, upload-time = "2026-01-14T23:17:10.573Z" }, - { url = "https://files.pythonhosted.org/packages/06/b7/25635d2809664b79f183070786a5552dd4e627e5aedb0065f4e3cf8ee37d/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e", size = 769981, upload-time = "2026-01-14T23:17:12.871Z" }, - { url = "https://files.pythonhosted.org/packages/16/8b/fc3fcbb2393dcfa4a6c5ffad92dc498e842df4581ea9d14309fcd3c55fb9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521", size = 854780, upload-time = "2026-01-14T23:17:14.837Z" }, - { url = "https://files.pythonhosted.org/packages/d0/38/dde117c76c624713c8a2842530be9c93ca8b606c0f6102d86e8cd1ce8bea/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db", size = 799778, upload-time = "2026-01-14T23:17:17.369Z" }, - { url = "https://files.pythonhosted.org/packages/e3/0d/3a6cfa9ae99606afb612d8fb7a66b245a9d5ff0f29bb347c8a30b6ad561b/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e", size = 274667, upload-time = "2026-01-14T23:17:19.301Z" }, - { url = "https://files.pythonhosted.org/packages/5b/b2/297293bb0742fd06b8d8e2572db41a855cdf1cae0bf009b1cb74fe07e196/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf", size = 284386, upload-time = "2026-01-14T23:17:21.231Z" }, - { url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837, upload-time = "2026-01-14T23:17:23.146Z" }, -] - [[package]] name = "requests" version = "2.33.0" @@ -715,15 +290,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, ] -[[package]] -name = "setuptools" -version = "83.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, -] - [[package]] name = "soupsieve" version = "2.9" @@ -733,18 +299,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/d6/3185ab5ad1280319b31986898f3206dd7227cd75e293d4dba2a5e6bf27a0/soupsieve-2.9-py3-none-any.whl", hash = "sha256:a2b2c76d67df2382d245409fd71e321a571717e58463efa32ace87dcadac2c12", size = 37387, upload-time = "2026-07-19T01:35:17.106Z" }, ] -[[package]] -name = "sympy" -version = "1.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mpmath" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, -] - [[package]] name = "syncedlyrics" version = "1.0.1" @@ -759,86 +313,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/ad/62d2a1cd2a251ea2d43185f2b98badb8acf97a42278ce424ea8a97d8f911/syncedlyrics-1.0.1-py3-none-any.whl", hash = "sha256:31ca4322311b454f969e837c7350ecbef241f705f2122ea0183a2902a1a3791a", size = 17971, upload-time = "2024-07-28T09:42:13.198Z" }, ] -[[package]] -name = "tiktoken" -version = "0.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "regex" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/4d017d0f76ec3171d469d80fc03dfbb4e48a4bcaddaa831b31d526f05edc/tiktoken-0.12.0.tar.gz", hash = "sha256:b18ba7ee2b093863978fcb14f74b3707cdc8d4d4d3836853ce7ec60772139931", size = 37806, upload-time = "2025-10-06T20:22:45.419Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/05/3abc1db5d2c9aadc4d2c76fa5640134e475e58d9fbb82b5c535dc0de9b01/tiktoken-0.12.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a90388128df3b3abeb2bfd1895b0681412a8d7dc644142519e6f0a97c2111646", size = 1050188, upload-time = "2025-10-06T20:22:19.563Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7b/50c2f060412202d6c95f32b20755c7a6273543b125c0985d6fa9465105af/tiktoken-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:da900aa0ad52247d8794e307d6446bd3cdea8e192769b56276695d34d2c9aa88", size = 993978, upload-time = "2025-10-06T20:22:20.702Z" }, - { url = "https://files.pythonhosted.org/packages/14/27/bf795595a2b897e271771cd31cb847d479073497344c637966bdf2853da1/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:285ba9d73ea0d6171e7f9407039a290ca77efcdb026be7769dccc01d2c8d7fff", size = 1129271, upload-time = "2025-10-06T20:22:22.06Z" }, - { url = "https://files.pythonhosted.org/packages/f5/de/9341a6d7a8f1b448573bbf3425fa57669ac58258a667eb48a25dfe916d70/tiktoken-0.12.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:d186a5c60c6a0213f04a7a802264083dea1bbde92a2d4c7069e1a56630aef830", size = 1151216, upload-time = "2025-10-06T20:22:23.085Z" }, - { url = "https://files.pythonhosted.org/packages/75/0d/881866647b8d1be4d67cb24e50d0c26f9f807f994aa1510cb9ba2fe5f612/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:604831189bd05480f2b885ecd2d1986dc7686f609de48208ebbbddeea071fc0b", size = 1194860, upload-time = "2025-10-06T20:22:24.602Z" }, - { url = "https://files.pythonhosted.org/packages/b3/1e/b651ec3059474dab649b8d5b69f5c65cd8fcd8918568c1935bd4136c9392/tiktoken-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8f317e8530bb3a222547b85a58583238c8f74fd7a7408305f9f63246d1a0958b", size = 1254567, upload-time = "2025-10-06T20:22:25.671Z" }, - { url = "https://files.pythonhosted.org/packages/80/57/ce64fd16ac390fafde001268c364d559447ba09b509181b2808622420eec/tiktoken-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:399c3dd672a6406719d84442299a490420b458c44d3ae65516302a99675888f3", size = 921067, upload-time = "2025-10-06T20:22:26.753Z" }, - { url = "https://files.pythonhosted.org/packages/ac/a4/72eed53e8976a099539cdd5eb36f241987212c29629d0a52c305173e0a68/tiktoken-0.12.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2c714c72bc00a38ca969dae79e8266ddec999c7ceccd603cc4f0d04ccd76365", size = 1050473, upload-time = "2025-10-06T20:22:27.775Z" }, - { url = "https://files.pythonhosted.org/packages/e6/d7/0110b8f54c008466b19672c615f2168896b83706a6611ba6e47313dbc6e9/tiktoken-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbb9a3ba275165a2cb0f9a83f5d7025afe6b9d0ab01a22b50f0e74fee2ad253e", size = 993855, upload-time = "2025-10-06T20:22:28.799Z" }, - { url = "https://files.pythonhosted.org/packages/5f/77/4f268c41a3957c418b084dd576ea2fad2e95da0d8e1ab705372892c2ca22/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:dfdfaa5ffff8993a3af94d1125870b1d27aed7cb97aa7eb8c1cefdbc87dbee63", size = 1129022, upload-time = "2025-10-06T20:22:29.981Z" }, - { url = "https://files.pythonhosted.org/packages/4e/2b/fc46c90fe5028bd094cd6ee25a7db321cb91d45dc87531e2bdbb26b4867a/tiktoken-0.12.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:584c3ad3d0c74f5269906eb8a659c8bfc6144a52895d9261cdaf90a0ae5f4de0", size = 1150736, upload-time = "2025-10-06T20:22:30.996Z" }, - { url = "https://files.pythonhosted.org/packages/28/c0/3c7a39ff68022ddfd7d93f3337ad90389a342f761c4d71de99a3ccc57857/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54c891b416a0e36b8e2045b12b33dd66fb34a4fe7965565f1b482da50da3e86a", size = 1194908, upload-time = "2025-10-06T20:22:32.073Z" }, - { url = "https://files.pythonhosted.org/packages/ab/0d/c1ad6f4016a3968c048545f5d9b8ffebf577774b2ede3e2e352553b685fe/tiktoken-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5edb8743b88d5be814b1a8a8854494719080c28faaa1ccbef02e87354fe71ef0", size = 1253706, upload-time = "2025-10-06T20:22:33.385Z" }, - { url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" }, -] - -[[package]] -name = "torch" -version = "2.13.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cuda-bindings", marker = "python_full_version < '3.15' and sys_platform == 'linux'" }, - { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, - { name = "filelock" }, - { name = "fsspec" }, - { name = "jinja2" }, - { name = "networkx" }, - { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, - { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, - { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, - { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, - { name = "setuptools" }, - { name = "sympy" }, - { name = "triton", marker = "python_full_version < '3.15' and sys_platform == 'linux'" }, - { name = "typing-extensions" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/76/6dcc7f0c07052102dd36f83cbc5800842a909c8c3fbf1a7f8a5844954de9/torch-2.13.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d849b390e07d8d333ce8ecaf91b273c656c598379a19c9acf1318a883f6b391c", size = 111227066, upload-time = "2026-07-08T16:03:33.6Z" }, - { url = "https://files.pythonhosted.org/packages/e9/09/2c10e8cd0e00fa5d23c052df6ce467eaa7182399f5e0f824f1e4ff42ccae/torch-2.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:a3893dc2da0a972a8ca5d698c85a9f967559ac5f8ee1797b77408aa8734d073c", size = 427226309, upload-time = "2026-07-08T16:02:53.127Z" }, - { url = "https://files.pythonhosted.org/packages/76/c6/22c2102bbef14ca6a6cb4c20e42f088e49c5f812be4e160ae57502e325f9/torch-2.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:49f1ea385c754e54919408a9bb3b5a72b0b755bbe2c916c1d6f70afbec4908a2", size = 526614507, upload-time = "2026-07-08T16:02:16.441Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0c/7d1deb6bce5bc3e6042caf39100ac768eba3b9a098e1dddd16f75bd6489b/torch-2.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f8573e3ce9ebcd53fe922f01077a6085ccdfbe5f12fd215883a9d87d7a744fd", size = 122051871, upload-time = "2026-07-08T16:03:23.521Z" }, - { url = "https://files.pythonhosted.org/packages/f4/ce/aa8b7f9949d32e0f2f624f342bc3b48112c1b8a130288465938bc83bcbf9/torch-2.13.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:c28def70706c2f9ecc752574766e8ae4da9b810ab6676b611166761a78a9f1e1", size = 111537025, upload-time = "2026-07-08T16:02:44.28Z" }, - { url = "https://files.pythonhosted.org/packages/69/d1/491e3a0389430946145888b0203f2b6a759ce2a61481b96a85c2da4f2ced/torch-2.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:31061ff56ed8fbf26c749806905aeb749ebeb819810fd5d52508aa5afd90dddc", size = 427219769, upload-time = "2026-07-08T16:02:31.18Z" }, - { url = "https://files.pythonhosted.org/packages/9a/1d/38006e045bf0a1fc28ef01e757c554e59e59a8770c284bc4f47b14e60441/torch-2.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:cc26eead4cf51d0b544e31e364dcf000846549c273bd148936fe9d24d29acb92", size = 526571320, upload-time = "2026-07-08T16:01:59.348Z" }, - { url = "https://files.pythonhosted.org/packages/56/94/655c91992a882bd5071aa0b5d22a07dbb130d801e872be97c0b627a7c693/torch-2.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a7de8a313090dc5c7d7ba4bfe5c3be222528f9a4dba1acc83bddb1157360c4b8", size = 122306773, upload-time = "2026-07-08T16:02:39.832Z" }, -] - -[[package]] -name = "tqdm" -version = "4.67.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, -] - -[[package]] -name = "triton" -version = "3.7.1" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/71/e01aa7ad573883ed9456f130226babdec70b005e098c4d6226a6238e761b/triton-3.7.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4ea396a06171f1f1f58cbd39c70b09294398f7dd7c620939bab54ad6f934fa", size = 184705764, upload-time = "2026-06-17T20:03:59.064Z" }, - { url = "https://files.pythonhosted.org/packages/a4/09/5683146fda6a2b569deb78ccfd8fbfea8bfe55f726b081c0a6bb18dd6f28/triton-3.7.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2020153b08280415ec0da6607834e79166442147e78e144df06b508c75b186d2", size = 197729537, upload-time = "2026-06-17T19:53:35.516Z" }, - { url = "https://files.pythonhosted.org/packages/e9/f8/448220c3092019f9fdfab39ec47985968181d67da34b44f6a7f6280a5cbb/triton-3.7.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c58e4c61f0c73b5dba3b5d19b4a7093c32f90dc18b2a7f121a7c16ccd31107b7", size = 184814760, upload-time = "2026-06-17T20:04:04.984Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ac/229b7d4589d2e5937310e72c6d46e89599d16a4a12b479ffa1499fee8eb8/triton-3.7.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10ba85fa2cca4a2fbdeb36bf1cb082f2c252bda55bf9fccd74f65ec5bc647e68", size = 197824404, upload-time = "2026-06-17T19:53:42.772Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0" From 449b5277936efd3ab94193c61764345bba3c09c9 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:49:52 +0200 Subject: [PATCH 3/6] docs(changelog): note that force_reencode now purges only at startup --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c976e52..ae5d59c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,5 +31,6 @@ All notable changes to this project are documented here. The format follows [Kee - Lyrics: when the providers return nothing, nothing is written. Results made of one repeated token, with fewer than 4 timed lines, or with only an advertisement line are rejected, and the reason is logged. - Deleting one of two sources that share a name (for example the MP3 next to a FLAC) no longer deletes the `.lrc` copies the other one still needs. - Lossy copies are written through a temp file and renamed, like encodes, so a half-written copy is never visible. +- `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. [0.6.0]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.0 From 0e574a67794a172ede135787b52def8662925919 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:56:13 +0200 Subject: [PATCH 4/6] fix: write M4A tags the way Picard does and let a late FLAC replace its lossy copy Freeform atoms now use the names MusicBrainz Picard writes (lowercase replaygain_*, "MusicBrainz Track Id" and friends, ISRC, LABEL, CATALOGNUMBER) and sort names go to soar, soaa and soal, so every common reader recognises them. When a lossless source is processed and the output still holds a copy of its lossy twin (X.m4a copied, then X.flac arrives), the copy is removed and the lossless file encoded, instead of the copy blocking the encode. A finished encode is kept: a same-name file is only treated as the copy while it is byte-identical to the lossy source. CI installs FFmpeg so the real-file tests run there, and the new error branches have tests. --- .github/workflows/release.yml | 3 + .github/workflows/tests.yml | 3 + CHANGELOG.md | 4 +- docs/configuration.md | 15 +- src/audio_transcode_watcher/encoder.py | 58 ++++--- src/audio_transcode_watcher/sync.py | 53 +++++- tests/test_encoder.py | 87 ++++++++-- tests/test_lyrics.py | 12 ++ tests/test_sync.py | 224 +++++++++++++++++++++++++ 9 files changed, 414 insertions(+), 45 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2c7591..f8f9704 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,9 @@ jobs: python-version: '3.14' cache: 'pip' + - name: Install FFmpeg (the real-file tests need it) + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg + - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1e86e48..772709b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,6 +19,9 @@ jobs: python-version: '3.14' cache: 'pip' + - name: Install FFmpeg (the real-file tests need it) + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg + - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/CHANGELOG.md b/CHANGELOG.md index ae5d59c..7082fe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,13 @@ All notable changes to this project are documented here. The format follows [Kee - Lossy sources are copied, not encoded. An MP3, M4A, AAC, Ogg, Opus or WMA file in the source folder lands in a lossless output (ALAC, FLAC, WAV) as an unchanged copy with its own extension. Before, everything except MP3 was encoded to ALAC, which turned a lossy file into a lossless-sized one that only looked lossless. - Into a lossy output, a lossy source is copied unchanged when it already has that output's codec (an MP3 into an MP3 output, an M4A into an AAC output). It is transcoded only when the codecs differ. That is the one lossy-to-lossy encode left. - Ogg and Opus are now treated as lossy. AIF and TAK are now recognised as lossless sources, and WMA as a lossy one. Files with these extensions used to be ignored. -- When a lossless and a lossy file share a name in the source folder, the lossless file wins in every output. +- When a lossless and a lossy file share a name in the source folder, the lossless file wins in every output. If the lossy file arrived first, its copy is removed and the lossless file is encoded when it is processed, not left behind until an orphan pass. - The periodic full sync logs as "Periodic sync" instead of "Initial sync", so the logs no longer read like restarts. Its interval is the new setting `settings.sync_interval_seconds` (default 300). - A lyrics file written next to a source now gets the source file's owner and group and mode 0664, instead of root and 0644. ### Added -- ALAC and AAC outputs keep the tags the standard MP4 atoms have no room for: ReplayGain track and album gain and peak, the MusicBrainz track, album, artist, album artist and release group ids, ISRC, label, catalog number, and the artist, album artist and album sort names. They are written as iTunes freeform atoms (`----:com.apple.iTunes:REPLAYGAIN_TRACK_GAIN` and so on). FLAC, Ogg, APE, WavPack, TAK, WAV and AIFF sources are read. +- ALAC and AAC outputs keep the tags the standard MP4 atoms have no room for: ReplayGain track and album gain and peak, the MusicBrainz track, album, artist, album artist and release group ids, ISRC, label, catalog number, and the artist, album artist and album sort names. They use the atom names MusicBrainz Picard writes (`----:com.apple.iTunes:replaygain_track_gain`, `----:com.apple.iTunes:MusicBrainz Track Id`, `ISRC`, `LABEL`, `CATALOGNUMBER`), so Picard, TagLib, foobar2000 and iTunes all read them, and the sort names go to the standard `soar`, `soaa` and `soal` atoms. FLAC, Ogg, APE, WavPack, TAK, WAV and AIFF sources are read. - `CHANGELOG.md`. ### Removed diff --git a/docs/configuration.md b/docs/configuration.md index d9f5c97..6a514e8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -75,11 +75,22 @@ A lossless source is encoded to every output. A lossy source is never encoded into a lossless output, because that only makes a big file that looks lossless. It is copied there unchanged, with its own extension, so an ALAC folder can hold `.m4a` encodes next to `.mp3` or `.ogg` copies. Into a lossy output it is copied unchanged when it already has that output's codec (`.mp3` into `mp3`, `.m4a` or `.aac` into `aac`, `.opus` into `opus`) and transcoded otherwise. Copies keep their tags and cover as they are. -When a lossless and a lossy file share a name in the source folder, the lossless one is used for every output. +When a lossless and a lossy file share a name in the source folder, the lossless one is used for every output. If the lossy file came first and was already copied, the copy is removed and the lossless file is encoded as soon as it is processed. ## Tags in ALAC and AAC outputs -FFmpeg writes the standard MP4 atoms (title, artist, album and so on). After an ALAC or AAC encode, the tags those atoms cannot hold are copied from the source as iTunes freeform atoms named `----:com.apple.iTunes:`: `REPLAYGAIN_TRACK_GAIN`, `REPLAYGAIN_TRACK_PEAK`, `REPLAYGAIN_ALBUM_GAIN`, `REPLAYGAIN_ALBUM_PEAK`, `MUSICBRAINZ_TRACKID`, `MUSICBRAINZ_ALBUMID`, `MUSICBRAINZ_ARTISTID`, `MUSICBRAINZ_ALBUMARTISTID`, `MUSICBRAINZ_RELEASEGROUPID`, `ISRC`, `LABEL`, `CATALOGNUMBER`, `ARTISTSORT`, `ALBUMARTISTSORT` and `ALBUMSORT`. +FFmpeg writes the standard MP4 atoms (title, artist, album and so on). After an ALAC or AAC encode, the tags FFmpeg drops are copied from the source under the names MusicBrainz Picard uses, so Picard, TagLib (Navidrome, Jellyfin), foobar2000 and iTunes all read them: + +| Source tag | MP4 atom | +|----------------------------------------------|-------------------------------------------------------| +| `REPLAYGAIN_TRACK_GAIN`, `_TRACK_PEAK`, `_ALBUM_GAIN`, `_ALBUM_PEAK` | `----:com.apple.iTunes:replaygain_track_gain` and so on, lowercase | +| `MUSICBRAINZ_TRACKID` | `----:com.apple.iTunes:MusicBrainz Track Id` | +| `MUSICBRAINZ_ALBUMID` | `----:com.apple.iTunes:MusicBrainz Album Id` | +| `MUSICBRAINZ_ARTISTID` | `----:com.apple.iTunes:MusicBrainz Artist Id` | +| `MUSICBRAINZ_ALBUMARTISTID` | `----:com.apple.iTunes:MusicBrainz Album Artist Id` | +| `MUSICBRAINZ_RELEASEGROUPID` | `----:com.apple.iTunes:MusicBrainz Release Group Id` | +| `ISRC`, `LABEL`, `CATALOGNUMBER` | `----:com.apple.iTunes:ISRC`, `LABEL`, `CATALOGNUMBER` | +| `ARTISTSORT`, `ALBUMARTISTSORT`, `ALBUMSORT` | the standard sort atoms `soar`, `soaa`, `soal` | ## Supported Codecs diff --git a/src/audio_transcode_watcher/encoder.py b/src/audio_transcode_watcher/encoder.py index 510ba25..6cd069d 100644 --- a/src/audio_transcode_watcher/encoder.py +++ b/src/audio_transcode_watcher/encoder.py @@ -35,25 +35,28 @@ "vf#", ) -# Tags the standard MP4 atoms cannot hold, copied after an ALAC or AAC -# encode as iTunes freeform atoms ``----:com.apple.iTunes:``. -FREEFORM_TAGS = ( - "REPLAYGAIN_TRACK_GAIN", - "REPLAYGAIN_TRACK_PEAK", - "REPLAYGAIN_ALBUM_GAIN", - "REPLAYGAIN_ALBUM_PEAK", - "MUSICBRAINZ_TRACKID", - "MUSICBRAINZ_ALBUMID", - "MUSICBRAINZ_ARTISTID", - "MUSICBRAINZ_ALBUMARTISTID", - "MUSICBRAINZ_RELEASEGROUPID", - "ISRC", - "LABEL", - "CATALOGNUMBER", - "ARTISTSORT", - "ALBUMARTISTSORT", - "ALBUMSORT", -) +# Tags ffmpeg drops on an ALAC or AAC encode, copied from the source after +# it: source tag name -> MP4 atom. The atom names are the ones MusicBrainz +# Picard writes, so Picard, TagLib (Navidrome, Jellyfin), foobar2000 and +# iTunes all recognise them. Sort names go to the standard sort atoms. +_FREEFORM = "----:com.apple.iTunes:" +MP4_TAG_ATOMS = { + "REPLAYGAIN_TRACK_GAIN": _FREEFORM + "replaygain_track_gain", + "REPLAYGAIN_TRACK_PEAK": _FREEFORM + "replaygain_track_peak", + "REPLAYGAIN_ALBUM_GAIN": _FREEFORM + "replaygain_album_gain", + "REPLAYGAIN_ALBUM_PEAK": _FREEFORM + "replaygain_album_peak", + "MUSICBRAINZ_TRACKID": _FREEFORM + "MusicBrainz Track Id", + "MUSICBRAINZ_ALBUMID": _FREEFORM + "MusicBrainz Album Id", + "MUSICBRAINZ_ARTISTID": _FREEFORM + "MusicBrainz Artist Id", + "MUSICBRAINZ_ALBUMARTISTID": _FREEFORM + "MusicBrainz Album Artist Id", + "MUSICBRAINZ_RELEASEGROUPID": _FREEFORM + "MusicBrainz Release Group Id", + "ISRC": _FREEFORM + "ISRC", + "LABEL": _FREEFORM + "LABEL", + "CATALOGNUMBER": _FREEFORM + "CATALOGNUMBER", + "ARTISTSORT": "soar", + "ALBUMARTISTSORT": "soaa", + "ALBUMSORT": "soal", +} # How the same tags are stored in ID3 (WAV, AIFF and MP3 sources). _ID3_FRAMES = { @@ -287,7 +290,7 @@ def atomic_ffmpeg_encode( def _source_tag_values(source: str) -> dict[str, list[str]]: """ - Read FREEFORM_TAGS from *source*, whatever its tag format. + Read the MP4_TAG_ATOMS source tags from *source*, whatever its tag format. Vorbis comments (FLAC, Ogg, Opus) and APEv2 (APE, WavPack, TAK) store them under the same names; ID3 (WAV, AIFF, MP3) uses TXXX and a few @@ -325,17 +328,18 @@ def _source_tag_values(source: str) -> dict[str, list[str]]: plain[str(key).upper()] = [str(v) for v in values] out: dict[str, list[str]] = {} - for name in FREEFORM_TAGS: + for name in MP4_TAG_ATOMS: values = [v for v in plain.get(name, []) if v.strip()] if values: out[name] = values return out -def copy_freeform_tags(source: str, dest: str) -> int: +def copy_mp4_tags(source: str, dest: str) -> int: """ - Copy FREEFORM_TAGS from *source* into the MP4 file *dest*. + Copy the MP4_TAG_ATOMS tags from *source* into the MP4 file *dest*. + Freeform atoms hold UTF-8 bytes; the sort atoms hold text. Returns the number of tags written. """ values = _source_tag_values(source) @@ -345,9 +349,11 @@ def copy_freeform_tags(source: str, dest: str) -> int: if mp4.tags is None: mp4.add_tags() for name, vals in values.items(): - mp4.tags[f"----:com.apple.iTunes:{name}"] = [ - MP4FreeForm(v.encode("utf-8")) for v in vals - ] + atom = MP4_TAG_ATOMS[name] + if atom.startswith(_FREEFORM): + mp4.tags[atom] = [MP4FreeForm(v.encode("utf-8")) for v in vals] + else: + mp4.tags[atom] = vals mp4.save() return len(values) diff --git a/src/audio_transcode_watcher/sync.py b/src/audio_transcode_watcher/sync.py index 536e5d3..4a13f40 100644 --- a/src/audio_transcode_watcher/sync.py +++ b/src/audio_transcode_watcher/sync.py @@ -2,6 +2,7 @@ from __future__ import annotations +import filecmp import logging import os import shutil @@ -11,11 +12,12 @@ from pathlib import Path from .config import Config, OutputConfig -from .encoder import atomic_ffmpeg_encode, build_ffmpeg_command, copy_freeform_tags +from .encoder import atomic_ffmpeg_encode, build_ffmpeg_command, copy_mp4_tags from .lyrics import fetch_lyrics_for_file from .utils import ( AUDIO_EXTENSIONS, LOSSLESS_EXTENSIONS, + LOSSY_EXTENSIONS, SIDECAR_EXTENSIONS, appears_empty_dir, get_output_file_path, @@ -54,7 +56,7 @@ # A .tmp__ff file younger than this (seconds) may be an encode in progress. TEMP_MIN_AGE = 600.0 -# Output codecs whose files are MP4 and take iTunes freeform tags. +# Output codecs whose files are MP4 and take the extra MP4 tags. _MP4_CODECS = {"alac", "aac"} # Safety guard logging throttle @@ -197,6 +199,47 @@ def _has_lossless_source(source_path: str, config: Config) -> bool: return False +def _lossy_siblings(source_path: str) -> list[str]: + """Lossy sources with the same stem as *source_path*, in its folder.""" + stem = Path(source_path).stem + source_dir = os.path.dirname(source_path) + siblings = [] + for ext in sorted(LOSSY_EXTENSIONS): + other = nfc_path(os.path.join(source_dir, f"{stem}{ext}")) + if other != source_path and os.path.isfile(other): + siblings.append(other) + return siblings + + +def _remove_lossy_copies( + source_path: str, siblings: list[str], output: OutputConfig, config: Config +) -> None: + """ + Remove copies of *siblings* from *output*: the lossless *source_path* wins. + + A copy under a different name (``X.mp3`` beside the ``X.m4a`` encode) + always goes. A copy under the encode's own name (``X.m4a`` copied before + ``X.flac`` arrived) goes only while it is still byte-identical to the + lossy source, so a finished encode is never thrown away. + """ + own_name, _ = plan_output(source_path, output) + own_path = get_output_file_path(source_path, config.source_path, output.path, own_name) + for sibling in siblings: + name, action = plan_output(sibling, output) + if action != "copy": + continue + copy_path = get_output_file_path(sibling, config.source_path, output.path, name) + try: + if not os.path.exists(copy_path): + continue + if copy_path == own_path and not filecmp.cmp(sibling, copy_path, shallow=True): + continue + logger.info("✘ remove lossy copy %s (lossless %s wins)", copy_path, source_path) + os.remove(copy_path) + except OSError as e: + logger.error("Failed to remove lossy copy %s: %s", copy_path, e) + + def _has_other_source(source_path: str) -> bool: """Check if another audio source with the same stem exists.""" stem = Path(source_path).stem @@ -254,6 +297,8 @@ def _process_outputs(source_path: str, config: Config, force: bool) -> bool: logger.debug("Skipping lossy %s - lossless source exists", source_path) return True + siblings = _lossy_siblings(source_path) if is_lossless(source_path) else [] + ok = True for output in config.outputs: if safety_guard_active(config): @@ -263,6 +308,8 @@ def _process_outputs(source_path: str, config: Config, force: bool) -> bool: out_path = get_output_file_path( source_path, config.source_path, output.path, out_filename, ) + if siblings: + _remove_lossy_copies(source_path, siblings, output, config) if not force and os.path.exists(out_path): continue @@ -274,7 +321,7 @@ def _process_outputs(source_path: str, config: Config, force: bool) -> bool: finalize = None if output.codec in _MP4_CODECS: def finalize(tmp: str, src: str = source_path) -> None: - copy_freeform_tags(src, tmp) + copy_mp4_tags(src, tmp) cmd = build_ffmpeg_command(source_path, out_path, output) rc = atomic_ffmpeg_encode(cmd, out_path, finalize=finalize) diff --git a/tests/test_encoder.py b/tests/test_encoder.py index c91fcef..b0c71dd 100644 --- a/tests/test_encoder.py +++ b/tests/test_encoder.py @@ -12,12 +12,12 @@ from audio_transcode_watcher.config import OutputConfig from audio_transcode_watcher.encoder import ( - FREEFORM_TAGS, + MP4_TAG_ATOMS, _cleanup_temp, _remove_artwork_from_command, atomic_ffmpeg_encode, build_ffmpeg_command, - copy_freeform_tags, + copy_mp4_tags, ) @@ -572,6 +572,39 @@ def _make_flac(path: Path, seconds: int = 3) -> None: "ALBUMSORT": "At Last!", } +# The atoms MusicBrainz Picard writes; every common reader knows them. +EXPECTED_ATOMS = { + "REPLAYGAIN_TRACK_GAIN": "----:com.apple.iTunes:replaygain_track_gain", + "REPLAYGAIN_TRACK_PEAK": "----:com.apple.iTunes:replaygain_track_peak", + "REPLAYGAIN_ALBUM_GAIN": "----:com.apple.iTunes:replaygain_album_gain", + "REPLAYGAIN_ALBUM_PEAK": "----:com.apple.iTunes:replaygain_album_peak", + "MUSICBRAINZ_TRACKID": "----:com.apple.iTunes:MusicBrainz Track Id", + "MUSICBRAINZ_ALBUMID": "----:com.apple.iTunes:MusicBrainz Album Id", + "MUSICBRAINZ_ARTISTID": "----:com.apple.iTunes:MusicBrainz Artist Id", + "MUSICBRAINZ_ALBUMARTISTID": "----:com.apple.iTunes:MusicBrainz Album Artist Id", + "MUSICBRAINZ_RELEASEGROUPID": "----:com.apple.iTunes:MusicBrainz Release Group Id", + "ISRC": "----:com.apple.iTunes:ISRC", + "LABEL": "----:com.apple.iTunes:LABEL", + "CATALOGNUMBER": "----:com.apple.iTunes:CATALOGNUMBER", + "ARTISTSORT": "soar", + "ALBUMARTISTSORT": "soaa", + "ALBUMSORT": "soal", +} + + +def _atom_text(tags, atom): + value = tags[atom][0] + return value if isinstance(value, str) else bytes(value).decode() + + +class TestMp4TagAtoms: + """The atom names, checked without ffmpeg.""" + + def test_atom_names_match_picard(self): + assert MP4_TAG_ATOMS == EXPECTED_ATOMS + assert set(REAL_TAG_VALUES) == set(MP4_TAG_ATOMS) + + needs_ffmpeg = pytest.mark.skipif( shutil.which("ffmpeg") is None, reason="ffmpeg is not installed; the real-file tag checks need it", @@ -582,11 +615,8 @@ def _make_flac(path: Path, seconds: int = 3) -> None: class TestFreeformTagsRealFiles: """Tags the MP4 atoms cannot hold survive an ALAC or AAC encode.""" - def test_list_covers_the_required_tags(self): - assert set(REAL_TAG_VALUES) <= set(FREEFORM_TAGS) - @pytest.mark.parametrize("codec", ["alac", "aac"]) - def test_flac_tags_land_as_itunes_freeform_atoms(self, tmp_path, codec): + def test_flac_tags_land_in_picard_atoms(self, tmp_path, codec): from mutagen.flac import FLAC from mutagen.mp4 import MP4 @@ -603,15 +633,18 @@ def test_flac_tags_land_as_itunes_freeform_atoms(self, tmp_path, codec): dest = str(tmp_path / codec / "Etta James - At Last.m4a") cmd = build_ffmpeg_command(str(src), dest, out) rc = atomic_ffmpeg_encode( - cmd, dest, finalize=lambda tmp: copy_freeform_tags(str(src), tmp) + cmd, dest, finalize=lambda tmp: copy_mp4_tags(str(src), tmp) ) assert rc == 0 tags = MP4(dest).tags for k, v in REAL_TAG_VALUES.items(): - atom = f"----:com.apple.iTunes:{k}" + atom = EXPECTED_ATOMS[k] assert atom in tags, atom - assert bytes(tags[atom][0]).decode() == v + assert _atom_text(tags, atom) == v + # Sort names are plain text in the standard sort atoms. + assert tags["soar"] == ["James, Etta"] + assert not any(a.startswith("----:com.apple.iTunes:ARTISTSORT") for a in tags) # Standard atoms ffmpeg writes are still there. assert tags["\xa9nam"] == ["At Last"] assert tags["\xa9ART"] == ["Etta James"] @@ -643,7 +676,7 @@ def test_id3_source_tags_are_read(self, tmp_path): "-c:a", "alac", "-f", "mp4", str(dest)], check=True, ) - assert copy_freeform_tags(str(src), str(dest)) == 6 + assert copy_mp4_tags(str(src), str(dest)) == 6 tags = MP4(str(dest)).tags expect = { @@ -655,9 +688,39 @@ def test_id3_source_tags_are_read(self, tmp_path): "ARTISTSORT": "Beatles, The", } for k, v in expect.items(): - assert bytes(tags[f"----:com.apple.iTunes:{k}"][0]).decode() == v + assert _atom_text(tags, EXPECTED_ATOMS[k]) == v def test_source_without_tags_writes_nothing(self, tmp_path): src = tmp_path / "bare.flac" _make_flac(src, seconds=1) - assert copy_freeform_tags(str(src), str(tmp_path / "unused.m4a")) == 0 + assert copy_mp4_tags(str(src), str(tmp_path / "unused.m4a")) == 0 + + +class TestEncoderEdges: + """Small branches of the new encoder code.""" + + def test_source_name_without_input_flag(self, fake_ffmpeg, tmp_path, monkeypatch, caplog): + monkeypatch.setenv("FAKE_RC", "2") + dest = str(tmp_path / "out.m4a") + assert atomic_ffmpeg_encode(["ffmpeg", dest], dest, retry_without_artwork=False) == 2 + assert "FFmpeg failed (rc=2) for ? →" in caplog.text + + def test_finalize_failure_keeps_the_output(self, fake_ffmpeg, tmp_path, caplog): + cmd, dest = _alac_cmd(tmp_path) + + def broken(_tmp): + raise ValueError("bad tags") + + assert atomic_ffmpeg_encode(cmd, dest, finalize=broken) == 0 + assert os.path.isfile(dest) + assert "Post-encode step failed" in caplog.text + + def test_finalize_runs_on_the_temp_file(self, fake_ffmpeg, tmp_path): + cmd, dest = _alac_cmd(tmp_path) + seen = [] + assert atomic_ffmpeg_encode(cmd, dest, finalize=seen.append) == 0 + assert seen == [dest + ".tmp__ff"] + + @patch("audio_transcode_watcher.encoder.mutagen.File", return_value=None) + def test_unreadable_source_copies_no_tags(self, _file, tmp_path): + assert copy_mp4_tags(str(tmp_path / "x.flac"), str(tmp_path / "x.m4a")) == 0 diff --git a/tests/test_lyrics.py b/tests/test_lyrics.py index 97f6141..a3a2fc0 100644 --- a/tests/test_lyrics.py +++ b/tests/test_lyrics.py @@ -254,3 +254,15 @@ def test_chown_failure_is_logged_at_debug_and_file_kept(self, _chown, tmp_path, assert os.path.isfile(lrc_path) chown_logs = [r for r in caplog.records if "Could not chown" in r.getMessage()] assert chown_logs and chown_logs[0].levelno == logging.DEBUG + + +class TestLrcModeFailure: + @patch("audio_transcode_watcher.lyrics.os.chmod", side_effect=PermissionError("ro")) + def test_chmod_failure_is_logged_at_debug(self, _chmod, tmp_path, caplog): + audio = tmp_path / "song.flac" + audio.touch() + lrc_path = str(tmp_path / "song.lrc") + with caplog.at_level(logging.DEBUG, logger="audio_transcode_watcher.lyrics"): + assert _write_lrc(lrc_path, GOOD_LRC, "l", "s", owner_of=str(audio)) == lrc_path + msgs = [r for r in caplog.records if "Could not chmod" in r.getMessage()] + assert msgs and msgs[0].levelno == logging.DEBUG diff --git a/tests/test_sync.py b/tests/test_sync.py index 7f1afdc..f9e7968 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -1455,3 +1455,227 @@ def test_corrupt_flac_writes_no_output_and_is_remembered(self, temp_dir): assert list(out.iterdir()) == [] assert str(bad) in sync_mod._failed_sources + + +class TestLosslessWinsAtProcessTime: + """A lossless source that arrives after its lossy twin replaces the copy.""" + + @staticmethod + def _fake_encode(cmd, dest, **_kwargs): + Path(dest).parent.mkdir(parents=True, exist_ok=True) + Path(dest).write_bytes(b"ALAC encode of the FLAC") + return 0 + + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_m4a_copied_then_flac_arrives(self, _guard, temp_dir): + source, alac = _dirs(temp_dir, "source", "alac") + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(alac))], + fetch_lyrics=False, + ) + lossy = source / "X.m4a" + lossy.write_bytes(b"lossy AAC from the bot") + + with patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode") as enc: + process_source_file(str(lossy), config, check_stable=False) + enc.assert_not_called() + assert (alac / "X.m4a").read_bytes() == b"lossy AAC from the bot" + + flac = source / "X.flac" + flac.write_bytes(b"flac") + with patch( + "audio_transcode_watcher.sync.atomic_ffmpeg_encode", side_effect=self._fake_encode + ) as enc: + process_source_file(str(flac), config, force=False, check_stable=False) + + enc.assert_called_once() + assert enc.call_args.args[1] == str(alac / "X.m4a") + assert (alac / "X.m4a").read_bytes() == b"ALAC encode of the FLAC" + + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_mp3_copy_removed_when_flac_arrives(self, _guard, temp_dir): + source, alac = _dirs(temp_dir, "source", "alac") + (source / "X.mp3").write_bytes(b"mp3") + (alac / "X.mp3").write_bytes(b"mp3") + (source / "X.flac").write_bytes(b"flac") + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(alac))], + fetch_lyrics=False, + ) + + with patch( + "audio_transcode_watcher.sync.atomic_ffmpeg_encode", side_effect=self._fake_encode + ): + process_source_file(str(source / "X.flac"), config, check_stable=False) + + assert sorted(p.name for p in alac.iterdir()) == ["X.m4a"] + + @patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode") + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_finished_encode_is_not_redone_on_every_scan(self, _guard, mock_encode, temp_dir): + source, alac = _dirs(temp_dir, "source", "alac") + (source / "X.m4a").write_bytes(b"lossy AAC from the bot") + (source / "X.flac").write_bytes(b"flac") + (alac / "X.m4a").write_bytes(b"ALAC encode of the FLAC") + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(alac))], + fetch_lyrics=False, + ) + + process_source_file(str(source / "X.flac"), config, check_stable=False) + + mock_encode.assert_not_called() + assert (alac / "X.m4a").read_bytes() == b"ALAC encode of the FLAC" + + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_remove_failure_is_logged(self, _guard, temp_dir, caplog): + source, alac = _dirs(temp_dir, "source", "alac") + (source / "X.mp3").write_bytes(b"mp3") + (alac / "X.mp3").write_bytes(b"mp3") + (source / "X.flac").write_bytes(b"flac") + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(alac))], + fetch_lyrics=False, + ) + + with patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode", return_value=0), \ + patch("audio_transcode_watcher.sync.os.remove", side_effect=PermissionError("ro")): + process_source_file(str(source / "X.flac"), config, check_stable=False) + + assert "Failed to remove lossy copy" in caplog.text + + +class TestErrorPaths: + """Failure branches of the new sync code: logged, never fatal.""" + + def _config(self, source, out, **kw): + return Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(out))], + fetch_lyrics=False, + **kw, + ) + + def test_failure_memory_ignores_missing_files(self, temp_dir): + missing = str(Path(temp_dir) / "gone.flac") + sync_mod._remember_failure(missing) + assert missing not in sync_mod._failed_sources + sync_mod._failed_sources[missing] = 1.0 + assert sync_mod._is_known_failure(missing) is False + + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_failed_copy_leaves_no_temp(self, _guard, temp_dir, caplog): + source, out = _dirs(temp_dir, "source", "alac") + mp3 = source / "Song.mp3" + mp3.write_bytes(b"mp3") + config = self._config(source, out) + + with patch("audio_transcode_watcher.sync.os.replace", side_effect=OSError("disk full")): + process_source_file(str(mp3), config, check_stable=False) + + assert list(out.iterdir()) == [] + assert "Copy failed" in caplog.text + + @patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode") + def test_safety_guard_between_outputs_stops_the_loop(self, mock_encode, temp_dir): + source, out = _dirs(temp_dir, "source", "alac") + flac = source / "Song.flac" + flac.touch() + config = self._config(source, out) + # False for process_source_file's own check, True inside the loop. + with patch("audio_transcode_watcher.sync.safety_guard_active", side_effect=[False, True]): + process_source_file(str(flac), config, check_stable=False) + mock_encode.assert_not_called() + + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_delete_outputs_logs_remove_failure(self, _guard, temp_dir, caplog): + source, out = _dirs(temp_dir, "source", "alac") + (out / "Song.m4a").touch() + config = self._config(source, out) + with patch("audio_transcode_watcher.sync.os.remove", side_effect=PermissionError("ro")): + delete_outputs(str(source / "Song.flac"), config) + assert "Failed to remove" in caplog.text + + def test_initial_sync_logs_worker_errors_and_cleaned_temps(self, temp_dir, caplog): + source, out = _dirs(temp_dir, "source", "alac") + (source / "Song.flac").touch() + (out / "old.m4a.tmp__ff").touch() + config = self._config(source, out, parallel_workers=1) + with patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False), \ + patch("audio_transcode_watcher.sync.process_source_file", side_effect=RuntimeError("boom")), \ + caplog.at_level("INFO", logger="audio_transcode_watcher.sync"): + initial_sync(config) + assert "Cleaned up 1 stale temp files" in caplog.text + assert "Error processing" in caplog.text and "boom" in caplog.text + + @patch("audio_transcode_watcher.sync._cleanup_orphans") + @patch("audio_transcode_watcher.sync.process_source_file") + def test_initial_sync_skips_orphans_when_guard_trips_late(self, _proc, mock_orphans, temp_dir, caplog): + source, out = _dirs(temp_dir, "source", "alac") + (source / "Song.flac").touch() + config = self._config(source, out, parallel_workers=1) + with patch("audio_transcode_watcher.sync.safety_guard_active", side_effect=[False, True]), \ + caplog.at_level("INFO", logger="audio_transcode_watcher.sync"): + initial_sync(config, periodic=True) + mock_orphans.assert_not_called() + assert "Periodic sync complete (partial)." in caplog.text + + def test_orphans_skip_when_source_scan_fails(self, temp_dir, caplog): + source, out = _dirs(temp_dir, "source", "alac") + (out / "Orphan.m4a").touch() + with patch("audio_transcode_watcher.sync.walk_audio_files", side_effect=OSError("io")): + _cleanup_orphans(self._config(source, out)) + assert (out / "Orphan.m4a").exists() + assert "Failed to scan source for orphan cleanup" in caplog.text + + def test_orphans_skip_when_source_is_empty(self, temp_dir): + source, out = _dirs(temp_dir, "source", "alac") + (out / "Orphan.m4a").touch() + _cleanup_orphans(self._config(source, out)) + assert (out / "Orphan.m4a").exists() + + def test_unreadable_age_counts_as_young(self, temp_dir): + source, out = _dirs(temp_dir, "source", "alac") + (source / "Song.flac").touch() + (out / "Song.mp3").touch() # orphan once age is known + (out / "Gone.m4a").touch() + with patch("audio_transcode_watcher.sync._age_seconds", side_effect=OSError("stat")): + _cleanup_orphans(self._config(source, out)) + assert (out / "Song.mp3").exists() and (out / "Gone.m4a").exists() + + def test_orphan_remove_failure_is_logged(self, temp_dir, caplog): + source, out = _dirs(temp_dir, "source", "alac") + (source / "Song.flac").touch() + (out / "Gone.m4a").touch() + with patch("audio_transcode_watcher.sync.os.remove", side_effect=PermissionError("ro")): + _cleanup_orphans(self._config(source, out)) + assert "Failed to remove" in caplog.text + + def test_orphan_pass_leaves_other_files_alone(self, temp_dir): + source, out = _dirs(temp_dir, "source", "alac") + (source / "Song.flac").touch() + (out / "cover.jpg").touch() + (out / "Song.m4a.tmp__ff").touch() + _cleanup_orphans(self._config(source, out)) + assert (out / "cover.jpg").exists() and (out / "Song.m4a.tmp__ff").exists() + + def test_orphan_output_scan_failure_is_logged(self, temp_dir, caplog): + source, out = _dirs(temp_dir, "source", "alac") + (source / "Song.flac").touch() + real_walk = os.walk + raised = [] + + def walk(path, *a, **k): + # os is shared, so this also sees remove_empty_dirs: fail once. + if str(path) == str(out) and not raised: + raised.append(path) + raise OSError("output gone") + return real_walk(path, *a, **k) + + with patch("audio_transcode_watcher.sync.os.walk", side_effect=walk): + _cleanup_orphans(self._config(source, out)) + assert "Failed to scan" in caplog.text and "for orphans" in caplog.text From c666b8de9071b7b75213cc37896332f28b0b73dc Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:58:04 +0200 Subject: [PATCH 5/6] fix: time out hung ffmpeg runs, reject bare-domain ad lyrics, validate the interval type From the CodeRabbit review: a hung ffmpeg is killed after 30 minutes and recorded as a failed source, lyrics made only of bare domains such as site.co are rejected like any other advertisement, and a non-numeric sync_interval_seconds raises the normal configuration error instead of a TypeError. --- CHANGELOG.md | 1 + src/audio_transcode_watcher/config.py | 5 +++-- src/audio_transcode_watcher/encoder.py | 10 +++++++++- src/audio_transcode_watcher/lyrics.py | 3 ++- tests/test_config.py | 10 ++++++++++ tests/test_encoder.py | 13 +++++++++++++ tests/test_lyrics.py | 17 +++++++++++++++++ 7 files changed, 55 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7082fe9..e18659a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ All notable changes to this project are documented here. The format follows [Kee ### Fixed - A source that does not decode cleanly now fails instead of producing a short copy. FFmpeg runs with `-xerror`, and a decode error on its stderr counts as a failure even when it exits 0. A corrupt FLAC used to come out two seconds short with nothing in the logs. Now the error is logged with the file name, no output is written, and the file is not tried again until its modification time changes. +- An ffmpeg run that takes longer than 30 minutes is killed and counts as a failure, so one hung encode can no longer stall every later sync. - The retry without cover art only runs when the error is about the picture stream. It used to run on any error that mentioned "decode". - The periodic sync could delete an output the watcher had just written, because it judged orphans from a source list built before the file arrived. The orphan pass now reads the source folder again, and it leaves alone any file younger than 120 seconds or whose source is being processed. - Startup and periodic cleanup no longer delete `.tmp__ff` files younger than 10 minutes, which could belong to an encode still running. diff --git a/src/audio_transcode_watcher/config.py b/src/audio_transcode_watcher/config.py index 22afbeb..681fbd1 100644 --- a/src/audio_transcode_watcher/config.py +++ b/src/audio_transcode_watcher/config.py @@ -112,8 +112,9 @@ def __post_init__(self) -> None: if not self.source_path: raise ValueError("source_path is required") - if self.sync_interval_seconds <= 0: - raise ValueError("sync_interval_seconds must be greater than 0") + interval = self.sync_interval_seconds + if isinstance(interval, bool) or not isinstance(interval, (int, float)) or interval <= 0: + raise ValueError("sync_interval_seconds must be a number greater than 0") if not self.outputs: raise ValueError("At least one output is required") diff --git a/src/audio_transcode_watcher/encoder.py b/src/audio_transcode_watcher/encoder.py index 6cd069d..953a733 100644 --- a/src/audio_transcode_watcher/encoder.py +++ b/src/audio_transcode_watcher/encoder.py @@ -19,6 +19,11 @@ # Exit code returned when ffmpeg exits 0 but reported a decode error. DECODE_ERROR_RC = 69 +# A single encode that runs longer than this (seconds) is killed and counts +# as a failure, so a hung ffmpeg cannot stall the sync for good. +FFMPEG_TIMEOUT = 1800 +TIMEOUT_RC = 124 + # stderr text that means the audio did not decode cleanly, even when # ffmpeg exits 0 (a corrupt FLAC used to come out seconds short). _DECODE_ERROR_HINTS = ("decode_frame() failed", "invalid", "error while decoding") @@ -208,7 +213,10 @@ def _is_artwork_error(stderr: str) -> bool: def _run_ffmpeg(cmd: list[str]) -> tuple[int, str]: """Run ffmpeg; a decode error on stderr counts as a failure even with rc 0.""" - proc = subprocess.run(cmd, capture_output=True) + try: + proc = subprocess.run(cmd, capture_output=True, timeout=FFMPEG_TIMEOUT) + except subprocess.TimeoutExpired: + return TIMEOUT_RC, f"ffmpeg timed out after {FFMPEG_TIMEOUT} s" stderr = proc.stderr.decode("utf-8", errors="ignore") if proc.stderr else "" rc = proc.returncode if rc == 0 and _decode_error(stderr): diff --git a/src/audio_transcode_watcher/lyrics.py b/src/audio_transcode_watcher/lyrics.py index 095c374..ef63f78 100644 --- a/src/audio_transcode_watcher/lyrics.py +++ b/src/audio_transcode_watcher/lyrics.py @@ -16,7 +16,8 @@ # A synced line: one or more [mm:ss.xx] stamps, then the text. _TIMED_LINE = re.compile(r"^\s*(?:\[\d{1,3}:\d{1,2}(?:[.:]\d{1,3})?\])+(.*)$") -_URL = re.compile(r"(https?://|www\.)\S+|\b\S+\.(com|net|org|io|me|ru|cn)\b", re.IGNORECASE) +# A URL or a bare domain (``site.co``, ``lyrics.example.xyz``). +_URL = re.compile(r"(https?://|www\.)\S+|\b[\w-]+(\.[\w-]+)*\.[a-z]{2,24}\b", re.IGNORECASE) _TOKEN = re.compile(r"\w+|[^\w\s]+") # Fewer timed lines than this is not a usable synced lyric. diff --git a/tests/test_config.py b/tests/test_config.py index 41b5843..65e70cf 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -273,3 +273,13 @@ def test_no_warning_without_old_keys(self, caplog): with caplog.at_level(logging.WARNING, logger="audio_transcode_watcher.config"): Config.from_dict(_settings_config({"fetch_lyrics": True})) assert not caplog.records + + +class TestSyncIntervalTypes: + @pytest.mark.parametrize("bad", ["300", True, None, -5]) + def test_rejects_non_numbers_with_value_error(self, bad): + with pytest.raises(ValueError, match="sync_interval_seconds"): + Config.from_dict(_settings_config({"sync_interval_seconds": bad})) + + def test_accepts_float(self): + assert Config.from_dict(_settings_config({"sync_interval_seconds": 90.5})).sync_interval_seconds == 90.5 diff --git a/tests/test_encoder.py b/tests/test_encoder.py index b0c71dd..bf05cee 100644 --- a/tests/test_encoder.py +++ b/tests/test_encoder.py @@ -724,3 +724,16 @@ def test_finalize_runs_on_the_temp_file(self, fake_ffmpeg, tmp_path): @patch("audio_transcode_watcher.encoder.mutagen.File", return_value=None) def test_unreadable_source_copies_no_tags(self, _file, tmp_path): assert copy_mp4_tags(str(tmp_path / "x.flac"), str(tmp_path / "x.m4a")) == 0 + + +class TestFfmpegTimeout: + def test_hung_ffmpeg_fails_and_writes_nothing(self, tmp_path, caplog): + cmd, dest = _alac_cmd(tmp_path) + with patch( + "subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="ffmpeg", timeout=1800) + ) as run: + rc = atomic_ffmpeg_encode(cmd, dest) + assert rc == 124 + assert run.call_args.kwargs["timeout"] == 1800 + assert not os.path.exists(dest) + assert "timed out" in caplog.text diff --git a/tests/test_lyrics.py b/tests/test_lyrics.py index a3a2fc0..4cb40b0 100644 --- a/tests/test_lyrics.py +++ b/tests/test_lyrics.py @@ -266,3 +266,20 @@ def test_chmod_failure_is_logged_at_debug(self, _chmod, tmp_path, caplog): assert _write_lrc(lrc_path, GOOD_LRC, "l", "s", owner_of=str(audio)) == lrc_path msgs = [r for r in caplog.records if "Could not chmod" in r.getMessage()] assert msgs and msgs[0].levelno == logging.DEBUG + + +class TestBareDomainAds: + @patch("audio_transcode_watcher.lyrics.syncedlyrics") + @patch("audio_transcode_watcher.lyrics.extract_metadata") + def test_four_bare_domain_lines_are_rejected(self, mock_meta, mock_sl, tmp_path): + audio = tmp_path / "Band - Song.flac" + audio.touch() + mock_meta.return_value = ("Band", "Song") + mock_sl.search.return_value = ( + "[00:01.00] lyricsite.co\n" + "[00:02.00] get-the-app.xyz\n" + "[00:03.00] songs.example.club\n" + "[00:04.00] more.lyrics.info\n" + ) + assert fetch_lyrics_for_file(str(audio)) is None + assert list(tmp_path.glob("*.lrc")) == [] From 61fcc78026e320488611cdc89043a6435c99ac8d Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:14:18 +0200 Subject: [PATCH 6/6] fix: check frame checksums so a one-bit FLAC corruption fails, and test on the image's FFmpeg FFmpeg 7.1, the one the Docker image ships, decodes a FLAC with a single flipped bit silently and exits 0. With -err_detect crccheck+explode the decoder verifies each frame checksum and stops on a mismatch, which -xerror turns into a failure. CI's test jobs now run in python:3.14-slim, the image's base, so the real-file tests exercise the same FFmpeg as production. Ubuntu's FFmpeg 6.1 decoded even the heavily corrupted test FLAC without an error. --- .github/workflows/release.yml | 17 ++++++++--------- .github/workflows/tests.yml | 17 ++++++++--------- CHANGELOG.md | 1 + docs/how-it-works.md | 2 +- src/audio_transcode_watcher/encoder.py | 4 ++++ tests/test_encoder.py | 8 ++++++++ tests/test_sync.py | 26 ++++++++++++++++++++++++++ 7 files changed, 56 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8f9704..f9e1c77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,17 +12,16 @@ env: jobs: test: runs-on: ubuntu-latest + # The Docker image's base, so the real-file tests run against the FFmpeg + # the image ships. Ubuntu's FFmpeg 6.1 misses corruption that 7.1 catches. + container: python:3.14-slim steps: - - uses: actions/checkout@v7 - - - name: Set up Python 3.14 - uses: actions/setup-python@v7 - with: - python-version: '3.14' - cache: 'pip' + - name: Install FFmpeg and the tools checkout and Codecov need + run: | + apt-get update + apt-get install -y --no-install-recommends ffmpeg git curl gnupg ca-certificates - - name: Install FFmpeg (the real-file tests need it) - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg + - uses: actions/checkout@v7 - name: Install dependencies run: | diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 772709b..a358f74 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,17 +10,16 @@ on: jobs: test: runs-on: ubuntu-latest + # The Docker image's base, so the real-file tests run against the FFmpeg + # the image ships. Ubuntu's FFmpeg 6.1 misses corruption that 7.1 catches. + container: python:3.14-slim steps: - - uses: actions/checkout@v7 - - - name: Set up Python 3.14 - uses: actions/setup-python@v7 - with: - python-version: '3.14' - cache: 'pip' + - name: Install FFmpeg and the tools checkout and Codecov need + run: | + apt-get update + apt-get install -y --no-install-recommends ffmpeg git curl gnupg ca-certificates - - name: Install FFmpeg (the real-file tests need it) - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg + - uses: actions/checkout@v7 - name: Install dependencies run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index e18659a..f5cfa52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ All notable changes to this project are documented here. The format follows [Kee ### Fixed - A source that does not decode cleanly now fails instead of producing a short copy. FFmpeg runs with `-xerror`, and a decode error on its stderr counts as a failure even when it exits 0. A corrupt FLAC used to come out two seconds short with nothing in the logs. Now the error is logged with the file name, no output is written, and the file is not tried again until its modification time changes. +- FFmpeg also checks every frame checksum of the input (`-err_detect crccheck+explode`). Without it, the FFmpeg 7.1 in the image decoded a FLAC with a single flipped bit without a word. - An ffmpeg run that takes longer than 30 minutes is killed and counts as a failure, so one hung encode can no longer stall every later sync. - The retry without cover art only runs when the error is about the picture stream. It used to run on any error that mentioned "decode". - The periodic sync could delete an output the watcher had just written, because it judged orphans from a source list built before the file arrived. The orphan pass now reads the source folder again, and it leaves alone any file younger than 120 seconds or whose source is being processed. diff --git a/docs/how-it-works.md b/docs/how-it-works.md index a6209da..daf4900 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -13,7 +13,7 @@ Lossy sources are copied rather than encoded where that keeps quality honest; se ## Corrupt sources -FFmpeg runs with `-xerror`. 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 file is not tried again until its modification time changes, or the service restarts. +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 file is not tried again until its modification time changes, or the service restarts. ## Recursive Directory Support diff --git a/src/audio_transcode_watcher/encoder.py b/src/audio_transcode_watcher/encoder.py index 953a733..cead8fd 100644 --- a/src/audio_transcode_watcher/encoder.py +++ b/src/audio_transcode_watcher/encoder.py @@ -99,8 +99,12 @@ def build_ffmpeg_command( dest = nfc_path(dest) # Common arguments + # -err_detect crccheck+explode makes the decoder verify each frame's + # checksum and stop on a mismatch. Without it ffmpeg 7.1 (the image's) + # decodes a FLAC with one flipped bit silently. cmd = [ "ffmpeg", "-loglevel", "error", "-xerror", "-y", + "-err_detect", "crccheck+explode", "-i", source, "-map", "0:a:0", # First audio stream ] diff --git a/tests/test_encoder.py b/tests/test_encoder.py index bf05cee..3c1ef96 100644 --- a/tests/test_encoder.py +++ b/tests/test_encoder.py @@ -480,6 +480,14 @@ def test_every_command_uses_xerror(self, codec): out = OutputConfig(name=codec, codec=codec, path="/out") assert "-xerror" in build_ffmpeg_command("/in/a.flac", "/out/a.x", out) + @pytest.mark.parametrize("codec", ["alac", "aac", "mp3", "opus", "flac", "wav"]) + def test_every_command_checks_frame_crcs_on_input(self, codec): + out = OutputConfig(name=codec, codec=codec, path="/out") + cmd = build_ffmpeg_command("/in/a.flac", "/out/a.x", out) + i = cmd.index("-err_detect") + assert cmd[i + 1] == "crccheck+explode" + assert i < cmd.index("-i") # an input option, so it must come first + def test_clean_run_succeeds(self, fake_ffmpeg, tmp_path): cmd, dest = _alac_cmd(tmp_path) assert atomic_ffmpeg_encode(cmd, dest) == 0 diff --git a/tests/test_sync.py b/tests/test_sync.py index f9e7968..0158572 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -1456,6 +1456,32 @@ def test_corrupt_flac_writes_no_output_and_is_remembered(self, temp_dir): assert list(out.iterdir()) == [] assert str(bad) in sync_mod._failed_sources + def test_one_flipped_bit_is_caught(self, temp_dir): + """ffmpeg 7.1 decodes this silently unless frame CRCs are checked.""" + import subprocess + + source, out = _dirs(temp_dir, "source", "alac") + good = source / "good.tmp.flac" + subprocess.run( + ["ffmpeg", "-loglevel", "error", "-y", "-f", "lavfi", + "-i", "sine=frequency=440:duration=5", "-c:a", "flac", str(good)], + check=True, + ) + data = bytearray(good.read_bytes()) + good.unlink() + data[len(data) // 2] ^= 0x01 + bad = source / "One Bit - Flipped.flac" + bad.write_bytes(bytes(data)) + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(out))], + fetch_lyrics=False, + ) + + process_source_file(str(bad), config, check_stable=False) + + assert list(out.iterdir()) == [] + class TestLosslessWinsAtProcessTime: """A lossless source that arrives after its lossy twin replaces the copy."""