o2aif (9519B)
1 #!/usr/bin/env python3 2 3 import argparse 4 import subprocess 5 import sys 6 from concurrent.futures import ThreadPoolExecutor, as_completed 7 from dataclasses import dataclass 8 from pathlib import Path 9 10 11 @dataclass 12 class ConversionStats: 13 converted: int = 0 14 artwork_preserved: int = 0 15 no_artwork: int = 0 16 artwork_lost: int = 0 17 skipped: int = 0 18 errors: int = 0 19 deleted: int = 0 20 21 22 def check_artwork(file_path): 23 """Return True if the file contains a video/artwork stream.""" 24 result = subprocess.run( 25 [ 26 "ffprobe", 27 "-v", 28 "error", 29 "-select_streams", 30 "v", 31 "-show_entries", 32 "stream=codec_type,stream_disposition=attached_pic", 33 "-of", 34 "default=noprint_wrappers=1:nokey=1", 35 str(file_path), 36 ], 37 capture_output=True, 38 text=True, 39 ) 40 output = result.stdout 41 return "video" in output or "attached_pic=1" in output 42 43 44 COVER_NAMES = ("cover", "folder", "front", "albumart", "album") 45 COVER_EXTS = (".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp") 46 47 48 def find_external_cover(input_path): 49 """Return the path to a cover image next to the input, or None.""" 50 parent = input_path.parent 51 for name in COVER_NAMES: 52 for ext in COVER_EXTS: 53 for variant in (name + ext, name.title() + ext, name.upper() + ext): 54 candidate = parent / variant 55 if candidate.is_file(): 56 return candidate 57 return None 58 59 60 def convert_opus_to_aiff(input_path, delete_original=True): 61 """Convert a single Opus file to AIFF. 62 63 Returns (success, status) where status is one of: 64 "artwork_preserved", "no_artwork", "artwork_lost", "skipped". 65 On failure returns (False, "error"). 66 """ 67 output_path = input_path.with_suffix(".aiff") 68 69 if output_path.exists(): 70 print(f"Skipping {input_path} - {output_path} already exists") 71 return True, "skipped" 72 73 print(f"Converting {input_path} to {output_path}") 74 75 had_embedded_artwork = check_artwork(input_path) 76 external_cover = None if had_embedded_artwork else find_external_cover(input_path) 77 78 try: 79 ffmpeg_cmd = [ 80 "ffmpeg", 81 "-i", 82 str(input_path), 83 ] 84 85 if external_cover: 86 ffmpeg_cmd.extend(["-i", str(external_cover)]) 87 88 ffmpeg_cmd.extend( 89 [ 90 "-c:a", 91 "pcm_s16be", # 16-bit PCM (AIFF default audio codec) 92 "-map", 93 "0:a", # Map audio stream 94 "-map_metadata", 95 "0:s:0", # Copy Vorbis-comment tags to global AIFF ID3 metadata 96 "-write_id3v2", 97 "1", # Write ID3v2 chunk (required: AIFF muxer defaults to off) 98 "-id3v2_version", 99 "3", # ID3v2.3 for broad compatibility 100 ] 101 ) 102 103 if had_embedded_artwork: 104 ffmpeg_cmd.extend(["-map", "0:v?", "-c:v", "copy"]) 105 elif external_cover: 106 ffmpeg_cmd.extend(["-map", "1:v", "-c:v", "copy"]) 107 108 ffmpeg_cmd.extend(["-f", "aiff", str(output_path)]) 109 110 result = subprocess.run(ffmpeg_cmd, check=True, capture_output=True) 111 112 # Surface any non-fatal ffmpeg warnings. 113 if result.stderr: 114 for line in result.stderr.decode(errors="replace").splitlines(): 115 if line.strip().lower().startswith("warning"): 116 print(f" ffmpeg warning: {line.strip()}") 117 118 has_output_artwork = check_artwork(output_path) 119 if has_output_artwork: 120 if had_embedded_artwork: 121 print( 122 f"Successfully converted {input_path} (embedded artwork preserved)" 123 ) 124 status = "artwork_preserved" 125 elif external_cover: 126 print( 127 f"Successfully converted {input_path} " 128 f"(external cover {external_cover.name} embedded)" 129 ) 130 status = "artwork_preserved" 131 else: 132 print(f"Successfully converted {input_path} (artwork preserved)") 133 status = "artwork_preserved" 134 else: 135 if had_embedded_artwork or external_cover: 136 # Artwork existed in source or as external file but did not 137 # make it to the output. Keep the original so the user can 138 # investigate / re-encode. 139 print( 140 f"Warning: Artwork did not transfer for {input_path}; " 141 "keeping original" 142 ) 143 return True, "artwork_lost" 144 print(f"Successfully converted {input_path}") 145 status = "no_artwork" 146 147 if delete_original: 148 try: 149 input_path.unlink() 150 print(f"Deleted original file: {input_path}") 151 except OSError as e: 152 print(f"Warning: Could not delete original file {input_path}: {str(e)}") 153 154 return True, status 155 156 except subprocess.CalledProcessError as e: 157 print(f"Error converting {input_path}:") 158 print(f"ffmpeg error: {e.stderr.decode(errors='replace')}") 159 return False, "error" 160 except Exception as e: 161 print(f"Unexpected error converting {input_path}: {str(e)}") 162 return False, "error" 163 164 165 def iter_opus_files(start_dir): 166 """Yield Opus files under start_dir, case-insensitively. 167 168 pathlib's rglob is case-sensitive on Linux, so *.OPUS would be missed. 169 """ 170 lower = list(start_dir.rglob("*.opus")) 171 upper = list(start_dir.rglob("*.OPUS")) 172 # Deduplicate while preserving a stable order. 173 seen = set() 174 for p in lower + upper: 175 rp = p.resolve() 176 if rp not in seen: 177 seen.add(rp) 178 yield p 179 180 181 def main(): 182 parser = argparse.ArgumentParser( 183 description="Convert Opus files to AIFF format recursively." 184 ) 185 parser.add_argument("folder_path", help="Path to the folder containing Opus files") 186 parser.add_argument( 187 "--keep-original", 188 action="store_true", 189 help="Keep original Opus files after conversion (default: delete)", 190 ) 191 parser.add_argument( 192 "--dry-run", 193 action="store_true", 194 help="Show what would be converted without writing or deleting anything", 195 ) 196 parser.add_argument( 197 "--jobs", 198 "-j", 199 type=int, 200 default=1, 201 help="Number of files to convert in parallel (default: 1)", 202 ) 203 args = parser.parse_args() 204 205 start_dir = Path(args.folder_path).resolve() 206 if not start_dir.exists(): 207 print(f"Error: The directory '{start_dir}' does not exist.") 208 return 1 209 if not start_dir.is_dir(): 210 print(f"Error: '{start_dir}' is not a directory.") 211 return 1 212 213 files = list(iter_opus_files(start_dir)) 214 if not files: 215 print(f"No Opus files found under {start_dir}") 216 return 0 217 218 stats = ConversionStats() 219 delete_original = not args.keep_original 220 221 def handle(file_path): 222 return convert_opus_to_aiff(file_path, delete_original) 223 224 if args.dry_run: 225 for f in files: 226 print(f"[dry-run] Would convert {f} -> {f.with_suffix('.aiff')}") 227 print(f"\n{len(files)} file(s) would be converted.") 228 return 0 229 230 if args.jobs > 1: 231 with ThreadPoolExecutor(max_workers=args.jobs) as pool: 232 futures = {pool.submit(handle, f): f for f in files} 233 for fut in as_completed(futures): 234 f = futures[fut] 235 try: 236 success, status = fut.result() 237 except Exception as e: 238 print(f"Failed to process {f}: {str(e)}") 239 stats.errors += 1 240 continue 241 _tally(stats, success, status, f, delete_original) 242 else: 243 for f in files: 244 try: 245 success, status = handle(f) 246 except Exception as e: 247 print(f"Failed to process {f}: {str(e)}") 248 stats.errors += 1 249 continue 250 _tally(stats, success, status, f, delete_original) 251 252 print("\nConversion Complete!") 253 print(f"Successfully converted: {stats.converted} files") 254 if stats.artwork_preserved: 255 print(f" with artwork preserved: {stats.artwork_preserved}") 256 if stats.no_artwork: 257 print(f" without artwork: {stats.no_artwork}") 258 if stats.skipped: 259 print(f"Skipped (already exist): {stats.skipped} files") 260 if stats.artwork_lost: 261 print(f"Artwork transfer failed (originals kept): {stats.artwork_lost} files") 262 if not args.keep_original: 263 print(f"Original files deleted: {stats.deleted} files") 264 if stats.errors: 265 print(f"Errors encountered: {stats.errors} files") 266 267 return 1 if stats.errors else 0 268 269 270 def _tally(stats, success, status, file_path, delete_original): 271 if not success: 272 stats.errors += 1 273 return 274 if status == "skipped": 275 stats.skipped += 1 276 return 277 stats.converted += 1 278 if status == "artwork_preserved": 279 stats.artwork_preserved += 1 280 elif status == "no_artwork": 281 stats.no_artwork += 1 282 elif status == "artwork_lost": 283 stats.artwork_lost += 1 284 if delete_original and not file_path.exists() and status != "artwork_lost": 285 stats.deleted += 1 286 287 288 if __name__ == "__main__": 289 sys.exit(main())