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