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