rip.py (20.3 KB)
1 #!/usr/bin/env python3 2 """ 3 Rips audio CDs to MP3 files in /mix directory 4 Uses system tools: ffmpeg/ffprobe (no Python dependencies needed) 5 """ 6 7 import os 8 import sys 9 10 # Hop into the shared venv (managed by scan.py) before doing anything else, 11 # so scan.rescan() has mutagen available when we call it after ripping. 12 sys.path.insert(0, str(os.path.dirname(os.path.abspath(__file__)))) 13 import scan 14 scan.bootstrap(__file__, packages=("mutagen", "discid")) 15 16 import subprocess 17 import shutil 18 import time 19 import json 20 import urllib.request 21 import urllib.error 22 from pathlib import Path 23 import platform 24 25 SCRIPT_DIR = Path(__file__).parent.absolute() 26 MIX_DIR = SCRIPT_DIR / "mix" 27 28 29 def check_ffmpeg(): 30 """Check if ffmpeg is installed, offer to install if not""" 31 try: 32 subprocess.check_output(['ffmpeg', '-version'], stderr=subprocess.DEVNULL) 33 return True 34 except (subprocess.CalledProcessError, FileNotFoundError): 35 return False 36 37 38 def install_ffmpeg(): 39 """Attempt to install ffmpeg based on the platform""" 40 system = platform.system() 41 42 print("\nffmpeg is required to convert audio files to MP3.") 43 print("Would you like to install it now? (requires admin/sudo privileges)") 44 response = input("Install ffmpeg? (y/n): ").lower().strip() 45 46 if response != 'y': 47 print("Cannot proceed without ffmpeg. Exiting.") 48 sys.exit(1) 49 50 try: 51 if system == "Darwin": # macOS 52 print("\nAttempting to install ffmpeg via Homebrew...") 53 # Check if brew is installed 54 try: 55 subprocess.check_output(['brew', '--version'], stderr=subprocess.DEVNULL) 56 except FileNotFoundError: 57 print("Error: Homebrew is not installed.") 58 print("Please install Homebrew from https://brew.sh or install ffmpeg manually.") 59 sys.exit(1) 60 subprocess.check_call(['brew', 'install', 'ffmpeg']) 61 62 elif system == "Linux": 63 print("\nAttempting to install ffmpeg...") 64 # Try to detect package manager 65 if shutil.which('apt'): 66 subprocess.check_call(['sudo', 'apt', 'update']) 67 subprocess.check_call(['sudo', 'apt', 'install', '-y', 'ffmpeg']) 68 elif shutil.which('dnf'): 69 subprocess.check_call(['sudo', 'dnf', 'install', '-y', 'ffmpeg']) 70 elif shutil.which('pacman'): 71 subprocess.check_call(['sudo', 'pacman', '-S', '--noconfirm', 'ffmpeg']) 72 else: 73 print("Error: Could not detect package manager.") 74 print("Please install ffmpeg manually for your distribution.") 75 sys.exit(1) 76 77 elif system == "Windows": 78 print("\nAutomatic installation not supported on Windows.") 79 print("Please download ffmpeg from https://ffmpeg.org/download.html") 80 print("and add it to your PATH.") 81 sys.exit(1) 82 else: 83 print(f"\nAutomatic installation not supported on {system}.") 84 print("Please install ffmpeg manually.") 85 sys.exit(1) 86 87 print("✓ ffmpeg installed successfully.") 88 return True 89 90 except subprocess.CalledProcessError as e: 91 print(f"Error installing ffmpeg: {e}") 92 print("Please install ffmpeg manually.") 93 sys.exit(1) 94 95 96 def check_libdiscid(): 97 """Verify the libdiscid C library is loadable via the discid Python package""" 98 try: 99 import discid 100 _ = discid.LIBDISCID_VERSION_STRING 101 return True 102 except (ImportError, OSError, AttributeError): 103 return False 104 105 106 def install_libdiscid(): 107 """Attempt to install libdiscid based on the platform. Returns True on success.""" 108 system = platform.system() 109 110 print("\nlibdiscid is required for CD metadata lookup (artist, album, titles).") 111 print("Would you like to install it now? (requires admin/sudo privileges)") 112 response = input("Install libdiscid? (y/n): ").lower().strip() 113 114 if response != 'y': 115 return False 116 117 try: 118 if system == "Darwin": 119 try: 120 subprocess.check_output(['brew', '--version'], stderr=subprocess.DEVNULL) 121 except FileNotFoundError: 122 print("\nError: Homebrew is not installed.") 123 print("Install Homebrew from https://brew.sh, then run: brew install libdiscid") 124 return False 125 print("\nInstalling libdiscid via Homebrew...") 126 subprocess.check_call(['brew', 'install', 'libdiscid']) 127 128 elif system == "Linux": 129 print("\nInstalling libdiscid...") 130 if shutil.which('apt'): 131 subprocess.check_call(['sudo', 'apt', 'update']) 132 subprocess.check_call(['sudo', 'apt', 'install', '-y', 'libdiscid0']) 133 elif shutil.which('dnf'): 134 subprocess.check_call(['sudo', 'dnf', 'install', '-y', 'libdiscid']) 135 elif shutil.which('pacman'): 136 subprocess.check_call(['sudo', 'pacman', '-S', '--noconfirm', 'libdiscid']) 137 else: 138 print("Error: Could not detect package manager.") 139 print("Please install libdiscid manually for your distribution.") 140 return False 141 142 elif system == "Windows": 143 print("\nAutomatic installation not supported on Windows.") 144 print("Download libdiscid from https://musicbrainz.org/doc/libdiscid") 145 print("and place discid.dll alongside rip.py (or on your PATH).") 146 return False 147 else: 148 print(f"\nAutomatic installation not supported on {system}.") 149 print("Please install libdiscid manually.") 150 return False 151 152 print("✓ libdiscid installed successfully.") 153 return True 154 155 except subprocess.CalledProcessError as e: 156 print(f"\nError installing libdiscid: {e}") 157 print("You can install it manually, or skip metadata lookup and enter the artist by hand.") 158 return False 159 160 161 def lookup_musicbrainz(): 162 """Compute disc ID from the default optical device and query MusicBrainz. 163 Returns (artist, album, {track_num: title}) or None on any failure.""" 164 try: 165 import discid 166 except ImportError: 167 return None 168 169 try: 170 # discid.read() with no argument uses the platform's default optical device 171 disc = discid.read() 172 except Exception as e: 173 print(f" Could not read disc ID: {e}") 174 return None 175 176 url = f"https://musicbrainz.org/ws/2/discid/{disc.id}?inc=recordings+artist-credits&fmt=json" 177 req = urllib.request.Request(url, headers={ 178 'User-Agent': 'mixapps/1.0 ( https://github.com/hunterirving/mixapps )', 179 'Accept': 'application/json', 180 }) 181 182 try: 183 with urllib.request.urlopen(req, timeout=10) as resp: 184 data = json.loads(resp.read().decode()) 185 except urllib.error.HTTPError as e: 186 if e.code == 404: 187 print(" Disc not found in MusicBrainz database.") 188 else: 189 print(f" MusicBrainz lookup failed: HTTP {e.code}") 190 return None 191 except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as e: 192 print(f" MusicBrainz lookup failed: {e}") 193 return None 194 195 releases = data.get('releases', []) 196 if not releases: 197 return None 198 199 release = releases[0] 200 album = release.get('title', '') 201 artist = ' & '.join( 202 ac.get('name', '') for ac in release.get('artist-credit', []) 203 if isinstance(ac, dict) and ac.get('name') 204 ) or 'Unknown Artist' 205 206 titles = {} 207 for medium in release.get('media', []): 208 for track in medium.get('tracks', []): 209 try: 210 num = int(track.get('position') or track.get('number')) 211 titles[num] = track.get('title', '') 212 except (TypeError, ValueError): 213 continue 214 215 return artist, album, titles 216 217 218 def find_cd_mount(): 219 """Find the mount point of an audio CD""" 220 system = platform.system() 221 222 if system == "Darwin": # macOS 223 # Check /Volumes for CD mounts 224 volumes = Path("/Volumes") 225 if not volumes.exists(): 226 return None 227 228 # Look for CD mounts (typically Audio CD or similar) 229 for vol in volumes.iterdir(): 230 if vol.is_dir(): 231 # Check if this volume contains audio files 232 audio_files = list(vol.glob("*.aiff")) + list(vol.glob("*.aif")) 233 if audio_files: 234 return vol 235 return None 236 237 elif system == "Linux": 238 # Check common mount points 239 mount_points = [ 240 Path("/media") / os.getlogin(), 241 Path("/run/media") / os.getlogin(), 242 Path("/mnt"), 243 ] 244 245 for mount_base in mount_points: 246 if mount_base.exists(): 247 for vol in mount_base.iterdir(): 248 if vol.is_dir(): 249 # Check for audio files 250 audio_files = (list(vol.glob("*.wav")) + 251 list(vol.glob("*.aiff")) + 252 list(vol.glob("*.aif"))) 253 if audio_files: 254 return vol 255 return None 256 257 else: 258 print(f"Platform {system} not fully supported yet.") 259 return None 260 261 262 def natural_sort_key(path): 263 """Generate a key for natural sorting of filenames with numbers""" 264 import re 265 # Split filename into text and number parts 266 parts = [] 267 for part in re.split(r'(\d+)', str(path.name)): 268 if part.isdigit(): 269 parts.append(int(part)) # Convert numbers to integers for proper sorting 270 else: 271 parts.append(part.lower()) # Lowercase for case-insensitive sorting 272 return parts 273 274 275 def get_audio_files(mount_point): 276 """Get all audio files from the CD mount point""" 277 audio_extensions = ['*.wav', '*.aiff', '*.aif', '*.flac', '*.mp3'] 278 audio_files = [] 279 280 for ext in audio_extensions: 281 audio_files.extend(mount_point.glob(ext)) 282 # Also check subdirectories (some CDs have nested structures) 283 audio_files.extend(mount_point.glob(f"*/{ext}")) 284 285 # Sort using natural sorting (handles numbers correctly) 286 return sorted(audio_files, key=natural_sort_key) 287 288 289 def get_audio_duration(input_file): 290 """Get the duration of an audio file in seconds using ffprobe""" 291 try: 292 cmd = [ 293 'ffprobe', 294 '-v', 'error', 295 '-show_entries', 'format=duration', 296 '-of', 'default=noprint_wrappers=1:nokey=1', 297 str(input_file) 298 ] 299 result = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) 300 return float(result.decode().strip()) 301 except (subprocess.CalledProcessError, ValueError): 302 return None 303 304 305 def print_progress_bar(progress, disc_progress=None, time_str="", width=40, show_disc=True): 306 """Print a progress bar with width-stable percent + disc progress + remaining time""" 307 filled = int(width * progress) 308 bar = '█' * filled + '░' * (width - filled) 309 percent = int(progress * 100) 310 311 output = f"\r\033[K[{bar}] {percent:>2}%" 312 if disc_progress is not None: 313 if show_disc: 314 disc_pct = int(disc_progress * 100) 315 output += f" Total: {disc_pct}%" 316 if time_str: 317 output += f" (remaining: {time_str})" 318 print(output, end='', flush=True) 319 320 321 def convert_to_mp3(input_file, output_file, track_num, title, artist, album, 322 duration, total_size, processed_size, queued_audio_seconds, 323 show_disc=True): 324 """Convert an audio file to MP3 using ffmpeg with progress bar and ETA.""" 325 tmp_path = output_file.with_name(output_file.name + '.ripping') 326 try: 327 cmd = [ 328 'ffmpeg', '-i', str(input_file), 329 '-codec:a', 'libmp3lame', '-qscale:a', '2', 330 '-f', 'mp3', 331 '-metadata', f'track={track_num}', 332 '-metadata', f'title={title}', 333 '-metadata', f'artist={artist}', 334 ] 335 if album: 336 cmd += ['-metadata', f'album={album}'] 337 cmd += ['-progress', 'pipe:1', '-y', str(tmp_path)] 338 339 process = subprocess.Popen(cmd, stdout=subprocess.PIPE, 340 stderr=subprocess.PIPE, universal_newlines=True) 341 last_state = None 342 current_time = 0.0 343 speed = None 344 345 for line in process.stdout: 346 line = line.strip() 347 if line.startswith('out_time_ms='): 348 try: 349 current_time = int(line.split('=')[1]) / 1_000_000 350 except (ValueError, IndexError): 351 continue 352 elif line.startswith('speed='): 353 val = line.split('=', 1)[1].rstrip('x').strip() 354 try: 355 speed = float(val) if val and val != 'N/A' else None 356 except ValueError: 357 speed = None 358 else: 359 continue 360 361 if not (duration and duration > 0): 362 continue 363 364 progress = min(current_time / duration, 1.0) 365 processed_bytes = input_file.stat().st_size * progress 366 total_processed = processed_size + processed_bytes 367 disc_progress = min(total_processed / total_size, 1.0) if total_size > 0 else 0 368 369 # remaining audio seconds = rest of this track + everything after it 370 audio_remaining = max(duration - current_time, 0) + queued_audio_seconds 371 if speed and speed > 0: 372 eta_sec = audio_remaining / speed 373 time_str = f"{int(eta_sec / 60):>2d}m {int(eta_sec % 60):>2d}s" 374 else: 375 time_str = "" 376 377 state = (int(progress * 100), int(disc_progress * 100), time_str) 378 if state != last_state: 379 print_progress_bar(progress, disc_progress, time_str, show_disc=show_disc) 380 last_state = state 381 382 process.wait() 383 if process.returncode == 0: 384 os.replace(tmp_path, output_file) 385 print_progress_bar(1.0) 386 print() 387 return True 388 else: 389 stderr_output = process.stderr.read() if process.stderr else "" 390 tmp_path.unlink(missing_ok=True) 391 print() 392 if stderr_output: 393 # show the last few lines of ffmpeg's error output 394 err_tail = "\n".join(stderr_output.strip().splitlines()[-5:]) 395 print(f" ffmpeg error:\n{err_tail}") 396 return False 397 398 except KeyboardInterrupt: 399 # kill ffmpeg, drop the half-written tmp, and let the caller exit cleanly 400 try: 401 process.terminate() 402 process.wait(timeout=2) 403 except (NameError, subprocess.TimeoutExpired): 404 try: 405 process.kill() 406 except (NameError, ProcessLookupError): 407 pass 408 tmp_path.unlink(missing_ok=True) 409 raise 410 except Exception as e: 411 tmp_path.unlink(missing_ok=True) 412 print(f"\n Error: {e}") 413 return False 414 415 416 def sanitize_filename(filename): 417 """Sanitize filename to remove invalid characters""" 418 # Remove extension 419 name = Path(filename).stem 420 # Replace invalid characters 421 invalid_chars = '<>:"|?*\\' 422 for char in invalid_chars: 423 name = name.replace(char, '_') 424 return name 425 426 427 def canonical_mp3_name(artist, title): 428 """Match scan.py / serve.py's canonical 'Artist – Title.mp3' form so we 429 can dedup against tracks already in /mix.""" 430 return f"{scan._sanitize_filename(artist)} – {scan._sanitize_filename(title)}.mp3" 431 432 433 def cleanup_ripping_files(): 434 """Remove any leftover .ripping temp files from a previous interrupted run""" 435 if not MIX_DIR.exists(): 436 return 437 stale = list(MIX_DIR.glob('*.ripping')) 438 for f in stale: 439 try: 440 f.unlink() 441 except OSError: 442 pass 443 if stale: 444 print(f"Cleaned up {len(stale)} stale .ripping file(s) from previous run.") 445 446 447 def rip_cd(only_track=None): 448 """Main function to rip CD to MP3 files. 449 If only_track is set, only that track number is ripped.""" 450 print("=" * 60) 451 print("💿 mixapps - CD Ripper") 452 print("=" * 60) 453 454 # Check for ffmpeg 455 if not check_ffmpeg(): 456 print("\n✗ ffmpeg not found.") 457 install_ffmpeg() 458 else: 459 print("\n✓ ffmpeg found.") 460 461 # Create tracks directory if it doesn't exist 462 if not MIX_DIR.exists(): 463 print(f"\nCreating {MIX_DIR.name} directory...") 464 MIX_DIR.mkdir(parents=True, exist_ok=True) 465 466 cleanup_ripping_files() 467 468 # Find CD mount point — poll until one appears (Ctrl-C to quit) 469 print("\nSearching for audio CD...") 470 mount_point = find_cd_mount() 471 if not mount_point: 472 print(" Insert an audio CD to begin (Ctrl-C to quit).") 473 if platform.system() == "Linux": 474 print(" On Linux you may need: sudo mount /dev/cdrom /mnt/cdrom") 475 try: 476 while not mount_point: 477 time.sleep(2) 478 mount_point = find_cd_mount() 479 except KeyboardInterrupt: 480 print("\n\n⚠ Cancelled.") 481 sys.exit(130) 482 483 print(f"✓ Found CD at: {mount_point}") 484 485 # Get audio files from CD 486 audio_files = get_audio_files(mount_point) 487 488 if not audio_files: 489 print("✗ No audio files found on the CD.") 490 sys.exit(1) 491 492 print(f"✓ Found {len(audio_files)} audio track(s).") 493 494 if only_track is not None: 495 if only_track < 1 or only_track > len(audio_files): 496 print(f"✗ Track {only_track} is out of range (CD has {len(audio_files)} tracks).") 497 sys.exit(1) 498 print(f"\nWill rip only track {only_track} to MP3 format.") 499 else: 500 print(f"\nThis will copy and convert {len(audio_files)} tracks to MP3 format.") 501 print(f"Output directory: {MIX_DIR}") 502 503 # Try MusicBrainz lookup for artist/album/track titles 504 if not check_libdiscid(): 505 print("\n⚠ libdiscid not found — needed for automatic CD metadata lookup.") 506 install_libdiscid() 507 508 mb_titles = {} 509 album = "" 510 artist = "" 511 if check_libdiscid(): 512 print("\nLooking up disc on MusicBrainz...") 513 result = lookup_musicbrainz() 514 if result: 515 artist, album, mb_titles = result 516 print(f"✓ Found: {artist} — {album}") 517 else: 518 print(" No match found.") 519 520 if not artist: 521 print("\nEnter the artist name for this album.") 522 artist = input("Artist (press Enter for 'Unknown Artist'): ").strip() 523 if not artist: 524 artist = "Unknown Artist" 525 526 print("\nRipping CD...") 527 print("-" * 60) 528 529 # Resolve titles once and partition into skip-vs-rip. 530 import re 531 planned = [] # list of (idx, audio_file, cleaned_name, will_rip, duration) 532 skipped_count = 0 533 skipped_size = 0 534 print("\nReading track durations...") 535 for idx, audio_file in enumerate(audio_files, start=1): 536 if only_track is not None and idx != only_track: 537 continue 538 base_name = sanitize_filename(audio_file.name) 539 cleaned_name = re.sub(r'^\d+\s*[-.]?\s*', '', base_name) or base_name 540 # prefer MB title — cleaner than the OS-scraped .aiff name 541 if idx in mb_titles and mb_titles[idx]: 542 cleaned_name = sanitize_filename(mb_titles[idx]) 543 544 duration = get_audio_duration(audio_file) or 0 545 546 canonical = canonical_mp3_name(artist, cleaned_name) 547 if (MIX_DIR / canonical).exists(): 548 print(f"Skipping track {idx} of {len(audio_files)}: {cleaned_name} (already in /mix)") 549 skipped_count += 1 550 skipped_size += audio_file.stat().st_size 551 planned.append((idx, audio_file, cleaned_name, False, duration)) 552 else: 553 planned.append((idx, audio_file, cleaned_name, True, duration)) 554 555 queued_after = [] 556 tail = 0.0 557 for entry in reversed(planned): 558 queued_after.append(tail) 559 if entry[3]: # will_rip 560 tail += entry[4] # duration 561 queued_after.reverse() 562 563 success_count = 0 564 start_time = time.time() 565 total_size = sum(f.stat().st_size for _, f, _, _, _ in planned) 566 # pre-seed with skipped bytes so Disc% starts at the right baseline 567 processed_size = skipped_size 568 padding_width = len(str(len(audio_files))) 569 rip_count = sum(1 for _, _, _, will_rip, _ in planned if will_rip) 570 show_disc = rip_count > 1 571 572 try: 573 for entry_idx, (idx, audio_file, cleaned_name, will_rip, duration) in enumerate(planned): 574 if not will_rip: 575 continue 576 padded_idx = str(idx).zfill(padding_width) 577 output_file = MIX_DIR / f"{padded_idx} {cleaned_name}.mp3" 578 579 # Handle duplicate filenames 580 counter = 1 581 while output_file.exists(): 582 output_file = MIX_DIR / f"{padded_idx} {cleaned_name}_{counter}.mp3" 583 counter += 1 584 585 print(f"Ripping track {idx} of {len(audio_files)}: {cleaned_name}") 586 587 ripped_this_track = False 588 if audio_file.suffix.lower() == '.mp3': 589 tmp_path = output_file.with_name(output_file.name + '.ripping') 590 try: 591 shutil.copy2(audio_file, tmp_path) 592 os.replace(tmp_path, output_file) 593 success_count += 1 594 ripped_this_track = True 595 print(f"[████████████████████████████████████████] 100%") 596 print(f"✓ Ripped: {cleaned_name}") 597 except KeyboardInterrupt: 598 tmp_path.unlink(missing_ok=True) 599 raise 600 except Exception as e: 601 tmp_path.unlink(missing_ok=True) 602 print(f"✗ Error: {e}") 603 else: 604 if convert_to_mp3(audio_file, output_file, idx, cleaned_name, artist, album, 605 duration, total_size, processed_size, queued_after[entry_idx], 606 show_disc=show_disc): 607 success_count += 1 608 ripped_this_track = True 609 print(f"✓ Ripped: {cleaned_name}") 610 else: 611 print(f"✗ Conversion failed") 612 613 if ripped_this_track: 614 scan.rescan(silent=True) 615 616 processed_size += audio_file.stat().st_size 617 except KeyboardInterrupt: 618 print("\n\n⚠ Interrupted. Cleaning up...") 619 cleanup_ripping_files() 620 if success_count > 0: 621 scan.rescan(silent=True) 622 print(f"Ripped {success_count} track(s) before interrupt.") 623 sys.exit(130) 624 625 # Calculate total time 626 total_time = time.time() - start_time 627 total_minutes = int(total_time / 60) 628 total_secs = int(total_time % 60) 629 630 print("-" * 60) 631 planned_total = len(planned) 632 summary = f"\n✓ Successfully ripped {success_count}/{planned_total} tracks in {total_minutes}m {total_secs}s." 633 if skipped_count: 634 summary += f" ({skipped_count} skipped — already in /mix)" 635 print(summary) 636 637 if success_count > 0: 638 print(f"\nSaved to: {MIX_DIR}") 639 print("\nRun serve.py to test your mixapp.") 640 641 if success_count > 0 or skipped_count == planned_total: 642 # Eject the CD 643 print("\nEjecting CD...") 644 try: 645 system = platform.system() 646 if system == "Darwin": # macOS 647 subprocess.run(['diskutil', 'eject', str(mount_point)], check=False) 648 elif system == "Linux": 649 subprocess.run(['eject', str(mount_point)], check=False) 650 except Exception as e: 651 print(f"Could not auto-eject CD: {e}") 652 print("You can manually eject it.") 653 654 655 def main(): 656 """Main entry point""" 657 only_track = None 658 if len(sys.argv) > 1: 659 try: 660 only_track = int(sys.argv[1]) 661 if only_track < 1: 662 raise ValueError 663 except ValueError: 664 print(f"Usage: {sys.argv[0]} [track_number]") 665 print(" track_number: optional positive integer to rip only that track") 666 sys.exit(1) 667 rip_cd(only_track=only_track) 668 669 670 if __name__ == "__main__": 671 main()