scan.py (5.9 KB)


  1 #!/usr/bin/env python3
  2 """
  3 Scans /mix directory and populates tracks.json with metadata
  4 Supports MP3, M4A, OGG, FLAC, and WAV formats
  5 Automatically manages a virtual environment for dependencies
  6 """
  7 
  8 import os
  9 import sys
 10 import subprocess
 11 import json
 12 from pathlib import Path
 13 
 14 SCRIPT_DIR = Path(__file__).parent.absolute()
 15 VENV_DIR = SCRIPT_DIR / "venv"
 16 MIX_DIR = SCRIPT_DIR / "mix"
 17 OUTPUT_FILE = MIX_DIR / "tracks.json"
 18 
 19 
 20 def setup_venv():
 21 	"""Create and set up virtual environment if it doesn't exist"""
 22 	# Determine the path to pip and python in the venv
 23 	if sys.platform == "win32":
 24 		pip_path = VENV_DIR / "Scripts" / "pip"
 25 		python_path = VENV_DIR / "Scripts" / "python"
 26 	else:
 27 		pip_path = VENV_DIR / "bin" / "pip"
 28 		python_path = VENV_DIR / "bin" / "python3"
 29 
 30 	# Check if venv needs to be created or recreated
 31 	if not VENV_DIR.exists() or not python_path.exists():
 32 		if VENV_DIR.exists():
 33 			print("Virtual environment incomplete, recreating...")
 34 			import shutil
 35 			shutil.rmtree(VENV_DIR)
 36 		else:
 37 			print("Creating virtual environment...")
 38 
 39 		try:
 40 			subprocess.check_call([sys.executable, "-m", "venv", str(VENV_DIR)])
 41 			print("Virtual environment created successfully.")
 42 		except subprocess.CalledProcessError as e:
 43 			print(f"Error creating virtual environment: {e}")
 44 			sys.exit(1)
 45 
 46 	# Ensure pip is available (sometimes venv doesn't include it)
 47 	if not pip_path.exists():
 48 		print("Installing pip in virtual environment...")
 49 		try:
 50 			subprocess.check_call([str(python_path), "-m", "ensurepip", "--upgrade"])
 51 		except subprocess.CalledProcessError as e:
 52 			print(f"Error ensuring pip: {e}")
 53 			sys.exit(1)
 54 
 55 	check = subprocess.run(
 56 		[str(python_path), "-c", "import mutagen"],
 57 		capture_output=True
 58 	)
 59 	if check.returncode != 0:
 60 		try:
 61 			subprocess.check_call([str(python_path), "-m", "pip", "install", "-q", "mutagen"])
 62 		except subprocess.CalledProcessError:
 63 			print("Note: Could not install mutagen (offline?). Metadata will be derived from filenames.\n")
 64 
 65 	return python_path
 66 
 67 
 68 def run_in_venv():
 69 	"""Re-run this script in the virtual environment"""
 70 	python_path = setup_venv()
 71 
 72 	# Re-run this script with the venv Python
 73 	print("Running scanner in virtual environment...\n")
 74 	subprocess.check_call([str(python_path), __file__, "--in-venv"])
 75 	sys.exit(0)
 76 
 77 
 78 def scan_tracks():
 79 	"""Main function to scan audio files and generate tracks.json"""
 80 	# Import mutagen here (only after venv is active)
 81 	try:
 82 		from mutagen import File as MutagenFile  # type: ignore
 83 		has_mutagen = True
 84 	except ImportError:
 85 		MutagenFile = None
 86 		has_mutagen = False
 87 		print("Mutagen not available. Metadata will be derived from filenames.\n")
 88 
 89 	# Supported audio formats
 90 	SUPPORTED_EXTENSIONS = ('.mp3', '.m4a', '.ogg', '.flac', '.wav')
 91 
 92 	# Check if tracks directory exists, create if it doesn't
 93 	if not MIX_DIR.exists():
 94 		print(f"Creating {MIX_DIR.name} directory...")
 95 		MIX_DIR.mkdir(parents=True, exist_ok=True)
 96 		print(f"āœ“ {MIX_DIR.name} directory created.")
 97 		print(f"\nAdd audio files to the {MIX_DIR.name} directory and run this script again.")
 98 		print(f"Supported formats: {', '.join(SUPPORTED_EXTENSIONS)}")
 99 		sys.exit(0)
