diff --git a/CHANGELOG.md b/CHANGELOG.md index f5cfa52..5485cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ 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.1] - 2026-10-04 + +### Added + +- Per-output `channels` and `max_sample_rate`. An `aac` output now defaults to `channels: 2` and `max_sample_rate: 48000`, so the AAC copies play everywhere a phone, AirPods or CarPlay can take them: anything with more than two channels is downmixed to stereo, 88.2 and 176.4 kHz sources become 44.1 kHz, and 96 and 192 kHz sources become 48 kHz. Nothing is ever upsampled or upmixed. A lossy AAC source that exceeds the limits is transcoded rather than copied. Other codecs have no limit unless they set one, and `0` turns a limit off. ALAC is unchanged. + +### Fixed + +- A lossless file that arrives after a lossy one of the same name now replaces the lossy file's transcode too, not only its copy. An Ogg transcoded into an MP3 output as `X.mp3` used to block the later `X.flac` from ever being encoded there. Each output folder now keeps `.atw-manifest.json`, recording which source made each file and how (encode, copy or transcode). A row is trusted only while the output still has the size and modification time it was written with. A missing or unreadable manifest means "unknown", which behaves exactly as before, and the orphan pass drops rows whose file is gone. + +### Upgrading + +- Existing AAC files are not rebuilt by the upgrade. They are re-encoded only when their source changes, or with `force_reencode: true` on startup. To rebuild just the hi-res and multichannel ones, delete the AAC files above 48 kHz or 2 channels and the next periodic sync encodes them again: `docker exec audio_transcoder find /music/aac -name '*.m4a' -exec sh -c 'ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate,channels -of default=nw=1 "$1" | awk -F= "/^sample_rate/{r=\$2} /^channels/{c=\$2} END{exit !(r>48000||c>2)}" && rm -v "$1"' _ {} \;` + +### Security + +- `urllib3` is now required at 2.8.0 or newer, for GHSA-vxq7-64xx-v4gw, GHSA-8988-9cw3-xx77 and GHSA-gh4c-6fx4-qh6g. It comes in through syncedlyrics and requests; the image installs from `pyproject.toml`, so the floor is declared there and `uv.lock` resolves 2.8.0. + ## [0.6.0] - 2026-10-04 ### Changed @@ -35,4 +53,5 @@ All notable changes to this project are documented here. The format follows [Kee - 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.1]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.1 [0.6.0]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.0 diff --git a/README.md b/README.md index e32e635..8d19682 100644 --- a/README.md +++ b/README.md @@ -35,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.6.0 + drumsergio/audio-transcoder:0.6.1 ``` 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`. diff --git a/config.example.yaml b/config.example.yaml index dd933e0..dc76441 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,4 +1,4 @@ -# Audio Transcode Watcher Configuration (0.6.0) +# Audio Transcode Watcher Configuration (0.6.1) # ====================================== # # This configuration file defines the source folder and output destinations @@ -28,12 +28,18 @@ outputs: path: /music/mp3 include_artwork: true - # AAC 256kbps for modern devices + # AAC 256kbps for phones, AirPods and CarPlay - name: aac-256 codec: aac bitrate: 256k path: /music/aac include_artwork: true + # Portable limits. These are the AAC defaults, shown for reference: + # more than 2 channels are downmixed to stereo, and rates above 48 kHz + # drop to 44.1 kHz (88.2/176.4 kHz sources) or 48 kHz (96/192 kHz). + # Lower rates are never upsampled. Any codec can opt in; 0 = no limit. + channels: 2 + max_sample_rate: 48000 # Optional settings settings: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c791413..b087bbf 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap -Current version: **0.6.0** +Current version: **0.6.1** ## Completed @@ -12,6 +12,7 @@ Current version: **0.6.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) +- **Portable AAC**: stereo downmix and a 48 kHz cap for AAC outputs, per-output `channels` and `max_sample_rate` (v0.6.1) ## v0.7.0 -- Quality of Life diff --git a/docs/configuration.md b/docs/configuration.md index 6a514e8..4fec650 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -75,7 +75,30 @@ 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. 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. +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 or transcoded, that file is replaced by an encode of the lossless one as soon as it is processed. To tell those files apart, each output folder keeps a hidden `.atw-manifest.json` recording which source made each file and whether it was an encode, a copy or a transcode. Deleting it is safe: files it does not know about are treated as they were before 0.6.1. + +## Portable limits (channels and sample rate) + +Each output can cap the channel count and the sample rate: + +```yaml + - name: aac-256 + codec: aac + bitrate: 256k + path: /music/aac + channels: 2 # downmix anything with more channels to stereo + max_sample_rate: 48000 # resample anything above 48 kHz +``` + +`aac` outputs get `channels: 2` and `max_sample_rate: 48000` when they don't set them, because they are for phones, AirPods and CarPlay. Every other codec has no limit unless you set one, and `0` turns a limit off (also for `aac`). + +The sample rate never goes up, and a rate above the cap drops within its own family: 88.2 and 176.4 kHz become 44.1 kHz, 96 and 192 kHz become 48 kHz. A rate from neither family goes to the cap. A mono or stereo source is never upmixed. A lossy source that would normally be copied into the output (an `.m4a` into `aac`) is transcoded instead when it exceeds a limit. ALAC and the other lossless outputs keep the source's rate and channels. + +Changing these settings does not rebuild files that already exist. They are re-encoded when their source changes, or on startup with `force_reencode: true`. To rebuild only the files above the limits, delete them and let the next periodic sync encode them again: + +```bash +docker exec audio_transcoder find /music/aac -name '*.m4a' -exec sh -c 'ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate,channels -of default=nw=1 "$1" | awk -F= "/^sample_rate/{r=\$2} /^channels/{c=\$2} END{exit !(r>48000||c>2)}" && rm -v "$1"' _ {} \; +``` ## Tags in ALAC and AAC outputs diff --git a/docs/getting-started.md b/docs/getting-started.md index a8e6cd0..ecccdb6 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.6.0 + image: drumsergio/audio-transcoder:0.6.1 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.6.0 + drumsergio/audio-transcoder:0.6.1 ``` diff --git a/docs/how-it-works.md b/docs/how-it-works.md index daf4900..09c193d 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -11,6 +11,10 @@ Lossy sources are copied rather than encoded where that keeps quality honest; see [Source formats](configuration.md#source-formats). +## Provenance manifest + +Each output folder holds `.atw-manifest.json`, a map from each output file to the source that made it (path, size, modification time) and how: `encode`, `copy` or `transcode`. It is written in batches at most every 5 seconds and at the end of each sync. It is only used to replace a file made from a lossy source once a lossless source of the same name appears. A missing or unreadable manifest just means "unknown". + ## Corrupt sources 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. diff --git a/pyproject.toml b/pyproject.toml index 886a4d3..11d62fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "audio-transcode-watcher" -version = "0.6.0" +version = "0.6.1" description = "Watch a source folder and automatically transcode audio files to multiple formats" readme = "README.md" @@ -30,6 +30,9 @@ dependencies = [ "pyyaml>=6.0", "mutagen>=1.47.0", "syncedlyrics>=1.0.0", + # Transitive via syncedlyrics/requests; pinned up for GHSA-vxq7-64xx-v4gw, + # GHSA-8988-9cw3-xx77 and GHSA-gh4c-6fx4-qh6g. + "urllib3>=2.8.0", ] [project.optional-dependencies] diff --git a/src/audio_transcode_watcher/__init__.py b/src/audio_transcode_watcher/__init__.py index 527c09c..565c6ae 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.6.0" +__version__ = "0.6.1" diff --git a/src/audio_transcode_watcher/config.py b/src/audio_transcode_watcher/config.py index 681fbd1..ac2dc66 100644 --- a/src/audio_transcode_watcher/config.py +++ b/src/audio_transcode_watcher/config.py @@ -42,45 +42,64 @@ "opus": "128k", } +# Portable-playback limits applied when an output does not set them. +# AAC goes to phones, AirPods and CarPlay: stereo, at most 48 kHz. +# Any codec can opt in by setting channels / max_sample_rate; 0 = no limit. +DEFAULT_CHANNELS = {"aac": 2} +DEFAULT_MAX_SAMPLE_RATE = {"aac": 48000} + @dataclass class OutputConfig: """Configuration for a single output destination.""" - + name: str codec: str path: str bitrate: str = "" include_artwork: bool = True - + channels: int | None = None # Downmix above this many channels; 0 = keep + max_sample_rate: int | None = None # Resample above this rate (Hz); 0 = keep + def __post_init__(self) -> None: """Validate and set defaults after initialization.""" self.codec = self.codec.lower() - + if self.codec not in CODEC_EXTENSIONS: raise ValueError( f"Unknown codec '{self.codec}'. " f"Supported: {', '.join(CODEC_EXTENSIONS.keys())}" ) - + # Set default bitrate for lossy codecs if not self.bitrate and self.codec in DEFAULT_BITRATES: self.bitrate = DEFAULT_BITRATES[self.codec] - + # Artwork not supported for some codecs if self.include_artwork and self.codec not in ARTWORK_SUPPORTED_CODECS: self.include_artwork = False - + + if self.channels is None: + self.channels = DEFAULT_CHANNELS.get(self.codec, 0) + if self.max_sample_rate is None: + self.max_sample_rate = DEFAULT_MAX_SAMPLE_RATE.get(self.codec, 0) + for key in ("channels", "max_sample_rate"): + value = getattr(self, key) + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError( + f"Output '{self.name}': {key} must be a whole number, 0 for no limit" + ) + @property def extension(self) -> str: """Get the file extension for this codec.""" return CODEC_EXTENSIONS[self.codec] - + @property def is_lossless(self) -> bool: """Check if this codec is lossless.""" return self.codec in {"alac", "flac", "wav"} - + @classmethod def from_dict(cls, data: dict[str, Any]) -> OutputConfig: """Create OutputConfig from a dictionary.""" @@ -90,13 +109,15 @@ def from_dict(cls, data: dict[str, Any]) -> OutputConfig: path=data["path"], bitrate=data.get("bitrate", ""), include_artwork=data.get("include_artwork", True), + channels=data.get("channels"), + max_sample_rate=data.get("max_sample_rate"), ) @dataclass class Config: """Main configuration for audio-transcode-watcher.""" - + source_path: str outputs: list[OutputConfig] = field(default_factory=list) force_reencode: bool = False @@ -106,48 +127,52 @@ class Config: min_stable_seconds: float = 1.0 fetch_lyrics: bool = True # Auto-fetch .lrc lyrics via syncedlyrics 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") interval = self.sync_interval_seconds - if isinstance(interval, bool) or not isinstance(interval, (int, float)) or interval <= 0: + 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") - + # Check for duplicate output names names = [o.name for o in self.outputs] if len(names) != len(set(names)): raise ValueError("Duplicate output names detected") - + # Check for duplicate output paths paths = [o.path for o in self.outputs] if len(paths) != len(set(paths)): raise ValueError("Duplicate output paths detected") - + @property def output_paths(self) -> list[str]: """Get list of all output directory paths.""" return [o.path for o in self.outputs] - + def get_output_by_name(self, name: str) -> OutputConfig | None: """Get an output configuration by name.""" for output in self.outputs: if output.name == name: return output return None - + @classmethod 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", ""), outputs=outputs, @@ -159,14 +184,14 @@ def from_dict(cls, data: dict[str, Any]) -> Config: fetch_lyrics=settings.get("fetch_lyrics", True), sync_interval_seconds=settings.get("sync_interval_seconds", 300), ) - + @classmethod def from_yaml_file(cls, path: str) -> Config: """Load configuration from a YAML file.""" with open(path, "r", encoding="utf-8") as f: data = yaml.safe_load(f) return cls.from_dict(data) - + @classmethod def from_json_string(cls, json_str: str) -> Config: """Load configuration from a JSON string.""" @@ -191,11 +216,11 @@ def _warn_deprecated_settings(settings: dict[str, Any]) -> None: def load_config() -> Config: """ Load configuration from environment variables. - + Configuration is loaded from one of these sources (in priority order): 1. CONFIG_FILE env var - path to a YAML config file 2. CONFIG_JSON env var - JSON string with full configuration - + Raises: ValueError: If no valid configuration is found """ @@ -205,12 +230,12 @@ def load_config() -> Config: if not Path(config_file).exists(): raise ValueError(f"CONFIG_FILE not found: {config_file}") return Config.from_yaml_file(config_file) - + # Try CONFIG_JSON config_json = os.getenv("CONFIG_JSON") if config_json: return Config.from_json_string(config_json) - + # No configuration provided raise ValueError( "No configuration found. Set CONFIG_FILE (path to YAML config) " diff --git a/src/audio_transcode_watcher/encoder.py b/src/audio_transcode_watcher/encoder.py index cead8fd..c6480c7 100644 --- a/src/audio_transcode_watcher/encoder.py +++ b/src/audio_transcode_watcher/encoder.py @@ -86,109 +86,169 @@ def build_ffmpeg_command( ) -> list[str]: """ Build an FFmpeg command for transcoding. - + Args: source: Path to source audio file dest: Path to destination file output_config: Output configuration - + Returns: FFmpeg command as list of arguments """ source = nfc_path(source) 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 + "ffmpeg", + "-loglevel", + "error", + "-xerror", + "-y", + "-err_detect", + "crccheck+explode", + "-i", + source, + "-map", + "0:a:0", # First audio stream ] - + # Add video/artwork mapping if enabled if output_config.include_artwork: cmd.extend(["-map", "0:v:0?"]) # First video/image stream (optional) - + # Copy metadata cmd.extend(["-map_metadata", "0"]) - + # Codec-specific options codec = output_config.codec - + if codec == "alac": cmd.extend(["-c:a", "alac"]) if output_config.include_artwork: cmd.extend(["-c:v", "copy"]) # Ensure album_artist is mapped correctly for M4A (aART tag) cmd.extend(["-movflags", "+faststart", "-f", "mp4"]) - + elif codec == "aac": cmd.extend(["-c:a", "aac", "-b:a", output_config.bitrate]) if output_config.include_artwork: cmd.extend(["-c:v", "copy"]) cmd.extend(["-movflags", "+faststart", "-f", "mp4"]) - + elif codec == "mp3": cmd.extend(["-c:a", "libmp3lame", "-b:a", output_config.bitrate]) if output_config.include_artwork: # MP3 needs mjpeg for ID3 APIC artwork cmd.extend(["-c:v", "mjpeg"]) cmd.extend(["-id3v2_version", "3", "-write_id3v2", "1", "-f", "mp3"]) - + elif codec == "opus": cmd.extend(["-c:a", "libopus", "-b:a", output_config.bitrate]) cmd.extend(["-f", "opus"]) - + elif codec == "flac": cmd.extend(["-c:a", "flac"]) if output_config.include_artwork: cmd.extend(["-c:v", "copy"]) cmd.extend(["-f", "flac"]) - + elif codec == "wav": cmd.extend(["-c:a", "pcm_s16le", "-f", "wav"]) - + else: raise ValueError(f"Unsupported codec: {codec}") - + + cmd.extend(portable_limit_args(source, output_config)) cmd.append(dest) return cmd +def target_sample_rate(source_rate: int, max_rate: int) -> int | None: + """ + Return the rate to resample *source_rate* to under *max_rate*, or None. + + Never upsamples. A rate above the cap drops to the highest rate of its + own family that fits: 88.2 and 176.4 kHz go to 44.1 kHz, 96 and 192 kHz + to 48 kHz. A rate from neither family, or a family with no rate under + the cap, goes to the cap itself. + """ + if not max_rate or not source_rate or source_rate <= max_rate: + return None + for base in (44100, 48000): + if source_rate % base == 0: + rate = base + while rate * 2 <= max_rate: + rate *= 2 + while rate > max_rate and rate % 2 == 0: + rate //= 2 + return min(rate, max_rate) + return max_rate + + +def limit_args( + source_rate: int | None, source_channels: int | None, output: OutputConfig +) -> list[str]: + """ffmpeg arguments that apply the output's channel and sample-rate limits.""" + args: list[str] = [] + if output.channels and source_channels and source_channels > output.channels: + args.extend(["-ac", str(output.channels)]) + rate = target_sample_rate(source_rate or 0, output.max_sample_rate or 0) + if rate: + args.extend(["-ar", str(rate)]) + return args + + +def _probe_shape(source: str) -> tuple[int | None, int | None]: + """Return (sample rate, channels) of *source*, or Nones if unreadable.""" + try: + info = mutagen.File(source).info + except Exception: # noqa: BLE001 - any unreadable file means "unknown" + return None, None + return getattr(info, "sample_rate", None), getattr(info, "channels", None) + + +def portable_limit_args(source: str, output: OutputConfig) -> list[str]: + """Probe *source* only when *output* has a limit, and return its arguments.""" + if not output.channels and not output.max_sample_rate: + return [] + rate, channels = _probe_shape(source) + return limit_args(rate, channels, output) + + def _remove_artwork_from_command(cmd: list[str]) -> list[str]: """ Remove artwork-related options from an FFmpeg command. - + Used for retry when artwork encoding fails. """ filtered = [] i = 0 - + while i < len(cmd): arg = cmd[i] - + # Skip -map 0:v:0? pair if arg == "-map" and i + 1 < len(cmd) and cmd[i + 1] == "0:v:0?": i += 2 continue - + # Skip -c:v and its value if arg == "-c:v": i += 2 continue - + # Skip -vf and its value if arg.startswith("-vf"): i += 2 continue - + filtered.append(arg) i += 1 - + return filtered @@ -196,7 +256,7 @@ 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): + except ValueError, IndexError: return "?" @@ -218,7 +278,9 @@ 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.""" try: - proc = subprocess.run(cmd, capture_output=True, timeout=FFMPEG_TIMEOUT) + proc = subprocess.run( + cmd, capture_output=True, timeout=FFMPEG_TIMEOUT, check=False + ) 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 "" @@ -236,7 +298,7 @@ def atomic_ffmpeg_encode( ) -> int: """ Run FFmpeg with atomic output (write to temp, then rename). - + Args: cmd: FFmpeg command (last element is destination) final_dest: Final destination path @@ -244,31 +306,31 @@ def atomic_ffmpeg_encode( 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). No output is written on failure. """ final_dest = nfc_path(final_dest) dest_dir = os.path.dirname(final_dest) os.makedirs(dest_dir, exist_ok=True) - + tmp_dest = final_dest + ".tmp__ff" - + # Clean up any stale temp file try: if os.path.exists(tmp_dest): os.remove(tmp_dest) except Exception: pass - + # Replace destination with temp path cmd = list(cmd) cmd[-1] = tmp_dest source = _source_from_cmd(cmd) - + logger.info("► %s", " ".join(cmd)) 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) @@ -276,7 +338,7 @@ def atomic_ffmpeg_encode( cmd[-1] = tmp_dest logger.info("► (retry) %s", " ".join(cmd)) rc, stderr = _run_ffmpeg(cmd) - + if rc != 0: detail = _decode_error(stderr) or (stderr.strip().splitlines() or [""])[-1] logger.error( @@ -284,13 +346,13 @@ def atomic_ffmpeg_encode( ) _cleanup_temp(tmp_dest) return rc - + if finalize is not None: try: finalize(tmp_dest) except Exception as e: logger.warning("Post-encode step failed for %s: %s", final_dest, e) - + try: os.replace(tmp_dest, final_dest) return 0 diff --git a/src/audio_transcode_watcher/lyrics.py b/src/audio_transcode_watcher/lyrics.py index ef63f78..d67fc73 100644 --- a/src/audio_transcode_watcher/lyrics.py +++ b/src/audio_transcode_watcher/lyrics.py @@ -10,14 +10,16 @@ import mutagen import syncedlyrics -from .utils import nfc, nfc_path +from .utils import nfc_path logger = logging.getLogger(__name__) # 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})?\])+(.*)$") # 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) +_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. @@ -149,7 +151,7 @@ def _write_lrc( with open(lrc_path, "w", encoding="utf-8") as f: f.write(content) except Exception: - logger.error("Failed to write lyrics file: %s", lrc_path, exc_info=True) + logger.exception("Failed to write lyrics file: %s", lrc_path) return None if owner_of: diff --git a/src/audio_transcode_watcher/main.py b/src/audio_transcode_watcher/main.py index 122fb13..7662aee 100644 --- a/src/audio_transcode_watcher/main.py +++ b/src/audio_transcode_watcher/main.py @@ -24,7 +24,7 @@ def setup_logging() -> None: sys.stderr.reconfigure(encoding="utf-8") except Exception: pass - + logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s", @@ -36,24 +36,24 @@ def main() -> int: """Main entry point.""" setup_logging() logger = logging.getLogger(__name__) - + # Load configuration try: config = load_config() except ValueError as e: logger.error("Configuration error: %s", e) return 1 - + # Normalize paths config.source_path = nfc_path(config.source_path) for output in config.outputs: output.path = nfc_path(output.path) - + # Validate source exists if not os.path.isdir(config.source_path): logger.error("Source directory does not exist: %s", config.source_path) return 1 - + # Log configuration logger.info("Source: %s", config.source_path) for output in config.outputs: @@ -64,22 +64,22 @@ def main() -> int: f" {output.bitrate}" if output.bitrate else "", output.path, ) - + # Perform initial sync initial_sync(config) - + # Start watcher observer = start_watcher(config) logger.info("Watching %s …", config.source_path) - + # Periodic sync interval (settings.sync_interval_seconds, default 300) sync_interval = config.sync_interval_seconds last_sync = time.time() - + try: while True: time.sleep(min(10, sync_interval)) - + # Periodic sync to catch deleted outputs if time.time() - last_sync >= sync_interval: initial_sync(config, periodic=True) @@ -87,7 +87,7 @@ def main() -> int: last_sync = time.time() except KeyboardInterrupt: observer.stop() - + observer.join() return 0 diff --git a/src/audio_transcode_watcher/manifest.py b/src/audio_transcode_watcher/manifest.py new file mode 100644 index 0000000..e97efb6 --- /dev/null +++ b/src/audio_transcode_watcher/manifest.py @@ -0,0 +1,161 @@ +"""Per-output provenance: which source, and how, produced each output file. + +Each output folder holds ``.atw-manifest.json``, mapping an output file's +path (relative to the output folder) to the source that produced it:: + + {"Artist/X.mp3": {"source": "Artist/X.ogg", "size": 4123456, + "mtime": 1790000000.0, "kind": "transcode", + "output_size": 3801234, "output_mtime": 1790000050.0}} + +``kind`` is ``"encode"`` (from a lossless source), ``"copy"`` (a lossy source +copied unchanged) or ``"transcode"`` (a lossy source re-encoded to another +lossy codec). ``output_size`` and ``output_mtime`` describe the output file +as written; a row whose file no longer matches them is not trusted. The +manifest is advisory: a missing or unreadable one means +"unknown", and every caller then behaves as if it did not exist. +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +import time + +from .utils import nfc_path + +logger = logging.getLogger(__name__) + +MANIFEST_NAME = ".atw-manifest.json" + +# Seconds between writes of a manifest that keeps changing. Rows recorded +# since the last write are lost if the process dies; that only means +# "unknown" for those files, never a wrong answer. +FLUSH_INTERVAL = 5.0 + +_lock = threading.Lock() +_rows: dict[str, dict[str, dict]] = {} # output root -> rows +_dirty: set[str] = set() +_last_write: dict[str, float] = {} + + +def _key(root: str, out_path: str) -> str: + return nfc_path(os.path.relpath(out_path, root)) + + +def _load(root: str) -> dict[str, dict]: + """Rows for *root*, read once per process. Call with _lock held.""" + rows = _rows.get(root) + if rows is not None: + return rows + rows = {} + path = os.path.join(root, MANIFEST_NAME) + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + rows = {k: v for k, v in data.items() if isinstance(v, dict)} + else: + logger.warning("Ignoring manifest %s: not a JSON object", path) + except FileNotFoundError: + pass + except (OSError, ValueError) as e: + logger.warning("Ignoring unreadable manifest %s: %s", path, e) + _rows[root] = rows + return rows + + +def lookup(root: str, out_path: str) -> dict | None: + """The row for *out_path* in the output folder *root*, or None if unknown.""" + with _lock: + return _load(root).get(_key(root, out_path)) + + +def record( + root: str, out_path: str, source_root: str, source_path: str, kind: str +) -> None: + """Remember that *source_path* produced *out_path* (written on the next flush).""" + try: + st = os.stat(source_path) + size, mtime = st.st_size, st.st_mtime + except OSError: + size, mtime = None, None + try: + out = os.stat(out_path) + output_size, output_mtime = out.st_size, out.st_mtime + except OSError: + output_size, output_mtime = None, None + row = { + "source": nfc_path(os.path.relpath(source_path, source_root)), + "size": size, + "mtime": mtime, + "kind": kind, + "output_size": output_size, + "output_mtime": output_mtime, + } + with _lock: + _load(root)[_key(root, out_path)] = row + _dirty.add(root) + + +def output_matches(row: dict, out_path: str) -> bool: + """True if *out_path* still has the size and mtime recorded in *row*.""" + try: + st = os.stat(out_path) + except OSError: + return False + return ( + row.get("output_size") is not None + and st.st_size == row.get("output_size") + and st.st_mtime == row.get("output_mtime") + ) + + +def prune(root: str) -> int: + """Drop rows whose output file is gone. Returns how many were dropped.""" + with _lock: + rows = _load(root) + gone = [k for k in rows if not os.path.exists(os.path.join(root, k))] + for k in gone: + del rows[k] + if gone: + _dirty.add(root) + return len(gone) + + +def flush(root: str, force: bool = False) -> None: + """Write *root*'s manifest if it changed (at most every FLUSH_INTERVAL s).""" + with _lock: + if root not in _dirty: + return + now = time.monotonic() + if not force and now - _last_write.get(root, 0.0) < FLUSH_INTERVAL: + return + path = os.path.join(root, MANIFEST_NAME) + tmp = path + ".tmp" + try: + with open(tmp, "w", encoding="utf-8") as f: + json.dump(_rows.get(root, {}), f, ensure_ascii=False, sort_keys=True) + os.replace(tmp, path) + except OSError as e: + logger.warning("Could not write manifest %s: %s", path, e) + return + _dirty.discard(root) + _last_write[root] = now + + +def flush_all(force: bool = False) -> None: + """Flush every manifest that changed.""" + with _lock: + roots = list(_dirty) + for root in roots: + flush(root, force=force) + + +def forget(root: str) -> None: + """Drop what is cached for *root* (after its files were purged).""" + with _lock: + _rows.pop(root, None) + _dirty.discard(root) + _last_write.pop(root, None) diff --git a/src/audio_transcode_watcher/sync.py b/src/audio_transcode_watcher/sync.py index 4a13f40..cc640d0 100644 --- a/src/audio_transcode_watcher/sync.py +++ b/src/audio_transcode_watcher/sync.py @@ -11,8 +11,14 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path +from . import manifest from .config import Config, OutputConfig -from .encoder import atomic_ffmpeg_encode, build_ffmpeg_command, copy_mp4_tags +from .encoder import ( + atomic_ffmpeg_encode, + build_ffmpeg_command, + copy_mp4_tags, + portable_limit_args, +) from .lyrics import fetch_lyrics_for_file from .utils import ( AUDIO_EXTENSIONS, @@ -30,8 +36,8 @@ nfc, nfc_path, remove_empty_dirs, - walk_audio_files, wait_for_stable, + walk_audio_files, ) # Default number of parallel encoding workers (can be overridden in config) @@ -67,14 +73,14 @@ def safety_guard_active(config: Config) -> bool: """ Check if safety guard should prevent operations. - + Safety guard activates if source appears empty. Empty destinations are allowed if allow_initial_bulk_encode is True. """ global _last_safety_log_ts - + src_empty = appears_empty_dir(config.source_path) - + # Source being empty is always a problem if src_empty: now = time.time() @@ -82,14 +88,14 @@ def safety_guard_active(config: Config) -> bool: logger.warning("Safety guard active: source directory appears empty") _last_safety_log_ts = now return True - + # Empty destinations are OK if allow_initial_bulk_encode is True if config.allow_initial_bulk_encode: return False - + # Otherwise, check destinations dest_empty = {o.name: appears_empty_dir(o.path) for o in config.outputs} - + if any(dest_empty.values()): now = time.time() if now - _last_safety_log_ts >= _SAFETY_LOG_INTERVAL: @@ -100,7 +106,7 @@ def safety_guard_active(config: Config) -> bool: ) _last_safety_log_ts = now return True - + return False @@ -112,7 +118,7 @@ def process_source_file( ) -> None: """ Process a source file and create all configured outputs. - + Args: source_path: Path to source audio file config: Configuration @@ -120,13 +126,13 @@ def process_source_file( check_stable: If True, wait for file to be stable first """ source_path = nfc_path(source_path) - + if not is_audio_file(source_path): return - + if safety_guard_active(config): return - + # Wait for file to stabilize if needed if check_stable and not wait_for_stable( source_path, @@ -135,7 +141,7 @@ 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 @@ -145,7 +151,7 @@ def process_source_file( if source_path in _in_progress: return _in_progress.add(source_path) - + try: if not _process_outputs(source_path, config, force): _remember_failure(source_path) @@ -159,6 +165,7 @@ def process_source_file( finally: with _in_progress_lock: _in_progress.discard(source_path) + manifest.flush_all() def _is_known_failure(source_path: str) -> bool: @@ -191,7 +198,7 @@ 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 source_dir = os.path.dirname(source_path) - + for ext in LOSSLESS_EXTENSIONS: lossless_path = os.path.join(source_dir, f"{stem}{ext}") if os.path.exists(lossless_path) and lossless_path != source_path: @@ -223,7 +230,9 @@ def _remove_lossy_copies( 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) + 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": @@ -232,9 +241,13 @@ def _remove_lossy_copies( try: if not os.path.exists(copy_path): continue - if copy_path == own_path and not filecmp.cmp(sibling, copy_path, shallow=True): + 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) + 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) @@ -261,10 +274,15 @@ def plan_output(source_path: str, output: OutputConfig) -> tuple[str, str]: - 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. + codec, unless it exceeds the output's channel or sample-rate limit, + 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: + if output.is_lossless: + return nfc(os.path.basename(source_path)), "copy" + if lossy_source_codec(source_path) == output.codec and not portable_limit_args( + source_path, output + ): return nfc(os.path.basename(source_path)), "copy" return get_output_filename(source_path, output.extension), "encode" @@ -287,6 +305,21 @@ def _atomic_copy(source_path: str, out_path: str) -> bool: return False +def _made_from_lossy(output: OutputConfig, out_path: str) -> bool: + """True if the manifest says *out_path* came from a lossy source. + + Unknown (no manifest, no row) is False, which keeps today's behaviour. + """ + row = manifest.lookup(output.path, out_path) + if not row or row.get("kind") not in ("copy", "transcode"): + return False + # Trust the row only while the file is still the one it describes. + if not manifest.output_matches(row, out_path): + logger.debug("Manifest row for %s is stale; leaving the file alone", out_path) + return False + return True + + def _process_outputs(source_path: str, config: Config, force: bool) -> bool: """ Process all outputs for a source file. @@ -306,26 +339,44 @@ def _process_outputs(source_path: str, config: Config, force: bool) -> bool: out_filename, action = plan_output(source_path, output) out_path = get_output_file_path( - source_path, config.source_path, output.path, out_filename, + 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 + if not (is_lossless(source_path) and _made_from_lossy(output, out_path)): + continue + logger.info( + "Replacing %s: it was made from a lossy source, %s wins", + out_path, + source_path, + ) os.makedirs(os.path.dirname(out_path), exist_ok=True) if action == "copy": - _atomic_copy(source_path, out_path) + if _atomic_copy(source_path, out_path): + manifest.record( + output.path, out_path, config.source_path, source_path, "copy" + ) continue finalize = None if output.codec in _MP4_CODECS: + def finalize(tmp: str, src: str = source_path) -> None: copy_mp4_tags(src, tmp) cmd = build_ffmpeg_command(source_path, out_path, output) rc = atomic_ffmpeg_encode(cmd, out_path, finalize=finalize) - if rc != 0: + if rc == 0: + kind = "encode" if is_lossless(source_path) else "transcode" + manifest.record( + output.path, out_path, config.source_path, source_path, kind + ) + else: logger.error( "%s encode failed for %s", output.name.upper(), @@ -346,8 +397,8 @@ def delete_outputs(source_path: str, config: Config) -> None: if safety_guard_active(config): return - lossless_sibling = ( - not is_lossless(source_path) and _has_lossless_source(source_path, config) + lossless_sibling = not is_lossless(source_path) and _has_lossless_source( + source_path, config ) for output in config.outputs: @@ -360,7 +411,10 @@ def delete_outputs(source_path: str, config: Config) -> None: continue filepath = get_output_file_path( - source_path, config.source_path, output.path, filename, + source_path, + config.source_path, + output.path, + filename, ) if os.path.exists(filepath): try: @@ -398,18 +452,25 @@ def sync_sidecars(source_path: str, config: Config) -> None: sidecar_filename = f"{stem}{ext}" for output in config.outputs: sidecar_dst = get_output_file_path( - source_path, config.source_path, output.path, sidecar_filename, + source_path, + config.source_path, + output.path, + sidecar_filename, ) try: needs_copy = not os.path.exists(sidecar_dst) if not needs_copy: - needs_copy = os.path.getmtime(sidecar_src) > os.path.getmtime(sidecar_dst) + needs_copy = os.path.getmtime(sidecar_src) > os.path.getmtime( + sidecar_dst + ) if needs_copy: os.makedirs(os.path.dirname(sidecar_dst), exist_ok=True) shutil.copy2(sidecar_src, sidecar_dst) logger.info("► copy sidecar %s → %s", sidecar_src, sidecar_dst) except Exception as e: - logger.error("Failed to copy sidecar %s → %s: %s", sidecar_src, sidecar_dst, e) + logger.error( + "Failed to copy sidecar %s → %s: %s", sidecar_src, sidecar_dst, e + ) def delete_sidecars(source_path: str, config: Config) -> None: @@ -421,7 +482,10 @@ def delete_sidecars(source_path: str, config: Config) -> None: sidecar_filename = f"{stem}{ext}" for output in config.outputs: sidecar_path = get_output_file_path( - source_path, config.source_path, output.path, sidecar_filename, + source_path, + config.source_path, + output.path, + sidecar_filename, ) if os.path.exists(sidecar_path): try: @@ -486,6 +550,7 @@ def purge_all_outputs(config: Config) -> None: os.remove(full) except Exception as e: logger.error("Failed to purge %s: %s", full, e) + manifest.forget(output.path) remove_empty_dirs(output.path) except Exception as e: logger.error("Failed to purge folder %s: %s", output.path, e) @@ -530,8 +595,10 @@ def initial_sync(config: Config, periodic: bool = False) -> None: return # 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) + workers = getattr(config, "parallel_workers", DEFAULT_PARALLEL_WORKERS) + logger.info( + "Processing %d source files with %d workers…", len(source_files), workers + ) def process_one(src_file: str) -> None: try: @@ -551,9 +618,12 @@ def process_one(src_file: str) -> None: return if not source_files: - logger.warning("No source files found; skipping orphan cleanup to avoid wiping outputs.") + logger.warning( + "No source files found; skipping orphan cleanup to avoid wiping outputs." + ) else: _cleanup_orphans(config) + manifest.flush_all(force=True) logger.info("%s complete.", label) @@ -581,7 +651,9 @@ def _cleanup_orphans(config: Config) -> None: 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.") + logger.warning( + "No source files found; skipping orphan cleanup to avoid wiping outputs." + ) return now = time.time() @@ -647,6 +719,9 @@ def remove(full: str, kind: str) -> None: except Exception as e: logger.error("Failed to scan %s for orphans: %s", output.path, e) - # Remove empty subdirectories left after orphan cleanup + # Remove empty subdirectories left after orphan cleanup, and forget + # manifest rows whose output is gone for output in config.outputs: remove_empty_dirs(output.path) + manifest.prune(output.path) + manifest.flush(output.path, force=True) diff --git a/src/audio_transcode_watcher/utils.py b/src/audio_transcode_watcher/utils.py index f5edf26..3e26120 100644 --- a/src/audio_transcode_watcher/utils.py +++ b/src/audio_transcode_watcher/utils.py @@ -9,8 +9,15 @@ # Audio file extensions considered as source files LOSSLESS_EXTENSIONS = { - ".flac", ".alac", ".wav", ".aiff", ".aif", - ".ape", ".wv", ".tta", ".tak", + ".flac", + ".alac", + ".wav", + ".aiff", + ".aif", + ".ape", + ".wv", + ".tta", + ".tak", } LOSSY_EXTENSIONS = {".mp3", ".aac", ".m4a", ".ogg", ".opus", ".wma"} @@ -42,13 +49,13 @@ def nfc_path(p: str) -> str: parts = list(Path(p).parts) if not parts: return p - + if parts[0] == os.sep: # Absolute path handling on POSIX normalized = os.sep + os.path.join(*[nfc(x) for x in parts[1:]]) else: normalized = os.path.join(*[nfc(x) for x in parts]) - + return normalized @@ -97,7 +104,7 @@ def is_mp3(path: str) -> bool: def appears_empty_dir(path: str) -> bool: """ Check if a directory appears empty (no non-hidden files). - + Returns True if: - Directory doesn't exist - Directory is not accessible @@ -106,7 +113,7 @@ def appears_empty_dir(path: str) -> bool: try: if not os.path.isdir(path): return True - + with os.scandir(path) as entries: for entry in entries: if not entry.name.startswith("."): @@ -118,45 +125,43 @@ def appears_empty_dir(path: str) -> bool: def wait_for_stable( - path: str, - min_stable_secs: float = 1.0, - timeout: float = 60.0 + path: str, min_stable_secs: float = 1.0, timeout: float = 60.0 ) -> bool: """ Wait until file size stays unchanged for min_stable_secs. - + Args: path: Path to the file min_stable_secs: Minimum seconds the file must be stable timeout: Maximum time to wait - + Returns: True if file is stable, False if timed out or file doesn't exist """ path = nfc_path(path) start = time.time() - + try: last_size = os.path.getsize(path) except OSError: return False - + last_change = time.time() - + while time.time() - start < timeout: try: cur_size = os.path.getsize(path) except OSError: return False - + if cur_size != last_size: last_size = cur_size last_change = time.time() elif time.time() - last_change >= min_stable_secs: return True - + time.sleep(0.2) - + return False diff --git a/src/audio_transcode_watcher/watcher.py b/src/audio_transcode_watcher/watcher.py index 266a0cd..6a7d247 100644 --- a/src/audio_transcode_watcher/watcher.py +++ b/src/audio_transcode_watcher/watcher.py @@ -71,7 +71,7 @@ def _process_later(self, path: str, force: bool = False) -> None: process_source_file(path, self.config, force=force, check_stable=True) self._mark_processed(path) - + def on_created(self, event) -> None: """Handle file creation (audio or sidecar).""" if event.is_directory: @@ -81,7 +81,7 @@ def on_created(self, event) -> None: sync_sidecars(event.src_path, self.config) else: self._process_later(event.src_path, force=False) - + def on_modified(self, event) -> None: """Handle file modification (audio or sidecar).""" if event.is_directory: @@ -100,7 +100,7 @@ def on_modified(self, event) -> None: return delete_outputs(event.src_path, self.config) self._process_later(event.src_path, force=True) - + def _handle_directory_delete(self, dir_path: str) -> None: """Clean up mirrored output subtrees when a source directory is removed.""" if safety_guard_active(self.config): @@ -135,7 +135,9 @@ def on_moved(self, event) -> None: ).start() return - if has_sidecar_extension(event.src_path) or has_sidecar_extension(event.dest_path): + if has_sidecar_extension(event.src_path) or has_sidecar_extension( + event.dest_path + ): if has_sidecar_extension(event.src_path): delete_sidecars(event.src_path, self.config) if has_sidecar_extension(event.dest_path): @@ -161,7 +163,7 @@ def on_deleted(self, event) -> None: def start_watcher(config: Config) -> Observer: """ Start the file system watcher. - + Returns the observer instance (call observer.stop() to stop). """ observer = Observer() diff --git a/tests/conftest.py b/tests/conftest.py index 8f979d7..d377253 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,5 @@ """Pytest fixtures for audio-transcode-watcher tests.""" -import os import tempfile from pathlib import Path @@ -21,12 +20,12 @@ def source_dir(temp_dir): """Create a source directory with some test files.""" src = Path(temp_dir) / "source" src.mkdir() - + # Create some fake audio files (just empty files for testing) (src / "Artist - Song 1.flac").touch() (src / "Artist - Song 2.flac").touch() (src / "Other - Track.mp3").touch() - + return str(src) diff --git a/tests/test_config.py b/tests/test_config.py index 65e70cf..109deec 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,8 +2,6 @@ import json import logging -import os -from pathlib import Path import pytest @@ -17,7 +15,7 @@ class TestOutputConfig: """Tests for OutputConfig.""" - + def test_create_with_required_fields(self): """Test creating OutputConfig with required fields only.""" config = OutputConfig( @@ -30,36 +28,36 @@ def test_create_with_required_fields(self): assert config.path == "/output" assert config.bitrate == "256k" # Default for mp3 assert config.include_artwork is True - + def test_codec_normalized_to_lowercase(self): """Test that codec is normalized to lowercase.""" config = OutputConfig(name="test", codec="ALAC", path="/output") assert config.codec == "alac" - + def test_invalid_codec_raises_error(self): """Test that invalid codec raises ValueError.""" with pytest.raises(ValueError, match="Unknown codec"): OutputConfig(name="test", codec="invalid", path="/output") - + def test_extension_property(self): """Test the extension property for each codec.""" for codec, expected_ext in CODEC_EXTENSIONS.items(): config = OutputConfig(name="test", codec=codec, path="/output") assert config.extension == expected_ext - + def test_is_lossless_property(self): """Test the is_lossless property.""" lossless_codecs = {"alac", "flac", "wav"} lossy_codecs = {"mp3", "aac", "opus"} - + for codec in lossless_codecs: config = OutputConfig(name="test", codec=codec, path="/output") assert config.is_lossless is True - + for codec in lossy_codecs: config = OutputConfig(name="test", codec=codec, path="/output") assert config.is_lossless is False - + def test_artwork_disabled_for_unsupported_codecs(self): """Test that artwork is disabled for codecs that don't support it.""" # Opus doesn't support artwork @@ -70,7 +68,7 @@ def test_artwork_disabled_for_unsupported_codecs(self): include_artwork=True, ) assert config.include_artwork is False - + def test_from_dict(self): """Test creating OutputConfig from a dictionary.""" data = { @@ -90,7 +88,7 @@ def test_from_dict(self): class TestConfig: """Tests for Config.""" - + def test_create_with_required_fields(self, sample_output_config): """Test creating Config with required fields.""" config = Config( @@ -100,17 +98,17 @@ def test_create_with_required_fields(self, sample_output_config): assert config.source_path == "/music/source" assert len(config.outputs) == 1 assert config.force_reencode is False - + def test_empty_source_path_raises_error(self, sample_output_config): """Test that empty source_path raises ValueError.""" with pytest.raises(ValueError, match="source_path is required"): Config(source_path="", outputs=[sample_output_config]) - + def test_empty_outputs_raises_error(self): """Test that empty outputs list raises ValueError.""" with pytest.raises(ValueError, match="At least one output is required"): Config(source_path="/music", outputs=[]) - + def test_duplicate_output_names_raises_error(self): """Test that duplicate output names raise ValueError.""" outputs = [ @@ -119,7 +117,7 @@ def test_duplicate_output_names_raises_error(self): ] with pytest.raises(ValueError, match="Duplicate output names"): Config(source_path="/music", outputs=outputs) - + def test_duplicate_output_paths_raises_error(self): """Test that duplicate output paths raise ValueError.""" outputs = [ @@ -128,22 +126,22 @@ def test_duplicate_output_paths_raises_error(self): ] with pytest.raises(ValueError, match="Duplicate output paths"): Config(source_path="/music", outputs=outputs) - + def test_output_paths_property(self, sample_config): """Test the output_paths property.""" paths = sample_config.output_paths assert len(paths) == 3 assert all(isinstance(p, str) for p in paths) - + def test_get_output_by_name(self, sample_config): """Test getting output by name.""" output = sample_config.get_output_by_name("mp3-256") assert output is not None assert output.codec == "mp3" - + # Non-existent name assert sample_config.get_output_by_name("nonexistent") is None - + def test_from_dict(self): """Test creating Config from a dictionary.""" data = { @@ -161,7 +159,7 @@ def test_from_dict(self): assert len(config.outputs) == 1 assert config.force_reencode is True assert config.stability_timeout == 30.0 - + def test_from_yaml_file(self, yaml_config_file): """Test loading Config from a YAML file.""" config = Config.from_yaml_file(yaml_config_file) @@ -169,15 +167,17 @@ def test_from_yaml_file(self, yaml_config_file): assert len(config.outputs) == 2 assert config.stability_timeout == 30 assert config.min_stable_seconds == 0.5 - + def test_from_json_string(self): """Test loading Config from a JSON string.""" - json_str = json.dumps({ - "source": {"path": "/music/src"}, - "outputs": [ - {"name": "aac", "codec": "aac", "path": "/music/aac"}, - ], - }) + json_str = json.dumps( + { + "source": {"path": "/music/src"}, + "outputs": [ + {"name": "aac", "codec": "aac", "path": "/music/aac"}, + ], + } + ) config = Config.from_json_string(json_str) assert config.source_path == "/music/src" assert config.outputs[0].codec == "aac" @@ -185,39 +185,44 @@ def test_from_json_string(self): class TestLoadConfig: """Tests for load_config function.""" - + def test_load_from_config_file(self, yaml_config_file, monkeypatch): """Test loading config from CONFIG_FILE env var.""" monkeypatch.setenv("CONFIG_FILE", yaml_config_file) config = load_config() assert config.source_path == "/music/flac" - + def test_load_from_config_json(self, monkeypatch): """Test loading config from CONFIG_JSON env var.""" - json_config = json.dumps({ - "source": {"path": "/json/source"}, - "outputs": [ - {"name": "test", "codec": "flac", "path": "/json/output"}, - ], - }) + json_config = json.dumps( + { + "source": {"path": "/json/source"}, + "outputs": [ + {"name": "test", "codec": "flac", "path": "/json/output"}, + ], + } + ) monkeypatch.setenv("CONFIG_JSON", json_config) config = load_config() assert config.source_path == "/json/source" - + def test_config_file_takes_precedence(self, yaml_config_file, monkeypatch): """Test that CONFIG_FILE takes precedence over CONFIG_JSON.""" monkeypatch.setenv("CONFIG_FILE", yaml_config_file) - monkeypatch.setenv("CONFIG_JSON", '{"source":{"path":"/json"},"outputs":[{"name":"x","codec":"mp3","path":"/x"}]}') + monkeypatch.setenv( + "CONFIG_JSON", + '{"source":{"path":"/json"},"outputs":[{"name":"x","codec":"mp3","path":"/x"}]}', + ) config = load_config() assert config.source_path == "/music/flac" # From YAML, not JSON - + def test_missing_config_raises_error(self, monkeypatch): """Test that missing config raises ValueError.""" monkeypatch.delenv("CONFIG_FILE", raising=False) monkeypatch.delenv("CONFIG_JSON", raising=False) with pytest.raises(ValueError, match="No configuration found"): load_config() - + def test_nonexistent_config_file_raises_error(self, monkeypatch): """Test that nonexistent config file raises ValueError.""" monkeypatch.setenv("CONFIG_FILE", "/nonexistent/config.yaml") @@ -282,4 +287,54 @@ def test_rejects_non_numbers_with_value_error(self, bad): 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 + assert ( + Config.from_dict( + _settings_config({"sync_interval_seconds": 90.5}) + ).sync_interval_seconds + == 90.5 + ) + + +class TestPortableLimits: + """channels / max_sample_rate per output, with AAC defaults.""" + + def test_aac_defaults(self): + out = OutputConfig(name="aac", codec="aac", path="/a") + assert (out.channels, out.max_sample_rate) == (2, 48000) + + @pytest.mark.parametrize("codec", ["alac", "flac", "wav", "mp3", "opus"]) + def test_other_codecs_have_no_limit(self, codec): + out = OutputConfig(name=codec, codec=codec, path="/a") + assert (out.channels, out.max_sample_rate) == (0, 0) + + def test_from_dict_reads_and_opts_in(self): + out = OutputConfig.from_dict( + { + "name": "mp3", + "codec": "mp3", + "path": "/m", + "channels": 2, + "max_sample_rate": 44100, + } + ) + assert (out.channels, out.max_sample_rate) == (2, 44100) + + def test_zero_turns_the_aac_limits_off(self): + out = OutputConfig.from_dict( + { + "name": "aac", + "codec": "aac", + "path": "/a", + "channels": 0, + "max_sample_rate": 0, + } + ) + assert (out.channels, out.max_sample_rate) == (0, 0) + + @pytest.mark.parametrize("key", ["channels", "max_sample_rate"]) + @pytest.mark.parametrize("bad", [True, "2", -1, 2.5]) + def test_rejects_bad_values(self, key, bad): + with pytest.raises(ValueError, match=key): + OutputConfig.from_dict( + {"name": "aac", "codec": "aac", "path": "/a", key: bad} + ) diff --git a/tests/test_encoder.py b/tests/test_encoder.py index 3c1ef96..c7c7e70 100644 --- a/tests/test_encoder.py +++ b/tests/test_encoder.py @@ -1,5 +1,6 @@ """Tests for FFmpeg encoder module.""" +import json import logging import os import shutil @@ -18,12 +19,14 @@ atomic_ffmpeg_encode, build_ffmpeg_command, copy_mp4_tags, + limit_args, + target_sample_rate, ) class TestBuildFFmpegCommand: """Tests for build_ffmpeg_command function.""" - + def test_alac_command_with_artwork(self): """Test ALAC encoding command with artwork.""" config = OutputConfig( @@ -33,7 +36,7 @@ def test_alac_command_with_artwork(self): include_artwork=True, ) cmd = build_ffmpeg_command("/input/test.flac", "/output/test.m4a", config) - + assert cmd[0] == "ffmpeg" assert "-i" in cmd assert "/input/test.flac" in cmd @@ -44,7 +47,7 @@ def test_alac_command_with_artwork(self): assert "-c:v" in cmd assert "copy" in cmd assert cmd[-1] == "/output/test.m4a" - + def test_alac_command_without_artwork(self): """Test ALAC encoding command without artwork.""" config = OutputConfig( @@ -54,10 +57,10 @@ def test_alac_command_without_artwork(self): include_artwork=False, ) cmd = build_ffmpeg_command("/input/test.flac", "/output/test.m4a", config) - + assert "0:v:0?" not in cmd assert "-c:v" not in cmd - + def test_aac_command(self): """Test AAC encoding command.""" config = OutputConfig( @@ -67,14 +70,14 @@ def test_aac_command(self): bitrate="192k", ) cmd = build_ffmpeg_command("/input/test.flac", "/output/test.m4a", config) - + assert "-c:a" in cmd assert "aac" in cmd assert "-b:a" in cmd assert "192k" in cmd assert "-movflags" in cmd assert "+faststart" in cmd - + def test_mp3_command(self): """Test MP3 encoding command.""" config = OutputConfig( @@ -85,7 +88,7 @@ def test_mp3_command(self): include_artwork=True, ) cmd = build_ffmpeg_command("/input/test.flac", "/output/test.mp3", config) - + assert "-c:a" in cmd assert "libmp3lame" in cmd assert "-b:a" in cmd @@ -93,7 +96,7 @@ def test_mp3_command(self): assert "-c:v" in cmd assert "mjpeg" in cmd # MP3 needs mjpeg for ID3 APIC assert "-id3v2_version" in cmd - + def test_opus_command(self): """Test Opus encoding command.""" config = OutputConfig( @@ -103,14 +106,14 @@ def test_opus_command(self): bitrate="128k", ) cmd = build_ffmpeg_command("/input/test.flac", "/output/test.opus", config) - + assert "-c:a" in cmd assert "libopus" in cmd assert "-b:a" in cmd assert "128k" in cmd # Opus doesn't support artwork assert "0:v:0?" not in cmd - + def test_flac_command(self): """Test FLAC encoding command (re-encoding).""" config = OutputConfig( @@ -120,11 +123,11 @@ def test_flac_command(self): include_artwork=True, ) cmd = build_ffmpeg_command("/input/test.wav", "/output/test.flac", config) - + assert "-c:a" in cmd assert "flac" in cmd assert "-f" in cmd - + def test_wav_command(self): """Test WAV encoding command.""" config = OutputConfig( @@ -133,17 +136,17 @@ def test_wav_command(self): path="/output", ) cmd = build_ffmpeg_command("/input/test.flac", "/output/test.wav", config) - + assert "-c:a" in cmd assert "pcm_s16le" in cmd assert "-f" in cmd assert "wav" in cmd - + def test_command_has_common_options(self): """Test that all commands have common options.""" config = OutputConfig(name="test", codec="mp3", path="/output") cmd = build_ffmpeg_command("/input/test.flac", "/output/test.mp3", config) - + assert "-loglevel" in cmd assert "error" in cmd assert "-y" in cmd # Overwrite @@ -155,63 +158,83 @@ def test_command_has_common_options(self): class TestRemoveArtworkFromCommand: """Tests for _remove_artwork_from_command function.""" - + def test_removes_video_mapping(self): """Test that video mapping is removed.""" cmd = [ - "ffmpeg", "-i", "input.flac", - "-map", "0:a:0", - "-map", "0:v:0?", - "-c:a", "alac", - "-c:v", "copy", - "output.m4a" + "ffmpeg", + "-i", + "input.flac", + "-map", + "0:a:0", + "-map", + "0:v:0?", + "-c:a", + "alac", + "-c:v", + "copy", + "output.m4a", ] filtered = _remove_artwork_from_command(cmd) - + assert "0:v:0?" not in filtered assert "-c:v" not in filtered assert "copy" not in filtered # Audio mapping should remain assert "0:a:0" in filtered - + def test_preserves_audio_mapping(self): """Test that audio mapping is preserved.""" cmd = [ - "ffmpeg", "-i", "input.flac", - "-map", "0:a:0", - "-map", "0:v:0?", - "-c:a", "libmp3lame", - "-c:v", "mjpeg", - "output.mp3" + "ffmpeg", + "-i", + "input.flac", + "-map", + "0:a:0", + "-map", + "0:v:0?", + "-c:a", + "libmp3lame", + "-c:v", + "mjpeg", + "output.mp3", ] filtered = _remove_artwork_from_command(cmd) - + # Audio should be preserved assert "-map" in filtered assert "0:a:0" in filtered assert "-c:a" in filtered assert "libmp3lame" in filtered - + def test_removes_vf_options(self): """Test that -vf options are removed.""" cmd = [ - "ffmpeg", "-i", "input.flac", - "-vf", "scale=300:300", - "-c:a", "aac", - "output.m4a" + "ffmpeg", + "-i", + "input.flac", + "-vf", + "scale=300:300", + "-c:a", + "aac", + "output.m4a", ] filtered = _remove_artwork_from_command(cmd) - + assert "-vf" not in filtered assert "scale=300:300" not in filtered - + def test_handles_command_without_video(self): """Test handling command that already has no video options.""" cmd = [ - "ffmpeg", "-i", "input.flac", - "-map", "0:a:0", - "-c:a", "opus", - "output.opus" + "ffmpeg", + "-i", + "input.flac", + "-map", + "0:a:0", + "-c:a", + "opus", + "output.opus", ] filtered = _remove_artwork_from_command(cmd) @@ -278,9 +301,17 @@ def test_retries_without_artwork_on_artwork_error(self, mock_run, tmp_path): """Retry without artwork when stderr hints at artwork failure.""" final = str(tmp_path / "output.m4a") cmd = [ - "ffmpeg", "-i", "input.flac", - "-map", "0:a:0", "-map", "0:v:0?", - "-c:a", "alac", "-c:v", "copy", + "ffmpeg", + "-i", + "input.flac", + "-map", + "0:a:0", + "-map", + "0:v:0?", + "-c:a", + "alac", + "-c:v", + "copy", final, ] @@ -418,8 +449,10 @@ def test_no_error_when_file_missing(self, tmp_path): def test_no_error_on_permission_failure(self, tmp_path): """Gracefully handle permission errors.""" - with patch("os.path.exists", return_value=True), \ - patch("os.remove", side_effect=PermissionError("denied")): + with ( + patch("os.path.exists", return_value=True), + patch("os.remove", side_effect=PermissionError("denied")), + ): _cleanup_temp(str(tmp_path / "locked.tmp__ff")) @@ -440,8 +473,7 @@ def test_no_error_on_permission_failure(self, tmp_path): """ CORRUPT_FLAC_STDERR = ( - "[flac @ 0x7f] invalid residual\\n" - "[flac @ 0x7f] decode_frame() failed" + "[flac @ 0x7f] invalid residual\\n[flac @ 0x7f] decode_frame() failed" ) @@ -502,7 +534,9 @@ def test_clean_run_succeeds(self, fake_ffmpeg, tmp_path): "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): + 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) @@ -522,7 +556,9 @@ def test_nonzero_exit_fails(self, fake_ffmpeg, tmp_path, monkeypatch): 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): + 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) @@ -539,7 +575,9 @@ def test_decode_error_does_not_trigger_artwork_retry(self, fake_ffmpeg, tmp_path "Error initializing output stream 0:1 -- video stream", ], ) - def test_artwork_error_retries_without_cover(self, fake_ffmpeg, tmp_path, monkeypatch, art_stderr): + 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) @@ -554,9 +592,17 @@ def test_artwork_error_retries_without_cover(self, fake_ffmpeg, tmp_path, monkey 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), + "ffmpeg", + "-loglevel", + "error", + "-y", + "-f", + "lavfi", + "-i", + f"sine=frequency=440:duration={seconds}", + "-c:a", + "flac", + str(path), ], check=True, ) @@ -664,8 +710,17 @@ def test_id3_source_tags_are_read(self, tmp_path): src = tmp_path / "song.wav" subprocess.run( - ["ffmpeg", "-loglevel", "error", "-y", "-f", "lavfi", - "-i", "sine=duration=1", str(src)], + [ + "ffmpeg", + "-loglevel", + "error", + "-y", + "-f", + "lavfi", + "-i", + "sine=duration=1", + str(src), + ], check=True, ) wav = WAVE(str(src)) @@ -680,8 +735,19 @@ def test_id3_source_tags_are_read(self, tmp_path): dest = tmp_path / "song.m4a" subprocess.run( - ["ffmpeg", "-loglevel", "error", "-y", "-i", str(src), - "-c:a", "alac", "-f", "mp4", str(dest)], + [ + "ffmpeg", + "-loglevel", + "error", + "-y", + "-i", + str(src), + "-c:a", + "alac", + "-f", + "mp4", + str(dest), + ], check=True, ) assert copy_mp4_tags(str(src), str(dest)) == 6 @@ -707,10 +773,15 @@ def test_source_without_tags_writes_nothing(self, tmp_path): class TestEncoderEdges: """Small branches of the new encoder code.""" - def test_source_name_without_input_flag(self, fake_ffmpeg, tmp_path, monkeypatch, caplog): + 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 ( + 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): @@ -738,10 +809,190 @@ 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) + "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 + + +class TestTargetSampleRate: + """Cap the rate within its own family, never upsample.""" + + @pytest.mark.parametrize( + "src,cap,expected", + [ + (44100, 48000, None), + (48000, 48000, None), + (32000, 48000, None), + (22050, 48000, None), + (88200, 48000, 44100), + (176400, 48000, 44100), + (352800, 48000, 44100), + (96000, 48000, 48000), + (192000, 48000, 48000), + (384000, 48000, 48000), + (64000, 48000, 48000), + (192000, 96000, 96000), + (176400, 96000, 88200), + (96000, 32000, 24000), + (88200, 32000, 22050), + (44100, 8000, 8000), + (96000, 8000, 6000), + (96000, 0, None), + (0, 48000, None), + ], + ) + def test_rates(self, src, cap, expected): + assert target_sample_rate(src, cap) == expected + + +AAC = OutputConfig(name="aac", codec="aac", path="/out") +ALAC = OutputConfig(name="alac", codec="alac", path="/out") + + +class TestPortableAacArgs: + """AAC gets -ac 2 and a 48 kHz cap by default; nothing else changes.""" + + @pytest.mark.parametrize( + "rate,channels,expected", + [ + (44100, 2, []), + (48000, 1, []), + (44100, 6, ["-ac", "2"]), + (48000, 8, ["-ac", "2"]), + (88200, 2, ["-ar", "44100"]), + (176400, 2, ["-ar", "44100"]), + (96000, 2, ["-ar", "48000"]), + (192000, 6, ["-ac", "2", "-ar", "48000"]), + (None, None, []), + ], + ) + def test_limit_args(self, rate, channels, expected): + assert limit_args(rate, channels, AAC) == expected + + @pytest.mark.parametrize( + "rate,channels,expected", + [ + (44100, 2, []), + (96000, 2, ["-ar", "48000"]), + (88200, 6, ["-ac", "2", "-ar", "44100"]), + ], + ) + def test_aac_command_gets_limits_before_the_output(self, rate, channels, expected): + with patch( + "audio_transcode_watcher.encoder._probe_shape", + return_value=(rate, channels), + ): + cmd = build_ffmpeg_command("/in/a.flac", "/out/a.m4a", AAC) + assert cmd[-1] == "/out/a.m4a" + tail = cmd[cmd.index("mp4") + 1 : -1] + assert tail == expected + + @pytest.mark.parametrize("codec", ["alac", "flac", "wav", "mp3", "opus"]) + def test_other_codecs_untouched_and_not_probed(self, codec): + out = OutputConfig(name=codec, codec=codec, path="/out") + with patch( + "audio_transcode_watcher.encoder._probe_shape", return_value=(192000, 6) + ) as probe: + cmd = build_ffmpeg_command("/in/a.flac", "/out/a.x", out) + assert "-ac" not in cmd and "-ar" not in cmd + probe.assert_not_called() + + def test_another_codec_can_opt_in(self): + mp3 = OutputConfig( + name="mp3", codec="mp3", path="/out", channels=2, max_sample_rate=48000 + ) + with patch( + "audio_transcode_watcher.encoder._probe_shape", return_value=(96000, 6) + ): + cmd = build_ffmpeg_command("/in/a.flac", "/out/a.mp3", mp3) + assert cmd[-5:-1] == ["-ac", "2", "-ar", "48000"] + + def test_unreadable_source_adds_nothing(self, tmp_path): + junk = tmp_path / "junk.flac" + junk.write_bytes(b"not audio") + cmd = build_ffmpeg_command(str(junk), "/out/a.m4a", AAC) + assert "-ac" not in cmd and "-ar" not in cmd + + +def _probe(path) -> tuple[int, int]: + out = subprocess.run( + [ + "ffprobe", + "-v", + "error", + "-select_streams", + "a:0", + "-show_entries", + "stream=sample_rate,channels", + "-of", + "json", + str(path), + ], + capture_output=True, + text=True, + check=True, + ).stdout + stream = json.loads(out)["streams"][0] + return int(stream["sample_rate"]), int(stream["channels"]) + + +@needs_ffmpeg +class TestPortableAacRealFiles: + """Real hi-res and multichannel FLACs through the AAC and ALAC encodes.""" + + @staticmethod + def _flac(path, lavfi): + subprocess.run( + [ + "ffmpeg", + "-loglevel", + "error", + "-y", + "-f", + "lavfi", + "-i", + lavfi, + "-t", + "2", + "-c:a", + "flac", + str(path), + ], + check=True, + ) + + def _encode(self, src, output, tmp_path): + dest = str(tmp_path / output.codec / "out.m4a") + assert ( + atomic_ffmpeg_encode(build_ffmpeg_command(str(src), dest, output), dest) + == 0 + ) + return _probe(dest) + + def test_96k_flac_becomes_48k_aac(self, tmp_path): + src = tmp_path / "hires.flac" + self._flac(src, "sine=frequency=440:sample_rate=96000") + assert _probe(src) == (96000, 1) + assert self._encode( + src, OutputConfig(name="aac", codec="aac", path="/x"), tmp_path + ) == (48000, 1) + + def test_6_channel_flac_becomes_stereo_aac(self, tmp_path): + src = tmp_path / "surround.flac" + self._flac(src, "anullsrc=channel_layout=5.1:sample_rate=48000") + assert _probe(src) == (48000, 6) + assert self._encode( + src, OutputConfig(name="aac", codec="aac", path="/x"), tmp_path + ) == (48000, 2) + + def test_alac_keeps_96k_and_6_channels(self, tmp_path): + src = tmp_path / "hires-surround.flac" + self._flac(src, "anullsrc=channel_layout=5.1:sample_rate=96000") + assert self._encode( + src, OutputConfig(name="alac", codec="alac", path="/x"), tmp_path + ) == (96000, 6) diff --git a/tests/test_lyrics.py b/tests/test_lyrics.py index 4cb40b0..7512008 100644 --- a/tests/test_lyrics.py +++ b/tests/test_lyrics.py @@ -4,6 +4,7 @@ import logging import os +from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -93,10 +94,9 @@ def test_fetches_and_writes_lyrics(self, mock_meta, mock_syncedlyrics, tmp_path) assert result is not None assert result.endswith(".lrc") assert os.path.isfile(result) - content = open(result).read() + content = Path(result).read_text() assert "Radio gaga" in content - @patch("audio_transcode_watcher.lyrics.syncedlyrics") @patch("audio_transcode_watcher.lyrics.extract_metadata") def test_returns_none_when_no_lyrics(self, mock_meta, mock_syncedlyrics, tmp_path): @@ -118,7 +118,9 @@ def test_returns_none_when_no_metadata(self, tmp_path): @patch("audio_transcode_watcher.lyrics.syncedlyrics") @patch("audio_transcode_watcher.lyrics.extract_metadata") - def test_handles_syncedlyrics_exception(self, mock_meta, mock_syncedlyrics, tmp_path): + def test_handles_syncedlyrics_exception( + self, mock_meta, mock_syncedlyrics, tmp_path + ): """Handle exception from syncedlyrics.search gracefully.""" audio = tmp_path / "Artist - Song.flac" audio.touch() @@ -135,9 +137,11 @@ class TestWriteLrc: def test_writes_content_to_file(self, tmp_path): """Write LRC content and return path.""" lrc_path = str(tmp_path / "song.lrc") - result = _write_lrc(lrc_path, "[00:01.00] Hello", "Artist - Song", "syncedlyrics") + result = _write_lrc( + lrc_path, "[00:01.00] Hello", "Artist - Song", "syncedlyrics" + ) assert result == lrc_path - assert open(lrc_path).read() == "[00:01.00] Hello" + assert Path(lrc_path).read_text() == "[00:01.00] Hello" def test_returns_none_on_write_error(self, tmp_path): """Return None when writing fails.""" @@ -160,13 +164,31 @@ class TestLyricsRejectReason: @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: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"), + ( + "[00:00.00] https://example.com/lyrics\n[00:01.00]\n[00:02.00]\n[00:03.00]\n", + "advertisement", + ), ("", "0 timed lines"), ], ) @@ -204,11 +226,15 @@ def test_nothing_found_writes_nothing(self, mock_meta, mock_sl, returned, tmp_pa @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): + 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] ♪" + 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 @@ -231,7 +257,9 @@ class TestLrcOwnership: @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): + 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") @@ -243,13 +271,20 @@ def test_chown_to_source_owner_and_mode_0664(self, mock_meta, mock_sl, mock_chow 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): + @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)) + 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()] @@ -263,7 +298,10 @@ def test_chmod_failure_is_logged_at_debug(self, _chmod, tmp_path, caplog): 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 + 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_main.py b/tests/test_main.py index a745cf2..342ead0 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -125,6 +125,7 @@ def test_periodic_sync_triggers_after_interval( # We use a counter so every call returns an incrementing value that # eventually crosses the 300-second threshold. counter = [0] + def fake_time(): counter[0] += 1 # First call (last_sync = time.time()) -> 0 @@ -132,13 +133,16 @@ def fake_time(): return 0 if counter[0] == 1 else 400 sleep_calls = [0] + def fake_sleep(secs): sleep_calls[0] += 1 if sleep_calls[0] >= 2: raise KeyboardInterrupt - with patch("time.time", side_effect=fake_time), \ - patch("time.sleep", side_effect=fake_sleep): + with ( + patch("time.time", side_effect=fake_time), + patch("time.sleep", side_effect=fake_sleep), + ): main() # initial_sync called once at startup + once in periodic loop @@ -150,7 +154,9 @@ def fake_sleep(secs): @patch("audio_transcode_watcher.main.initial_sync") @patch("audio_transcode_watcher.main.load_config") @patch("os.path.isdir", return_value=True) - def test_logs_output_with_bitrate(self, _isdir, mock_load, _sync, mock_watcher, _sleep): + def test_logs_output_with_bitrate( + self, _isdir, mock_load, _sync, mock_watcher, _sleep + ): """Log output configuration including bitrate when present.""" from audio_transcode_watcher.config import Config, OutputConfig @@ -170,14 +176,24 @@ def test_logs_output_with_bitrate(self, _isdir, mock_load, _sync, mock_watcher, class TestConfigurableInterval: """settings.sync_interval_seconds drives the periodic pass.""" - @pytest.mark.parametrize("interval,elapsed,expected_calls", [(60, 61, 2), (300, 61, 1)]) + @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 + self, + _isdir, + mock_load, + mock_sync, + mock_watcher, + _gc, + interval, + elapsed, + expected_calls, ): from audio_transcode_watcher.config import Config, OutputConfig @@ -195,8 +211,10 @@ def fake_sleep(_secs): if sleeps[0] >= 2: raise KeyboardInterrupt - with patch("time.time", side_effect=lambda: next(times)), \ - patch("time.sleep", side_effect=fake_sleep): + with ( + patch("time.time", side_effect=lambda: next(times)), + patch("time.sleep", side_effect=fake_sleep), + ): main() assert mock_sync.call_count == expected_calls diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..c97133b --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,122 @@ +"""Tests for the per-output provenance manifest.""" + +import json + +import pytest + +from audio_transcode_watcher import manifest + + +@pytest.fixture(autouse=True) +def _clean(): + for store in (manifest._rows, manifest._dirty, manifest._last_write): + store.clear() + yield + for store in (manifest._rows, manifest._dirty, manifest._last_write): + store.clear() + + +def _read(root): + return json.loads((root / manifest.MANIFEST_NAME).read_text()) + + +def test_nothing_written_until_something_changes(tmp_path): + manifest.flush(str(tmp_path), force=True) + assert not (tmp_path / manifest.MANIFEST_NAME).exists() + + +def test_flush_is_throttled_unless_forced(tmp_path): + src = tmp_path / "a.flac" + src.write_bytes(b"abc") + root = str(tmp_path) + manifest.record(root, str(tmp_path / "a.m4a"), root, str(src), "encode") + manifest.flush(root) + assert set(_read(tmp_path)) == {"a.m4a"} + + manifest.record(root, str(tmp_path / "b.m4a"), root, str(src), "encode") + manifest.flush(root) # within FLUSH_INTERVAL: not yet + assert set(_read(tmp_path)) == {"a.m4a"} + manifest.flush(root, force=True) + assert set(_read(tmp_path)) == {"a.m4a", "b.m4a"} + assert _read(tmp_path)["a.m4a"]["size"] == 3 + + +def test_missing_source_records_unknown_size(tmp_path): + root = str(tmp_path) + manifest.record( + root, str(tmp_path / "x.m4a"), root, str(tmp_path / "gone.flac"), "copy" + ) + row = manifest.lookup(root, str(tmp_path / "x.m4a")) + assert row["size"] is None and row["mtime"] is None and row["kind"] == "copy" + + +def test_non_object_manifest_is_ignored(tmp_path, caplog): + (tmp_path / manifest.MANIFEST_NAME).write_text("[1, 2]") + assert manifest.lookup(str(tmp_path), str(tmp_path / "x.m4a")) is None + assert "not a JSON object" in caplog.text + + +def test_write_failure_is_logged_and_retried_later(tmp_path, caplog): + root = tmp_path / "missing-output" + manifest.record( + str(root), + str(root / "x.m4a"), + str(tmp_path), + str(tmp_path / "x.flac"), + "encode", + ) + manifest.flush(str(root), force=True) + assert "Could not write manifest" in caplog.text + assert str(root) in manifest._dirty + + +def test_prune_drops_rows_without_a_file(tmp_path): + root = str(tmp_path) + (tmp_path / "here.m4a").touch() + for name in ("here.m4a", "gone.m4a"): + manifest.record( + root, str(tmp_path / name), root, str(tmp_path / "s.flac"), "encode" + ) + assert manifest.prune(root) == 1 + assert manifest.lookup(root, str(tmp_path / "gone.m4a")) is None + assert manifest.lookup(root, str(tmp_path / "here.m4a")) is not None + + +def test_nested_paths_are_relative_to_the_output(tmp_path): + src_root = tmp_path / "src" + (src_root / "Artist").mkdir(parents=True) + src = src_root / "Artist" / "X.ogg" + src.touch() + out_root = tmp_path / "out" + manifest.record( + str(out_root), + str(out_root / "Artist" / "X.mp3"), + str(src_root), + str(src), + "transcode", + ) + out_root.mkdir() + manifest.flush_all(force=True) + assert _read(out_root) == { + "Artist/X.mp3": { + "source": "Artist/X.ogg", + "size": 0, + "mtime": src.stat().st_mtime, + "kind": "transcode", + "output_size": None, + "output_mtime": None, + } + } + + +def test_output_matches_only_the_file_as_written(tmp_path): + root = str(tmp_path) + out = tmp_path / "x.mp3" + out.write_bytes(b"as written") + manifest.record(root, str(out), root, str(tmp_path / "x.ogg"), "transcode") + row = manifest.lookup(root, str(out)) + assert manifest.output_matches(row, str(out)) + out.write_bytes(b"edited by hand") + assert not manifest.output_matches(row, str(out)) + out.unlink() + assert not manifest.output_matches(row, str(out)) diff --git a/tests/test_sync.py b/tests/test_sync.py index 0158572..c933709 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -1,12 +1,14 @@ """Tests for sync module.""" +import json import os from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest import audio_transcode_watcher.sync as sync_mod +from audio_transcode_watcher import manifest from audio_transcode_watcher.config import Config, OutputConfig from audio_transcode_watcher.sync import ( _cleanup_orphans, @@ -37,55 +39,59 @@ def _no_age_guards(monkeypatch): class TestSafetyGuard: """Tests for safety_guard_active function.""" - + def test_active_when_source_empty(self, temp_dir): """Test safety guard is active when source is empty.""" empty_source = Path(temp_dir) / "empty_source" empty_source.mkdir() - + output = Path(temp_dir) / "output" output.mkdir() (output / "file.m4a").touch() - + config = Config( source_path=str(empty_source), outputs=[OutputConfig(name="out", codec="alac", path=str(output))], ) - + assert safety_guard_active(config) is True - - def test_active_when_output_empty_and_bulk_encode_disabled(self, source_dir, temp_dir): + + def test_active_when_output_empty_and_bulk_encode_disabled( + self, source_dir, temp_dir + ): """Test safety guard is active when output empty and bulk encode disabled.""" empty_output = Path(temp_dir) / "empty_output" empty_output.mkdir() - + config = Config( source_path=source_dir, outputs=[OutputConfig(name="out", codec="alac", path=str(empty_output))], allow_initial_bulk_encode=False, # Disable bulk encoding ) - + assert safety_guard_active(config) is True - - def test_inactive_when_output_empty_and_bulk_encode_enabled(self, source_dir, temp_dir): + + def test_inactive_when_output_empty_and_bulk_encode_enabled( + self, source_dir, temp_dir + ): """Test safety guard allows empty outputs when bulk encode enabled (default).""" empty_output = Path(temp_dir) / "empty_output" empty_output.mkdir() - + config = Config( source_path=source_dir, outputs=[OutputConfig(name="out", codec="alac", path=str(empty_output))], allow_initial_bulk_encode=True, # Default behavior ) - + assert safety_guard_active(config) is False - + def test_inactive_when_all_have_files(self, source_dir, output_dirs): """Test safety guard is inactive when all dirs have files.""" # Add files to output dirs for path in output_dirs.values(): (Path(path) / "test.m4a").touch() - + config = Config( source_path=source_dir, outputs=[ @@ -93,168 +99,174 @@ def test_inactive_when_all_have_files(self, source_dir, output_dirs): for name, path in output_dirs.items() ], ) - + assert safety_guard_active(config) is False - + def test_ignores_hidden_files(self, temp_dir): """Test that hidden files are ignored when checking empty.""" source = Path(temp_dir) / "source" source.mkdir() (source / ".hidden").touch() # Hidden file - + output = Path(temp_dir) / "output" output.mkdir() (output / "file.m4a").touch() - + config = Config( source_path=str(source), outputs=[OutputConfig(name="out", codec="alac", path=str(output))], ) - + # Source has only hidden file, should be considered empty assert safety_guard_active(config) is True class TestProcessSourceFile: """Tests for process_source_file function.""" - + @patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode") @patch("audio_transcode_watcher.sync.safety_guard_active") - def test_skips_non_audio_files(self, mock_guard, mock_encode, sample_config, temp_dir): + def test_skips_non_audio_files( + self, mock_guard, mock_encode, sample_config, temp_dir + ): """Test that non-audio files are skipped.""" mock_guard.return_value = False - + # Create a non-audio file non_audio = Path(temp_dir) / "readme.txt" non_audio.write_text("test") - + process_source_file(str(non_audio), sample_config, check_stable=False) - + mock_encode.assert_not_called() - + @patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode") @patch("audio_transcode_watcher.sync.safety_guard_active") - def test_skips_when_safety_guard_active(self, mock_guard, mock_encode, sample_config, source_dir): + def test_skips_when_safety_guard_active( + self, mock_guard, mock_encode, sample_config, source_dir + ): """Test that processing is skipped when safety guard is active.""" mock_guard.return_value = True - + audio_file = Path(source_dir) / "Artist - Song 1.flac" process_source_file(str(audio_file), sample_config, check_stable=False) - + mock_encode.assert_not_called() - + @patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode") @patch("audio_transcode_watcher.sync.safety_guard_active") - def test_processes_audio_files(self, mock_guard, mock_encode, sample_config, source_dir): + def test_processes_audio_files( + self, mock_guard, mock_encode, sample_config, source_dir + ): """Test that audio files are processed.""" mock_guard.return_value = False mock_encode.return_value = 0 - + audio_file = Path(source_dir) / "Artist - Song 1.flac" process_source_file(str(audio_file), sample_config, check_stable=False) - + # Should be called for each output (3 outputs) assert mock_encode.call_count == 3 class TestDeleteOutputs: """Tests for delete_outputs function.""" - + @patch("audio_transcode_watcher.sync.safety_guard_active") def test_deletes_matching_files(self, mock_guard, temp_dir): """Test that matching output files are deleted.""" mock_guard.return_value = False - + source = Path(temp_dir) / "source" source.mkdir() (source / "Artist - Song.flac").touch() - + output = Path(temp_dir) / "output" output.mkdir() output_file = output / "Artist - Song.m4a" output_file.touch() - + config = Config( source_path=str(source), outputs=[OutputConfig(name="out", codec="alac", path=str(output))], ) - + delete_outputs(str(source / "Artist - Song.flac"), config) - + assert not output_file.exists() - + @patch("audio_transcode_watcher.sync.safety_guard_active") def test_handles_mp3_copies(self, mock_guard, temp_dir): """Test deletion of MP3 copies in ALAC folder.""" mock_guard.return_value = False - + source = Path(temp_dir) / "source" source.mkdir() (source / "Song.mp3").touch() - + output = Path(temp_dir) / "output" output.mkdir() output_file = output / "Song.mp3" output_file.touch() - + config = Config( source_path=str(source), outputs=[OutputConfig(name="alac", codec="alac", path=str(output))], ) - + delete_outputs(str(source / "Song.mp3"), config) - + assert not output_file.exists() class TestPurgeAllOutputs: """Tests for purge_all_outputs function.""" - + @patch("audio_transcode_watcher.sync.safety_guard_active") def test_purges_all_files(self, mock_guard, temp_dir): """Test that all output files are purged.""" mock_guard.return_value = False - + source = Path(temp_dir) / "source" source.mkdir() (source / "file.flac").touch() - + output = Path(temp_dir) / "output" output.mkdir() (output / "file1.m4a").touch() (output / "file2.m4a").touch() - + config = Config( source_path=str(source), outputs=[OutputConfig(name="out", codec="alac", path=str(output))], ) - + purge_all_outputs(config) - + # All files should be deleted assert list(output.glob("*.m4a")) == [] - + @patch("audio_transcode_watcher.sync.safety_guard_active") def test_skips_when_safety_guard_active(self, mock_guard, temp_dir): """Test that purge is skipped when safety guard is active.""" mock_guard.return_value = True - + source = Path(temp_dir) / "source" source.mkdir() (source / "file.flac").touch() - + output = Path(temp_dir) / "output" output.mkdir() test_file = output / "file.m4a" test_file.touch() - + config = Config( source_path=str(source), outputs=[OutputConfig(name="out", codec="alac", path=str(output))], ) - + purge_all_outputs(config) - + # File should still exist assert test_file.exists() @@ -431,6 +443,7 @@ def test_does_not_overwrite_identical(self, temp_dir): dst.write_text("lyrics") # Make destination newer than source import os + os.utime(str(dst), (time.time() + 10, time.time() + 10)) config = Config( @@ -571,7 +584,11 @@ def test_returns_true_when_lossless_exists(self, temp_dir): config = Config( source_path=str(source), - outputs=[OutputConfig(name="alac", codec="alac", path=str(Path(temp_dir) / "out"))], + outputs=[ + OutputConfig( + name="alac", codec="alac", path=str(Path(temp_dir) / "out") + ) + ], ) (Path(temp_dir) / "out").mkdir() @@ -586,7 +603,11 @@ def test_returns_false_when_no_lossless_exists(self, temp_dir): config = Config( source_path=str(source), - outputs=[OutputConfig(name="alac", codec="alac", path=str(Path(temp_dir) / "out"))], + outputs=[ + OutputConfig( + name="alac", codec="alac", path=str(Path(temp_dir) / "out") + ) + ], ) (Path(temp_dir) / "out").mkdir() @@ -622,7 +643,9 @@ def test_mp3_copied_to_alac_folder_unchanged(self, _guard, mock_encode, temp_dir @patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode") @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) - def test_mp3_skipped_when_lossless_source_exists(self, _guard, mock_encode, temp_dir): + def test_mp3_skipped_when_lossless_source_exists( + self, _guard, mock_encode, temp_dir + ): """Skip MP3 for ALAC output when a lossless source with same stem exists.""" source = Path(temp_dir) / "source" source.mkdir() @@ -650,7 +673,9 @@ class TestProcessSourceFileStability: @patch("audio_transcode_watcher.sync.wait_for_stable", return_value=False) @patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode") @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) - def test_skips_when_file_not_stable(self, _guard, mock_encode, _stable, source_dir, sample_config): + def test_skips_when_file_not_stable( + self, _guard, mock_encode, _stable, source_dir, sample_config + ): """Skip processing when file is not stable.""" audio_file = Path(source_dir) / "Artist - Song 1.flac" process_source_file(str(audio_file), sample_config, check_stable=True) @@ -701,7 +726,9 @@ def test_logs_error_on_encode_failure(self, _guard, mock_encode, temp_dir): config = Config( source_path=str(source), - outputs=[OutputConfig(name="mp3", codec="mp3", path=str(out), bitrate="256k")], + outputs=[ + OutputConfig(name="mp3", codec="mp3", path=str(out), bitrate="256k") + ], ) # Should not raise; just logs the error @@ -713,10 +740,15 @@ class TestProcessSourceFileLyrics: """Tests for lyrics fetching in process_source_file.""" @patch("audio_transcode_watcher.sync.sync_sidecars") - @patch("audio_transcode_watcher.sync.fetch_lyrics_for_file", side_effect=Exception("network")) + @patch( + "audio_transcode_watcher.sync.fetch_lyrics_for_file", + side_effect=Exception("network"), + ) @patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode", return_value=0) @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) - def test_lyrics_failure_does_not_stop_processing(self, _guard, _encode, _lyrics, _sync, temp_dir): + def test_lyrics_failure_does_not_stop_processing( + self, _guard, _encode, _lyrics, _sync, temp_dir + ): """Lyrics fetch failure does not prevent sync_sidecars from running.""" source = Path(temp_dir) / "source" source.mkdir() @@ -728,7 +760,9 @@ def test_lyrics_failure_does_not_stop_processing(self, _guard, _encode, _lyrics, config = Config( source_path=str(source), - outputs=[OutputConfig(name="mp3", codec="mp3", path=str(out), bitrate="256k")], + outputs=[ + OutputConfig(name="mp3", codec="mp3", path=str(out), bitrate="256k") + ], fetch_lyrics=True, ) @@ -755,7 +789,9 @@ def test_in_progress_set_cleared_after_processing(self, _guard, _encode, temp_di config = Config( source_path=str(source), - outputs=[OutputConfig(name="mp3", codec="mp3", path=str(out), bitrate="256k")], + outputs=[ + OutputConfig(name="mp3", codec="mp3", path=str(out), bitrate="256k") + ], fetch_lyrics=False, ) @@ -813,7 +849,11 @@ def test_skips_nonexistent_output_dirs(self, temp_dir): config = Config( source_path=str(source), - outputs=[OutputConfig(name="out", codec="alac", path=str(Path(temp_dir) / "missing"))], + outputs=[ + OutputConfig( + name="out", codec="alac", path=str(Path(temp_dir) / "missing") + ) + ], ) assert cleanup_stale_temp_files(config) == 0 @@ -859,7 +899,9 @@ def test_mp3_delete_skips_non_alac_when_lossless_exists(self, _guard, temp_dir): config = Config( source_path=str(source), - outputs=[OutputConfig(name="mp3", codec="mp3", path=str(out_mp3), bitrate="256k")], + outputs=[ + OutputConfig(name="mp3", codec="mp3", path=str(out_mp3), bitrate="256k") + ], ) delete_outputs(str(mp3), config) @@ -891,7 +933,9 @@ def test_skips_when_safety_guard_active(self, _guard, temp_dir): @patch("audio_transcode_watcher.sync._cleanup_orphans") @patch("audio_transcode_watcher.sync.process_source_file") @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) - def test_processes_source_files_and_cleans_orphans(self, _guard, mock_proc, mock_orphan, temp_dir): + def test_processes_source_files_and_cleans_orphans( + self, _guard, mock_proc, mock_orphan, temp_dir + ): """Process all source files and clean up orphans.""" source = Path(temp_dir) / "source" source.mkdir() @@ -915,7 +959,9 @@ def test_processes_source_files_and_cleans_orphans(self, _guard, mock_proc, mock @patch("audio_transcode_watcher.sync._cleanup_orphans") @patch("audio_transcode_watcher.sync.process_source_file") @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) - def test_skips_orphan_cleanup_when_no_source_files(self, _guard, _proc, mock_orphan, temp_dir): + def test_skips_orphan_cleanup_when_no_source_files( + self, _guard, _proc, mock_orphan, temp_dir + ): """Skip orphan cleanup when no source files found to avoid wiping outputs.""" source = Path(temp_dir) / "source" source.mkdir() @@ -936,7 +982,9 @@ def test_skips_orphan_cleanup_when_no_source_files(self, _guard, _proc, mock_orp @patch("audio_transcode_watcher.sync.purge_all_outputs") @patch("audio_transcode_watcher.sync.process_source_file") @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) - def test_purges_when_force_reencode_enabled(self, _guard, _proc, mock_purge, temp_dir): + def test_purges_when_force_reencode_enabled( + self, _guard, _proc, mock_purge, temp_dir + ): """Purge all outputs when force_reencode is enabled.""" source = Path(temp_dir) / "source" source.mkdir() @@ -955,7 +1003,10 @@ def test_purges_when_force_reencode_enabled(self, _guard, _proc, mock_purge, tem mock_purge.assert_called_once() - @patch("audio_transcode_watcher.sync.walk_audio_files", side_effect=Exception("disk error")) + @patch( + "audio_transcode_watcher.sync.walk_audio_files", + side_effect=Exception("disk error"), + ) @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) def test_handles_source_scan_failure(self, _guard, _walk, temp_dir): """Gracefully handle failure to scan source directory.""" @@ -1052,6 +1103,11 @@ def test_handles_copy_error(self, temp_dir): LOSSY = [".mp3", ".aac", ".m4a", ".ogg", ".opus", ".wma"] +def _visible(folder): + """Files in *folder*, without hidden ones such as the manifest.""" + return [p for p in sorted(folder.iterdir()) if not p.name.startswith(".")] + + def _dirs(temp_dir, *names): paths = [] for name in names: @@ -1067,7 +1123,9 @@ class TestLossySources: @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): + 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()) @@ -1080,7 +1138,7 @@ def test_lossy_source_copied_unchanged_into_alac(self, _guard, mock_encode, temp 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 [p.name for p in _visible(out)] == [f"Artist - Title{ext}"] assert (out / f"Artist - Title{ext}").read_bytes() == src.read_bytes() @pytest.mark.parametrize( @@ -1108,7 +1166,13 @@ def test_lossy_source_copied_into_lossy_output_of_same_codec( @pytest.mark.parametrize( "ext,codec", - [(".ogg", "mp3"), (".mp3", "aac"), (".m4a", "mp3"), (".wma", "opus"), (".opus", "aac")], + [ + (".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) @@ -1129,7 +1193,9 @@ def test_lossy_source_transcoded_into_lossy_output_of_other_codec( @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): + 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() @@ -1145,13 +1211,17 @@ def test_lossless_source_encoded_with_tag_copy_for_mp4(self, _guard, mock_encode 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} + 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): + 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" @@ -1168,7 +1238,7 @@ def test_lossy_source_skipped_when_lossless_sibling_exists(self, _guard, mock_en process_source_file(str(ogg), config, check_stable=False) mock_encode.assert_not_called() - assert list(alac.iterdir()) == [] and list(mp3.iterdir()) == [] + assert list(_visible(alac)) == [] and list(_visible(mp3)) == [] class TestMixedExtensionOrphans: @@ -1191,7 +1261,7 @@ def test_alac_output_keeps_encodes_and_lossy_copies(self, temp_dir): _cleanup_orphans(config) - assert sorted(p.name for p in alac.iterdir()) == sorted(keep) + assert sorted(p.name for p in _visible(alac)) == sorted(keep) def test_lossy_output_keeps_copies_and_transcodes(self, temp_dir): source, mp3 = _dirs(temp_dir, "source", "mp3") @@ -1203,15 +1273,19 @@ def test_lossy_output_keeps_copies_and_transcodes(self, temp_dir): (mp3 / name).touch() config = Config( source_path=str(source), - outputs=[OutputConfig(name="mp3", codec="mp3", path=str(mp3), bitrate="256k")], + 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) + assert sorted(p.name for p in _visible(mp3)) == 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): + 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") @@ -1226,7 +1300,7 @@ def test_deleting_lossy_source_keeps_lossless_output_and_lrc(self, _guard, temp_ # 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"] + assert sorted(p.name for p in _visible(alac)) == ["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): @@ -1240,7 +1314,7 @@ def test_deleting_last_source_of_stem_removes_lrc(self, _guard, temp_dir): delete_outputs(str(source / "Song.ogg"), config) - assert list(alac.iterdir()) == [] + assert list(_visible(alac)) == [] class TestOrphanRaceGuards: @@ -1312,7 +1386,9 @@ def test_in_progress_stem_is_kept(self, temp_dir, monkeypatch): 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): + 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) @@ -1329,7 +1405,10 @@ 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): + with patch( + "audio_transcode_watcher.sync.process_source_file", + side_effect=watcher_meanwhile, + ): initial_sync(config, periodic=True) assert (out / "New.m4a").exists() @@ -1402,7 +1481,13 @@ def test_success_is_not_remembered(self, _guard, mock_encode, temp_dir): 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")]) + @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): @@ -1435,8 +1520,19 @@ def test_corrupt_flac_writes_no_output_and_is_remembered(self, temp_dir): 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)], + [ + "ffmpeg", + "-loglevel", + "error", + "-y", + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=5", + "-c:a", + "flac", + str(good), + ], check=True, ) data = bytearray(good.read_bytes()) @@ -1453,7 +1549,7 @@ def test_corrupt_flac_writes_no_output_and_is_remembered(self, temp_dir): process_source_file(str(bad), config, check_stable=False) - assert list(out.iterdir()) == [] + assert list(_visible(out)) == [] assert str(bad) in sync_mod._failed_sources def test_one_flipped_bit_is_caught(self, temp_dir): @@ -1463,8 +1559,19 @@ def test_one_flipped_bit_is_caught(self, temp_dir): 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)], + [ + "ffmpeg", + "-loglevel", + "error", + "-y", + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=5", + "-c:a", + "flac", + str(good), + ], check=True, ) data = bytearray(good.read_bytes()) @@ -1480,7 +1587,7 @@ def test_one_flipped_bit_is_caught(self, temp_dir): process_source_file(str(bad), config, check_stable=False) - assert list(out.iterdir()) == [] + assert list(_visible(out)) == [] class TestLosslessWinsAtProcessTime: @@ -1511,7 +1618,8 @@ def test_m4a_copied_then_flac_arrives(self, _guard, temp_dir): flac = source / "X.flac" flac.write_bytes(b"flac") with patch( - "audio_transcode_watcher.sync.atomic_ffmpeg_encode", side_effect=self._fake_encode + "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) @@ -1532,15 +1640,18 @@ def test_mp3_copy_removed_when_flac_arrives(self, _guard, temp_dir): ) with patch( - "audio_transcode_watcher.sync.atomic_ffmpeg_encode", side_effect=self._fake_encode + "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"] + assert sorted(p.name for p in _visible(alac)) == ["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): + 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") @@ -1568,8 +1679,13 @@ def test_remove_failure_is_logged(self, _guard, temp_dir, caplog): 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")): + 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 @@ -1600,10 +1716,12 @@ def test_failed_copy_leaves_no_temp(self, _guard, temp_dir, caplog): mp3.write_bytes(b"mp3") config = self._config(source, out) - with patch("audio_transcode_watcher.sync.os.replace", side_effect=OSError("disk full")): + 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 list(_visible(out)) == [] assert "Copy failed" in caplog.text @patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode") @@ -1613,7 +1731,10 @@ def test_safety_guard_between_outputs_stops_the_loop(self, mock_encode, temp_dir 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]): + 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() @@ -1622,7 +1743,9 @@ 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")): + 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 @@ -1631,21 +1754,35 @@ def test_initial_sync_logs_worker_errors_and_cleaned_temps(self, temp_dir, caplo (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"): + 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): + 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"): + 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 @@ -1653,7 +1790,9 @@ def test_initial_sync_skips_orphans_when_guard_trips_late(self, _proc, mock_orph 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")): + 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 @@ -1669,7 +1808,9 @@ def test_unreadable_age_counts_as_young(self, temp_dir): (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")): + 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() @@ -1677,7 +1818,9 @@ 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")): + 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 @@ -1705,3 +1848,230 @@ def 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 + + +@pytest.fixture +def fresh_manifests(): + """Start and end with no cached manifest rows.""" + manifest._rows.clear() + manifest._dirty.clear() + manifest._last_write.clear() + yield + manifest._rows.clear() + manifest._dirty.clear() + manifest._last_write.clear() + + +def _fake_encode(content): + def encode(cmd, dest, **_kwargs): + Path(dest).parent.mkdir(parents=True, exist_ok=True) + Path(dest).write_bytes(content) + return 0 + + return encode + + +@pytest.mark.usefixtures("fresh_manifests") +class TestManifestProvenance: + """An output made from a lossy source is replaced when the lossless one arrives.""" + + def _setup(self, temp_dir): + source, mp3 = _dirs(temp_dir, "source", "mp3") + config = Config( + source_path=str(source), + outputs=[ + OutputConfig(name="mp3", codec="mp3", path=str(mp3), bitrate="256k") + ], + fetch_lyrics=False, + ) + return source, mp3, config + + def _ogg_then_flac(self, source, mp3, config, between=None): + ogg = source / "X.ogg" + ogg.write_bytes(b"ogg") + with patch( + "audio_transcode_watcher.sync.atomic_ffmpeg_encode", + side_effect=_fake_encode(b"from ogg"), + ): + process_source_file(str(ogg), config, check_stable=False) + manifest.flush_all(force=True) + assert (mp3 / "X.mp3").read_bytes() == b"from ogg" + if between: + between() + flac = source / "X.flac" + flac.write_bytes(b"flac") + with patch( + "audio_transcode_watcher.sync.atomic_ffmpeg_encode", + side_effect=_fake_encode(b"from flac"), + ) as enc: + process_source_file(str(flac), config, force=False, check_stable=False) + return enc + + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_ogg_transcode_then_flac_reencodes(self, _guard, temp_dir): + source, mp3, config = self._setup(temp_dir) + + def restart(): # rows must come back from disk, not memory + manifest._rows.clear() + + enc = self._ogg_then_flac(source, mp3, config, between=restart) + + enc.assert_called_once() + assert (mp3 / "X.mp3").read_bytes() == b"from flac" + manifest.flush_all(force=True) + rows = json.loads((mp3 / ".atw-manifest.json").read_text()) + assert rows["X.mp3"]["kind"] == "encode" + assert rows["X.mp3"]["source"] == "X.flac" + assert rows["X.mp3"]["size"] == 4 + + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_missing_manifest_behaves_as_before(self, _guard, temp_dir): + source, mp3, config = self._setup(temp_dir) + + def lose_manifest(): + (mp3 / ".atw-manifest.json").unlink() + manifest._rows.clear() + + enc = self._ogg_then_flac(source, mp3, config, between=lose_manifest) + + enc.assert_not_called() + assert (mp3 / "X.mp3").read_bytes() == b"from ogg" + + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_unreadable_manifest_behaves_as_before(self, _guard, temp_dir, caplog): + source, mp3, config = self._setup(temp_dir) + + def corrupt(): + (mp3 / ".atw-manifest.json").write_text("{not json") + manifest._rows.clear() + + enc = self._ogg_then_flac(source, mp3, config, between=corrupt) + + enc.assert_not_called() + assert "Ignoring unreadable manifest" in caplog.text + + @patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode") + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_encode_from_lossless_is_kept(self, _guard, mock_encode, temp_dir): + source, mp3, config = self._setup(temp_dir) + flac = source / "X.flac" + flac.write_bytes(b"flac") + (mp3 / "X.mp3").write_bytes(b"from flac") + manifest.record(str(mp3), str(mp3 / "X.mp3"), str(source), str(flac), "encode") + + 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_copy_is_recorded(self, _guard, temp_dir): + source, alac = _dirs(temp_dir, "source", "alac") + mp3 = source / "Song.mp3" + mp3.write_bytes(b"mp3") + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(alac))], + fetch_lyrics=False, + ) + process_source_file(str(mp3), config, check_stable=False) + assert manifest.lookup(str(alac), str(alac / "Song.mp3"))["kind"] == "copy" + + def test_orphan_pass_drops_rows_whose_output_is_gone(self, temp_dir): + source, alac = _dirs(temp_dir, "source", "alac") + (source / "Keep.flac").touch() + (alac / "Keep.m4a").touch() + rows = { + "Keep.m4a": { + "source": "Keep.flac", + "size": 0, + "mtime": 0, + "kind": "encode", + }, + "Gone.m4a": { + "source": "Gone.flac", + "size": 0, + "mtime": 0, + "kind": "encode", + }, + } + (alac / ".atw-manifest.json").write_text(json.dumps(rows)) + config = Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(alac))], + ) + + _cleanup_orphans(config) + + assert (alac / ".atw-manifest.json").exists() # never treated as an orphan + assert set(json.loads((alac / ".atw-manifest.json").read_text())) == { + "Keep.m4a" + } + + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_purge_forgets_cached_rows(self, _guard, temp_dir): + source, alac = _dirs(temp_dir, "source", "alac") + (source / "X.flac").touch() + manifest.record( + str(alac), + str(alac / "X.m4a"), + str(source), + str(source / "X.flac"), + "transcode", + ) + purge_all_outputs( + Config( + source_path=str(source), + outputs=[OutputConfig(name="alac", codec="alac", path=str(alac))], + ) + ) + assert manifest.lookup(str(alac), str(alac / "X.m4a")) is None + + +@pytest.mark.usefixtures("fresh_manifests") +class TestLimitsAndProvenanceEdges: + """Follow-ups from review: over-limit lossy copies and stale manifest rows.""" + + @pytest.mark.parametrize( + "name,shape,expected", + [ + ("X.m4a", (44100, 2), ("X.m4a", "copy")), + ("X.m4a", (96000, 2), ("X.m4a", "encode")), + ("X.m4a", (48000, 6), ("X.m4a", "encode")), + ("X.aac", (96000, 2), ("X.m4a", "encode")), + ], + ) + def test_over_limit_aac_source_is_transcoded(self, name, shape, expected): + aac = OutputConfig(name="aac", codec="aac", path="/out") + with patch("audio_transcode_watcher.encoder._probe_shape", return_value=shape): + assert sync_mod.plan_output(f"/src/{name}", aac) == expected + + def test_lossy_into_lossless_is_never_probed(self): + alac = OutputConfig(name="alac", codec="alac", path="/out") + with patch("audio_transcode_watcher.encoder._probe_shape") as probe: + assert sync_mod.plan_output("/src/X.m4a", alac) == ("X.m4a", "copy") + probe.assert_not_called() + + @patch("audio_transcode_watcher.sync.safety_guard_active", return_value=False) + def test_edited_output_is_not_replaced(self, _guard, temp_dir): + source, mp3 = _dirs(temp_dir, "source", "mp3") + config = Config( + source_path=str(source), + outputs=[ + OutputConfig(name="mp3", codec="mp3", path=str(mp3), bitrate="256k") + ], + fetch_lyrics=False, + ) + (source / "X.ogg").write_bytes(b"ogg") + with patch( + "audio_transcode_watcher.sync.atomic_ffmpeg_encode", + side_effect=_fake_encode(b"from ogg"), + ): + process_source_file(str(source / "X.ogg"), config, check_stable=False) + (mp3 / "X.mp3").write_bytes(b"edited by hand, longer") + (source / "X.flac").write_bytes(b"flac") + + with patch("audio_transcode_watcher.sync.atomic_ffmpeg_encode") as enc: + process_source_file(str(source / "X.flac"), config, check_stable=False) + + enc.assert_not_called() + assert (mp3 / "X.mp3").read_bytes() == b"edited by hand, longer" diff --git a/tests/test_utils.py b/tests/test_utils.py index 88fe2f1..01b66e8 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -29,21 +29,21 @@ class TestNfc: """Tests for NFC normalization.""" - + def test_normalizes_to_nfc(self): """Test that strings are normalized to NFC.""" # NFD representation of "é" (e + combining acute accent) nfd_string = "e\u0301" # NFC representation of "é" (single character) nfc_string = "é" - + assert nfc(nfd_string) == nfc_string - + def test_already_nfc_unchanged(self): """Test that NFC strings are unchanged.""" nfc_string = "Sigur Rós" assert nfc(nfc_string) == nfc_string - + def test_ascii_unchanged(self): """Test that ASCII strings are unchanged.""" ascii_string = "Artist - Song" @@ -52,22 +52,22 @@ def test_ascii_unchanged(self): class TestNfcPath: """Tests for NFC path normalization.""" - + def test_normalizes_path_components(self): """Test that path components are normalized.""" # Path with NFD filename nfd_path = "/music/Sigur Ro\u0301s/song.flac" result = nfc_path(nfd_path) - + # Should be NFC normalized assert "Sigur Rós" in result - + def test_preserves_absolute_path(self): """Test that absolute paths remain absolute.""" path = "/music/artist/song.flac" result = nfc_path(path) assert result.startswith("/") - + def test_handles_empty_path(self): """Test handling of empty path.""" assert nfc_path("") == "" @@ -75,31 +75,31 @@ def test_handles_empty_path(self): class TestHasAudioExtension: """Tests for has_audio_extension function (no file existence check).""" - + def test_flac_extension(self): """Test that .flac extension is recognized.""" assert has_audio_extension("/music/Artist - Song.flac") is True - + def test_mp3_extension(self): """Test that .mp3 extension is recognized.""" assert has_audio_extension("/music/Artist - Song.mp3") is True - + def test_m4a_extension(self): """Test that .m4a extension is recognized.""" assert has_audio_extension("/music/Artist - Song.m4a") is True - + def test_nonexistent_file_still_recognized(self): """Test that a nonexistent path with audio extension returns True.""" assert has_audio_extension("/nonexistent/path/Song.flac") is True - + def test_txt_extension_not_audio(self): """Test that .txt extension is not recognized.""" assert has_audio_extension("/music/readme.txt") is False - + def test_case_insensitive(self): """Test that extension matching is case-insensitive.""" assert has_audio_extension("/music/Song.FLAC") is True - + def test_no_extension(self): """Test that path without extension is not recognized.""" assert has_audio_extension("/music/noext") is False @@ -131,35 +131,35 @@ def test_nonexistent_path(self): class TestIsAudioFile: """Tests for is_audio_file function.""" - + def test_flac_is_audio(self, temp_dir): """Test that FLAC files are recognized.""" flac = Path(temp_dir) / "test.flac" flac.touch() assert is_audio_file(str(flac)) is True - + def test_mp3_is_audio(self, temp_dir): """Test that MP3 files are recognized.""" mp3 = Path(temp_dir) / "test.mp3" mp3.touch() assert is_audio_file(str(mp3)) is True - + def test_m4a_is_audio(self, temp_dir): """Test that M4A files are recognized.""" m4a = Path(temp_dir) / "test.m4a" m4a.touch() assert is_audio_file(str(m4a)) is True - + def test_txt_is_not_audio(self, temp_dir): """Test that TXT files are not recognized.""" txt = Path(temp_dir) / "test.txt" txt.touch() assert is_audio_file(str(txt)) is False - + def test_nonexistent_is_not_audio(self, temp_dir): """Test that nonexistent files are not recognized.""" assert is_audio_file(str(Path(temp_dir) / "nonexistent.flac")) is False - + def test_case_insensitive(self, temp_dir): """Test that extension matching is case-insensitive.""" flac = Path(temp_dir) / "test.FLAC" @@ -169,19 +169,19 @@ def test_case_insensitive(self, temp_dir): class TestIsLossless: """Tests for is_lossless function.""" - + def test_flac_is_lossless(self): """Test that FLAC is recognized as lossless.""" assert is_lossless("song.flac") is True - + def test_wav_is_lossless(self): """Test that WAV is recognized as lossless.""" assert is_lossless("song.wav") is True - + def test_ape_is_lossless(self): """Test that APE is recognized as lossless.""" assert is_lossless("song.ape") is True - + def test_mp3_is_not_lossless(self): """Test that MP3 is not recognized as lossless.""" assert is_lossless("song.mp3") is False @@ -189,15 +189,15 @@ def test_mp3_is_not_lossless(self): class TestIsMp3: """Tests for is_mp3 function.""" - + def test_mp3_is_mp3(self): """Test that .mp3 is recognized.""" assert is_mp3("song.mp3") is True - + def test_mp3_case_insensitive(self): """Test that extension is case-insensitive.""" assert is_mp3("song.MP3") is True - + def test_flac_is_not_mp3(self): """Test that FLAC is not recognized as MP3.""" assert is_mp3("song.flac") is False @@ -205,27 +205,27 @@ def test_flac_is_not_mp3(self): class TestAppearsEmptyDir: """Tests for appears_empty_dir function.""" - + def test_empty_dir_is_empty(self, temp_dir): """Test that empty directory appears empty.""" empty = Path(temp_dir) / "empty" empty.mkdir() assert appears_empty_dir(str(empty)) is True - + def test_dir_with_files_not_empty(self, temp_dir): """Test that directory with files doesn't appear empty.""" with_files = Path(temp_dir) / "with_files" with_files.mkdir() (with_files / "file.txt").touch() assert appears_empty_dir(str(with_files)) is False - + def test_dir_with_hidden_only_is_empty(self, temp_dir): """Test that directory with only hidden files appears empty.""" hidden_only = Path(temp_dir) / "hidden_only" hidden_only.mkdir() (hidden_only / ".hidden").touch() assert appears_empty_dir(str(hidden_only)) is True - + def test_nonexistent_dir_is_empty(self, temp_dir): """Test that nonexistent directory appears empty.""" assert appears_empty_dir(str(Path(temp_dir) / "nonexistent")) is True @@ -233,18 +233,18 @@ def test_nonexistent_dir_is_empty(self, temp_dir): class TestGetOutputFilename: """Tests for get_output_filename function.""" - + def test_replaces_extension(self): """Test that extension is replaced.""" result = get_output_filename("Artist - Song.flac", ".m4a") assert result == "Artist - Song.m4a" - + def test_handles_unicode(self): """Test handling of Unicode filenames.""" result = get_output_filename("Sigur Rós - Hoppípolla.flac", ".mp3") assert "Sigur Rós - Hoppípolla" in result assert result.endswith(".mp3") - + def test_normalizes_to_nfc(self): """Test that output filename is NFC normalized.""" # NFD input @@ -260,14 +260,20 @@ class TestGetOutputFilePath: def test_flat_directory(self): """Test flat dir returns output_root/filename.""" result = get_output_file_path( - "/music/flac/song.flac", "/music/flac", "/music/mp3", "song.mp3", + "/music/flac/song.flac", + "/music/flac", + "/music/mp3", + "song.mp3", ) assert result == "/music/mp3/song.mp3" def test_nested_directory(self): """Test nested dir preserves relative structure.""" result = get_output_file_path( - "/music/flac/album1/song.flac", "/music/flac", "/music/mp3", "song.mp3", + "/music/flac/album1/song.flac", + "/music/flac", + "/music/mp3", + "song.mp3", ) assert result == "/music/mp3/album1/song.mp3" @@ -420,8 +426,10 @@ class TestAppearsEmptyDirError: def test_returns_true_on_scandir_error(self): """Return True when scandir raises an exception.""" - with patch("os.path.isdir", return_value=True), \ - patch("os.scandir", side_effect=PermissionError("denied")): + with ( + patch("os.path.isdir", return_value=True), + patch("os.scandir", side_effect=PermissionError("denied")), + ): assert appears_empty_dir("/some/path") is True diff --git a/tests/test_watcher.py b/tests/test_watcher.py index 80efe01..0b766f2 100644 --- a/tests/test_watcher.py +++ b/tests/test_watcher.py @@ -1,10 +1,8 @@ """Tests for the watcher module.""" -import os import time -from pathlib import Path from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest @@ -171,7 +169,9 @@ def test_skips_non_audio_files(self, _sleep, _is_audio, mock_proc, handler): @patch("audio_transcode_watcher.watcher.safety_guard_active", return_value=True) @patch("audio_transcode_watcher.watcher.is_audio_file", return_value=True) @patch("time.sleep") - def test_skips_when_safety_guard_active(self, _sleep, _is_audio, _safety, mock_proc, handler): + def test_skips_when_safety_guard_active( + self, _sleep, _is_audio, _safety, mock_proc, handler + ): """Skip processing when safety guard is active.""" handler._process_later("/music/source/song.flac") mock_proc.assert_not_called() @@ -184,7 +184,9 @@ class TestOnCreatedAudio: @patch("audio_transcode_watcher.watcher.safety_guard_active", return_value=False) @patch("audio_transcode_watcher.watcher.is_audio_file", return_value=True) @patch("time.sleep") - def test_processes_audio_on_create(self, _sleep, _is_audio, _safety, mock_proc, handler): + def test_processes_audio_on_create( + self, _sleep, _is_audio, _safety, mock_proc, handler + ): """Audio files trigger _process_later on create.""" handler.on_created(_event("/music/source/new_song.flac")) mock_proc.assert_called_once() @@ -209,12 +211,16 @@ class TestOnMoved: @patch("audio_transcode_watcher.watcher.is_audio_file", return_value=True) @patch("audio_transcode_watcher.watcher.delete_outputs") @patch("time.sleep") - def test_moves_audio_file(self, _sleep, mock_del, _is_audio, _safety, mock_proc, handler): + def test_moves_audio_file( + self, _sleep, mock_del, _is_audio, _safety, mock_proc, handler + ): """Delete old outputs and process new location on audio file move.""" - handler.on_moved(_event( - "/music/source/old.flac", - dest_path="/music/source/new.flac", - )) + handler.on_moved( + _event( + "/music/source/old.flac", + dest_path="/music/source/new.flac", + ) + ) mock_del.assert_called_once() mock_proc.assert_called_once() @@ -222,10 +228,12 @@ def test_moves_audio_file(self, _sleep, mock_del, _is_audio, _safety, mock_proc, @patch("audio_transcode_watcher.watcher.delete_sidecars") def test_moves_sidecar_file(self, mock_del_sc, mock_sync_sc, handler): """Delete old sidecars and sync new sidecars on sidecar move.""" - handler.on_moved(_event( - "/music/source/song.lrc", - dest_path="/music/source/renamed.lrc", - )) + handler.on_moved( + _event( + "/music/source/song.lrc", + dest_path="/music/source/renamed.lrc", + ) + ) mock_del_sc.assert_called_once() mock_sync_sc.assert_called_once() @@ -233,10 +241,12 @@ def test_moves_sidecar_file(self, mock_del_sc, mock_sync_sc, handler): @patch("audio_transcode_watcher.watcher.delete_sidecars") def test_move_from_sidecar_to_non_sidecar(self, mock_del_sc, mock_sync_sc, handler): """Delete old sidecar, no sync when dest is not a sidecar.""" - handler.on_moved(_event( - "/music/source/song.lrc", - dest_path="/music/source/song.txt", - )) + handler.on_moved( + _event( + "/music/source/song.lrc", + dest_path="/music/source/song.txt", + ) + ) mock_del_sc.assert_called_once() mock_sync_sc.assert_not_called() @@ -244,10 +254,12 @@ def test_move_from_sidecar_to_non_sidecar(self, mock_del_sc, mock_sync_sc, handl @patch("audio_transcode_watcher.watcher.delete_sidecars") def test_move_from_non_sidecar_to_sidecar(self, mock_del_sc, mock_sync_sc, handler): """Sync new sidecar when moving from non-sidecar to sidecar.""" - handler.on_moved(_event( - "/music/source/song.txt", - dest_path="/music/source/song.lrc", - )) + handler.on_moved( + _event( + "/music/source/song.txt", + dest_path="/music/source/song.lrc", + ) + ) mock_del_sc.assert_not_called() mock_sync_sc.assert_called_once() @@ -255,11 +267,13 @@ def test_move_from_non_sidecar_to_sidecar(self, mock_del_sc, mock_sync_sc, handl @patch("audio_transcode_watcher.watcher.AudioSyncHandler._handle_directory_delete") def test_moves_directory(self, mock_handle_del, mock_reprocess, handler): """Handle directory move: delete old + reprocess new.""" - handler.on_moved(_event( - "/music/source/old_album", - is_directory=True, - dest_path="/music/source/new_album", - )) + handler.on_moved( + _event( + "/music/source/old_album", + is_directory=True, + dest_path="/music/source/new_album", + ) + ) mock_handle_del.assert_called_once_with("/music/source/old_album") # _reprocess_directory is called in a thread, but we patched it at class level @@ -267,10 +281,12 @@ def test_moves_directory(self, mock_handle_del, mock_reprocess, handler): @patch("audio_transcode_watcher.watcher.is_audio_file", return_value=False) def test_move_non_audio_src_to_non_audio_dest(self, _is_audio, mock_del, handler): """Moving non-audio to non-audio does nothing.""" - handler.on_moved(_event( - "/music/source/readme.txt", - dest_path="/music/source/readme2.txt", - )) + handler.on_moved( + _event( + "/music/source/readme.txt", + dest_path="/music/source/readme2.txt", + ) + ) mock_del.assert_not_called() @@ -334,7 +350,9 @@ class TestReprocessDirectory: @patch("audio_transcode_watcher.watcher.safety_guard_active", return_value=False) @patch("audio_transcode_watcher.watcher.is_audio_file", return_value=True) @patch("time.sleep") - def test_reprocesses_all_audio_files(self, _sleep, _is_audio, _safety, mock_proc, tmp_path): + def test_reprocesses_all_audio_files( + self, _sleep, _is_audio, _safety, mock_proc, tmp_path + ): """Reprocess all audio files found in directory.""" src = tmp_path / "source" src.mkdir() @@ -345,7 +363,9 @@ def test_reprocesses_all_audio_files(self, _sleep, _is_audio, _safety, mock_proc config = Config( source_path=str(src), - outputs=[OutputConfig(name="mp3", codec="mp3", path=str(out), bitrate="192k")], + outputs=[ + OutputConfig(name="mp3", codec="mp3", path=str(out), bitrate="192k") + ], ) h = AudioSyncHandler(config) h._reprocess_directory(str(src)) @@ -364,7 +384,9 @@ def test_returns_observer_instance(self, tmp_path): out.mkdir() config = Config( source_path=str(src), - outputs=[OutputConfig(name="mp3", codec="mp3", path=str(out), bitrate="192k")], + outputs=[ + OutputConfig(name="mp3", codec="mp3", path=str(out), bitrate="192k") + ], ) observer = start_watcher(config) assert observer.is_alive() diff --git a/uv.lock b/uv.lock index bcae1ff..9553676 100644 --- a/uv.lock +++ b/uv.lock @@ -4,12 +4,13 @@ requires-python = ">=3.14" [[package]] name = "audio-transcode-watcher" -version = "0.6.0" +version = "0.6.1" source = { editable = "." } dependencies = [ { name = "mutagen" }, { name = "pyyaml" }, { name = "syncedlyrics" }, + { name = "urllib3" }, { name = "watchdog" }, ] @@ -28,6 +29,7 @@ requires-dist = [ { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.12.0" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "syncedlyrics", specifier = ">=1.0.0" }, + { name = "urllib3", specifier = ">=2.8.0" }, { name = "watchdog", specifier = ">=4.0.0" }, ] provides-extras = ["dev"] @@ -324,11 +326,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]]