diff --git a/backend/app/api/artist.py b/backend/app/api/artist.py index 6ca865c..e748360 100644 --- a/backend/app/api/artist.py +++ b/backend/app/api/artist.py @@ -1,15 +1,25 @@ from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func -from typing import List +from typing import List, Optional +import aiohttp +import json +import os +from pathlib import Path +from datetime import datetime, timedelta from app.db.session import get_db from app.models.models import Music from app.schemas.schemas import Music as MusicSchema +from app.core.config import settings from pydantic import BaseModel router = APIRouter() +# Cache directory for artist info +CACHE_DIR = os.path.join(settings.BASE_DIR, "data", "cache", "artists") +CACHE_DURATION_DAYS = 30 # Cache for 30 days + class Artist(BaseModel): name: str @@ -19,6 +29,128 @@ class Artist(BaseModel): from_attributes = True +class ArtistInfo(BaseModel): + name: str + image: Optional[str] = None + bio: Optional[str] = None + listeners: Optional[int] = None + playcount: Optional[int] = None + + +def get_cache_path(artist_name: str) -> str: + """Get cache file path for an artist""" + os.makedirs(CACHE_DIR, exist_ok=True) + # Use a safe filename + safe_name = "".join(c if c.isalnum() or c in (' ', '-', '_') else '_' for c in artist_name) + return os.path.join(CACHE_DIR, f"{safe_name}.json") + + +def get_cached_artist_info(artist_name: str) -> Optional[ArtistInfo]: + """Get cached artist info if it exists and is not expired""" + cache_path = get_cache_path(artist_name) + + if not os.path.exists(cache_path): + return None + + try: + # Check if cache is expired + file_time = datetime.fromtimestamp(os.path.getmtime(cache_path)) + if datetime.now() - file_time > timedelta(days=CACHE_DURATION_DAYS): + # Cache expired, remove it + os.remove(cache_path) + return None + + # Read and return cached data + with open(cache_path, 'r', encoding='utf-8') as f: + data = json.load(f) + return ArtistInfo(**data) + except Exception as e: + print(f"Error reading cache: {e}") + return None + + +def save_artist_info_to_cache(artist_name: str, info: ArtistInfo): + """Save artist info to cache""" + cache_path = get_cache_path(artist_name) + + try: + with open(cache_path, 'w', encoding='utf-8') as f: + json.dump(info.model_dump(), f, ensure_ascii=False, indent=2) + except Exception as e: + print(f"Error writing cache: {e}") + + +async def get_artist_info_from_apis(artist_name: str) -> Optional[ArtistInfo]: + """Fetch artist info from Deezer API (primary) and MusicBrainz (fallback)""" + try: + # Try Deezer API first (no key needed, has good artist images) + deezer_url = "https://api.deezer.com/search/artist" + params = {"q": artist_name, "limit": 1} + + async with aiohttp.ClientSession() as session: + async with session.get(deezer_url, params=params) as response: + if response.status == 200: + data = await response.json() + + if data.get("data") and len(data["data"]) > 0: + artist = data["data"][0] + + # Deezer provides different image sizes + image_url = artist.get("picture_xl") or artist.get("picture_big") or artist.get("picture_medium") + + return ArtistInfo( + name=artist.get("name", artist_name), + image=image_url, + bio=None, + listeners=artist.get("nb_fan"), # Number of fans + playcount=None + ) + + # Fallback to MusicBrainz if Deezer fails + search_url = "https://musicbrainz.org/ws/2/artist/" + params = { + "query": artist_name, + "fmt": "json", + "limit": 1 + } + + async with session.get(search_url, params=params) as response: + if response.status != 200: + return None + + data = await response.json() + + if not data.get("artists"): + return None + + artist = data["artists"][0] + artist_real_name = artist.get("name", artist_name) + + # Get disambiguation and type as "bio" + bio_parts = [] + if artist.get("type"): + bio_parts.append(f"Type: {artist['type']}") + if artist.get("disambiguation"): + bio_parts.append(artist["disambiguation"]) + if artist.get("country"): + bio_parts.append(f"Country: {artist['country']}") + + bio = " • ".join(bio_parts) if bio_parts else None + + return ArtistInfo( + name=artist_real_name, + image=None, # MusicBrainz doesn't have images + bio=bio, + listeners=None, + playcount=None + ) + + return None + except Exception as e: + print(f"Error fetching artist data: {e}") + return None + + @router.get("/", response_model=List[Artist]) async def get_artists(db: AsyncSession = Depends(get_db)): """Get all artists with song counts""" @@ -41,6 +173,51 @@ async def get_artists(db: AsyncSession = Depends(get_db)): return artists +@router.get("/{artist_name}/info", response_model=ArtistInfo) +async def get_artist_info(artist_name: str): + """Get artist information from Deezer and MusicBrainz (with 30-day cache)""" + # Try to get from cache first + cached_info = get_cached_artist_info(artist_name) + if cached_info: + return cached_info + + # Fetch from APIs if not cached + info = await get_artist_info_from_apis(artist_name) + if not info: + # Return minimal info if APIs fail + info = ArtistInfo(name=artist_name) + + # Save to cache + save_artist_info_to_cache(artist_name, info) + + return info + + +@router.delete("/{artist_name}/cache") +async def clear_artist_cache(artist_name: str): + """Clear cached artist info (forces refresh on next request)""" + cache_path = get_cache_path(artist_name) + + if os.path.exists(cache_path): + os.remove(cache_path) + return {"message": f"Cache cleared for {artist_name}"} + + return {"message": "No cache found"} + + +@router.delete("/cache/all") +async def clear_all_artist_cache(): + """Clear all cached artist info""" + if os.path.exists(CACHE_DIR): + count = 0 + for file in Path(CACHE_DIR).glob("*.json"): + file.unlink() + count += 1 + return {"message": f"Cleared {count} cached artists"} + + return {"message": "No cache found"} + + @router.get("/{artist_name}", response_model=List[MusicSchema]) async def get_artist_songs(artist_name: str, db: AsyncSession = Depends(get_db)): """Get all songs by a specific artist""" diff --git a/frontend/index.html b/frontend/index.html index 9092332..d328aec 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,9 @@ - + + + YouMusic - Your Music Player diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f14d88d..0f2cb1f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import Player from './components/player/Player' import MusicLibrary from './components/MusicLibrary' import SearchPage from './components/search/SearchPage' import PlaylistsPage from './components/playlist/PlaylistsPage' +import PlaylistDetailPage from './components/playlist/PlaylistDetailPage' import DownloadCenter from './components/download/DownloadCenter' import ArtistsPage from './components/artist/ArtistsPage' import ArtistDetailPage from './components/artist/ArtistDetailPage' @@ -15,9 +16,58 @@ function App() { const [currentMusic, setCurrentMusic] = useState(null) const [isPlaying, setIsPlaying] = useState(false) const [playlist, setPlaylist] = useState([]) + const [theme, setTheme] = useState<'light' | 'dark'>('dark') + const [playMode, setPlayMode] = useState<'loop' | 'shuffle' | 'repeat-one'>('loop') const audioRef = useRef(null) const location = useLocation() + // Load theme from localStorage on mount + useEffect(() => { + const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null + if (savedTheme) { + setTheme(savedTheme) + } else { + // Default to system preference + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + setTheme(prefersDark ? 'dark' : 'light') + } + }, []) + + // Load play mode from localStorage + useEffect(() => { + const savedMode = localStorage.getItem('playMode') as 'loop' | 'shuffle' | 'repeat-one' | null + if (savedMode) { + setPlayMode(savedMode) + } + }, []) + + // Apply theme to document + useEffect(() => { + if (theme === 'dark') { + document.documentElement.classList.add('dark') + } else { + document.documentElement.classList.remove('dark') + } + localStorage.setItem('theme', theme) + }, [theme]) + + // Save play mode to localStorage + useEffect(() => { + localStorage.setItem('playMode', playMode) + }, [playMode]) + + const toggleTheme = () => { + setTheme(prev => prev === 'dark' ? 'light' : 'dark') + } + + const togglePlayMode = () => { + setPlayMode(prev => { + if (prev === 'loop') return 'shuffle' + if (prev === 'shuffle') return 'repeat-one' + return 'loop' + }) + } + const playMusic = (music: Music, musicList?: Music[]) => { setCurrentMusic(music) if (musicList) { @@ -38,11 +88,36 @@ function App() { } const playNext = () => { - if (currentMusic && playlist.length > 0) { - const currentIndex = playlist.findIndex(m => m.id === currentMusic.id) - if (currentIndex >= 0 && currentIndex < playlist.length - 1) { - playMusic(playlist[currentIndex + 1], playlist) + if (!currentMusic || playlist.length === 0) return + + const currentIndex = playlist.findIndex(m => m.id === currentMusic.id) + + if (playMode === 'repeat-one') { + // Repeat current song + if (audioRef.current) { + audioRef.current.currentTime = 0 + audioRef.current.play() } + return + } + + if (playMode === 'shuffle') { + // Pick a random song (not the current one) + const availableIndexes = playlist + .map((_, idx) => idx) + .filter(idx => idx !== currentIndex) + + if (availableIndexes.length > 0) { + const randomIndex = availableIndexes[Math.floor(Math.random() * availableIndexes.length)] + playMusic(playlist[randomIndex], playlist) + } + return + } + + // Loop mode (default) + if (currentIndex >= 0) { + const nextIndex = currentIndex < playlist.length - 1 ? currentIndex + 1 : 0 + playMusic(playlist[nextIndex], playlist) } } @@ -77,16 +152,17 @@ function App() { }, [location]) return ( -
- +
+ -
+
} /> } /> } /> } /> } /> + } /> } />
@@ -98,6 +174,8 @@ function App() { onNext={playNext} onPrevious={playPrevious} audioRef={audioRef} + playMode={playMode} + onTogglePlayMode={togglePlayMode} />