scan.py (9.2 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(packages=("mutagen",)):
 21 	"""Create the shared venv if missing and ensure each package is installed.
 22 	Returns the path to the venv Python interpreter."""
 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 	if not VENV_DIR.exists() or not python_path.exists():
 31 		if VENV_DIR.exists():
 32 			print("Virtual environment incomplete, recreating...")
 33 			import shutil
 34 			shutil.rmtree(VENV_DIR)
 35 		else:
 36 			print("Creating virtual environment...")
 37 
 38 		try:
 39 			subprocess.check_call([sys.executable, "-m", "venv", str(VENV_DIR)])
 40 			print("Virtual environment created successfully.")
 41 		except subprocess.CalledProcessError as e:
 42 			print(f"Error creating virtual environment: {e}")
 43 			sys.exit(1)
 44 
 45 	if not pip_path.exists():
 46 		print("Installing pip in virtual environment...")
 47 		try:
 48 			subprocess.check_call([str(python_path), "-m", "ensurepip", "--upgrade"])
 49 		except subprocess.CalledProcessError as e:
 50 			print(f"Error ensuring pip: {e}")
 51 			sys.exit(1)
 52 
 53 	for pkg in packages:
 54 		check = subprocess.run(
 55 			[str(python_path), "-c", f"import {pkg}"],
 56 			capture_output=True
 57 		)
 58 		if check.returncode != 0:
 59 			try:
 60 				subprocess.check_call([str(python_path), "-m", "pip", "install", "-q", pkg])
 61 			except subprocess.CalledProcessError:
 62 				print(f"Note: Could not install {pkg} (offline?).\n")
 63 
 64 	return python_path
 65 
 66 
 67 def bootstrap(script_path, packages=("mutagen",), sentinel="--in-venv"):
 68 	"""Ensure the calling script is running inside the shared venv
 69 	with `packages` available. Re-execs through the venv Python on first call;
 70 	returns immediately on the second pass once the sentinel is in argv.
 71 
 72 	Each script that uses this should pass its own __file__ as script_path."""
 73 	if sentinel in sys.argv:
 74 		sys.argv.remove(sentinel)
 75 		return
 76 	python_path = setup_venv(packages)
 77 	try:
 78 		result = subprocess.run([str(python_path), script_path, sentinel, *sys.argv[1:]])
 79 		sys.exit(result.returncode)
 80 	except KeyboardInterrupt:
 81 		sys.exit(130)
 82 
 83 
 84 def run_in_venv():
 85 	"""Re-run this script in the virtual environment (CLI use of scan.py)."""
 86 	python_path = setup_venv()
 87 	print("Running scanner in virtual environment...\n")
 88 	subprocess.check_call([str(python_path), __file__, "--in-venv"])
 89 	sys.exit(0)
 90 
 91 
 92 SUPPORTED_EXTENSIONS = ('.mp3', '.m4a', '.ogg', '.flac', '.wav')
 93 
 94 _INVALID_FILENAME_CHARS = '<>:"/\\|?*'
 95 
 96 
 97 def _sanitize_filename(name):
 98 	for char in _INVALID_FILENAME_CHARS:
 99 		name = name.replace(char, '')
100 	return name.strip()
101 
102 
103 def _write_tags(audio_file, artist, title, MutagenFile):
104 	"""Write artist/title tags to disk if they don't already match. Returns
105 	True if anything was written."""
106 	try:
107 		audio = MutagenFile(audio_file, easy=True)
108 		if audio is None:
109 			return False
110 		if audio.tags is None:
111 			audio.add_tags()
112 		current_title = audio.tags.get('title', [None])[0]
113 		current_artist = audio.tags.get('artist', [None])[0]
114 		if current_title == title and current_artist == artist:
115 			return False
116 		audio.tags['title'] = title
117 		audio.tags['artist'] = artist
118 		audio.save()
119 		return True
120 	except Exception:
121 		return False
122 
123 
124 def _canonicalize(tracks, has_mutagen, MutagenFile, silent):
125 	"""Rename files to 'Artist – Title.ext' and sync ID3 tags so disk state
126 	matches tracks.json metadata. Mutates `tracks` in place; returns a list
127 	of {from, to} renames so callers can migrate any external state (eg.
128 	preloaded blob URLs in connected browser tabs)."""
129 	renames = []
130 	for entry in tracks:
131 		old_filename = entry['filename']
132 		ext = Path(old_filename).suffix
133 		artist = _sanitize_filename(entry['artist'])
134 		title = _sanitize_filename(entry['title'])
135 		new_filename = f"{artist} – {title}{ext}"
136 
137 		old_path = MIX_DIR / old_filename
138 		new_path = MIX_DIR / new_filename
139 
140 		# Keep tags aligned with the (possibly hand-edited) tracks.json
141 		# entry, regardless of whether a rename is needed.
142 		if has_mutagen and old_path.exists():
143 			_write_tags(old_path, entry['artist'], entry['title'], MutagenFile)
144 
145 		if old_filename == new_filename:
146 			continue
147 		if not old_path.exists():
148 			continue
149 		if new_path.exists():
150 			# Don't clobber an unrelated file that already occupies the
151 			# canonical name. Leave the entry pointing at its current file.
152 			if not silent:
153 				print(f"  Skipping rename {old_filename} -> {new_filename} (target exists)")
154 			continue
155 		try:
156 			old_path.rename(new_path)
157 			entry['filename'] = new_filename
158 			renames.append({'from': old_filename, 'to': new_filename})
159 			if not silent:
160 				print(f"  Renamed: {old_filename} -> {new_filename}")
161 		except Exception as e:
162 			if not silent:
163 				print(f"  Error renaming {old_filename}: {e}")
164 	return renames
165 
166 
167 def rescan(silent=False):
168 	"""Reconcile tracks.json with /mix. Renames files to canonical
169 	'Artist – Title.ext' form when they drift, syncing ID3 tags at the same
170 	time. Returns (tracks, changed, renames) where renames is a list of
171 	{from, to} dicts for any files that moved this pass."""
172 	try:
173 		from mutagen import File as MutagenFile  # type: ignore
174 		has_mutagen = True
175 	except ImportError:
176 		MutagenFile = None
177 		has_mutagen = False
178 		if not silent:
179 			print("Mutagen not available. Metadata will be derived from filenames.\n")
180 
181 	if not MIX_DIR.exists():
182 		MIX_DIR.mkdir(parents=True, exist_ok=True)
183 
184 	audio_files = [f for f in MIX_DIR.iterdir() if f.suffix.lower() in SUPPORTED_EXTENSIONS]
185 
186 	existing_tracks = []
187 	if OUTPUT_FILE.exists():
188 		try:
189 			with open(OUTPUT_FILE, 'r', encoding='utf-8') as f:
190 				loaded = json.load(f)
191 			if isinstance(loaded, list):
192 				existing_tracks = loaded
193 		except (json.JSONDecodeError, OSError) as e:
194 			if not silent:
195 				print(f"Warning: could not read existing {OUTPUT_FILE.name} ({e}). Starting fresh.\n")
196 
197 	existing_by_filename = {
198 		t['filename']: t for t in existing_tracks
199 		if isinstance(t, dict) and 'filename' in t
200 	}
201 	on_disk_filenames = {f.name for f in audio_files}
202 	files_by_name = {f.name: f for f in audio_files}
203 
204 	def read_metadata(audio_file):
205 		title = None
206 		artist = None
207 		if has_mutagen:
208 			audio = MutagenFile(audio_file, easy=True)
209 			if audio and audio.tags:
210 				title = audio.tags.get('title', [None])[0]
211 				artist = audio.tags.get('artist', [None])[0]
212 		if not title:
213 			title = audio_file.stem
214 		if not artist:
215 			artist = "Unknown Artist"
216 		return {"title": title, "artist": artist, "filename": audio_file.name}
217 
218 	tracks = []
219 	removed = []
220 	for entry in existing_tracks:
221 		if not isinstance(entry, dict) or 'filename' not in entry:
222 			continue
223 		if entry['filename'] in on_disk_filenames:
224 			tracks.append(entry)
225 		else:
226 			removed.append(entry['filename'])
227 
228 	new_files = sorted(
229 		(files_by_name[name] for name in on_disk_filenames if name not in existing_by_filename),
230 		key=lambda f: f.name,
231 	)
232 	new_tracks = []
233 	for audio_file in new_files:
234 		try:
235 			new_tracks.append(read_metadata(audio_file))
236 		except Exception as e:
237 			if not silent:
238 				print(f"✗ Error reading {audio_file.name}: {e}")
239 
240 	if not silent:
241 		for track in new_tracks:
242 			print(f"+ {track['artist']} - {track['title']}")
243 		for filename in removed:
244 			print(f"- {filename} (removed; no longer on disk)")
245 		if not new_tracks and not removed:
246 			print("No changes — tracks.json already matches /mix.")
247 
248 	tracks.extend(new_tracks)
249 
250 	renames = _canonicalize(tracks, has_mutagen, MutagenFile, silent)
251 
252 	changed = bool(new_tracks) or bool(removed) or bool(renames)
253 	if changed or not OUTPUT_FILE.exists():
254 		try:
255 			OUTPUT_FILE.write_text(
256 				json.dumps(tracks, indent='\t', ensure_ascii=False) + '\n',
257 				encoding='utf-8',
258 			)
259 		except Exception as e:
260 			if not silent:
261 				print(f"\nError writing {OUTPUT_FILE.name}: {e}")
262 			raise
263 
264 	return tracks, changed, renames
265 
266 
267 def scan_tracks():
268 	"""CLI entry point: rescan and print a summary."""
269 	if not MIX_DIR.exists():
270 		print(f"Creating {MIX_DIR.name} directory...")
271 		MIX_DIR.mkdir(parents=True, exist_ok=True)
272 		print(f"✓ {MIX_DIR.name} directory created.")
273 		print(f"\nAdd audio files to the {MIX_DIR.name} directory and run this script again.")
274 		print(f"Supported formats: {', '.join(SUPPORTED_EXTENSIONS)}")
275 		sys.exit(0)
276 
277 	audio_files = [f for f in MIX_DIR.iterdir() if f.suffix.lower() in SUPPORTED_EXTENSIONS]
278 	if not audio_files:
279 		print(f"No audio files found in {MIX_DIR}")
280 		print(f"\nPlease add audio files to the {MIX_DIR.name} directory and run this script again.")
281 		print(f"Supported formats: {', '.join(SUPPORTED_EXTENSIONS)}")
282 		sys.exit(0)
283 
284 	print(f"Found {len(audio_files)} audio file(s). Extracting metadata...\n")
285 	tracks, changed, _renames = rescan(silent=False)
286 	if not tracks:
287 		print("\nNo valid audio files could be processed.")
288 		sys.exit(1)
289 	if changed:
290 		print(f"\n✓ Updated {OUTPUT_FILE.name} ({len(tracks)} track(s)).")
291 
292 
293 def main():
294 	"""Main entry point"""
295 	# Check if we're already running in venv
296 	if "--in-venv" not in sys.argv:
297 		run_in_venv()
298 	else:
299 		scan_tracks()
300 
301 
302 if __name__ == "__main__":
303 	main()