rip.py (11.1 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 import subprocess 10 import shutil 11 import time 12 from pathlib import Path 13 import platform 14 15 SCRIPT_DIR = Path(__file__).parent.absolute() 16 MIX_DIR = SCRIPT_DIR / "mix" 17 18 19 def check_ffmpeg(): 20 """Check if ffmpeg is installed, offer to install if not""" 21 try: 22 subprocess.check_output(['ffmpeg', '-version'], stderr=subprocess.DEVNULL) 23 return True 24 except (subprocess.CalledProcessError, FileNotFoundError): 25 return False 26 27 28 def install_ffmpeg(): 29 """Attempt to install ffmpeg based on the platform""" 30 system = platform.system() 31 32 print("\nffmpeg is required to convert audio files to MP3.") 33 print("Would you like to install it now? (requires admin/sudo privileges)") 34 response = input("Install ffmpeg? (y/n): ").lower().strip() 35 36 if response != 'y': 37 print("Cannot proceed without ffmpeg. Exiting.") 38 sys.exit(1) 39 40 try: 41 if system == "Darwin": # macOS 42 print("\nAttempting to install ffmpeg via Homebrew...") 43 # Check if brew is installed 44 try: 45 subprocess.check_output(['brew', '--version'], stderr=subprocess.DEVNULL) 46 except FileNotFoundError: 47 print("Error: Homebrew is not installed.") 48 print("Please install Homebrew from https://brew.sh or install ffmpeg manually.") 49 sys.exit(1) 50 subprocess.check_call(['brew', 'install', 'ffmpeg']) 51 52 elif system == "Linux": 53 print("\nAttempting to install ffmpeg...") 54 # Try to detect package manager 55 if shutil.which('apt'): 56 subprocess.check_call(['sudo', 'apt', 'update']) 57 subprocess.check_call(['sudo', 'apt', 'install', '-y', 'ffmpeg']) 58 elif shutil.which('dnf'): 59 subprocess.check_call(['sudo', 'dnf', 'install', '-y', 'ffmpeg']) 60 elif shutil.which('pacman'): 61 subprocess.check_call(['sudo', 'pacman', '-S', '--noconfirm', 'ffmpeg']) 62 else: 63 print("Error: Could not detect package manager.") 64 print("Please install ffmpeg manually for your distribution.") 65 sys.exit(1) 66 67 elif system == "Windows": 68 print("\nAutomatic installation not supported on Windows.") 69 print("Please download ffmpeg from https://ffmpeg.org/download.html") 70 print("and add it to your PATH.") 71 sys.exit(1) 72 else: 73 print(f"\nAutomatic installation not supported on {system}.") 74 print("Please install ffmpeg manually.") 75 sys.exit(1) 76 77 print("โ ffmpeg installed successfully.") 78 return True 79 80 except subprocess.CalledProcessError as e: 81 print(f"Error installing ffmpeg: {e}") 82 print("Please install ffmpeg manually.") 83 sys.exit(1) 84 85 86 def find_cd_mount(): 87 """Find the mount point of an audio CD""" 88 system = platform.system() 89 90 if system == "Darwin": # macOS 91 # Check /Volumes for CD mounts 92 volumes = Path("/Volumes") 93 if not volumes.exists(): 94 return None 95 96 # Look for CD mounts (typically Audio CD or similar) 97 for vol in volumes.iterdir(): 98 if vol.is_dir(): 99 # Check if this volume contains audio files 100 audio_files = list(vol.glob("*.aiff")) + list(vol.glob("*.aif")) 101 if audio_files: 102 return vol 103 return None 104 105 elif system == "Linux": 106 # Check common mount points 107 mount_points = [ 108 Path("/media") / os.getlogin(), 109 Path("/run/media") / os.getlogin(), 110 Path("/mnt"), 111 ] 112 113 for mount_base in mount_points: 114 if mount_base.exists(): 115 for vol in mount_base.iterdir(): 116 if vol.is_dir(): 117 # Check for audio files 118 audio_files = (list(vol.glob("*.wav")) + 119 list(vol.glob("*.aiff")) + 120 list(vol.glob("*.aif"))) 121 if audio_files: 122 return vol 123 return None 124 125 else: 126 print(f"Platform {system} not fully supported yet.") 127 return None 128 129 130 def natural_sort_key(path): 131 """Generate a key for natural sorting of filenames with numbers""" 132 import re 133 # Split filename into text and number parts 134 parts = [] 135 for part in re.split(r'(\d+)', str(path.name)): 136 if part.isdigit(): 137 parts.append(int(part)) # Convert numbers to integers for proper sorting 138 else: 139 parts.append(part.lower()) # Lowercase for case-insensitive sorting 140 return parts 141 142 143 def get_audio_files(mount_point): 144 """Get all audio files from the CD mount point""" 145 audio_extensions = ['*.wav', '*.aiff', '*.aif', '*.flac', '*.mp3'] 146 audio_files = [] 147 148 for ext in audio_extensions: 149 audio_files.extend(mount_point.glob(ext)) 150 # Also check subdirectories (some CDs have nested structures) 151 audio_files.extend(mount_point.glob(f"*/{ext}")) 152 153 # Sort using natural sorting (handles numbers correctly) 154 return sorted(audio_files, key=natural_sort_key) 155 156 157 def get_audio_duration(input_file): 158 """Get the duration of an audio file in seconds using ffprobe""" 159 try: 160 cmd = [ 161 'ffprobe', 162 '-v', 'error', 163 '-show_entries', 'format=duration', 164 '-of', 'default=noprint_wrappers=1:nokey=1', 165 str(input_file) 166 ] 167 result = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) 168 return float(result.decode().strip()) 169 except (subprocess.CalledProcessError, ValueError): 170 return None 171 172 173 def print_progress_bar(progress, eta_str="", width=40): 174 """Print a progress bar using block characters with optional ETA""" 175 filled = int(width * progress) 176 bar = 'โ' * filled + 'โ' * (width - filled) 177 percent = int(progress * 100) 178 179 output = f"\r\033[K[{bar}] {percent}%" 180 if eta_str: 181 output += f" ยท Total ETA: {eta_str}" 182 print(output, end='', flush=True) 183 184 185 def convert_to_mp3(input_file, output_file, track_num, title, artist, 186 total_size, processed_size, start_time): 187 """Convert an audio file to MP3 using ffmpeg with progress bar and ETA""" 188 try: 189 duration = get_audio_duration(input_file) 190 191 cmd = [ 192 'ffmpeg', '-i', str(input_file), 193 '-codec:a', 'libmp3lame', '-qscale:a', '2', 194 '-metadata', f'track={track_num}', 195 '-metadata', f'title={title}', 196 '-metadata', f'artist={artist}', 197 '-progress', 'pipe:1', '-y', 198 str(output_file) 199 ] 200 201 process = subprocess.Popen(cmd, stdout=subprocess.PIPE, 202 stderr=subprocess.DEVNULL, universal_newlines=True) 203 last_percent = -1 204 last_eta_str = "" 205 206 for line in process.stdout: 207 if line.startswith('out_time_ms='): 208 try: 209 microseconds = int(line.split('=')[1]) 210 current_time = microseconds / 1_000_000 211 212 if duration and duration > 0: 213 progress = min(current_time / duration, 1.0) 214 current_percent = int(progress * 100) 215 216 # Calculate total ETA 217 total_processed = processed_size + input_file.stat().st_size * progress 218 if total_processed > 0: 219 elapsed = time.time() - start_time 220 bytes_per_sec = total_processed / elapsed 221 eta_sec = (total_size - total_processed) / bytes_per_sec if bytes_per_sec > 0 else 0 222 eta_str = f"{int(eta_sec / 60)}m {int(eta_sec % 60):02d}s" 223 else: 224 eta_str = "" 225 226 # Update if either percentage or ETA changed 227 if current_percent != last_percent or eta_str != last_eta_str: 228 print_progress_bar(progress, eta_str) 229 last_percent = current_percent 230 last_eta_str = eta_str 231 except (ValueError, IndexError): 232 pass 233 234 process.wait() 235 if process.returncode == 0: 236 print_progress_bar(1.0) 237 print() 238 return True 239 else: 240 print() 241 return False 242 243 except Exception as e: 244 print(f"\n Error: {e}") 245 return False 246 247 248 def sanitize_filename(filename): 249 """Sanitize filename to remove invalid characters""" 250 # Remove extension 251 name = Path(filename).stem 252 # Replace invalid characters 253 invalid_chars = '<>:"|?*\\' 254 for char in invalid_chars: 255 name = name.replace(char, '_') 256 return name 257 258 259 def rip_cd(): 260 """Main function to rip CD to MP3 files""" 261 print("=" * 60) 262 print("๐ฟ mixapps - CD Ripper") 263 print("=" * 60) 264 265 # Check for ffmpeg 266 if not check_ffmpeg(): 267 print("\nโ ffmpeg not found.") 268 install_ffmpeg() 269 else: 270 print("\nโ ffmpeg found.") 271 272 # Create tracks directory if it doesn't exist 273 if not MIX_DIR.exists(): 274 print(f"\nCreating {MIX_DIR.name} directory...") 275 MIX_DIR.mkdir(parents=True, exist_ok=True) 276 277 # Find CD mount point 278 print("\nSearching for audio CD...") 279 mount_point = find_cd_mount() 280 281 if not mount_point: 282 print("โ No audio CD found.") 283 print("\nPlease insert an audio CD and try again.") 284 print("\nNote: On some systems, you may need to manually mount the CD first.") 285 if platform.system() == "Linux": 286 print("\nOn Linux: sudo mount /dev/cdrom /mnt/cdrom") 287 sys.exit(1) 288 289 print(f"โ Found CD at: {mount_point}") 290 291 # Get audio files from CD 292 audio_files = get_audio_files(mount_point) 293 294 if not audio_files: 295 print("โ No audio files found on the CD.") 296 sys.exit(1) 297 298 print(f"โ Found {len(audio_files)} audio track(s).") 299 300 # Confirm before ripping 301 print(f"\nThis will copy and convert {len(audio_files)} tracks to MP3 format.") 302 print(f"Output directory: {MIX_DIR}") 303 304 # Prompt for artist name 305 print("\nEnter the artist name for this album.") 306 artist = input("Artist (press Enter for 'Unknown Artist'): ").strip() 307 if not artist: 308 artist = "Unknown Artist" 309 310 print("\nRipping CD...") 311 print("-" * 60) 312 313 success_count = 0 314 start_time = time.time() 315 total_size = sum(f.stat().st_size for f in audio_files) 316 processed_size = 0 317 padding_width = len(str(len(audio_files))) 318 319 for idx, audio_file in enumerate(audio_files, start=1): 320 base_name = sanitize_filename(audio_file.name) 321 322 # Remove leading track number to avoid duplicates like "01 1 Track" 323 import re 324 cleaned_name = re.sub(r'^\d+\s*[-.]?\s*', '', base_name) or base_name 325 326 padded_idx = str(idx).zfill(padding_width) 327 output_file = MIX_DIR / f"{padded_idx} {cleaned_name}.mp3" 328 329 # Handle duplicate filenames 330 counter = 1 331 while output_file.exists(): 332 output_file = MIX_DIR / f"{padded_idx} {cleaned_name}_{counter}.mp3" 333 counter += 1 334 335 print(f"[{idx}/{len(audio_files)}] {audio_file.name} -> {output_file.name}") 336 337 if audio_file.suffix.lower() == '.mp3': 338 try: 339 shutil.copy2(audio_file, output_file) 340 success_count += 1 341 print(f"[โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ] 100%") 342 print(f"โ Copied") 343 except Exception as e: 344 print(f"โ Error: {e}") 345 else: 346 if convert_to_mp3(audio_file, output_file, idx, cleaned_name, artist, 347 total_size, processed_size, start_time): 348 success_count += 1 349 print(f"โ Converted to MP3") 350 else: 351 print(f"โ Conversion failed") 352 353 processed_size += audio_file.stat().st_size 354 355 # Calculate total time 356 total_time = time.time() - start_time 357 total_minutes = int(total_time / 60) 358 total_secs = int(total_time % 60) 359 360 print("-" * 60) 361 print(f"\nโ Successfully ripped {success_count}/{len(audio_files)} tracks in {total_minutes}m {total_secs}s.") 362 363 if success_count > 0: 364 print(f"\nTracks saved to: {MIX_DIR}") 365 print("\nNext steps:") 366 print(" 1. Run scan.py to generate tracks.json with metadata") 367 print(" 2. Run serve.py to test your mixtape locally") 368 369 # Eject the CD 370 print("\nEjecting CD...") 371 try: 372 system = platform.system() 373 if system == "Darwin": # macOS 374 subprocess.run(['diskutil', 'eject', str(mount_point)], check=False) 375 elif system == "Linux": 376 subprocess.run(['eject', str(mount_point)], check=False) 377 print("โ CD ejected") 378 except Exception as e: 379 print(f"Note: Could not auto-eject CD: {e}") 380 print("You can manually eject it.") 381 382 383 def main(): 384 """Main entry point""" 385 rip_cd() 386 387 388 if __name__ == "__main__": 389 main()