""" Music library helpers — read folder tree, delete files/folders. File serving is handled directly in main.py via FastAPI's FileResponse. """ import shutil from pathlib import Path from typing import Any from config import MUSIC_DIR def get_library() -> list[dict[str, Any]]: """ Walk the Music directory and return a nested structure: [ { "artist": "Queen", "albums": [ { "album": "A Night at the Opera", "tracks": [ { "filename": "01 - Bohemian Rhapsody.mp3", "path": "Queen/A Night at the Opera/01 - Bohemian Rhapsody.mp3" } ] } ] } ] """ artists: list[dict[str, Any]] = [] if not MUSIC_DIR.exists(): return artists for artist_dir in sorted(MUSIC_DIR.iterdir()): if not artist_dir.is_dir(): continue albums: list[dict[str, Any]] = [] for album_dir in sorted(artist_dir.iterdir()): if not album_dir.is_dir(): continue tracks = [] for track_file in sorted(album_dir.iterdir()): if track_file.suffix.lower() not in {".mp3", ".flac", ".m4a", ".ogg"}: continue rel = track_file.relative_to(MUSIC_DIR) tracks.append({ "filename": track_file.name, "path": rel.as_posix(), "size": track_file.stat().st_size, }) if tracks: albums.append({"album": album_dir.name, "tracks": tracks}) if albums: artists.append({"artist": artist_dir.name, "albums": albums}) return artists def delete_path(rel_path: str) -> None: """ Delete a file or directory relative to MUSIC_DIR. Raises ValueError if the resolved path escapes MUSIC_DIR (path traversal guard). """ target = (MUSIC_DIR / rel_path).resolve() # Security: ensure target is inside MUSIC_DIR if not str(target).startswith(str(MUSIC_DIR)): raise ValueError("Path traversal detected.") if not target.exists(): raise FileNotFoundError(f"Not found: {rel_path}") if target.is_dir(): shutil.rmtree(target) else: target.unlink() # Clean up empty parent directories (album → artist) _remove_empty_parents(target.parent) def _remove_empty_parents(directory: Path) -> None: """Walk up and remove directories that are now empty, stopping at MUSIC_DIR.""" current = directory while current != MUSIC_DIR and current.is_dir(): if not any(current.iterdir()): current.rmdir() current = current.parent else: break def resolve_track_path(rel_path: str) -> Path: """ Resolve a relative path (e.g. 'Artist/Album/track.mp3') to an absolute path inside MUSIC_DIR. Raises ValueError on path traversal. """ target = (MUSIC_DIR / rel_path).resolve() if not str(target).startswith(str(MUSIC_DIR)): raise ValueError("Path traversal detected.") if not target.exists(): raise FileNotFoundError(f"Not found: {rel_path}") return target