rip to flac, fetch album art, auto build, speed up cd reads
Hunter
Saturday, October 10th, 2026 at 11:58 PM
1 #!/usr/bin/env python3
2 """
-Rips audio CDs to MP3 files in /mix directory
-Uses system tools: ffmpeg/ffprobe (no Python dependencies needed)
3 +Rips audio CDs to FLAC files in /mix directory
4 +(CDs that contain MP3 files are copied as-is)
5 +Looks up artist, album, and track titles on MusicBrainz
6 +Uses ffmpeg, plus discid and mutagen in the shared venv
7 +
8 +Usage: ./rip.py [track_number]
9 + ./rip.py rips the whole disc
10 + ./rip.py 3 rips only track 3
11 +
12 +If /mix holds only tracks from this CD, ripping the whole disc also
13 +downloads its cover art to mix/album_art.jpg and runs build.py
14 """
15
16 import os
33
34 SCRIPT_DIR = Path(__file__).parent.absolute()
35 MIX_DIR = SCRIPT_DIR / "mix"
36 +USER_AGENT = 'mixapps/1.0 ( https://github.com/hunterirving/mixapps )'
37
38
39 def check_ffmpeg():
49 """Attempt to install ffmpeg based on the platform"""
50 system = platform.system()
51
- print("\nffmpeg is required to convert audio files to MP3.")
52 + print("\nffmpeg is required to convert audio files to FLAC.")
53 print("Would you like to install it now? (requires admin/sudo privileges)")
54 response = input("Install ffmpeg? (y/n): ").lower().strip()
55
170
171 def lookup_musicbrainz():
172 """Compute disc ID from the default optical device and query MusicBrainz.
- Returns (artist, album, {track_num: title}) or None on any failure."""
173 + Returns (release_id, release_group_id, artist, album, {track_num: title})
174 + or None on any failure."""
175 try:
176 import discid
177 except ImportError:
184 print(f" Could not read disc ID: {e}")
185 return None
186
- url = f"https://musicbrainz.org/ws/2/discid/{disc.id}?inc=recordings+artist-credits&fmt=json"
187 + url = f"https://musicbrainz.org/ws/2/discid/{disc.id}?inc=recordings+artist-credits+release-groups&fmt=json"
188 req = urllib.request.Request(url, headers={
- 'User-Agent': 'mixapps/1.0 ( https://github.com/hunterirving/mixapps )',
189 + 'User-Agent': USER_AGENT,
190 'Accept': 'application/json',
191 })
192
223 except (TypeError, ValueError):
224 continue
225
- return artist, album, titles
226 + release_group_id = release.get('release-group', {}).get('id', '')
227 + return release.get('id', ''), release_group_id, artist, album, titles
228 +
229 +
230 +def fetch_front_cover(entity, mbid):
231 + """Return the full-resolution front cover of a MusicBrainz release or
232 + release group from the Cover Art Archive, or None if it has none"""
233 + url = f"https://coverartarchive.org/{entity}/{mbid}/front"
234 + req = urllib.request.Request(url, headers={'User-Agent': USER_AGENT})
235 + try:
236 + with urllib.request.urlopen(req, timeout=60) as resp:
237 + return resp.read()
238 + except urllib.error.HTTPError as e:
239 + if e.code == 404:
240 + return None
241 + raise
242 +
243 +
244 +def download_album_art(release_id, release_group_id):
245 + """Save the release's front cover to mix/album_art.jpg, falling back to the
246 + release group's cover, converting it to JPEG with ffmpeg if needed"""
247 + try:
248 + image = fetch_front_cover('release', release_id)
249 + if image is None and release_group_id:
250 + image = fetch_front_cover('release-group', release_group_id)
251 + except urllib.error.HTTPError as e:
252 + print(f" Album art download failed: HTTP {e.code}")
253 + return
254 + except (urllib.error.URLError, TimeoutError) as e:
255 + print(f" Album art download failed: {e}")
256 + return
257 +
258 + if image is None:
259 + print(" No album art found in the Cover Art Archive.")
260 + return
261 +
262 + album_art = MIX_DIR / "album_art.jpg"
263 + if image.startswith(b'\xff\xd8\xff'):
264 + album_art.write_bytes(image)
265 + else:
266 + download = MIX_DIR / "album_art.download"
267 + download.write_bytes(image)
268 + try:
269 + result = subprocess.run(['ffmpeg', '-loglevel', 'error', '-y', '-i', str(download),
270 + '-frames:v', '1', '-q:v', '2', str(album_art)],
271 + capture_output=True, text=True)
272 + finally:
273 + download.unlink(missing_ok=True)
274 + if result.returncode != 0:
275 + album_art.unlink(missing_ok=True)
276 + err_tail = "\n".join(result.stderr.strip().splitlines()[-5:])
277 + print(f" Could not convert album art to JPEG:\n{err_tail}")
278 + return
279 + print("✓ Saved album art to mix/album_art.jpg")
280 +
281 +
282 +def mix_tracks():
283 + """Names of the audio files currently in /mix"""
284 + return {f.name for f in MIX_DIR.iterdir() if f.suffix.lower() in scan.SUPPORTED_EXTENSIONS}
285 +
286 +
287 +def build_mixapp(album):
288 + """Run build.py, answering its prompts with the album name and the default path"""
289 + print(f"\nBuilding mixapp \"{album}\"...")
290 + subprocess.run([sys.executable, str(SCRIPT_DIR / "build.py")], input=f"{album}\n\n", text=True)
291
292
293 def find_cd_mount():
361 return sorted(audio_files, key=natural_sort_key)
362
363
-def get_audio_duration(input_file):
- """Get the duration of an audio file in seconds using ffprobe"""
- try:
- cmd = [
- 'ffprobe',
- '-v', 'error',
- '-show_entries', 'format=duration',
- '-of', 'default=noprint_wrappers=1:nokey=1',
- str(input_file)
- ]
- result = subprocess.check_output(cmd, stderr=subprocess.DEVNULL)
- return float(result.decode().strip())
- except (subprocess.CalledProcessError, ValueError):
- return None
-
-
364 def print_progress_bar(progress, disc_progress=None, time_str="", width=40, show_disc=True):
365 """Print a progress bar with width-stable percent + disc progress + remaining time"""
366 filled = int(width * progress)
377 print(output, end='', flush=True)
378
379
-def convert_to_mp3(input_file, output_file, track_num, title, artist, album,
- duration, total_size, processed_size, queued_audio_seconds,
- show_disc=True):
- """Convert an audio file to MP3 using ffmpeg with progress bar and ETA."""
- tmp_path = output_file.with_name(output_file.name + '.ripping')
- try:
- cmd = [
- 'ffmpeg', '-i', str(input_file),
- '-codec:a', 'libmp3lame', '-qscale:a', '2',
- '-f', 'mp3',
- '-metadata', f'track={track_num}',
- '-metadata', f'title={title}',
- '-metadata', f'artist={artist}',
- ]
- if album:
- cmd += ['-metadata', f'album={album}']
- cmd += ['-progress', 'pipe:1', '-y', str(tmp_path)]
-
- process = subprocess.Popen(cmd, stdout=subprocess.PIPE,
- stderr=subprocess.PIPE, universal_newlines=True)
- last_state = None
- current_time = 0.0
- speed = None
-
- for line in process.stdout:
- line = line.strip()
- if line.startswith('out_time_ms='):
- try:
- current_time = int(line.split('=')[1]) / 1_000_000
- except (ValueError, IndexError):
- continue
- elif line.startswith('speed='):
- val = line.split('=', 1)[1].rstrip('x').strip()
- try:
- speed = float(val) if val and val != 'N/A' else None
- except ValueError:
- speed = None
- else:
- continue
-
- if not (duration and duration > 0):
- continue
-
- progress = min(current_time / duration, 1.0)
- processed_bytes = input_file.stat().st_size * progress
- total_processed = processed_size + processed_bytes
- disc_progress = min(total_processed / total_size, 1.0) if total_size > 0 else 0
-
- # remaining audio seconds = rest of this track + everything after it
- audio_remaining = max(duration - current_time, 0) + queued_audio_seconds
- if speed and speed > 0:
- eta_sec = audio_remaining / speed
- time_str = f"{int(eta_sec / 60):>2d}m {int(eta_sec % 60):>2d}s"
- else:
- time_str = ""
-
380 +def copy_from_cd(input_file, local_copy, total_size, processed_size, queued_size,
381 + show_disc=True):
382 + """Copy a track off the CD in large sequential reads with progress bar and ETA."""
383 + size = input_file.stat().st_size
384 + copied = 0
385 + start_time = time.time()
386 + last_state = None
387 + with open(input_file, 'rb') as src, open(local_copy, 'wb') as dst:
388 + while chunk := src.read(1 << 20):
389 + dst.write(chunk)
390 + copied += len(chunk)
391 + progress = copied / size
392 + disc_progress = min((processed_size + copied) / total_size, 1.0) if total_size > 0 else 0
393 + rate = copied / (time.time() - start_time)
394 + eta_sec = (size - copied + queued_size) / rate
395 + time_str = f"{int(eta_sec / 60):>2d}m {int(eta_sec % 60):>2d}s"
396 state = (int(progress * 100), int(disc_progress * 100), time_str)
397 if state != last_state:
398 print_progress_bar(progress, disc_progress, time_str, show_disc=show_disc)
399 last_state = state
400
- process.wait()
- if process.returncode == 0:
- os.replace(tmp_path, output_file)
- print_progress_bar(1.0)
- print()
- return True
- else:
- stderr_output = process.stderr.read() if process.stderr else ""
- tmp_path.unlink(missing_ok=True)
- print()
- if stderr_output:
- # show the last few lines of ffmpeg's error output
- err_tail = "\n".join(stderr_output.strip().splitlines()[-5:])
- print(f" ffmpeg error:\n{err_tail}")
- return False
401
402 +def convert_to_flac(input_file, output_file, track_num, title, artist, album):
403 + """Convert a local audio file to lossless FLAC using ffmpeg"""
404 + tmp_path = output_file.with_name(output_file.name + '.ripping')
405 + cmd = [
406 + 'ffmpeg', '-loglevel', 'error', '-i', str(input_file),
407 + '-codec:a', 'flac',
408 + '-f', 'flac',
409 + '-metadata', f'track={track_num}',
410 + '-metadata', f'title={title}',
411 + '-metadata', f'artist={artist}',
412 + ]
413 + if album:
414 + cmd += ['-metadata', f'album={album}']
415 + cmd += ['-y', str(tmp_path)]
416 +
417 + try:
418 + result = subprocess.run(cmd, capture_output=True, text=True)
419 except KeyboardInterrupt:
- # kill ffmpeg, drop the half-written tmp, and let the caller exit cleanly
- try:
- process.terminate()
- process.wait(timeout=2)
- except (NameError, subprocess.TimeoutExpired):
- try:
- process.kill()
- except (NameError, ProcessLookupError):
- pass
420 tmp_path.unlink(missing_ok=True)
421 raise
422 except Exception as e:
423 tmp_path.unlink(missing_ok=True)
- print(f"\n Error: {e}")
424 + print(f" Error: {e}")
425 return False
426
427 + if result.returncode == 0:
428 + os.replace(tmp_path, output_file)
429 + return True
430 + tmp_path.unlink(missing_ok=True)
431 + if result.stderr:
432 + err_tail = "\n".join(result.stderr.strip().splitlines()[-5:])
433 + print(f" ffmpeg error:\n{err_tail}")
434 + return False
435 +
436
437 def sanitize_filename(filename):
438 """Sanitize filename to remove invalid characters"""
445 return name
446
447
-def canonical_mp3_name(artist, title):
- """Match scan.py / serve.py's canonical 'Artist – Title.mp3' form so we
448 +def output_extension(audio_file):
449 + """MP3s on the CD are copied as-is; anything else is converted to FLAC"""
450 + return '.mp3' if audio_file.suffix.lower() == '.mp3' else '.flac'
451 +
452 +
453 +def canonical_name(artist, title, ext):
454 + """Match scan.py / serve.py's canonical 'Artist – Title.ext' form so we
455 can dedup against tracks already in /mix."""
- return f"{scan._sanitize_filename(artist)} – {scan._sanitize_filename(title)}.mp3"
456 + return f"{scan._sanitize_filename(artist)} – {scan._sanitize_filename(title)}{ext}"
457
458
459 def cleanup_ripping_files():
471
472
473 def rip_cd(only_track=None):
- """Main function to rip CD to MP3 files.
474 + """Main function to rip CD to FLAC files.
475 If only_track is set, only that track number is ripped."""
476 print("=" * 60)
477 print("💿 mixapps - CD Ripper")
521 if only_track < 1 or only_track > len(audio_files):
522 print(f"✗ Track {only_track} is out of range (CD has {len(audio_files)} tracks).")
523 sys.exit(1)
- print(f"\nWill rip only track {only_track} to MP3 format.")
524 + print(f"\nWill rip only track {only_track} to FLAC format.")
525 else:
- print(f"\nThis will copy and convert {len(audio_files)} tracks to MP3 format.")
526 + print(f"\nThis will copy and convert {len(audio_files)} tracks to FLAC format.")
527 print(f"Output directory: {MIX_DIR}")
528
529 # Try MusicBrainz lookup for artist/album/track titles
532 install_libdiscid()
533
534 mb_titles = {}
535 + release_id = ""
536 + release_group_id = ""
537 album = ""
538 artist = ""
539 if check_libdiscid():
540 print("\nLooking up disc on MusicBrainz...")
541 result = lookup_musicbrainz()
542 if result:
- artist, album, mb_titles = result
543 + release_id, release_group_id, artist, album, mb_titles = result
544 print(f"✓ Found: {artist} — {album}")
545 else:
546 print(" No match found.")
556
557 # Resolve titles once and partition into skip-vs-rip.
558 import re
- planned = [] # list of (idx, audio_file, cleaned_name, will_rip, duration)
559 + planned = [] # list of (idx, audio_file, cleaned_name, will_rip, size)
560 skipped_count = 0
561 skipped_size = 0
- print("\nReading track durations...")
562 + cd_tracks = set()
563 for idx, audio_file in enumerate(audio_files, start=1):
564 if only_track is not None and idx != only_track:
565 continue
569 if idx in mb_titles and mb_titles[idx]:
570 cleaned_name = sanitize_filename(mb_titles[idx])
571
- duration = get_audio_duration(audio_file) or 0
572 + size = audio_file.stat().st_size
573
- canonical = canonical_mp3_name(artist, cleaned_name)
574 + canonical = canonical_name(artist, cleaned_name, output_extension(audio_file))
575 + cd_tracks.add(canonical)
576 if (MIX_DIR / canonical).exists():
577 print(f"Skipping track {idx} of {len(audio_files)}: {cleaned_name} (already in /mix)")
578 skipped_count += 1
- skipped_size += audio_file.stat().st_size
- planned.append((idx, audio_file, cleaned_name, False, duration))
579 + skipped_size += size
580 + planned.append((idx, audio_file, cleaned_name, False, size))
581 else:
- planned.append((idx, audio_file, cleaned_name, True, duration))
582 + planned.append((idx, audio_file, cleaned_name, True, size))
583 +
584 + only_this_cd = mix_tracks() <= cd_tracks
585
586 queued_after = []
587 tail = 0.0
588 for entry in reversed(planned):
589 queued_after.append(tail)
590 if entry[3]: # will_rip
- tail += entry[4] # duration
591 + tail += entry[4] # size
592 queued_after.reverse()
593
594 success_count = 0
601 show_disc = rip_count > 1
602
603 try:
- for entry_idx, (idx, audio_file, cleaned_name, will_rip, duration) in enumerate(planned):
604 + for entry_idx, (idx, audio_file, cleaned_name, will_rip, size) in enumerate(planned):
605 if not will_rip:
606 continue
607 padded_idx = str(idx).zfill(padding_width)
- output_file = MIX_DIR / f"{padded_idx} {cleaned_name}.mp3"
608 + ext = output_extension(audio_file)
609 + output_file = MIX_DIR / f"{padded_idx} {cleaned_name}{ext}"
610
611 # Handle duplicate filenames
612 counter = 1
613 while output_file.exists():
- output_file = MIX_DIR / f"{padded_idx} {cleaned_name}_{counter}.mp3"
614 + output_file = MIX_DIR / f"{padded_idx} {cleaned_name}_{counter}{ext}"
615 counter += 1
616
617 print(f"Ripping track {idx} of {len(audio_files)}: {cleaned_name}")
633 tmp_path.unlink(missing_ok=True)
634 print(f"✗ Error: {e}")
635 else:
- if convert_to_mp3(audio_file, output_file, idx, cleaned_name, artist, album,
- duration, total_size, processed_size, queued_after[entry_idx],
- show_disc=show_disc):
636 + local_copy = MIX_DIR / f"{audio_file.name}.ripping"
637 + try:
638 + copy_from_cd(audio_file, local_copy, total_size, processed_size,
639 + queued_after[entry_idx], show_disc=show_disc)
640 + print_progress_bar(1.0)
641 + print()
642 + converted = convert_to_flac(local_copy, output_file, idx, cleaned_name, artist, album)
643 + except Exception as e:
644 + print(f"\n Error: {e}")
645 + converted = False
646 + finally:
647 + local_copy.unlink(missing_ok=True)
648 + if converted:
649 success_count += 1
650 ripped_this_track = True
651 print(f"✓ Ripped: {cleaned_name}")
678
679 if success_count > 0:
680 print(f"\nSaved to: {MIX_DIR}")
681 +
682 + whole_cd_in_mix = only_track is None and success_count + skipped_count == planned_total
683 + if only_this_cd and whole_cd_in_mix and album:
684 + if release_id and not (MIX_DIR / "album_art.jpg").exists():
685 + download_album_art(release_id, release_group_id)
686 + build_mixapp(album)
687 + print("\nRun https_serve.py to install your mixapp.")
688 + elif success_count > 0:
689 print("\nRun serve.py to test your mixapp.")
690
691 if success_count > 0 or skipped_count == planned_total:
74 sys.argv.remove(sentinel)
75 return
76 python_path = setup_venv(packages)
- try:
- result = subprocess.run([str(python_path), script_path, sentinel, *sys.argv[1:]])
- sys.exit(result.returncode)
- except KeyboardInterrupt:
- sys.exit(130)
77 + process = subprocess.Popen([str(python_path), script_path, sentinel, *sys.argv[1:]])
78 + while True:
79 + try:
80 + sys.exit(process.wait())
81 + except KeyboardInterrupt:
82 + continue
83
84
85 def run_in_venv():