Initial commit, prod ready
This commit is contained in:
108
backend/library.py
Normal file
108
backend/library.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user