add rip.py; scan.py: strip preceeding track numbers

Hunter
Monday, November 10th, 2025 at 8:05 PM

rip.pynew
scan.py+21-1

rip.py
  1 +#!/usr/bin/env python3
  2 +"""
  3 +CD Ripper - Rips audio CDs to MP3 files in /tracks 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 +TRACKS_DIR = SCRIPT_DIR / "tracks"
 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("๐Ÿ’ฟ vibe capsule - 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 TRACKS_DIR.exists():
274 +		print(f"\nCreating {TRACKS_DIR.name} directory...")
275 +		TRACKS_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: {TRACKS_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 = TRACKS_DIR / f"{padded_idx} {cleaned_name}.mp3"
328 +
329 +		# Handle duplicate filenames
330 +		counter = 1
331 +		while output_file.exists():
332 +			output_file = TRACKS_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: {TRACKS_DIR}")
365 +		print("\nNext steps:")
366 +		print("  1. Run scan.py to generate tracks.json with metadata")
367 +		print("  2. Run host.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()
scan.py
 89  
 90  	if not mp3_files:
 91  		print(f"No MP3 files found in {TRACKS_DIR}")
    -		sys.exit(1)
 92 +		print(f"\nPlease add MP3 files to the {TRACKS_DIR.name} directory and run this script again.")
 93 +		sys.exit(0)
 94  
 95  	print(f"Found {len(mp3_files)} MP3 file(s). Extracting metadata...\n")
 96  

134  			print(f"โœ— Error reading {mp3_file.name}: {e}")
135  			continue
136  
137 +	# Check if ALL titles start with numbers
138 +	# If so, strip the leading numbers from all titles
139 +	import re
140 +	all_have_leading_numbers = all(
141 +		re.match(r'^\d+\s*[-.]?\s*', track['title'])
142 +		for track in tracks
143 +	)
144 +
145 +	if all_have_leading_numbers and tracks:
146 +		print("\nDetected track numbers in all titles. Stripping them...")
147 +		for track in tracks:
148 +			original_title = track['title']
149 +			# Remove leading number pattern
150 +			cleaned_title = re.sub(r'^\d+\s*[-.]?\s*', '', original_title)
151 +			if cleaned_title:  # Only update if something remains
152 +				track['title'] = cleaned_title
153 +				if cleaned_title != original_title:
154 +					print(f"  {original_title} โ†’ {cleaned_title}")
155 +
156  	if not tracks:
157  		print("\nNo valid MP3 files could be processed.")
158  		sys.exit(1)