rip to flac, fetch album art, auto build, speed up cd reads

Hunter
Saturday, October 10th, 2026 at 11:58 PM

rip.py+175-125
scan.py+6-5

rip.py
  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:
scan.py
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():