add rip.py; scan.py: strip preceeding track numbers
Hunter
Monday, November 10th, 2025 at 8:05 PM
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()
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)