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: # noqa: BLE001 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: # noqa: BLE001 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: # noqa: BLE001 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")