Files
musicseerr/backend/api/v1/routes/profile.py
T
2026-04-03 15:53:00 +01:00

209 lines
7.1 KiB
Python

import asyncio
import logging
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import FileResponse
from api.v1.schemas.profile import (
ProfileResponse,
ProfileSettings,
ProfileUpdateRequest,
ServiceConnection,
LibraryStats,
)
from core.dependencies import (
get_preferences_service,
get_jellyfin_library_service,
get_local_files_service,
get_navidrome_library_service,
get_settings_service,
)
from core.config import Settings, get_settings
from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute
from services.preferences_service import PreferencesService
from services.jellyfin_library_service import JellyfinLibraryService
from services.local_files_service import LocalFilesService
from services.navidrome_library_service import NavidromeLibraryService
logger = logging.getLogger(__name__)
AVATAR_DIR_NAME = "profile"
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
MAX_AVATAR_SIZE = 5 * 1024 * 1024 # 5 MB
router = APIRouter(route_class=MsgSpecRoute, prefix="/profile", tags=["profile"])
@router.get("", response_model=ProfileResponse)
async def get_profile(
preferences: PreferencesService = Depends(get_preferences_service),
jellyfin_service: JellyfinLibraryService = Depends(get_jellyfin_library_service),
local_service: LocalFilesService = Depends(get_local_files_service),
navidrome_service: NavidromeLibraryService = Depends(get_navidrome_library_service),
) -> ProfileResponse:
profile = preferences.get_profile_settings()
services: list[ServiceConnection] = []
library_stats_list: list[LibraryStats] = []
jellyfin_conn = preferences.get_jellyfin_connection()
services.append(ServiceConnection(
name="Jellyfin",
enabled=jellyfin_conn.enabled,
username=jellyfin_conn.user_id,
url=jellyfin_conn.jellyfin_url,
))
lb_conn = preferences.get_listenbrainz_connection()
services.append(ServiceConnection(
name="ListenBrainz",
enabled=lb_conn.enabled,
username=lb_conn.username,
url="https://listenbrainz.org",
))
lastfm_conn = preferences.get_lastfm_connection()
services.append(ServiceConnection(
name="Last.fm",
enabled=lastfm_conn.enabled,
username=lastfm_conn.username,
url="https://www.last.fm",
))
navidrome_conn = preferences.get_navidrome_connection()
services.append(ServiceConnection(
name="Navidrome",
enabled=navidrome_conn.enabled,
username=navidrome_conn.username,
url=navidrome_conn.navidrome_url,
))
local_conn = preferences.get_local_files_connection()
async def _fetch_jellyfin_stats() -> LibraryStats | None:
if not jellyfin_conn.enabled:
return None
try:
s = await jellyfin_service.get_stats()
return LibraryStats(source="Jellyfin", total_tracks=s.total_tracks, total_albums=s.total_albums, total_artists=s.total_artists)
except Exception as e:
logger.warning("Failed to fetch Jellyfin stats for profile: %s", e)
return None
async def _fetch_local_stats() -> LibraryStats | None:
if not local_conn.enabled:
return None
try:
s = await local_service.get_storage_stats()
return LibraryStats(source="Local Files", total_tracks=s.total_tracks, total_albums=s.total_albums, total_artists=s.total_artists, total_size_bytes=s.total_size_bytes, total_size_human=s.total_size_human)
except Exception as e:
logger.warning("Failed to fetch Local Files stats for profile: %s", e)
return None
async def _fetch_navidrome_stats() -> LibraryStats | None:
if not navidrome_conn.enabled:
return None
try:
s = await navidrome_service.get_stats()
return LibraryStats(source="Navidrome", total_tracks=s.total_tracks, total_albums=s.total_albums, total_artists=s.total_artists)
except Exception as e:
logger.warning("Failed to fetch Navidrome stats for profile: %s", e)
return None
results = await asyncio.gather(_fetch_jellyfin_stats(), _fetch_local_stats(), _fetch_navidrome_stats())
library_stats_list = [r for r in results if r is not None]
return ProfileResponse(
display_name=profile.display_name,
avatar_url=profile.avatar_url,
services=services,
library_stats=library_stats_list,
)
@router.put("", response_model=ProfileSettings)
async def update_profile(
body: ProfileUpdateRequest = MsgSpecBody(ProfileUpdateRequest),
preferences: PreferencesService = Depends(get_preferences_service),
) -> ProfileSettings:
current = preferences.get_profile_settings()
updated = ProfileSettings(
display_name=body.display_name if body.display_name is not None else current.display_name,
avatar_url=body.avatar_url if body.avatar_url is not None else current.avatar_url,
)
preferences.save_profile_settings(updated)
return updated
def _get_avatar_dir() -> Path:
settings = get_settings()
avatar_dir = settings.cache_dir / AVATAR_DIR_NAME
avatar_dir.mkdir(parents=True, exist_ok=True)
return avatar_dir
@router.post("/avatar")
async def upload_avatar(
file: UploadFile = File(...),
preferences: PreferencesService = Depends(get_preferences_service),
):
if file.content_type not in ALLOWED_IMAGE_TYPES:
raise HTTPException(status_code=400, detail="Invalid image type. Allowed: JPEG, PNG, WebP, GIF")
data = await file.read()
if len(data) > MAX_AVATAR_SIZE:
raise HTTPException(status_code=400, detail="Image too large. Maximum size is 5 MB")
ext = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/gif": ".gif",
}.get(file.content_type, ".jpg")
avatar_dir = _get_avatar_dir()
# Remove old avatar files
for old_file in avatar_dir.glob("avatar.*"):
try:
old_file.unlink()
except OSError:
pass
filename = f"avatar{ext}"
file_path = avatar_dir / filename
file_path.write_bytes(data)
avatar_url = "/api/v1/profile/avatar"
current = preferences.get_profile_settings()
updated = ProfileSettings(
display_name=current.display_name,
avatar_url=avatar_url,
)
preferences.save_profile_settings(updated)
return {"avatar_url": avatar_url}
@router.get("/avatar")
async def get_avatar():
avatar_dir = _get_avatar_dir()
for ext in (".jpg", ".png", ".webp", ".gif"):
file_path = avatar_dir / f"avatar{ext}"
if file_path.exists():
media_type = {
".jpg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
}[ext]
return FileResponse(
file_path,
media_type=media_type,
headers={"Cache-Control": "public, max-age=3600"},
)
raise HTTPException(status_code=404, detail="No avatar found")