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}
/>
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index a3669a9..6684438 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -66,4 +66,5 @@ export const searchApi = {
export const artistApi = {
getAll: () => api.get('/artists/'),
getArtistSongs: (artistName: string) => api.get(`/artists/${encodeURIComponent(artistName)}`),
+ getArtistInfo: (artistName: string) => api.get(`/artists/${encodeURIComponent(artistName)}/info`),
}
diff --git a/frontend/src/components/MusicLibrary.tsx b/frontend/src/components/MusicLibrary.tsx
index e5dfe10..f6661e1 100644
--- a/frontend/src/components/MusicLibrary.tsx
+++ b/frontend/src/components/MusicLibrary.tsx
@@ -35,7 +35,7 @@ export default function MusicLibrary({ onPlayMusic }: MusicLibraryProps) {
>
{music.thumbnail ? (
diff --git a/frontend/src/components/Navigation.tsx b/frontend/src/components/Navigation.tsx
index c9c2ec1..6fb405a 100644
--- a/frontend/src/components/Navigation.tsx
+++ b/frontend/src/components/Navigation.tsx
@@ -1,7 +1,13 @@
import { Link, useLocation } from 'react-router-dom'
-import { Home, Search, ListMusic, Download, Music2 } from 'lucide-react'
+import { Home, Search, ListMusic, Download, Music2, Sun, Moon } from 'lucide-react'
+import { Button } from './ui/button'
-export default function Navigation() {
+interface NavigationProps {
+ onToggleTheme: () => void
+ theme: 'light' | 'dark'
+}
+
+export default function Navigation({ onToggleTheme, theme }: NavigationProps) {
const location = useLocation()
const navItems = [
@@ -13,17 +19,17 @@ export default function Navigation() {
]
return (
-