109 lines
3.1 KiB
Python
109 lines
3.1 KiB
Python
"""
|
|
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
|