100 
101 	# Find all supported audio files
102 	audio_files = [f for f in MIX_DIR.iterdir() if f.suffix.lower() in SUPPORTED_EXTENSIONS]
103 
104 	if not audio_files:
105 		print(f"No audio files found in {MIX_DIR}")
106 		print(f"\nPlease add audio files to the {MIX_DIR.name} directory and run this script again.")
107 		print(f"Supported formats: {', '.join(SUPPORTED_EXTENSIONS)}")
108 		sys.exit(0)
109 
110 	print(f"Found {len(audio_files)} audio file(s). Extracting metadata...\n")
111 
112 	existing_tracks = []
113 	if OUTPUT_FILE.exists():
114 		try:
115 			with open(OUTPUT_FILE, 'r', encoding='utf-8') as f:
116 				loaded = json.load(f)
117 			if isinstance(loaded, list):
118 				existing_tracks = loaded
119 		except (json.JSONDecodeError, OSError) as e:
120 			print(f"Warning: could not read existing {OUTPUT_FILE.name} ({e}). Starting fresh.\n")
121 
122 	existing_by_filename = {
123 		t['filename']: t for t in existing_tracks
124 		if isinstance(t, dict) and 'filename' in t
125 	}
126 	on_disk_filenames = {f.name for f in audio_files}
127 	files_by_name = {f.name: f for f in audio_files}
128 
129 	def read_metadata(audio_file):
130 		title = None
131 		artist = None
132 		if has_mutagen:
133 			audio = MutagenFile(audio_file, easy=True)
134 			if audio and audio.tags:
135 				title = audio.tags.get('title', [None])[0]
136 				artist = audio.tags.get('artist', [None])[0]
137 		if not title:
138 			title = audio_file.stem
139 		if not artist:
140 			artist = "Unknown Artist"
141 		return {"title": title, "artist": artist, "filename": audio_file.name}
142 
143 	tracks = []
144 	removed = []
145 	for entry in existing_tracks:
146 		if not isinstance(entry, dict) or 'filename' not in entry:
147 			continue
148 		if entry['filename'] in on_disk_filenames:
149 			tracks.append(entry)
150 		else:
151 			removed.append(entry['filename'])
152 
153 	new_files = sorted(
154 		(files_by_name[name] for name in on_disk_filenames if name not in existing_by_filename),
155 		key=lambda f: f.name,
156 	)
157 	new_tracks = []
158 	for audio_file in new_files:
159 		try:
160 			new_tracks.append(read_metadata(audio_file))
161 		except Exception as e:
162 			print(f"āœ— Error reading {audio_file.name}: {e}")
163 
164 	for track in new_tracks:
165 		print(f"+ {track['artist']} - {track['title']}")
166 	for filename in removed:
167 		print(f"- {filename} (removed; no longer on disk)")
168 	if not new_tracks and not removed:
169 		print("No changes — tracks.json already matches /mix.")
170 
171 	tracks.extend(new_tracks)
172 
173 	if not tracks:
174 		print("\nNo valid audio files could be processed.")
175 		sys.exit(1)
176 
177 	# Write to tracks.json
178 	try:
179 		with open(OUTPUT_FILE, 'w', encoding='utf-8') as f:
180 			json.dump(tracks, f, indent="\t", ensure_ascii=False)
181 
182 		print(f"\nāœ“ Successfully generated {OUTPUT_FILE.name} with {len(tracks)} track(s).")
183 
184 	except Exception as e:
185 		print(f"\nError writing {OUTPUT_FILE.name}: {e}")
186 		sys.exit(1)
187 
188 
189 def main():
190 	"""Main entry point"""
191 	# Check if we're already running in venv
192 	if "--in-venv" not in sys.argv:
193 		run_in_venv()
194 	else:
195 		scan_tracks()
196 
197 
198 if __name__ == "__main__":
199 	main()