make rip.py more user-friendly
Hunter
Wednesday, May 6th, 2026 at 4:50 PM
8 import re
9 from pathlib import Path
10
11 +# Bootstrap into the shared venv first thing (re-execs on first run),
12 +# so scan.rescan() has mutagen available when we call it before building.
13 +import scan
14 +scan.bootstrap(__file__)
15 +
16 +
17 def get_configuration():
18 """Prompt user for configuration values"""
19 print("=" * 60)
101 print("Building PWA files...")
102 print(f" Cache name: {cache_name}")
103
- # Load tracks.json
104 + # Rescan /mix so tracks.json reflects any additions/removals on disk
105 + # before we snapshot it into the service worker's static file list.
106 + scan.rescan(silent=True)
107 +
108 if not TRACKS_JSON.exists():
- print("Error: tracks.json not found. Run scan.py first.")
109 + print("Error: tracks.json not found.")
110 return
111
112 with open(TRACKS_JSON, 'r', encoding='utf-8') as f:
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
-scan.bootstrap(__file__)
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
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()
302 return None
303
304
-def print_progress_bar(progress, eta_str="", width=40):
- """Print a progress bar using block characters with optional ETA"""
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)
-
- output = f"\r\033[K[{bar}] {percent}%"
- if eta_str:
- output += f" · Total ETA: {eta_str}"
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
-def convert_to_mp3(input_file, output_file, track_num, title, artist,
- total_size, processed_size, start_time):
- """Convert an audio file to MP3 using ffmpeg with progress bar and ETA"""
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:
- duration = get_audio_duration(input_file)
-
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}',
- '-progress', 'pipe:1', '-y',
- str(output_file)
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,
- stderr=subprocess.DEVNULL, universal_newlines=True)
- last_percent = -1
- last_eta_str = ""
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:
- microseconds = int(line.split('=')[1])
- current_time = microseconds / 1_000_000
-
- if duration and duration > 0:
- progress = min(current_time / duration, 1.0)
- current_percent = int(progress * 100)
-
- # Calculate total ETA
- total_processed = processed_size + input_file.stat().st_size * progress
- if total_processed > 0:
- elapsed = time.time() - start_time
- bytes_per_sec = total_processed / elapsed
- eta_sec = (total_size - total_processed) / bytes_per_sec if bytes_per_sec > 0 else 0
- eta_str = f"{int(eta_sec / 60)}m {int(eta_sec % 60):02d}s"
- else:
- eta_str = ""
-
- # Update if either percentage or ETA changed
- if current_percent != last_percent or eta_str != last_eta_str:
- print_progress_bar(progress, eta_str)
- last_percent = current_percent
- last_eta_str = eta_str
349 + current_time = int(line.split('=')[1]) / 1_000_000
350 except (ValueError, IndexError):
- pass
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
424 return name
425
426
-def rip_cd():
- """Main function to rip CD to MP3 files"""
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)
463 print(f"\nCreating {MIX_DIR.name} directory...")
464 MIX_DIR.mkdir(parents=True, exist_ok=True)
465
- # Find CD mount point
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:
- print("✗ No audio CD found.")
- print("\nPlease insert an audio CD and try again.")
- print("\nNote: On some systems, you may need to manually mount the CD first.")
472 + print(" Insert an audio CD to begin (Ctrl-C to quit).")
473 if platform.system() == "Linux":
- print("\nOn Linux: sudo mount /dev/cdrom /mnt/cdrom")
- sys.exit(1)
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
491
492 print(f"✓ Found {len(audio_files)} audio track(s).")
493
- # Confirm before ripping
- print(f"\nThis will copy and convert {len(audio_files)} tracks to MP3 format.")
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}")
-
- # Prompt for artist name
- print("\nEnter the artist name for this album.")
- artist = input("Artist (press Enter for 'Unknown Artist'): ").strip()
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:
- artist = "Unknown 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
- success_count = 0
- start_time = time.time()
- total_size = sum(f.stat().st_size for f in audio_files)
- processed_size = 0
- padding_width = len(str(len(audio_files)))
-
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)
-
- # Remove leading track number to avoid duplicates like "01 1 Track"
- import re
539 cleaned_name = re.sub(r'^\d+\s*[-.]?\s*', '', base_name) or base_name
-
- padded_idx = str(idx).zfill(padding_width)
- output_file = MIX_DIR / f"{padded_idx} {cleaned_name}.mp3"
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
- # Handle duplicate filenames
- counter = 1
- while output_file.exists():
- output_file = MIX_DIR / f"{padded_idx} {cleaned_name}_{counter}.mp3"
- counter += 1
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
- print(f"[{idx}/{len(audio_files)}] {audio_file.name} -> {output_file.name}")
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
- if audio_file.suffix.lower() == '.mp3':
- try:
- shutil.copy2(audio_file, output_file)
- success_count += 1
- print(f"[████████████████████████████████████████] 100%")
- print(f"✓ Copied")
- except Exception as e:
- print(f"✗ Error: {e}")
- else:
- if convert_to_mp3(audio_file, output_file, idx, cleaned_name, artist,
- total_size, processed_size, start_time):
- success_count += 1
- print(f"✓ Converted to MP3")
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:
- print(f"✗ Conversion failed")
-
- processed_size += audio_file.stat().st_size
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
628 total_secs = int(total_time % 60)
629
630 print("-" * 60)
- print(f"\n✓ Successfully ripped {success_count}/{len(audio_files)} tracks in {total_minutes}m {total_secs}s.")
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:
- # Pick up the newly-ripped tracks (with the artist tag we just wrote
- # via ffmpeg) into tracks.json in disk order.
- scan.rescan(silent=True)
-
- print(f"\nTracks saved to: {MIX_DIR}")
- print("\nNext step: run serve.py to test your mixtape locally.")
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:
647 subprocess.run(['diskutil', 'eject', str(mount_point)], check=False)
648 elif system == "Linux":
649 subprocess.run(['eject', str(mount_point)], check=False)
- print("✓ CD ejected")
650 except Exception as e:
- print(f"Note: Could not auto-eject CD: {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"""
- rip_cd()
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__":