"""File operation helpers for the import flow.""" from __future__ import annotations import logging import os import shutil import subprocess import time from pathlib import Path from config.settings import config_manager logger = logging.getLogger("imports.file_ops") def safe_move_file(src, dst): """Move a file safely across filesystems.""" src = Path(src) dst = Path(dst) dst.parent.mkdir(parents=True, exist_ok=True) if not src.exists(): if dst.exists(): logger.info(f"Source gone but destination exists, file already transferred: {dst.name}") return raise FileNotFoundError(f"Source file not found and destination does not exist: {src}") if dst.exists(): for _attempt in range(3): try: dst.unlink() break except PermissionError: if _attempt < 2: time.sleep(1) else: logger.warning(f"Could not remove locked destination after 3 attempts: {dst.name}") except Exception: break try: shutil.move(str(src), str(dst)) return except FileNotFoundError: if dst.exists(): logger.info(f"Source moved by another thread, destination exists: {dst.name}") return raise except (OSError, PermissionError) as e: error_msg = str(e).lower() if dst.exists() and dst.stat().st_size > 0: logger.warning(f"Move raised {type(e).__name__} but destination exists, treating as success: {e}") try: src.unlink() except Exception: logger.info(f"Could not delete source file (may be owned by another process): {src}") return if "cross-device" in error_msg or "operation not permitted" in error_msg or "permission denied" in error_msg: logger.warning(f"Cross-device move detected, using fallback copy method: {e}") try: with open(src, "rb") as f_src: with open(dst, "wb") as f_dst: shutil.copyfileobj(f_src, f_dst) f_dst.flush() os.fsync(f_dst.fileno()) try: src.unlink() except PermissionError: logger.info(f"Could not delete source file (may be owned by another process): {src}") logger.info(f"Successfully moved file using fallback method: {src} -> {dst}") return except Exception as fallback_error: logger.error(f"Fallback copy also failed: {fallback_error}") raise raise def cleanup_empty_directories(download_path, moved_file_path): """Remove empty directories after a move, ignoring hidden files.""" try: current_dir = os.path.dirname(moved_file_path) while current_dir != download_path and current_dir.startswith(download_path): is_empty = not any(not f.startswith(".") for f in os.listdir(current_dir)) if is_empty: logger.warning(f"Removing empty directory: {current_dir}") os.rmdir(current_dir) current_dir = os.path.dirname(current_dir) else: break except Exception as e: logger.error(f"An error occurred during directory cleanup: {e}") def get_audio_quality_string(file_path): """Return a compact audio quality string for the given file.""" try: ext = os.path.splitext(file_path)[1].lower() if ext == ".flac": from mutagen.flac import FLAC audio = FLAC(file_path) return f"FLAC {audio.info.bits_per_sample}bit" if ext == ".mp3": from mutagen.mp3 import MP3, BitrateMode audio = MP3(file_path) bitrate_kbps = audio.info.bitrate // 1000 if audio.info.bitrate_mode == BitrateMode.VBR: return "MP3-VBR" return f"MP3-{bitrate_kbps}" if ext in (".m4a", ".aac", ".mp4"): from mutagen.mp4 import MP4 audio = MP4(file_path) return f"M4A-{audio.info.bitrate // 1000}" if ext == ".ogg": from mutagen.oggvorbis import OggVorbis audio = OggVorbis(file_path) return f"OGG-{audio.info.bitrate // 1000}" if ext == ".opus": from mutagen.oggopus import OggOpus audio = OggOpus(file_path) return f"OPUS-{audio.info.bitrate // 1000}" return "" except Exception as e: logger.debug(f"Could not determine audio quality for {file_path}: {e}") return "" def get_quality_tier_from_extension(file_path): """Classify a file extension into a quality tier.""" if not file_path: return ("unknown", 999) ext = os.path.splitext(file_path)[1].lower() quality_tiers = { "lossless": { "extensions": [".flac", ".ape", ".wav", ".alac", ".dsf", ".dff", ".aiff", ".aif"], "tier": 1, }, "high_lossy": { "extensions": [".opus", ".ogg"], "tier": 2, }, "standard_lossy": { "extensions": [".m4a", ".aac"], "tier": 3, }, "low_lossy": { "extensions": [".mp3", ".wma"], "tier": 4, }, } for tier_name, tier_data in quality_tiers.items(): if ext in tier_data["extensions"]: return (tier_name, tier_data["tier"]) return ("unknown", 999) def downsample_hires_flac(final_path, context): """Downsample a hi-res FLAC to 16-bit/44.1kHz if enabled.""" from mutagen.flac import FLAC if not config_manager.get("lossy_copy.downsample_hires", False): return None if os.path.splitext(final_path)[1].lower() != ".flac": return None try: audio = FLAC(final_path) original_bits = audio.info.bits_per_sample original_rate = audio.info.sample_rate except Exception as e: logger.error(f"[Downsample] Could not read FLAC info: {e}") return None if original_bits <= 16 and original_rate <= 44100: return None logger.info(f"[Downsample] Converting {original_bits}-bit/{original_rate}Hz -> 16-bit/44100Hz: {os.path.basename(final_path)}") ffmpeg_bin = shutil.which("ffmpeg") if not ffmpeg_bin: local = os.path.join(os.path.dirname(__file__), "tools", "ffmpeg") if os.path.isfile(local): ffmpeg_bin = local else: logger.warning("[Downsample] ffmpeg not found - skipping hi-res conversion") return None temp_path = final_path + ".tmp.flac" try: result = subprocess.run( [ ffmpeg_bin, "-i", final_path, "-sample_fmt", "s16", "-ar", "44100", "-map_metadata", "0", "-compression_level", "8", "-y", temp_path, ], capture_output=True, text=True, timeout=300, ) if result.returncode != 0: logger.error(f"[Downsample] ffmpeg failed: {result.stderr[:200]}") if os.path.exists(temp_path): os.remove(temp_path) return None if not os.path.isfile(temp_path) or os.path.getsize(temp_path) == 0: logger.warning("[Downsample] Output file missing or empty") if os.path.exists(temp_path): os.remove(temp_path) return None verify_audio = FLAC(temp_path) if verify_audio.info.bits_per_sample != 16: logger.info(f"[Downsample] Output not 16-bit ({verify_audio.info.bits_per_sample}-bit), aborting") os.remove(temp_path) return None os.replace(temp_path, final_path) logger.info(f"[Downsample] Converted to 16-bit/44.1kHz: {os.path.basename(final_path)}") new_quality = "FLAC 16bit" try: updated_audio = FLAC(final_path) updated_audio["QUALITY"] = new_quality updated_audio.save() except Exception as tag_err: logger.error(f"[Downsample] Could not update QUALITY tag: {tag_err}") old_quality = context.get("_audio_quality", "") context["_audio_quality"] = new_quality if old_quality and old_quality != new_quality and old_quality in os.path.basename(final_path): new_basename = os.path.basename(final_path).replace(old_quality, new_quality) new_path = os.path.join(os.path.dirname(final_path), new_basename) try: os.rename(final_path, new_path) logger.info(f"[Downsample] Renamed: {os.path.basename(final_path)} -> {new_basename}") for lyrics_ext in (".lrc", ".txt"): old_lyrics = os.path.splitext(final_path)[0] + lyrics_ext if os.path.isfile(old_lyrics): new_lyrics = os.path.splitext(new_path)[0] + lyrics_ext os.rename(old_lyrics, new_lyrics) return new_path except Exception as rename_err: logger.error(f"[Downsample] Could not rename file: {rename_err}") return final_path except subprocess.TimeoutExpired: logger.info(f"[Downsample] Conversion timed out for: {os.path.basename(final_path)}") if os.path.exists(temp_path): os.remove(temp_path) except Exception as e: logger.error(f"[Downsample] Conversion error: {e}") if os.path.exists(temp_path): try: os.remove(temp_path) except Exception: pass return None def create_lossy_copy(final_path): """Convert a FLAC file to a lossy copy using the configured codec.""" from mutagen.flac import FLAC if not config_manager.get("lossy_copy.enabled", False): return None if os.path.splitext(final_path)[1].lower() != ".flac": return None codec = config_manager.get("lossy_copy.codec", "mp3").lower() bitrate = config_manager.get("lossy_copy.bitrate", "320") if codec == "opus" and int(bitrate) > 256: bitrate = "256" codec_map = { "mp3": ("libmp3lame", ".mp3", f"MP3-{bitrate}", ["-vn", "-id3v2_version", "3"]), "opus": ("libopus", ".opus", f"OPUS-{bitrate}", ["-vn", "-map", "0:a", "-vbr", "on"]), "aac": ("aac", ".m4a", f"AAC-{bitrate}", ["-vn", "-movflags", "+faststart"]), } if codec not in codec_map: logger.info(f"[Lossy Copy] Unknown codec '{codec}' - skipping conversion") return None ffmpeg_codec, out_ext, quality_label, extra_args = codec_map[codec] out_path = os.path.splitext(final_path)[0] + out_ext original_quality = get_audio_quality_string(final_path) if original_quality: out_basename = os.path.basename(out_path) if original_quality in out_basename: out_basename = out_basename.replace(original_quality, quality_label) out_path = os.path.join(os.path.dirname(out_path), out_basename) ffmpeg_bin = shutil.which("ffmpeg") if not ffmpeg_bin: local = os.path.join(os.path.dirname(__file__), "tools", "ffmpeg") if os.path.isfile(local): ffmpeg_bin = local else: logger.warning(f"[Lossy Copy] ffmpeg not found - skipping {codec.upper()} conversion") return None try: logger.info(f"[Lossy Copy] Converting to {quality_label}: {os.path.basename(final_path)}") cmd = [ ffmpeg_bin, "-i", final_path, "-codec:a", ffmpeg_codec, "-b:a", f"{bitrate}k", "-map_metadata", "0", ] + extra_args + ["-y", out_path] result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) if result.returncode == 0: logger.info(f"[Lossy Copy] Created {quality_label} copy: {os.path.basename(out_path)}") try: from mutagen import File as MutagenFile audio = MutagenFile(out_path) if audio is not None: if codec == "mp3": from mutagen.id3 import TXXX audio.tags.add(TXXX(encoding=3, desc="QUALITY", text=[quality_label])) elif codec == "opus": audio["QUALITY"] = [quality_label] elif codec == "aac": from mutagen.mp4 import MP4FreeForm audio["----:com.apple.iTunes:QUALITY"] = [MP4FreeForm(quality_label.encode("utf-8"))] audio.save() except Exception as tag_err: logger.error(f"[Lossy Copy] Could not update QUALITY tag: {tag_err}") return out_path logger.error(f"[Lossy Copy] ffmpeg failed: {result.stderr[:200]}") if os.path.exists(out_path): try: os.remove(out_path) except Exception: pass return None except subprocess.TimeoutExpired: logger.warning(f"[Lossy Copy] Conversion timed out for: {os.path.basename(final_path)}") except Exception as e: logger.error(f"[Lossy Copy] Conversion error: {e}") return None