make rip.py more user-friendly

Hunter
Wednesday, May 6th, 2026 at 4:50 PM

build.py+11-2
rip.py+373-100

build.py
  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:
rip.py
 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__":