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()