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()